Toko Kue: Simpan Pesanan ke Database
Transaksi berhasil! Pelajari cara mendesain tabel 'Pesanan' dan 'DetailPesanan' di Supabase, lalu membuat API Route atau Server Action di Next.js untuk menyimpan informasi pesanan (item keranjang, data pengguna) ke database.
Pesanan Masuk! Nyimpen Data Checkout Toko Kue ke Database Supabase
Toko Kue kita udah punya sistem login dan halaman checkout yang lumayan. Tapi, pas pengguna ngeklik "Proses Pesanan", datanya cuma nongol di alert
dan console.log
terus keranjangnya dikosongin. Sayang banget kan, pesanan benerannya gak kesimpen di mana-mana!
Nah, di bagian ini, kita bakal bikin proses checkout jadi lebih "beneran" dengan cara nyimpen data pesanan itu ke database Supabase kita. Ini ngelibatin beberapa langkah:
- Ngedesain Tabel Database buat nyimpen info pesanan dan detail item yang dipesan.
- Bikin API Endpoint (Route Handler) atau Server Action di Next.js buat nerima data checkout dari frontend dan nyimpennya ke tabel-tabel itu pake Supabase Client (atau Prisma Client kalau kita pake itu buat interaksi DB).
Anggap aja kita mau bikin dua tabel utama:
Pesanan
: Buat nyimpen info umum soal pesanan (siapa yang pesen, kapan, total harga, status, alamat kirim).DetailPesanan
: Buat nyimpen item-item kue apa aja yang ada di dalem satu pesanan itu (karena satu pesanan bisa isi banyak jenis kue).
Langkah 1: Mendesain Tabel Pesanan
dan DetailPesanan
di Supabase
Kita bisa bikin tabel ini pake Table Editor di Supabase, atau kalau kamu pake Prisma buat ngelola skema database Supabase-mu, kamu definisiin modelnya di schema.prisma
terus prisma migrate dev
/ db push
.
Opsi A: Pake Table Editor Supabase (atau SQL Editor)
-
Buka Dashboard Supabase > Table Editor.
-
Buat tabel baru bernama
pesanan
(atauorders
). Kolom-kolomnya kira-kira:id
:uuid
, Primary Key, defaultgen_random_uuid()
.user_id
:uuid
, Foreign Key keauth.users.id
. Ini penting buat tau siapa yang pesen. Pastikan kolom ini ada dan berelasi dengan benar!nama_pemesan
:text
, NOT NULL.email_pemesan
:text
, NOT NULL.alamat_pengiriman
:text
, NOT NULL.telepon_pemesan
:text
.catatan_pesanan
:text
, NULLABLE.total_harga
:integer
(ataunumeric
), NOT NULL.status_pesanan
:text
, default'PENDING'
(misal: PENDING, DIPROSES, DIKIRIM, SELESAI, DIBATALKAN).created_at
:timestamptz
, defaultnow()
, NOT NULL.
-
Buat tabel baru bernama
detail_pesanan
(atauorder_items
). Kolom-kolomnya:id
:uuid
, Primary Key, defaultgen_random_uuid()
.pesanan_id
:uuid
, Foreign Key kepesanan.id
. Relasi penting!kue_id
:text
(atau tipe ID Kue-mu di tabelkue
), Foreign Key kekue.id
. Relasi penting!nama_kue
:text
(buat referensi cepat, meskipun bisa di-join).jumlah
:integer
, NOT NULL.harga_satuan
:integer
(harga kue pas dipesen).subtotal
:integer
(jumlah * harga_satuan).created_at
:timestamptz
, defaultnow()
, NOT NULL.
PENTING SOAL RELASI & RLS:
- Pastikan kamu ngeset Foreign Key Constraints dengan bener di Supabase Table Editor biar integritas data terjaga.
- Pikirin juga Row Level Security (RLS) buat tabel
pesanan
dandetail_pesanan
. Misalnya:- Pengguna yang login cuma boleh
INSERT
pesanan buat dirinya sendiri (user_id
harus sama denganauth.uid()
). - Pengguna yang login cuma boleh
SELECT
(baca) pesanan miliknya sendiri. - Admin mungkin punya akses lebih luas. (Bikin RLS ini agak di luar cakupan dasar, tapi penting buat diketahui).
- Pengguna yang login cuma boleh
Opsi B: Pake Prisma Schema (Kalau Kamu Pake Prisma buat Kelola DB Supabase)
Tambahin model ini ke prisma/schema.prisma
-mu, terus jalanin npx prisma db push
(atau migrate dev
):
// prisma/schema.prisma
// ... (model User dan Kue yang udah ada) ...
model Pesanan {
id String @id @default(uuid())
user_id String // Di Supabase, auth.users.id itu UUID (string)
user User @relation(fields: [user_id], references: [id]) // Asumsi model User Prisma-mu punya id String juga
nama_pemesan String
email_pemesan String
alamat_pengiriman String
telepon_pemesan String?
catatan_pesanan String?
total_harga Int
status_pesanan String @default("PENDING")
created_at DateTime @default(now())
updated_at DateTime @updated_at
detailPesanan DetailPesanan[] // Satu Pesanan punya banyak DetailPesanan
}
model DetailPesanan {
id String @id @default(uuid())
pesanan Pesanan @relation(fields: [pesananId], references: [id])
pesananId String
// Jika kamu punya model Kue di Prisma:
kue Kue @relation(fields: [kue_id], references: [id])
kue_id String // Sesuaikan tipe ID Kue-mu
// Jika tidak ada model Kue di Prisma, bisa simpan info produk langsung:
// nama_kueProduk String
// idKueProduk String
nama_kue String // Nama kue saat dipesan (bisa juga diambil dari relasi)
jumlah Int
harga_satuan Int
subtotal Int
created_at DateTime @default(now())
}
// Pastikan model User di Prisma juga punya relasi ke Pesanan
// model User {
// // ... field lain ...
// pesanan Pesanan[]
// }
- Catatan: Kalau pake Prisma, ID User dari Supabase Auth (
auth.uid()
) itu tipenyauuid
(string). Jadi, fielduser_id
di modelPesanan
Prisma-mu danid
di modelUser
Prisma-mu harusnya juga string (atauuuid
kalau databasemu ngedukung tipeuuid
langsung di Prisma dan kamu mau pake itu). Sesuaikan relasinya.
Pilih salah satu cara buat bikin tabelnya. Buat contoh ini, kita asumsikan tabelnya udah jadi di Supabase.
Langkah 2: Bikin API Route (Route Handler) buat Nyimpen Pesanan
Kita butuh API endpoint di Next.js (misal, /api/checkout
) yang bakal nerima data checkout dari frontend, terus nyimpennya ke tabel pesanan
dan detail_pesanan
di Supabase.
- Di
src/app/api/
, bikin foldercheckout
. - Di dalemnya, bikin file
route.ts
.
File src/app/api/checkout/route.ts
:
// src/app/api/checkout/route.ts
import { NextResponse, NextRequest } from 'next/server';
import { supabase } from '@/lib/supabase-client'; // Supabase client kita
import { ItemKeranjang, Kue } from '@/data/kue'; // Tipe data kita
// Tipe data buat request body dari frontend
interface CheckoutRequestBody {
nama: string;
email: string;
alamat: string;
telepon?: string;
catatan?: string;
itemDipesan: ItemKeranjang[];
total_harga: number;
}
export async function POST(request: NextRequest) {
try {
const body = await request.json() as CheckoutRequestBody;
// 1. Dapatkan session pengguna (untuk user_id)
// Di Route Handler, kita bisa akses Supabase client yang di-setup dengan service_role key
// untuk operasi yang butuh privilese lebih, atau kita bisa pake anon key dan RLS.
// Untuk membuat pesanan atas nama user, kita perlu user_id.
// Cara paling aman adalah get session dari cookie di server-side.
// Next.js menyediakan helper untuk ini, atau bisa pake Supabase SSR lib.
// Untuk contoh ini, kita asumsikan user_id bisa didapat (misalnya sudah ada di body,
// atau idealnya diambil dari sesi server-side yang aman).
// Di aplikasi nyata, JANGAN PERCAYA user_id dari body request klien tanpa validasi!
// Kita akan coba ambil dari sesi (jika ada, ini butuh setup Supabase SSR/Auth Helpers)
// Untuk sementara, kita bisa buat ini opsional atau hardcode untuk demo
const { data: { session } } = await supabase.auth.getSession();
let user_id = session?.user?.id;
if (!user_id && body.email) {
// Jika tidak ada sesi, mungkin kita bisa coba cari user berdasarkan email
// atau ini adalah guest checkout (jika didukung).
// Untuk simplicity, kita akan error jika tidak ada user.
// Atau, kita bisa buat 'user_id' di tabel 'pesanan' nullable dan handle guest checkout.
// Untuk sekarang, kita anggap user harus login.
// Jika tidak, Anda bisa mendapatkan user_id dari body.email jika user mendaftar saat checkout
// const { data: existingUser } = await supabase.from('users').select('id').eq('email', body.email).single();
// if (existingUser) user_id = existingUser.id;
// else throw new Error("User tidak ditemukan untuk email: " + body.email);
console.warn("Tidak ada sesi pengguna aktif untuk checkout. Memproses sebagai guest atau butuh login.");
// return NextResponse.json({ message: "Pengguna tidak login." }, { status: 401 });
// Untuk contoh, kita coba lanjutkan, tapi user_id akan null jika tabel memperbolehkan
}
// 2. Simpen data ke tabel 'pesanan'
const { data: dataPesanan, error: errorPesanan } = await supabase
.from('pesanan')
.insert({
// user_id: user_id, // Akan error jika user_id undefined dan kolomnya NOT NULL
nama_pemesan: body.nama,
email_pemesan: body.email,
alamat_pengiriman: body.alamat,
telepon_pemesan: body.telepon,
catatan_pesanan: body.catatan,
total_harga: body.total_harga,
status_pesanan: 'PENDING', // Status awal
// Jika user_id adalah kolom yang wajib, pastikan ia ada nilainya.
// Jika bisa null untuk guest checkout, skema DB harus mengizinkan.
...(user_id && { user_id: user_id }) // Hanya tambahkan user_id jika ada
})
.select() // Dapetin data pesanan yang baru dibuat (termasuk ID-nya)
.single(); // Kita cuma insert satu pesanan
if (errorPesanan) {
console.error("Error Supabase pas simpen pesanan:", errorPesanan);
throw errorPesanan;
}
if (!dataPesanan) {
throw new Error("Gagal membuat record pesanan utama.");
}
const idPesananBaru = dataPesanan.id;
// 3. Simpen data ke tabel 'detail_pesanan'
const detailPesananUntukInsert = body.itemDipesan.map(item => ({
pesanan_id: idPesananBaru,
kue_id: item.id, // Asumsi item.id adalah ID dari tabel 'kue'
nama_kue: item.nama,
jumlah: item.jumlah,
harga_satuan: item.harga,
subtotal: item.harga * item.jumlah,
}));
const { error: errorDetail } = await supabase
.from('detail_pesanan')
.insert(detailPesananUntukInsert);
if (errorDetail) {
console.error("Error Supabase pas simpen detail pesanan:", errorDetail);
// Idealnya, kalau detail gagal, kita rollback/hapus pesanan utama (transaksi)
// Tapi buat simpel, kita lempar error aja dulu
throw errorDetail;
}
// Kalau semua berhasil
return NextResponse.json({
message: "Pesanan berhasil dibuat!",
idPesanan: idPesananBaru
}, { status: 201 });
} catch (error: any) {
console.error("Error di API checkout:", error);
return NextResponse.json({ message: error.message || "Terjadi kesalahan pada server." }, { status: 500 });
}
}
Bedah API Route /api/checkout/route.ts
:
- Dia cuma nanganin method
POST
. CheckoutRequestBody
: Kita definisiin tipe buat data yang kita harapin dari frontend.- Ambil
user_id
: Ini bagian yang agak tricky.- Cara paling aman adalah ngambil
user_id
dari sesi server-side yang valid. Supabase nyediain helper buat ini (misal, pake@supabase/ssr
ataucreateRouteHandlerClient
kalau pake Pages Router lama). Di App Router, kamu bisa bikin Supabase client khusus buat Route Handler yang bisa akses info sesi dari cookies. - Untuk contoh dasar ini, saya menyederhanakannya dengan asumsi
user_id
bisa didapat darisupabase.auth.getSession()
. Namun, dalam skenario Route Handler yang dipanggil dari client, sesi ini mungkin tidak selalu tersedia langsung seperti di Client Component. Anda mungkin perlu setup Supabase Auth Helpers for Next.js (@supabase/auth-helpers-nextjs
) atau@supabase/ssr
untuk manajemen sesi yang robust di server-side (Route Handlers). - SANGAT PENTING: Jangan pernah percaya
user_id
yang dikirim dari body request klien tanpa validasi server-side yang ketat terhadap sesi aktif! - Pada contoh di atas, saya menambahkan
...(user_id && { user_id: user_id })
agaruser_id
hanya ditambahkan jikauser_id
ada nilainya. Skema database Anda di kolompesanan.user_id
harus memperbolehkanNULL
jika Anda ingin mendukung guest checkout atau jika sesi tidak selalu ada.
- Cara paling aman adalah ngambil
- Insert ke
pesanan
: Kita pakesupabase.from('pesanan').insert(...).select().single()
..select().single()
penting biar kita dapetidPesananBaru
yang di-generate Supabase. - Insert ke
detail_pesanan
: Kita nge-mapitemDipesan
dari body jadi array objek yang formatnya sesuai buat tabeldetail_pesanan
, terus kita insert semua sekaligus. - Error Handling: Ada blok
try...catch
buat nangkep error. Idealnya, kalau insert detail gagal, kita harusnya nge-rollback (hapus) record pesanan utama yang udah keburu kebuat. Ini namanya transaksi, yang lebih advance. Buat sekarang, kita cukup lempar error. - Response Sukses: Kirim balik pesan sukses dan ID pesanan baru dengan status
201 Created
.
Langkah 3: Modifikasi Fungsi handleSubmitCheckout
di Frontend
Sekarang, kita ubah fungsi handleSubmitCheckout
di src/app/checkout/page.tsx
biar dia manggil API endpoint /api/checkout
kita pake fetch
.
File src/app/checkout/page.tsx
(bagian handleSubmitCheckout
dan state terkait):
// src/app/checkout/page.tsx
"use client";
import React, { useState, FormEvent, useEffect } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useCart } from '@/context/cart-context';
export default function HalamanCheckout() {
const router = useRouter();
const { itemDiKeranjang, total_harga, kosongkanKeranjang, user, session, isLoadingAuth } = useCart();
const [formData, setFormData] = useState({
nama: '',
email: '',
alamat: '',
telepon: '',
catatan: ''
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
// Efek buat isi form dengan data user jika sudah login
useEffect(() => {
if (user) {
setFormData(prev => ({ ...prev, nama: user.user_metadata?.full_name || '', email: user.email || '' }));
}
}, [user]);
// (useEffect buat cek login tetap ada di sini)
useEffect(() => {
if (!isLoadingAuth && !session) {
router.push('/auth/signin?redirect=/checkout');
}
}, [user, session, isLoadingAuth, router]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const handleSubmitCheckout = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setSubmitError(null);
if (!formData.nama || !formData.email || !formData.alamat || !formData.telepon) {
setSubmitError("Mohon isi semua field yang wajib diisi (Nama, Email, Alamat, Telepon).");
return;
}
if (itemDiKeranjang.length === 0) {
setSubmitError("Keranjang Anda kosong. Tidak bisa checkout.");
return;
}
setIsSubmitting(true);
try {
const response = await fetch('/api/checkout', { // Panggil API kita
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...formData, // Data dari form
itemDipesan: itemDiKeranjang,
total_harga: total_harga,
// user_id akan coba diambil dari sesi di backend (API Route)
}),
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || 'Gagal memproses pesanan.');
}
// Kalau sukses
console.log("Pesanan berhasil:", result);
alert(`Terima kasih, ${formData.nama}! Pesanan Anda dengan ID ${result.idPesanan} sedang diproses.`);
kosongkanKeranjang();
router.push('/terima-kasih');
} catch (err: any) {
console.error("Error checkout:", err);
setSubmitError(err.message || "Terjadi kesalahan saat checkout.");
} finally {
setIsSubmitting(false);
}
};
// ... (bagian return JSX, pastikan input field value dan onChange ngacu ke formData dan handleInputChange) ...
// Contoh untuk input nama:
// <input type="text" id="nama" name="nama" value={formData.nama} onChange={handleInputChange} required ... />
// Lakukan hal yang sama untuk email, alamat, telepon, catatan
if (isLoadingAuth) {
return <p className="text-center py-10">Memeriksa status autentikasi...</p>;
}
if (!session || !user) {
return ( <div className="text-center py-10"> <p>Anda harus login untuk mengakses halaman ini.</p> <Link href="/auth/signin?redirect=/checkout" className="text-blue-600 hover:underline"> Silakan Login </Link> </div> );
}
if (itemDiKeranjang.length === 0 && !isSubmitting) { // Jangan tampilkan ini jika sedang submit
return ( <div className="text-center py-10"> <h1 className="text-2xl font-bold mb-4">Keranjang Anda Kosong</h1> <p className="mb-6">Anda tidak bisa checkout jika keranjang masih kosong.</p> <Link href="/" className="bg-pink-500 hover:bg-pink-600 text-white font-semibold py-3 px-6 rounded-lg"> Kembali Belanja </Link> </div> );
}
return (
<div className="container mx-auto p-4 md:p-8 max-w-2xl">
<h1 className="text-3xl font-bold text-gray-800 mb-2 text-center">Formulir Checkout</h1>
<p className="text-center text-sm text-gray-600 mb-6">Login sebagai: {user.email}</p>
<form onSubmit={handleSubmitCheckout} className="bg-white shadow-md rounded-lg px-8 pt-6 pb-8 mb-4">
{/* Nama */}
<div className="mb-4">
<label htmlFor="nama" className="block text-gray-700 text-sm font-bold mb-2">Nama Lengkap <span className="text-red-500">*</span></label>
<input type="text" id="nama" name="nama" value={formData.nama} onChange={handleInputChange} required
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline focus:border-blue-500" />
</div>
{/* Email */}
<div className="mb-4">
<label htmlFor="email" className="block text-gray-700 text-sm font-bold mb-2">Alamat Email <span className="text-red-500">*</span></label>
<input type="email" id="email" name="email" value={formData.email} onChange={handleInputChange} required
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline focus:border-blue-500" />
</div>
{/* Alamat */}
<div className="mb-6">
<label htmlFor="alamat" className="block text-gray-700 text-sm font-bold mb-2">Alamat Pengiriman <span className="text-red-500">*</span></label>
<textarea id="alamat" name="alamat" value={formData.alamat} onChange={handleInputChange} required rows={3}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline focus:border-blue-500"></textarea>
</div>
{/* Telepon */}
<div className="mb-4">
<label htmlFor="telepon" className="block text-gray-700 text-sm font-bold mb-2">Nomor Telepon <span className="text-red-500">*</span></label>
<input type="tel" id="telepon" name="telepon" value={formData.telepon} onChange={handleInputChange} required
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline focus:border-blue-500" />
</div>
{/* Catatan */}
<div className="mb-6">
<label htmlFor="catatan" className="block text-gray-700 text-sm font-bold mb-2">Catatan Tambahan (Opsional)</label>
<textarea id="catatan" name="catatan" value={formData.catatan} onChange={handleInputChange} rows={2}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline focus:border-blue-500"></textarea>
</div>
{/* Ringkasan Pesanan */}
<div className="mb-6 p-4 bg-gray-100 rounded">
<h3 className="font-semibold text-lg mb-2">Ringkasan Pesanan</h3>
{itemDiKeranjang.map(item => (
<div key={item.id} className="flex justify-between text-sm mb-1">
<span>{item.nama} x {item.jumlah}</span>
<span>Rp {(item.harga * item.jumlah).toLocaleString('id-ID')}</span>
</div>
))}
<hr className="my-2"/>
<div className="flex justify-between font-bold text-md">
<span>Total Bayar:</span>
<span>Rp {total_harga.toLocaleString('id-ID')}</span>
</div>
</div>
{submitError && <p className="text-red-500 text-sm mb-4">{submitError}</p>}
<div className="flex items-center justify-between">
<button type="submit"
disabled={isSubmitting || itemDiKeranjang.length === 0}
className="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-6 rounded focus:outline-none focus:shadow-outline transition-colors disabled:opacity-50">
{isSubmitting ? 'Memproses...' : 'Proses Pesanan'}
</button>
<Link href="/keranjang" className="inline-block align-baseline font-bold text-sm text-blue-500 hover:text-blue-800">
Kembali ke Keranjang
</Link>
</div>
</form>
</div>
);
}
Perubahan di HalamanCheckout.tsx
(Submit Logic):
- State form diubah jadi satu objek
formData
biar lebih gampang di-manage. handleInputChange
sekarang generik, bisa ngupdate field mana aja berdasarkan atributname
.handleSubmitCheckout
sekarang jadiasync
dan manggilfetch('/api/checkout', ...)
pake methodPOST
.- Body request di-
JSON.stringify()
dan isinyaformData
,itemDipesan
dari context, dantotal_harga
. - Ada penanganan response
ok
atau error dari API. - State
isSubmitting
dansubmitError
ditambahin buat UX yang lebih baik. - Input fields di JSX diupdate
value
danonChange
-nya biar ngacu keformData
danhandleInputChange
.
Tes Lagi!
Sekarang, kalau kamu jalanin semua ini:
- Pastikan tabel
pesanan
dandetail_pesanan
udah ada di Supabase-mu (dan RLS-nya ngizinin insert, atau sementara di-disable dulu RLS-nya buat tes). - Login ke Toko Kue.
- Masukin item ke keranjang.
- Lanjut ke Checkout.
- Isi form, klik "Proses Pesanan".
- Kalau semua bener, kamu bakal liat alert sukses, diarahkan ke halaman terima kasih, dan kalau kamu cek di Supabase Table Editor, data pesanan dan detail pesanannya udah masuk!
Luar biasa! Toko Kue Online kita sekarang udah bener-bener bisa nerima dan nyimpen pesanan! Kita udah ngeliat gimana Next.js App Router (Server Components & API Routes), React (Client Components, State, Context), TypeScript, dan Supabase (Database & Auth) bisa kerja bareng buat bikin aplikasi full-stack sederhana.
Ini adalah puncak dari studi kasus kita. Dari sini, banyak banget yang bisa kamu kembangin lagi.
Uji Pemahamanmu!
Memeriksa status login...
Sebelumnya
Toko Kue: Proteksi Halaman & Info User (Supabase Auth)
Selanjutnya
Supabase Storage (Upload File)