Toko Kue: Simpan Pesanan ke Database

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:

  1. Ngedesain Tabel Database buat nyimpen info pesanan dan detail item yang dipesan.
  2. 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)

  1. Buka Dashboard Supabase > Table Editor.

  2. Buat tabel baru bernama pesanan (atau orders). Kolom-kolomnya kira-kira:

    • id: uuid, Primary Key, default gen_random_uuid().
    • user_id: uuid, Foreign Key ke auth.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 (atau numeric), NOT NULL.
    • status_pesanan: text, default 'PENDING' (misal: PENDING, DIPROSES, DIKIRIM, SELESAI, DIBATALKAN).
    • created_at: timestamptz, default now(), NOT NULL.
  3. Buat tabel baru bernama detail_pesanan (atau order_items). Kolom-kolomnya:

    • id: uuid, Primary Key, default gen_random_uuid().
    • pesanan_id: uuid, Foreign Key ke pesanan.id. Relasi penting!
    • kue_id: text (atau tipe ID Kue-mu di tabel kue), Foreign Key ke kue.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, default now(), 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 dan detail_pesanan. Misalnya:
    • Pengguna yang login cuma boleh INSERT pesanan buat dirinya sendiri (user_id harus sama dengan auth.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).

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

prismaprisma
// 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 tipenya uuid (string). Jadi, field user_id di model Pesanan Prisma-mu dan id di model User Prisma-mu harusnya juga string (atau uuid kalau databasemu ngedukung tipe uuid 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.

  1. Di src/app/api/, bikin folder checkout.
  2. Di dalemnya, bikin file route.ts.

File src/app/api/checkout/route.ts:

typescripttypescript
// 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 atau createRouteHandlerClient 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 dari supabase.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 }) agar user_id hanya ditambahkan jika user_id ada nilainya. Skema database Anda di kolom pesanan.user_id harus memperbolehkan NULL jika Anda ingin mendukung guest checkout atau jika sesi tidak selalu ada.
  • Insert ke pesanan: Kita pake supabase.from('pesanan').insert(...).select().single(). .select().single() penting biar kita dapet idPesananBaru yang di-generate Supabase.
  • Insert ke detail_pesanan: Kita nge-map itemDipesan dari body jadi array objek yang formatnya sesuai buat tabel detail_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):

tsxtsx
// 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 atribut name.
  • handleSubmitCheckout sekarang jadi async dan manggil fetch('/api/checkout', ...) pake method POST.
  • Body request di-JSON.stringify() dan isinya formData, itemDipesan dari context, dan total_harga.
  • Ada penanganan response ok atau error dari API.
  • State isSubmitting dan submitError ditambahin buat UX yang lebih baik.
  • Input fields di JSX diupdate value dan onChange-nya biar ngacu ke formData dan handleInputChange.

Tes Lagi!

Sekarang, kalau kamu jalanin semua ini:

  1. Pastikan tabel pesanan dan detail_pesanan udah ada di Supabase-mu (dan RLS-nya ngizinin insert, atau sementara di-disable dulu RLS-nya buat tes).
  2. Login ke Toko Kue.
  3. Masukin item ke keranjang.
  4. Lanjut ke Checkout.
  5. Isi form, klik "Proses Pesanan".
  6. 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...