K

Command Palette

Search for a command to run...

Daftar

Toko Kue: Keranjang Belanja (State, Context, localStorage)

Bikin fitur keranjang belanja untuk Toko Kue-mu! Pelajari cara mengelola state keranjang, menambah/menghapus item, dan menyimpan data keranjang di localStorage agar tidak hilang. Mungkin juga pengenalan Context API untuk state global.

Isi Troli Belanjaanmu! Bikin Keranjang Belanja di Toko Kue Pake React & TypeScript

Udah bisa nampilin daftar kue dan detailnya? Keren! Sekarang, fitur paling penting di toko online apa coba? Ya, Keranjang Belanja! Pengguna harus bisa "masukin" kue yang mereka mau ke keranjang, liat apa aja isinya, dan nanti lanjut ke checkout.

Di bagian ini, kita bakal ngebangun fungsionalitas dasar keranjang belanja:

  1. Nambahin kue ke keranjang dari halaman detail produk (atau halaman daftar).
  2. Nampilin isi keranjang di halaman khusus.
  3. (Opsional dasar) Ngubah jumlah atau ngapus item dari keranjang.
  4. Yang penting, data keranjang ini gak ilang pas halaman di-refresh atau browser ditutup! Kita bakal pake localStorage buat nyimpennya.
  5. Kita juga bakal ngintip gimana React Context API bisa ngebantu nge-share state keranjang ini ke berbagai komponen tanpa perlu prop drilling yang ribet.

Ini bakal jadi bagian yang lumayan banyak ngoprek state dan logika JavaScript (dengan TypeScript tentunya!).

Langkah 1: Mendefinisikan Tipe Data untuk Item Keranjang

Selain interface Kue yang udah kita punya, kita perlu interface buat ngewakilin satu item di dalam keranjang. Item keranjang mungkin butuh properti tambahan kayak jumlah.

Di file src/types/kue.ts (atau file tipe terpisah), tambahkan:

typescript
// src/types/kue.ts (atau src/types.ts)
// ... (interface Kue yang sudah ada) ...
 
export interface ItemKeranjang extends Kue { // Mewarisi semua properti dari Kue
  jumlah: number;
}

Jadi, ItemKeranjang punya semua properti Kue plus properti jumlah.

Langkah 2: State Management untuk Keranjang - Pake useState dan useEffect di AppLayout (atau Provider Context)

Data keranjang ini idealnya jadi state global atau state yang ada di level atas aplikasi biar bisa diakses sama banyak komponen (misal, tombol "Tambah ke Keranjang" di halaman produk, ikon jumlah item di header, dan halaman keranjang itu sendiri).

Ada beberapa cara buat ngelola state global di React. Buat contoh ini, kita bisa:

  • Opsi A (Simpel dulu): Taruh state keranjang di src/app/layout.tsx (RootLayout) atau bikin komponen layout khusus (misal AppLayout.tsx) yang ngebungkus semua halaman dan ngelola state ini, terus dioper pake props. Tapi ini bisa jadi prop drilling kalau komponennya banyak.
  • Opsi B (Lebih Baik untuk State Global): Pake React Context API. Kita bikin "CartContext" buat nyimpen state keranjang dan fungsi-fungsi buat ngelolanya. Komponen mana aja yang butuh bisa "langganan" ke context ini. Ini pendekatan yang akan kita coba di sini.

Membuat CartContext

  1. Bikin folder baru src/context/.
  2. Di dalam src/context/, bikin file cart-context.tsx.

File src/context/cart-context.tsx:

tsx
// src/context/cart-context.tsx
"use client"; // Context Provider yang punya state dan useEffect biasanya butuh "use client"
 
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { Kue, ItemKeranjang } from '@/types/kue'; // Impor tipe kita
 
// Definisikan tipe untuk nilai context
interface CartContextType {
  itemDiKeranjang: ItemKeranjang[];
  tambahKeKeranjang: (kue: Kue) => void;
  hapusDariKeranjang: (idKue: string) => void;
  updateJumlah: (idKue: string, jumlahBaru: number) => void;
  kosongkanKeranjang: () => void;
  totalItem: number;
  totalHarga: number;
}
 
// Bikin Context dengan nilai default undefined (atau objek default)
// Kita kasih '!' karena kita yakin Provider akan ngasih nilai.
const CartContext = createContext<CartContextType>(null!); 
 
// Custom hook biar gampang pake context ini di komponen lain
export function useCart() {
  return useContext(CartContext);
}
 
// Komponen Provider buat ngebungkus aplikasi kita
interface CartProviderProps {
  children: ReactNode;
}
 
export function CartProvider({ children }: CartProviderProps) {
  const [itemDiKeranjang, setItemDiKeranjang] = useState<ItemKeranjang[]>(() => {
    // Ambil dari localStorage pas awal
    if (typeof window !== "undefined") { // Pastikan localStorage cuma diakses di client-side
      const savedCart = localStorage.getItem('keranjangBelanja');
      try {
        return savedCart ? JSON.parse(savedCart) : [];
      } catch (e) {
        console.error("Gagal parse keranjang dari localStorage", e);
        return [];
      }
    }
    return [];
  });
 
  // Simpen ke localStorage setiap kali itemDiKeranjang berubah
  useEffect(() => {
    if (typeof window !== "undefined") {
      localStorage.setItem('keranjangBelanja', JSON.stringify(itemDiKeranjang));
    }
  }, [itemDiKeranjang]);
 
  const tambahKeKeranjang = (kueDiterima: Kue) => {
    setItemDiKeranjang(prevItems => {
      const itemSudahAda = prevItems.find(item => item.id === kueDiterima.id);
      if (itemSudahAda) {
        // Kalau udah ada, tambah jumlahnya
        return prevItems.map(item =>
          item.id === kueDiterima.id ? { ...item, jumlah: item.jumlah + 1 } : item
        );
      } else {
        // Kalau belum ada, tambahin item baru dengan jumlah 1
        return [...prevItems, { ...kueDiterima, jumlah: 1 }];
      }
    });
  };
 
  const hapusDariKeranjang = (idKue: string) => {
    setItemDiKeranjang(prevItems => prevItems.filter(item => item.id !== idKue));
  };
 
  const updateJumlah = (idKue: string, jumlahBaru: number) => {
    if (jumlahBaru <= 0) { // Kalau jumlah baru 0 atau kurang, hapus aja itemnya
      hapusDariKeranjang(idKue);
    } else {
      setItemDiKeranjang(prevItems =>
        prevItems.map(item =>
          item.id === idKue ? { ...item, jumlah: jumlahBaru } : item
        )
      );
    }
  };
  
  const kosongkanKeranjang = () => {
    setItemDiKeranjang([]);
  };
 
  const totalItem = itemDiKeranjang.reduce((total, item) => total + item.jumlah, 0);
  const totalHarga = itemDiKeranjang.reduce((total, item) => total + (item.harga * item.jumlah), 0);
 
  const contextValue = {
    itemDiKeranjang,
    tambahKeKeranjang,
    hapusDariKeranjang,
    updateJumlah,
    kosongkanKeranjang,
    totalItem,
    totalHarga
  };
 
  return (
    <CartContext.Provider value={contextValue}>
      {children}
    </CartContext.Provider>
  );
}

Bedah cart-context.tsx:

  • "use client";: Karena Provider ini punya useState dan useEffect (yang berinteraksi dengan localStorage browser), dia harus jadi Client Component.
  • CartContextType: Definisikan "kontrak" data dan fungsi apa aja yang bakal disediain sama context ini.
  • createContext<CartContextType>(null!): Bikin context-nya. null! itu cara bilang ke TS, "Tenang, aku janji pas dipake nanti nilainya gak bakal null karena udah dibungkus Provider."
  • useCart(): Custom hook biar komponen lain tinggal manggil useCart() buat dapet semua dari context, gak perlu useContext(CartContext) lagi.
  • CartProvider: Ini komponen yang bakal ngebungkus aplikasi kita.
    • Dia punya state itemDiKeranjang (bertipe ItemKeranjang[]).
    • Dia pake useEffect buat sinkronisasi state ini sama localStorage (inget JSON.stringify dan JSON.parse!).
    • Punya fungsi-fungsi buat ngelola keranjang: tambahKeKeranjang, hapusDariKeranjang, updateJumlah, kosongkanKeranjang.
    • Ngitung totalItem dan totalHarga dari state itemDiKeranjang.
    • Semua state dan fungsi itu "dikirim" lewat value prop di <CartContext.Provider>.

Menggunakan CartProvider di Root Layout

Sekarang, kita bungkus aplikasi kita pake CartProvider ini biar semua komponen di dalamnya bisa ngakses context keranjang.

File src/app/layout.tsx (RootLayout):

tsx
// src/app/layout.tsx
import React from 'react';
import './globals.css'; // Atau App.css jika itu style globalmu
import { CartProvider } from '@/context/cart-context'; // Impor Provider kita
// import Navbar from '@/components/Navbar'; // Jika kamu punya Navbar
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="id">
      <body>
        <CartProvider> {/* Bungkus semua dengan CartProvider */}
          {/* <Navbar /> */} {/* Navbar bisa ditaruh di sini biar bisa akses info keranjang */}
          <main className="main-content-area"> {/* Kasih class buat styling main content */}
            {children}
          </main>
          {/* Footer bisa di sini juga */}
        </CartProvider>
      </body>
    </html>
  );
}

Dengan begini, semua children (termasuk page.tsx dan komponen-komponen di dalamnya) bisa pake useCart().

Langkah 3: Komponen Tombol "Tambah ke Keranjang"

Kita bikin komponen tombol ini biar bisa dipake di halaman daftar produk dan halaman detail.

File src/components/tombol-tambah-keranjang.tsx:

tsx
// src/components/tombol-tambah-keranjang.tsx
"use client"; // Karena dia manggil fungsi dari context dan berinteraksi
 
import React from 'react';
import { useCart } from '@/context/cart-context';
import { Kue } from '@/types/kue';
 
interface TombolTambahKeranjangProps {
  kue: Kue; // Nerima objek kue yang mau ditambah
}
 
export default function TombolTambahKeranjang({ kue }: TombolTambahKeranjangProps) {
  const { tambahKeKeranjang, itemDiKeranjang } = useCart();
 
  const itemSudahDiKeranjang = itemDiKeranjang.find(item => item.id === kue.id);
  const jumlahDiKeranjang = itemSudahDiKeranjang ? itemSudahDiKeranjang.jumlah : 0;
 
  return (
    <button
      onClick={() => tambahKeKeranjang(kue)}
      className="w-full bg-green-500 hover:bg-green-600 text-white font-bold py-2 px-4 rounded-lg transition-colors text-sm md:text-base"
    >
      Tambah ke Keranjang 
      {jumlahDiKeranjang > 0 && ` (${jumlahDiKeranjang})`}
    </button>
  );
}
  • Komponen ini pake useCart() buat dapet fungsi tambahKeKeranjang.
  • Dia juga bisa nampilin jumlah item itu yang udah ada di keranjang.

Gunakan di kartu-kue.tsx dan HalamanDetailKue: Sekarang, ganti tombol placeholder di kartu-kue.tsx dan src/app/kue/[idKue]/page.tsx dengan komponen ini:

tsx
// Di kartu-kue.tsx (bagian tombolnya)
// import TombolTambahKeranjang from './tombol-tambah-keranjang'; // Sesuaikan path
// ...
// <TombolTambahKeranjang kue={kue} />
 
// Di src/app/kue/[idKue]/page.tsx (bagian tombolnya)
// import TombolTambahKeranjang from '@/components/tombol-tambah-keranjang'; // Sesuaikan path
// ...
// <TombolTambahKeranjang kue={kue} />

Langkah 4: Halaman Keranjang Belanja (src/app/keranjang/page.tsx)

Bikin folder src/app/keranjang/ dan file page.tsx di dalamnya.

File src/app/keranjang/page.tsx:

tsx
// src/app/keranjang/page.tsx
"use client"; // Karena butuh akses context dan interaksi
 
import React from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { useCart } from '@/context/cart-context';
 
export default function HalamanKeranjang() {
  const { itemDiKeranjang, updateJumlah, hapusDariKeranjang, totalItem, totalHarga } = useCart();
 
  if (itemDiKeranjang.length === 0) {
    return (
      <div className="text-center py-10">
        <h1 className="text-2xl font-bold mb-4">Keranjang Belanjamu Kosong</h1>
        <p className="mb-6">Yuk, pilih kue-kue enak di toko kami!</p>
        <Link href="/" className="bg-pink-500 hover:bg-pink-600 text-white font-semibold py-3 px-6 rounded-lg">
          Mulai Belanja
        </Link>
      </div>
    );
  }
 
  return (
    <div className="container mx-auto p-4 md:p-8">
      <h1 className="text-3xl font-bold text-gray-800 mb-6">Keranjang Belanja ({totalItem} item)</h1>
      <div className="bg-white shadow-md rounded-lg p-6">
        {itemDiKeranjang.map(item => (
          <div key={item.id} className="flex items-center justify-between border-b border-gray-200 py-4">
            <div className="flex items-center">
              <div className="relative w-20 h-20 mr-4 rounded overflow-hidden">
                <Image src={item.gambarUrl} alt={item.nama} fill style={{ objectFit: 'cover' }} />
              </div>
              <div>
                <h2 className="text-lg font-semibold text-gray-700">{item.nama}</h2>
                <p className="text-sm text-gray-500">Rp {item.harga.toLocaleString('id-ID')}</p>
              </div>
            </div>
            <div className="flex items-center">
              <input 
                type="number" 
                value={item.jumlah}
                onChange={(e) => updateJumlah(item.id, parseInt(e.target.value) || 0)}
                min="0" // Bisa juga min="1" kalau mau hapus otomatis pas 0
                className="w-16 text-center border border-gray-300 rounded-md p-1 mx-2"
              />
              <button 
                onClick={() => hapusDariKeranjang(item.id)}
                className="text-red-500 hover:text-red-700 font-semibold text-sm"
              >
                Hapus
              </button>
            </div>
          </div>
        ))}
        <div className="mt-6 pt-6 border-t border-gray-300">
          <div className="flex justify-between items-center">
            <p className="text-xl font-semibold text-gray-800">Total Harga:</p>
            <p className="text-xl font-bold text-pink-600">
              Rp {totalHarga.toLocaleString('id-ID')}
            </p>
          </div>
          <Link href="/checkout" className="mt-6 w-full block text-center bg-green-500 hover:bg-green-600 text-white font-bold py-3 px-6 rounded-lg transition-colors">
            Lanjut ke Checkout
          </Link>
        </div>
      </div>
    </div>
  );
}

Bedah HalamanKeranjang:

  • Pake useCart() buat dapet semua data dan fungsi keranjang.
  • Nampilin pesan kalau keranjang kosong.
  • Kalau ada item, nge-map itemDiKeranjang buat nampilin tiap item: gambar, nama, harga, input buat ngubah jumlah (manggil updateJumlah), dan tombol hapus (manggil hapusDariKeranjang).
  • Nampilin total harga dan link ke halaman checkout.

Langkah 5: Styling Tambahan (di globals.css atau App.css)

Kamu mungkin perlu nambahin beberapa style global atau spesifik buat halaman keranjang dan komponennya biar tampilannya makin oke. Misalnya:

css
/* src/app/globals.css atau App.css */
/* ... (style yang udah ada) ... */
 
.main-content-area { /* class  buat <main> di layout.tsx */
  padding-top: 20px; /* Kasih jarak dari header/nav (kalau ada) */
  padding-bottom: 40px;
}
 
/* Bisa tambahin style lain sesuai kebutuhan */

Keranjang Belanja Toko Kue

Tes Semuanya!

Jalanin npm run dev dan coba semua fiturnya:

  1. Buka halaman utama, klik tombol "Tambah ke Keranjang" di beberapa kue.
  2. Klik tombol "Tambah ke Keranjang" lagi di kue yang sama, liat jumlahnya nambah di tombol itu.
  3. Pindah ke halaman detail produk, klik tombol "Tambah ke Keranjang".
  4. Buka halaman /keranjang. Harusnya kamu liat semua item yang udah kamu tambahin.
  5. Coba ubah jumlah item di keranjang. Coba set jadi 0, itemnya harusnya ilang.
  6. Coba hapus item dari keranjang.
  7. Refresh halaman keranjang, datanya harusnya masih ada (karena localStorage).
  8. Tutup browser, buka lagi, buka halaman keranjang. Datanya juga harusnya masih ada.

Ini dia implementasi dasar keranjang belanja! Kita udah pake useState buat state lokal (di TodoForm misalnya), useState + useEffect + localStorage buat state keranjang yang persisten di CartProvider, dan useContext (lewat custom hook useCart) buat nge-share state keranjang itu ke komponen-komponen yang butuh.

Ini adalah pola yang sangat umum di aplikasi React. Dengan Context API, kita bisa ngindarin prop drilling buat state yang dipake di banyak tempat.

Di bagian studi kasus terakhir, kita bakal bikin form checkout sederhana dan halaman "terima kasih".

Kuis Keranjang Belanja Toko Kue (Next.js+TS)

Pertanyaan 1 dari 4

Dalam studi kasus Toko Kue, mengapa React Context API (misalnya, melalui `CartProvider` dan `useCart`) digunakan untuk mengelola state keranjang belanja?