Integrasi payment gateway adalah jantung dari aplikasi e-commerce yang fungsional. Tutorial ini akan memandu Anda secara mendalam untuk membangun sistem checkout yang profesional, aman, dan modern menggunakan Next.js App Router. Ini bukan sekadar tutorial dasar; kita akan membangun alur kerja yang kokoh dan siap produksi.
Setelah mengikuti panduan ini, Anda akan memiliki aplikasi dengan fitur lengkap dan teknologi terkini:
- Autentikasi Pengguna: Sistem login dan registrasi lengkap (Email, Google, GitHub) dengan paket baru
@supabase/ssr
dan@supabase/auth-ui-react
. - Katalog & Keranjang Belanja: Halaman produk dan keranjang belanja dinamis menggunakan Zustand untuk state management yang ringan dan persisten.
- Pembayaran Mulus: Integrasi Midtrans Snap untuk pengalaman checkout modern tanpa meninggalkan situs Anda.
- Webhook Aman: Penanganan notifikasi pembayaran secara aman dengan verifikasi signature, memastikan integritas data.
- Manajemen Transaksi: Penyimpanan riwayat transaksi yang detail ke database Supabase.
- Notifikasi Email: Pengiriman tanda terima otomatis kepada pelanggan menggunakan Resend dan React Email
Mari kita mulai membangun!
1. Persiapan dan Inisialisasi Proyek
Langkah pertama adalah menyiapkan fondasi proyek kita. Kita akan membuat proyek Next.js baru dan menginstal semua dependensi yang diperlukan di awal.
a. Buat Proyek Next.js
Buka terminal Anda dan jalankan perintah berikut. Anda akan dipandu melalui beberapa pertanyaan; pastikan untuk memilih TypeScript, Tailwind CSS, dan App Router.
npx create-next-app@latest nextjs-checkout
b. Instalasi Semua Dependensi
Navigasikan ke dalam direktori proyek dan instal semua pustaka yang kita butuhkan.
cd nextjs-checkout
npm install zustand midtrans-client @supabase/ssr @supabase/supabase-js @supabase/auth-ui-react @supabase/auth-ui-shared resend react-email react-hot-toast lucide-react
Penjelasan Dependensi:
zustand
: State management minimalis untuk mengelola state keranjang belanja di sisi klien.midtrans-client
: Pustaka resmi dari Midtrans untuk berinteraksi dengan API mereka dari sisi server.@supabase/ssr
&@supabase/supabase-js
: Paket baru dari Supabase untuk menangani autentikasi dan interaksi database dengan mulus di lingkungan server (Server Components, Route Handlers) dan klien (Client Components).resend
&react-email
: Kombinasi modern untuk mengirim email transaksional.react-email
memungkinkan kita membuat template email menggunakan komponen React.
c. Inisialisasi Supabase di Proyek
Jika Anda belum menginstal Supabase CLI, lakukan sekarang (npm install -g supabase
). Kemudian, hubungkan proyek lokal Anda dengan proyek Supabase di cloud.
# Login ke akun Supabase Anda (hanya perlu dilakukan sekali)
npx supabase login
# Tautkan proyek Supabase Anda (ganti <YOUR_PROJECT_ID> dengan ID dari URL dasbor Supabase Anda)
npx supabase link --project-ref <YOUR_PROJECT_ID>
# Generate definisi tipe dari skema database Anda
npx supabase gen types typescript --linked > types/supabase.ts
Penting: Perintah gen types
sangat krusial. Ini akan menginspeksi skema database Supabase Anda dan membuat file supabase.ts
yang berisi semua definisi tipe TypeScript untuk tabel, kolom, dan fungsi Anda. Ini memberikan keamanan tipe (type safety) end-to-end dari database hingga frontend.
d. Deklarasi Tipe untuk midtrans-client
Pustaka midtrans-client
tidak menyediakan tipe TypeScript bawaan. Untuk menghindari error TypeScript dan mendapatkan pengalaman pengembangan yang lebih baik, kita akan membuat file deklarasi tipe manual.
// types/midtrans-client.d.ts
// Penjelasan: File ini memberitahu TypeScript tentang bentuk objek dan kelas dari 'midtrans-client'.
// Dengan ini, kita bisa mengimpor dan menggunakan `new Snap()` dengan type safety.
declare module 'midtrans-client' {
export class Snap {
constructor(options: { isProduction: boolean; serverKey: string; clientKey: string });
createTransactionToken(parameter: object): Promise<string>;
transaction: {
notification(notification: object): Promise<any>;
};
}
}
c. Struktur Folder Proyek Akhir
Ini adalah gambaran struktur folder yang akan kita bangun untuk menjaga proyek tetap terorganisir.
/app
/about/page.tsx # Halaman tentang (opsional)
/auth/callback/route.ts # Callback saat checkout
/page.tsx # Halaman utama (daftar produk)
/products/page.tsx # Halaman semua produk
/product/[productId]/page.tsx # Halaman detail produk
/cart/page.tsx # Halaman keranjang belanja
/checkout/page.tsx # (Opsional) Halaman ringkasan sebelum bayar
/login/page.tsx # Halaman login ketika ingin checkout
/order/[orderId]/page.tsx # Halaman detail pesanan setelah checkout
/api/ # Folder untuk Route Handlers (API endpoints)
/payment/route.ts # Endpoint untuk membuat token Midtrans
/webhook/route.ts # Endpoint untuk menerima notifikasi dari Midtrans
/layout.tsx # Layout dasar aplikasi
/components
/ProductCard.tsx
/CartItem.tsx
/CheckoutButton.tsx
/AddToCartButton.tsx
/CartIndicator.tsx
/LoginForm.tsx
/emails/ReceiptEmail.tsx # Template email dengan React Email
/ui/badge.tsx
/data/products.ts # Data produk statis sebagai contoh
/public/images/ # Gambar statis sebagai contoh
/lib/
/utils.ts
/cartStore.ts # State management Zustand untuk keranjang
/email.ts # Fungsi helper untuk mengirim email
/supabase/
/client.ts # Inisialisasi Supabase untuk Client Components
/server.ts # Inisialisasi Supabase untuk Server Components
/types/
/supabase.ts # Tipe yang digenerate oleh Supabase CLI
/midtrans-client.d.ts # Tipe manual untuk midtrans-client
.env.local # File untuk menyimpan kunci rahasia
2. Konfigurasi Supabase dengan Paket SSR
Paket @supabase/ssr
menyederhanakan pengelolaan sesi autentikasi di Next.js App Router dengan menyediakan helper untuk membuat klien Supabase yang sadar akan konteks (server atau klien).
a. Konfigurasi Environment Variables
Buat file .env.local
di root proyek Anda.
# .env.local
# Supabase Credentials
NEXT_PUBLIC_SUPABASE_URL=https://xxxxxxxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyxxxxxxxx
# Midtrans Credentials (Gunakan kunci Sandbox untuk pengembangan)
MIDTRANS_SERVER_KEY=SB-Mid-server-xxxxxxxx
NEXT_PUBLIC_MIDTRANS_CLIENT_KEY=SB-Mid-client-xxxxxxxx
# Resend API Key
RESEND_API_KEY=re_xxxxxxxx
# Base URL untuk aplikasi Anda (penting untuk oAuth redirect)
NEXT_PUBLIC_SITE_URL=http://localhost:3000
Catatan Keamanan:
- Variabel dengan prefix
NEXT_PUBLIC_
dapat diakses di browser (klien). - Variabel tanpa prefix (seperti
MIDTRANS_SERVER_KEY
danRESEND_API_KEY
) hanya dapat diakses di server, menjadikannya aman.
b. Supabase Client untuk Client Components
Klien ini digunakan di dalam komponen yang memiliki direktif 'use client'
. Ia berinteraksi dengan Supabase API melalui browser.
// lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'
import type { Database } from '@/types/supabase'
// Fungsi ini membuat instance klien Supabase untuk digunakan di sisi klien (browser).
export function createClient() {
return createBrowserClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
}
c. Supabase Client untuk Server Components
Klien ini digunakan di Server Components, Server Actions, dan Route Handlers. Ia mengelola sesi autentikasi dengan membaca dan menulis cookie HTTP.
// lib/supabase/server.ts
import { createServerClient, type CookieOptions } from '@supabase/ssr'
import { cookies } from 'next/headers'
import type { Database } from '@/types/supabase'
// Fungsi ini membuat instance klien Supabase untuk digunakan di sisi server.
// Ia memerlukan akses ke cookie request untuk mengelola sesi pengguna.
export function createClient() {
const cookieStore = cookies()
return createServerClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
// Fungsi get() digunakan untuk membaca cookie dari request yang masuk.
get(name: string) {
return cookieStore.get(name)?.value
},
// Fungsi set() digunakan untuk mengatur (atau memperbarui) cookie di response.
set(name: string, value: string, options: CookieOptions) {
try {
cookieStore.set({ name, value, ...options })
} catch (error) {
// Ini terjadi jika kita mencoba mengatur cookie di Server Component
// yang sudah streaming. Ini bisa diabaikan dengan aman.
}
},
// Fungsi remove() digunakan untuk menghapus cookie.
remove(name: string, options: CookieOptions) {
try {
cookieStore.set({ name, value: '', ...options })
} catch (error) {
// Sama seperti set(), ini bisa diabaikan jika terjadi di Server Component.
}
},
},
}
)
}
d. Membuat Layout Utama yang Kokoh
File app/layout.tsx
akan menjadi kerangka utama aplikasi kita.
// app/layout.tsx
import './globals.css';
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import Script from 'next/script';
import Link from 'next/link';
import { ShoppingCartIcon } from 'lucide-react';
import { Toaster } from 'react-hot-toast';
import { CartIndicator } from '@/components/CartIndicator';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: { default: 'Toko Modern Next.js', template: '%s | Toko Modern Next.js' },
description: 'Panduan Checkout E-commerce dengan Next.js, Midtrans, & Supabase.',
};
function Header() {
return (
<header className="bg-white shadow-sm sticky top-0 z-50">
<nav className="container mx-auto px-4 py-4 flex justify-between items-center">
<Link href="/" className="text-2xl font-bold text-gray-800 hover:text-blue-600">Toko<span className="text-blue-600">Kita</span></Link>
<div className="flex items-center space-x-6">
<Link href="/products" className="text-gray-600 hover:text-blue-600 font-medium">Produk</Link>
<div className="relative">
<Link href="/cart" className="text-gray-600 hover:text-blue-600 p-2" aria-label="Keranjang Belanja"><ShoppingCartIcon size={24} /></Link>
<CartIndicator />
</div>
</div>
</nav>
</header>
);
}
function Footer() {
return (
<footer className="bg-gray-800 text-white mt-auto">
<div className="container mx-auto px-4 py-6 text-center"><p>© {new Date().getFullYear()} TokoKita.</p></div>
</footer>
);
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="id">
<body className={`${inter.className} bg-gray-50 flex flex-col min-h-screen`}>
<Toaster position="top-center" />
<Header />
<main className="flex-grow">{children}</main>
<Footer />
<Script
src="https://app.sandbox.midtrans.com/snap/snap.js"
data-client-key={process.env.NEXT_PUBLIC_MIDTRANS_CLIENT_KEY}
strategy="beforeInteractive"
/>
</body>
</html>
);
}
3. Autentikasi Pengguna dengan Supabase
Sebelum checkout, pengguna harus bisa login. Kita akan menggunakan Supabase Auth UI untuk ini.
a. Halaman Login (app/login/page.tsx
)
Ini adalah Server Component yang tugasnya hanya merender form login interaktif.
// app/login/page.tsx
import { LoginForm } from '@/components/LoginForm';
import type { Metadata } from 'next';
export const metadata: Metadata = { title: 'Login atau Daftar' };
export default function LoginPage() {
return (
<div className="flex justify-center items-center min-h-[calc(100vh-200px)] px-4">
<LoginForm />
</div>
);
}
b. Form Login Interaktif (components/LoginForm.tsx
)
Ini adalah Client Component yang berisi UI Auth dari Supabase.
// components/LoginForm.tsx
'use client';
import { createClient } from '@/lib/supabase/client';
import { Auth } from '@supabase/auth-ui-react';
import { ThemeSupa } from '@supabase/auth-ui-shared';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect } from 'react';
export function LoginForm() {
const supabase = createClient();
const router = useRouter();
const searchParams = useSearchParams();
const redirectUrl = searchParams.get('redirect');
useEffect(() => {
const { data: { subscription } } = supabase.auth.onAuthStateChange((event, session) => {
if (event === 'SIGNED_IN' && session) {
router.push(redirectUrl || '/');
router.refresh();
}
});
return () => subscription.unsubscribe();
}, [supabase, router, redirectUrl]);
return (
<div className="w-full max-w-md p-8 bg-white rounded-lg shadow-md">
<Auth
supabaseClient={supabase}
appearance={{ theme: ThemeSupa }}
providers={['google', 'github']}
redirectTo={`${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`}
localization={{ variables: { /* ...terjemahan bahasa Indonesia... */ } }}
/>
</div>
);
}
Catatan: Jangan lupa mengkonfigurasi provider (Google, GitHub) di dasbor Supabase Anda!
c. Callback Route Handler (app/auth/callback/route.ts
)
Penting untuk menangani redirect dari penyedia OAuth.
// app/auth/callback/route.ts
import { createClient } from '@/lib/supabase/server';
import { NextResponse } from 'next/server';
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url);
const code = searchParams.get('code');
const next = searchParams.get('next') ?? '/';
if (code) {
const supabase = createClient();
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (!error) return NextResponse.redirect(`${origin}${next}`);
}
return NextResponse.redirect(`${origin}/login?error=Could not authenticate user`);
}
4. Halaman Produk dan Keranjang Belanja
Sekarang kita akan membangun antarmuka pengguna untuk menampilkan produk dan mengelola keranjang belanja.
a. Data Produk
Untuk kesederhanaan, kita akan menggunakan data produk statis. Dalam aplikasi nyata, data ini akan diambil dari database.
// data/products.ts
export const products = [
{
id: 1,
name: "T-shirt Next.js",
price: 120000,
image: "/images/tshirt.png",
description: "Kaos berkualitas tinggi dengan logo Next.js, nyaman dipakai sehari-hari."
},
{ id: 2,
name: "Topi React",
price: 80000,
image: "/images/topi.png",
description: "Topi gaul yang keren dipakai untuk kegiatan sehari-hari."
},
{ id: 3,
name: "Sticker Vercel",
price: 25000,
image: "/images/sticker.png",
description: "Sticker masa kini yang merekat super kuat"
},
];
b. State Management Keranjang Belanja dengan Zustand
Zustand adalah pilihan tepat untuk state global yang simpel seperti keranjang belanja. Kita akan menggunakan middleware persist
untuk menyimpan state keranjang di localStorage
, sehingga tidak hilang saat halaman di-refresh.
// lib/cartStore.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
export type CartItem = {
id: number;
name: string;
price: number;
quantity: number;
image: string;
};
type CartState = {
items: CartItem[];
add: (item: Omit<CartItem, 'quantity'>) => void;
remove: (id: number) => void;
updateQuantity: (id: number, quantity: number) => void;
getTotal: () => number;
clear: () => void;
};
// Hook `useCartStore` akan menjadi satu-satunya cara kita berinteraksi dengan state keranjang.
export const useCartStore = create<CartState>()(
// `persist` adalah middleware yang akan menyimpan state secara otomatis.
persist(
(set, get) => ({
items: [],
// Logika untuk menambah item atau meningkatkan kuantitas jika sudah ada.
add: (item) => {
const exists = get().items.find(i => i.id === item.id);
if (exists) {
set({
items: get().items.map(i =>
i.id === item.id
? { ...i, quantity: i.quantity + 1 }
: i
)
});
} else {
set({ items: [...get().items, { ...item, quantity: 1 }] });
}
},
// Logika untuk menghapus item dari keranjang.
remove: (id) => set({ items: get().items.filter(i => i.id !== id) }),
// Logika untuk mengubah kuantitas, termasuk menghapus jika kuantitas <= 0.
updateQuantity: (id, quantity) => {
if (quantity <= 0) {
get().remove(id);
return;
}
set({
items: get().items.map(item =>
item.id === id ? { ...item, quantity } : item
)
});
},
// Menghitung total harga dari semua item di keranjang.
getTotal: () => get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),
// Mengosongkan keranjang, berguna setelah checkout berhasil.
clear: () => set({ items: [] }),
}),
{
name: 'cart-storage', // Nama kunci di localStorage.
storage: createJSONStorage(() => localStorage), // Menentukan storage yang digunakan.
}
)
);
c. Buat fungsi helper cn, formatCurrency, formatDate
// lib/utils.ts
export function cn(...inputs: any[]) {
return inputs.filter(Boolean).join(' ');
}
// Fungsi untuk memformat tanggal ke format lokal Indonesia.
export function formatDate(dateString: string | Date) {
const date = new Date(dateString);
return new Intl.DateTimeFormat('id-ID', {
dateStyle: 'long',
timeStyle: 'short'
}).format(date);
}
// Fungsi untuk memformat angka menjadi format mata uang Rupiah.
export function formatCurrency(amount: number) {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
maximumFractionDigits: 0
}).format(amount);
}
d. Halaman Katalog Produk (app/products/page.tsx
)
Server Component yang menampilkan semua produk.
// app/products/page.tsx
import { ProductCard } from '@/components/ProductCard';
import { products } from '@/data/products';
import type { Metadata } from 'next';
// Metadata spesifik untuk halaman ini
export const metadata: Metadata = {
title: 'Semua Produk',
description: 'Jelajahi semua koleksi produk eksklusif kami.',
};
// Ini adalah Server Component, sama seperti halaman utama.
// Ia mengambil data dan menyusun struktur halaman.
export default function ProductsPage() {
return (
<div className="bg-white">
<main className="container mx-auto px-4 py-12 sm:px-6 lg:px-8">
{/* Bagian Header Halaman */}
<div className="pb-10 border-b border-gray-200">
<h1 className="text-4xl font-extrabold tracking-tight text-gray-900">
Katalog Produk
</h1>
<p className="mt-4 text-base text-gray-500">
Semua yang Anda butuhkan ada di sini. Temukan produk favorit Anda dan
tambahkan ke keranjang.
</p>
</div>
{/* Grid Produk */}
<section aria-labelledby="products-heading" className="pt-12">
<h2 id="products-heading" className="sr-only">
Products
</h2>
{products.length > 0 ? (
<div className="grid grid-cols-1 gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 xl:gap-x-8">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
) : (
<div className="text-center py-20">
<h3 className="text-xl font-medium text-gray-900">
Produk Tidak Ditemukan
</h3>
<p className="mt-2 text-gray-500">
Maaf, saat ini tidak ada produk yang tersedia. Silakan cek kembali
nanti.
</p>
</div>
)}
</section>
{/* Di sini Anda bisa menambahkan paginasi di masa depan */}
</main>
</div>
);
}
Catatan: ProductCard
adalah Client Component yang menampilkan satu produk dan AddToCartButton
adalah komponen terisolasi di dalamnya untuk interaktivitas.
c. Halaman Detail Produk (app/product/[productId]/page.tsx
)
Halaman dinamis yang menampilkan detail satu produk.
// app/product/[productId]/page.tsx
import Image from 'next/image';
import { notFound } from 'next/navigation';
import { products } from '@/data/products';
import { formatCurrency } from '@/lib/utils';
import { AddToCartButton } from '@/components/AddToCartButton';
// Tipe untuk props, memastikan `params` memiliki `productId`.
type ProductPageProps = {
params: {
productId: string;
};
};
// Ini adalah Server Component. Ia menerima `params` dari URL.
export default async function ProductDetailPage({ params }: ProductPageProps) {
// 1. Mengambil ID produk dari parameter URL.
// Parameter dari URL selalu berupa string, jadi kita perlu mengubahnya menjadi angka.
const productId = parseInt(params.productId, 10);
// 2. Mencari produk yang sesuai di dalam data kita.
// Dalam aplikasi nyata, ini akan menjadi query ke database:
// const { data: product } = await supabase.from('products').select().eq('id', productId).single();
const product = products.find((p) => p.id === productId);
// 3. Menangani kasus jika produk tidak ditemukan.
// Jika tidak ada produk dengan ID tersebut, kita panggil `notFound()`
// yang akan merender halaman 404 Not Found.
if (!product) {
notFound();
}
return (
<main className="container mx-auto px-4 py-12">
<div className="grid grid-cols-1 md:grid-cols-2 gap-12">
{/* Kolom Kiri: Gambar Produk */}
<div className="relative w-full aspect-square rounded-lg overflow-hidden shadow-lg">
<Image
src={product.image}
alt={product.name}
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, 50vw"
priority // Beri prioritas pada gambar ini karena merupakan LCP (Largest Contentful Paint)
/>
</div>
{/* Kolom Kanan: Detail Produk dan Aksi */}
<div className="flex flex-col justify-center">
<h1 className="text-4xl font-bold mb-4">{product.name}</h1>
<p className="text-gray-600 mb-6 text-lg">{product.description}</p>
<p className="text-3xl font-semibold text-gray-900 mb-8">
{formatCurrency(product.price)}
</p>
{/* Tombol interaktif diisolasi dalam Client Component */}
<AddToCartButton product={product} />
</div>
</div>
</main>
);
}
d. Halaman Keranjang Belanja (app/cart/page.tsx
)
Client Component untuk menampilkan item, kuantitas, total, dan tombol checkout.
// app/cart/page.tsx
'use client';
import { useCartStore, CartItem } from "@/lib/cartStore";
import Image from 'next/image';
import { CheckoutButton } from "@/components/CheckoutButton";
import { useEffect, useState } from 'react';
import Link from "next/link";
import { formatCurrency } from "@/lib/utils";
// Komponen terpisah untuk setiap item di keranjang
function CartItemRow({ item }: { item: CartItem }) {
const { remove, updateQuantity } = useCartStore();
return (
<div className="flex items-center justify-between border-b pb-6 last:border-b-0 last:pb-0">
<div className="flex items-center space-x-4">
<div className="relative w-20 h-20 flex-shrink-0">
<Image
src={item.image}
alt={item.name}
fill
className="object-cover rounded-md"
sizes="80px"
/>
</div>
<div>
<h3 className="font-semibold text-lg">{item.name}</h3>
<p className="text-gray-600">{formatCurrency(item.price)}</p>
<button
onClick={() => remove(item.id)}
className="text-red-500 hover:text-red-700 text-sm font-medium mt-1"
>
Hapus
</button>
</div>
</div>
<div className="flex items-center space-x-4">
{/* Kontrol Kuantitas */}
<div className="flex items-center border rounded-md">
<button
onClick={() => updateQuantity(item.id, item.quantity - 1)}
className="px-3 py-1 text-gray-600 hover:bg-gray-100 rounded-l-md"
aria-label="Kurangi kuantitas"
>
-
</button>
<span className="px-4 py-1 font-medium">{item.quantity}</span>
<button
onClick={() => updateQuantity(item.id, item.quantity + 1)}
className="px-3 py-1 text-gray-600 hover:bg-gray-100 rounded-r-md"
aria-label="Tambah kuantitas"
>
+
</button>
</div>
{/* Subtotal Item */}
<p className="font-semibold min-w-[120px] text-right">
{formatCurrency(item.price * item.quantity)}
</p>
</div>
</div>
);
}
export default function CartPage() {
const { items, getTotal } = useCartStore();
const [isMounted, setIsMounted] = useState(false);
// Mengatasi hydration mismatch karena localStorage hanya ada di klien.
useEffect(() => {
setIsMounted(true);
}, []);
// Selama SSR atau sebelum client-side mount, kita bisa tampilkan skeleton UI
// untuk menghindari layout shift dan memberikan UX yang lebih baik.
if (!isMounted) {
return (
<main className="container mx-auto p-4 max-w-4xl animate-pulse">
<div className="h-8 bg-gray-200 rounded w-1/3 mb-8"></div>
<div className="bg-white rounded-lg shadow p-6">
<div className="h-6 bg-gray-200 rounded w-1/4 mb-6"></div>
<div className="space-y-6">
<div className="h-24 bg-gray-200 rounded"></div>
<div className="h-24 bg-gray-200 rounded"></div>
</div>
</div>
</main>
);
}
return (
<main className="container mx-auto px-4 py-12 max-w-5xl">
<h1 className="text-3xl font-bold mb-8 text-gray-800">Keranjang Belanja Anda</h1>
{items.length === 0 ? (
// Tampilan jika keranjang kosong
<div className="text-center py-20 bg-white rounded-lg shadow">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path vectorEffect="non-scaling-stroke" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
<h2 className="mt-2 text-xl font-medium text-gray-900">Keranjang Anda kosong</h2>
<p className="mt-1 text-gray-500">Sepertinya Anda belum menambahkan produk apapun.</p>
<div className="mt-6">
<Link href="/" className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Lanjutkan Belanja
</Link>
</div>
</div>
) : (
// Tampilan jika keranjang berisi item
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Kolom Kiri: Daftar Item Keranjang */}
<div className="lg:col-span-2">
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-xl font-semibold mb-6">Item di Keranjang ({items.length})</h2>
<div className="space-y-6">
{items.map((item) => (
<CartItemRow key={item.id} item={item} />
))}
</div>
</div>
</div>
{/* Kolom Kanan: Ringkasan Belanja */}
<div className="lg:col-span-1 h-fit sticky top-24">
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-xl font-semibold mb-4">Ringkasan Belanja</h2>
<div className="space-y-3 mb-6 border-b pb-4">
<div className="flex justify-between text-gray-600">
<span>Subtotal</span>
<span>{formatCurrency(getTotal())}</span>
</div>
<div className="flex justify-between text-gray-600">
<span>Pengiriman</span>
<span className="font-medium text-green-600">Gratis</span>
</div>
</div>
<div className="flex justify-between font-bold text-lg mb-6">
<span>Total</span>
<span>{formatCurrency(getTotal())}</span>
</div>
<CheckoutButton />
<p className="text-xs text-gray-500 mt-4 text-center">
Dengan melanjutkan, Anda menyetujui Syarat & Ketentuan kami.
</p>
</div>
</div>
</div>
)}
</main>
);
}
5. Proses Checkout dengan Midtrans
a. Tombol Checkout yang Cerdas (components/CheckoutButton.tsx
)
Tombol ini adalah pemicu utama alur checkout.
// components/CheckoutButton.tsx
'use client';
import { createClient } from '@/lib/supabase/client';
import { useCartStore } from "@/lib/cartStore";
import { useEffect, useState } from "react";
import type { User } from '@supabase/supabase-js';
import { useRouter } from 'next/navigation';
import toast from 'react-hot-toast';
import { Loader2 } from 'lucide-react'; // Menggunakan ikon dari lucide-react
import { formatCurrency } from '@/lib/utils';
declare global {
interface Window {
snap: any;
}
}
export function CheckoutButton() {
const router = useRouter();
const supabase = createClient();
const { items, getTotal, clear } = useCartStore();
const [loading, setLoading] = useState(false);
const [user, setUser] = useState<User | null>(null);
const [isFetchingUser, setIsFetchingUser] = useState(true);
useEffect(() => {
const fetchUser = async () => {
try {
const { data: { user } } = await supabase.auth.getUser();
setUser(user);
} catch (error) {
console.error("Error fetching user:", error);
} finally {
setIsFetchingUser(false);
}
};
fetchUser();
// Listener untuk perubahan status auth (login/logout)
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
setUser(session?.user ?? null);
});
return () => {
subscription.unsubscribe();
};
}, [supabase]);
const handleCheckout = async () => {
// Validasi Awal (Guard Clauses)
if (!user) {
toast.error('Silakan login untuk melanjutkan pembayaran.');
router.push('/login?redirect=/cart'); // Arahkan ke login, lalu kembali ke keranjang
return;
}
if (items.length === 0) {
toast.error("Keranjang belanja Anda kosong.");
return;
}
setLoading(true);
const loadingToastId = toast.loading('Mempersiapkan pembayaran...');
try {
const order_id = `TOKO-${Date.now()}`;
const amount = getTotal();
const customer = {
first_name: user.user_metadata?.full_name || user.email?.split('@')[0] || "Pelanggan",
email: user.email!,
phone: user.phone || "" // Lebih aman mengirim string kosong jika tidak ada
};
const response = await fetch('/api/payment', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order_id, amount, customer, items }),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || "Gagal membuat transaksi.");
}
const { token } = await response.json();
toast.dismiss(loadingToastId); // Hapus toast loading
// Penting: Reset loading state SEBELUM membuka popup Midtrans.
// Jika tidak, tombol akan tetap disabled setelah popup ditutup.
setLoading(false);
window.snap.pay(token, {
onSuccess: (result: any) => {
toast.success('Pembayaran berhasil!');
clear();
router.push(`/order/${order_id}?status=success`);
},
onPending: (result: any) => {
toast.loading('Menunggu pembayaran...');
router.push(`/order/${order_id}?status=pending`);
},
onError: (err: any) => {
toast.error('Pembayaran gagal atau dibatalkan.');
router.push(`/order/${order_id}?status=error`);
},
onClose: () => {
// Tidak perlu melakukan apa-apa, pengguna hanya menutup popup
// Status di DB masih 'pending'.
}
});
} catch (error: any) {
console.error("Checkout error:", error);
toast.error(`Terjadi kesalahan: ${error.message}`);
setLoading(false); // Pastikan loading dihentikan jika ada error
toast.dismiss(loadingToastId); // Hapus toast loading
}
};
// Tampilkan tombol "Login" jika pengguna belum login
if (!isFetchingUser && !user) {
return (
<button
onClick={() => router.push('/login?redirect=/cart')}
className="w-full bg-blue-600 text-white font-bold py-3 px-4 rounded-md hover:bg-blue-700 transition-colors"
>
Login untuk Melanjutkan
</button>
);
}
return (
<button
onClick={handleCheckout}
disabled={loading || items.length === 0 || isFetchingUser}
className="w-full bg-green-600 text-white font-bold py-3 px-4 rounded-md disabled:bg-gray-400 disabled:cursor-not-allowed hover:bg-green-700 transition-colors flex items-center justify-center"
>
{loading ? (
<>
<Loader2 className="animate-spin mr-2 h-5 w-5" />
Memproses...
</>
) : (
`Bayar Sekarang (${formatCurrency(getTotal())})`
)}
</button>
);
}
b. Endpoint Pembuatan Transaksi (app/api/payment/route.ts
)
Route Handler ini membuat transaksi di database kita dan meminta token ke Midtrans.
// app/api/payment/route.ts
import { NextResponse } from 'next/server';
import midtransClient from 'midtrans-client';
import { createClient } from '@/lib/supabase/server';
import type { CartItem } from '@/lib/cartStore';
export async function POST(req: Request) {
const supabase = createClient();
// Data dikirim dari CheckoutButton di sisi klien.
const {
order_id,
amount,
customer,
items // Detail item dari keranjang belanja
} = await req.json();
try {
// 1. Simpan transaksi ke database kita DENGAN status 'pending'.
// Ini adalah praktik yang baik untuk mencatat semua upaya transaksi,
// bahkan yang gagal atau ditinggalkan.
const { error } = await supabase
.from('transactions')
.insert({
order_id,
customer_email: customer.email,
amount,
status: 'pending',
items: items as CartItem[] // Simpan detail item sebagai JSONB
});
if (error) throw new Error(`Gagal menyimpan transaksi: ${error.message}`);
// 2. Inisialisasi Midtrans Snap client.
const snap = new midtransClient.Snap({
isProduction: false,
serverKey: process.env.MIDTRANS_SERVER_KEY!,
clientKey: process.env.NEXT_PUBLIC_MIDTRANS_CLIENT_KEY!
});
// 3. Siapkan parameter yang akan dikirim ke Midtrans.
const parameter = {
transaction_details: {
order_id: order_id,
gross_amount: amount
},
customer_details: customer,
// `item_details` penting untuk ditampilkan di halaman pembayaran Midtrans
// dan juga untuk analisis bisnis.
item_details: items.map((item: CartItem) => ({
id: item.id.toString(),
price: item.price,
quantity: item.quantity,
name: item.name
}))
};
// 4. Buat token transaksi menggunakan Midtrans Snap.
const token = await snap.createTransactionToken(parameter);
// 5. Kirim token kembali ke klien.
return NextResponse.json({ token });
} catch (error: any) {
console.error("Error creating Midtrans token:", error);
return NextResponse.json(
{ error: error.message || "Internal server error" },
{ status: 500 }
);
}
}
6. Menangani Notifikasi Pembayaran (Webhook)
a. Skema Database (transactions
)
Jalankan SQL ini di Editor SQL Supabase Anda.
CREATE TABLE transactions (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
order_id TEXT UNIQUE NOT NULL,
user_id UUID REFERENCES auth.users(id),
customer_email TEXT NOT NULL,
amount NUMERIC NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'paid', 'failed', 'fraud')),
created_at TIMESTAMPTZ DEFAULT NOW(),
payment_method TEXT,
payment_date TIMESTAMPTZ,
items JSONB NOT NULL,
payment_gateway_details JSONB
);
b. Endpoint Webhook (app/api/webhook/route.ts
)
Menerima notifikasi, memverifikasi signature, mengupdate DB, dan mengirim email.
// app/api/webhook/route.ts
import { NextResponse } from 'next/server';
import crypto from 'crypto';
import { createClient } from '@/lib/supabase/server';
import { sendPaymentEmail } from '@/lib/email';
export async function POST(request: Request) {
const supabase = createClient();
const notificationJson = await request.json();
try {
// 1. Verifikasi Signature Key untuk Keamanan.
// Ini memastikan bahwa notifikasi benar-benar datang dari Midtrans dan tidak dimanipulasi.
// Rumus hash harus sama persis dengan yang dijelaskan di dokumentasi Midtrans.
const signatureKey = crypto
.createHash('sha512')
.update(
notificationJson.order_id +
notificationJson.status_code +
notificationJson.gross_amount +
process.env.MIDTRANS_SERVER_KEY
)
.digest('hex');
if (notificationJson.signature_key !== signatureKey) {
console.error('Invalid signature:', {
received: notificationJson.signature_key,
calculated: signatureKey
});
// Jika signature tidak valid, tolak request.
return new NextResponse("Invalid signature", { status: 403 });
}
const {
order_id,
transaction_status,
fraud_status,
gross_amount,
settlement_time,
payment_type
} = notificationJson;
// 2. Terjemahkan status Midtrans ke status internal aplikasi kita.
let newStatus = 'pending';
if (transaction_status === 'capture' || transaction_status === 'settlement') {
if (fraud_status === 'accept') {
newStatus = 'paid'; // Pembayaran berhasil dan aman.
} else {
newStatus = 'fraud'; // Pembayaran dianggap penipuan.
}
} else if (['deny', 'cancel', 'expire'].includes(transaction_status)) {
newStatus = 'failed'; // Pembayaran gagal atau dibatalkan.
}
// 3. Update status transaksi di database Supabase.
// Kita juga menyimpan detail tambahan seperti metode pembayaran dan data lengkap dari webhook.
const { data: transaction, error: updateError } = await supabase
.from('transactions')
.update({
status: newStatus,
payment_method: payment_type,
payment_date: settlement_time ? new Date(settlement_time) : new Date(),
payment_gateway_details: notificationJson
})
.eq('order_id', order_id)
.select('customer_email, items, amount') // Ambil data yang diperlukan untuk email.
.single();
if (updateError) {
throw new Error(`Supabase update failed: ${updateError.message}`);
}
// 4. Kirim email notifikasi jika pembayaran berhasil.
if (newStatus === 'paid' && transaction) {
await sendPaymentEmail(
transaction.customer_email,
order_id,
transaction.amount,
transaction.items
);
}
// Beri response 200 OK ke Midtrans untuk mengonfirmasi penerimaan webhook.
return NextResponse.json({ received: true });
} catch (error: any) {
console.error("Webhook error:", error);
return NextResponse.json(
{ error: error.message || "Internal server error" },
{ status: 500 }
);
}
}
7. Notifikasi Email dan Halaman Status Pesanan
a.1. Template dan Fungsi Email (Resend
)
Siapkan template email menggunakan React Email dan fungsi untuk mengirimkannya.
// components/emails/ReceiptEmail.tsx - Template email dengan React Email
import { Html, Heading, Text, Section, Column, Row, Button } from '@react-email/components';
interface ReceiptEmailProps {
orderId: string;
amount: number;
items: Array<{ name: string; price: number; quantity: number; }>;
}
export const ReceiptEmail = ({ orderId, amount, items }: ReceiptEmailProps) => (
<Html>
<Section style={{ fontFamily: 'Arial, sans-serif', maxWidth: '600px', margin: '0 auto' }}>
<Heading as="h1" style={{ color: '#333', textAlign: 'center' }}>Pembayaran Diterima!</Heading>
<Text style={{ fontSize: '16px', marginBottom: '24px' }}>
Terima kasih telah berbelanja di toko kami. Pembayaran Anda untuk pesanan berikut telah kami konfirmasi.
</Text>
<Section style={{ backgroundColor: '#f9f9f9', padding: '20px', borderRadius: '8px' }}>
<Text style={{ fontWeight: 'bold', fontSize: '18px', marginBottom: '16px' }}>Detail Pesanan #{orderId}</Text>
{/* ... Detail Item dan Total ... */}
</Section>
<Section style={{ textAlign: 'center', marginTop: '32px' }}>
<Button
href={`https://yourdomain.com/order/${orderId}`}
style={{ backgroundColor: '#3b82f6', color: 'white', padding: '12px 24px', borderRadius: '4px', textDecoration: 'none' }}
>Lihat Detail Pesanan</Button>
</Section>
</Section>
</Html>
);
a.2. Fungsi helper untuk mengirim email dengan Resend
// lib/email.ts
import { Resend } from 'resend';
import { ReceiptEmail } from '@/components/emails/ReceiptEmail';
const resend = new Resend(process.env.RESEND_API_KEY);
export async function sendPaymentEmail(
to: string,
orderId: string,
amount: number,
items: any[]
) {
try {
// Pastikan format item sesuai dengan yang diharapkan oleh komponen email.
const emailItems = items.map(item => ({
name: item.name,
price: item.price,
quantity: item.quantity
}));
// Penting: Domain di alamat 'from' harus diverifikasi di akun Resend Anda
// untuk memastikan email terkirim dan tidak masuk spam.
const { error } = await resend.emails.send({
from: 'Toko Anda <noreply@yourdomain.com>',
to: [to],
subject: `Pembayaran untuk Pesanan #${orderId} Berhasil`,
react: ReceiptEmail({ orderId, amount, items: emailItems }),
});
if (error) {
console.error('Resend error:', error);
throw new Error('Failed to send email via Resend');
}
return true;
} catch (error) {
console.error('Failed to send payment email:', error);
return false;
}
}
b. Halaman Detail Pesanan (app/order/[orderId]/page.tsx
)
Server Component yang mengambil data transaksi dari database dan menampilkannya kepada pengguna.
// app/order/[orderId]/page.tsx
import { createClient } from '@/lib/supabase/server';
import { notFound } from 'next/navigation';
import { Badge } from '@/components/ui/badge'; // Komponen UI sederhana
import { formatDate, formatCurrency } from '@/lib/utils'; // Fungsi helper
export default async function OrderPage({
params,
searchParams,
}: {
params: { orderId: string };
searchParams: { status?: string }; // Menangkap query param dari URL
}) {
const supabase = createClient();
const { data: transaction, error } = await supabase
.from('transactions')
.select('*')
.eq('order_id', params.orderId)
.single();
if (error || !transaction) return notFound();
// Logika untuk menampilkan teks dan warna status yang sesuai.
const statusText = { /* ... */ };
const statusColor = { /* ... */ };
return (
<main className="container mx-auto p-4 max-w-4xl">
<div className="bg-white rounded-lg shadow p-6 mb-6">
<div className="flex justify-between items-start mb-6">
{/* ... Detail Header Pesanan ... */}
</div>
{/* Menampilkan pesan kontekstual berdasarkan query param 'status' dari URL.
Ini memberikan feedback langsung kepada pengguna setelah mereka kembali dari halaman pembayaran. */}
{searchParams.status === 'success' && (
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded mb-6">
<p className="font-medium">Pembayaran berhasil!</p>
<p>Terima kasih telah berbelanja di toko kami.</p>
</div>
)}
{/* ... Pesan untuk status 'pending' dan 'error' ... */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div>
<h2 className="text-lg font-semibold mb-4">Detail Produk</h2>
{/* ... Loop melalui transaction.items untuk menampilkan produk ... */}
</div>
<div>
<h2 className="text-lg font-semibold mb-4">Informasi Pembayaran</h2>
{/* ... Menampilkan detail pembayaran ... */}
</div>
</div>
</div>
</main>
);
}
8. Penutup
Selamat! Anda telah berhasil membangun alur checkout e-commerce yang lengkap, aman, dan modern di Next.js. Anda telah menguasai cara mengintegrasikan berbagai layanan penting secara efisien, mulai dari autentikasi, manajemen state, hingga pembayaran dan notifikasi.
Langkah Selanjutnya
Fondasi yang telah Anda bangun sangat kokoh. Berikut adalah beberapa ide untuk pengembangan lebih lanjut:
- Manajemen Stok Produk: Kurangi stok produk di database setelah pembayaran berhasil.
- Biaya Pengiriman: Integrasikan dengan API eksternal (misal: RajaOngkir) untuk menghitung biaya pengiriman.
- Halaman Riwayat Transaksi: Buat halaman dasbor pengguna untuk melihat semua pesanan mereka.
- Fitur Promo dan Diskon: Terapkan logika untuk kode kupon di keranjang belanja.
Untuk referensi, Anda dapat melihat kode final dari tutorial ini di repositori GitHub berikut: Source code lengkap
Jika Anda menemukan kesulitan atau memiliki pertanyaan, jangan ragu untuk bertanya di kolom komentar!