Aplikasi web untuk Drive Tech — pasar otomotif akhir pekan di Kampung Tentara, Singosari, Malang, Musim 1 dibuka Sabtu–Minggu 7–8 November 2026, lalu digelar setiap hari Minggu sampai 27 Desember 2026 (8 pekan), untuk mobil & motor baru, mobil & motor bekas, UMKM, otomotif & leasing, dan kuliner. Pengunjung dan calon tenant melihat denah interaktif yang sinkron realtime dengan database, lalu memesan lapak sendiri per tanggal: pilih satu atau beberapa tanggal weekend, dan slot yang sama bisa disewa orang berbeda di tanggal yang berbeda.
Sumber kebenaran fungsionalnya adalah dokumen rencana teknis internal
Sistem Pameran Arsitektur.md — dokumen itu tidak ikut dipublikasikan di repo ini,
tetapi seluruh keputusannya sudah dirangkum di README ini (§9 Keputusan yang Diambil dan
§10 Denah). Tata letak denah diekstrak dari gambar layout-venue-v2.jpeg (Layout v2, 2026-09-02) di root,
yang ikut disertakan.
Stack: Next.js 15 (App Router) · React 19 · TypeScript strict · Tailwind CSS v4 · Supabase (Postgres + Auth + Realtime + Storage) · zod. Tanpa dependency UI eksternal — semua ikon adalah SVG inline.
Keduanya berbagi entitas Zone → Slot → Tenant, tetapi berdiri sendiri.
Denah "/" → pilih ZONA → ketuk SLOT di peta → pilih TANGGAL (≥1 tanggal weekend) di panel slot
→ /booking/{slotId} isi data tenant → booking (pending_payment)
+ baris booking_dates per tanggal
→ /booking/{bookingId}/bayar bayar QRIS + unggah bukti → pembayaran (submitted)
→ /booking/{bookingId}/status pantau verifikasi panitia
↓
Admin /admin/bookings verifikasi → booking confirmed
tolak → pembayaran rejected, tanggal tetap terkunci
Biaya admin berlaku per tanggal (kolom zones.admin_fee): total tagihan = biaya admin
zona × jumlah tanggal yang dipilih; hanya tanggal yang masih bebas untuk slot itu yang
bisa dipilih (peta menandai slot "Tersedia" selama masih ada minimal satu tanggal kosong).
Anti double-booking dijaga database lewat unique index parsial
booking_dates_active_slot_date_idx pada pasangan (slot_id, event_date) — slot yang sama
bisa disewa orang berbeda di tanggal yang berbeda. Kolom slots.status kini berarti kondisi
slotnya sendiri: available = normal, selain itu = diblokir panitia untuk semua tanggal
(label UI "Diblokir") — bukan lagi status booking. Daftar tanggal gelaran (tabel
event_dates) dikelola panitia di /admin/pengaturan.
Denah "/" → slot tenant terisi
→ /beli/{slotId} data pembeli + cash / transfer / credit
→ (kalau credit) /beli/{transactionId}/leasing pilih mitra, DP, tenor
→ /beli/{transactionId}/status pantau status pengajuan
↓
Admin /admin/leasing update status & komisi platform
Komisi platform = (harga unit − DP) × commission_rate mitra.
| Kebutuhan | Versi | Catatan |
|---|---|---|
| Node.js | 22+ | Versi terkunci di .nvmrc (nvm use). |
| npm | 10+ | Ikut Node 22. |
| Supabase CLI | terbaru | Untuk database lokal & migrasi. |
| Docker Desktop | berjalan | Dibutuhkan supabase start. |
# 1) Dependensi
npm install
# 2) Environment
cp .env.example .env.local
# 3a) Database lokal (butuh Docker)
supabase start # menyalakan Postgres, Auth, Storage, Studio
supabase db reset # menjalankan migrasi + seed.sql dari nol
supabase status # salin anon key & service_role key ke .env.local
# 3b) ATAU pakai project Supabase cloud
supabase login
supabase link --project-ref <project-ref>
supabase db push # migrasi saja
psql "<connection-string>" -f supabase/seed.sql # seed dijalankan manual
# 4) Jalankan
npm run dev # http://localhost:3000Isi .env.local:
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321
NEXT_PUBLIC_SUPABASE_ANON_KEY=<anon key>
SUPABASE_SERVICE_ROLE_KEY=<service role key> # RAHASIA — server saja
NEXT_PUBLIC_SITE_URL=http://localhost:3000Aplikasi tetap bisa di-
next builddan dibuka tanpa env terisi. Dalam mode itu denah ditampilkan dari data cadangan (src/lib/domain/fallback.ts) dan tombol pemesanan dinonaktifkan. HelperisSupabaseConfigured()yang menentukan, bukanthrowsaat modul diimpor.
Skrip npm yang tersedia:
| Perintah | Fungsi |
|---|---|
npm run dev |
Server pengembangan. |
npm run build / npm start |
Build produksi & menjalankannya. |
npm run lint |
ESLint (config Next). |
npm run typecheck |
tsc --noEmit. |
npm run db:reset / npm run db:push |
Pintasan Supabase CLI. |
Semua entitas (booking, pembayaran, pembelian unit, pengajuan leasing) bisa disinkronkan
otomatis ke Google Sheets lewat webhook Apps Script — ditambah tombol Ekspor CSV manual
di /admin/bookings.
Cara pasang (± 3 menit, cukup sekali):
- Buka spreadsheet tujuan (spreadsheet Google Sheets mana pun milik panitia;
kalau ganti spreadsheet, pasang ulang skrip lalu perbarui
SHEETS_WEBHOOK_URL). - Menu Extensions → Apps Script, hapus isi editor bawaan, lalu tempel seluruh isi
tools/google-sheets-webhook.gs. Skrip memakaiSpreadsheetApp.getActiveSpreadsheet(), jadi wajib dipasang di dalam spreadsheet itu — bukan sebagai project Apps Script lepas. - Deploy → New deployment → Web app: Execute as = Me, Who has access = Anyone, lalu klik Deploy dan izinkan akses saat diminta.
- Salin Web app URL (berakhiran
/exec) ke.env.local:
SHEETS_WEBHOOK_URL=https://script.google.com/macros/s/…/execPerilakunya:
- Aplikasi mengirim POST JSON
{ entity, sentAt, data }setelah operasi sukses — buat booking, submit/verifikasi/tolak pembayaran, batal booking, pembelian unit, pengajuan & update leasing (lihatsrc/lib/sheets.ts). - Skrip membuat satu sheet per entitas (Bookings, Payments, Purchases,
Leasing), header otomatis dari keys data, lalu meng-upsert baris berdasarkan kolom
pertama (kode/id) dan mengisi kolom
updated_at. - Sinkronisasi bersifat fire-and-forget: bila
SHEETS_WEBHOOK_URLkosong atau webhook gagal, operasi utama tidak ikut gagal (hanya tercatatconsole.warndi server).
Tabel admin_users mengacu ke auth.users(id), jadi user Auth harus dibuat lebih dulu
dan barisnya tidak bisa ikut di-seed. Pendaftaran mandiri sengaja dimatikan.
- Buka Supabase Dashboard → Authentication → Users → Add user
(lokal:
http://127.0.0.1:54323). Isi email + password, centang Auto Confirm User. - Jalankan di SQL Editor (ganti email dan namanya):
insert into public.admin_users (id, email, full_name, role)
select u.id, u.email, 'Nama Admin', 'admin'
from auth.users u
where u.email = 'admin@example.com'
on conflict (id) do nothing;- Login di
/admin/login.
Peran yang tersedia: admin (akses penuh) dan verifikator (fokus verifikasi pembayaran).
| Rute | Isi |
|---|---|
/ |
Landing + denah interaktif ala tiket bioskop: pilih zona → ketuk slot → pilih tanggal di panel slot; legenda status & cek kode booking. |
/booking/{slotId} |
Formulir data tenant + ringkasan slot, tanggal terpilih, dan total biaya admin (per tanggal × jumlah tanggal). |
/katalog |
Katalog kendaraan publik per tanggal gelaran: unit milik booking terkonfirmasi di zona kendaraan (foto, harga, plat, lokasi slot). Filter zona + chips tanggal. |
/katalog/{listingId} |
Detail satu kendaraan: foto besar, spesifikasi, tanggal hadir, lokasi slot. Pembelian offline di lokasi. |
/booking/by-svg/{svgElementId} |
Jembatan denah statis → form: cari slot lewat svg_element_id lalu redirect ke /booking/{slotId}; id tak dikenal → 404. |
/booking/{bookingId}/bayar |
Bayar lewat QRIS panitia (gambar QRIS statis, nominal diisi pembayar) + unggah tangkapan layar bukti berhasil. Opsi cash dihapus 2026-08-28, transfer bank dihapus 2026-09-02; booking dikunci lewat pembayaran yang diverifikasi. |
/booking/{bookingId}/status |
Status booking + pembayaran, tombol batal, dan QR verifikasi panitia (memuat /admin/bookings?q=<kode> agar panitia bisa mencocokkan bukti, nominal, dan waktu kirim dengan waktu pada bukti). |
/beli/{slotId} |
Formulir pembeli unit: cash / transfer / credit. |
/beli/{transactionId}/leasing |
Pilih mitra leasing, DP, tenor, simulasi cicilan. |
/beli/{transactionId}/status |
Status pengajuan leasing. |
Catatan implementasi: Next.js hanya mengizinkan satu nama slug per posisi segmen, sehingga folder
/booking/[slotId]/bayardan/beli/[slotId]/statusmemakai nama segmen[slotId]walau nilainya adalah id booking / id transaksi. Bentuk URL-nya tetap persis seperti tabel di atas.
| Rute | Isi |
|---|---|
/admin/login |
Login email + password. |
/admin |
Dashboard: okupansi per zona untuk tanggal gelaran terdekat, pembayaran & leasing menunggu. |
/admin/slots |
Blokir / buka slot untuk semua tanggal (override panitia). |
/admin/bookings |
Verifikasi / tolak pembayaran, chip tanggal sewa, tombol Ekspor CSV. |
/admin/leasing |
Kelola mitra leasing, status pengajuan, komisi. |
/admin/tenants |
Daftar tenant beserta lapaknya. |
/admin/analitik |
Grafik okupansi (per tanggal terdekat), tren booking, leasing, metode bayar. |
/admin/pengaturan |
Kelola tanggal event (event_dates), biaya admin per zona, info event. |
| Metode | Rute | Fungsi |
|---|---|---|
GET |
/api/bookings |
Daftar slot ringkas untuk polling ketersediaan. |
POST |
/api/bookings |
Buat booking baru. |
POST |
/api/bookings/{bookingId}/payment |
Submit metode + bukti pembayaran. |
POST |
/api/purchases |
Catat transaksi pembelian unit. |
POST |
/api/purchases/{transactionId}/leasing |
Ajukan pembiayaan ke mitra leasing. |
Aksi admin (verifikasi, override slot, update leasing) tidak punya endpoint REST — semuanya memakai Server Action, sesuai rencana teknis bagian 5.
Semua endpoint mengembalikan JSON dengan bentuk seragam:
- Sukses → body data langsung, status
200/201. - Gagal →
{ "error": "pesan bahasa Indonesia", "code": "KODE", "fieldErrors": { ... } }.
Peta status HTTP (src/app/api/_lib/respond.ts):
code |
HTTP | Arti |
|---|---|---|
VALIDATION / INVALID_BODY |
400 | Body atau field tidak valid. |
NOT_FOUND |
404 | Slot / booking / transaksi tidak ada. |
SLOT_TAKEN, DATE_TAKEN, ALREADY_EXISTS, ALREADY_VERIFIED, CANCELLED |
409 | Bentrok status (slot diblokir / sebagian tanggal baru saja terisi). |
PROOF_TOO_LARGE |
413 | Bukti transfer > 2 MB. |
PROOF_TYPE |
415 | Bukti transfer bukan JPG/PNG/WEBP. |
NOT_BOOKABLE, NOT_CREDIT, INACTIVE_PARTNER |
422 | Aturan bisnis dilanggar. |
UPLOAD_FAILED |
502 | Storage Supabase menolak unggahan. |
NO_CONFIG |
503 | Env Supabase belum diisi. |
| lainnya / tak dikenal | 500 | Kesalahan internal, pesan digenerikkan. |
Query opsional: status=available|pending|confirmed, zone=<svg_group_id>,
bookable=true (hanya slot yang bisa dibooking, fasilitas dibuang).
curl -s "http://localhost:3000/api/bookings?status=available&zone=zone-umkm"{
"event": {
"id": "1f0a…",
"name": "Drive Tech",
"location": "Kota Malang",
"startDate": null,
"endDate": null
},
"eventDates": [{ "id": "5d2c…", "date": "2026-08-29" }],
"occupancy": [{ "slotId": "3b21…", "date": "2026-08-29", "status": "confirmed" }],
"total": 28,
"fetchedAt": "2026-08-26T09:15:00.000Z",
"slots": [
{
"id": "3b21…",
"slotNumber": 1,
"slotLabel": null,
"displayName": "Slot 01",
"status": "available",
"svgElementId": "slot-umkm-01",
"bookable": true,
"zone": {
"id": "9c44…",
"name": "Area UMKM",
"zoneType": "umkm",
"svgGroupId": "zone-umkm",
"adminFee": 300000
}
}
]
}Model per tanggal: slots[].status adalah kondisi slotnya sendiri (available = normal,
selain itu = diblokir panitia), sedangkan ketersediaan per tanggal dihitung dari
eventDates (tanggal gelaran aktif mendatang) + occupancy (baris view
slot_date_status). Halaman publik sendiri tidak memakai endpoint ini — ia berlangganan
Realtime tabel slots dan booking_dates. Endpoint ini disediakan untuk integrasi
eksternal (papan info, bot WhatsApp, dsb.) yang lebih mudah melakukan polling.
Sejak 2026-09-03 email wajib (kode booking dan semua notifikasi dikirim ke email),
dan bila pengiriman email aktif (SMTP/Resend terkonfigurasi, lihat isEmailConfigured())
booking juga wajib menyertakan kode verifikasi email (emailOtp, 6 digit) yang
diminta lebih dulu lewat POST /api/bookings/email-code — pengaman anti-penimbunan slot.
Tanpa transport email, emailOtp tidak diminta.
# 1) minta kode (3 kode / 10 menit per email, 10 / 10 menit per IP)
curl -s -X POST http://localhost:3000/api/bookings/email-code \
-H "Content-Type: application/json" -d '{ "email": "busri@example.com" }'
# 2) buat booking dengan kode yang diterima di email
curl -s -X POST http://localhost:3000/api/bookings \
-H "Content-Type: application/json" \
-d '{
"slotId": "3b21c9d4-0000-4000-8000-000000000001",
"eventDates": ["2026-08-29", "2026-08-30"],
"tenantName": "Warung Bu Sri",
"tenantPhone": "081234567890",
"tenantEmail": "busri@example.com",
"emailOtp": "123456",
"tenantType": "umkm",
"detail": { "kategori": "Kerajinan" },
"notes": "Butuh colokan listrik"
}'Kode salah/kedaluwarsa — 400 OTP_INVALID; kode belum disertakan padahal email aktif —
400 OTP_REQUIRED; terlalu sering minta kode — 429 RATE_LIMITED.
201 Created
{ "bookingId": "7a10c2f6-…", "bookingCode": "BK-A1B2C3" }Gagal validasi — 400
{
"error": "Data booking tidak valid.",
"code": "VALIDATION",
"fieldErrors": { "tenantPhone": "Nomor telepon tidak valid. Contoh: 081234567890 atau +6281234567890." }
}Sebagian tanggal direbut orang lain — 409
{ "error": "Sebagian tanggal yang dipilih baru saja terisi. Silakan pilih tanggal lain.", "code": "DATE_TAKEN" }eventDates wajib berisi 1–16 tanggal YYYY-MM-DD yang terdaftar aktif di event_dates
dan belum lewat; slot harus bebas di semua tanggal itu. Tagihan yang terbit =
zones.admin_fee × jumlah tanggal.
Env belum diisi — 503
{ "error": "Supabase belum dikonfigurasi. Salin .env.example ke .env.local dan isi kredensialnya.", "code": "NO_CONFIG" }Menerima dua bentuk body, dideteksi lewat header Content-Type.
JSON (bukti sudah diunggah sendiri, kirim URL-nya):
curl -s -X POST http://localhost:3000/api/bookings/7a10c2f6-…/payment \
-H "Content-Type: application/json" \
-d '{ "method": "qris", "proofUrl": "https://supabase.187.53.129.205.sslip.io/storage/v1/object/public/bukti-transfer/bukti.jpg" }'multipart/form-data (unggah berkas langsung; disimpan ke bucket bukti-transfer):
curl -s -X POST http://localhost:3000/api/bookings/7a10c2f6-…/payment \
-F "method=qris" \
-F "proof=@bukti-qris.jpg;type=image/jpeg"Metode cash (2026-08-28) dan transfer (2026-09-02) sudah tidak diterima: satu-satunya
metode adalah qris + bukti, dan booking yang tidak membayar dalam 24 jam dibatalkan
otomatis oleh expire_unpaid_bookings() (pg_cron, tiap 15 menit). Nilai enum lama tetap
ada di database untuk data historis.
200 OK
{ "bookingId": "7a10c2f6-…" }QRIS tanpa bukti — 400
{
"error": "Data pembayaran tidak valid.",
"code": "VALIDATION",
"fieldErrors": { "proofUrl": "Bukti pembayaran QRIS wajib diunggah." }
}Batas berkas: 2 MB, tipe image/jpeg, image/png, image/webp.
curl -s -X POST http://localhost:3000/api/purchases \
-H "Content-Type: application/json" \
-d '{
"slotId": "3b21c9d4-0000-4000-8000-000000000001",
"buyerName": "Budi Santoso",
"buyerPhone": "081298765432",
"paymentMethod": "credit",
"unitDescription": "Toyota Avanza 2018",
"unitPrice": 150000000,
"notes": "Minta simulasi tenor 36 bulan"
}'201 Created
{ "transactionId": "b8d1…", "transactionCode": "TX-D4E5F6", "needsLeasing": true }needsLeasing bernilai true bila paymentMethod = "credit" — lanjutkan ke endpoint
berikutnya. Slot tidak berubah statusnya: slot adalah lapak tenant, bukan unit yang dijual.
curl -s -X POST http://localhost:3000/api/purchases/b8d1…/leasing \
-H "Content-Type: application/json" \
-d '{
"leasingPartnerId": "c5f0…",
"dpAmount": 30000000,
"tenorBulan": 36,
"notes": "Dokumen menyusul"
}'201 Created
{ "leasingApplicationId": "e91b…" }Transaksi bukan kredit — 422
{ "error": "Pengajuan leasing hanya untuk pembelian dengan metode kredit.", "code": "NOT_CREDIT" }Sudah pernah diajukan (relasi 1:1) — 409
{ "error": "Transaksi ini sudah punya pengajuan leasing.", "code": "ALREADY_EXISTS" }Tenor yang diterima: 12, 18, 24, 36, 48, 60. Komisi platform dihitung otomatis dari
commission_rate mitra.
.
├── layout-venue-v2.jpeg Denah asli event (Layout v2, 2026-09-02) — sumber kebenaran tata letak
├── public/denah.svg Denah statis hasil generator (fallback & pratinjau)
├── tools/ Skrip bantu: generator SVG denah + webhook Google Sheets (.gs)
├── supabase/
│ ├── migrations/ Skema: enum, tabel, index, trigger, RLS, Realtime, Storage
│ ├── seed.sql 1 event, 9 tanggal Musim 1 (7-8 Nov, lalu tiap Minggu s.d. 27 Des 2026), 8 zona, 107 slot, 3 mitra leasing
│ └── README.md Panduan database (lokal, cloud, RLS, Storage)
└── src/
├── middleware.ts Refresh sesi Supabase di setiap request
├── app/
│ ├── page.tsx Landing + denah interaktif
│ ├── booking/ Alur sewa lapak (data tenant → bayar → status)
│ ├── beli/ Alur beli unit (pembeli → leasing → status)
│ ├── admin/ Dashboard & modul verifikasi panitia
│ └── api/ Route Handler REST + helper respons (_lib/respond.ts)
├── components/
│ ├── ui/ Kit dasar: Button, Card, Badge, Field, Alert, Stepper…
│ ├── layout/ Header & footer situs
│ ├── denah/ Denah SVG, legenda, langganan Realtime, saran slot
│ ├── forms/ Form publik (booking, pembayaran, pembelian, leasing)
│ └── admin/ Komponen khusus panel admin
└── lib/
├── domain/ Konstanta, label Indonesia, geometri denah, saran slot
├── services/ Akses database (service role) — semua kembalikan Result<T>
├── actions/ Server Action ("use server") pembungkus service
├── supabase/ Klien browser / server / admin / middleware + cek env
├── validation/ Skema zod semua input
├── types/database.ts Tipe hasil skema Postgres
├── result.ts Tipe Result<T> + helper ok() / fail()
├── utils.ts cn, formatRupiah, formatTanggal, slotDisplayName
└── image.ts Kompresi bukti transfer di sisi klien (canvas)
Menjawab tiga pertanyaan terbuka di bagian 6 rencana teknis.
Memakai Supabase Auth email/password, ditambah tabel admin_users yang mengacu
auth.users(id) dan menyimpan kolom role bertipe enum admin_role:
| Role | Maksud |
|---|---|
admin |
Akses penuh seluruh menu admin. |
verifikator |
Fokus verifikasi pembayaran biaya admin. |
Ini sengaja dipilih sebagai superset: punya user Auth saja belum cukup, harus ada
barisnya di admin_users. Kalau ternyata pembagian peran tidak dibutuhkan, cukup beri
semua orang role admin — tidak ada migrasi yang perlu dibatalkan. Sebaliknya, menambah
role baru nanti hanya perlu alter type admin_role add value.
Biaya admin disimpan satu angka per zona (kolom zones.admin_fee, numeric), bukan
per slot dan bukan tabel tarif terpisah. Panitia bisa mengubahnya lewat SQL tanpa deploy
ulang. Nilai default di supabase/seed.sql:
| Zona | zone_type |
Biaya admin |
|---|---|---|
| Tenda Dealer Mobil Baru (Area A) | mobil_baru |
Rp 1.000.000 |
| Area Pameran Mobil Bekas (Area B) | mobil_bekas |
Rp 50.000 |
| Area Pameran Motor Baru (Area C) | motor_baru |
Rp 500.000 |
| Area Pameran Motor Bekas (Area C) | mobil_motor_bekas |
Rp 25.000 |
| Tenda UMKM (Area D, kolom 1-10 & 21-30) | umkm |
Rp 250.000 |
| Tenda Otomotif & Leasing (Area D, kolom 11-20) | booth_khusus |
Rp 500.000 |
| Warung | warung |
Rp 500.000 (belum dibuka online) |
| Fasilitas Umum | facility |
Rp 0 (tidak bisa dibooking) |
Model per tanggal: tagihan booking = admin_fee × jumlah tanggal terpilih. Hasil kalinya
disalin ke admin_fee_payments.amount saat booking dibuat, sehingga perubahan tarif di
kemudian hari tidak mengubah tagihan yang sudah terbit.
Ya, perlu. Foto layar m-banking dari ponsel modern rutin 4–8 MB, terlalu besar untuk
Server Action maupun bucket. src/lib/image.ts mengompresi di browser memakai
<canvas> sebelum berkas dikirim:
- sisi terpanjang maksimum 1600 px;
- encode ulang JPEG kualitas 0.8, diturunkan bertahap sampai 0.4 bila masih besar;
- target akhir ≤ 2 MB (
MAX_PROOF_BYTES), sama denganfile_size_limitbucket; - kalau kompresi gagal (format aneh, canvas ter-taint, lingkungan tanpa DOM), berkas asli dipakai apa adanya — pengguna tidak pernah melihat error karena ini.
Server tetap memeriksa ulang ukuran dan tipe berkas; kompresi klien adalah kenyamanan, bukan pengamanan.
Bagian 4 rencana teknis meminta: kalau zona penuh, tawarkan zona lain "dengan zone_type
yang sama". Tetapi contoh yang ditulis di dokumen itu sendiri — Area Pameran Mobil penuh
→ tawarkan Area Pameran Mobil & Motor — justru melintasi zone_type
(mobil_bekas → mobil_motor_bekas), dan seed hanya membuat satu zona per tipe, jadi
mencocokkan tipe saja tidak akan pernah menghasilkan saran apa pun.
Karena itu urutan saran di src/lib/domain/suggestions.ts jadi tiga tingkat:
- slot tersedia di zona yang sama, diurut jarak nomor terdekat;
- zona lain dengan
zone_typeidentik; - zona pengganti dari
ZONE_TYPE_FALLBACK(src/lib/domain/constants.ts).
export const ZONE_TYPE_FALLBACK: Record<ZoneType, readonly ZoneType[]> = {
mobil_baru: [], // dealer resmi tidak dicampur ke area bekas
mobil_bekas: ["mobil_motor_bekas"], // contoh eksplisit di rencana teknis
mobil_motor_bekas: ["mobil_bekas"],
umkm: [], // UMKM non-kuliner ≠ warung kuliner
warung: [],
facility: [],
};Urutan di dalam array = urutan prioritas. Kosongkan array kalau sebuah tipe zona tidak boleh disarankan pindah. Hasilnya tetap daftar saran, tidak pernah auto-assign — penyewa harus mengonfirmasi sendiri.
Geometri SVG denah diekstrak dari layout-venue.jpeg di root proyek, bukan
dari deskripsi teks. Semua koordinat hidup di src/lib/domain/layout.ts dengan
viewBox "0 0 1123 1600" (portrait).
Bila gambar berbeda dari rencana teknis, gambar yang menang. Perbedaan yang tercatat:
| Hal | Gambar (dipakai) | Sistem Pameran Arsitektur.md |
Tindakan |
|---|---|---|---|
| Jumlah warung | 12 unit | "~9" | Ikut gambar: 12 baris slot di zona warung. |
| Jumlah fasilitas | 13 unit — tambahan Kantor Sekretariat & Rest Area Kostrad, Tempat Cuci Mobil & Motor, dan dari Layout v2: VIP Lounge, LED, Tenda VIP, Area Wahana, Toilet | 6 (Stage, Musholah, Zumba, Kolam Pemancingan, Lapangan Tembak, Parkiran) | Ikut gambar: 13 baris slot facility, semuanya tidak bisa dibooking. |
| Warung tanpa nama | 10 kotak bertuliskan "WARUNG" saja | tidak dibahas | Keputusan kami: diberi nomor Warung 1–10 (slot-warung-01 … slot-warung-10) supaya bisa dibooking. Dua warung bernama tetap memakai labelnya: slot-warung-warmindo (Warmindo) dan slot-warung-sate-gule (Warung Sate & Gule). |
Total: 107 kotak, 82 bisa dibooking online (10 mobil baru + 30 mobil bekas + 4 motor baru + 8 motor bekas + 20 UMKM + 10 otomotif & leasing); 12 warung dan 13 fasilitas digambar tetapi tidak disewakan online. Jumlah slot Area C mengikuti gambar Layout v2 (4 baru + 8 bekas, keputusan pemilik 2026-09-03); teks Deck v4 menyebut 3 + 14.
Sepuluh warung bernomor tetap diberi slot_label ("Warung 1" … "Warung 10") di
supabase/seed.sql, dan slotDisplayName() mendahulukan label di atas nomor — supaya
nama lapak di halaman booking sama persis dengan yang tertulis di denah. Zona bernomor
lain (slot_label NULL) tetap tampil sebagai "Slot 07".
| Dipakai untuk | Sumber | |
|---|---|---|
src/components/denah/FloorPlan.tsx |
denah interaktif di aplikasi: klik slot, warna ikut status, langganan Supabase Realtime | FLOOR_PLAN_ZONES di layout.ts |
public/denah.svg |
denah statis: cetak, PDF, embed, dan fallback <noscript> di FloorPlanBoard.tsx |
tools/generate-denah-svg.ts — mengimpor layout.ts langsung, tidak ada koordinat ganda |
Berkas statisnya berdiri sendiri (tanpa aset eksternal) dan setiap kotak punya
id yang sama dengan slots.svg_element_id plus atribut data-status, jadi bisa
diwarnai ulang dari luar aplikasi:
document.getElementById("slot-umkm-07").dataset.status = "confirmed";Setiap slot di kedua render membawa penunjuk langsung ke form bookingnya:
FloorPlan.tsx(peta React) —<g>slot yang punya baris database diberidata-slot-uuid(uuid barisslots) dandata-form-url(/booking/<uuid>), sehingga script/test/ekstensi luar bisa menyambungkan kotak denah ke form tanpa menebak URL. Slot tanpa baris database tidak diberi kedua atribut itu.public/denah.svg(denah statis) — tiap slot bookable dibungkus<a href="/booking/by-svg/<svg_element_id>">dan<g>-nya diberi atributdata-formberisi URL yang sama, jadi file SVG yang dibuka langsung (browser, embed, PDF viewer yang mendukung tautan) pun bisa diklik menuju form slot itu. Slot fasilitas & warung tidak diberi<a>.- Rute
/booking/by-svg/<svg_element_id>menerjemahkan id elemen SVG (mis.slot-umkm-07) menjadi uuid slot lewatgetSlotBySvgId()lalu redirect ke/booking/<slotId>; id yang tidak dikenal menghasilkan 404.
Id elemen adalah perekat antara gambar, kode, dan database — ketiganya harus tetap sinkron:
src/lib/domain/layout.ts— koordinat, ukuran kotak, warna aksen zona, dekor, anotasi.supabase/seed.sql— bariszones.svg_group_iddanslots.svg_element_id.tools/generate-denah-svg.ts→public/denah.svg— denah statis yang dibangun langsung darilayout.ts(jalankannpm run denahsetelah mengubah koordinat — butuh Node ≥ 22.6, skrip memakai--experimental-strip-types;npm run denah:checkmemberitahu kalau berkas hasilnya berubah dan belum di-commit).
Aturannya: slots.svg_element_id di database wajib sama persis dengan id kotak di
layout.ts (slot-<zoneSlug>-<NN>, dua digit mulai 01). Slot yang idnya tidak cocok
akan tetap tergambar, tetapi tidak akan ikut berubah warna saat statusnya berganti.
Setelah mengubah seed, jalankan supabase db reset.
Hal-hal yang perlu diketahui sebelum dipakai sungguhan.
- Operasi multi-tabel belum atomik.
createBookingmenjalankan insert tenant → insert booking → insert booking_dates → insert tagihan secara berurutan dengan kompensasi manual bila salah satu langkah gagal. Pengaman sebenarnya ada di unique indexbooking_dates_active_slot_date_idx(satu penyewa aktif per pasangan slot-tanggal). Untuk produksi, pindahkan rangkaian ini ke satu Postgres function (RPC) agar benar-benar berjalan dalam satu transaksi. Hal yang sama berlaku untukverifyPaymentdancancelBooking. - RLS hanya membuka
selectpublik untukevents,zones,slots, danleasing_partners(barisis_activesaja). Tabel lain tanpa policy sama sekali, jadi hanyaservice_roleyang bisa mengaksesnya. SUPABASE_SERVICE_ROLE_KEYwajib server-side. Kunci itu mem-bypass seluruh RLS. Jangan pernah diberi prefixNEXT_PUBLIC_, jangan di-commit, dan jangan diimpor dari komponen klien —src/lib/supabase/admin.tsserta modulservices/*menolak berjalan di browser.- Bucket
bukti-transferbersifat publik. Siapa pun yang memegang URL bisa membuka bukti transfer tanpa autentikasi (disengaja agar admin cepat memeriksa). Untuk produksi, ubah bucket jadi privat dan ganti URL publik dengan signed URL berumur pendek. - Rate limiting dua lapis (sejak 2026-09-03).
src/lib/rate-limit.ts: lapis in-memory per instance, plus pembatas bersama lintas instance lewat fungsipublic.rate_limit_hit(tabelrate_limit_events, migrasi20260903121000_rate_limit_bersama.sql). Dipakai form booking danPOST /api/bookings(5/menit per IP tiap kiriman, 20/24 jam per IP untuk kiriman valid — satu Wi-Fi lokasi = satu IP, angkanya konstanta diactions/booking.ts), pembatalan mandiri (5/jam per booking, 20/jam per IP), login admin (10/10 menit per IP, 5/10 menit per username), pembayaran (10/jam per booking), dan pembelian. Fail-open bila DB bermasalah. - Endpoint cron fail-closed.
/api/cron/*menolak 503 di produksi bilaCRON_SECRETkosong; recap mingguan juga butuhSHEETS_ACTION_KEY(=RECAP_KEYdi Apps Script, terpisah dariRESET_KEYyang hanya ada di editor Apps Script). - Belum ada CAPTCHA / verifikasi nomor telepon. Tenant dikenali dari nomor telepon saja; pembatas laju di atas menahan penimbunan massal dari satu jaringan, tetapi booking iseng memakai nomor palsu satuan belum tersaring otomatis (OTP WhatsApp adalah langkah berikutnya).
- Tidak ada kedaluwarsa booking otomatis. Booking
pending_paymentmengunci tanggal-tanggal sewanya sampai admin memverifikasi atau membatalkannya. Pertimbangkan cron (pg_cron) yang membatalkan bookingpending_paymentyang lewat N jam.
Jujur, ini yang belum ada di versi sekarang:
- Notifikasi — sejak 2026-09-03 jalur UTAMA adalah EMAIL (
src/lib/notifications.ts): kode booking, tenggat bayar, terverifikasi, ditolak, dan dibatalkan dikirim ke email penyewa (wajib diisi), memuat tautan status dan nomor bantuan WhatsApp 0822-2855-5254. Transport: SMTP generik (SMTP_HOST/PORT/USER/PASS/FROM, nodemailer) atau Resend (RESEND_API_KEY); tanpa keduanya hanya dicatat (dry-run). Verifikasi email (OTP) sebelum booking dikunci — tabelemail_verifications, kode 6 digit di-hash, berlaku 10 menit, maks 5 percobaan — hanya diwajibkan bila transport email aktif; tanpa transport, langkah kode disembunyikan di form dan dilewati server (email tetap wajib). WhatsApp (WA_PROVIDER:offbawaan,outbox,fonnte) kini opsional karena nomor kantor diblokir.WA_OVERRIDE_RECIPIENTmengalihkan SEMUA WA ke satu nomor untuk uji. Pengajuan leasing belum punya notifikasi. - Pembayaran otomatis. Belum ada payment gateway maupun rekonsiliasi mutasi bank — verifikasi transfer sepenuhnya manual oleh panitia.
- Multi-event. Skema sudah punya tabel
events, tetapi UI dan seed mengasumsikan satu event aktif (sesuai keputusan di rencana teknis). - Audit trail. Tidak ada riwayat siapa mengubah status slot/booking kapan; hanya
verified_bydanverified_atpada pembayaran. - Ekspor & laporan. Ada tombol ekspor CSV sederhana di beberapa tabel admin, tetapi belum ada laporan pendapatan, rekap komisi leasing, atau cetak invoice.
- Pengujian otomatis. Belum ada unit test maupun end-to-end test; verifikasi masih
mengandalkan
npm run typecheck,npm run lint, dan pengujian manual. - Aksesibilitas & i18n. Denah SVG sudah punya label dan fokus keyboard dasar, tetapi belum diuji dengan pembaca layar. Seluruh teks berbahasa Indonesia dan di-hardcode.
- Manajemen mitra leasing lanjutan. Belum ada portal untuk mitra memantau pengajuan; semuanya lewat panel admin panitia.