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:
- Cara membangun form dinamis dengan
react-hook-formdanuseFieldArrayuntuk input yang bisa ditambah/dihapus - Validasi form menggunakan Zod — library schema validation yang kuat dan type-safe
- Manajemen state sederhana dengan
useStatedanlocalStorageuntuk persistensi data - Animasi antarmuka menggunakan
framer-motionuntuk pengalaman pengguna yang lebih halus - Integrasi WhatsApp API melalui deep link
wa.meuntuk mengirim pesan terformat - Struktur komponen React yang modular dan mudah dipelihara
- 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
| Teknologi | Kegunaan |
|---|---|
| React 18 | Library UI berbasis komponen |
| TypeScript | Tipe statis untuk kode yang lebih aman |
| Vite | Build tool dan dev server yang cepat |
| react-hook-form | Manajemen state form yang efisien |
| Zod | Validasi schema form |
| framer-motion | Animasi masuk/keluar item yang halus |
| shadcn/ui | Komponen UI yang sudah jadi dan bisa dikustomisasi |
| Tailwind CSS | Utility-first CSS untuk styling cepat |
| wouter | Router ringan untuk React |
| localStorage | Menyimpan 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
redsebagai 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.tsximport { 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:
QueryClientProvidermenyiapkan TanStack Query (meskipun aplikasi ini tidak memanggil API, ini adalah praktik standar)WouterRoutermenggunakanBASE_URLdari Vite agar routing bekerja dengan benar baik di development maupun productionToasterditempatkan 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.tsximport { 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 reloadconst [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.tsximport { 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 diaturuseEffect(() => {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><Inputplaceholder="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 component:
value={kurirWa}danonChangedikendalikan oleh parent melalui props - Lifted state: state disimpan di
Home.tsx(parent), bukan di komponen ini, sehinggaOrderFormjuga 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 pesananconst 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 formconst 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 dengantype="number"tetap mengembalikan nilai berupa string.z.coerce.number()secara otomatis mengkonversi string"15000"menjadi angka15000sebelum 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-formdefaultValues: {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 (denganidunik untuk key React)append(obj)— menambahkan item baru ke akhir arrayremove(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.divkey={field.id}initial={{ opacity: 0, height: 0, y: -10 }} // kondisi awal saat munculanimate={{ opacity: 1, height: "auto", y: 0 }} // kondisi normalexit={{ opacity: 0, height: 0, y: -10 }} // kondisi saat menghilangtransition={{ duration: 0.2 }}>{/* konten item */}</motion.div>))}</AnimatePresence>
AnimatePresencememungkinkan animasiexitberjalan sebelum elemen dihapus dari DOMinitial={false}mencegah animasi masuk saat komponen pertama kali di-render- Menggunakan
field.id(bukanindex) 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 pesanlet 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 URLconst 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-formmenggunakan 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.
// MenulislocalStorage.setItem("kunci", "nilai");// MembacalocalStorage.getItem("kunci"); // "nilai" atau null jika tidak ada// MenghapuslocalStorage.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:
| Skenario | Langkah | Hasil yang Diharapkan |
|---|---|---|
| Kurir belum diatur | Buka app, langsung tekan kirim | Muncul peringatan nomor kurir belum diisi |
| Validasi kosong | Submit form tanpa mengisi apapun | Semua field menampilkan pesan error |
| Tambah item | Tekan "Tambah Item" | Item baru muncul dengan animasi halus |
| Hapus item | Tekan ikon sampah | Item menghilang dengan animasi halus |
| Total otomatis | Isi harga dan jumlah | Total terhitung langsung |
| Kirim WA | Isi semua field, tekan kirim | Tab baru terbuka dengan WhatsApp Web dan pesan terformat |
| Persistensi | Isi nomor kurir, reload halaman | Nomor kurir masih tersimpan |
Ide Pengembangan Lanjutan
Setelah memahami aplikasi ini, berikut beberapa fitur yang bisa ditambahkan sebagai latihan:
- Riwayat pesanan — simpan daftar pesanan sebelumnya di localStorage
- Menu favorit — simpan item yang sering dipesan agar bisa dipilih cepat
- Multiple kurir — duktar lebih dari satu kurir dengan nama berbeda
- Cetak struk — tombol untuk mencetak atau menyimpan pesanan sebagai PDF
- Dark mode — tampilan gelap untuk penggunaan di ruangan redup
- 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!






.png)
Tidak ada komentar:
Posting Komentar