Overview#
Bloom is a nine-page website for a hair salon and day spa. The demo brand is Bloom Hair & Spa, a fictional independent salon at 48 Orchard Lane, Bristol. This is the Next.js version: Next.js 16 (App Router) with React 19, TypeScript and Tailwind CSS v4, exported as a static site. It looks and behaves like the HTML version, but the service menu, booking wizard, team, gallery and opening hours are all generated from one data file. Highlights are a five-step booking wizard that works out real time slots from opening hours, staff working days and treatment length, level-based pricing, a gift card builder with a live preview, a gallery with saved looks and a lightbox, and a live "Open now" status. This guide covers version 1.0.3. Live demo: freeframe.dev/templates/bloom.
Quick start#
Requirements: Node.js 24 and pnpm (run corepack enable pnpm once), or npm.
pnpm install
pnpm devOpen http://localhost:3000.
| Script | What it does |
|---|---|
pnpm dev |
Development server with hot reload |
pnpm build |
Static export to out/ |
pnpm start |
Serve out/ locally |
pnpm lint |
ESLint with the Next.js config |
pnpm typecheck |
Generate route types, then run TypeScript |
pnpm build:demo |
Build for the Freeframe demo sub-folder into demo/ (safe to delete) |
Pages#
| Route | File | Notable sections and features |
|---|---|---|
/ |
src/app/page.tsx |
Hero, signature treatments, before/after slider, team, reviews, gift cards |
/services |
src/app/services/page.tsx |
Full menu from SERVICES with category filter (ServicesMenu) |
/team |
src/app/team/page.tsx |
Team cards with specialty filter and working days (TeamGrid), pricing levels |
/book |
src/app/book/page.tsx |
Five-step booking wizard (BookingWizard) |
/gift-cards |
src/app/gift-cards/page.tsx |
Gift card builder with live preview (GiftCardBuilder) |
/gallery |
src/app/gallery/page.tsx |
Looks with filter, saved looks and lightbox (Gallery) |
/about |
src/app/about/page.tsx |
Story, values and milestones |
/contact |
src/app/contact/page.tsx |
Map, opening hours, open-now badge, contact form |
| 404 | src/app/not-found.tsx |
Page not found |
The layout adds the header, the footer (with an open-now badge and the newsletter form) and the back-to-top button to every page.
Project structure#
bloom/
├── src/
│ ├── app/
│ │ ├── layout.tsx Fonts, metadataBase, skip link, header, footer, back to top
│ │ ├── globals.css Tailwind @theme tokens and component styles
│ │ ├── icon.svg Favicon
│ │ ├── page.tsx Home page
│ │ ├── not-found.tsx 404 page
│ │ └── <route>/page.tsx One folder per page
│ ├── components/
│ │ ├── BookingWizard.tsx Five-step booking wizard
│ │ ├── GiftCardBuilder.tsx Gift card form and preview
│ │ ├── Gallery.tsx Looks grid, saved looks, lightbox
│ │ ├── ServicesMenu.tsx Service menu and category filter
│ │ ├── TeamGrid.tsx Team cards and specialty filter
│ │ ├── OpeningHours.tsx OpenNowBadge and HoursTable
│ │ ├── BeforeAfter.tsx Before/after slider
│ │ ├── ContactForm.tsx Contact form
│ │ ├── NewsletterForm.tsx Footer newsletter form
│ │ ├── SiteHeader.tsx Header, NAV and mobile menu
│ │ ├── SiteFooter.tsx Footer
│ │ ├── BackToTop.tsx Back-to-top button
│ │ ├── RevealObserver.tsx Scroll reveals
│ │ ├── Chips.tsx Filter chip group
│ │ └── Icons.tsx Category and UI icons
│ ├── fonts/ Bodoni Moda and Jost (Latin .woff2) with their OFL licences
│ ├── data/bloom.ts Salon, hours, categories, services, levels, team, looks, gift options
│ └── lib/
│ ├── booking.ts Prices, time slots, opening status, formatting
│ ├── client.ts useQueryParam, useNow, storage
│ ├── metadata.ts pageMetadata()
│ └── asset.ts asset() base-path helper
├── public/
│ ├── img/ SVG artwork (looks/, team/, before, after, salon, map, og-image.svg)
│ └── og-image.png Social sharing image
├── next.config.ts Static export and base path
├── eslint.config.mjs, postcss.config.mjs, tsconfig.json
├── package.json, pnpm-lock.yaml, pnpm-workspace.yaml
├── template.json Template metadata (name, version, pages, features)
├── README.md, CHANGELOG.md, DOCS.mdCustomising#
Brand and colours#
The design tokens are in the @theme block at the top of src/app/globals.css. Tailwind turns each --color-* token
into utilities such as bg-shell, text-plum and border-line.
| Token | Default | Used for |
|---|---|---|
--color-shell |
#faf6f2 |
Page background |
--color-cream |
#fffdfb |
Cards |
--color-blush |
#f3e4dd |
Soft panels |
--color-rose |
#e8cfc4 |
Deeper panels |
--color-line |
#e3d3ca |
Borders |
--color-ink |
#2a2127 |
Text |
--color-mute |
#685a5f |
Secondary text (meets AA on shell, cream and blush) |
--color-plum, --color-plum-dark |
#7a3e5b, #5e2c45 |
Accent: buttons, links, selected states |
--color-sage |
#5a6954 |
Secondary accent, open-now badge |
--color-gold |
#a5804f |
Decorative details |
--color-error |
#a23a3a |
Form errors |
@theme {
--color-plum: #2f5d62;
--color-plum-dark: #1f4044;
}The gift card designs (.gift-petal, .gift-sage, .gift-noir in globals.css) and the SVG artwork have their own
colours. Also update themeColor in the viewport export of src/app/layout.tsx, src/app/icon.svg and accent in
template.json.
Typography#
Bodoni Moda (headings, font-display) and Jost (body, font-sans) are self-hosted with next/font/local in
src/app/layout.tsx, from the Latin .woff2 files in src/fonts/: the same files Google Fonts serves to the HTML
version, so text sets exactly like it. Their SIL Open Font License texts are the *-OFL.txt files beside them.
const bodoni = localFont({
src: [
{ path: "../fonts/BodoniModa-latin.woff2", weight: "400 700", style: "normal" },
{ path: "../fonts/BodoniModa-Italic-latin.woff2", weight: "400 700", style: "italic" },
],
variable: "--font-bodoni",
display: "swap",
adjustFontFallback: false,
fallback: ["Bodoni Moda"],
declarations: [{ prop: "font-family", value: "Bodoni Moda" }],
});
const jost = localFont({
src: "../fonts/Jost-latin.woff2",
variable: "--font-jost",
weight: "300 600",
display: "swap",
adjustFontFallback: false,
fallback: ["Jost"],
declarations: [{ prop: "font-family", value: "Jost" }],
});The font-family declaration keeps each face's real name, so CSS or SVG can also refer to "Bodoni Moda" and "Jost";
fallback puts that name in the CSS variable, and adjustFontFallback: false leaves out the Arial-based fallback face,
which would otherwise be picked instead of the font on systems that have Arial.
@theme uses the variables: --font-display: var(--font-bodoni), … and --font-sans: var(--font-jost), …. To swap
a font, add its .woff2 (and licence) to src/fonts/ and point src at it, or import another family from
next/font/google, and keep the variable name (or change it in both files):
import { Cormorant_Garamond, Jost } from "next/font/google";
const bodoni = Cormorant_Garamond({ variable: "--font-bodoni", subsets: ["latin"], weight: ["400", "500", "600"], style: ["normal", "italic"] });Content#
Almost everything the interactive parts show comes from src/data/bloom.ts:
| Export | Type | What it holds |
|---|---|---|
SALON |
object | Name, address, phone, phoneHref, email, currency (ISO code, e.g. GBP), locale (e.g. en-GB) |
HOURS |
Hours[] |
Seven entries indexed by day (0 is Sunday): label, open/close as "HH:MM" or null |
CATEGORIES |
{ id: CategoryId; label; blurb }[] |
hair, colour, nails, skin, massage |
SERVICES |
Service[] |
id, cat, name, mins, price, desc, optional signature |
LEVELS |
{ id: LevelId; label; multiplier; blurb }[] |
stylist ×1, senior ×1.2, director ×1.4 |
TEAM |
Member[] |
id, name, role, level, cats, days, bio, specialties |
LOOKS |
Look[] |
id, cat, title, by (team id), palette, kind |
LOOK_CATEGORIES |
{ id; label }[] |
Gallery filters |
GIFT_AMOUNTS, GIFT_DESIGNS |
arrays | Preset gift amounts and the three designs |
The services menu, the booking wizard, the team grid, the gallery, the opening-hours table and the open-now badges read these directly, so one change updates every page. To add a treatment:
{ id: "fringe-trim", cat: "hair", name: "Fringe trim", mins: 15, price: 12, desc: "A quick tidy between cuts." },It appears on /services with a "Book" link to /book?service=fringe-trim and in step 1 of the wizard. To add a team
member, add a Member (days uses 0 for Sunday to 6 for Saturday) and a portrait at public/img/team/<id>.svg. To
add a look, add a Look and public/img/looks/<id>.svg.
Some content is still written in the pages and needs editing by hand:
- The home page (
src/app/page.tsx): signature treatments, team preview, reviews and prices are JSX. - The pricing levels explanation on
/team. - Contact details in
SiteFooter.tsx,src/app/contact/page.tsxand the home page call button. Search for+44 117 496 0123,+441174960123,hello@bloomhairspa.exampleand48 Orchard Lane.SALONis used only for the booking wizard's no-JavaScript message, so you can import it in those places instead.
Images and artwork#
All artwork is SVG in public/img/. Paths go through asset() from src/lib/asset.ts, which adds the base path when
the site is served from a sub-folder:
<img src={asset(`/img/team/${t.id}.svg`)} alt={`Illustrated portrait of ${t.name}`} width={400} height={480} />| Files | Size | Ratio |
|---|---|---|
looks/<look id>.svg (12 files) |
480×600 | 4:5 |
team/<member id>.svg (6 files) |
400×480 | 5:6 |
before.svg, after.svg |
640×520 | Both must be the same size |
salon.svg |
640×480 | 4:3 |
map.svg |
640×420 | Illustrative map on the contact page |
The team grid, the stylist list in the wizard and the gallery build file names from ids (/img/team/${id}.svg,
/img/looks/${id}.svg). If you switch to JPGs, change the extension in TeamGrid.tsx, BookingWizard.tsx and
Gallery.tsx.
public/og-image.png (1200×630) is the social image; public/img/og-image.svg is its editable source. Export it
again as a 1200×630 PNG after editing. The favicon is src/app/icon.svg.
Navigation, header and footer#
src/components/SiteHeader.tsx has a NAV array at the top (href, label) for both the desktop and mobile menus,
and marks the current page with aria-current="page". The footer links are written in
src/components/SiteFooter.tsx. To add a page, create src/app/<route>/page.tsx with a metadata export (see
SEO and sharing) and add it to NAV and the footer.
Features#
Booking wizard#
src/components/BookingWizard.tsx renders five steps (STEPS): Treatments, Stylist, Date & time, Your details,
Confirm.
| Step | What happens | Validation |
|---|---|---|
| 1. Treatments | Checkboxes for every service, grouped by CATEGORIES |
At least one |
| 2. Stylist | "Anyone available" plus each member from eligibleMembers() |
None |
| 3. Date and time | 21 days from today, then the free start times for the chosen day | A time is chosen |
| 4. Your details | Name, email, mobile, notes, first visit, remember details, cancellation policy | Name 2+ characters, valid email, 10–15 digit phone, policy ticked |
| 5. Confirm | Review rows with "Change" buttons | None |
On confirm, confirm() saves or clears the remembered details and shows a confirmation with a demo reference (BLM-
plus randomCode()). Focus moves to each step's heading and to the confirmation; the progress list marks the current
step with aria-current="step". "Book something else" starts again.
Preselect from links: /book?service=balayage ticks a treatment, /book?stylist=amara picks a team member (read with
useQueryParam() after hydration). Unknown ids are ignored, and a stylist who can't do the chosen treatments falls back
to "Anyone available". Changing treatments clears the chosen date and time. If the chosen day has no times left, the
wizard moves to the first available day.
Time slots and availability#
slotsFor(date, serviceIds, stylist, now) in src/lib/booking.ts returns { start, member }[], with start in
minutes after midnight. The rules:
- Opening hours:
HOURS[date.getDay()]; a day withopen: nullhas no slots (Monday in the demo). - Length:
totalMins()of the chosen services; a slot must finish by closing. A 180-minute balayage on a Thursday (09:00–20:00) can start at 17:00 at the latest. - Step: every 15 minutes.
- Today: at least an hour from now, rounded up to the next 15 minutes (at 10:07 the first slot is 11:15).
- Who:
eligibleMembers()keeps members whosecatsinclude every chosen category; with "any", the first free member is assigned to the slot. Members are only considered on theirdays. - Diary:
isBusy()marks about one in five 30-minute blocks as booked with a deterministic hash, so the demo diary is the same on every visit.memberFree()requires every block the treatment touches to be free. - Mixed treatments: if nobody can do all the categories (a cut plus a gel manicure, say), it books "Best available
team" back to back.
teamPlan()takes the treatments in menu order and gives each one to the first team member (inTEAMorder) who does that category, works that day and is free for the whole treatment; a start time is only offered if every treatment can be covered. TheSlotkeeps the plan inteam(one member id per treatment).
To use real availability, replace isBusy() with a lookup of your booking system's busy blocks:
// busy["theo|2026-10-08"] = [20, 21, 26] -> 30-minute block numbers (block 20 = 10:00)
let busy: Record<string, number[]> = {};
export function setBusy(next: Record<string, number[]>) {
busy = next;
}
const isBusy = (memberId: string, key: string, block: number) => busy[`${memberId}|${key}`]?.includes(block) ?? false;Fetch the busy data in BookingWizard (for example in an effect when the component mounts, from your booking API) and
call setBusy() before showing step 3. To change the interval, edit t += 15; the notice period is the + 60 in the
earliest line; the number of days is the 21 in BookingWizard.tsx.
Level-based pricing#
priceFor(service, level) multiplies price by the level's multiplier for hair and colour services only
(isScaled()), rounded to whole pounds. Nails, skin and massage are the same price with anyone.
| Treatment | Stylist | Senior (×1.2) | Director (×1.4) |
|---|---|---|---|
| Signature cut & finish (£68) | £68 | £82 | £95 |
| Balayage (£210) | £210 | £252 | £294 |
Until a stylist or a time (and so a member) is chosen, the running total shows "from" the stylist price. The services
menu shows hair and colour prices as "from" the base price. Change the multipliers in LEVELS, or make another category
scale by editing scales in booking.ts. money() formats prices with Intl.NumberFormat using SALON.currency and SALON.locale (whole amounts without
decimals, "£45" / "£45.50"); set them for another currency, for example "EUR" and "fr-FR".
Opening hours and open now#
src/components/OpeningHours.tsx exports:
OpenNowBadge: text fromopenStatus(), such as "Open now · until 19:00" or "Closed · opens tomorrow at 09:00", with the classis-closedwhen closed. Before hydration it shows "Tue–Sun · see opening hours"; update that fallback if your days change.HoursTable: the week from Monday, with today's row givenis-todayandaria-current="date".
Both use useNow() from src/lib/client.ts, which updates every minute. The visitor's clock and time zone are used.
Services and team filters#
ServicesMenu shows a Chips filter of CATEGORIES and a live count ("5 treatments in Hair"). /services?category=colour
opens with a category selected. TeamGrid filters TEAM by category with an "Everyone" option, shows each member's
level and working days (daysLabel(), for example "Tue–Sat"), and links to /book?stylist=<id>. Both counts are
polite live regions and the chips use aria-pressed.
Gallery and saved looks#
src/components/Gallery.tsx filters LOOKS by LOOK_CATEGORIES and a "Saved" toggle. Each look has a heart button
(aria-pressed) that saves it to localStorage (see Saved data); saved looks stay in sync
across tabs through the storage event. The lightbox is a native <dialog>: arrow keys and buttons step through the
visible looks, Escape or a backdrop click closes it, focus returns to the look, and "Book with" links to
/book?stylist=<id>.
Gift cards#
src/components/GiftCardBuilder.tsx:
- Amount:
GIFT_AMOUNTSplus "Other" (a whole number from £25 to £500). - Design:
GIFT_DESIGNS(petal,sage,noir); the preview usesgift-<design>. - To, recipient email, from, your email, message (160 characters with a counter).
- Delivery: now or on a date from today to one year ahead.
The preview updates as you type. A valid submit shows a demo code (BLOOM-XXXX-XXXX) and a summary; no payment is
taken.
Before/after slider#
src/components/BeforeAfter.tsx stacks before.svg and after.svg under a native range input; its value sets
--pos. It works with mouse, touch and the arrow keys.
Header and scroll reveals#
SiteHeader adds a shadow on scroll and has an accessible mobile menu (Escape closes it and returns focus).
RevealObserver fades in .reveal elements and skips the animation with reduced motion; the inline script in
layout.tsx adds the js class so content is only hidden when it can be shown again.
Back to top#
src/components/BackToTop.tsx shows a button after about one screen of scrolling (it's inert while hidden). It
scrolls to the top (instantly with reduced motion) and moves focus to <main id="main">. Restyle it with the
.back-to-top rules in globals.css; remove <BackToTop /> from layout.tsx to drop it. Unlike the HTML version, it
doesn't move around toasts or other fixed elements.
Forms#
| Form | Component | Validation |
|---|---|---|
| Booking | BookingWizard.tsx |
Per step, see Booking wizard |
| Gift card | GiftCardBuilder.tsx |
Amount, names, both emails, delivery date |
| Contact | ContactForm.tsx |
Name 2+ characters, valid email, message 20+ characters; ?topic=careers preselects a topic |
| Newsletter | NewsletterForm.tsx (footer) |
Valid email |
Errors appear under each field with aria-invalid, and the first invalid field gets focus. None of the forms send
anything. The places to connect them:
confirm()inBookingWizard.tsx: "Demo only. Send { services, stylist, date, time, details } to your booking system here."submitinGiftCardBuilder.tsx: "Demo only: create a payment session with your provider (e.g. Stripe Checkout) here."submitinContactForm.tsx: "Send { ...form, topic } to your endpoint here (e.g. https://formspree.io/f/yourid)."- the submit handler in
NewsletterForm.tsx.
Connecting to Formspree#
In ContactForm.tsx, make submit async and post after validation:
const submit = async (e: React.FormEvent) => {
e.preventDefault();
// ...validation as before...
const res = await fetch("https://formspree.io/f/your-form-id", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ ...form, topic: currentTopic }),
});
if (!res.ok) {
setErrors({ message: "Sorry, that did not send. Please call or email us." });
return;
}
setForm({ name: "", email: "", message: "" });
setSent(true);
requestAnimationFrame(() => document.getElementById("contact-success")?.focus());
};For a booking request by email, post from confirm():
await fetch("https://formspree.io/f/your-form-id", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({
treatments: selected.map((id) => SERVICE_BY_ID[id].name),
with: who,
date: dateKey(chosenDay!.date),
time: hhmm(chosenSlot!.start),
...details,
}),
});(make confirm async). A form service only delivers the request; it doesn't block the time. For real bookings, send
the same data to your booking system and feed its availability into isBusy().
Using your own server#
With output: "export" there is no server. To handle forms or payments yourself, remove output: "export" from
next.config.ts and add a Route Handler, for example src/app/api/booking/route.ts:
export async function POST(request: Request) {
const body = await request.json();
if (!Array.isArray(body.treatments) || !body.date || !body.time) {
return Response.json({ error: "Missing booking details" }, { status: 400 });
}
// Check availability, create the appointment and send a confirmation email here.
return Response.json({ ok: true, reference: "BLM-1234" });
}The same approach works for gift cards: create a Stripe Checkout session in a Route Handler and redirect to its URL. Validate everything again on the server.
Saved data (localStorage)#
| Key | Shape | Written |
|---|---|---|
bloom-saved-looks |
Array of look ids, for example ["honey-balayage"] |
When a heart is toggled (Gallery.tsx) |
bloom-details |
{"name": "…", "email": "…", "phone": "…"} |
On booking confirmation if "Remember my details on this device" is ticked (BookingWizard.tsx) |
Validation on read:
readSaved()keeps only unique strings that are ids inLOOKSand writes the cleaned list back if anything was dropped.readDetails()requires an object, keepsname(80 characters max),email(120 max, and only if it looks like an email) andphone(30 max), and removes the key if nothing valid is left.
Confirming without "remember" removes bloom-details. All access goes through storage in src/lib/client.ts,
which never throws. The keys match the HTML version. To reset, delete them in your browser's developer tools
(Application, Local Storage).
SEO and sharing#
Each page exports metadata with pageMetadata(title, description) from src/lib/metadata.ts, which sets the title,
description, Open Graph and Twitter tags and the shared og-image.png:
export const metadata: Metadata = pageMetadata("Book an appointment — Bloom Hair & Spa", "…");metadataBase in src/app/layout.tsx is process.env.NEXT_PUBLIC_SITE_URL, falling back to https://example.com.
Set your domain when you build so social image URLs are absolute:
NEXT_PUBLIC_SITE_URL=https://example.com pnpm buildOn Vercel, add NEXT_PUBLIC_SITE_URL as an environment variable. There is no sitemap, robots.txt or structured data.
You can add src/app/sitemap.ts and src/app/robots.ts, and a HairSalon or BeautySalon JSON-LD script on the
home page built from SALON and HOURS.
Accessibility#
Built in:
- A "Skip to content" link, landmarks (
header,nav,main,footer) and one<h1>per page. - Visible focus styles.
- Menu button with
aria-expanded; Escape closes the menu. - Chips, saved-look hearts, dates and time slots use
aria-pressed; unavailable dates are disabled. - Live regions for the services, team and gallery counts; step errors use
role="alert". - Wizard steps with focusable headings,
aria-current="step"and focus on the confirmation. - Today's opening hours marked with
aria-current="date". - Native
<dialog>lightbox with focus return, native range input for the slider. - Reduced motion: no reveal animations and instant scrolling.
When editing, keep one <h1> per page, label every new input, and write alt text for meaningful photos.
Building and deploying#
pnpm buildThis writes a static site to out/ (output: "export" and images: { unoptimized: true }). Preview it with
pnpm start and upload out/ to any static host: Vercel, Netlify, Cloudflare Pages or GitHub Pages.
- Vercel: import the repository; the defaults work. Set
NEXT_PUBLIC_SITE_URL. - Sub-folder: build with
DEMO_BASE_PATH=/bloom. It setsbasePathandNEXT_PUBLIC_BASE_PATH, whichasset()uses. - 404: the export includes
404.html.
To use server features (Route Handlers, Server Actions, image optimisation), remove output: "export" from
next.config.ts and deploy to Vercel or a Node.js host with next start.
TypeScript: tsc is TypeScript 7 (@typescript/native); the typescript package points at the TypeScript 6 build
for ESLint and Next.js, because typescript-eslint doesn't support 7 yet. eslint.config.mjs sets the React version
explicitly for eslint-plugin-react.
Troubleshooting#
- A new team member's portrait is missing. Add
public/img/team/<id>.svg(the id inTEAM), or change the paths inTeamGrid.tsxandBookingWizard.tsx. - No times appear for a day. The salon is closed (
HOURS), no eligible member works that day (days), or the demo diary is full. Try "Anyone available". - The open-now badge shows "Tue–Sun · see opening hours". That's the pre-hydration fallback; it updates once JavaScript runs. Update the text if your days change.
- Images are missing on a sub-folder deploy. Use
asset()for everypublic/path and build withDEMO_BASE_PATH. - Social previews point to example.com. Set
NEXT_PUBLIC_SITE_URLat build time. ?service=or?stylist=is ignored. The value must be anidinSERVICESorTEAM.
License#
MIT: the full text is in LICENSE.txt, included in the download. You can use, change and sell sites built with this
template, including the SVG artwork. Bloom Hair & Spa, its team and its clients are fictional.