Freeframe

Documentation

Handy

v1.0.1Updated Nov 13, 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. 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, a 24/7 emergency bar and a sticky mobile call bar. This is the Nuxt version (1.0.0): Nuxt 4, Vue 3, TypeScript and Tailwind CSS v4, generated as a static site. It looks and behaves like the HTML version.

Live demo: freeframe.dev/templates/handy

Quick start#

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

sh
pnpm install      # also runs `nuxt prepare` (types and the ESLint config in .nuxt/)
pnpm dev          # http://localhost:3000
Script What it does
pnpm dev Development server at http://localhost:3000
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/handy into demo/ (you can delete it)

Pages#

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

Project structure#

app/
├── app.vue                   <NuxtLayout><NuxtPage /></NuxtLayout>, with a page key (see "Links to the same page")
├── layouts/default.vue       skip link, header, the page, footer, mobile call bar and back-to-top button
├── pages/                    one file per page (index.vue, services.vue, service.vue, quote.vue, ...)
├── error.vue                 404 page (generated as 404.html)
├── components/
│   ├── QuoteBuilder.vue      quote calculator, estimate panel and booking request
│   ├── ZipChecker.vue        "Do we cover your area?" form (home and service areas)
│   ├── AreaMap.vue           the SVG coverage map
│   ├── BeforeAfter.vue       before/after slider
│   ├── ProjectGallery.vue    project filters and lightbox
│   ├── ReviewBrowser.vue     review filters and sort
│   ├── ReviewCard.vue, RatingSummary.vue, StarIcons.vue
│   ├── ContactForm.vue, FieldError.vue
│   ├── SiteHeader.vue        24/7 bar, sticky header and phone menu
│   ├── SiteFooter.vue, MobileCallBar.vue, EmergencyCta.vue, BackToTop.vue
├── composables/
│   ├── useValidation.ts      inline form validation
│   ├── usePageSeo.ts         title, description, canonical and Open Graph tags per page
│   ├── useCurrentPage.ts     aria-current for menu links
│   └── useAsset.ts           files in public/ with the base path
├── data/
│   ├── site.ts               business details, header, menu and footer links
│   ├── areas.ts              service areas (ZIP codes, map shapes) and findArea()
│   ├── quote.ts              price model and the calculator's and booking form's choices
│   ├── projects.ts           project gallery
│   └── reviews.ts            reviews and the rating summary
├── utils/
│   ├── quote.ts              estimate(), readQuote(), saveQuote()
│   ├── format.ts             money(), makeRef(), todayISO()
│   └── safeStorage.ts        localStorage with an in-memory fallback
├── plugins/
│   ├── reveal.client.ts      scroll reveals
│   ├── same-page.client.ts   links to the page already showing start it afresh
│   └── stable-build.server.ts  reproducible builds
├── router.options.ts         keeps query strings as written
└── assets/
    ├── css/main.css          Tailwind import, fonts, design tokens (@theme) and component classes
    └── fonts/                Archivo and Public Sans (Latin .woff2) with their OFL licenses
public/img/                   SVG artwork, favicon.svg, og-image.svg and og-image.png
nuxt.config.ts                base path, head defaults, SITE_URL, prerendered routes, Tailwind
template.json                 template metadata used by freeframe.dev

Customizing#

Brand and colors#

The design tokens are in the @theme block of app/assets/css/main.css. Tailwind turns each --color-* token into utilities such as bg-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. Change a whole scale (50 to 950) so tints and hover states stay in step. Also update the theme-color meta in nuxt.config.ts, the logo colors in SiteHeader.vue and SiteFooter.vue, public/img/favicon.svg, the map background (fill="#eef4fa") and workshop marker in AreaMap.vue, and accent in template.json.

Typography#

Headings use Archivo (--font-display) and text uses Public Sans (--font-sans). Both are self-hosted from app/assets/fonts/ and declared with @font-face at the top of main.css: the same Latin files Google Fonts serves, so no request goes to Google and text sets exactly like the HTML version. Their SIL Open Font License texts are app/assets/fonts/Archivo-OFL.txt and app/assets/fonts/PublicSans-OFL.txt.

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.

Content#

Page text is written in each page's template. Shared content is typed data in app/data/:

File What it holds
site.ts SITE (name, phone, email, address, the year written in the pages), NAV, MOBILE_NAV, footer link lists
areas.ts AREAS: name, zips, tier ("core" same day or "extended" next day), eta, map points, labelX, labelY
quote.ts PRICES, SIZE, OPTIONS, URGENCY and the calculator's cards and labels (see Quote calculator)
projects.ts PROJECT_FILTERS and PROJECTS (key, title, location, category, duration, budget)
reviews.ts REVIEWS (name, location, service, stars, date, title, text), REVIEW_SERVICES and RATING

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. Update the map's <desc> in AreaMap.vue and the intro text on /areas when you change areas.

The phone number, email and address shown on every page (header, footer, call bar, call buttons and the contact page) come from SITE; the about page's story mentions the Larimer Street workshop in its own words. The price tables on /services and /service, the home page's services, process, projects and FAQ, and the team on /about are written in the pages.

Images and artwork#

All artwork is SVG in public/img/. Templates reference it through useAsset(), which adds the base path:

<img :src="asset('img/after.svg')" alt="After: new tankless water heater mounted on the wall" width="900" height="560">
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 gallery builds each file name from the project key (img/project-<key>.svg); change the path in ProjectGallery.vue if you use photos. public/img/og-image.png (1200 × 630) is the social image and public/img/favicon.svg the favicon.

app/layouts/default.vue renders the skip link, SiteHeader (with the 24/7 bar), the page, SiteFooter, MobileCallBar and BackToTop on every page. Desktop menu items come from NAV in app/data/site.ts; the phone menu shows MOBILE_NAV (Home, NAV, then "Instant quote"). The current page gets aria-current="page". To add a page, create app/pages/<name>.vue with a usePageSeo() call, add its route to routes in nuxt.config.ts, and link it from NAV and the footer lists.

Features#

Quote calculator#

QuoteBuilder.vue renders the four questions and the estimate panel. Every change recalculates with estimate() from app/utils/quote.ts and saves the choices (see Saved data).

The price model in app/data/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 OPTIONS order, then the urgency's add.
  4. Multiply by every percentage: the selected options with a mult, 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.
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 add a service, add its id to PRICES and SERVICE_NAMES (and SIZE_SCALED for whole-home work), a card to QUOTE_SERVICES with its icon (the inside of a 24 × 24 line icon) and a name to REVIEW_SERVICES in app/data/reviews.ts; TypeScript flags a missing entry. The cards' "+$75" and "+50%" are text in QUOTE_URGENCY; update them if you change the numbers. /quote?service=hvac preselects a service (the services pages link this way); unknown values are ignored. 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.vue collects name, phone, email, ZIP code, a preferred date (min is today on the visitor's device), arrival windows (TIME_WINDOWS, at least one) and details. On a valid request the form is replaced with a confirmation: a reference (BL- and four characters from makeRef()), a summary (service, date, windows, the estimate, whether the ZIP code is in an area and the phone number we'll call) and what happens next. Focus moves to the confirmation; "Start another request" empties the form. Nothing is sent; see Forms.

ZIP code checker and coverage map#

<ZipChecker id="hero" /> checks for five digits and looks the ZIP code up with findArea(): core areas get a same-day message, extended areas a next-day message and unknown ZIP codes a "call us" message, in a role="status" live region. On /areas, a match is shared with AreaMap.vue (useState("handy-zip-area")), which adds has-match to the map and is-match to the area: the others fade and the match gets a yellow outline (.area-shape styles in main.css). The map is role="img" with a <title> and <desc>; the city list next to it is the text equivalent.

Before/after slider#

BeforeAfter.vue stacks after.svg and before.svg under a transparent range input. Its value sets --pos, which clips the before picture (.before-after-clip in main.css). It works with mouse, touch and the arrow keys, shows a focus ring and sets aria-valuetext such as "60% before, 40% after". The input id is fixed (ba-range), so use it once per page.

ProjectGallery.vue renders PROJECT_FILTERS as toggle buttons (aria-pressed) and PROJECTS as cards; each project's category must match a filter id. A card opens a native <dialog> lightbox: focus moves to the close button, Escape, the close button or a click on the backdrop closes it and focus returns to the card, and the arrow keys and buttons step through the projects the current filter shows. The count above the grid is a live region.

Reviews#

ReviewBrowser.vue filters REVIEWS by service and stars and sorts by newest, highest or lowest (ties by date). The star filters are all, 5, 4 and 3 ("3 stars & below"). When nothing matches, a message with "Clear filters" appears. The rating summary reads RATING in app/data/reviews.ts; update its numbers and bar widths by hand. The home page shows the first three reviews.

Header, mobile menu, scroll reveals and back to top#

SiteHeader.vue 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 when you follow a link or widen the window to 1024px. app/plugins/reveal.client.ts fades in .reveal elements as they scroll into view (everything above one that appears is revealed too); with reduced motion everything shows at once. BackToTop.vue appears after about one screen of scrolling, returns to the top and focuses <main>; it lifts above the mobile call bar (data-back-to-top-avoid) and steps aside while the footer's "Back to top" link is on screen. Remove <MobileCallBar /> or <BackToTop /> from the layout to drop them.

In the HTML version every link loads a page. Here links are client-side navigations, so app/app.vue keys each page by its path and query, and app/plugins/same-page.client.ts restarts the page when a link points at the page already showing (the header's "Free quote" button on /quote) and scrolls to the top, as a page load would.

Forms#

Form Component Validates
ZIP checker ZipChecker.vue Five digits; looks up AREAS only, nothing to send
Booking request QuoteBuilder.vue Name, phone (10 digits), email, ZIP code (5 digits), date (today or later), at least one window, details (20+ characters)
Contact ContactForm.vue Name, email, optional phone (10 digits if filled), message (10+ characters)

Both forms use useValidation() from app/composables/useValidation.ts, with the HTML version's rules and messages. It reads required, type="email", minlength and data-validate (phone, phone-optional, zip, future-date). Bind v-bind="v.field(id)" on a field and put <FieldError :id="${id}-error" :message="v.errors[id]" /> after it; v.validateAll(form) checks everything on submit and v.focusFirstInvalid(form) moves focus to the first error. Errors appear when you leave a field that has a value, update as you type, and are linked with aria-describedby and aria-invalid.

Nothing is sent until you connect the forms, although the confirmations read as if it had been: they show a reference (BL- and four characters, with no 0/O or 1/I to misread over the phone) and promise a call within 15 minutes (8 a.m.–7 p.m.) or a reply within one business day. Change them if you work differently.

To post the contact form to a form service such as Formspree, replace the "To send for real" comment in onSubmit():

ts
const res = await fetch("https://formspree.io/f/your-id", {
  method: "POST",
  body: new FormData(form),
  headers: { Accept: "application/json" },
});
if (!res.ok) return alert("Sorry, your message could not be sent. Please call us.");

For the booking request, do the same in onRequest() in QuoteBuilder.vue, adding the calculator's state:

ts
data.append("service", state.service);
data.append("size", state.size);
data.append("options", state.options.join(", "));
data.append("urgency", state.urgency);
data.append("estimate", range.value);

With pnpm build (a Node.js server build) you can post to your own server route instead, for example server/api/contact.post.ts; a static site from pnpm generate has no server routes. Validate again on the server.

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 app/utils/quote.ts runs after the page has loaded 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. A ?service= in the URL overrides the saved service without saving it. safeStorage keeps working in memory when storage is blocked or full. The key and shape are the same as the HTML version's. If you change the shape, rename QUOTE_KEY (for example handy-quote-v2).

SEO and sharing#

  • Each page calls usePageSeo({ title, description }) (app/composables/usePageSeo.ts), which sets the title, description, canonical URL, Open Graph tags (with og:image at img/og-image.png) and the Twitter card. The 404 page passes noindex: true.
  • Canonical, og:url and og:image are absolute: SITE_URL (runtimeConfig.public.siteUrl in nuxt.config.ts, falling back to https://brightline.example) plus the base path. Set it when you build:
sh
SITE_URL=https://www.your-company.com pnpm generate
  • lang, the theme color and the favicon are in app.head in nuxt.config.ts.

For launch, consider adding a sitemap and public/robots.txt, and HomeAndConstructionBusiness structured data (name, phone, address, hours, area served) as JSON-LD on the home page with useHead().

Accessibility#

Built in:

  • a "Skip to content" link, labeled landmarks and breadcrumbs, and one h1 per page;
  • 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, with focus moved to the first error and to confirmations;
  • toggle buttons with aria-pressed for gallery and star filters, and star ratings with role="img" and a label;
  • a native <dialog> lightbox with focus return, a native range input for the slider and native <details> FAQs;
  • reduced motion respected by reveals, smooth scrolling, the pulsing dots and the back-to-top button.

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

Building and deploying#

sh
pnpm generate     # static site in .output/public/
pnpm preview      # serve it locally

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

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

sh
DEMO_BASE_PATH=/handy pnpm generate

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

TypeScript setup#

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

Troubleshooting#

  • A ZIP code says it's outside the area. It must be in AREAS[].zips as a string, for example "80205".
  • The calculator starts with old choices. It restores handy-quote-v1; clear it in your browser's developer tools (Application, Local Storage).
  • TypeScript errors after adding a service. Add the id to PRICES, SERVICE_NAMES and REVIEW_SERVICES.
  • A new page is missing from the generated site. Add its route to routes in nuxt.config.ts.
  • pnpm lint can't find .nuxt/eslint.config.mjs. Run pnpm install or npx nuxt prepare first.
  • A Tailwind class has no effect. Tailwind only generates classes it finds as complete strings in the source. Don't build class names from pieces; write the full class.
  • Social previews use brightline.example. Set SITE_URL when building.

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 Public Sans are licensed under the SIL Open Font License (app/assets/fonts/*-OFL.txt). Brightline Home Services, its team and its customers are fictional.

Search documentation

Search the guides and every template's documentation