Full design · Business
Salon and spa booking
A complete booking app for a hair salon, day spa or beauty studio.
Live preview. Click around, it works.
About this template
A complete booking app for a hair salon, day spa or beauty studio. Clients browse the menu, pick one or more services, choose a team member or anyone, and book from a real slot engine that respects opening hours, rosters, breaks, clean up time and existing bookings, with guest checkout and an optional deposit. Clients can look up, reschedule or cancel inside your cancellation policy. The owner side has a staff by time calendar for today, a bookings list with status changes, client history and notes, weekly and monthly revenue with a chart, and settings for hours, rosters, services and policy.
What the agents are asked
The first message sent when you use this template. You can edit the plan before anything is built.
Adapt this salon and spa booking app to my business. Keep the client side (Home, Services, Book, My bookings) and the owner side (Today, Bookings, Clients, Revenue, Settings), and keep the slot engine, the guest checkout and the cancellation window working. Change the brand, colours, address, opening hours, team, rosters and service menu with prices to match my business, and replace the sample reviews with real ones only when I supply them. Where I want real accounts, real payments or emailed confirmations, wire in authentication, the database in schema.sql, Stripe Checkout and an email service as the README describes, never putting a secret key in the browser.
A booking app for a hair salon, day spa, beauty or nail studio. Clients see the menu, pick one or more services, choose a team member or anyone, and book from a slot engine that respects opening hours, closure days, each person's roster and break, clean up time between services, lead time, the booking window and every existing booking. Checkout is a guest checkout with an optional or required deposit. Clients can find, reschedule or cancel their booking with their email and booking code, inside the salon's cancellation window. The team side runs the day on a staff by time calendar, works a filterable bookings list, keeps client history and notes, and the owner sees revenue and edits hours, rosters, services and policy.
"Ochre Room" is an invented business. The name, team, clients, address, phone numbers and prices are made up for the template (phone numbers come from the ranges ACMA sets aside for fiction). Rename everything freely.
Files
| File | What it is |
|---|---|
index.html | Shell: demo ribbon with the Viewing as switch, top bar, main, footer, phone tab bar, one <dialog> for sheets, toast region. One <script type="module">. |
styles.css | Tokens first (light, dark by prefers-color-scheme, and a manual override on <html data-theme>), then components. Every colour is a token, and every photograph frame has a token fallback colour. |
script.js | One plain ES module, no dependencies. Sections: helpers, seed data, store (the data layer), slot engine, client screens, team screens, settings, router, events. |
schema.sql | Postgres tables, row level security, grants, the guest functions, seed rows and a VERIFY and rehearsal block. |
template.json | Catalogue entry. |
media.json | The photographs and the hero loop, with the prompts they were generated from (see Art direction). |
There is no build step. Serve the folder with any static server.
Roles and the Viewing as switch
The ribbon at the top says Demo and carries Viewing as: Customer, Staff, Owner.
| Role | Sees |
|---|---|
| Customer | Home, Services, Book, My bookings. |
| Staff | Today, Bookings, Clients. Can change statuses, move bookings, add clients and keep notes. |
| Owner | Everything Staff sees, plus Revenue and Settings. |
Opening a team page as a customer, or an owner page as staff, shows a page explaining who it is for, not an error. In the live app the switch is replaced by sign in (see "Auth and roles" below).
Screens
| Route | Screen |
|---|---|
#/ | Home. A hero with the room photograph (a five second loop where motion is allowed), open or closed right now and the calls to book; the menu as five category photographs linking to Services; the most booked services beside a sticky photograph; the team as portraits with "Book with" links; the product shelf band; three reviews each tagged Sample on an ochre field; the week's hours with today marked, closure days, a drawn map, address, phone and email. |
#/services | Services and prices. Category chips (?cat= opens one category), each category beside its own photograph (sticky on a wide screen, alternating sides), every active service with description, duration and price in AUD, a Book button per service, and the booking policy. |
#/book | Book, five steps with a stepper. 1 Services: pick one or more; if no single person offers them all the flow says so. 2 Who: Anyone available or a person, each with their next free time; people who do not offer the chosen services are named. 3 When: a strip of days in the booking window with how many times each has (Closed or Full when none), times grouped Morning, Afternoon, Evening, and with Anyone each time shows who it goes to. When a day is empty it offers the next free time. 4 Details: name, email, optional mobile and note, validated inline. 5 Confirm: summary, deposit choice (by the owner's setting), policy acknowledgement, and the checkout panel that says "Demo checkout. Connect Stripe in project settings." Then a confirmation with the booking code. ?service=, ?staff= preselect. |
#/my | My bookings. Find a booking by email and code. Shows status, time, person, services, total and deposit, then Reschedule (same person or anyone, same slot engine, the booking itself ignored) or Cancel (asks in the page first). Inside the cancellation window both are replaced by a message with the phone number. Bookings made on this device are listed for one tap reopening, and a "Fill in a sample booking" link helps in the demo. |
#/owner | Today. Date navigation, four figures (appointments, booked value, arrived and done, share of rostered chair time booked), then a calendar with a column per rostered person and a row per time: breaks and time off the roster are hatched, clean up time shows under each appointment, a line marks the current time. Each appointment opens a sheet with status buttons, details, client notes and Move, which offers new times from the slot engine. A day list below repeats the day with a status control per row. ?date=YYYY-MM-DD opens any day. |
#/owner/bookings | Bookings. Search (name, code, service), When (today, next 7 days, upcoming, past 30 days, all), Status and Team member filters. Grouped by day with count and value. Each row has a status control (Confirmed, Arrived, Completed, No show, Cancelled) with Undo. Arrived, Completed and No show cannot be set before the day; reinstating a cancelled booking checks its time is still free. |
#/owner/clients | Clients. Search and sort (last visit, name, spend, visits). Visits, spend, no shows, last and next visit. Add client. |
#/owner/clients/:id | Client. Contact, who they usually see, figures, notes the team can edit, full visit history, and Book for this client. |
#/owner/revenue | Revenue (owner only). Week or Month, previous and next. Completed revenue with the change against the same point of the previous period, booked ahead, deposits taken, average spend, no shows. A per day column chart (completed and booked ahead, with legend, hover and keyboard tooltips, and a table view), revenue by category and by team member. |
#/owner/settings | Settings (owner only), five tabs. Hours: each weekday open or closed with times, and closure dates. Team and rosters: name, role, what they offer, bio, a weekly roster with break, and Taking bookings. Services: add, edit, archive, feature, duration, clean up time, price. Booking policy: online change window, lead time, start time interval, booking window, deposit off, optional or required and its percentage, policy wording, business details, with a preview of what clients read. Sample data: remove or restore. Hours and roster changes warn when upcoming bookings now fall outside them. |
On a phone the bottom tab bar holds the role's screens; at 860 px and wider it moves to the top bar. The calendar scrolls sideways inside its own frame on a phone, with the time column pinned.
Art direction
Quiet, image led and plain spoken: sharp close ups on plain grounds, calm rooms, and booking always one tap away.
Palette. Three solid colours, each a token at the top of styles.css: Plaster #E6E2DC (the ground), Soot #1A1714 (text, buttons and dark bands) and Ochre #C07A1E (colour fields behind close ups, the reviews band, the Book tab and the dock's button). Text in ochre on plaster uses a deeper #8A5210 for contrast. Dark swaps the ground to Soot and the ink to Plaster; Ochre stays. There are no gradients.
Type. Tenor Sans for display (one weight; hierarchy comes from size, tight tracking on large sizes and a wide tracked uppercase wordmark) and Hanken Grotesk for text and numbers (tabular figures for times and prices). Scale about 1.33, body line height 1.6.
Layout. Photographs are square cornered; controls use a 2 to 4 px radius and statuses stay pills. Home opens on an asymmetric hero: the photograph fills the right eight columns and the headline sits on a plaster panel that overlaps its edge. Then the menu as one tall tile and four smaller ones (two set on plain ochre and soot grounds), the most booked list beside a sticky photograph, the team with staggered portraits, a full bleed shelf band, reviews on an ochre field and hours with a drawn map. Services sets each category beside its photograph, alternating sides. Book shows a close up on step 1 and the chosen service's photograph in the summary, with portraits in the Who step. My bookings and the confirmation pair the form with a photograph. Owner and staff screens keep their dense layouts and get the same palette and type, with portrait thumbnails on the calendar and in Settings. On a wide screen a small dock shows the next free time with a Book button on every client screen except Book; it steps aside while scrolling down. On a phone the Book tab is the ochre one.
Motion. One job each, all behind prefers-reduced-motion:
- The orchestrated moment, on the first load of Home: the hero lifts like a blind (a
clip-path: inset()wipe from the bottom with a slow settle from 1.1 scale), then the two headline lines rise in, then the copy, then the dock. - Other screens: a short staggered entrance on first load, once.
- Scroll: text blocks rise in with
animation-timeline: view()inside@supports; without it an IntersectionObserver adds a class at threshold 0.2. - Photographs: a clip-path reveal with a slight scale, once, driven by the observer.
- Sticky photograph columns on Home, Services, My bookings and the confirmation.
- Screen changes:
document.startViewTransitioncross fade where supported (skipped when the tab is hidden). - Hovers 180 ms, eased, no bounce.
- The hero loop plays only when motion is allowed, only while on screen and only while the tab is visible. With reduced motion it is not rendered at all and the photograph shows.
Photographs. Generated from media.json in one shoot: late morning window light from the left, neutral warm 4500K, a 50mm feel at f/2.8, fine grain, warm grey plaster grounds, natural skin with no airbrushing. Served from public/template-media/salon-booking/ and referenced root relative as /template-media/salon-booking/<name>; project creation makes those paths absolute. Every frame has a fallback colour so the layout holds while it loads.
| File | Size | Where it is used |
|---|---|---|
room.webp | 1600 x 893 | Home hero (poster and fallback for the loop), Hair category, the confirmation screen |
room-loop.mp4 | 1280 x 716, 5 s, H.264, muted | Home hero loop, decorative (aria-hidden); the photo beneath keeps the alt text |
colour.webp | 1000 x 1241 | Colour category, Book step 1 |
treatment.webp | 1000 x 1241 | Skin and brows category, Most booked sticky photograph |
nails.webp | 1000 x 1241 | Nails category, My bookings |
shelf.webp | 1600 x 818 | Shelf band, Massage category |
portrait-ines.webp, portrait-theo.webp, portrait-juno.webp, portrait-mae.webp | 700 x 700 | Team section, Who step, calendar heads, Settings |
Which photograph stands for each category is CAT_PHOTO in script.js; portraits are PORTRAITS, keyed by the sample team's ids. A team member added later shows an ochre monogram until a photo is added to that map.
The slot engine
availableSlots({ serviceIds, staffId, date, ignoreBookingId, forTeam }) in script.js, and _slots() in schema.sql, apply the same rules:
- Services must be active and offered by the person (their categories cover every chosen service). Several services run back to back with one person. Time held = the durations plus the last service's clean up time.
- The day must be inside the booking window (not for the team), not a closure date, and open that weekday.
- The person's shift is cut to opening hours, minus the break.
- Minus every booking of theirs that holds time (confirmed, arrived, completed, no show; a cancelled booking frees its time), except the booking being moved.
- Start times fall on the interval (every 15 minutes by default) and must be at least the lead time from now (the team has no lead time).
- With Anyone, each time goes to the qualified person with the fewest booked minutes that day, then roster order.
The booking is re-checked at the moment of booking, so a time taken since the list was drawn is refused with a clear message and the flow returns to the times. On the server the exclusion constraint on bookings is the final guard: two overlapping live bookings for one person cannot both exist.
Data model
store in script.js is the only thing that reads or writes data. Its tables are arrays of rows whose fields match schema.sql exactly, saved in localStorage under ochreroom.data.v1 (preferences such as role and theme, and the codes booked on this device, live under ochreroom.prefs.v1 and are not part of the schema). The saved record carries two version numbers in meta: version guards the shape of the tables (a mismatch reseeds the whole store), and sample_version guards the content of the sample rows only. On a returning visit, if sample_version is missing or behind the current SAMPLE_VERSION, the sample bookings, their lines and payments, and sample clients are replaced with a fresh seed while every booking, client, service, staff member and preference the visitor added or changed is kept exactly as it was.
business_settings one row: name, tagline, address, phone, email, timezone, hours[7] {open, close} or null,
closed_dates[], slot_interval_min, lead_time_min, horizon_days, cancel_window_hours,
deposit_mode (off | optional | required), deposit_percent, policy_text, owner_id
services id, category, name, description, duration_min, buffer_min, price_cents, featured, active, sort_order
staff id, name, role, categories[], bio, tint, user_id, active, sort_order
staff_shifts id, staff_id, weekday (0 = Sunday), start_time, end_time, break_start, break_end
clients id, name, email, phone, notes, created_at
bookings id, code (OR-XXXXXX), client_id, staff_id, starts_at, ends_at, blocked_until, status,
total_cents, deposit_cents, deposit_status, notes, source (online | owner), created_at, cancelled_at
booking_services id, booking_id, service_id, and the name, duration and price as booked, position
payments id, booking_id, kind (deposit | balance | refund), amount_cents, provider, provider_ref, created_atEvery seeded row carries is_sample. Money is whole cents. Payments are append only: a refund is a new negative row.
store.rpc holds what a guest may do, named after the SQL functions that do it on a server: available_slots, create_booking, find_booking (code and email must both match), cancel_booking and reschedule_booking (both refuse inside the cancellation window).
What is sample or simulated
- Sample data. Four team members with weekly rosters and breaks, fourteen services in five categories, thirty invented clients, and about 950 bookings from eight weeks ago to two weeks ahead, laid along the rosters so none overlap and no client is in two chairs at once. Dates are relative to today, and on later visits the sample slides forward by whole weeks so the demo stays current. Sample rows show a Sample tag. Settings, Sample data removes them in one step (bookings, their lines and payments, and sample clients with no bookings of your own), or restores the full demo. The sample history is denser per client than a real salon's, so every screen has something to show. A new template version can also bump
SAMPLE_VERSION, which replaces those same sample rows on a returning visitor's next load, without touching anything the visitor added or changed. - Prices and wording are sample values for an Australian salon, in AUD including GST. Set your own.
- Payments. "Demo checkout. Connect Stripe in project settings." The demo records a deposit payment row and a refund row when a client cancels in time; nothing is charged and no card details are ever asked for.
- Email. The confirmation screen says a confirmation would be emailed; nothing is sent.
- Reviews on the home page are three labelled samples. Replace them only with real reviews shared with permission.
- Photographs and the hero loop were generated for this invented business (see Art direction). They show no real people or brands. Swap in photos of your own room and team when you have them. The map is a drawing, painted by theme tokens.
- Sign in is replaced by the Viewing as switch.
Plugging in a real backend
1. Database
Run schema.sql in the Supabase SQL editor (after rehearsing it with the block at its end, and only with the owner's go ahead on a live project). Then make the owner:
update public.business_settings set owner_id = '<the owner''s auth user id>';To give a team member the Staff view, set staff.user_id to their auth user id.
2. Swap the store
Only store changes. Views keep calling the same methods. The shape is identical, so the swap is a transport change: load a snapshot on start, write through on change.
import { createClient } from '@supabase/supabase-js';
const db = createClient(SUPABASE_URL, SUPABASE_ANON_KEY); // anon key only; it is public by design
store.load = async function () {
const tables = ['services', 'staff', 'staff_shifts', 'clients', 'bookings', 'booking_services', 'payments'];
const [settings, ...rows] = await Promise.all([
db.from('business_settings').select('*').single(),
...tables.map((t) => db.from(t).select('*')), // RLS returns only what this user may see
]);
this.state = { meta: { version: 1 }, business_settings: settings.data };
tables.forEach((t, i) => (this.state[t] = rows[i].data || []));
this.rev++;
};
store.update = async function (table, id, patch) {
const { data, error } = await db.from(table).update(patch).eq('id', id).select().single();
if (error) throw error; // RLS refusals update zero rows: .single() turns that into an error
Object.assign(this.get(table, id), data);
this.rev++;
return data;
};
store.rpc.create_booking = async (input) => {
const { data, error } = await db.rpc('create_booking', {
p_service_ids: input.service_ids,
p_start: isoAt(input.date, input.start_mins),
p_staff_id: input.staff_id === 'any' ? null : input.staff_id,
p_name: input.name, p_email: input.email, p_phone: input.phone, p_notes: input.notes,
p_pay_deposit: input.pay_deposit, p_client_id: input.client_id,
});
if (error) throw new BookingError(error.message);
return data[0]; // { booking_id, code, deposit_cents }
};
// available_slots, find_booking, cancel_booking and reschedule_booking map the same way.Guests never read bookings directly: available_slots returns only start times and who they go to, and a guest's own booking comes back only from find_booking with the right code and email. For real traffic, put the guest functions behind an edge function with rate limiting so codes cannot be guessed at speed.
The demo does day and time arithmetic in the browser's timezone. The SQL uses business_settings.timezone (Australia/Sydney by default), which is what a real booking site should use.
3. Payments (Stripe)
Keys stay on the server. Two server functions:
create-checkout-session: called aftercreate_bookingreturns adeposit_centsabove zero. It creates a Stripe Checkout Session for that amount with the booking id in its metadata and returns the URL; the browser redirects. The Confirm step's "Pay deposit and book" button calls this instead of the demo.stripe-webhook: oncheckout.session.completed, with the service role, inserts apaymentsrow (kind deposit, provider stripe, provider_ref the payment intent) and setsdeposit_status = 'paid'. Whencancel_bookingleaves a booking atrefund_due, the same server issues the refund and inserts a negativerefundrow.
Add a scheduled job that cancels bookings still pending after about 30 minutes, so an abandoned checkout frees its time.
4. Email
A send-confirmation server function, triggered after a booking is created, moved or cancelled, sends the code, time and a link to My bookings. Resend, Postmark or SES all work; the key stays on the server.
5. Auth and roles
Use Supabase Auth for the team only; clients stay guests. Replace the Viewing as switch with sign in: an owner (business_settings.owner_id) gets all five team screens, a team member (staff.user_id) gets Today, Bookings and Clients. The policies in schema.sql already enforce this on the server, so hiding a tab is convenience, not security.
Adapting it
Change the brand in seedSettings() and the tokens at the top of styles.css, the team and rosters in seedStaff() and seedShifts(), the menu in seedServices(), and the categories in CATEGORIES (and the matching check on services.category in schema.sql). Keep schema.sql's seed section in step (it was generated from the same functions). Everything else follows from the data.