Freeframe

Documentation

Bloom

v1.0.6Updated Oct 18, 2026A calm, elegant website for a hair salon and day spa, with online booking and gift cards.

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.

sh
pnpm install
pnpm dev

Open 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.md

Customising#

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
css
@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.

tsx
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):

tsx
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:

ts
{ 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.tsx and the home page call button. Search for +44 117 496 0123, +441174960123, hello@bloomhairspa.example and 48 Orchard Lane. SALON is 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:

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

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 with open: null has 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 whose cats include every chosen category; with "any", the first free member is assigned to the slot. Members are only considered on their days.
  • 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 (in TEAM order) 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. The Slot keeps the plan in team (one member id per treatment).

To use real availability, replace isBusy() with a lookup of your booking system's busy blocks:

ts
// 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 from openStatus(), such as "Open now · until 19:00" or "Closed · opens tomorrow at 09:00", with the class is-closed when 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 given is-today and aria-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.

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_AMOUNTS plus "Other" (a whole number from £25 to £500).
  • Design: GIFT_DESIGNS (petal, sage, noir); the preview uses gift-<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() in BookingWizard.tsx: "Demo only. Send { services, stylist, date, time, details } to your booking system here."
  • submit in GiftCardBuilder.tsx: "Demo only: create a payment session with your provider (e.g. Stripe Checkout) here."
  • submit in ContactForm.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:

tsx
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():

tsx
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:

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 in LOOKS and writes the cleaned list back if anything was dropped.
  • readDetails() requires an object, keeps name (80 characters max), email (120 max, and only if it looks like an email) and phone (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:

ts
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:

sh
NEXT_PUBLIC_SITE_URL=https://example.com pnpm build

On 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#

sh
pnpm build

This 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 sets basePath and NEXT_PUBLIC_BASE_PATH, which asset() 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 in TEAM), or change the paths in TeamGrid.tsx and BookingWizard.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 every public/ path and build with DEMO_BASE_PATH.
  • Social previews point to example.com. Set NEXT_PUBLIC_SITE_URL at build time.
  • ?service= or ?stylist= is ignored. The value must be an id in SERVICES or TEAM.

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.

Search documentation

Search the guides and every template's documentation