The Problem
S Sculpt is a body sculpting and facial rejuvenation salon in Kempton Park running on a physical booking book, WhatsApp confirmations, and a spreadsheet for loyalty and vouchers. The brief was an online booking system that respects real business hours and machine capacity, a promotions and treatment CMS the owner can run herself, and a loyalty/voucher system — all on a solo/freelance budget, with no backend server to host or maintain.
Two Apps, One Database, No API Layer
The system is a public booking site and a companion admin console sharing one Supabase Postgres database, deployed as a static site. Rather than building a custom API to gatekeep writes, the trust boundary lives directly in Postgres via Row-Level Security. The client-side JavaScript just makes Supabase calls; the database enforces who can do what.
Anonymous visitors get narrow, specific write access — they can create a booking, but they can only ever redeem a voucher, never un-redeem one:
-- Public can redeem an unredeemed voucher (booking.js marks it used at
-- checkout). Scoped to only flip an unredeemed voucher to redeemed —
-- it can't be used to un-redeem one or touch any other column's intent,
-- since `using` only matches rows that are still unredeemed.
create policy "public can redeem voucher"
on vouchers for update
using (is_redeemed = false)
with check (is_redeemed = true);
Everything that mutates treatments, specials, hours or bookings requires an authenticated session:
create policy "admin full access bookings"
on bookings for all
using (auth.role() = 'authenticated')
with check (auth.role() = 'authenticated');
That single pattern, repeated per table, removes an entire server tier from the architecture without weakening the security model.
Slot Availability Is Not Just “Is This Slot Taken”
The salon runs multiple machines per treatment type — three RF machines means three concurrent bookings can share the same slot, but a laser lipolysis session with one bed doesn’t get that flexibility. Availability has to account for business hours, blocked dates, and per-treatment capacity, independently per treatment:
// Only consider active bookings for THIS specific treatment.
// Bookings for other treatments are irrelevant — different machines.
const relevantBookings = treatmentId
? dayBookings.filter((b) => b.status !== "cancelled" && b.treatmentId === treatmentId)
: dayBookings.filter((b) => b.status !== "cancelled");
// Count how many existing bookings for this treatment overlap this slot.
const concurrentCount = relevantBookings.filter((b) => {
const bStart = timeToMinutes(b.time);
const bEnd = bStart + (b.durationMinutes || 60);
return start < bEnd && end > bStart;
}).length;
// Slot is available as long as we haven't hit the stations limit.
if (concurrentCount < maxConcurrent) slots.push(minutesToTime(start));
A slot only closes once every station for that specific treatment is occupied — a booking for RF sculpting never blocks a slot for facial rejuvenation.
Vouchers, Loyalty, and the Admin Console
Gift vouchers can be redeemed at checkout with partial-balance carryover if the voucher is worth more than the treatment. Loyalty runs on a simple points-per-rand model across three tiers, with a self-service balance lookup by phone number on the public site and a manual adjustment tool in the admin console for cash and walk-in payments.
The admin console itself sits behind real Supabase Auth rather than a shared
passcode, and covers full CRUD for treatments, specials and business hours,
a bookings dashboard with a per-customer history drawer, and a gift voucher
generator that renders a shareable image on a <canvas> — downloadable or
sent straight to WhatsApp.
Documentation as a Product Surface
Instead of a README, the salon owner gets two in-app help systems styled
consistently with the product itself — a customer-facing guide for the
public site and an admin operations guide — both linked contextually from a
? icon in the header and the admin sidebar.
SEO / AI-Search Readiness
A late-stage audit against current Google crawlability and AI Overviews
guidance turned up the highest-impact finding: the treatment grid renders
entirely client-side with no static fallback in the raw HTML, so any
crawler that doesn’t execute JavaScript sees an empty page. The fix list —
HealthAndBeautyBusiness JSON-LD with real NAP/geo/hours, canonical and
Open Graph tags, and either build-time pre-rendering or static fallback
markup — is prioritised rather than chasing every “AI SEO” tactic going
around.
What This Project Demonstrates
- Trust boundaries enforced at the database layer — Postgres RLS instead of an application-layer API, with policies scoped tightly enough that public write access can’t be misused
- Real-world constraint modeling — slot availability driven by business hours, blocked dates, and independent per-treatment machine capacity
- Modular vanilla JS — a public site and admin console decoupled from each
other while sharing one data layer, split into focused store modules
(
treatment-store.js,schedule-store.js,admin-store.js) - Client-side canvas image generation for shareable, on-brand vouchers without a design tool or image host
- A production SEO audit grounded in current guidance rather than cargo-culted schema
Live: s-sculpt-web.pages.dev
Next: Project 07 — Silent Auction. A fundraising platform with a bidder registration flow, a sponsor-video tombola with server-side anti-cheat, and Google Sheets standing in as the database behind a Google Apps Script API.