Skip to content

/ 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.

RoleFull-stack
Year2025
TypeBooking
Next.jsTypeScriptDrizzleTursoXendit
booking.mavebeauty.co.id Live recreation
IDEN

Book Your Appointment

✦ Get Your Glow Now ✦

1Studio
2Treatment
3Waktu
4Data
5Bayar

Pilih Studio

Mau treatment di studio yang mana?

Gading SerpongTangerang
Jakarta BaratWest JakartaNOT AVAILABLE
LANJUT

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
BookingMave Beauty Booking
5 steps
Booking flow
Xendit
Deposit capture
1 / person
Identity records
Deposits
No-show guard

/ 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.

Book an appointmentStep 1 / 5

Choose your studio

Pick the location nearest you.

Gading SerpongJakarta Barat
  • 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…, and 62… 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.

/ 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.

+628118100141one identity

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.

01

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.

02

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.

03

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.

04

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.