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).
pnpm install # also runs `nuxt prepare` (types and the ESLint config in .nuxt/)
pnpm dev # http://localhost:3000Other 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.mdCustomizing#
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 |
@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.
@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:
{ 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
/servicesand/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-0173and+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:
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.
Navigation, header and footer#
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 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 (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 whosecatsinclude every chosen category; with "any", the first free member is assigned to the slot. Members are only considered on theirdays. - 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 (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 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.vueshowsopenStatus(), such as "Open now · until 7:00 PM" or "Closed · opens tomorrow at 9:00 AM", with the classis-closedwhen closed. It is in the footer and on the contact page.HoursTable.vuelists the week from Monday, with today's row givenis-todayandaria-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.
Gallery and saved looks#
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_AMOUNTSplus "Other" (a whole number from $25 to $500,GIFT_MINandGIFT_MAX). - Design:
GIFT_DESIGNS(petal,sage,noir); the preview usesgift-<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()inBookingWizard.vue: "Demo only. Send { services, stylist, date, time, details } to your booking system here."submit()inGiftCardBuilder.vue: "Demo only: create a payment session with your provider (e.g. Stripe Checkout) here."submit()inContactForm.vue: "Send the form's fields to your endpoint here."submit()inNewsletterForm.vue.
Connecting to Formspree#
In ContactForm.vue, post after validation:
// ...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):
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:
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 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 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#
pnpm generate # static site in .output/public/
pnpm preview # serve it locallynuxt 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:
DEMO_BASE_PATH=/bloom pnpm generatepnpm 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 inTEAM), or change the paths inTeamGrid.vueandBookingWizard.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.vueif your days change. - Images are missing on a sub-folder deploy. Use
useAsset()for everypublic/path and build withDEMO_BASE_PATH. - Social previews point to bloomhairspa.example. Set
SITE_URLat build time. ?service=or?stylist=is ignored. The value must be anidinSERVICESorTEAM.- A new page is missing from the generated site. Add its route to
routesinnuxt.config.ts. pnpm lintcan't find.nuxt/eslint.config.mjs. Runpnpm installornpx nuxt preparefirst.
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.