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).
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.devCustomizing#
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:
{ 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.
Navigation, header and footer#
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:
- Start from
PRICES[service]. - Apply the home size factor. Services in
SIZE_SCALEDuse the full factor; others use1 + (factor - 1) * 0.3, so a large home adds 7.5% to a repair instead of 25%. - Add every fixed add-on: the selected options with an
addvalue, inOPTIONSorder, then the urgency'sadd. - Multiply by every percentage: the selected options with a
mult, inOPTIONSorder, then the urgency'smult. Multipliers apply to the whole running total, add-ons included. - 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.
Project gallery#
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.
Links to the same page#
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():
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:
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 (withog:imageatimg/og-image.png) and the Twitter card. The 404 page passesnoindex: true. - Canonical,
og:urlandog:imageare absolute:SITE_URL(runtimeConfig.public.siteUrlinnuxt.config.ts, falling back tohttps://brightline.example) plus the base path. Set it when you build:
SITE_URL=https://www.your-company.com pnpm generatelang, the theme color and the favicon are inapp.headinnuxt.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
h1per page; - visible
:focus-visibleoutlines; - a menu button with
aria-expanded,aria-controlsand 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-describedbyandaria-invalid, with focus moved to the first error and to confirmations; - toggle buttons with
aria-pressedfor gallery and star filters, and star ratings withrole="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#
pnpm generate # static site in .output/public/
pnpm preview # serve it locallynuxt generate prerenders every route listed in routes in nuxt.config.ts as <page>.html (services.html,
linked as /services), plus 404.html from app/error.vue. Next to each page is a folder with its
_payload.json, which Nuxt loads when you move between pages. Upload .output/public/ to any static host (Netlify,
Cloudflare Pages, GitHub Pages, S3, Vercel). Hosts that don't map /services to services.html on their own need that
rewrite (Vercel: "cleanUrls": true); most serve 404.html for unknown URLs.
To serve the site from a sub-folder, set DEMO_BASE_PATH, which becomes Nuxt's app.baseURL:
DEMO_BASE_PATH=/handy pnpm generatepnpm build makes a Node.js server build instead (run it with node .output/server/index.mjs); the pages are still
prerendered, and you can add server routes. demo/ is the freeframe.dev demo build and can be deleted.
TypeScript setup#
pnpm typecheck runs vue-tsc against the configs Nuxt writes to .nuxt/ (tsconfig.json references them).
typescript is aliased to @typescript/typescript6, the TypeScript 6 API that vue-tsc and typescript-eslint load;
replace it with typescript@7 once they support it.
Troubleshooting#
- A ZIP code says it's outside the area. It must be in
AREAS[].zipsas 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_NAMESandREVIEW_SERVICES. - A new page is missing from the generated site. Add its route to
routesinnuxt.config.ts. pnpm lintcan't find.nuxt/eslint.config.mjs. Runpnpm installornpx nuxt preparefirst.- 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. SetSITE_URLwhen 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.