Overview#
Fade is a nine-page website for a barbershop. The demo brand is Fade Bros. Barber Co., a fictional independent barbershop with two shops in Chicago: Logan Square (2741 N Milwaukee Ave) and Pilsen (1532 W 18th St). This is the Nuxt version (Nuxt 4, Vue 3, TypeScript, Tailwind CSS v4), version 1.0.1, ported page for page from the HTML template. Highlights are an illustrative "current wait" board for each shop, a five-step booking flow that works out time slots from each barber's schedule and the length of the chosen services, filterable services and barbers, a grooming product shop with a saved bag, and a gift card builder with a live preview. It is generated as a static site. Live demo: freeframe.dev/templates/fade.
The site is a front end only. Nothing is booked, charged or sent until you connect your own services (see Connecting real bookings and Forms).
Quick start#
Requirements: Node.js 24 or newer and pnpm.
pnpm install
pnpm dev # http://localhost:3000
pnpm generate # static site in .output/publicOther scripts:
| Script | What it does |
|---|---|
pnpm build |
Node.js server build in .output/ (pages are still prerendered) |
pnpm preview |
Serve the last build |
pnpm lint |
ESLint (@nuxt/eslint) |
pnpm typecheck |
nuxt prepare and vue-tsc |
pnpm build:demo |
Build for freeframe.dev under /demos/nuxt/fade into demo/ |
Pages#
| Route | File | Purpose and features |
|---|---|---|
/ |
app/pages/index.vue |
Hero with the current-wait board, services preview, barbers, house rules, reviews, both shops with open-now status, gift card call to action |
/services |
app/pages/services.vue |
Price list by category with times, category filter with live count (?category=), "Book" link per service, FAQ accordion |
/barbers |
app/pages/barbers.vue |
Six profiles, filters by shop and specialty (?location=, ?speciality=), days in the chair, next free time, "Book with" links |
/book |
app/pages/book.vue |
Five-step booking flow with a running summary, confirmation and .ics download (?location=, ?service=, ?barber=) |
/shop |
app/pages/shop.vue |
Product grid with category filter, the bag (quantities, pickup or shipping, checkout), gift card builder (/shop#gift-cards) |
/locations |
app/pages/locations.vue |
SVG map, each shop's hours, open-now status, transit, parking, walk-in wait, how the estimate works (/locations#wait-model) |
/about |
app/pages/about.vue |
Story, timeline, standards, open jobs and chair rental (/about#careers) |
/contact |
app/pages/contact.vue |
Validated contact form with topics (?topic=), both shops' details, group and wedding bookings |
| 404 | app/error.vue |
Page not found (generated as 404.html) |
Every page shares the header (with the bag count), the footer (open-now badges for both shops and the newsletter form)
and the back-to-top button, all set up in app/layouts/default.vue.
Project structure#
fade/
├── app/
│ ├── app.vue <NuxtLayout><NuxtPage /></NuxtLayout>, with a page key (see below)
│ ├── layouts/default.vue Skip link, header, the page, footer, back-to-top button; loads the saved bag
│ ├── pages/<page>.vue One file per page (index.vue is the home page)
│ ├── error.vue 404 page
│ ├── components/
│ │ ├── SiteHeader.vue Sticky header, main and mobile menus, bag count
│ │ ├── SiteFooter.vue Footer with both shops and the newsletter form
│ │ ├── SiteLogo.vue Logo and barber-pole mark
│ │ ├── OpenStatus.vue Open-now badge
│ │ ├── TodayHours.vue Today's opening hours
│ │ ├── HoursTable.vue A shop's hours table, today highlighted
│ │ ├── WaitRow.vue One shop on the home page wait board
│ │ ├── WaitCard.vue One shop's walk-in wait card (locations page)
│ │ ├── WaitChart.vue, WaitTime.vue
│ │ ├── NewsletterForm.vue
│ │ └── BackToTop.vue Back-to-top button
│ ├── composables/
│ │ ├── useShopNow.ts The shop-local time, refreshed every minute
│ │ ├── useBag.ts The saved bag, shared by the header and the shop
│ │ ├── usePageSeo.ts Per-page title, description, canonical URL and Open Graph
│ │ ├── useCurrentPage.ts aria-current for menu links
│ │ └── useYear.ts The copyright year
│ ├── data/
│ │ ├── shop.ts SHOP, LOCATIONS (hours), TRAFFIC (wait model), NAV
│ │ ├── services.ts CATEGORIES and SERVICES
│ │ ├── barbers.ts SPECIALTIES and BARBERS (skills and schedules)
│ │ └── products.ts PRODUCTS, PRODUCT_CATEGORIES, GIFT_DESIGNS, GIFT, SHIPPING
│ ├── utils/ Auto-imported helpers
│ │ ├── schedule.ts Opening hours, wait estimate, time slots, next free time
│ │ ├── wait.ts The wait board's numbers and bar chart
│ │ ├── dates.ts Date keys, shop-local "now" (and ?at=), .ics time conversion
│ │ ├── bag.ts Bag storage, validation and totals
│ │ ├── format.ts Money, durations, times, the email check, references
│ │ ├── art.ts Product drawings (SVG markup)
│ │ └── safeStorage.ts, storage.ts localStorage helpers that never throw
│ ├── plugins/ Scroll reveals, same-page links, stable builds
│ ├── router.options.ts Query strings written like the HTML version
│ └── assets/
│ ├── css/main.css Tailwind CSS: fonts, @theme tokens and component styles
│ └── fonts/ woff2 files and their license (OFL.txt)
├── public/img/ Favicon, barber portraits, storefront, social image and its source
├── nuxt.config.ts Base path, head defaults, SITE_URL, prerendered routes
├── template.json Template metadata (name, version, pages, features)
├── README.md, DOCS.md, CHANGELOG.md
└── package.jsonCustomizing#
Brand and colors#
All design tokens live in the @theme block near the top of app/assets/css/main.css:
| Token | Default | Used for |
|---|---|---|
--color-paper |
#f2ead9 |
Page background |
--color-card |
#fbf7ef |
Cards, inputs |
--color-sand |
#e8dcc4 |
Soft panels and alternate sections |
--color-ink |
#151515 |
Text, borders, hard shadows, dark sections |
--color-coal |
#24211e |
Raised surfaces on dark (the wait board) |
--color-mute |
#5a5249 |
Secondary text |
--color-red |
#b5281c |
Barber red: buttons, eyebrows, links |
--color-navy |
#1d3557 |
Barber blue in the stripes |
--color-brass |
#e0a83a |
Accents on dark backgrounds only |
--color-go |
#2c6a37 |
"Open" dots and positive states |
Each token becomes Tailwind utilities (bg-red, text-ink, border-brass...). To change the accent:
@theme {
--color-red: #1f6f5c; /* a deep green instead of barber red */
--color-red-dark: #165243;
}Keep text colors at 4.5:1 contrast or more against paper, card and sand. Brass is only for text on ink. The
barber-pole stripes are the pole-stripe utility (a repeating-linear-gradient of red, paper and navy); the header
and footer use it through .pole-band.
The SVG artwork has its colors written in. Portraits (public/img/barbers/), the product drawings in
app/utils/art.ts, the inline drawings in the pages (stamp, storefront, map, 404), the logo mark in
app/components/SiteLogo.vue, the favicon and the social image use the same hex values, so a find-and-replace of
#b5281c updates them.
Typography#
Two fonts are self-hosted from app/assets/fonts/ and declared with @font-face at the top of
app/assets/css/main.css. They are the exact files Google Fonts serves the HTML version, so text sets exactly the
same:
- Anton (
--font-display) for headings. Allh1andh2elements, and anything withfont-display, are set in Anton, uppercase. - Archivo (
--font-sans) for everything else. It's a variable font with a width axis (62–125%): the.condensedclass and the button, tag and label styles usefont-stretchfor the narrow, sign-painter look.
Each font has one file per character range (latin, latin-ext, vietnamese) under one family name, so the CSS and the
SVG text use "Archivo" and "Anton" directly. To swap a font, put its woff2 files in app/assets/fonts/, change
the @font-face rules and update the two --font-* tokens. If the new font has no width axis, drop the
font-stretch declarations.
Content#
Page copy lives in each page's .vue file. Everything the interactive parts need is typed data in app/data/:
| Export | File | What it holds |
|---|---|---|
SHOP |
shop.ts |
Name, email, timeZone (America/Chicago), currency and locale |
LOCATIONS |
shop.ts |
The two shops: id, address, phone, chairs, hours per weekday (0 = Sunday, null = closed), transit, parking |
TRAFFIC |
shop.ts |
The demo walk-in traffic curves used by the wait estimate |
NAV |
shop.ts |
Main and mobile menu links |
CATEGORIES, SERVICES |
services.ts |
Service categories and services: id, cat, name, mins, price, desc, optional needs and popular |
SPECIALTIES, BARBERS |
barbers.ts |
The specialty filter; each barber's id, short name, role, skills, specialties and schedule per shop per weekday |
PRODUCTS, PRODUCT_CATEGORIES |
products.ts |
Shop products (with the colors of their drawing) and their filter categories |
GIFT_DESIGNS, GIFT, SHIPPING |
products.ts |
Gift card designs and limits, flat shipping and the free-shipping threshold |
The booking checkboxes, barber cards, products, hours tables and summaries are rendered from this data. The price
list on /services and the featured services and barbers on the home page are written out in their pages, like the
HTML version, so update those too. To add a "Hot towel & line-up" service:
{ id: "towel-lineup", cat: "cuts", name: "Hot towel & line-up", mins: 20, price: 24, desc: "A line-up finished with two hot towels." },It appears in step 2 of /book and can be linked as /book?service=towel-lineup; add a row for it to the price
list in app/pages/services.vue. needs lists the
skills a barber must have; it defaults to the service's own category. Packages use it, e.g. needs: ["cuts", "shaves"]
for The Full Service, so only barbers with both skills are offered.
To add a barber, add an entry to BARBERS and a portrait at public/img/barbers/<id>.svg:
{
id: "nia", short: "Nia", name: "Nia Brooks", role: "Barber", years: 4,
skills: ["cuts", "beard", "kids"], specialties: ["fades", "kids"],
bio: "…", signature: "Burst fade",
schedule: { logan: { 4: ["12:00", "20:00"], 5: ["12:00", "20:00"] } },
},Keep barber schedules inside the shop's opening hours. The home page features four services and four barbers, written
out in app/pages/index.vue.
Images and artwork#
Everything is SVG, so there are no photos to license:
- Barber portraits:
public/img/barbers/<barber id>.svg, 400 x 480 (5:6). The home page, barbers page and booking flow load them by id, with the base path in front ($config.app.baseURL). To use photos, keep the 5:6 ratio (e.g. 800 x 960 JPG) and change theimg/barbers/…svgpaths inapp/pages/index.vue,app/pages/barbers.vueandapp/pages/book.vue. - Products:
productArt()inapp/utils/art.tsdraws each product from itskind,color,label,nameandsize(square). To use photos, replace the.product-artdiv inapp/pages/shop.vuewith an<img>of a square product shot. - Storefront (about page) and map (locations page) are inline SVG in their pages, so they use the page fonts.
public/img/storefront.svgis a standalone copy. - Social image:
public/img/og-image.png(1200 x 630);public/img/og-image.svgis its source. Export a new PNG from any vector editor and replace the file. - Favicon:
public/img/favicon.svg.
Navigation, header and footer#
The header menu and mobile menu are built from NAV in app/data/shop.ts; the current page gets
aria-current="page", which styles it. The footer links are in app/components/SiteFooter.vue. To add a page, create
app/pages/<route>.vue (call usePageSeo({ title, description }) and start the template with
<main id="main" class="flex-1">), then add it to NAV, the footer, routes in nuxt.config.ts and the pages
list in template.json.
Features#
Shop-local time#
Everything time-dependent works in the shop's time zone (SHOP.timeZone), whatever the visitor's own zone is.
computeShopNow() in app/utils/dates.ts returns { key: "2026-10-16", day: 5, mins: 980 } (date, weekday with
0 = Sunday, minutes after midnight). Components read it through useShopNow() (app/composables/useShopNow.ts),
which is null while the pages are generated and during hydration: the static HTML shows a neutral fallback ("See
opening hours", "—") and the browser fills in the real values once the page is mounted, so the generated pages never
contain a stale time and Vue never sees a hydration mismatch. It refreshes every minute.
Add ?at=2026-10-17T10:30 to any page URL to preview the site at a shop-local time (useful for testing busy and closed
states). Invalid values are ignored.
Current wait estimate#
WaitRow.vue (home page board) and WaitCard.vue (locations page), with the numbers from waitFor() in
app/utils/wait.ts. Each shows the estimate, who is in the chair, and a bar chart of today's typical wait by hour.
estimateWait() in app/utils/schedule.ts multiplies the traffic level for this hour (TRAFFIC, 0 to 1, separate
curves for weekdays, Saturday and Sunday) by 70 minutes, divides by the number of barbers working, adds a small stable
variation and rounds to 5 minutes. It is illustrative: the page says so next to every number and
/locations#wait-model explains it. To show real waits, replace estimateWait() with data from your queue system
(e.g. fetch a JSON endpoint in onMounted()) and remove the "Illustrative estimate" labels.
Open now and hours#
OpenStatus.vue, TodayHours.vue and HoursTable.vue, using openStatus() and hoursText() from
app/utils/schedule.ts. Badges show "Open now · until 8 pm", "Closing soon · 8 pm" (last 45 minutes) or
"Closed · opens tomorrow 9 am". Today's row in each hours table is highlighted and marked aria-current="date".
Services filter#
app/pages/services.vue. Chips (aria-pressed) show one category; the count is announced with
aria-live="polite". /services?category=beard preselects a category (read once the page is mounted; the generated
page shows all services).
Barber filters and next free time#
app/pages/barbers.vue. Two chip groups filter by shop and specialty; if no barber matches, an empty state
offers to reset (focus returns to "Both"). /barbers?location=pilsen and ?speciality=curly preselect filters. Each
card shows the barber's next free time for a Classic cut (nextFree()), worked out in the browser.
Booking flow#
app/pages/book.vue. Five steps inside one <form id="booking-form">:
- Shop: radio cards with open-now status and who is in today.
- Services: checkboxes; the duration and total add up in the summary and the running total.
- Barber: "First available" plus every barber who works at the chosen shop and has the skills for every chosen
service (
eligible()), with their next free time. Barbers left out are listed with the reason (e.g. "Rosa (no straight-razor shaves)"). - Time: a month calendar and the open times for the chosen day, grouped into morning, afternoon and evening.
- Details: name, mobile, email, notes, reminder and cancellation policy, all validated.
If nobody at the chosen shop can do every chosen service in one visit (kids cut + hot towel shave in Pilsen), step 2
explains it instead of continuing. The confirmation shows a summary, a reference (FB- and four characters, from
makeRef() in app/utils/format.ts) and a button that downloads an .ics file carrying the same reference. It says the chair is on
hold and that the shop will text to confirm within a few minutes; change that to match how your booking system
confirms. The note at the top of the page repeats the cancellation policy.
URL parameters preselect choices: /book?location=pilsen, ?service=fade, ?barber=rosa (a barber also sets their
shop). Only ids that exist in app/data/ are accepted.
Time slots#
Slots come from slotsFor(locId, key, ids, barberId, now) in app/utils/schedule.ts:
- The shop's hours that day, the barber's
scheduleat that shop, and the total minutes of the chosen services. - Start times every
SLOT_STEP(15) minutes; today's earliest isLEAD_TIME(30) minutes from now. - Visitors can book
BOOK_AHEAD(42) days ahead. isBooked()marks about a third of each barber's half-hours as taken, using a stable hash so the calendar looks real and doesn't change on reload. Replace it with your booking system's availability.
export const SLOT_STEP = 15; // minutes between start times
export const LEAD_TIME = 30; // earliest booking is this many minutes from now
export const BOOK_AHEAD = 42; // days visitors can book ahead (6 weeks)Connecting real bookings#
The flow is a front end only. To take real bookings, either embed your provider's widget in place of the
<section data-booking> in app/pages/book.vue (Square Appointments, Fresha, Booksy and Cal.com all offer one), or
keep this flow and send the request where confirm() in book.vue says so:
// Demo only: nothing is sent. Post { location, services, barber, date, time, details } to
// your booking provider here (Square Appointments, Fresha, Booksy or Cal.com APIs/embeds).Booking APIs need a secret key, so call them from a server: a Nuxt server route (server/api/book.post.ts, deployed
with pnpm build to a Node.js host; see Building and deploying) or a serverless function
(Netlify Functions, Cloudflare Workers, Vercel Functions). Never put the key in client code.
Bag and checkout#
app/pages/shop.vue, app/utils/bag.ts and app/composables/useBag.ts. "Add to bag" buttons add products; the bag lists products with
quantity steppers (1 to MAX_QTY, 10) and gift cards, offers pickup at either shop or shipping (SHIPPING: $6, free
over $50 in products) with a progress meter, and totals everything. "Checkout" shows an "Order placed" summary with an order reference from makeRef(); the line under the button
says no payment is taken online. The header bag icon shows the item count on every page (useBag()) and links to
/shop#bag.
To sell for real, send the bag lines from readBag() to Shopify (Buy Button or Storefront API), Square Online or
Stripe Checkout in checkout(), and show the provider's own order number instead of the made-up reference.
Gift card builder#
The gift card builder in app/pages/shop.vue. Preset amounts or a custom whole amount between GIFT.min and GIFT.max ($20 to
$300), three designs (.gift-pole, .gift-stamp, .gift-blackout in main.css), recipient, sender, message (140
characters, with a counter) and "right away" or a date within a year. The preview updates as you type and a
screen-reader summary is announced politely. A valid card is added to the bag as a gift line.
Header, menu, reveals and back to top#
The header (SiteHeader.vue) is sticky. Below 1024px a menu button opens a full-screen menu (aria-expanded, Esc
closes it and returns focus, choosing a link closes it). Elements with .reveal fade up once as they scroll into view
(app/plugins/reveal.client.ts); nothing is hidden with reduced motion. The marquee and the stamp stop moving with
reduced motion. To remove the back-to-top button, delete <BackToTop /> from app/layouts/default.vue,
BackToTop.vue and the .back-to-top styles.
Links are client-side navigations. A link to the page that is already showing (the header's "Services" link on the
services page after filtering) starts the page afresh at the top, like the full page load of the HTML version
(app/plugins/same-page.client.ts and the page key in app/app.vue).
Forms#
| Form | Where | Validates |
|---|---|---|
| Booking details | app/pages/book.vue (step 5) |
Name, 10-digit US mobile, email, policy checkbox |
| Gift card | app/pages/shop.vue |
Custom amount range, recipient name and email, sender, delivery date |
| Contact | app/pages/contact.vue |
Name, email, optional phone (10 digits if given), message of 20+ characters |
| Newsletter | app/components/NewsletterForm.vue (footer) |
Errors appear inline next to each field, fields get aria-invalid, and focus moves to the first problem. On success
the contact form says the message was received, promises a reply within one business day (as the page already says)
and shows a reference (FB- and four characters from makeRef(); use your form service's own reference instead if
it returns one). The footer newsletter message asks subscribers to look out for a confirmation email, so use a
mailing list tool that sends one (double opt-in) or reword it. No form sends anything until you connect it.
/contact?topic=careers (or group, chair-rental, feedback, press) preselects the topic.
Connecting to Formspree#
Create a form at Formspree, then replace the demo comment in onSubmit() in app/pages/contact.vue:
const res = await fetch("https://formspree.io/f/your-id", {
method: "POST",
body: new FormData(form.value!),
headers: { Accept: "application/json" },
});
if (!res.ok) return; // show an error message here
Object.assign(fields, fresh());
reference.value = makeRef(); // or the reference your service returnsMake onSubmit async, and check that the success text still describes what happens next. Every field has a
name, so FormData includes it. The newsletter form has the same kind of comment; point it at Mailchimp,
Buttondown or your email tool. With a server build (pnpm build) you can post to a Nuxt server route instead
(server/api/contact.post.ts).
Saved data (localStorage)#
| Key | Shape | Validation on read |
|---|---|---|
fade-bag |
{ items: [...], delivery: "logan" | "pilsen" | "ship" } |
cleanBag() in app/utils/bag.ts |
Each item is either { type: "product", id, qty } or { type: "gift", key, amount, design, to, toEmail, from, message, date }. cleanBag() drops unknown product ids, duplicate lines, quantities that aren't whole numbers (and caps them at
10), gift cards with an amount outside the limits, an unknown design or an invalid email, and trims text to its maximum
length. readBag() writes a cleaned version back (the layout does this once per page load). Bags open in two tabs
stay in sync through the storage event. If the browser blocks storage (a sandboxed frame, strict privacy settings),
the bag still works for the current page view.
To reset, empty the bag on /shop, or run localStorage.removeItem("fade-bag") in the browser console. Nothing else
is stored.
SEO and sharing#
Each page sets its title and description with usePageSeo() (app/composables/usePageSeo.ts), which also writes
the canonical URL and Open Graph tags. The social image is public/img/og-image.png; set SITE_URL when building
so the canonical and image URLs are absolute:
SITE_URL=https://fadebros.example pnpm generateThe 404 page is noindex. For local search, consider adding BarberShop structured data (JSON-LD, with useHead())
with each shop's address, phone and opening hours to app/pages/locations.vue.
Accessibility#
- Skip link, landmarks (
header,nav,main,footer), oneh1per page and a logical heading order. - Visible focus styles (a red outline, brass on dark sections) on every interactive element.
- Filters are buttons with
aria-pressed; result counts usearia-live="polite". - The booking flow moves focus to each step's heading, marks the current step with
aria-current="step", and the calendar uses a roving tabindex with arrow keys, Home/End and PageUp/PageDown. Each day's label says how many times are open. - The FAQ uses native
<details>/<summary>. - Text meets WCAG AA contrast; decorative SVGs are
aria-hidden, the storefront and the map have text alternatives. prefers-reduced-motionturns off reveals, the marquee and the stamp rotation, and makes scrolling instant.
When editing, keep one h1 per page, give new inputs a <label>, and keep brass text on dark backgrounds only.
Building and deploying#
pnpm generate # static site in .output/public
pnpm preview # preview itEvery page is prerendered to <page>.html (the routes are listed in nuxt.config.ts), and app/error.vue becomes
404.html. Deploy .output/public to any static host (Netlify, Cloudflare Pages, GitHub Pages, S3, Vercel). Hosts
that serve /services from services.html need no setup; elsewhere, link to the .html files or add a rewrite.
For a sub-folder, set DEMO_BASE_PATH (e.g. DEMO_BASE_PATH=/fade pnpm generate); links, scripts and images get the
base path. demo/ is the freeframe.dev demo build and can be deleted. To use server routes, run pnpm build and
deploy .output/ to a Node.js host.
typescript is aliased to @typescript/typescript6, the TypeScript 6 API that vue-tsc needs.
Troubleshooting#
- The wait board says "Closed" during your opening hours. Times are worked out in
SHOP.timeZone. Set it to your shop's IANA zone (e.g.America/New_York) and checkhoursinLOCATIONS(index 0 is Sunday). - Open-now badges and wait times are missing from the HTML source. They depend on the current time, so they are filled in by the browser; the static HTML has a neutral fallback.
- A barber never shows up in booking. They must have the shop in
scheduleand every skill in the chosen services'needs(or category). Checkskillsspelling against the category ids. - No times on any day. The chosen services may be longer than the barber's shift, or the shift falls outside the
shop's hours. Shorten the services or widen the
schedule. - The bag shows fewer items after you changed product ids. Unknown ids are dropped from saved bags the next time they are read; that is the validation working.
- Fonts fall back to the system font. Check that
app/assets/fonts/still has the woff2 files named in the@font-facerules inmain.css. - Images or links break under a sub-folder. Build with
DEMO_BASE_PATHset to that folder. - A new page is missing from
.output/public. Add its route toroutesinnuxt.config.ts.
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. Archivo and Anton
are licensed under the SIL Open Font License (app/assets/fonts/OFL.txt). Barber Co., its shops, barbers and
the people quoted are fictional.