Freeframe

Documentation

Bloom

v1.0.0Updated Nov 12, 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 Burns Court, Sarasota, Florida. This is the Nuxt version: Nuxt 4 with Vue 3, TypeScript and Tailwind CSS v4, generated as a static site. It looks and behaves like the HTML version, but the service menu, booking wizard, team, gallery and business hours are all rendered from one typed data file. Highlights are a five-step booking wizard that works out real time slots from business 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.0. Live demo: freeframe.dev/templates/bloom.

Quick start#

Requirements: Node.js 24 or newer and pnpm (npm works too).

sh
pnpm install      # also runs `nuxt prepare` (types and the ESLint config in .nuxt/)
pnpm dev          # http://localhost:3000

Other scripts:

Script What it does
pnpm generate Static site in .output/public/ (nuxt generate): every page prerendered to HTML
pnpm build A Node.js server build in .output/ (nuxt build), for hosts that run Nuxt as a server
pnpm preview Serve the last build locally
pnpm lint ESLint (@nuxt/eslint)
pnpm typecheck nuxt prepare and vue-tsc
pnpm build:demo Generate for freeframe.dev under /demos/nuxt/bloom into demo/

Pages#

Route File Notable sections and features
/ app/pages/index.vue Hero, signature treatments, before/after slider (BeforeAfter), team, reviews, gift cards
/services app/pages/services.vue Full menu from SERVICES with a category filter (ServicesMenu), pricing levels, policies
/team app/pages/team.vue Team cards with a specialty filter and working days (TeamGrid), pricing levels, careers
/book app/pages/book.vue Five-step booking wizard (BookingWizard)
/gift-cards app/pages/gift-cards.vue Gift card builder with live preview (GiftCardBuilder), FAQ
/gallery app/pages/gallery.vue Looks with a filter, saved looks and a lightbox (LookGallery)
/about app/pages/about.vue Story, values and milestones
/contact app/pages/contact.vue Map, business hours (HoursTable), open-now badge, contact form (ContactForm)
404 app/error.vue Page not found, generated as 404.html

The layout (app/layouts/default.vue) adds the skip link, 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/
├── app/
│   ├── app.vue                   <NuxtLayout><NuxtPage /></NuxtLayout>, keyed so a link to the same page restarts it
│   ├── error.vue                 404 page
│   ├── router.options.ts         Writes query strings like the HTML version (?service=signature-cut)
│   ├── layouts/default.vue       Skip link, header, page, footer, back-to-top button
│   ├── pages/                    One file per page (index.vue, services.vue, book.vue...)
│   ├── components/
│   │   ├── BookingWizard.vue     Five-step booking wizard and summary
│   │   ├── GiftCardBuilder.vue   Gift card form, preview and confirmation
│   │   ├── LookGallery.vue       Looks grid, saved looks, lightbox
│   │   ├── ServicesMenu.vue      Service menu and category filter
│   │   ├── TeamGrid.vue          Team cards and specialty filter
│   │   ├── OpenBadge.vue         "Open now" badge
│   │   ├── HoursTable.vue        Business hours with today highlighted
│   │   ├── BeforeAfter.vue       Before/after slider
│   │   ├── ContactForm.vue       Contact form
│   │   ├── NewsletterForm.vue    Footer newsletter form
│   │   ├── SiteHeader.vue        Header and mobile menu
│   │   ├── SiteFooter.vue        Footer
│   │   ├── BackToTop.vue         Back-to-top button
│   │   ├── LogoMark.vue          The flower mark
│   │   └── CategoryIcon.vue      Line icons for the five treatment categories
│   ├── composables/
│   │   ├── useNow.ts             The current time, updated every minute after the page loads
│   │   ├── useCurrentPage.ts     aria-current for menu links
│   │   ├── usePageSeo.ts         Title, description, canonical and social tags
│   │   └── useAsset.ts           Paths to files in public/ with the base path
│   ├── data/
│   │   ├── bloom.ts              Salon, hours, categories, services, levels, team, looks, gift options
│   │   └── nav.ts                Header and footer links
│   ├── utils/
│   │   ├── booking.ts            Prices, time slots, opening status, formatting
│   │   ├── safeStorage.ts        localStorage that never throws, and the storage keys
│   │   └── url.ts                getParam()
│   ├── plugins/
│   │   ├── reveal.client.ts      Scroll reveals
│   │   ├── same-page.client.ts   Links to the page already showing start it afresh at the top
│   │   └── stable-build.server.ts Reproducible builds
│   └── assets/
│       ├── css/main.css          Fonts, Tailwind @theme tokens and component styles
│       └── fonts/                Bodoni Moda and Jost (Latin .woff2) with their OFL licenses
├── public/img/                   SVG artwork (looks/, team/, before, after, salon, map), favicon, og-image.png and .svg
├── nuxt.config.ts                Base path, head defaults, SITE_URL, prerendered routes, Tailwind
├── eslint.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

Customizing#

Brand and colors#

The design tokens are in the @theme block of app/assets/css/main.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 main.css) and the SVG artwork have their own colors. Also update theme-color in nuxt.config.ts, public/img/favicon.svg and accent in template.json.

Typography#

Bodoni Moda (headings, font-display) and Jost (body, font-sans) are self-hosted from app/assets/fonts/, declared with @font-face at the top of main.css. They are the Latin .woff2 files Google Fonts serves to the HTML version, so text sets exactly like it; Bodoni Moda keeps its optical-size axis and has an italic face. Their SIL Open Font License texts are the *-OFL.txt files beside them.

css
@font-face {
  font-family: "Jost";
  font-style: normal;
  font-weight: 300 600;
  font-display: swap;
  src: url("../fonts/Jost-latin.woff2") format("woff2");
}

To swap a font, add its .woff2 (and license) to app/assets/fonts/, change the @font-face rule and the family name in --font-display or --font-sans in @theme.

Content#

Almost everything the interactive parts show comes from app/data/bloom.ts:

Export Type What it holds
SALON object Name, address, phone, phoneHref, email, currency (ISO code, e.g. USD), locale (e.g. en-US)
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 hours table, the open-now badges and the footer address read these directly, so one change updates every page. To add a treatment:

ts
{ id: "bang-trim", cat: "hair", name: "Bang trim", mins: 15, price: 15, desc: "A quick cleanup between cuts." },

It appears on /services with a "Book" link to /book?service=bang-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 written in the pages and needs editing by hand:

  • The home page (app/pages/index.vue): signature treatments, the team preview, reviews and the gift card panel.
  • The pricing level tables on /services and /team, the about page and the contact page's address block.
  • The phone number in the home page's call button and on the contact page. Search for (941) 555-0173 and +19415550173.

Images and artwork#

All artwork is SVG in public/img/. Paths go through useAsset(), which adds the base path when the site is served from a sub-folder:

ts
const asset = useAsset();
// <img :src="asset(`img/team/${m.id}.svg`)" ...>
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.vue, BookingWizard.vue and LookGallery.vue. The home page's hero and the 404 page draw inline SVG.

public/img/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 public/img/favicon.svg.

app/data/nav.ts has NAV (header and mobile menu) and FOOTER_NAV (the footer's "Explore" list). The header marks the current page with aria-current="page" (useCurrentPage()). To add a page, create app/pages/<route>.vue with a usePageSeo() call (see SEO and sharing), add its route to routes in nuxt.config.ts and add it to the menus.

Features#

Booking wizard#

app/components/BookingWizard.vue 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, cell phone, notes, first visit, remember details, cancellation policy Name 2+ characters, valid email, 10–15 digit phone, policy checked
5. Confirm Review rows with "Change" buttons None

On confirm, confirm() saves or clears the remembered details and shows a confirmation with a reference (BL- plus four characters, from makeRef() in app/utils/booking.ts) and the email address the confirmation will go to. 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 checks a treatment and /book?stylist=amara picks a team member (read in onMounted()). Unknown ids are ignored, and a stylist who can't do the chosen treatments falls back to "Anyone available" when step 2 opens. 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 app/utils/booking.ts returns { start, member }[], with start in minutes after midnight. The rules:

  • Business 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 (9:00 AM–8:00 PM) can start at 5:00 PM at the latest.
  • Step: every 15 minutes.
  • Today: at least an hour from now, rounded up to the next 15 minutes (at 10:07 AM the first slot is 11:15 AM).
  • 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.
  • Schedule: isBusy() marks about one in five 30-minute blocks as booked with a deterministic hash, so the demo schedule 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 AM)
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.vue (for example in onMounted(), from your booking API) and call setBusy() before step 3 opens. 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 renderDates().

Level-based pricing#

priceFor(service, level) multiplies price by the level's multiplier for hair and colour services only (isScaled()), rounded to whole dollars. Nails, skin and massage are the same price with anyone.

Treatment Stylist Senior (×1.2) Director (×1.4)
Signature cut & finish ($85) $85 $102 $119
Balayage ($260) $260 $312 $364

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 color 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, "$55" / "$55.50"); set them for another currency, for example "CAD" and "en-CA".

Business hours and open now#

  • OpenBadge.vue shows openStatus(), such as "Open now · until 7:00 PM" or "Closed · opens tomorrow at 9:00 AM", with the class is-closed when closed. It is in the footer and on the contact page.
  • HoursTable.vue lists the week from Monday, with today's row given is-today and aria-current="date".

Both use useNow(), which starts once the page has loaded and updates every minute. The visitor's clock and time zone are used. The generated HTML shows the fallback "Tue–Sun · see hours" until then; update it in OpenBadge.vue if your days change.

Services and team filters#

ServicesMenu.vue filters CATEGORIES with chips and a live count ("5 treatments in Hair"). /services?category=colour opens with a category selected. TeamGrid.vue 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.

LookGallery.vue filters LOOKS by LOOK_CATEGORIES and a "Saved" toggle. Each look has a heart button (aria-pressed) that saves it in the browser (see Saved data); saved looks stay in sync across tabs through the storage event. The lightbox is a native <dialog>: the arrow keys and the buttons step through the visible looks, Escape or a click on the backdrop closes it, focus returns to the look, and "Book with" links to /book?stylist=<id>.

Gift cards#

GiftCardBuilder.vue:

  • Amount: GIFT_AMOUNTS plus "Other" (a whole number from $25 to $500, GIFT_MIN and GIFT_MAX).
  • Design: GIFT_DESIGNS (petal, sage, noir); the preview uses gift-<design>.
  • Recipient's name and email, your name and 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 "Order confirmed" with a gift card code (BLOOM-XXXX-XXXX), a summary and an order reference (BL- plus four characters); no payment is taken.

Before/after slider#

BeforeAfter.vue stacks before.svg and after.svg under a native range input; its value sets --pos. It works with mouse, touch and the arrow keys, and screen readers hear "Showing 50% before".

Header, menu and motion#

SiteHeader.vue adds a hairline and shadow once the page scrolls and has an accessible mobile menu (aria-expanded; Escape closes it and returns focus to the button; following a link or widening the window past 1024px closes it too). app/plugins/reveal.client.ts fades in .reveal elements as they scroll into view and skips the animation with reduced motion; the head script in nuxt.config.ts adds the js class so content is only hidden when it can be shown again. Links to the page already showing start it afresh at the top, as a page load would in the HTML version (app/plugins/same-page.client.ts and the page key in app/app.vue).

Back to top#

BackToTop.vue shows a button after about one screen of scrolling. It scrolls to the top (instantly with reduced motion) and moves focus to <main id="main">; it lifts itself above toasts and steps aside while an in-page "Back to top" link is on screen. Restyle it with the .back-to-top rules in main.css; remove <BackToTop /> from app/layouts/default.vue to drop it.

Keeping the demo current#

Nothing dated goes stale: the booking calendar starts today, the open-now badges and today's hours follow the visitor's clock, gift card delivery dates run from today to a year ahead, and the copyright year is the current year. The URL, the clock and localStorage are only read in onMounted() or event handlers, never while rendering, so the generated pages are the same on every build and hydration never mismatches.

Forms#

Form Component Validation
Booking BookingWizard.vue Per step, see Booking wizard
Gift card GiftCardBuilder.vue Amount, names, both emails, delivery date
Contact ContactForm.vue Name 2+ characters, valid email, message 20+ characters; ?topic=careers preselects a topic
Newsletter NewsletterForm.vue (footer) Valid email

Errors appear under each field with aria-invalid, and the first invalid field gets focus. Each form ends with an in-world confirmation. The booking, gift card and contact confirmations show a short reference made in the browser (makeRef(); if your booking system, payment provider or form service returns its own reference, show that instead), and the newsletter asks subscribers to look out for a confirmation email.

None of the forms send anything until you connect them. The places to connect them:

  • confirm() in BookingWizard.vue: "Demo only. Send { services, stylist, date, time, details } to your booking system here."
  • submit() in GiftCardBuilder.vue: "Demo only: create a payment session with your provider (e.g. Stripe Checkout) here."
  • submit() in ContactForm.vue: "Send the form's fields to your endpoint here."
  • submit() in NewsletterForm.vue.

Connecting to Formspree#

In ContactForm.vue, post after validation:

ts
// ...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),
});
if (!res.ok) {
  errors.message = "Sorry, that did not send. Please call or email us.";
  return;
}
Object.assign(form, blank());

For a booking request by email, post from confirm() (it is already async):

ts
await fetch("https://formspree.io/f/your-form-id", {
  method: "POST",
  headers: { "Content-Type": "application/json", Accept: "application/json" },
  body: JSON.stringify({
    treatments: selected.value.map((id) => SERVICE_BY_ID[id]!.name),
    date: dateKey(state.date!),
    time: hhmm(state.slot!),
    name: fields.name.trim(),
    email: fields.email.trim(),
    phone: fields.phone.trim(),
  }),
});

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#

pnpm build makes a Node.js server build, so you can add a server route, for example server/api/booking.post.ts:

ts
export default defineEventHandler(async (event) => {
  const body = await readBody(event);
  if (!Array.isArray(body?.treatments) || !body.date || !body.time) {
    throw createError({ statusCode: 400, statusMessage: "Missing booking details" });
  }
  // Check availability, create the appointment and send a confirmation email here.
  return { ok: true, reference: "BL-7K2Q" };
});

Post to it with $fetch("/api/booking", { method: "POST", body }). The same approach works for gift cards: create a Stripe Checkout session in a server route 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 (LookGallery.vue)
bloom-details {"name": "…", "email": "…", "phone": "…"} On booking confirmation if "Remember my details on this device" is checked (BookingWizard.vue)

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 safeStorage and store in app/utils/safeStorage.ts, which never throw and keep the value in memory for the page view when the browser blocks storage. The keys (STORAGE_KEYS) match the HTML version. To reset, delete them in your browser's developer tools (Application, Local Storage).

SEO and sharing#

Each page calls usePageSeo({ title, description }) (app/composables/usePageSeo.ts), which sets the title, description, Open Graph and Twitter tags and a canonical link. URLs are absolute: set SITE_URL when you build (SITE_URL=https://your-domain.example pnpm generate); the default is https://bloomhairspa.example. The social image is public/img/og-image.png. The 404 page is noindex. Add a sitemap.xml and robots.txt to public/ when you deploy, and consider a HairSalon or BeautySalon JSON-LD script built from SALON and HOURS (useHead({ script: [{ type: "application/ld+json", innerHTML: … }] })).

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 business hours marked with aria-current="date".
  • Native <dialog> lightbox with focus return, and a 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 generate     # static site in .output/public/
pnpm preview      # serve it locally

nuxt generate prerenders every route listed in routes in nuxt.config.ts as <page>.html (services.html, linked as /services), plus 404.html from app/error.vue. Next to each page is a folder with its _payload.json, which Nuxt loads when you move between pages. Upload .output/public/ to any static host (Netlify, Cloudflare Pages, GitHub Pages, S3, Vercel). Hosts that don't map /services to services.html on their own need that rewrite (Vercel: "cleanUrls": true); most serve 404.html for unknown URLs.

To serve the site from a sub-folder, set DEMO_BASE_PATH, which becomes Nuxt's app.baseURL:

sh
DEMO_BASE_PATH=/bloom pnpm generate

pnpm build makes a Node.js server build instead (run it with node .output/server/index.mjs); the pages are still prerendered, and you can add server routes. demo/ is the freeframe.dev demo build and can be deleted.

TypeScript setup#

pnpm typecheck runs vue-tsc against the configs Nuxt writes to .nuxt/ (tsconfig.json references them). typescript is aliased to @typescript/typescript6, the TypeScript 6 API that vue-tsc and typescript-eslint load; replace it with typescript@7 once they support it.

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.vue and BookingWizard.vue.
  • No times appear for a day. The salon is closed (HOURS), no eligible member works that day (days), or the demo schedule is full. Try "Anyone available".
  • The open-now badge shows "Tue–Sun · see hours". That's the generated text; it updates once the page has loaded. Update it in OpenBadge.vue if your days change.
  • Images are missing on a sub-folder deploy. Use useAsset() for every public/ path and build with DEMO_BASE_PATH.
  • Social previews point to bloomhairspa.example. Set SITE_URL at build time.
  • ?service= or ?stylist= is ignored. The value must be an id in SERVICES or TEAM.
  • A new page is missing from the generated site. Add its route to routes in nuxt.config.ts.
  • pnpm lint can't find .nuxt/eslint.config.mjs. Run pnpm install or npx nuxt prepare first.

License#

Freeframe Commercial License: use it in unlimited websites and apps for yourself and your clients; it may not be resold or redistributed as a template. The full terms, with your licensee details, are in LICENSE.txt and at https://freeframe.dev/license#commercial. Third-party components keep their own licenses. Bodoni Moda and Jost are under the SIL Open Font License 1.1 (app/assets/fonts/*-OFL.txt). Bloom Hair & Spa, its team and its clients are fictional.

Search documentation

Search the guides and every template's documentation