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:
- Nambahin kue ke keranjang dari halaman detail produk (atau halaman daftar).
- Nampilin isi keranjang di halaman khusus.
- (Opsional dasar) Ngubah jumlah atau ngapus item dari keranjang.
- Yang penting, data keranjang ini gak ilang pas halaman di-refresh atau browser ditutup! Kita bakal pake
localStorage
buat nyimpennya. - 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:
// 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 (misalAppLayout.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
- Bikin folder baru
src/context/
. - Di dalam
src/context/
, bikin filecart-context.tsx
.
File src/context/cart-context.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 punyauseState
danuseEffect
(yang berinteraksi denganlocalStorage
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 bakalnull
karena udah dibungkus Provider."useCart()
: Custom hook biar komponen lain tinggal manggiluseCart()
buat dapet semua dari context, gak perluuseContext(CartContext)
lagi.CartProvider
: Ini komponen yang bakal ngebungkus aplikasi kita.- Dia punya state
itemDiKeranjang
(bertipeItemKeranjang[]
). - Dia pake
useEffect
buat sinkronisasi state ini samalocalStorage
(ingetJSON.stringify
danJSON.parse
!). - Punya fungsi-fungsi buat ngelola keranjang:
tambahKeKeranjang
,hapusDariKeranjang
,updateJumlah
,kosongkanKeranjang
. - Ngitung
totalItem
dantotalHarga
dari stateitemDiKeranjang
. - Semua state dan fungsi itu "dikirim" lewat
value
prop di<CartContext.Provider>
.
- Dia punya state
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):
// 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
:
// 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 fungsitambahKeKeranjang
. - 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:
// 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
:
// 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 (manggilupdateJumlah
), dan tombol hapus (manggilhapusDariKeranjang
). - 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:
/* 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 */
Tes Semuanya!
Jalanin npm run dev
dan coba semua fiturnya:
- Buka halaman utama, klik tombol "Tambah ke Keranjang" di beberapa kue.
- Klik tombol "Tambah ke Keranjang" lagi di kue yang sama, liat jumlahnya nambah di tombol itu.
- Pindah ke halaman detail produk, klik tombol "Tambah ke Keranjang".
- Buka halaman
/keranjang
. Harusnya kamu liat semua item yang udah kamu tambahin. - Coba ubah jumlah item di keranjang. Coba set jadi 0, itemnya harusnya ilang.
- Coba hapus item dari keranjang.
- Refresh halaman keranjang, datanya harusnya masih ada (karena
localStorage
). - 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?