Lewati ke konten

PRAKTIKUM RSI

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.

TugasContoh di sistem kantin
Menampilkan dataDaftar warung, daftar menu, harga, status ketersediaan
Menerima inputKotak pencarian, pilihan kategori, jumlah pesanan
Mengirim permintaanMenyimpan pesanan, mengubah status pesanan
Mengolah jawabanMenampilkan 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.

Analogi

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.

AlasanArti sederhananyaContoh di katalog
ModularityAplikasi terbagi jadi bagian kecil dengan fungsi tertentuHeader, MenuCard, CartPanel
AbstractionDetail di dalam disembunyikan, cukup tahu cara memakainyaCukup menulis <Button />, tidak perlu tahu kelas Tailwind-nya
EncapsulationData dan logika yang berkaitan dikelola di dalam komponen ituStatus gambar gagal disimpan di dalam MenuCard
ReusabilityKomponen dipakai lagi tanpa dibuat dari nolSembilan menu memakai satu MenuCard yang sama
Separation of ConcernSetiap bagian punya tanggung jawab yang tidak tercampurData 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.

Jangan memecah terlalu cepat

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.

Bacaan Lanjutan
  • 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:

cara imperatif (bukan React)ts
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:

Keranjang.tsxtsx
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.
Analogi

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.

MenuCard.tsxtsx
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:

hasil kompilasi (gambaran)ts
// 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, bukan class.
  • Ekspresi JavaScript dimasukkan dengan kurung kurawal, misal {nama}.
  • Label form memakai htmlFor, bukan for.
  • 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.

MenuCard.tsx (props bertipe)tsx
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>
  )
}
Bacaan Lanjutan

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.

Catalog.tsx dan MenuCard.tsxtsx
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 propsArahContoh
DataInduk ke anakObjek menu, teks pencarian, daftar pesanan
CallbackAnak memanggil, induk yang menanganiSaat menu dipilih, saat status pesanan diubah
Analogi

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.

Props itu read-only

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.

Bacaan Lanjutan

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
FolderIsi
src/componentsKomponen yang dipakai di lebih dari satu tempat, misal Header dan MenuCard
src/pagesHalaman yang menyusun komponen, misal Catalog dan Dashboard
src/dataData contoh dan tipe datanya
src/libFungsi bantu yang tidak berhubungan dengan tampilan, misal format rupiah
publicAset 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.

Jangan jadikan App.tsx tempat sampah

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.

src/index.csscss
@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:

TokenDipakai untukContoh class
--color-pageLatar halamanbg-page
--color-surfaceLatar kartu dan panelbg-surface
--color-textTeks utamatext-text
--color-mutedTeks sekundertext-muted
--color-primaryAksi utama dan penanda brandbg-primary
Hindari nilai mentah

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.

Bacaan Lanjutan

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.

Terminal: Clone starter Pertemuan 06
git clone --single-branch --branch starter/pertemuan-06 https://github.com/rrrambatsigma/praktikum-rsi-2026.git praktikum-pert-6

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 2

Masuk ke folder Hands-on 1. Semua langkah berikut dijalankan dari dalam folder ini.

Terminal: Masuk folder Hands-on 1
cd praktikum-pert-6/pertemuan-06-frontend-dasar/hands-on-1-reactprop
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

Terminal: Install dan jalankan
npm install npm run dev

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.
Cara membaca starter

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.

src/components/Header.tsxtsx
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 Header

Perhatikan value={search} danonChange. Tanpa keduanya, input tetap bisa diketik, tetapi React tidak tahu isinya dan daftar menu tidak ikut tersaring.

Jangan simpan dua kali

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.

src/components/MenuCard.tsxtsx
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 MenuCard

Ada 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.

src/lib/format.tsts
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.

src/App.tsx (state dan filter)tsx

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 (aksi keranjang)tsx
// 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.',
    )
  }
Urutan deklarasi penting

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 (tampilan)tsx
// 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 App

Setelah blok ini ditempel, jalankan npm run dev. Sebelumnya build bisa gagal karena masih ada import yang belum terpakai.

Jangan ubah data asli

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.

KasusHasil yang diharapkan
Halaman pertama dibuka9 kartu menu tampil
Ketik "mie" di pencarianHanya menu yang memuat kata itu, termasuk dari nama warung
Pilih kategori MinumanHanya menu minuman yang tampil
Pencarian dan kategori tanpa hasilTampil 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:

GejalaKemungkinan penyebab
Input tidak bisa diketikvalue diisi, tetapi onChange belum memanggil props
Daftar tidak berubah saat mencariFilter belum memakai nilai search, atau props tidak diteruskan
Gambar menu kosongPath gambar harus diawali /images/ dan file ada di public/images
Style Tailwind tidak jalanimport "./index.css" hilang di main.tsx
changeQuantity is not definedHandler 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.

src/components/MenuDetailModal.tsxtsx
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 MenuDetailModal

useEffect 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.

src/components/CartPanel.tsxtsx
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 CartPanel
Kenapa total tidak disimpan

Total 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:

KasusHasil yang diharapkan
Tambah satu menu dari modalItem muncul di keranjang, jumlah di header bertambah
Klik tombol tambah di keranjangJumlah item naik, total harga ikut berubah
Kurangi jumlah sampai nolItem hilang dari keranjang
Klik hapusHanya item itu yang hilang, item lain tetap
Checkout saat keranjang kosongMuncul peringatan, keranjang tidak berubah
Buka modal lalu tekan EscapeModal 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.

Terminal: Pindah ke Hands-on 2
cd ../hands-on-2-dashboard npm install npm run dev
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.

src/components/DashboardHeader.tsxtsx
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.

src/components/OrderStats.tsxtsx

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>
  )
}
Jangan duplikasi state

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.

src/components/OrderList.tsxtsx

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.

src/components/OrderCard.tsxtsx

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.

src/pages/Dashboard.tsxtsx

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:

KasusHasil yang diharapkan
Data awalTotal 8, Menunggu 3, Diproses 2, Selesai 3
Cari "MB-012"Hanya satu pesanan tampil
Filter SelesaiTiga 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 hasilPesan 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.

KonsepInti yang perlu diingat
FrontendMenampilkan, menerima input, dan berkomunikasi dengan backend
KomponenBagian UI kecil dengan tanggung jawab jelas dan bisa dipakai ulang
ReactTampilan mengikuti data, bukan diperintah satu per satu
Props dan stateProps masuk dari induk, state milik komponen sendiri
TailwindUtility-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.

Diagram
Diagram (perbesar)
100%