Pengantar Frontend, Dasar React + Tailwind
Component-based architecture, JSX/TSX & props, struktur project React, styling dengan Tailwind.
Konsep Frontend
Frontend: Bagian yang Kamu Sentuh
Selama lima pertemuan, semua yang kita bangun ada di sisi server. Ada database, ada endpoint, ada token. Semuanya bekerja, tetapi tidak ada yang bisa dilihat pengguna. Yang terlihat hanya Postman dan terminal.
Hari ini kita pindah ke sisi yang berlawanan. Frontend adalah bagian aplikasi yang berinteraksi langsung dengan pengguna. Tugasnya menampilkan informasi, menerima input, dan menyampaikan permintaan ke backend.
| Tugas | Contoh di sistem kantin |
|---|---|
| Menampilkan data | Daftar warung, daftar menu, harga, status ketersediaan |
| Menerima input | Kotak pencarian, pilihan kategori, jumlah pesanan |
| Mengirim permintaan | Menyimpan pesanan, mengubah status pesanan |
| Mengolah jawaban | Menampilkan pesan berhasil, pesan gagal, atau data kosong |
Kalau kamu membayangkan sistem kantin, halaman yang kamu lihat semuanya frontend: login, beranda, daftar menu, detail menu, profil, keranjang, dan riwayat pesanan. Di pertemuan ini kita membuat dua di antaranya, yaitu katalog menu dan dashboard pesanan.
Frontend itu meja makan dan buku menu. Pengunjung melihat menu, memilih, dan memanggil pelayan di sana. Dapur, gudang, dan aturan memasak tetap di belakang. Pengunjung tidak perlu tahu bagaimana nasi goreng dibuat, tetapi meja yang berantakan membuat pengalaman makan jadi buruk.
Satu hal yang sering keliru: frontend bukan tempat menyimpan aturan bisnis. Validasi di frontend berguna supaya pengguna cepat tahu kesalahannya, tetapi keputusan tetap milik backend. Ingat pertemuan 4, server tidak boleh mempercayai data dari client. Frontend pun tidak boleh dianggap sebagai penjaga.
Di pertemuan ini frontend belum tersambung ke API. Datanya masih lokal, karena menyambung frontend ke backend adalah materi pertemuan 7.
Memecah UI jadi Komponen
Bayangkan satu file App.tsx berisi seluruh halaman: header, pencarian, daftar menu, detail menu, keranjang, dan footer. Panjangnya bisa ratusan baris. Saat ada dua menu yang tampilannya sama, markup-nya disalin. Saat warna tombol berubah, kamu harus mencari semua salinannya.
Component Based Architecture adalah pendekatan memecah antarmuka menjadi bagian kecil yang punya tanggung jawab jelas dan bisa dipakai ulang. Komponen adalah bagian UI yang dibuat terpisah, lalu disusun bersama komponen lain untuk membentuk halaman.
| Alasan | Arti sederhananya | Contoh di katalog |
|---|---|---|
| Modularity | Aplikasi terbagi jadi bagian kecil dengan fungsi tertentu | Header, MenuCard, CartPanel |
| Abstraction | Detail di dalam disembunyikan, cukup tahu cara memakainya | Cukup menulis <Button />, tidak perlu tahu kelas Tailwind-nya |
| Encapsulation | Data dan logika yang berkaitan dikelola di dalam komponen itu | Status gambar gagal disimpan di dalam MenuCard |
| Reusability | Komponen dipakai lagi tanpa dibuat dari nol | Sembilan menu memakai satu MenuCard yang sama |
| Separation of Concern | Setiap bagian punya tanggung jawab yang tidak tercampur | Data di data/, format harga di lib/ |
Hasil akhirnya seperti pohon. Halaman katalog menyusun Header, daftar MenuCard, dan CartPanel. Setiap komponen bisa dibaca sendiri tanpa harus memahami seluruh aplikasi.
Komponen lahir karena ada pengulangan atau karena tanggung jawabnya jelas, bukan karena setiap kotak harus jadi file baru. Memecah berlebihan justru membuat alur data sulit diikuti.
- Thinking in React: cara memecah antarmuka menjadi komponen berdasarkan data dan tanggung jawab.
React: UI dari Data
React adalah library JavaScript untuk membangun antarmuka yang interaktif dan berbasis komponen. React bukan framework penuh. Dia tidak mengatur routing, database, atau struktur folder. Dia fokus pada satu hal: menjaga tampilan tetap sesuai dengan data.
Cara berpikirnya bisa ditulis singkat sebagai UI adalah fungsi dari data. Kalau datanya berubah, tampilan ikut berubah. Kamu tidak perlu mencari elemen lalu mengubah isinya satu per satu.
Cara lama, mengubah DOM secara manual:
const label = document.querySelector('#label-keranjang')
// setiap kali data berubah, kita harus ingat memperbarui tampilan
label.textContent = `Keranjang: ${jumlah} item`Cara React, cukup nyatakan tampilan untuk suatu nilai:
function Keranjang() {
const [jumlah, setJumlah] = useState(0)
return (
<button onClick={() => setJumlah(jumlah + 1)}>
Keranjang: {jumlah} item
</button>
)
}Di contoh kedua, tidak ada perintah "ubah teks tombol". Kita hanya mengubahjumlah, lalu React memperbarui tampilan yang memakainya. Inilah yang disebut deklaratif.
React tidak menulis ulang seluruh halaman setiap kali data berubah. Ia membandingkan hasil render baru dengan hasil sebelumnya lewat representasi ringan yang disebut Virtual DOM, lalu hanya memperbarui bagian yang benar-benar berbeda. Karena itu tugas kita cukup mengubah data, bukan menyentuh DOM satu per satu.
Tiga istilah yang akan sering muncul:
- Component: fungsi yang mengembalikan markup.
- Props: data yang dikirim dari komponen induk ke anak. Bersifat read-only.
- State: data milik komponen yang bisa berubah dan memicu render ulang.
State itu angka di papan skor, tampilan itu papan skornya. Kamu tidak mengecat ulang angka di papan setiap kali ada gol. Kamu cukup mengubah angkanya, dan papan selalu menampilkan angka terbaru.
Di pertemuan ini kita memakai state seperlunya, untuk pencarian, filter, dan keranjang. Routing dan login belum masuk, karena keduanya dibahas di pertemuan 7.
JSX dan TSX
JSX adalah ekstensi sintaks JavaScript yang membuat kita bisa menulis struktur antarmuka mirip HTML di dalam kode JavaScript. TSX adalah versi TypeScript-nya. Karena project kita memakai TypeScript, semua file komponen berekstensi.tsx.
function MenuCard() {
return (
<article className="rounded-xl border p-4">
<h3>Nasi Goreng</h3>
<p>Rp15.000</p>
</article>
)
}JSX bukan string dan bukan HTML. Sebelum dijalankan browser, JSX dikompilasi menjadi pemanggilan fungsi. Kurang lebih bentuknya seperti ini:
// kurang lebih seperti ini hasil kompilasinya
function MenuCard() {
return jsx('article', {
className: 'rounded-xl border p-4',
children: [
jsx('h3', { children: 'Nasi Goreng' }),
jsx('p', { children: 'Rp15.000' }),
],
})
}Beberapa aturan yang paling sering bikin error:
- Harus ada satu elemen pembungkus. Kalau tidak ingin menambah elemen, pakai fragment
<>...</>. - Atribut class ditulis
className, bukanclass. - Ekspresi JavaScript dimasukkan dengan kurung kurawal, misal
{nama}. - Label form memakai
htmlFor, bukanfor. - Semua tag harus ditutup, termasuk yang di HTML boleh terbuka seperti
<img />.
Karena memakai TypeScript, kita bisa menyatakan bentuk props. Ini membuat kesalahan seperti salah nama properti atau salah tipe ketahuan saat menulis kode, bukan saat aplikasi dibuka.
type MenuCardProps = {
nama: string
harga: number
tersedia: boolean
}
function MenuCard({ nama, harga, tersedia }: MenuCardProps) {
return (
<article>
<h3>{nama}</h3>
<p>Rp{harga.toLocaleString('id-ID')}</p>
{!tersedia && <span>Habis</span>}
</article>
)
}- Writing Markup with JSX: aturan JSX dan alasan di baliknya.
Props dan Reusability
Kalau kamu punya sembilan menu, bukan berarti kamu menulis sembilan komponen kartu. Kamu menulis satu MenuCard, lalu memakainya sembilan kali dengan data yang berbeda. Di sinilah props berperan.
Props adalah data yang dikirim dari komponen induk ke komponen anak. Arahnya satu jalur, dari atas ke bawah. Anak menerima props dan memakainya untuk merender tampilan, tetapi tidak boleh mengubahnya.
function Catalog() {
return (
<div>
{menuItems.map((menu) => (
<MenuCard
key={menu.id}
menu={menu}
onSelect={setSelectedMenu}
/>
))}
</div>
)
}
function MenuCard({ menu, onSelect }: MenuCardProps) {
return (
<button onClick={() => onSelect(menu)}>
{menu.name}
</button>
)
}Perhatikan dua hal. Pertama, menu dikirim sebagai data, sehingga satu MenuCard bisa menampilkan menu apa pun. Kedua,onSelect dikirim sebagai fungsi. Anak tidak tahu apa yang harus terjadi saat tombol diklik, ia hanya memanggil fungsi itu. Induk yang memutuskan.
| Jenis props | Arah | Contoh |
|---|---|---|
| Data | Induk ke anak | Objek menu, teks pencarian, daftar pesanan |
| Callback | Anak memanggil, induk yang menangani | Saat menu dipilih, saat status pesanan diubah |
Komponen itu seperti cetakan kue. Props adalah isi yang kamu masukkan ke cetakan. Cetakannya sama, hasilnya bisa berbeda-beda. Kalau cetakannya diubah, semua hasil ikut berubah. Itu keuntungan reusability.
Jangan mengubah nilai props di dalam anak. Kalau anak perlu mengubah data, data itu seharusnya jadi state di komponen induk, lalu induk mengirim nilai baru lewat props.
- Passing Props to a Component: cara mengirim data dan callback, serta aturan props read-only.
Struktur Project React
Project React modern biasanya dibuat dengan Vite. Vite menyiapkan server pengembangan, build produksi, dan konfigurasi TypeScript. Struktur yang kita pakai di hands-on nanti seperti ini:
praktikum-pert-6/
└─ hands-on-1-katalog-menu/
├─ public/
│ └─ images/ # foto menu, logo
├─ src/
│ ├─ components/ # potongan UI yang dipakai berulang
│ ├─ data/ # data contoh dan tipenya
│ ├─ lib/ # fungsi bantu, misal format rupiah
│ ├─ pages/ # halaman yang menyusun komponen
│ ├─ App.tsx # pintu masuk tampilan
│ ├─ main.tsx # memasang React ke HTML
│ └─ index.css # Tailwind dan design token
├─ index.html
├─ package.json
├─ tsconfig.json
└─ vite.config.ts| Folder | Isi |
|---|---|
src/components | Komponen yang dipakai di lebih dari satu tempat, misal Header dan MenuCard |
src/pages | Halaman yang menyusun komponen, misal Catalog dan Dashboard |
src/data | Data contoh dan tipe datanya |
src/lib | Fungsi bantu yang tidak berhubungan dengan tampilan, misal format rupiah |
public | Aset yang diakses lewat URL, misal gambar menu |
Aturan praktisnya sederhana. App.tsx hanya menyusun halaman. Logika tampilan tinggal di komponen. Data dan fungsi bantu tidak dicampur ke dalam komponen kalau dipakai di banyak tempat.
Kalau satu file mulai menangani pencarian, filter, keranjang, dan modal sekaligus, itu tanda ada bagian yang seharusnya jadi komponen atau halaman sendiri.
Di hands-on nanti, seluruh file konfigurasi dan aset sudah disiapkan di starter. Kamu fokus ke komponen, bukan ke urusan tsconfig.
Tailwind dan Design Token
Tailwind CSS adalah framework CSS dengan pendekatan utility-first. Artinya, kita tidak menulis satu class besar berisi banyak aturan. Kita menyusun tampilan dari class kecil yang masing-masing mengerjakan satu hal, seperti mengatur warna, jarak, ukuran, atau tata letak.
/* CSS biasa: satu nama class, isinya banyak aturan */
.kartu-menu {
border-radius: 1rem;
border: 1px solid #f0e3db;
background: #ffffff;
padding: 1rem;
}
/* Tailwind: aturan kecil yang disusun langsung di elemen */
<article className="rounded-2xl border border-border bg-surface p-4">Kelihatannya class-nya jadi panjang. Untungnya, kamu tidak perlu membuka file CSS lain untuk tahu tampilan sebuah elemen. Semua keputusan styling terlihat di tempat yang sama.
Masalah muncul kalau warna ditulis mentah di mana-mana, misalbg-[#fff8f4] di sepuluh tempat. Saat warna brand berubah, sepuluh tempat itu harus dicari satu per satu. Solusinyadesign token: beri nama pada keputusan desain, lalu pakai namanya.
@import "tailwindcss";
@theme {
--color-page: #fff8f4;
--color-surface: #ffffff;
--color-text: #29231f;
--color-muted: #796a61;
--color-primary: #b84208;
--color-border: #f0e3db;
}Setelah token didefinisikan, komponen memakai nama yang bermakna:
| Token | Dipakai untuk | Contoh class |
|---|---|---|
--color-page | Latar halaman | bg-page |
--color-surface | Latar kartu dan panel | bg-surface |
--color-text | Teks utama | text-text |
--color-muted | Teks sekunder | text-muted |
--color-primary | Aksi utama dan penanda brand | bg-primary |
bg-[#fff8f4] boleh dipakai sesekali untuk eksperimen, tetapi jangan jadi kebiasaan. Kalau sebuah nilai dipakai lebih dari sekali, jadikan token.
Tailwind versi 4 tidak membutuhkan tailwind.config.js. Token ditulis langsung di CSS lewat blok @theme.
- Tailwind: Theme variables: cara kerja
@themedan token di Tailwind v4.
Hands-on 1
Ambil Starter
Kita mulai dari titik awal yang sudah disiapkan, bukan dari folder kosong. Starter berisi konfigurasi, aset gambar, data menu, token Tailwind, dan fungsiformatRupiah. Yang dikosongkan hanya komponen yang jadi tujuan belajar.
Hasil clone berisi dua folder, sesuai dua hands-on di pertemuan ini:
praktikum-pert-6/
└─ pertemuan-06-frontend-dasar/
├─ hands-on-1-reactprop/ # titik awal Hands-on 1
└─ hands-on-2-dashboard/ # titik awal Hands-on 2Masuk ke folder Hands-on 1. Semua langkah berikut dijalankan dari dalam folder ini.
Versi jadi di GitHub (branch feat/pertemuan-6)Tautan starter lengkap ada dibranch starter/pertemuan-06, kalau kamu ingin melihat isi folder sebelum menjalankan perintah.
Kenali Starter
Vite akan menampilkan alamat lokal, biasanyahttp://localhost:5173. Halaman yang terbuka belum jadi. Header dan footer sudah ada, tetapi isi utama masih berupa teks TODO.
Sebelum menulis kode, buka file berikut dan baca komentar TODO di dalamnya:
src/App.tsx: state dan komposisi halaman.src/components/Header.tsx: header dan pencarian.src/components/MenuCard.tsx: kartu menu.src/components/MenuDetailModal.tsx: modal detail, bonus.src/components/CartPanel.tsx: keranjang, bonus.
Interface dan import sudah disiapkan. Tugasmu mengisi markup dan logika di bagian yang ditandai TODO. Jangan mengubah nama file, nama komponen, atau bentuk props, karena semuanya sudah dipakai di file lain.
Header dan Pencarian
Header menerima dua props: nilai pencarian dan fungsi untuk mengubahnya. Ini polacontrolled input. Nilai input tidak disimpan di dalam Header, tetapi di App, supaya daftar menu dan kotak pencarian membaca sumber data yang sama.
import { Search, MapPin, ShoppingCart } from 'lucide-react'
interface HeaderProps {
search: string
onSearchChange: (value: string) => void
}
function Header({ search, onSearchChange }: HeaderProps) {
return (
<header className="sticky top-0 z-40 border-b border-border bg-page/95 backdrop-blur-md">
<div className="mx-auto flex w-full max-w-[1440px] flex-wrap items-center gap-x-6 gap-y-3 px-5 py-4 sm:px-8 lg:flex-nowrap lg:px-10">
{/* Brand */}
<a
href="#top"
aria-label="Maem Bolo - Beranda"
className="flex shrink-0 items-center gap-3"
>
<img
src="/images/logo.png"
alt="Logo Maem Bolo"
className="h-12 w-12 shrink-0 rounded-2xl object-contain"
/>
<span className="flex flex-col">
<span className="font-heading text-xl font-extrabold tracking-tight text-primary-2">
Maem Bolo
</span>
<span className="mt-0.5 text-xs text-muted">
Teman makanmu di kampus
</span>
</span>
</a>
{/* Search */}
<div className="order-3 w-full lg:order-none lg:ml-5 lg:min-w-0 lg:flex-1">
<label htmlFor="header-search" className="sr-only">
Cari menu
</label>
<div className="flex items-center gap-3 rounded-2xl border border-border bg-surface px-4 py-3 transition-colors focus-within:border-primary-1 focus-within:ring-4 focus-within:ring-primary-1/10">
<Search
size={20}
strokeWidth={1.8}
className="shrink-0 text-muted-soft"
aria-hidden="true"
/>
<input
id="header-search"
type="search"
value={search}
onChange={(event) => onSearchChange(event.target.value)}
placeholder="Lagi pengin makan apa?"
className="w-full min-w-0 border-0 bg-transparent text-sm text-text outline-none placeholder:text-muted-soft"
/>
</div>
</div>
{/* Location and navigation */}
<div className="ml-auto flex shrink-0 items-center gap-3">
<div className="hidden items-center gap-2 border-r border-border pr-5 text-muted xl:flex">
<MapPin
size={17}
className="shrink-0 text-primary-1"
aria-hidden="true"
/>
<span className="flex flex-col">
<span className="text-xs font-semibold text-text">
Kantin FATISDA
</span>
<span className="mt-0.5 text-[11px]">
Lantai 1 · Buka s/d 16.30
</span>
</span>
</div>
<nav
aria-label="Navigasi utama"
className="flex items-center gap-2"
>
<a
href="#menu"
className="rounded-xl px-4 py-2.5 text-sm font-semibold text-muted transition-colors hover:bg-primary-soft hover:text-primary-2"
>
Menu
</a>
<a
href="#keranjang"
className="inline-flex items-center gap-2 rounded-xl bg-primary-1 px-4 py-2.5 text-sm font-bold text-white shadow-sm transition-colors hover:bg-primary-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-1 focus-visible:ring-offset-2"
>
<ShoppingCart size={18} aria-hidden="true" />
<span>Keranjang</span>
</a>
</nav>
</div>
</div>
</header>
)
}
export default HeaderPerhatikan value={search} danonChange. Tanpa keduanya, input tetap bisa diketik, tetapi React tidak tahu isinya dan daftar menu tidak ikut tersaring.
Kalau nilai pencarian disimpan di Header dan juga di App, keduanya bisa berbeda. Simpan di satu tempat, lalu kirim lewat props. Ini penerapan single source of truth.
MenuCard dan Data
Satu kartu menu menerima satu objek menu. Bentuk objeknya sudah didefinisikan di data/menu.ts, jadi TypeScript bisa mengingatkan kalau ada properti yang salah nama atau salah tipe.
import { useState } from 'react'
import { ShoppingBasket, Ban, Star } from 'lucide-react'
import type { MenuItem } from '../data/menu'
import { formatRupiah } from '../lib/format'
interface MenuCardProps {
menu: MenuItem
onSelect?: (menu: MenuItem) => void
}
function MenuCard({ menu, onSelect }: MenuCardProps) {
const [imageError, setImageError] = useState(false)
return (
<article
className={`group flex h-full flex-col overflow-hidden rounded-2xl border border-border bg-surface shadow-card transition hover:border-border-hover hover:shadow-card-hover ${
!menu.available ? 'opacity-75' : ''
}`}
>
{/* Gambar dan status menu */}
<button
type="button"
onClick={() => menu.available && onSelect?.(menu)}
disabled={!menu.available}
aria-label={`Lihat detail ${menu.name}`}
className="relative block aspect-[4/3] w-full overflow-hidden bg-menu-image-bg text-left focus-visible:outline-2 focus-visible:outline-offset-[-3px] focus-visible:outline-primary-1 disabled:cursor-not-allowed"
>
{!imageError ? (
<img
src={menu.image}
alt={menu.name}
loading="lazy"
onError={() => setImageError(true)}
className={`h-full w-full object-cover transition-transform duration-300 group-hover:scale-105 ${
!menu.available ? 'grayscale' : ''
}`}
/>
) : (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-gradient-to-br from-menu-image-bg to-menu-image-end text-primary-2">
<ShoppingBasket
className="h-10 w-10"
aria-hidden="true"
/>
<span className="text-sm font-semibold">
{menu.name}
</span>
<span className="text-xs text-muted">
Gambar belum tersedia
</span>
</div>
)}
<span className="absolute left-3 top-3 rounded-full bg-surface/95 px-3 py-1 text-xs font-semibold text-category-text shadow-sm">
{menu.category}
</span>
<span
className={`absolute right-3 top-3 rounded-full px-3 py-1 text-xs font-semibold shadow-sm ${
menu.available
? 'bg-available-bg text-available-text'
: 'bg-unavailable-bg text-unavailable-text'
}`}
>
{menu.available ? 'Tersedia' : 'Tidak tersedia'}
</span>
</button>
{/* Informasi menu */}
<div className="flex flex-1 flex-col p-4">
<div className="flex items-start justify-between gap-2">
<p className="min-w-0 truncate text-xs text-muted">
{menu.tenant} · {menu.stand}
</p>
{menu.rating !== undefined && menu.available && (
<span className="flex shrink-0 items-center gap-1 text-xs font-bold text-rating">
<Star
className="size-[15px] shrink-0 fill-current"
aria-hidden="true"
/>
{menu.rating.toFixed(1)}
</span>
)}
{!menu.available && (
<span className="shrink-0 text-xs font-semibold text-disabled-text">
Habis hari ini
</span>
)}
</div>
<h2 className="font-heading mt-2 text-lg font-bold leading-snug text-text">
{menu.name}
</h2>
<p className="mt-1 line-clamp-2 min-h-10 text-sm leading-5 text-muted">
{menu.description}
</p>
{/* Harga dan tombol aksi */}
<div className="mt-auto pt-5">
<div className="flex items-end justify-between gap-2">
<span className="text-xs text-muted">
Harga porsi
</span>
<span
className={`font-heading text-xl font-extrabold tracking-tight ${
menu.available
? 'text-primary-2'
: 'text-price-disabled line-through'
}`}
>
{formatRupiah(menu.price)}
</span>
</div>
<button
type="button"
disabled={!menu.available}
onClick={() => onSelect?.(menu)}
aria-label={
menu.available
? `Pilih menu ${menu.name}`
: `${menu.name} sedang habis`
}
className={`mt-3 flex min-h-11 w-full items-center justify-center gap-2 rounded-xl px-4 py-3 text-sm font-bold transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-1 ${
menu.available
? 'bg-primary-1 text-white hover:bg-primary-2 active:scale-[0.98]'
: 'cursor-not-allowed bg-disabled-bg text-disabled-text'
}`}
>
{menu.available ? (
<>
<ShoppingBasket
className="size-[18px] shrink-0"
aria-hidden="true"
/>
Pilih Menu
</>
) : (
<>
<Ban
className="size-[18px] shrink-0"
aria-hidden="true"
/>
Habis
</>
)}
</button>
</div>
</div>
</article>
)
}
export default MenuCardAda tiga hal yang perlu diperhatikan. Pertama, kartu menampilkan kondisi berbeda saat menu tidak tersedia. Kedua, harga diformat lewat fungsi bantu, bukan ditulis ulang di dalam komponen. Ketiga, gambar yang gagal dimuat diganti tampilan cadangan.
export function formatRupiah(price: number): string {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
maximumFractionDigits: 0,
}).format(price)
}formatRupiah sengaja dipisah kelib/. Kalau nanti dashboard juga butuh format harga, fungsi yang sama bisa dipakai tanpa menyalin kode.
State, Aksi, dan Tampilan
Di App kita menyimpan empat state: pencarian, kategori aktif, menu yang dipilih, dan isi keranjang. Dari state itu, daftar menu yang tampil dihitung ulang, bukan disimpan sebagai state terpisah.
import { useMemo, useState } from 'react'
import { ShoppingCart } from 'lucide-react'
import Header from './components/Header'
import MenuCard from './components/MenuCard'
import MenuDetailModal from './components/MenuDetailModal'
import CartPanel, { type CartItem } from './components/CartPanel'
import Footer from './components/Footer'
import { menuItems, type MenuCategory, type MenuItem } from './data/menu'
function App() {
const [search, setSearch] = useState('')
const [activeCategory, setActiveCategory] =
useState<'Semua' | MenuCategory>('Semua')
const [selectedMenu, setSelectedMenu] = useState<MenuItem | null>(null)
const [cartItems, setCartItems] = useState<CartItem[]>([])
const categories: ('Semua' | MenuCategory)[] = [
'Semua',
'Makanan',
'Minuman',
'Camilan',
]
const filteredMenus = useMemo(() => {
const keyword = search.trim().toLowerCase()
return menuItems.filter((menu) => {
const matchesSearch =
menu.name.toLowerCase().includes(keyword) ||
menu.description.toLowerCase().includes(keyword) ||
menu.tenant.toLowerCase().includes(keyword)
const matchesCategory =
activeCategory === 'Semua' ||
menu.category === activeCategory
return matchesSearch && matchesCategory
})
}, [search, activeCategory])
const totalCartItems = cartItems.reduce(
(total, item) => total + item.quantity,
0,
)
// Handler keranjang dan checkout menyusul di bagian bawah App.Setelah state, kita menulis aksi yang mengubahnya. Empat fungsi berikut menangani keranjang: menambah item, mengubah jumlah, menghapus item, dan checkout.
// src/App.tsx (bagian handler keranjang)
const addToCart = (menu: MenuItem, quantity: number) => {
if (!menu.available || quantity < 1) return
setCartItems((currentItems) => {
const existingItem = currentItems.find(
(item) => item.menu.id === menu.id,
)
if (existingItem) {
return currentItems.map((item) =>
item.menu.id === menu.id
? { ...item, quantity: item.quantity + quantity }
: item,
)
}
return [...currentItems, { menu, quantity }]
})
}
const changeQuantity = (menuId: number, quantity: number) => {
if (quantity <= 0) {
setCartItems((currentItems) =>
currentItems.filter((item) => item.menu.id !== menuId),
)
return
}
setCartItems((currentItems) =>
currentItems.map((item) =>
item.menu.id === menuId
? { ...item, quantity }
: item,
),
)
}
const removeFromCart = (menuId: number) => {
setCartItems((currentItems) =>
currentItems.filter((item) => item.menu.id !== menuId),
)
}
const handleCheckout = () => {
if (cartItems.length === 0) {
window.alert('Keranjang masih kosong. Silakan pilih menu terlebih dahulu.')
return
}
const confirmed = window.confirm(
'Lanjutkan checkout? Keranjang akan dikosongkan setelah dikonfirmasi.',
)
if (!confirmed) return
setCartItems([])
window.alert(
'Checkout demo berhasil. Pesanan belum dikirim ke server.',
)
}Semua state dan fungsi harus ditulis sebelum blokreturn. Kode dieksekusi dari atas ke bawah, danreturn mengakhiri fungsi. Kalau handler ditulis setelahnya, ia tidak pernah dibuat dan JSX akan gagal dengan pesanis not defined.
Baru setelah itu bagian tampilan menyusun Header, grid MenuCard, CartPanel, dan modal. Perhatikan key={menu.id} saat merender daftar, dan kondisi saat hasil pencarian kosong.
// src/App.tsx (bagian tampilan)
return (
<div
id="top"
className="min-h-screen bg-page text-text"
>
<Header
search={search}
onSearchChange={setSearch}
/>
<main className="mx-auto w-full max-w-[1440px] px-4 py-6 sm:px-6 lg:px-8">
{/* Judul halaman */}
<section className="mb-6">
<p className="text-sm font-semibold text-primary-1">
MAEM BOLO
</p>
<div className="mt-1 flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-extrabold tracking-tight sm:text-3xl">
Mau makan apa hari ini?
</h1>
<p className="mt-2 text-sm text-muted">
Temukan makanan dan minuman favoritmu.
</p>
</div>
<a
href="#keranjang"
className="inline-flex items-center gap-2 rounded-xl border border-border bg-surface px-4 py-2.5 text-sm font-bold text-primary-1 transition hover:bg-primary-soft"
>
<ShoppingCart size={18} />
Keranjang
<span className="rounded-full bg-primary-soft px-2 py-0.5 text-xs">
{totalCartItems}
</span>
</a>
</div>
</section>
{/* Katalog di kiri dan keranjang di kanan */}
<div className="grid grid-cols-1 items-start gap-6 lg:grid-cols-[minmax(0,1fr)_340px] xl:grid-cols-[minmax(0,1fr)_370px]">
<section
id="menu"
className="min-w-0 scroll-mt-24"
>
{/* Filter kategori */}
<div className="mb-5 flex flex-wrap gap-2">
{categories.map((category) => {
const isActive = activeCategory === category
return (
<button
key={category}
type="button"
onClick={() => setActiveCategory(category)}
aria-pressed={isActive}
className={`rounded-full px-4 py-2 text-sm font-semibold transition ${
isActive
? 'bg-primary-1 text-white'
: 'border border-border bg-surface text-muted hover:border-border-hover hover:text-primary-1'
}`}
>
{category}
</button>
)
})}
</div>
{/* Daftar menu */}
<div className="mb-4 flex items-center justify-between gap-3">
<h2 className="text-lg font-extrabold sm:text-xl">
{activeCategory === 'Semua'
? 'Semua Menu'
: `Menu ${activeCategory}`}
</h2>
<span className="text-sm text-muted">
{filteredMenus.length} menu
</span>
</div>
{filteredMenus.length > 0 ? (
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 xl:grid-cols-3">
{filteredMenus.map((menu) => (
<MenuCard
key={menu.id}
menu={menu}
onSelect={setSelectedMenu}
/>
))}
</div>
) : (
<div className="rounded-2xl border border-border bg-surface px-5 py-14 text-center">
<p className="font-bold">
Menu tidak ditemukan
</p>
<p className="mt-2 text-sm text-muted">
Coba kata kunci lain atau pilih kategori berbeda.
</p>
<button
type="button"
onClick={() => {
setSearch('')
setActiveCategory('Semua')
}}
className="mt-4 rounded-xl bg-primary-1 px-4 py-2.5 text-sm font-bold text-white transition hover:bg-primary-2"
>
Reset Pencarian
</button>
</div>
)}
</section>
{/* Keranjang belanja */}
<aside
className="min-w-0 scroll-mt-24 lg:sticky lg:top-5"
>
<CartPanel
items={cartItems}
onChangeQuantity={changeQuantity}
onRemove={removeFromCart}
onCheckout={handleCheckout}
/>
</aside>
</div>
</main>
<Footer />
{/* Popup detail menu */}
{selectedMenu && (
<MenuDetailModal
menu={selectedMenu}
onClose={() => setSelectedMenu(null)}
onAddToCart={addToCart}
/>
)}
</div>
)
}
export default AppSetelah blok ini ditempel, jalankan npm run dev. Sebelumnya build bisa gagal karena masih ada import yang belum terpakai.
Menyaring menu dilakukan dengan filter, yang menghasilkan array baru. Jangan memakai spliceatau mengubah menuItems langsung, karena data asli akan rusak dan pencarian berikutnya ikut salah.
Uji Katalog
Pastikan server masih berjalan, lalu uji beberapa kasus berikut. Jumlah menu awal ada 9.
| Kasus | Hasil yang diharapkan |
|---|---|
| Halaman pertama dibuka | 9 kartu menu tampil |
| Ketik "mie" di pencarian | Hanya menu yang memuat kata itu, termasuk dari nama warung |
| Pilih kategori Minuman | Hanya menu minuman yang tampil |
| Pencarian dan kategori tanpa hasil | Tampil pesan kosong dan tombol reset |
Komponen keranjang dan modal masih berupa kerangka di titik ini. Keduanya diuji setelah diisi pada step berikutnya.
Kalau ada yang tidak sesuai:
| Gejala | Kemungkinan penyebab |
|---|---|
| Input tidak bisa diketik | value diisi, tetapi onChange belum memanggil props |
| Daftar tidak berubah saat mencari | Filter belum memakai nilai search, atau props tidak diteruskan |
| Gambar menu kosong | Path gambar harus diawali /images/ dan file ada di public/images |
| Style Tailwind tidak jalan | import "./index.css" hilang di main.tsx |
changeQuantity is not defined | Handler keranjang belum ditambahkan, atau ditulis setelahreturn. Pindahkan ke atas blok tampilan. |
Modal Detail Menu
Modal ini bonus, tetapi isinya bagus untuk latihan state dan efek samping. Ada jumlah pesanan yang dikelola sendiri, penutupan lewat tombol Escape, dan klik di latar gelap untuk menutup.
import { useEffect, useState } from 'react'
import { Minus, Plus, X } from 'lucide-react'
import type { MenuItem } from '../data/menu'
import { formatRupiah } from '../lib/format'
interface MenuDetailModalProps {
menu: MenuItem
onClose: () => void
onAddToCart: (menu: MenuItem, quantity: number) => void
}
function MenuDetailModal({
menu,
onClose,
onAddToCart,
}: MenuDetailModalProps) {
const [quantity, setQuantity] = useState(1)
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
onClose()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => {
window.removeEventListener('keydown', handleKeyDown)
}
}, [onClose])
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
onClose()
}
}}
>
<section
role="dialog"
aria-modal="true"
aria-labelledby="menu-detail-title"
className="max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-3xl border border-border bg-surface shadow-2xl"
>
{/* Header modal */}
<div className="flex items-center justify-between border-b border-border p-5">
<h2
id="menu-detail-title"
className="font-heading text-lg font-bold text-text"
>
Detail Menu
</h2>
<button
type="button"
onClick={onClose}
aria-label="Tutup detail menu"
className="rounded-full p-2 text-muted transition-colors hover:bg-primary-soft hover:text-primary-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-1"
>
<X size={20} aria-hidden="true" />
</button>
</div>
{/* Informasi menu */}
<div className="p-5">
<img
src={menu.image}
alt={menu.name}
className="h-52 w-full rounded-2xl bg-menu-image-bg object-cover"
/>
<p className="mt-4 text-sm font-semibold text-primary-2">
{menu.category} · {menu.tenant} · {menu.stand}
</p>
<h3 className="mt-1 font-heading text-2xl font-extrabold text-text">
{menu.name}
</h3>
<p className="mt-2 text-sm leading-relaxed text-muted">
{menu.description}
</p>
{/* Harga dan pengaturan jumlah */}
<div className="mt-5 flex flex-wrap items-center justify-between gap-4">
<div>
<p className="text-sm text-muted">
Harga per porsi
</p>
<p className="font-heading text-xl font-extrabold text-primary-2">
{formatRupiah(menu.price)}
</p>
</div>
<div
className="flex items-center gap-3"
aria-label="Pengaturan jumlah pesanan"
>
<button
type="button"
onClick={() =>
setQuantity((value) => Math.max(1, value - 1))
}
disabled={quantity <= 1}
aria-label="Kurangi jumlah"
className="rounded-xl border border-border p-2 text-text transition-colors hover:bg-primary-soft hover:text-primary-2 disabled:cursor-not-allowed disabled:opacity-40"
>
<Minus size={18} aria-hidden="true" />
</button>
<span
className="min-w-5 text-center font-bold text-text"
aria-live="polite"
>
{quantity}
</span>
<button
type="button"
onClick={() => setQuantity((value) => value + 1)}
aria-label="Tambah jumlah"
className="rounded-xl border border-border p-2 text-text transition-colors hover:bg-primary-soft hover:text-primary-2"
>
<Plus size={18} aria-hidden="true" />
</button>
</div>
</div>
{/* Subtotal */}
<div className="mt-4 flex items-center justify-between gap-3 border-t border-border pt-4">
<span className="text-sm text-muted">
Subtotal
</span>
<strong className="font-heading text-lg font-extrabold text-text">
{formatRupiah(menu.price * quantity)}
</strong>
</div>
{/* Tombol tambah ke keranjang */}
<button
type="button"
onClick={() => {
onAddToCart(menu, quantity)
onClose()
}}
className="mt-5 w-full rounded-xl bg-primary-1 px-5 py-3 font-bold text-white transition-colors hover:bg-primary-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-1"
>
Tambah ke Keranjang
</button>
</div>
</section>
</div>
)
}
export default MenuDetailModaluseEffect dipakai untuk memasang listener keyboard, lalu membersihkannya saat modal ditutup. Tanpa pembersihan, listener menumpuk setiap kali modal dibuka.
Keranjang
Handler keranjang sudah kita tulis di step State, Aksi, dan Tampilan. Sekarang tinggal melengkapi tampilan CartPanel: daftar item, tombol ubah jumlah, tombol hapus, total belanja, dan checkout.
import { Minus, Plus, ShoppingCart, Trash2 } from 'lucide-react'
import type { MenuItem } from '../data/menu'
import { formatRupiah } from '../lib/format'
export interface CartItem {
menu: MenuItem
quantity: number
}
interface CartPanelProps {
items: CartItem[]
onChangeQuantity: (menuId: number, quantity: number) => void
onRemove: (menuId: number) => void
onCheckout: () => void
}
function CartPanel({
items,
onChangeQuantity,
onRemove,
onCheckout,
}: CartPanelProps) {
const totalQuantity = items.reduce(
(sum, item) => sum + item.quantity,
0,
)
const totalPrice = items.reduce(
(sum, item) => sum + item.menu.price * item.quantity,
0,
)
return (
<section
id="keranjang"
aria-labelledby="cart-title"
className="mt-10 scroll-mt-6 rounded-card border border-border bg-surface p-5 shadow-card sm:p-6"
>
{/* Header keranjang */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<ShoppingCart
className="h-6 w-6 shrink-0 text-primary-1"
aria-hidden="true"
/>
<h2
id="cart-title"
className="text-xl font-extrabold text-text"
>
Keranjang Belanja
</h2>
<span className="rounded-full bg-primary-soft px-3 py-1 text-xs font-bold text-primary-2">
{totalQuantity} item
</span>
</div>
</div>
{items.length === 0 ? (
/* Tampilan keranjang kosong */
<div className="py-10 text-center">
<ShoppingCart
className="mx-auto h-10 w-10 text-muted-soft"
aria-hidden="true"
/>
<p className="mt-3 font-semibold text-text">
Keranjangmu masih kosong
</p>
<p className="mt-1 text-sm text-muted">
Pilih menu yang kamu suka untuk mulai memesan.
</p>
</div>
) : (
<>
{/* Daftar item keranjang */}
<div className="mt-5 divide-y divide-border">
{items.map(({ menu, quantity }) => (
<div
key={menu.id}
className="flex flex-wrap items-center justify-between gap-4 py-4"
>
<div className="min-w-0 flex-1">
<h3 className="font-bold text-text">
{menu.name}
</h3>
<p className="mt-1 text-sm text-muted">
{formatRupiah(menu.price)} per porsi
</p>
<p className="mt-1 font-bold text-primary-2">
{formatRupiah(menu.price * quantity)}
</p>
</div>
{/* Kontrol jumlah dan hapus item */}
<div className="flex items-center gap-2">
<button
type="button"
aria-label={`Kurangi jumlah ${menu.name}`}
onClick={() =>
onChangeQuantity(menu.id, quantity - 1)
}
disabled={quantity <= 1}
className="rounded-lg border border-border p-2 text-text transition hover:bg-primary-soft hover:text-primary-2 disabled:cursor-not-allowed disabled:opacity-40"
>
<Minus size={16} aria-hidden="true" />
</button>
<span
className="min-w-6 text-center font-bold text-text"
aria-label={`Jumlah ${quantity}`}
>
{quantity}
</span>
<button
type="button"
aria-label={`Tambah jumlah ${menu.name}`}
onClick={() =>
onChangeQuantity(menu.id, quantity + 1)
}
className="rounded-lg border border-border p-2 text-text transition hover:bg-primary-soft hover:text-primary-2"
>
<Plus size={16} aria-hidden="true" />
</button>
<button
type="button"
aria-label={`Hapus ${menu.name} dari keranjang`}
onClick={() => onRemove(menu.id)}
className="ml-1 rounded-lg p-2 text-danger transition hover:bg-unavailable-bg"
>
<Trash2 size={17} aria-hidden="true" />
</button>
</div>
</div>
))}
</div>
{/* Ringkasan dan checkout */}
<div className="mt-3 border-t border-border pt-5">
<div className="flex items-center justify-between gap-3">
<span className="font-semibold text-muted">
Total Belanja
</span>
<span className="text-2xl font-extrabold text-primary-2">
{formatRupiah(totalPrice)}
</span>
</div>
<button
type="button"
onClick={onCheckout}
className="mt-5 w-full rounded-xl bg-primary-1 px-5 py-3 font-bold text-white transition hover:bg-primary-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-1"
>
Checkout Pesanan
</button>
</div>
</>
)}
</section>
)
}
export default CartPanelTotal adalah turunan dari item. Kalau disimpan sebagai state terpisah, ia bisa lupa diperbarui saat item berubah. Menghitung ulang setiap render selalu menghasilkan angka yang benar.
Setelah CartPanel dan MenuDetailModal terisi, uji alur berikut:
| Kasus | Hasil yang diharapkan |
|---|---|
| Tambah satu menu dari modal | Item muncul di keranjang, jumlah di header bertambah |
| Klik tombol tambah di keranjang | Jumlah item naik, total harga ikut berubah |
| Kurangi jumlah sampai nol | Item hilang dari keranjang |
| Klik hapus | Hanya item itu yang hilang, item lain tetap |
| Checkout saat keranjang kosong | Muncul peringatan, keranjang tidak berubah |
| Buka modal lalu tekan Escape | Modal tertutup tanpa menambah item |
Hands-on 2
Ambil Starter Dashboard
Hands-on 2 memakai folder kedua dari hasil clone yang sama. Isinya salinan lengkap Hands-on 1, ditambah data pesanan dan kerangka dashboard. Kamu tidak perlu mengulang katalog dari awal.
Versi jadi di GitHub (branch feat/pertemuan-6)DashboardHeader dan Layout
Header dashboard lebih sederhana dari header katalog. Tidak ada pencarian dan tidak ada tombol keluar, karena autentikasi baru dibahas di pertemuan 7. Tanggal hari ini diformat memakai Intl.DateTimeFormat dengan locale Indonesia.
import { Store } from 'lucide-react'
export default function DashboardHeader() {
return (
<header className="sticky top-0 z-30 border-b border-border bg-surface">
<div className="mx-auto flex min-h-[76px] max-w-7xl items-center justify-between gap-4 px-4 sm:px-6 lg:px-8">
<div className="flex min-w-0 items-center gap-3">
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-border bg-page text-primary-1">
<Store size={22} strokeWidth={1.8} aria-hidden="true" />
</span>
<span className="min-w-0">
<span className="block font-heading text-lg font-bold leading-tight tracking-[-0.025em] text-primary-2">
Maem Bolo
</span>
<span className="mt-1 block truncate font-body text-xs text-muted">
Dashboard Pemilik Kantin
</span>
</span>
</div>
</div>
</header>
)
}Layout dashboard memakai max-w-7xl dan padding responsif. Bagian atas berisi judul dan tanggal, bagian tengah berisi ringkasan, dan bagian bawah berisi daftar pesanan.
OrderStats
Ringkasan pesanan tidak menyimpan angka sendiri. Setiap kartu menghitung dari arrayorders yang sama, sehingga angka selalu ikut berubah saat status pesanan diperbarui.
import {
CheckCircle2,
ClipboardList,
Clock3,
CookingPot,
} from 'lucide-react'
import type { Order } from '../data/orders'
interface OrderStatsProps {
orders: Order[]
}
export default function OrderStats({ orders }: OrderStatsProps) {
const stats = [
{
label: 'Total Pesanan',
value: orders.length,
description: 'Semua pesanan masuk',
icon: ClipboardList,
color: 'text-primary-1',
background: 'bg-primary-soft',
},
{
label: 'Menunggu',
value: orders.filter((order) => order.status === 'Menunggu').length,
description: 'Perlu segera ditangani',
icon: Clock3,
color: 'text-warning',
background: 'bg-warning-bg',
},
{
label: 'Diproses',
value: orders.filter((order) => order.status === 'Diproses').length,
description: 'Sedang disiapkan',
icon: CookingPot,
color: 'text-info',
background: 'bg-info-bg',
},
{
label: 'Selesai',
value: orders.filter((order) => order.status === 'Selesai').length,
description: 'Pesanan telah selesai',
icon: CheckCircle2,
color: 'text-success',
background: 'bg-success-bg',
},
]
return (
<section
aria-label="Ringkasan pesanan"
className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4"
>
{stats.map(
({
label,
value,
description,
icon: Icon,
color,
background,
}) => (
<article
key={label}
className="rounded-2xl border border-border bg-surface p-5 shadow-card transition-colors duration-200 hover:border-border-hover"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="font-body text-sm font-medium text-muted">
{label}
</h3>
<p className="mt-3 font-heading text-3xl font-bold tracking-[-0.025em] text-text">
{value}
</p>
</div>
<span
className={`flex h-11 w-11 shrink-0 items-center justify-center rounded-xl ${background} ${color}`}
>
<Icon size={21} strokeWidth={1.8} aria-hidden="true" />
</span>
</div>
<p className="mt-4 border-t border-border pt-3 font-body text-xs leading-5 text-muted">
{description}
</p>
</article>
),
)}
</section>
)
}Menyimpan totalMenunggu di state terpisah memaksa kamu memperbarui dua tempat setiap kali status berubah. Hitung dari data sumbernya.
OrderList dan OrderCard
OrderList menyaring pesanan berdasarkan kata kunci dan filter status. Hasil saringannya dihitung dengan useMemo supaya tidak diulang tanpa perlu.
import { useMemo, useState } from 'react'
import {
Search,
ClipboardList,
X,
SlidersHorizontal,
} from 'lucide-react'
import OrderCard from './OrderCard'
import type { Order, OrderStatus } from '../data/orders'
type StatusFilter = 'Semua' | OrderStatus
interface OrderListProps {
orders: Order[]
onChangeStatus: (orderId: string, status: OrderStatus) => void
}
const filters: StatusFilter[] = [
'Semua',
'Menunggu',
'Diproses',
'Selesai',
]
export default function OrderList({
orders,
onChangeStatus,
}: OrderListProps) {
const [search, setSearch] = useState('')
const [activeFilter, setActiveFilter] =
useState<StatusFilter>('Semua')
const visibleOrders = useMemo(() => {
const keyword = search.trim().toLowerCase()
return orders.filter((order) => {
const searchableText =
`${order.id} ${order.customer} ${order.menu}`.toLowerCase()
const matchesSearch = searchableText.includes(keyword)
const matchesStatus =
activeFilter === 'Semua' || order.status === activeFilter
return matchesSearch && matchesStatus
})
}, [orders, search, activeFilter])
function resetFilters() {
setSearch('')
setActiveFilter('Semua')
}
return (
<section className="overflow-hidden rounded-2xl border border-border bg-surface shadow-card">
<div className="border-b border-border p-4 sm:p-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="font-heading text-lg font-bold tracking-[-0.025em] text-text">
Daftar Pesanan
</h2>
<p className="mt-1 font-body text-sm leading-6 text-muted">
Cari pesanan dan kelola statusnya.
</p>
</div>
<span className="inline-flex w-fit items-center gap-2 rounded-full bg-primary-soft px-3 py-2 font-body text-sm font-semibold text-primary-2">
<ClipboardList size={16} aria-hidden="true" />
{visibleOrders.length} dari {orders.length} pesanan
</span>
</div>
<div className="mt-5">
<label
htmlFor="order-search"
className="mb-2 block font-body text-sm font-semibold text-text"
>
Cari pesanan
</label>
<div className="flex min-h-12 items-center gap-2 rounded-xl border border-border bg-surface px-3 transition-colors focus-within:border-primary-1 focus-within:ring-2 focus-within:ring-primary-1/10">
<Search
size={18}
className="shrink-0 text-muted"
aria-hidden="true"
/>
<input
id="order-search"
type="search"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Nomor pesanan, nama pelanggan, atau menu..."
className="min-h-11 w-full min-w-0 bg-transparent py-2 font-body text-sm text-text outline-none placeholder:text-muted-soft"
/>
{search && (
<button
type="button"
onClick={() => setSearch('')}
aria-label="Hapus kata pencarian"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-muted transition-colors hover:bg-primary-soft hover:text-primary-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-1"
>
<X size={17} aria-hidden="true" />
</button>
)}
</div>
</div>
<div className="mt-5">
<div className="mb-3 flex items-center gap-2 font-body text-sm font-semibold text-text">
<SlidersHorizontal size={16} aria-hidden="true" />
Filter status
</div>
<div
className="flex flex-wrap gap-2"
role="group"
aria-label="Filter status pesanan"
>
{filters.map((filter) => {
const isActive = activeFilter === filter
return (
<button
key={filter}
type="button"
onClick={() => setActiveFilter(filter)}
aria-pressed={isActive}
className={`rounded-full border px-4 py-2 font-body text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-1 focus-visible:ring-offset-2 ${
isActive
? 'border-primary-1 bg-primary-1 text-surface'
: 'border-border bg-surface text-muted hover:border-border-hover hover:bg-page hover:text-primary-2'
}`}
>
{filter}
</button>
)
})}
</div>
</div>
</div>
{visibleOrders.length > 0 ? (
<div>
{visibleOrders.map((order) => (
<OrderCard
key={order.id}
order={order}
onChangeStatus={onChangeStatus}
/>
))}
</div>
) : (
<div className="flex flex-col items-center px-5 py-12 text-center sm:py-16">
<span className="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary-soft text-primary-1">
<ClipboardList size={26} aria-hidden="true" />
</span>
<h3 className="mt-4 font-heading text-base font-bold tracking-[-0.025em] text-text">
{orders.length === 0
? 'Belum ada pesanan'
: 'Pesanan tidak ditemukan'}
</h3>
<p className="mt-2 max-w-sm font-body text-sm leading-6 text-muted">
{orders.length === 0
? 'Pesanan yang masuk akan muncul di sini.'
: 'Coba kata kunci lain atau pilih filter status yang berbeda.'}
</p>
{(search || activeFilter !== 'Semua') && (
<button
type="button"
onClick={resetFilters}
className="mt-5 rounded-xl bg-primary-1 px-4 py-2.5 font-body text-sm font-semibold text-surface transition-colors hover:bg-primary-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-1 focus-visible:ring-offset-2"
>
Reset pencarian dan filter
</button>
)}
</div>
)}
</section>
)
}OrderCard menentukan aksi berikutnya berdasarkan status sekarang. Menunggu menjadi Diproses, Diproses menjadi Selesai, dan Selesai tidak punya aksi lagi.
import {
ArrowRight,
Check,
CheckCircle2,
CookingPot,
UserRound,
Utensils,
} from 'lucide-react'
import type { Order, OrderStatus } from '../data/orders'
interface OrderCardProps {
order: Order
onChangeStatus: (orderId: string, status: OrderStatus) => void
}
const statusStyles: Record<OrderStatus, string> = {
Menunggu: 'bg-warning-bg text-warning',
Diproses: 'bg-info-bg text-info',
Selesai: 'bg-success-bg text-success',
}
const nextStatus: Record<OrderStatus, OrderStatus | null> = {
Menunggu: 'Diproses',
Diproses: 'Selesai',
Selesai: null,
}
export default function OrderCard({
order,
onChangeStatus,
}: OrderCardProps) {
const next = nextStatus[order.status]
return (
<article className="border-b border-border bg-surface px-4 py-5 transition-colors last:border-b-0 hover:bg-page sm:px-5">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-body text-sm font-bold text-primary-1">
{order.id}
</span>
<span
className={`inline-flex items-center rounded-full px-3 py-1 font-body text-xs font-semibold ${statusStyles[order.status]}`}
>
{order.status}
</span>
</div>
<h3 className="mt-2 break-words font-heading text-base font-bold tracking-[-0.025em] text-text sm:text-lg">
{order.menu}
</h3>
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-2 font-body text-sm text-muted">
<span className="inline-flex items-center gap-1.5">
<UserRound
size={15}
strokeWidth={1.8}
aria-hidden="true"
/>
{order.customer}
</span>
<span className="inline-flex items-center gap-1.5">
<Utensils
size={15}
strokeWidth={1.8}
aria-hidden="true"
/>
{order.quantity} item
</span>
<span>{order.createdAt}</span>
</div>
</div>
<div className="shrink-0 sm:w-44">
{next ? (
<button
type="button"
onClick={() => onChangeStatus(order.id, next)}
className="inline-flex min-h-11 w-full items-center justify-center gap-2 rounded-xl bg-primary-1 px-4 py-3 font-body text-sm font-semibold text-surface transition-colors hover:bg-primary-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-1 focus-visible:ring-offset-2 active:scale-[0.98]"
aria-label={`Ubah status pesanan ${order.id} menjadi ${next}`}
>
{order.status === 'Menunggu' ? (
<>
<CookingPot
size={17}
strokeWidth={1.8}
aria-hidden="true"
/>
<span>Mulai proses</span>
</>
) : (
<>
<Check
size={17}
strokeWidth={2}
aria-hidden="true"
/>
<span>Selesaikan</span>
</>
)}
<ArrowRight size={15} aria-hidden="true" />
</button>
) : (
<div
className="inline-flex min-h-11 w-full items-center justify-center gap-2 rounded-xl bg-success-bg px-3 py-2.5 font-body text-sm font-semibold text-success"
role="status"
>
<CheckCircle2 size={18} aria-hidden="true" />
<span>Pesanan selesai</span>
</div>
)}
</div>
</div>
</article>
)
}Uji Ubah Status
Dashboard menyimpan daftar pesanan di state. Saat status diubah, array diganti dengan versi baru yang hanya mengubah satu pesanan. Statistik dan daftar ikut menyesuaikan karena semuanya membaca state yang sama.
import { useState } from 'react'
import { CalendarDays, ChefHat } from 'lucide-react'
import DashboardHeader from '../components/DashboardHeader'
import OrderStats from '../components/OrderStats'
import OrderList from '../components/OrderList'
import { initialOrders, type Order, type OrderStatus } from '../data/orders'
export default function Dashboard() {
const [orders, setOrders] = useState<Order[]>(initialOrders)
function changeOrderStatus(orderId: string, status: OrderStatus) {
setOrders((currentOrders) =>
currentOrders.map((order) =>
order.id === orderId ? { ...order, status } : order,
),
)
}
const today = new Intl.DateTimeFormat('id-ID', {
dateStyle: 'full',
}).format(new Date())
return (
<div className="min-h-screen bg-page font-body text-text">
<DashboardHeader />
<main className="mx-auto w-full max-w-7xl px-4 py-7 sm:px-6 sm:py-9 lg:px-8">
<section className="mb-8 flex flex-col gap-5 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="mb-2 font-body text-xs font-bold uppercase tracking-[0.14em] text-primary-1">
Dashboard Kantin
</p>
<h1 className="font-heading text-2xl font-bold tracking-[-0.025em] text-text sm:text-3xl">
Pesanan Hari Ini
</h1>
<p className="mt-2 max-w-xl text-sm leading-6 text-muted">
Kelola antrean pesanan dan pantau aktivitas kantin dalam satu
tempat.
</p>
</div>
<div className="inline-flex w-fit max-w-full items-center gap-2 rounded-xl border border-border bg-surface px-3.5 py-3 text-sm text-muted shadow-card">
<CalendarDays
size={17}
className="shrink-0 text-primary-1"
aria-hidden="true"
/>
<span>{today}</span>
</div>
</section>
<section aria-label="Ringkasan pesanan">
<OrderStats orders={orders} />
</section>
<section className="mt-9">
<div className="mb-5 flex items-center gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border border-border bg-surface text-primary-1">
<ChefHat size={21} strokeWidth={1.8} aria-hidden="true" />
</div>
<div>
<h2 className="font-heading text-lg font-bold tracking-[-0.025em] text-text">
Antrean Pesanan
</h2>
<p className="mt-1 text-sm leading-5 text-muted">
Periksa detail pesanan dan perbarui statusnya.
</p>
</div>
</div>
<OrderList
orders={orders}
onChangeStatus={changeOrderStatus}
/>
</section>
<footer className="mt-10 border-t border-border py-6 text-center font-body text-xs leading-5 text-muted">
<p>© {new Date().getFullYear()} Maem Bolo</p>
<p className="mt-1">
Dashboard operasional kantin · Simulasi praktikum
</p>
</footer>
</main>
</div>
)
}Kasus uji:
| Kasus | Hasil yang diharapkan |
|---|---|
| Data awal | Total 8, Menunggu 3, Diproses 2, Selesai 3 |
| Cari "MB-012" | Hanya satu pesanan tampil |
| Filter Selesai | Tiga pesanan selesai tampil |
| Klik "Mulai proses" | Status jadi Diproses, jumlah Menunggu turun, Diproses naik |
| Klik "Selesaikan" | Status jadi Selesai dan tombol aksi hilang |
| Filter tanpa hasil | Pesan kosong dan tombol reset muncul |
Penutup
Recap
Frontend adalah sisi yang dilihat dan disentuh pengguna. Supaya tidak jadi satu file raksasa, antarmuka dipecah menjadi komponen dengan tanggung jawab jelas. React menjaga tampilan tetap sesuai data, JSX membuat struktur UI bisa ditulis di dalam kode, dan Tailwind menyusun styling dari utility dengan token sebagai sumber keputusan desain.
| Konsep | Inti yang perlu diingat |
|---|---|
| Frontend | Menampilkan, menerima input, dan berkomunikasi dengan backend |
| Komponen | Bagian UI kecil dengan tanggung jawab jelas dan bisa dipakai ulang |
| React | Tampilan mengikuti data, bukan diperintah satu per satu |
| Props dan state | Props masuk dari induk, state milik komponen sendiri |
| Tailwind | Utility-first, dengan token untuk nilai yang dipakai berulang |
Di hands-on tadi, kita menyusun katalog menu dari komponen kecil, lalu membuat dashboard pesanan dari komponen yang sama polanya. Setelah ini, pertemuan 7 menyambungkan frontend ke API, menambahkan routing, dan membahas alur login.
