Freeframe

Documentation

Fade

v1.0.3Updated Oct 17, 2026A bold, vintage-modern barbershop website with live wait times, online booking, a product shop and gift cards.

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 Next.js version (Next.js 16 App Router, React 19, TypeScript, Tailwind CSS v4), version 1.0.0, 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 exports to static files. 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 (npm works too).

sh
pnpm install
pnpm dev          # http://localhost:3000

Other scripts:

Script What it does
pnpm build Static export to out/
pnpm start Serve out/ (npx serve out)
pnpm lint ESLint
pnpm typecheck next typegen and TypeScript 7 tsc --noEmit
pnpm build:demo Build for freeframe.dev under /demos/nextjs/fade into demo/

Pages#

Route Files Purpose and features
/ src/app/page.tsx Hero with the current-wait board, services preview, barbers, house rules, reviews, both shops with open-now status, gift card call to action
/services src/app/services/page.tsx, ServiceMenu.tsx Price list by category with times, category filter with live count (?category=), "Book" link per service, FAQ accordion
/barbers src/app/barbers/page.tsx, BarberList.tsx Six profiles, filters by shop and speciality (?location=, ?speciality=), days in the chair, next free time, "Book with" links
/book src/app/book/page.tsx, Booking.tsx Five-step booking flow with a running summary, confirmation and .ics download (?location=, ?service=, ?barber=)
/shop src/app/shop/page.tsx, ShopView.tsx, GiftBuilder.tsx Product grid with category filter, the bag (quantities, pickup or shipping, demo checkout), gift card builder (/shop#gift-cards)
/locations src/app/locations/page.tsx SVG map, each shop's hours, open-now status, transit, parking, walk-in wait, how the estimate works (/locations#wait-model)
/about src/app/about/page.tsx Story, timeline, standards, open jobs and chair rental (/about#careers)
/contact src/app/contact/page.tsx, ContactForm.tsx Validated contact form with topics (?topic=), both shops' details, group and wedding bookings
404 src/app/not-found.tsx Page not found (exported 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 src/app/layout.tsx.

Project structure#

fade/
├── src/
│   ├── app/
│   │   ├── layout.tsx            Root layout: fonts, metadata defaults, skip link, header, footer
│   │   ├── fonts.ts              Archivo and Anton with next/font/local
│   │   ├── globals.css           Tailwind CSS: @theme tokens and component styles
│   │   ├── page.tsx              Home
│   │   ├── <route>/page.tsx      One folder per page; interactive parts sit next to it
│   │   ├── not-found.tsx         404 page
│   │   ├── icon.svg              Favicon
│   │   └── opengraph-image.png   Social image (1200 x 630)
│   ├── components/
│   │   ├── SiteHeader.tsx        Sticky header, main and mobile menus, bag count
│   │   ├── SiteFooter.tsx        Footer with both shops and the newsletter form
│   │   ├── OpenNow.tsx           Open-now badges, today's hours, hours tables, wait board and cards
│   │   ├── Icon.tsx              Line icons and the icon per service category
│   │   ├── Logo.tsx              Logo, barber-pole mark and the rotating stamp
│   │   ├── BackToTop.tsx         Back-to-top button
│   │   ├── RevealObserver.tsx    Scroll reveals
│   │   ├── bag/useBag.ts         The saved bag as React state
│   │   └── forms/NewsletterForm.tsx
│   ├── 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
│   │   └── content.ts            Reviews, house rules, standards and the timeline
│   ├── lib/
│   │   ├── schedule.ts           Opening hours, wait estimate, time slots, next free time
│   │   ├── dates.ts              Date keys, shop-local "now" (and ?at=), .ics time conversion
│   │   ├── useShopNow.ts         The shop-local time as a React hook, refreshed every minute
│   │   ├── bag.ts                Bag storage, validation and totals
│   │   ├── format.ts             Money, durations, times, the email check
│   │   ├── art.ts                Product, storefront, map and 404 drawings (SVG markup)
│   │   ├── storage.ts            localStorage helpers that never throw
│   │   ├── metadata.ts           pageMeta(): per-page title, description and Open Graph
│   │   └── asset.ts              Prefixes public/ files with the base path
│   └── fonts/                    woff2 files and their license (OFL.txt)
├── public/img/                   Barber portraits, storefront and social image sources
├── next.config.ts                Static export and base path
├── template.json                 Template metadata (name, version, pages, features)
├── README.md, DOCS.md, CHANGELOG.md
└── package.json

Customising#

Brand and colours#

All design tokens live in the @theme block at the top of src/app/globals.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:

css
@theme {
  --color-red: #1f6f5c;      /* a deep green instead of barber red */
  --color-red-dark: #165243;
}

Keep text colours 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 colours written in. Portraits (public/img/barbers/), the drawings in src/lib/art.ts, the logo mark and stamp in src/components/Logo.tsx, 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 with next/font/local in src/app/fonts.ts, from the exact files Google Fonts serves (so text sets exactly as in the HTML version):

  • Anton (--font-display) for headings. All h1 and h2 elements, and anything with font-display, are set in Anton, uppercase.
  • Archivo (--font-sans) for everything else. It's a variable font with a width axis (62–125%): the .condensed class and the button, tag and label styles use font-stretch for the narrow, sign-painter look.

Each font has one file per character range (latin, latin-ext, vietnamese) under one family name, so globals.css and the SVG text use "Archivo" and "Anton" directly. To swap a font, put its woff2 files in src/fonts/, change the src and font-family declarations in fonts.ts, and update the two --font-* tokens in globals.css. If the new font has no width axis, drop the font-stretch declaration.

Content#

Page copy lives in each page's page.tsx. Everything the interactive parts need is typed data in src/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 speciality 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 colours 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

Unlike the HTML version, every list (price list, booking checkboxes, barber cards, products, hours tables) is rendered from this data, so a change shows up everywhere at once. To add a "Hot towel & line-up" service:

ts
{ 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 on /services, in step 2 of /book and can be linked as /book?service=towel-lineup. 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:

ts
{
  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 the services and barbers listed in FEATURED and TEAM at the top of src/app/page.tsx.

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 asset(). To use photos, keep the 5:6 ratio (e.g. 800 x 960 JPG) and change the /img/barbers/${b.id}.svg paths in src/app/page.tsx, src/app/barbers/BarberList.tsx and src/app/book/Booking.tsx.
  • Products: productArt() in src/lib/art.ts draws each product from its kind, color, label, name and size (square). To use photos, replace the .product-art div in src/app/shop/ShopView.tsx with an <img> of a square product shot.
  • Storefront (about page) and map (locations page) are inline SVG strings in src/lib/art.ts (STOREFRONT, MAP) so they use the page fonts. public/img/storefront.svg is a standalone copy.
  • Social image: src/app/opengraph-image.png (1200 x 630); public/img/og-image.svg is its source. Export a new PNG from any vector editor and replace the file.
  • Favicon: src/app/icon.svg.

The header menu and mobile menu are built from NAV in src/data/shop.ts; the current page gets aria-current="page", which styles it. The footer links are in src/components/SiteFooter.tsx. To add a page, create src/app/<route>/page.tsx (export metadata = pageMeta(...)), then add it to NAV and the footer, and to 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 src/lib/dates.ts returns { key: "2026-10-16", day: 5, mins: 980 } (date, weekday with 0 = Sunday, minutes after midnight). Components read it through useShopNow() (src/lib/useShopNow.ts), which is null while the page is prerendered and during hydration: the static HTML shows a neutral fallback ("See opening hours", "—") and the browser fills in the real values straight away, so the export never contains a stale time and React never sees a hydration mismatch. The hook 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 (home page board) and WaitCard (locations page) in src/components/OpenNow.tsx. Each shows the estimate, who is in the chair, and a bar chart of today's typical wait by hour.

estimateWait() in src/lib/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 a client component) and remove the "Illustrative estimate" labels.

Open now and hours#

OpenStatus, TodayHours and HoursTable in src/components/OpenNow.tsx, using openStatus() and hoursText() from src/lib/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#

src/app/services/ServiceMenu.tsx. Chips (aria-pressed) show one category; the count is announced with aria-live="polite". /services?category=beard preselects a category (read with useSearchParams inside a <Suspense> boundary; the prerendered page shows all services).

Barber filters and next free time#

src/app/barbers/BarberList.tsx. Two chip groups filter by shop and speciality; 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#

src/app/book/Booking.tsx. Five steps inside one <form id="booking-form">:

  1. Shop: radio cards with open-now status and who is in today.
  2. Services: checkboxes; the duration and total add up in the summary and the running total.
  3. 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)").
  4. Time: a month calendar and the open times for the chosen day, grouped into morning, afternoon and evening.
  5. 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 and a button that downloads an .ics file. It clearly says nothing was booked.

URL parameters preselect choices: /book?location=pilsen, ?service=fade, ?barber=rosa (a barber also sets their shop). Only ids that exist in src/data/ are accepted.

Time slots#

Slots come from slotsFor(locId, key, ids, barberId, now) in src/lib/schedule.ts:

  • The shop's hours that day, the barber's schedule at that shop, and the total minutes of the chosen services.
  • Start times every SLOT_STEP (15) minutes; today's earliest is LEAD_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 diary looks real and doesn't change on reload. Replace it with your booking system's availability.
ts
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 <Booking /> on src/app/book/page.tsx (Square Appointments, Fresha, Booksy and Cal.com all offer one), or keep this flow and send the request where confirm() in Booking.tsx says so:

ts
// 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 Route Handler (src/app/api/book/route.ts) or a Server Action once you remove the static export (see Building and deploying), or a serverless function (Netlify Functions, Cloudflare Workers, Vercel Functions). Never put the key in client code.

Bag and checkout#

src/app/shop/ShopView.tsx and src/lib/bag.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 a demo summary. 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 onCheckout().

Gift card builder#

src/app/shop/GiftBuilder.tsx. 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 globals.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.tsx) 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 (RevealObserver.tsx); 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 layout.tsx, BackToTop.tsx and the .back-to-top styles.

Forms#

Form Where Validates
Booking details src/app/book/Booking.tsx (step 5) Name, 10-digit US mobile, email, policy checkbox
Gift card src/app/shop/GiftBuilder.tsx Custom amount range, recipient name and email, sender, delivery date
Contact src/app/contact/ContactForm.tsx Name, email, optional phone (10 digits if given), message of 20+ characters
Newsletter src/components/forms/NewsletterForm.tsx (footer) Email

Errors appear inline next to each field, fields get aria-invalid, and focus moves to the first problem. On success each form shows a message that says it's a demo. 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 ContactForm.tsx:

tsx
const res = await fetch("https://formspree.io/f/your-id", {
  method: "POST",
  body: new FormData(form.current!),
  headers: { Accept: "application/json" },
});
if (!res.ok) return; // show an error message here
form.current!.reset();
setSent(true);

Make onSubmit async, and update the success text. 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. If you remove the static export, you can post to a Route Handler instead (src/app/api/contact/route.ts with export async function POST(request: Request)) or call a Server Action.

Saved data (localStorage)#

Key Shape Validation on read
fade-bag { items: [...], delivery: "logan" | "pilsen" | "ship" } cleanBag() in src/lib/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 header does this once per page load). Bags open in two tabs stay in sync through the storage event.

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 route sets its title and description with pageMeta() from src/lib/metadata.ts (which also writes Open Graph tags). The image is src/app/opengraph-image.png; set SITE_URL when building so its URL is absolute (metadataBase in layout.tsx):

sh
SITE_URL=https://fadebros.com pnpm build

The 404 page is noindex. Add src/app/sitemap.ts and src/app/robots.ts if you want them. For local search, consider adding BarberShop structured data (JSON-LD, in a <script type="application/ld+json">) with each shop's address, phone and opening hours to src/app/locations/page.tsx.

Accessibility#

  • Skip link, landmarks (header, nav, main, footer), one h1 per 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 use aria-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-motion turns 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#

sh
pnpm build        # static site in out/
pnpm start        # preview out/

next.config.ts sets output: "export", images.unoptimized and basePath from DEMO_BASE_PATH (for a sub-folder, e.g. DEMO_BASE_PATH=/fade pnpm build); asset() adds the same base path to plain <img> paths. Deploy out/ to any static host (Netlify, Cloudflare Pages, GitHub Pages, S3) or import the repository into Vercel. demo/ is the freeframe.dev demo build and can be deleted. To use server features (Route Handlers, Server Actions), remove output: "export" and deploy to a Node.js host or Vercel.

TypeScript 7 setup#

@typescript/native provides TypeScript 7's tsc for pnpm typecheck; typescript is aliased to @typescript/typescript6 because typescript-eslint and parts of the Next.js toolchain need the TypeScript 6 API. Replace both with typescript@7 once typescript-eslint supports it.

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 check hours in LOCATIONS (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 rendered in the browser only; the static HTML has a neutral fallback.
  • A barber never shows up in booking. They must have the shop in schedule and every skill in the chosen services' needs (or category). Check skills spelling 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 src/fonts/ still has the woff2 files named in src/app/fonts.ts.
  • Images or links break under a sub-folder. Build with DEMO_BASE_PATH set to that folder.

License#

MIT, see the repository license (included as LICENSE.txt in the download). All illustrations, portraits, product art, the map and the icons are original SVG artwork included under the same license. Archivo and Anton are under the SIL Open Font License 1.1 (src/fonts/OFL.txt).

Search documentation

Search the guides and every template's documentation