Portfolio / System Ops / snap

Six branches, one booking flow.
Zero double-bookings.

SNAP is a self-photo studio chain across six Indonesian cities. The old site was a static brochure — bookings happened in WhatsApp DMs, every branch ran its own notebook, and roughly one in twelve sessions ended in a clash at the door. We rebuilt the customer flow as a PWA and shipped a single admin dashboard that sees all six branches at once.

ClientSNAP Self-Photo Studio
ScopeBooking PWA · QRIS · Admin · Reports
Scale6 branches · 2 studio types · 30+ backdrops
Timeline8 weeks · 2026 · in rollout
What we built

A booking system that knows which branch you're standing in.

A mobile-first PWA on the customer side, a desktop SPA on the admin side, one API and one database underneath. Six modules carry the whole chain — from the city splash a customer sees on Instagram to the daily payout a branch manager confirms at close.

Module 01

Branch & city gates

Six cities, twelve branches, two physical studio archetypes. Customer flow auto-skips the branch step in single-studio cities and routes the right capacity model based on the venue.

Geo-routedMulti-tenant
Module 02

Two booking models

Box studios run a backdrop×time matrix — each room books independently. Roll-down studios run one shared schedule with a pure-aesthetic backdrop pick. Same UI shell, two different capacity rules underneath.

MatrixSingle-slot
Module 03

QRIS auto-confirm

Customer scans a dynamic QRIS code, the merchant callback releases the slot, the customer sees a checkmark. No staff watching m-banking. No manual "is it paid yet?" message.

QRISWebhook
Module 04

Admin overview

One dashboard for the founder. Today's bookings across six branches, sales vs. yesterday, expenses by category, occupancy by room. Drill from any tile into the raw rows.

Live KPIsDrill-down
Module 05

Branch ops console

Branch managers see only their branch — today's calendar, walk-in slot toggle, expense logger, end-of-day cash count. Role-based, locked by default.

RBACDaily close
Module 06

WhatsApp confirmations

Booking confirmed → templated WhatsApp goes out with the slot, branch address and a calendar link. Reminder fires 2 hours before. The DM thread is no longer where bookings live, but it's still where customers expect to hear back.

TemplatesReminders
Six branches · grayscale becomes colour on hover

One product line, one identity, six rooms.

Snap / Kemang
Snap / Senopati
Snap / Bandung
Snap / Surabaya
Snap / Bali
Snap / Yogya
● The problem

Bookings lived in a WhatsApp inbox.

Every branch had its own admin number. Customers messaged "tomorrow 3pm Kemang?" and someone replied if they were awake. Two staff sometimes confirmed the same slot to two different customers because the inbox was the only source of truth.

On busy weekends roughly one session in twelve ended in a clash at the door — one party got bumped, refunded, and angry on Instagram.

"We were running a calendar inside a chat app. It worked until two messages arrived in the same minute." — founder, SNAP
S
Snap Kemang
last seen 11 min ago
Hi! Any slot tomorrow at 3pm?10:42
Yes, 50% deposit please10:44
Hey, tomorrow 3pm bumping to 4 people10:46
Hi, I'd like to book 3pm tomorrow too, still open?10:47
One sec, let me check10:51
Hello? Already transferred11:08
??? hello?11:24
Double-booked
● The fix

The slot is a database row, not a message.

Customer picks a city, a branch, a date, a time and a backdrop — each step writes a hold on a real row. Two phones tapping the same slot at the same second: one wins, the other sees "just taken" and is offered the next free 30-minute block.

Confirmation goes out as WhatsApp afterwards, so customers still hear back where they expect. The DM is the receipt now — not the booking.

9:41 ●●●● ◐ ▮▮▮
Snap / Kemang
Sat 14 Feb · single studio
Step 4/6
Pick a time · 30 min 8 free
10:00
10:30
11:00
11:30
12:00
12:30
13:00
13:30
14:00
14:30
15:00
15:30
Pick a backdrop · aesthetic only
Rp 120,00013:30 · Blue
Continue →
● Second problem

"Backdrops" meant two different things.

Half the branches are box studios — each backdrop sits in its own room, so two backdrops means two parallel sessions. The other half are roll-down studios — multiple backdrops hung in one room, but only one session can run at a time.

The legacy site treated them identically. Customers booked four backdrops at the same time at a roll-down branch and showed up to find they couldn't actually all happen.

Kemang · Sat
13:00 — Adi (4 pax)
13:30 — Sarah ✓
Senopati · Sat
Box A — 1pm Tia
Box B — 1pm Rai
Box C — empty
Bandung
payment? — DP 50
cash 70 — sisanya?
Surabaya
walk-in 2 pax
cust ngantri ←
Bali · Sun
refund 1 booking
double again 😩
Yogya
expense ice 25k
listrik 280k
cleaning 60k
● The fix

The capacity model lives on the slot, not the backdrop.

Each branch is tagged multi or single in the database. Box studios get a backdrop×time grid — pick a cell and you've picked a room and a time in one move. Roll-down studios get one shared schedule and a separate backdrop pick that's purely aesthetic.

Same React shell, same components, different data shape from one endpoint. The customer never has to know the difference.

9:41 ●●●● ◐ ▮▮▮
Snap / Senopati
Sat 14 Feb · box · 4 rooms
Multi
Pick a room & time 9 free
·
12
12:30
13
13:30
14
A
×
×
B
×
C
×
×
D
×

○ free    × taken    ● picked

Room B · 13:00Senopati · 30 min
Continue →
● Third problem

Someone had to watch m-banking.

Customer transfers, screenshots the proof, sends it on WhatsApp. Branch admin opens the m-banking app, hunts for the matching amount, replies "ok confirmed" and writes the slot in the notebook. Five minutes per booking on a quiet day, much longer on a Saturday.

If the admin was on lunch, the customer waited. If the customer waited too long, they cancelled.

9:41 ●●●● ◐ ▮▮▮
Pay with QRIS
Booking #SNP-2614
Step 6/6
Rp 120,000
Snap Kemang · 13:30
14:32 left

Confirms automatically.
No screenshot needed.

Waiting for paymentauto-detect via QRIS callback
Live
Before & after

What a Saturday morning used to look like.

For one branch admin on a Saturday between opening and noon — the busiest stretch. Same person, same job, before and after the rebuild.

Before Sat · 4h chat-juggling
  • 09:00openOpen WhatsApp Web. 17 unread chats from overnight DMs.
  • 09:20+20 minCross-check the notebook against last night's bookings. Two slots conflict.
  • 10:05+1 hrCustomer messages "I transferred". Open m-banking. Hunt the amount. Reply "ok".
  • 10:48+1h 48Two phones, two customers, both want 11:30. Pick one. Refund the other.
  • 11:30+2h 30Walk-in arrives. Notebook says room is free. Phone says room is booked. Argument.
  • 12:55+3h 55Lunch postponed again.
~4 hr
After Sat · 35 min admin
  • 09:00openOpen the dashboard. Today's calendar already populated overnight.
  • 09:05+5 minSkim the WhatsApp template thread for outliers. None.
  • 10:00+1 hrWalk-in arrives. Tap "open walk-in slot". Customer pays QRIS at the counter. Slot logged.
  • 11:30+2h 30Glance at the room occupancy widget. All four rooms running. Nothing to do.
  • 12:00+3 hrLunch.
~35 min · ~14% of before
How it came together

Eight weeks, three rollouts.

01

Weeks 1–2 — map the chaos

Sat with two branch admins for a full Saturday each. Watched the notebook get filled, the chats get juggled, the m-banking app get refreshed. Wrote down every step. Cut anything that didn't have to exist if a database existed.

02

Weeks 3–6 — build the customer flow

React + Vite PWA, Express + MySQL on the back. Two booking models behind one endpoint shape so the UI didn't fork. QRIS merchant integration with webhook fallback so a flaky network never strands a customer mid-payment.

03

Weeks 7–8 — admin + soft launch

Admin SPA: overview, bookings calendar, expenses, sales, reports, settings. Rolled out one branch first (Kemang), watched it for a week, then onboarded the rest two at a time. Notebooks retired branch by branch.

Outcome · early rollout numbers

First branch on the system for ~6 weeks.

Numbers below are from the live database for the pilot branch (Kemang) plus the two branches that came online after. Chain-wide figures get published here only once there are enough months of real data behind them.

0
Door clashes since launch
Pilot branch · 6 weeks live · previously ~1 in 12 weekend sessions ended in a conflict.
~85%
Admin time reclaimed
Branch admin's pre-noon Saturday window dropped from ~4 hr to ~35 min — pilot branch only, so far.
< 6sec
Median QRIS confirm
From scan to "paid" callback. Was 3–5 min while a human watched m-banking.
3 / 6
Branches onboarded
Kemang, Senopati, Bandung. Three more rolling on a one-per-fortnight cadence.
Under the hood

Boring tech, on purpose.

Six branches across six cities, a founder who has to be able to read the dashboard on a phone in line at a coffee shop. Every decision answered: what happens when the wi-fi at one branch drops mid-Saturday-rush?

Customer client

React PWA, mobile-first

Installable from the homescreen. Page transitions via Framer Motion. CSS Modules and a strict black-and-white palette — the photos do the colour, the chrome stays out of the way.

React 18 · Vite · Framer Motion · CSS Modules
Backend

Express + MySQL, one tenant

One database, branches as rows, capacity model as an enum. Slot writes go through a single endpoint that locks the row to keep two simultaneous bookings from winning the same cell.

Node 20 · Express · MySQL 8 · row-level locks
Payments

QRIS dynamic + webhook

Per-booking dynamic QRIS code with a 15-minute TTL. Settlement webhook releases the slot; if the webhook misfires, a poller reconciles within a minute. Either path, the customer sees the same checkmark.

QRIS dynamic · webhook · poll fallback
Admin

Same React app, different shell

No separate admin codebase. Founder login lands in the desktop sidebar layout; branch login lands in a single-branch view. RBAC at the API, not the UI — clicking around can never grant you something the server hasn't already approved.

RBAC · server-checked routes · desktop SPA
Next → GhostKitchen

Booking inbox
out of control?

Free scope-out call. We'll tell you the cheapest thing that fixes it.

Builds like this run on the Core Platform + custom modules — most booking systems land between $2,500 and $4,500. Full pricing →