Freeframe

Documentation

Handy

v1.0.4Updated Oct 17, 2026A high-trust home services site with an instant quote calculator and service-area checker.

Overview#

Handy is a ten-page website for a home services company: plumbing, electrical, heating and cooling, carpentry, painting and remodels. The demo brand is Brightline Home Services, a fictional company in Denver, Colorado. This is the Next.js version: Next.js 16 (App Router) with React 19, TypeScript and Tailwind CSS v4, exported as a static site. It looks and behaves like the HTML version. Highlights are an instant quote calculator that turns into a booking request, a ZIP code service-area checker with a coverage map, a before/after slider, a filterable project gallery with a lightbox, a reviews page with filters and sorting, and a sticky mobile call bar. This guide covers version 1.0.2. Live demo: freeframe.dev/templates/handy.

Quick start#

Requirements: Node.js 24 and pnpm (run corepack enable pnpm once), or npm.

sh
pnpm install
pnpm dev

Open http://localhost:3000.

Script What it does
pnpm dev Development server with hot reload
pnpm build Static export to out/
pnpm start Serve out/ locally (npx serve@latest out)
pnpm lint ESLint with the Next.js config
pnpm typecheck Generate route types, then run TypeScript
pnpm build:demo Build for the Freeframe demo sub-folder into demo/ (safe to delete)

Pages#

Route File Notable sections and features
/ src/app/page.tsx Hero with ZIP checker, trust badges, services, process, reviews, projects, FAQ
/services src/app/services/page.tsx Price tables per service (anchors #plumbing, #electrical…), Brightline Care membership
/service src/app/service/page.tsx Water heaters in depth: what's included, pricing, before/after slider, FAQ
/quote src/app/quote/page.tsx Quote calculator and booking request (QuoteBuilder)
/areas src/app/areas/page.tsx Coverage map, ZIP checker and city list (AreaFinder)
/projects src/app/projects/page.tsx Filters, project cards, lightbox (ProjectGallery)
/reviews src/app/reviews/page.tsx Rating breakdown, service and star filters, sort (ReviewBrowser)
/about src/app/about/page.tsx Story, values, team and licenses
/contact src/app/contact/page.tsx Contact form, emergency line, hours
404 src/app/not-found.tsx Page not found

Project structure#

handy/
├── src/
│   ├── app/
│   │   ├── layout.tsx          Fonts, site metadata, skip link, shared header/footer/bars
│   │   ├── globals.css         Tailwind @theme tokens and component styles
│   │   ├── icon.svg            Favicon
│   │   ├── page.tsx            Home page
│   │   ├── not-found.tsx       404 page
│   │   └── <route>/page.tsx    One folder per page
│   ├── components/
│   │   ├── quote-builder.tsx   Quote calculator and booking request form
│   │   ├── zip-checker.tsx     ZIP checker and findArea()
│   │   ├── area-finder.tsx     Coverage map, checker and city list
│   │   ├── before-after.tsx    Before/after slider
│   │   ├── project-gallery.tsx Filters and lightbox
│   │   ├── review-browser.tsx  Review filters and sort
│   │   ├── rating-summary.tsx  Static rating breakdown
│   │   ├── contact-form.tsx    Contact form
│   │   ├── site-header.tsx     Sticky header and mobile menu
│   │   ├── site-footer.tsx     Footer
│   │   ├── emergency-bar.tsx   24/7 bar above the header
│   │   ├── mobile-call-bar.tsx Sticky Call / Free quote bar on small screens
│   │   ├── back-to-top.tsx     Back-to-top button
│   │   ├── reveal-observer.tsx Scroll reveals
│   │   ├── icon.tsx, logo.tsx, star-row.tsx
│   ├── data/site-data.ts       Areas, navigation, services, projects, reviews, quote form choices
│   ├── lib/
│   │   ├── quote.ts            Price model, estimate(), saved quote
│   │   ├── site.ts             Site name and URL, asset(), pageMetadata()
│   │   └── validate.tsx        useValidation() hook and FieldError
│   └── fonts/                  Archivo and Public Sans (woff2) and OFL.txt
├── public/
│   ├── img/                    SVG artwork
│   └── og-image.png            Social sharing image
├── next.config.ts              Static export and base path
├── eslint.config.mjs, postcss.config.mjs, tsconfig.json
├── package.json, pnpm-lock.yaml, pnpm-workspace.yaml
├── template.json               Template metadata (name, version, pages, features)
├── README.md, CHANGELOG.md, DOCS.md

Customising#

Brand and colours#

The design tokens are in the @theme block at the top of src/app/globals.css. Tailwind turns each --color-* token into utilities such as bg-navy-900, text-sun-400 and border-navy-100.

Token Default Used for
--color-navy-50 to --color-navy-950 #eef4fa to #0a1d31 Brand navy: header, footer, headings, buttons
--color-sun-100 to --color-sun-600 #fff4cc to #c98a00 Safety yellow: call-to-action buttons, highlights, map match
--color-ink-600, --color-ink-700 #4a5a6b, #33414f Body text
--color-paper #f7f5f0 Warm section backgrounds

The yellow is meant for fills behind navy text or for text on navy, never as text on white.

css
@theme {
  --color-navy-700: #14532d;
  --color-navy-800: #0f3f22;
  --color-navy-900: #0b2e19;
  --color-navy-950: #071f11;
}

Change the whole scale (50 to 950) so tints and hover states stay in step. Also update themeColor in the viewport export of src/app/layout.tsx, the logo colors in src/components/logo.tsx, src/app/icon.svg, the map colors in area-finder.tsx (the background fill="#eef4fa" and workshop marker), and accent in template.json.

Typography#

Archivo (headings, font-display) and Public Sans (body, font-sans) are self-hosted variable fonts in src/fonts/, loaded with next/font/local in src/app/layout.tsx. Each sets a CSS variable that @theme uses:

tsx
const archivo = localFont({ src: "../fonts/Archivo-latin.woff2", weight: "100 900", variable: "--font-archivo", display: "swap", preload: false });
css
--font-display: var(--font-archivo), "Arial Narrow", ui-sans-serif, system-ui, sans-serif;

To swap a font, either drop a new .woff2 into src/fonts/ and change src, or use next/font/google:

tsx
import { Barlow_Condensed } from "next/font/google";
const archivo = Barlow_Condensed({ subsets: ["latin"], weight: ["600", "700", "800"], variable: "--font-archivo", display: "swap" });

Keep the variable name, or change it in both layout.tsx and globals.css. The fonts are under the SIL Open Font License (src/fonts/OFL.txt).

Content#

Page text is JSX in each page.tsx. Shared data lives in two files.

src/data/site-data.ts:

Export What it holds
AREAS Area[]: name, zips, tier ("core" or "extended"), eta, and map polygon points, labelX, labelY
NAV Header menu items (href, label)
SERVICES, SERVICE_LABEL Service ids and names for the review filter and review cards
PROJECT_FILTERS, PROJECTS Gallery filter buttons and projects (key, title, location, category, duration, budget)
REVIEWS Reviews (name, location, service, stars, date, title, text)
QUOTE_SERVICES Calculator service cards (id, name, icon, description)
QUOTE_SIZES, QUOTE_OPTIONS, QUOTE_URGENCY Calculator choices and their labels
TIME_WINDOWS Booking arrival windows

src/lib/quote.ts holds the prices (see Quote calculator). src/lib/site.ts holds site.name and site.url.

To add a service area, add an entry to AREAS; the checkers, the map and the city list all read it:

ts
{
  name: "Parker",
  zips: ["80134", "80138"],
  tier: "extended",
  eta: "Next day",
  points: "500,250 590,260 590,400 420,400 420,300",
  labelX: 520,
  labelY: 330,
},

points are polygon coordinates in the map's 600×420 viewBox, and labelX/labelY place its name. eta is the badge in the city list.

Contact details are written into the components and pages, not read from site. Search src/ and replace (303) 555-0148, +13035550148, hello@brightline.example and 2150 Larimer Street, Denver, CO 80205 (they're in emergency-bar.tsx, mobile-call-bar.tsx, site-footer.tsx, the contact page and the call buttons on most pages). site.phone, site.phoneHref and site.email exist in src/lib/site.ts but aren't used yet; you can import them instead of the literals.

Content that is still JSX and must be edited by hand: the price tables on /services and /service, the reviews and projects on the home page, the team on /about, and the rating breakdown in rating-summary.tsx.

Images and artwork#

All artwork is SVG in public/img/. Files are referenced through asset() from src/lib/site.ts, which adds the base path when the site is served from a sub-folder:

tsx
<img src={asset("/img/after.svg")} alt="After: new tankless water heater mounted on the wall" width={900} height={560} />

Replace a file with a photo at the same aspect ratio and update the path and width/height:

Files Size Ratio
hero.svg 1200×900 4:3
project-<key>.svg (8 files) 800×600 4:3
before.svg, after.svg 900×560 Both must be the same size
team-*.svg (4 files) 240×240 1:1
van.svg 320×160 2:1

The project gallery builds the file name from the project key (/img/project-${key}.svg), so a new project needs a matching file, or change the src in project-gallery.tsx if you use JPGs.

public/og-image.png (1200×630) is the social image. The favicon is src/app/icon.svg, which Next.js picks up automatically.

The layout (src/app/layout.tsx) renders the skip link, EmergencyBar, SiteHeader, <main id="main">, SiteFooter, MobileCallBar, RevealObserver and BackToTop on every page.

  • Desktop menu items come from NAV in src/data/site-data.ts. The mobile menu shows Home, then NAV, then "Instant quote" (built in site-header.tsx).
  • The current page gets aria-current="page" automatically from usePathname().
  • The footer link columns are written in src/components/site-footer.tsx.

To add a page, create src/app/<route>/page.tsx with a metadata export (see SEO and sharing), then add it to NAV and the footer.

Features#

Quote calculator#

src/components/quote-builder.tsx renders the calculator and the estimate panel. Each change updates the state, recalculates with estimate() from src/lib/quote.ts and saves the choices (see Saved data).

The price model in src/lib/quote.ts:

Constant Default
PRICES [low, high] per service: plumbing [129, 420], electrical [119, 380], hvac [99, 650], carpentry [180, 900], painting [450, 2400], remodel [9800, 32000]
SIZE small: 0.85, medium: 1, large: 1.25, xl: 1.5
SIZE_SCALED painting, remodel, hvac
OPTIONS permit add [150, 250], haul add [90, 140], materials mult [1.15, 1.35], access add [80, 180]
URGENCY standard, nextday add [75, 75], emergency mult [1.5, 1.5]

How estimate() works:

  1. Start from PRICES[service].
  2. Apply the home size factor. Services in SIZE_SCALED use the full factor; others use 1 + (factor - 1) * 0.3, so a large home adds 7.5% to a repair instead of 25%.
  3. Add every fixed add-on: the selected options with an add value, in the order they're listed in OPTIONS, then the urgency's add.
  4. Multiply by every percentage: the selected options with a mult value, in OPTIONS order, then the urgency's mult. Multipliers apply to the whole running total, add-ons included.
  5. Round low down and high up to the nearest $10.

The result doesn't depend on the order options were ticked in, and matches the HTML version for the same choices.

Choices Estimate
Plumbing, 1,000–2,000 sq ft, standard $120 – $420
Plumbing, 2,000–3,000 sq ft, emergency $200 – $680
Painting, 2,000–3,000 sq ft, premium materials, haul-away, next day $830 – $4,350

To change prices, edit the constants:

ts
export const PRICES = {
  plumbing: [149, 450],
  // ...
} as const satisfies Record<string, readonly [number, number]>;

To add a service, add its id to PRICES and SERVICE_NAMES in quote.ts (and SIZE_SCALED for whole-home work), then a card to QUOTE_SERVICES in site-data.ts with an icon from src/components/icon.tsx (drop, bolt, fan, hammer, roller, ruler, wrench, house…). TypeScript will flag a missing SERVICE_NAMES entry. To add an option, add it to OPTIONS and QUOTE_OPTIONS with the same id.

Things to know:

  • The display labels for the calculator come from site-data.ts (QUOTE_*), while the breakdown lines use the labels in quote.ts. Change both when you rename something. The urgency cards' "+$75" and "+50%" are text in QUOTE_URGENCY; update them if you change the numbers. The breakdown lines themselves are built from the data by addText() and pctText().
  • /quote?service=hvac preselects a service (the services page links use this). Unknown values are ignored.
  • The radios, checkboxes and select are native controls, so the calculator works with the keyboard; the estimate has aria-live="polite".
  • The estimate panel has data-back-to-top-hide, so the back-to-top button hides rather than covering it.

Booking request#

The #request section of QuoteBuilder collects name, phone, email, ZIP, preferred date (min is today), arrival windows (TIME_WINDOWS, at least one) and details. onSubmit validates, then replaces the form with a confirmation that shows a demo reference (BL- plus four random digits from newReference()) and a summary: service, date, windows (labels from WINDOW_NAMES at the top of the file), the estimate, whether the ZIP is in an area (findArea()) and the phone number. Focus moves to the confirmation; "Start another request" resets the form. Nothing is sent; see Forms.

ZIP code checker#

<ZipChecker id="…" /> from src/components/zip-checker.tsx checks for five digits and looks the ZIP up with findArea(). Core areas get a same-day message, extended areas a next-day message, unknown ZIPs a "call us" message. The result paragraph has role="status" and aria-live="polite". The optional onResult(area) callback is how AreaFinder highlights the map. It's used on the home page (compact) and on /areas.

Coverage map#

src/components/area-finder.tsx draws one <polygon> per entry in AREAS inside a 600×420 SVG, with labels at labelX/labelY. A successful check adds has-match to the map and is-match to the area, which fades the others and outlines the match (.area-shape styles in globals.css). Core areas use area-core (navy) and extended areas area-extended (yellow). The map is role="img" with a <title> and <desc>; the city list next to it is the text equivalent. Update the <desc> text when you change areas.

Before/after slider#

src/components/before-after.tsx stacks after.svg and before.svg and covers them with a transparent range input. The value sets --pos, which clips the before image (.before-after-clip in globals.css). It works with mouse, touch and the arrow keys, shows a focus ring and sets aria-valuetext such as "60% before, 40% after". To use other images, change the two src values and the after image's alt. The input id is fixed (ba-range), so use it once per page.

src/components/project-gallery.tsx renders PROJECT_FILTERS as toggle buttons (aria-pressed) and PROJECTS as cards. Each project's category must match a filter id. Clicking a card opens a native <dialog> lightbox: focus moves to the close button, Escape or a backdrop click closes it, focus returns to the card, and the arrow keys and buttons step through the projects visible under the current filter. The count above the grid is a live region.

ts
{
  key: "roof",
  title: "Cedar roof repair",
  location: "Arvada · 80003",
  category: "outdoor",
  duration: "4 days",
  budget: "$6,800",
},

Add public/img/project-roof.svg (or change the image path) for it.

Reviews#

src/components/review-browser.tsx filters REVIEWS by service (SERVICES) and stars, and sorts by newest, highest or lowest (ties sorted by date). The star filters are all, 5, 4 and 3; "3" means 3 stars or fewer. date is YYYY-MM-DD. When nothing matches, a message with "Clear filters" appears. The count is a live region. The rating summary (rating-summary.tsx) is static, so update its numbers and bar widths by hand.

Header, mobile menu and scroll reveals#

SiteHeader adds a shadow after 8px of scrolling. The menu button toggles aria-expanded and its label; Escape closes the menu and returns focus to the button, and it closes on link click and at the desktop breakpoint. RevealObserver fades in .reveal elements on each route change; when one is revealed, all earlier ones are too. Reveals are skipped with reduced motion, and the inline script in layout.tsx adds the js class so content is only hidden when it can be shown again.

Mobile call bar#

MobileCallBar is a fixed bar with "Call now" and "Free quote" below 1024px. It has data-back-to-top-avoid, so the back-to-top button lifts above it. Remove <MobileCallBar /> from layout.tsx to drop it. It's hidden when printing.

Back to top#

src/components/back-to-top.tsx 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 above toasts and anything with data-back-to-top-avoid, hides while it would cover a fixed or sticky element with data-back-to-top-hide, and hides while a link whose text contains "Back to top" (the footer link) is on screen. Restyle it with the .back-to-top rules in globals.css; remove <BackToTop /> from layout.tsx to drop it.

Forms#

Form Component Validation
ZIP checker zip-checker.tsx 5 digits; looks up AREAS only, nothing to send
Booking request quote-builder.tsx Name, phone (10 digits), email, ZIP (5 digits), date (today or later), at least one window, details (20+ characters)
Contact contact-form.tsx Name, email, optional phone (10 digits if filled), topic, message (10+ characters)

Validation is useValidation() in src/lib/validate.tsx, with the same rules and messages as the HTML version. It reads required, type="email", minLength and data-validate (phone, phone-optional, zip, future-date). fieldProps(id) wires up aria-invalid, aria-describedby, blur and input checks; <FieldError> renders the message; checkAll(form) returns the first invalid field so the submit handler can focus it.

The demo forms don't send anything. Each submit handler has a comment, "To send for real, POST new FormData(…) to your … form service here.", at the point to change.

Connecting to Formspree#

In contact-form.tsx, make onSubmit async and post before resetting:

tsx
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
  e.preventDefault();
  const f = e.currentTarget;
  const first = checkAll(f);
  if (first) {
    first.focus();
    return;
  }
  const res = await fetch("https://formspree.io/f/your-form-id", {
    method: "POST",
    body: new FormData(f),
    headers: { Accept: "application/json" },
  });
  if (!res.ok) {
    alert("Sorry, your message could not be sent. Please call us.");
    return;
  }
  f.reset();
  setSent(true);
  requestAnimationFrame(() => success.current?.focus());
}

For the booking request, do the same in QuoteBuilder's onSubmit, after the validation block. The calculator is a separate form, so append its state:

tsx
const data = new FormData(f);
data.append("service", quote.service);
data.append("size", quote.size);
data.append("options", quote.options.join(", "));
data.append("urgency", quote.urgency);
data.append("estimate", `${money(result.low)} – ${money(result.high)}`);
await fetch("https://formspree.io/f/your-form-id", { method: "POST", body: data, headers: { Accept: "application/json" } });

Replace the demo reference with the ID your booking system returns, or remove it.

Using your own server#

The template uses output: "export", which has no server. To handle forms yourself, remove output: "export" from next.config.ts (see Building and deploying) and add a Route Handler, for example src/app/api/contact/route.ts:

ts
export async function POST(request: Request) {
  const data = await request.formData();
  const email = String(data.get("email") ?? "");
  if (!email.includes("@")) return Response.json({ error: "Invalid email" }, { status: 400 });
  // Send an email, save to a database or forward to your CRM here.
  return Response.json({ ok: true });
}

Then post to /api/contact instead of the Formspree URL. Validate again on the server; browser validation can be bypassed.

Saved data (localStorage)#

Key Shape Written
handy-quote-v1 (QUOTE_KEY) {"service": "plumbing", "size": "medium", "options": [], "urgency": "standard"} On every calculator change (saveQuote())

readQuote() in src/lib/quote.ts runs after hydration and validates the value: service, size and urgency must be keys of PRICES, SIZE and URGENCY, and options keeps only known, unique keys of OPTIONS. Anything else falls back to DEFAULT_QUOTE. Storage errors are ignored. A ?service= in the URL overrides the saved service.

The key and shape are the same as the HTML version, so the two share a saved quote on the same origin. To reset it, delete the key in your browser's developer tools (Application, Local Storage). If you change the shape, rename QUOTE_KEY (for example handy-quote-v2). To stop saving, remove the saveQuote(next) call in update() in quote-builder.tsx.

SEO and sharing#

Site-wide metadata is in src/app/layout.tsx (default title, description, openGraph, twitter). Each page exports its own with pageMetadata(title, description) from src/lib/site.ts, which also keeps the shared social image:

ts
export const metadata: Metadata = pageMetadata("Contact — Brightline Home Services", "Contact Brightline Home Services in Denver: …");

metadataBase is new URL(site.url), and site.url is process.env.NEXT_PUBLIC_SITE_URL, falling back to https://brightline.example. Set your domain when you build so og:image and other URLs are absolute:

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

On Vercel, add NEXT_PUBLIC_SITE_URL as an environment variable. There is no sitemap, robots.txt or structured data. You can add src/app/sitemap.ts and src/app/robots.ts (both work with static export), and a LocalBusiness (or HomeAndConstructionBusiness) JSON-LD <script type="application/ld+json"> on the home page with your name, phone, address, hours and area.

Accessibility#

Built in:

  • A "Skip to content" link targeting <main id="main">, and one <h1> per page.
  • Landmarks: header, labelled nav elements ("Main", "Mobile"), main and footer.
  • Visible :focus-visible outlines.
  • A menu button with aria-expanded, aria-controls and a changing label; Escape closes the menu.
  • Live regions for ZIP results, the estimate, the gallery count and the review count.
  • Form errors linked with aria-describedby and aria-invalid; focus moves to the first error and to confirmations.
  • Toggle buttons with aria-pressed for gallery and star filters; star ratings with role="img" and a text label.
  • A native <dialog> lightbox with focus return, and a native range input for the slider.
  • Reduced motion: no reveal animations, no pulsing dot, instant scrolling.

When editing, keep one <h1> per page, label every new input, keep yellow text off white, and write alt text for meaningful photos.

Building and deploying#

sh
pnpm build

This writes a static site to out/ (output: "export" and images: { unoptimized: true } in next.config.ts). Preview it with pnpm start, then upload out/ to any static host: Vercel, Netlify, Cloudflare Pages or GitHub Pages.

  • Vercel: import the repository; the defaults work. Set NEXT_PUBLIC_SITE_URL.
  • Sub-folder: set DEMO_BASE_PATH when building, for example DEMO_BASE_PATH=/handy pnpm build. It sets basePath and NEXT_PUBLIC_BASE_PATH, which asset() uses for images.
  • 404: the export includes 404.html; most static hosts serve it automatically.

To use server features (Route Handlers, Server Actions, image optimization), remove output: "export" (and images.unoptimized if you want optimized images) from next.config.ts and deploy to a Node.js host or Vercel. pnpm start then needs to become next start.

TypeScript: tsc is TypeScript 7 (@typescript/native). Because typescript-eslint doesn't support TypeScript 7 yet, the typescript package points at the TypeScript 6 build for ESLint and Next.js. eslint.config.mjs sets the React version explicitly for eslint-plugin-react.

Troubleshooting#

  • Images are missing when served from a sub-folder. Use asset("/img/…") for every public/ path, and build with DEMO_BASE_PATH set.
  • Social previews point to brightline.example. Set NEXT_PUBLIC_SITE_URL at build time.
  • A ZIP code says it's outside the area. It must be in AREAS[].zips as a string, for example "80205".
  • TypeScript errors after adding a service. Add the id to both PRICES and SERVICE_NAMES in quote.ts, and use an icon name that exists in icon.tsx.
  • The calculator starts with old choices. It restores handy-quote-v1; clear it in developer tools.

License#

MIT: the full text is in LICENSE.txt, included in the download. You can use, change and sell sites built with this template, including the SVG artwork. The fonts are under the SIL Open Font License (src/fonts/OFL.txt). Brightline Home Services, its team and its customers are fictional.

Search documentation

Search the guides and every template's documentation