Membuat Aplikasi Jastip Kantin dengan React + TypeScript


Membuat Aplikasi Jastip Kantin dengan React + TypeScript

Studi Kasus: Aplikasi Web Pemesanan Makanan via WhatsApp


Pendahuluan

Di banyak sekolah dan kantor, ada fenomena unik bernama jastip kantin — seseorang, biasanya siswa atau karyawan, bertindak sebagai kurir untuk mengambilkan makanan dari kantin dan mengantarkannya ke kelas atau ruang kerja. Proses pemesanannya masih manual: pesan lewat chat biasa, format berbeda-beda, mudah terlewat, dan sering terjadi kesalahan.

Artikel ini membahas proses membangun Jastip Kantin, sebuah aplikasi web satu halaman (Single Page Application) yang menjadi jembatan digital antara pemesan dan kurir melalui WhatsApp. Aplikasi ini sepenuhnya berjalan di sisi klien — tidak memerlukan server atau database — sehingga cocok sebagai proyek belajar pengembangan web modern.


Tujuan Pembelajaran

Setelah mempelajari artikel ini, pembaca diharapkan memahami:

  1. Cara membangun form dinamis dengan react-hook-form dan useFieldArray untuk input yang bisa ditambah/dihapus
  2. Validasi form menggunakan Zod — library schema validation yang kuat dan type-safe
  3. Manajemen state sederhana dengan useState dan localStorage untuk persistensi data
  4. Animasi antarmuka menggunakan framer-motion untuk pengalaman pengguna yang lebih halus
  5. Integrasi WhatsApp API melalui deep link wa.me untuk mengirim pesan terformat
  6. Struktur komponen React yang modular dan mudah dipelihara
  7. Penggunaan komponen UI siap pakai (shadcn/ui) dalam proyek nyata

Gambaran Umum Aplikasi

Aplikasi Jastip Kantin memiliki dua bagian utama:

Jastip Kantin
├── Pengaturan Kurir ← nomor WA kurir, disimpan di localStorage
└── Form Pesanan
├── Nama Pemesan
├── Kelas / Status
├── Pilih Kantin
├── Daftar Item (dinamis)
│ ├── Nama Menu
│ ├── Jumlah
│ └── Harga/Item
├── Total Tagihan (otomatis)
└── Tombol Kirim via WhatsApp

Ketika pengguna menekan tombol kirim, aplikasi menyusun pesan terformat dan membukanya di WhatsApp melalui URL https://wa.me/<nomor>?text=<pesan>.


Stack Teknologi

TeknologiKegunaan
React 18Library UI berbasis komponen
TypeScriptTipe statis untuk kode yang lebih aman
ViteBuild tool dan dev server yang cepat
react-hook-formManajemen state form yang efisien
ZodValidasi schema form
framer-motionAnimasi masuk/keluar item yang halus
shadcn/uiKomponen UI yang sudah jadi dan bisa dikustomisasi
Tailwind CSSUtility-first CSS untuk styling cepat
wouterRouter ringan untuk React
localStorageMenyimpan nomor WA kurir di browser

Struktur File Proyek

artifacts/jastip-kantin/
├── src/
│ ├── App.tsx ← entry point & router
│ ├── index.css ← tema warna global
│ ├── pages/
│ │ └── Home.tsx ← halaman utama
│ └── components/
│ ├── KurirSettings.tsx ← pengaturan nomor WA kurir
│ └── OrderForm.tsx ← form pemesanan utama
├── package.json
└── vite.config.ts

Struktur ini mengikuti prinsip separation of concerns: setiap file punya tanggung jawab yang jelas dan tidak tumpang tindih.


Proses Pembuatan

Langkah 1 — Menetapkan Tema Warna (index.css)

Langkah pertama adalah menetapkan identitas visual aplikasi. Kami menggunakan palet warna terinspirasi kantin Indonesia: kuning saffron hangat, aksen oranye, dan latar krem.

Tailwind CSS + shadcn/ui menggunakan variabel CSS berbasis HSL. Setiap variabel ditulis sebagai nilai HSL tanpa pembungkus hsl():

/* src/index.css */
@import "tailwindcss";
:root {
--background: 45 50% 96%; /* krem terang */
--foreground: 25 20% 15%; /* coklat tua */
--primary: 30 90% 50%; /* oranye hangat */
--card: 0 0% 100%;
--muted: 40 30% 90%;
--accent: 45 80% 88%;
--destructive: 0 72% 51%; /* merah untuk error */
/* ... variabel lainnya */
}

Catatan Penting: Scaffold awal shadcn/ui mengisi semua variabel dengan nilai red sebagai placeholder. Anda HARUS menggantinya semua sebelum menulis komponen apapun, atau seluruh halaman akan tampil berwarna merah.


Langkah 2 — Entry Point dan Router (App.tsx)

App.tsx adalah titik masuk aplikasi. Di sini kita menyiapkan provider global dan mendefinisikan routing:

// src/App.tsx
import { Switch, Route, Router as WouterRouter } from "wouter";
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip";
import Home from "@/pages/Home";
import NotFound from "@/pages/not-found";
const queryClient = new QueryClient();
function Router() {
return (
<Switch>
<Route path="/" component={Home} />
<Route component={NotFound} />
</Switch>
);
}
function App() {
return (
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<WouterRouter base={import.meta.env.BASE_URL.replace(/\/$/, "")}>
<Router />
</WouterRouter>
<Toaster />
</TooltipProvider>
</QueryClientProvider>
);
}
export default App;

Poin penting:

  • QueryClientProvider menyiapkan TanStack Query (meskipun aplikasi ini tidak memanggil API, ini adalah praktik standar)
  • WouterRouter menggunakan BASE_URL dari Vite agar routing bekerja dengan benar baik di development maupun production
  • Toaster ditempatkan di level paling atas agar notifikasi bisa muncul dari komponen mana pun

Langkah 3 — Halaman Utama dan Manajemen State (Home.tsx)

Home.tsx adalah orkestrator: ia mengelola state nomor WA kurir dan menghubungkan dua komponen utama.

// src/pages/Home.tsx
import { useState } from "react";
import KurirSettings from "@/components/KurirSettings";
import OrderForm from "@/components/OrderForm";
export default function Home() {
// Inisialisasi state dari localStorage agar data tersimpan setelah reload
const [kurirWa, setKurirWa] = useState(
() => localStorage.getItem("jastip_kurir_wa") || ""
);
const handleKurirWaChange = (newNumber: string) => {
setKurirWa(newNumber);
localStorage.setItem("jastip_kurir_wa", newNumber);
};
return (
<div className="min-h-[100dvh] w-full bg-background pb-12 pt-6 px-4 sm:px-6">
<div className="max-w-md mx-auto space-y-6">
<header className="text-center space-y-2 mb-8">
<h1 className="text-3xl font-extrabold text-primary tracking-tight">
Jastip Kantin
</h1>
<p className="text-muted-foreground text-sm">
Pesan makanan tanpa ribet, langsung kirim ke WA kurir.
</p>
</header>
<KurirSettings kurirWa={kurirWa} onKurirWaChange={handleKurirWaChange} />
<div className="bg-card border shadow-sm rounded-xl p-5">
<OrderForm kurirWa={kurirWa} />
</div>
</div>
</div>
);
}

Konsep kunci — Lazy Initializer pada useState:

// Ini BUKAN lazy initializer — localStorage dibaca setiap render:
const [kurirWa, setKurirWa] = useState(localStorage.getItem("jastip_kurir_wa") || "");
// Ini lazy initializer — fungsi hanya dipanggil SEKALI saat mount:
const [kurirWa, setKurirWa] = useState(
() => localStorage.getItem("jastip_kurir_wa") || ""
);

Menggunakan lazy initializer adalah praktik terbaik untuk operasi yang cukup mahal (seperti membaca storage) agar tidak dieksekusi berulang kali.


Langkah 4 — Komponen Pengaturan Kurir (KurirSettings.tsx)

Komponen ini mengelola input nomor WA kurir. Ia menggunakan komponen Collapsible dari shadcn/ui dan otomatis terbuka jika nomor belum diisi.

// src/components/KurirSettings.tsx
import { useState, useEffect } from "react";
import { Settings, Phone } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { CardContent } from "@/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
interface KurirSettingsProps {
kurirWa: string;
onKurirWaChange: (newNumber: string) => void;
}
export default function KurirSettings({ kurirWa, onKurirWaChange }: KurirSettingsProps) {
const [isOpen, setIsOpen] = useState(false);
// Otomatis buka jika nomor belum diatur
useEffect(() => {
if (!kurirWa) {
setIsOpen(true);
}
}, [kurirWa]);
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}
className="bg-card border shadow-sm rounded-xl overflow-hidden">
<CollapsibleTrigger asChild>
<button className="flex items-center justify-between w-full p-4 text-left hover:bg-muted/50 transition-colors">
<div className="flex items-center gap-2 text-sm font-semibold">
<Settings className="w-4 h-4 text-primary" />
Pengaturan Kurir
</div>
<div className="text-xs text-muted-foreground">
{kurirWa ? (
<span className="flex items-center gap-1">
<Phone className="w-3 h-3" /> {kurirWa}
</span>
) : (
<span className="text-destructive">Belum diatur</span>
)}
</div>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="p-4 pt-0 border-t">
<div className="space-y-1 pt-3">
<Label className="text-xs font-semibold text-muted-foreground">
No. WhatsApp Kurir
</Label>
<Input
placeholder="Contoh: 6281234567890"
value={kurirWa}
onChange={(e) => {
// Hanya izinkan angka dan tanda +
const val = e.target.value.replace(/[^0-9+]/g, "");
onKurirWaChange(val);
}}
className="font-mono text-sm"
/>
<p className="text-[10px] text-muted-foreground">
Gunakan format internasional tanpa spasi (misal: 628...).
</p>
</div>
</CardContent>
</CollapsibleContent>
</Collapsible>
);
}

Pola desain yang digunakan:

  • Controlled componentvalue={kurirWa} dan onChange dikendalikan oleh parent melalui props
  • Lifted state: state disimpan di Home.tsx (parent), bukan di komponen ini, sehingga OrderForm juga bisa mengaksesnya
  • Side effect dengan useEffect: membuka panel otomatis jika nomor belum diisi, sebagai UX guidance

Langkah 5 — Form Pemesanan dengan Validasi (OrderForm.tsx)

Ini adalah komponen paling kompleks. Mari kita bahas bagian per bagian.

5a. Mendefinisikan Schema Validasi dengan Zod

import * as z from "zod";
// Schema untuk satu item pesanan
const orderItemSchema = z.object({
namaMenu: z.string().min(1, "Nama menu tidak boleh kosong"),
jumlah: z.coerce.number().min(1, "Minimal 1"),
harga: z.coerce.number().min(100, "Harga tidak valid"),
});
// Schema untuk keseluruhan form
const orderFormSchema = z.object({
namaPemesan: z.string().min(1, "Nama tidak boleh kosong"),
kelas: z.string().min(1, "Kelas/Status tidak boleh kosong"),
kantin: z.string().min(1, "Pilih kantin"),
items: z.array(orderItemSchema).min(1, "Tambahkan minimal 1 item"),
});
type OrderFormValues = z.infer<typeof orderFormSchema>;

Mengapa menggunakan z.coerce? Input HTML dengan type="number" tetap mengembalikan nilai berupa string. z.coerce.number() secara otomatis mengkonversi string "15000" menjadi angka 15000 sebelum validasi, sehingga kita tidak perlu melakukan konversi manual.

5b. Inisialisasi Form

import { useForm, useFieldArray } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
export default function OrderForm({ kurirWa }: { kurirWa: string }) {
const form = useForm<OrderFormValues>({
resolver: zodResolver(orderFormSchema), // hubungkan Zod dengan react-hook-form
defaultValues: {
namaPemesan: "",
kelas: "",
kantin: "",
items: [{ namaMenu: "", jumlah: 1, harga: 0 }], // mulai dengan 1 item kosong
},
});

zodResolver adalah jembatan antara Zod dan react-hook-form: ia menjalankan validasi Zod saat form di-submit atau saat field berubah, lalu meneruskan error ke state form.

5c. Field Array — Daftar Item Dinamis

const { fields, append, remove } = useFieldArray({
control: form.control,
name: "items",
});

useFieldArray menyediakan tiga fungsi penting:

  • fields — array data item saat ini (dengan id unik untuk key React)
  • append(obj) — menambahkan item baru ke akhir array
  • remove(index) — menghapus item pada indeks tertentu

5d. Kalkulasi Total Secara Real-time

const items = form.watch("items"); // subscribe ke perubahan field "items"
const totalHarga = items.reduce(
(sum, item) => sum + ((item.jumlah || 0) * (item.harga || 0)),
0
);

form.watch("items") menyebabkan komponen re-render setiap kali nilai di dalam array items berubah. Ini memungkinkan total dihitung ulang secara langsung saat pengguna mengetik harga atau jumlah.

5e. Animasi dengan Framer Motion

import { motion, AnimatePresence } from "framer-motion";
<AnimatePresence initial={false}>
{fields.map((field, index) => (
<motion.div
key={field.id}
initial={{ opacity: 0, height: 0, y: -10 }} // kondisi awal saat muncul
animate={{ opacity: 1, height: "auto", y: 0 }} // kondisi normal
exit={{ opacity: 0, height: 0, y: -10 }} // kondisi saat menghilang
transition={{ duration: 0.2 }}
>
{/* konten item */}
</motion.div>
))}
</AnimatePresence>
  • AnimatePresence memungkinkan animasi exit berjalan sebelum elemen dihapus dari DOM
  • initial={false} mencegah animasi masuk saat komponen pertama kali di-render
  • Menggunakan field.id (bukan index) sebagai key adalah praktik penting agar React dan framer-motion bisa melacak identitas elemen dengan benar

5f. Pemformatan Rupiah

const formatRupiah = (number: number) => {
return new Intl.NumberFormat("id-ID", {
style: "currency",
currency: "IDR",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(number);
};
// Contoh:
formatRupiah(15000) // → "Rp 15.000"
formatRupiah(125000) // → "Rp 125.000"

Intl.NumberFormat adalah API bawaan JavaScript yang menangani format mata uang sesuai locale. Dengan locale "id-ID", pemisah ribuan menggunakan titik (.) bukan koma — persis seperti konvensi penulisan Rupiah di Indonesia.

5g. Membuat dan Mengirim Pesan WhatsApp

const onSubmit = (data: OrderFormValues) => {
if (!kurirWa) {
alert("Mohon isi nomor WhatsApp kurir terlebih dahulu.");
return;
}
// Susun teks pesan
let message = `*PESANAN JASTIP KANTIN*\n`;
message += `----------------------------\n`;
message += `Pemesan: ${data.namaPemesan}\n`;
message += `Kelas/Status: ${data.kelas}\n`;
message += `Kantin: ${data.kantin}\n\n`;
message += `*Daftar Pesanan:*\n`;
data.items.forEach((item, index) => {
const subtotal = item.harga * item.jumlah;
message += `${index + 1}. ${item.namaMenu} x${item.jumlah} = ${formatRupiah(subtotal)}\n`;
});
message += `----------------------------\n`;
message += `*Total: ${formatRupiah(totalHarga)}*\n\n`;
message += `Mohon dikonfirmasi ya kak`;
// Normalisasi nomor WA: pastikan diawali "62" bukan "0" atau "+62"
let formattedWa = kurirWa.replace(/[^0-9+]/g, "");
if (formattedWa.startsWith("0")) {
formattedWa = "62" + formattedWa.substring(1);
} else if (formattedWa.startsWith("+62")) {
formattedWa = formattedWa.substring(1); // hapus tanda +
}
// Encode pesan agar aman digunakan dalam URL
const waUrl = `https://wa.me/${formattedWa}?text=${encodeURIComponent(message)}`;
window.open(waUrl, "_blank");
};

Mengapa encodeURIComponent? URL tidak boleh mengandung karakter seperti spasi, *\n, dan karakter khusus lainnya secara mentah. encodeURIComponent mengubahnya menjadi representasi yang aman untuk URL, misalnya spasi menjadi %20 dan baris baru menjadi %0A.

Format nomor WA: API wa.me membutuhkan nomor dalam format internasional tanpa tanda +. Nomor Indonesia dimulai dengan 62, jadi 0812... harus diubah menjadi 62812....


Konsep Penting yang Dipelajari

1. Controlled vs Uncontrolled Components

Dalam React, ada dua cara mengelola form:

  • Uncontrolled: nilai disimpan di DOM, diakses dengan ref. Lebih sederhana tapi sulit divalidasi.
  • Controlled: nilai disimpan di state React. react-hook-form menggunakan pendekatan ini secara efisien dengan meminimalkan re-render.

2. Lifted State Pattern

Nomor WA kurir disimpan di Home.tsx (bukan di KurirSettings.tsx), karena OrderForm.tsx juga membutuhkannya. Ini disebut lifting state up — memindahkan state ke ancestor terdekat yang membutuhkannya.

Home.tsx (state: kurirWa)
├── KurirSettings.tsx ← baca dan tulis kurirWa via props
└── OrderForm.tsx ← hanya baca kurirWa via props

3. Persistensi dengan localStorage

Data yang disimpan di localStorage bertahan meski halaman di-refresh atau browser ditutup (berbeda dengan sessionStorage yang terhapus saat tab ditutup). Ini tepat untuk menyimpan preferensi pengguna seperti nomor kurir.

// Menulis
localStorage.setItem("kunci", "nilai");
// Membaca
localStorage.getItem("kunci"); // "nilai" atau null jika tidak ada
// Menghapus
localStorage.removeItem("kunci");

4. TypeScript dengan Zod

Zod bukan hanya untuk validasi runtime — ia juga menghasilkan tipe TypeScript secara otomatis:

const schema = z.object({ nama: z.string(), umur: z.number() });
type Pengguna = z.infer<typeof schema>;
// Hasilnya sama dengan: type Pengguna = { nama: string; umur: number; }

Ini berarti satu sumber kebenaran (schema) untuk validasi sekaligus tipe data.


Pengujian Manual Aplikasi

Untuk memastikan aplikasi berjalan dengan benar, lakukan pengujian berikut:

SkenarioLangkahHasil yang Diharapkan
Kurir belum diaturBuka app, langsung tekan kirimMuncul peringatan nomor kurir belum diisi
Validasi kosongSubmit form tanpa mengisi apapunSemua field menampilkan pesan error
Tambah itemTekan "Tambah Item"Item baru muncul dengan animasi halus
Hapus itemTekan ikon sampahItem menghilang dengan animasi halus
Total otomatisIsi harga dan jumlahTotal terhitung langsung
Kirim WAIsi semua field, tekan kirimTab baru terbuka dengan WhatsApp Web dan pesan terformat
PersistensiIsi nomor kurir, reload halamanNomor kurir masih tersimpan

Ide Pengembangan Lanjutan

Setelah memahami aplikasi ini, berikut beberapa fitur yang bisa ditambahkan sebagai latihan:

  1. Riwayat pesanan — simpan daftar pesanan sebelumnya di localStorage
  2. Menu favorit — simpan item yang sering dipesan agar bisa dipilih cepat
  3. Multiple kurir — duktar lebih dari satu kurir dengan nama berbeda
  4. Cetak struk — tombol untuk mencetak atau menyimpan pesanan sebagai PDF
  5. Dark mode — tampilan gelap untuk penggunaan di ruangan redup
  6. PWA (Progressive Web App) — agar bisa diinstall di layar beranda ponsel

Kesimpulan

Proyek Jastip Kantin adalah contoh sempurna aplikasi web nyata yang bisa dibangun tanpa backend. Dengan menguasai beberapa library modern — react-hook-form untuk form, Zod untuk validasi, dan framer-motion untuk animasi — kita bisa membuat alat yang benar-benar berguna bagi komunitas sekitar kita.

Kunci dari proyek ini bukan pada kerumitan teknisnya, melainkan pada pemahaman yang mendalam tentang konsep-konsep fundamental: manajemen state, komunikasi antar komponen, validasi data, dan cara mengintegrasikan layanan eksternal (WhatsApp) menggunakan standar web yang ada.

Selamat mencoba dan mengembangkan proyek ini lebih jauh!

Share:

Tidak ada komentar:

Posting Komentar

Blogger Tricks

Blogger Themes