/ Case study — Booking
Mave Beauty Booking
Bookings used to live in WhatsApp threads and a notebook. Now people book themselves, pay a deposit up front, and each customer is one record instead of three.

Book Your Appointment
✦ Get Your Glow Now ✦
Pilih Studio
Mau treatment di studio yang mana?
The actual product, rebuilt in code — real palette, real flow. Try selecting a studio.
booking.mavebeauty.co.idFIG.00 — Overview- Canonical identity — phone → E.164, one record / person
- Transactional slot-hold at the edge (Turso)
- Xendit deposit · webhook confirm | release
/ What it does
Book a treatment in under a minute
Instead of going back and forth on WhatsApp, clients book themselves — pick a studio, a treatment and a time, then lock the slot with a small deposit. The studio wakes up to a full, confirmed calendar.
Choose your studio
Pick the location nearest you.
Book in under a minute
No phone tag, no waiting for a reply — clients self-serve, day or night.
Every slot is guaranteed
A small deposit locks the booking in, so no more no-shows or double-bookings.
One profile, always recognized
However a client signs up — phone, walk-in or Google — their history and deposits stay in one place.
Problem
Bookings lived in WhatsApp threads and a paper diary. Double-bookings, no-shows, and duplicate customer records were routine — the same person existed three times (walk-in, phone number, Google email), so history and deposits never lined up.
Approach
Design the booking flow around one canonical customer identity first, then layer scheduling and payments on top. Every entry point — walk-in, phone, Google — resolves to a single normalized record before a slot is ever held.
- A five-step flow across multiple studios: studio → service → time → details → deposit.
- Phone numbers normalized to a single E.164 form, so
08…,+62…, and62…collapse to one identity. - Deposits via Xendit — non-refundable DP, reschedule up to H-1 — to make no-shows costly and bookings real.
Solve identity first, and the rest of the product stops fighting itself.
Architecture
Typed end to end: Zod-validated inputs, Drizzle schema, Turso at the edge. Availability is computed server-side so two tabs can’t grab the same slot.
Client (React)
→ POST /api/book (Zod validate)
→ resolveIdentity(phone|google) ── normalizePhone → E.164
→ holdSlot(studioId, serviceId, startsAt) ── transactional, edge (Turso)
→ Xendit invoice (deposit) ── webhook → confirm | release
→ confirmation state + calendar add
Key decisions & trade-offs
- Identity before UI. Solving the duplicate-customer problem first cost a migration up front but removed a whole class of downstream bugs (history, deposits, reminders).
- Deposits over reminders. Reminders reduce no-shows; a paid deposit reduces them more. The friction is mitigated by keeping the DP small and the flow tight.
- Edge DB (Turso). Fast reads near the user; slot-holding kept transactional to stay correct under contention.
Result
A self-serve booking system with paid deposits and a single record per customer. The studio stopped reconciling duplicate profiles by hand, and every booking now carries a real commitment.
Masalah
Booking hidup di thread WhatsApp dan buku agenda kertas. Double-booking, ketidakhadiran, dan data pelanggan ganda jadi rutin — orang yang sama ada tiga kali (walk-in, nomor HP, email Google), jadi riwayat dan deposit tidak pernah sinkron.
Pendekatan
Rancang alur booking di sekitar satu identitas pelanggan yang kanonik lebih dulu, baru tumpuk penjadwalan dan pembayaran di atasnya. Tiap titik masuk — walk-in, telepon, Google — mengarah ke satu data ternormalisasi sebelum sebuah slot pernah ditahan.
- Alur lima langkah lintas beberapa studio: studio → layanan → waktu → detail → deposit.
- Nomor HP dinormalisasi ke satu bentuk E.164, jadi
08…,+62…, dan62…menyatu jadi satu identitas. - Deposit lewat Xendit — DP yang tidak bisa dikembalikan, reschedule hingga H-1 — agar ketidakhadiran berbiaya dan booking jadi nyata.
Selesaikan identitas dulu, dan sisa produknya berhenti melawan dirinya sendiri.
Arsitektur
Ber-type dari ujung ke ujung: input tervalidasi Zod, skema Drizzle, Turso di edge. Ketersediaan dihitung di sisi server jadi dua tab tidak bisa merebut slot yang sama.
Client (React)
→ POST /api/book (Zod validate)
→ resolveIdentity(phone|google) ── normalizePhone → E.164
→ holdSlot(studioId, serviceId, startsAt) ── transactional, edge (Turso)
→ Xendit invoice (deposit) ── webhook → confirm | release
→ confirmation state + calendar add
Keputusan kunci & trade-off
- Identitas sebelum UI. Menyelesaikan masalah pelanggan-ganda lebih dulu memakan biaya migrasi di depan tapi menghapus seluruh kelas bug di hilir (riwayat, deposit, pengingat).
- Deposit ketimbang pengingat. Pengingat menekan ketidakhadiran; deposit berbayar menekannya lebih jauh. Gesekannya diredam dengan menjaga DP tetap kecil dan alurnya ringkas.
- DB edge (Turso). Baca cepat dekat pengguna; penahanan slot dijaga transaksional agar tetap benar di bawah rebutan.
Hasil
Sistem booking layani-sendiri dengan deposit berbayar dan satu data per pelanggan. Studionya berhenti mencocokkan profil ganda dengan tangan, dan tiap booking kini membawa komitmen yang nyata.
/ How it’s done
Canonical identity — live
The real normalizer from the booking source, rebuilt right here. Type a phone number in any format; watch it collapse to one canonical E.164 — so one person is never three records.
Normalize first, validate second. 0811…, +62 811… and 628… collapse to one canonical E.164 — so one person is never three records. A foreign +1… is kept, never bent into +62; a bare non-ID number is refused for manual review instead of guessed.
Identity before UI
Bookings, deposits, and reminders all key off one thing — who the customer is. Solving that first, before a single screen was designed, removed a whole class of downstream bugs: duplicated history, orphaned deposits, reminders to the wrong number. The migration cost up front bought correctness everywhere after.
Booking, deposit, dan pengingat semuanya berpatokan pada satu hal — siapa pelanggannya. Menyelesaikan itu lebih dulu, sebelum satu layar pun dirancang, menghapus seluruh kelas bug di hilir: riwayat ganda, deposit yang menggantung, pengingat ke nomor yang salah. Biaya migrasi di depan membeli kebenaran di mana-mana setelahnya.
Normalize first, validate second
Raw input is never validated directly. Every number is canonicalized to E.164 first, then checked. 08…, +62…, 62… and 0062… all resolve to the same +628…; an explicit foreign +1… is parsed by its own country code and kept — never bent into Indonesia. Only the canonical form is ever stored or compared.
Input mentah tidak pernah divalidasi langsung. Tiap nomor dikanonikalisasi ke E.164 dulu, baru dicek. 08…, +62…, 62…, dan 0062… semuanya mengarah ke +628… yang sama; +1… asing yang eksplisit diurai berdasarkan kode negaranya sendiri dan dipertahankan — tidak pernah dipaksa jadi Indonesia. Hanya bentuk kanonik yang pernah disimpan atau dibandingkan.
No confident answer? Refuse.
A bare local number that doesn’t resolve to an Indonesian mobile isn’t confidently anyone — the library would happily guess a country code and call it valid. So instead of coercing, it’s rejected as ambiguous-bare and routed to manual review. Guessing an identity is worse than asking.
Nomor lokal telanjang yang tidak mengarah ke seluler Indonesia bukan siapa-siapa dengan yakin — library-nya akan dengan senang hati menebak kode negara dan menganggapnya valid. Jadi alih-alih memaksa, ia ditolak sebagai ambiguous-bare dan diarahkan ke tinjauan manual. Menebak identitas lebih buruk daripada bertanya.
Transactional slot-hold at the edge
Availability is computed server-side and the chosen slot is held inside a transaction on Turso (edge SQLite), so two tabs can’t grab the same time. The Xendit deposit is the commitment: its webhook either confirms the booking or releases the hold — a paid, non-refundable DP that makes no-shows cost something.
Ketersediaan dihitung di sisi server dan slot yang dipilih ditahan di dalam transaksi pada Turso (SQLite edge), jadi dua tab tidak bisa merebut waktu yang sama. Deposit Xendit adalah komitmennya: webhook-nya entah mengonfirmasi booking atau melepas penahanannya — DP berbayar yang tidak bisa dikembalikan, yang membuat ketidakhadiran ada harganya.