Local Pro Quotes

Local business app · built by the Vibecode team

Harbor & Pine is a demo home-improvement contractor in Kitsap County, WA, with a quote form in the hero that checks the visitor's ZIP, six services with "from" prices and a ballpark estimator. Past jobs sit on a map as round photo pins and open into project pages with a before/after slider. Every request is saved before any alert goes out and lands in an owner CRM at /admin with call, text and WhatsApp buttons, a pipeline board, notes, private photos and CSV export. The owner edits prices, projects, reviews, page text and site-visit times from the same admin, and a publish checklist lists what to replace before going live. Photos are labelled AI samples and the reviews are labelled placeholders.

  • Web app
  • Built in 7 prompts
  • Free to remix
Local Pro Quotes, local business app built with Vibecode
Remix this template

Remix copies the working app into your account for free, exactly as we built it. It runs in your browser.

The prompt that builds it

We built Local Pro Quotes in 7 prompts. Here they are folded into one, so a single paste into a new app builds the finished version.

Prompt · 8,865 words

Build "Local Pro Quotes": a complete lead-generation website plus owner admin and lead CRM for a local home-improvement contractor. The demo business is Harbor & Pine Home Improvement, a fictional contractor in Kitsap County, Washington. It is made for roofers, painters, deck builders, cleaners, landscapers, electricians and auto shops: their visitor wants to know three things fast (Do they cover my town? Are they legit? How do I get a price?), and the owner wants to know the moment a lead arrives. It must look like a polished, designed product, not an AI template: no purple or blue gradients, no emoji, no glassmorphism, no default shadcn look, real typography, one accent colour.

Honesty rules (these matter to real contractors): the business, people, photos and reviews are fictional and must be labelled as demo/sample in the UI as described below. Never invent stats ("500+ happy customers", "#1 rated"). Any business fact left empty in admin hides its UI instead of showing a placeholder. Site copy is plain, local and no-hype, with short sentences and no em dashes.

## Reference screenshots (look at them first)

Design references (the original screenshots are not included; build from these descriptions):
1. Reference: a clinic site we like. Navy section, small eyebrow pill, and floating contact buttons at the bottom corners (a "Book Now" pill, a round gold Call button, a round navy Chat button). Borrow the navy confidence and the floating contact idea, not the brand.
2. Reference (Clinic site phone action bar): the same site at phone width: big stacked Call / Text buttons and fixed contact buttons at the bottom. Our phone version gets a fixed bottom bar: Call · Text · Get a quote.
3. Reference (Clinic site trust strip and rating chips): its trust strip (icon + short label items in one row, hours with an "Open now" chip, phone) and "TRUSTED ACROSS" rating chips ("Google ★ 5.0 · 111 reviews"). The pattern for our trust strip.
4. Reference: a window company whose hero has the quote form in a card beside the photo. Our hero uses this layout (form card on the right), but in our navy theme and far cleaner.
5. Reference (Power navy uppercase header): a remodeler with navy, letterspaced uppercase nav and a solid block "GET A FREE QUOTE" button in the header's right corner. Borrow the confident uppercase type and the block call to action.
6. Reference: a Google Business Profile reviews panel (names blurred): big average number, 5-to-1 bar histogram, "Write a review" pill, topic chips with counts ("roof replacement 20"), initial-letter avatars, stars with a relative date. Our reviews section copies this pattern.
7. Reference: Jobber (field-service software) home: "Workflow" cards with coloured top borders and big counts. Our /admin Today screen uses this for New, Contacted, Estimate sent, Won.
8. Reference: Jobber's client detail: name with a "Lead" badge, Email / Edit / More actions, a Contact info column, internal notes. Our lead detail layout.

If an image Read comes back empty, shrink it to 720px wide with ffmpeg and read the copy; don't stall on it. Delete the refs/ folder when the build is finished: other companies' screenshots must not ship with the site.

## Stack and ground rules

- Use this workspace's stack: React + Vite + Tailwind + React Router + React Query + Framer Motion + lucide-react in webapp/, Hono on Bun in backend/. Use the database-auth skill for Prisma (SQLite) and Better Auth, the upload-assets skill for Vibecode Storage, and the vibecode-apis skill for image generation and Resend email. Shared Zod contracts in backend/src/types.ts.
- The public site is never behind a password. Only /admin is.
- Seed realistic demo data automatically on backend start when the database is empty (so remixes and fresh deployments work), including the owner account.
- Remixes get a copy of backend/.env with BETTER_AUTH_SECRET cleared, and the standard env check would then stop the backend at boot. Add backend/src/lib/authSecret.ts exactly as below and call ensureAuthSecret() in backend/src/env.ts before the environment is validated (keep the validation):

```ts
import { randomBytes } from "node:crypto";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";

const LINE = /^[ \t]*(?:export[ \t]+)?BETTER_AUTH_SECRET[ \t]*=(.*)$/gm;

/** Remixes get a copy of .env with this value cleared, so a fresh copy generates and saves its own secret on first start. */
export function ensureAuthSecret(envPath = join(import.meta.dir, "..", "..", ".env")) {
  if (process.env.BETTER_AUTH_SECRET?.trim()) return;
  const secret = randomBytes(32).toString("base64");
  const line = `BETTER_AUTH_SECRET=${secret}`;
  try {
    const text = existsSync(envPath) ? readFileSync(envPath, "utf8") : "";
    // Bun only loads the .env in the working directory, so a saved secret can exist without being in process.env.
    const saved = [...text.matchAll(LINE)].map((m) => m[1]!.trim().replace(/^(["'])(.*)\1$/, "$2")).filter(Boolean).pop();
    if (saved) { process.env.BETTER_AUTH_SECRET = saved; return; }
    writeFileSync(envPath, text.match(LINE) ? text.replace(LINE, () => line) : `${text}${text && !text.endsWith("\n") ? "\n" : ""}${line}\n`);
  } catch (e) {
    console.warn("Couldn't save BETTER_AUTH_SECRET to .env; logins will reset when the server restarts", e);
  }
  process.env.BETTER_AUTH_SECRET = secret;
  console.log("Generated a new BETTER_AUTH_SECRET");
}
```

- Owner sign-in is email + password. Seeded owner: email `owner@harborandpine.example`, password `HarborPine-2026`. Show these credentials on the /admin sign-in page in a card titled "Demo owner login" with a "Use demo login" button that fills both fields, until the owner changes the password in Settings. Disable public sign-up on the server (only the seeded owner exists). Every /api/admin/* route checks the session on the server.
- Replace the template's favicon.ico, og-base.png, title and meta. No Vibecode branding anywhere: favicon, title, share image, footer, meta generator.

## Look and feel: "Trusted Navy"

- Colours (CSS variables, used through Tailwind tokens):
  - navy `#0A2340` (dark sections, logo, headings on white), navy-900 `#061A31` (top bar, footer), navy-700 `#12345C` (cards and hovers on navy)
  - amber `#F2A24B` (primary buttons, stars, numerals, active chips), amber-600 `#E08A2E` (hover). Buttons on amber use navy text, never white.
  - ice `#9FD8EA` (1px hairlines and small labels on navy, focus ring on navy)
  - ink `#13243A` (body text on white), slate `#52627A` (secondary text), line `#DDE4EC` (hairlines on white), mist `#F4F7FA` (alternate light sections), white
  - success `#2F8F5B`, error `#C2410C`
- Type: Barlow Condensed (Google Fonts, 600 and 700) for headings, ALWAYS uppercase via CSS (keep sentence case in the DOM). Inter (400/500/600) for body. H1 72px/0.95 desktop, 40px phone. H2 48px/1.0 desktop, 34px phone. H3 26px. Eyebrows: Inter 600 13px uppercase, letter-spacing .14em (ice on navy, amber-600 on white). Body 17px/1.6 desktop, 16px phone. Prices use tabular numbers. Every H1 and H2 uses `text-wrap: balance`, and every displayed phone number stays on one line (white-space: nowrap) everywhere, admin included.
- Near-square corners: 2px radius on buttons, inputs, cards and chips. Only map pins and avatars are round. Almost no shadows; use 1px borders. Cards on hover: border turns navy and the card lifts 2px.
- Buttons: primary = amber background, navy text, Barlow Condensed 600 uppercase 17px, letter-spacing .04em, height 52px (40px small), padding 0 24px. Secondary = 1px border (ice on navy, navy on white), transparent background.
- Spacing scale 4/8/12/16/24/32/48/64/96. Sections 96px vertical padding desktop, 64px phone. Container 1200px max, 32px side padding desktop, 20px phone.
- Icons: lucide-react at 1.75 stroke. No emoji anywhere.
- Logo: build it as an inline SVG React component: a 32px navy square (2px radius) holding a simple amber pine-tree triangle over two short ice-blue wave lines, then "HARBOR & PINE" in Barlow Condensed 700 22px (letter-spacing .06em, navy) with "HOME IMPROVEMENT" under it in Inter 600 9px letter-spacing .22em. Use the same mark for the favicon (SVG plus 32px PNG and 180px apple-touch icon).
- Theme presets switchable in admin Settings, all through the same CSS variables (hero and page-hero overlay gradients, the Sample chip background and shadows are built from the dark section colour variable, e.g. `rgb(var(--navy) / .78)`, so each preset recolours them): "Trusted Navy" (default, above), "Earth" (forest `#1F3D2B` sections, cream `#F4EFE3` light sections, ochre `#D9A441` buttons, sage `#A9C2A0` hairlines), "Coastal" (white, soft sky `#EAF4FB` light sections, deep navy `#0B2A4A`, teal `#138A8A` buttons with white text, `#9ED3D3` hairlines).

## Business facts (one source of truth)

Stored once in the Business table and used everywhere (top bar, header, footer, forms, FAQ answers, schema, llms.txt):
- Name: Harbor & Pine Home Improvement. Base: Poulsbo, WA 98370 (no street address is shown anywhere). Area: Kitsap County.
- Phone and text: (360) 555-0142 → `tel:+13605550142`, `sms:+13605550142`, WhatsApp `https://wa.me/13605550142`. Email: office@harborandpine.example
- WA contractor registration HARBOPH812KM. Bonded and insured, $2M general liability. In business since 2012. Owner Erik Halvorsen.
- Hours: Mon–Fri 7:00 AM–5:00 PM, Sat 8:00 AM–12:00 PM (estimates only), Sun closed. Timezone America/Los_Angeles, USD, en-US.
- Google review link (demo): `https://www.google.com/maps/search/?api=1&query=Harbor+%26+Pine+Home+Improvement+Poulsbo+WA`. Instagram `https://www.instagram.com/` and Facebook `https://www.facebook.com/` (placeholders). GTM ID and Google Ads conversion ID empty.
- Service area towns (town, ZIP, lat, lng): Poulsbo 98370 47.7359,-122.6465 · Bainbridge Island 98110 47.6262,-122.5212 · Silverdale 98383 47.6445,-122.6946 · Bremerton 98312 47.5673,-122.6326 · Port Orchard 98366 47.5404,-122.6363 · Kingston 98346 47.7987,-122.4982 · Hansville 98340 47.9187,-122.5546 · Suquamish 98392 47.7312,-122.5523 · Keyport 98345 47.7023,-122.6232 · Seabeck 98380 47.6398,-122.8287 · Olalla 98359 47.4273,-122.5612 · Manchester 98353 47.5559,-122.5446
- Phone numbers display as "(360) 555-0142". Dates are always human-readable: "Today, 9:42 AM", "Yesterday", "Mon, Oct 27", "Oct 27, 2025". Reviews use Google-style relative dates ("3 weeks ago") with the exact date in a tooltip.

## Demo labelling

- Business.demoMode is on by default. While on: a slim dismissible bar above the top bar (navy-900, ice text 13px, X button, dismissal remembered in localStorage): "Demo site. Harbor & Pine is a fictional company; photos are AI samples and reviews are placeholders. Owner sign-in: /admin" (the /admin part is a link).
- Every seeded image shows a small chip in its bottom-left corner: "Sample photo" (Inter 600 10px uppercase, letter-spacing .1em, white on rgba(10,35,64,.6), 2px radius). Each image row has an isSample flag; photos the owner uploads have it off, so their chip never shows. Below 160px of image width the chip shortens to a one-line "Sample" (9px).
- Every seeded review shows a small "Demo review" tag. The footer says "Demo content: business details, photos and reviews are placeholders." while demoMode is on.

## Home page, top to bottom

1. **Top bar** (navy-900, 13px, ice/white): "Licensed & insured · Serving Kitsap County · Call (360) 555-0142" (the phone is a tel: link). Scrolls away.
2. **Sticky header** (white, 76px, bottom 1px line, subtle shadow after 8px scroll): logo left; nav in Barlow Condensed 600 uppercase 16px letter-spacing .08em: Services (dropdown with the six services), Projects, Reviews, Service area, FAQ; right: phone link with a Phone icon "(360) 555-0142", then a solid amber block button "Get a free quote" that scrolls to the full form (#quote).
3. **Hero** (680px desktop): full-bleed finished-exterior photo with a navy gradient overlay from the left (rgba(10,35,64,.94) at 0% → .78 at 42% → .12 at 78%). Left column (7/12): eyebrow "Poulsbo, WA · Since 2012"; H1 "Home repairs and upgrades, done right the first time." in white; subline (20px, white 88%) "Roofing, decks, siding and painting across Kitsap County. Free written estimates within 48 hours."; three lines with ice-blue check icons: "Free written estimate within 48 hours", "Licensed, bonded and insured in Washington", "One crew from first board to final cleanup"; then text links "Call (360) 555-0142" and "Text us" (white, with icons). Right column (5/12): white quote card, 420px, 4px amber top border, 28px padding: title "Get a free quote" (Barlow 30px navy), sub "Takes 30 seconds. We reply within one business day." Fields: "What do you need?" (select of the six services plus "Rental turnover & repairs" and "Something else"), "ZIP code" (5 digits; checks the service area live and shows a success-green check icon with "We work in Silverdale" or "Outside our usual area. Send it anyway and we'll let you know." in slate), "Your name", "Mobile number" (auto-formats as you type). Button "Get my free quote" full width. Microcopy under it: "By sending, you agree we can call or text you about this request. No spam." Submits the lead (source "hero") and goes to /thank-you.
4. **Trust strip** (white band, 1px line top and bottom, four items in a row separated by 1px lines): amber icon + small uppercase slate label + Barlow 22px navy value: "Licensed · WA #HARBOPH812KM", "Bonded & insured · $2M liability", "In business since · 2012", "Google rating · 4.9 ★ (12 reviews)" (computed from the reviews table, links to the Google review link, shows the small "Demo" tag while any review is a demo). Each item hides itself when its field is empty.
5. **Services** (white): eyebrow "Services", H2 "What we do", 3×2 grid of cards: photo (3:2, Sample photo chip), name (Barlow 26px), one-line description (15px slate), price block in navy Barlow 22px with small Inter unit text, then an amber small "Get a quote" button (scrolls to #quote with that service selected) and a "Details →" link to /services/<slug>. Under the grid, an info line: "Final price after a free site visit." plus a link "Want a rough number first? Try the ballpark estimator".
   - Roofing (`roofing`): "Leak repairs, moss damage and full tear-offs. Soft decking gets replaced before it's covered." Repair from $450 · Replacement from $9,500.
   - Decks & porches (`decks-porches`): "Rot repairs, new stairs and railings, and new decks in cedar or composite." Repairs from $1,200 · New from $35 per sq ft.
   - Siding & exterior trim (`siding-trim`): "Fiber-cement and cedar siding, rotted trim replaced, flashing done right behind it." From $2,500.
   - Interior & exterior painting (`painting`): "Careful prep, two coats, clean lines. Interiors by the room, exteriors by the house." From $450 per room · Exterior from $3,200.
   - Windows & doors (`windows-doors`): "Replacement windows and entry doors, flashed and trimmed for Northwest rain." From $650 per window.
   - Fencing (`fencing`): "Cedar privacy fences, gates that stay square, and storm repairs." From $28 per linear ft.
6. **Ballpark estimator** (mist section, id "estimator"): eyebrow "Ballpark estimator", H2 "Get a rough number in a minute". Tabs for the six services. Inputs come from database pricing rules: Roofing (Repair: fixed from $450; Replacement: roof area slider 1,000–4,000 sq ft, default 1,800, $5.25/sq ft, min $9,500), Decks (Repair: from $1,200; New deck: size 100–800 sq ft, default 300, $35/sq ft, min $5,000), Siding (wall area 300–3,000 sq ft, default 1,200, $9.50/sq ft, min $2,500), Painting (Interior: rooms 1–12 at $450 each; Exterior: Small under 1,500 sq ft $3,200 / Medium 1,500–2,500 $4,800 / Large over 2,500 $6,800), Windows & doors (windows 1–30 at $650, exterior doors 0–4 at $1,800, min $650), Fencing (length 25–400 linear ft, default 120, $28/ft, min $1,400). Result card: the math line ("300 sq ft × $35 per sq ft"), a big range in Barlow 56px navy ("$10,500 – $12,600": low = max(min, quantity × rate), high = low × 1.2, rounded to $100), the label "Ballpark, not a quote." and "Final price after a free site visit.", for a fixed-price rule (unit job) the card shows "From $450" instead of a range; and an amber button "Send me a written estimate" that scrolls to #quote with the service, the estimator details in the description and the ballpark stored on the lead.
7. **Recent projects** (navy section): eyebrow "Recent projects", H2 "Work around Kitsap County", sub (white 75%) "Tap a pin to see the job. Pins mark the town, never the street address." Desktop: Leaflet map left (7/12, 520px tall, 1px ice border; OpenStreetMap tiles `https://tile.openstreetmap.org/{z}/{x}/{y}.png` with the OSM attribution, muted with a light grey CSS filter (CARTO's free basemaps now need an API key); zoomSnap 0.25 and zoomDelta 0.5; once the container has its size (after invalidateSize) fitBounds to the six pins with 36px padding; scroll-zoom off until the map is clicked; `.leaflet-container img.leaflet-tile { mix-blend-mode: plus-lighter; }` so fractional zoom shows no seams between tiles). Use the same tiles and fitting on /projects, the project page's mini map and the admin pin map. Pins are 48px round cover photos with a 3px white ring and a soft shadow (Leaflet divIcon, no default marker images); they pop in with a short stagger; the active pin gets an amber ring. Right (5/12): the selected project card (photo, title, "Poulsbo · 2026 · Decks & porches", two-line description, "View project" button). Hovering a gallery card highlights its pin. Below: filter chips (All plus each service that has projects; ice 1px border, active = amber with navy text), then a 3-column gallery of project cards (4:3 cover with Sample chip, title in white Barlow 22px, meta in ice 13px) linking to /projects/<slug>, and a "See all projects" link to /projects. The map must render on phones: explicit height, call invalidateSize() after mount and on resize.
8. **How it works** (white): H2 "How it works", three columns with big amber Barlow numerals "01", "02", "03" on a shared 1px ice hairline:
   1. "Tell us about the job." / "Photos welcome. Use the form, send a text or call. We reply within one business day."
   2. "We visit and give you a written estimate." / "Free and on site, usually within the week. Itemized, in writing, no pressure."
   3. "We schedule, build and clean up." / "One crew from start to finish. We haul away the debris and run a magnet sweep for nails."
9. **Service area** (mist, id "area"): left: H2 "Where we work", "We're based in Poulsbo and work across Kitsap County.", the 12 towns in three columns (check icon, town, ZIP in small slate). Right: the branded van photo (Sample chip) with caption "Look for the navy van.", then a white card "Check your ZIP" (input + "Check" button, same coverage result as the hero), then "Not sure we cover you? Text us." with an outline button "Text (360) 555-0142" (sms: link).
10. **Reviews** (white, id "reviews"), Google Business Profile style: left column: "4.9" in Barlow 72px navy, five amber stars, "12 reviews", a 5-to-1 histogram with amber bars, an amber button "Leave us a review" (to /review) and a link "See us on Google ↗" (hidden when the Google link is empty), then topic chips with counts ("on schedule 5", "cleanup 6", "written estimate 4", "fair price 4", "communication 5", "careful prep 3"). Right: a 2-column grid of six review cards (1px line border, 20px padding): 40px round avatar with the initial (white on one of #0A2340, #2F6F8F, #7A5C3E, #3E6B4E, #8A4F7D, #5B6577), name 15px 600, "Bainbridge Island · Roofing" 13px slate, stars plus relative date, text clamped to 5 lines with "More", an owner response block (mist background, "Response from the owner" bold) when present, and a footer row with a small Google "G" mark and "Google", or the "Demo review" tag. "See all 12 reviews" expands the rest.
11. **Meet the owner and crew** (navy): crew photo left (crew in front of the van, Sample chip); right: eyebrow "Meet the owner", H2 "Erik Halvorsen and crew", story (white 85%): "I started Harbor & Pine in 2012 after ten years framing houses around Kitsap County for other builders. We're a crew of six. We do the work ourselves, so the people who give you the estimate are the people on your roof." / "We like jobs where we leave a house better than we found it: dry, square and tidy. If something we built ever gives you trouble, call me directly." Then credentials with ice check icons: "WA contractor registration HARBOPH812KM", "Bonded and insured: $2M general liability plus workers' comp", "EPA Lead-Safe Certified Firm", "In business since 2012", and a signature line "Erik Halvorsen, Owner". Crew names in small text under the photo, left to right as they stand in it: Erik Halvorsen (owner), Marisol Reyes (project lead), Tomás Ortega (lead carpenter), Bea Walsh (painter).
12. **Landlords & property managers** (white, a bordered panel with a 4px navy left bar; 64px of section padding above it, 48px on phones, so it never touches the navy section; can be hidden in admin): eyebrow "For landlords & property managers", H3 "Turnovers, cleanouts and repairs", text "Turnovers, cleanouts and repairs between tenants. One call, a written scope, and photos when it's done.", bullets "Turnover punch lists, painting and patching", "Lockbox access, no need to meet us", "Photo report and an invoice per unit", button "Ask about a property account" (scrolls to #quote with "Rental turnover & repairs" selected).
13. **FAQ** (white, id "faq"): left (5/12): H2 "Questions we get a lot", breaking as "Questions we / get a lot", and "Still have a question? Text us." (sms link). Right (7/12): accordion, first item open, answers filled from Business facts:
   - "Are estimates free?" "Yes. We come out, measure, and send a written, itemized estimate within 48 hours of the visit. No charge and no pressure."
   - "Are you licensed and insured?" "Yes. We're a registered Washington contractor (#HARBOPH812KM), bonded, and we carry $2M in general liability plus workers' comp for the crew. We'll email the certificates before we start."
   - "Who handles permits?" "We do. If the job needs a permit from Kitsap County or your city, we pull it, schedule the inspections and include the fee in your estimate."
   - "How does payment work?" "A deposit to reserve your start date, a progress payment at the halfway point, and the balance after the final walkthrough with you. We never ask for full payment up front. We take checks, ACH and cards."
   - "What warranty do you give?" "Five years on our workmanship for roofs, decks, siding and windows, and two years on paint. Manufacturer warranties on materials pass to you in writing."
   - "How soon can you start?" "Small repairs are often scheduled within two weeks. Larger jobs usually start 3 to 6 weeks after you sign. Your estimate shows the next open start date."
   - "Do you work in the rain?" "Some jobs, yes. We stage roofing around the forecast and tarp at the end of each day, and we only paint exteriors when the surface is dry and above 50°F."
14. **Full quote form** (navy section, id "quote"): left: H2 "Get a free written estimate", "Tell us about the job. Photos help us give you a closer number before we visit.", contact rows with icons (Call, Text, WhatsApp, Email; all real links), the hours, "We reply within one business day." The left column is sticky (top 112px) so it stays beside the form card while you scroll. Right: white form card (640px, 32px padding): Name* and Phone* (two columns), Email and ZIP* (two columns), Street address (optional, "Only used to plan the visit"), Service* (select), "Describe the job"* (textarea, placeholder "Example: Back deck is about 12×16. Two boards are soft near the stairs and the railing wobbles."), "Best time for a site visit" (chips for the next 10 open estimate windows from the owner's availability, e.g. "Thu, Nov 6 · 8–10 AM", plus "I'm flexible" and "Call me to schedule"; each window chip has two lines that never break inside, the date ("Thu, Nov 6") in 13px slate over the window ("8–10 AM") in 15px ink 600; three chips per row on desktop, two at 390px; a taken window can never be picked twice), "Photos (up to 10)" (drag-and-drop or tap to choose, thumbnails with remove buttons, images resized in the browser to max 2000px JPEG before upload; the server checks each file's real type by its first bytes (JPEG, PNG, WebP, HEIC), allows at most 15 MB each and rejects anything else with "That file isn't a photo." shown inline, here, on the thank-you page's Add photos and in the admin project upload), "How did you hear about us?" (Google search, Google Maps, Facebook, Nextdoor, Yard sign, Saw our van, Friend or neighbor, Other), a required consent checkbox "I agree that Harbor & Pine may contact me about this request by call, text or email. Message and data rates may apply. Reply STOP to opt out.", an invisible spam trap (an off-screen "Company website" field with a unique id per form, tabindex -1 and autocomplete off, plus a minimum fill time of 2.5 seconds), and the submit button "Send my request" (56px, full width). Inline validation messages in error red under each field. While sending: spinner and "Sending…". If the request fails: a red box "We couldn't send that. Your details are still here. Try again or call (360) 555-0142." and the form keeps its values (draft kept in localStorage). On success go to /thank-you.
15. **Footer** (navy-900): logo and one line "Roofing, decks, siding and painting across Kitsap County."; columns "Contact" (phone, text, WhatsApp, email), "Hours" (from the hours table), "Service area" (towns as a comma list), "Licensed & insured" (registration number, bonded and insured line); social icons (hidden when empty); bottom row: "© 2026 Harbor & Pine Home Improvement", "Privacy", "Leave a review", "Owner sign-in", and the demo line while demoMode is on.

## Other public pages

- **/services/:slug** (one per service): hero band with the service photo under a navy overlay, breadcrumb, H1 (service name), price line, buttons "Get a free quote" and "Call (360) 555-0142". Then 250–350 words of plain, specific copy in the same tone (what we do, materials, how long it takes, permits), a "What's included" checklist, a price box ("From $450 · Final price after a free site visit."), that service's ballpark estimator, related projects (cards), three or four service FAQs, and a short quote form with the service preselected (source "service-<slug>"). Page title "Roofing in Kitsap County, WA | Harbor & Pine".
- **/projects**: the map and filterable gallery as a full page.
- **/projects/:slug**: breadcrumb "Projects / <title>", H1 title, meta row "Poulsbo · Decks & porches · 2026" (single spaces around the dots) followed by the status chip "Completed" or "In progress", a before/after slider (after image underneath, before image clipped by clip-path, 2px white divider with a 44px round handle with a left-right chevron icon, "Before" and "After" chips in the top corners, starts at 50%, drag with pointer events, keyboard arrows move 5%, role="slider" with aria values), the description, a photo grid with captions and a lightbox, a small map with the single approximate pin, related projects, and a CTA band "Want something like this?" with the amber button "Request a similar project" (goes to /quote?service=<slug>&ref=<project-slug>, which prefills the description with "Similar to: <title> (<town>, <year>)").
- **/quote**: the full quote form on its own page (reads ?service= and ?ref=). Ads can point here.
- **/thank-you**: navy hero "Thanks, Rachel. Your request is in." (first name from the lead), "We got it at 2:14 PM. Erik or Marisol will call or text you from (360) 555-0142 within one business day.", a summary card (reference number like "HP-1042", service, town, visit window, photo count), "What happens next" (the three steps), buttons "Call now" and "Text us", and "Add photos" (optional uploader that attaches up to 10 more photos to that lead using a one-time token returned when the lead was created). This page fires the conversion exactly once per lead (guard in sessionStorage): `dataLayer.push({event: "generate_lead", lead_ref, service, value})` and, when a Google Ads conversion ID and label are set, `gtag("event", "conversion", {send_to: "<id>/<label>", value, currency: "USD", transaction_id: lead_ref})`.
- **/review**: centered card "How did we do?" with five large tappable stars and an optional name. 4–5 stars: thank-you message, "Would you share that on Google? It helps neighbours find us.", a button "Leave a Google review" and an automatic redirect to the Google review link after 2 seconds with a visible countdown. 1–3 stars: a private feedback form ("We're sorry it wasn't great. Tell us what happened. This goes straight to Erik, not the internet.", text + optional phone or email, button "Send to Erik") that saves to the Feedback table, plus a small secondary link "You can also post a public review on Google" (Google does not allow hiding the public option, so keep it visible, including under the success message after the note is sent). Every rating is saved.
- **/privacy**: a short plain-English privacy policy built from the business facts (what the forms collect, that photos stay private, no selling of data, tracking tags only when set).
- A designed 404 page.

## Phone layout (390px)

- Demo bar wraps to two lines at 12px. Top bar shortens to "Licensed & insured · (360) 555-0142".
- Header 60px: logo (mark + "HARBOR & PINE" at 18px) and a menu button opening a full-height sheet with the nav, phone and the quote button.
- Fixed bottom action bar on every public page under 768px: white, 1px top line, 64px plus safe-area inset, three equal buttons with icons: "Call" (tel:), "Text" (sms:), and "Get a quote" (amber, navy text, bold; scrolls to #quote or goes to /quote). Add bottom padding to the page so nothing hides behind it. Never on /admin.
- Hero: the photo first (240px tall, Sample chip), then a navy panel with eyebrow, H1 40px, subline and checks, then the quote card full width.
- Trust strip as a 2×2 grid. Services as compact horizontal cards (112px square photo left; name, one-line description, price and a small "Get a quote" button right). Estimator stacked. Projects: map 340px tall, chips scroll horizontally, gallery 2 columns. Towns 2 columns. Reviews: summary, then three cards and "See all reviews". FAQ and forms full width, visit chips two per row. No text may be cut off and nothing may scroll sideways.

## Admin (/admin)

Desktop: a left sidebar (navy-900, 240px) with the logo, Today, Leads, Projects, Services & prices, Reviews, Content, Service area, Visit times, Settings, Publish checklist, and the owner's email with "Sign out" at the bottom; content on white/mist. Phone: bottom tab bar "Today · Leads · Projects · More" (More opens a sheet with the rest). Use the same theme, Inter for UI text, Barlow Condensed for page titles. Saves show a small toast "Saved". Deletes ask for confirmation.

- **/admin/login**: centered card on navy: logo, "Owner sign-in", email, password, "Sign in", "Forgot password?" (email code reset), and the "Demo owner login" card described above.
- **Notification banner** (top of every admin page): a red banner when any owner alert failed ("A lead alert didn't send: Email to office@harborandpine.example on Nov 3 at 2:14 PM. Resend said: <error>. The lead is saved." with "View lead", "Retry" and "Dismiss"), and otherwise ONE slim amber setup banner (one line on desktop, at most two lines at 390px) naming the channels that still need setup: "Alerts need setup: email (verify a sending domain) and texts (add Twilio keys). New leads are still saved." with an "Open settings" button. Raw provider errors appear only in Settings → Notifications and the notification log.
- **Today**: "Good morning, Erik" with the date, "Last lead: 2 hours ago" (amber warning "No new leads in 8 days. Send a test lead to check your form." when it has been more than 7 days), Jobber-style workflow cards with coloured 3px top borders and big counts: New (amber), Contacted (ice), Estimate sent (navy, with the pending total), Won this month (success green, with the total won value), each linking to the filtered lead list. Then "Follow up today" (new leads older than 24 hours and contacted leads with no update for 3 days, each with Call and Text buttons), "Upcoming site visits" (booked windows), and "Recent activity" (latest lead events).
- **Leads**: status tabs with counts (All · New · Contacted · Estimate sent · Won · Lost · Spam), search (name, phone, town), service and source filters, a List/Board toggle and an "Export CSV" button (exports the current filter: ref, received, status, name, phone, email, address, ZIP, town, service, description, visit, ballpark, won value, source, how heard, UTM source/medium/campaign, gclid, landing page, notes, last contacted). List rows: name, service, town and ZIP, received ("Today, 9:42 AM"), status chip (New amber, Contacted ice-blue, Estimate sent navy, Won green, Lost grey), photo count, and quick Call and Text icon buttons. Board: columns New → Contacted → Estimate sent → Won / Lost with drag and drop (plus a status menu on each card for touch).
- **Lead detail** (/admin/leads/:id, Jobber client-detail layout): header with the name, status select, reference number and received date; action buttons Call (tel:), Text (sms: with a prefilled message "Hi Rachel, this is Erik from Harbor & Pine about your deck request."), WhatsApp (wa.me with the same text), Email (mailto:), and "Mark contacted" (sets lastContactedAt). Main column: the job (service, description, ballpark, visit window), photos (grid with lightbox, loaded through signed URLs), notes (add a note; notes are timestamped), and a timeline of every event (created, alert sent/failed, status changes, notes, photos added). Side column: contact info (phone, email, address, ZIP → town), source (form name, how heard, UTM source/medium/campaign, gclid, landing page, referrer), won value field when Won, and "Delete lead".
- **Projects**: grid of project cards (cover, title, town, year, Published switch). Project editor: title, slug, service, town (from the service area), year, status (Completed / In progress), description, Published switch, map position (a small Leaflet map: click to drop the pin; coordinates are rounded to 2 decimals and shown as "Approximate: 47.74, -122.65 (about 1 km)"; a "Use town centre" button), and photos: bulk upload by drag-and-drop or picker (many files at once, resized in the browser, uploaded three at a time with a progress bar per file), then per photo: role (Before / After / Gallery), caption, "Set as cover", drag to reorder, delete.
- **Services & prices**: edit each service (name, slug, short description, long copy, price lines with amount and unit, photo, active, order) and its estimator pricing rules (label, unit job/sqft/room/window/door/lft/size, rate, minimum, input min/max/default; rate and minimum inputs accept cents (step 0.01) and show stored values exactly, e.g. "5.25").
- **Reviews**: list with add, edit, delete, demo flag, owner response; the Google review link; a "Private feedback" inbox from /review (rating, text, contact, date, mark handled).
- **Content**: edit or hide each home section's text (top bar, hero headline/subline/checks, how it works, owner story, landlords block, footer line) and the FAQ (add, edit, reorder, hide). Hours editor (a row per weekday).
- **Service area**: towns with ZIP and lat/lng (add, edit, remove).
- **Visit times**: weekly estimate windows (default Mon–Fri 8:00–10:00 AM and 3:00–5:00 PM, Sat 9:00–11:00 AM), blocked dates, and the list of booked windows with their leads. Windows are generated on a rolling 14-day basis from these rules; a booked window is stored with a unique start time so it can't be double-booked.
- **Settings**: Business (name, phone, text number, WhatsApp, email, registration number, insured line, since year, base town), Notifications (alert email, alert phone, "Send alerts from" address, status of Email (Resend) and Text (Twilio) with "Connected", "Needs setup" or "Failing", a "Send test lead" button that runs a real lead through the whole pipeline and shows each channel's result, and the notification log table), Tracking (GTM container ID, Google Ads conversion ID and label, canonical domain with the hint "Used in search results. Publish again after you change it."), Google review link, Theme preset (three swatches), Demo mode switch, Account (change email and password).
- **Publish checklist**: computed items with status icons and "Fix" links: "Replace the demo reviews (12 left)", "Replace the sample photos (n left)", "Set your real phone, email and registration number", "Change the owner password", "Send a test lead and confirm the alert arrived", "Connect text alerts (Twilio)", "Add your Google review link", "Add your GTM or Google Ads ID (optional)", "Turn off demo mode", and "Connect your domain and HTTPS" (manual tick).

## Functions that must actually work

- **Store first, then notify.** POST /api/leads (multipart: fields plus photos) validates with Zod, saves the Lead and a "created" LeadEvent in one transaction FIRST, then stores photos (a photo failure is logged as an event and never loses the lead), returns {ref, id, photoToken}, and only then sends notifications in the background: an owner alert email (Resend via the proxied RESEND_API_KEY at https://api.resend.com/emails, from the "Send alerts from" setting, never from a Vibecode domain; HTML email with the lead's details and a link to the lead in admin), an owner text through Twilio's REST API when TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN and TWILIO_FROM_NUMBER exist in backend/.env (otherwise logged as "needs setup"), and a confirmation email to the customer when they gave an email. Every attempt writes a NotificationLog row (channel, to, status sent / failed / needs_setup, error, time) and a LeadEvent. Classify Resend 401/403/422 answers (sender or domain not verified) as needs_setup and network errors or 5xx as failed. Failures drive the admin banners and have a Retry. Test Resend from the sandbox and report what happened.
- **Spam:** honeypot or too-fast submissions are still stored, marked isSpam, shown under the Spam tab, and never notify.
- **Private photos.** Lead photos go to Vibecode Storage, but their storage URL is never sent to the browser or any public endpoint. Admin gets short-lived signed URLs (`/api/files/lead-photo/:id?exp=…&sig=…`, HMAC-SHA256 with a server secret, 15 minutes) and that route checks the signature and streams the image. Project photos are public.
- **Attribution:** on first visit store utm_source, utm_medium, utm_campaign, utm_term, utm_content, gclid, the landing page and the referrer in localStorage (a newer gclid or UTM replaces the old one) and send them with every lead. GTM loads only on public pages and only when a GTM ID is set.
- **Lead CRM statuses:** new → contacted → estimate_sent → won / lost, each change logged with a timestamp.
- **Coverage check:** the ZIP fields look up the service area; leads store their town.
- **Working contact links:** tel:+13605550142, sms:+13605550142 (prefilled admin texts use the `sms:+1…?&body=` form that works on iOS and Android) and https://wa.me/13605550142, from the Business row.

## Data model (Prisma, SQLite)

Business (id, name, phone, smsPhone, whatsapp, email, licenseNo, insuredLine, sinceYear, baseTown, baseZip, timezone, currency, locale, gtmId, adsConversionId, adsConversionLabel, canonicalDomain, googleReviewUrl, instagramUrl, facebookUrl, notifyEmail, notifyPhone, alertsFrom, themePreset, demoMode, ownerPasswordChanged, photoSigningSecret) · Hours (weekday, opens, closes, note) · ServiceArea (id, town, zip, lat, lng, sortOrder) · Service (id, slug, name, shortDesc, body, included JSON, priceLines JSON [{label, amountCents, unit}], heroImageUrl, isSample, active, sortOrder) · PricingRule (id, serviceId, label, unit, rateCents, minCents, inputMin, inputMax, inputDefault, sortOrder) · Lead (id, ref, createdAt, status, name, phone, email, address, zip, town, serviceId, serviceLabel, description, visitSlotStart, visitPreference, ballparkCents, wonValueCents, heardAbout, consent, source, landingPage, referrer, utmSource, utmMedium, utmCampaign, utmTerm, utmContent, gclid, notes, lastContactedAt, isSpam, isTest, photoToken) · LeadPhoto (id, leadId, storageUrl, fileId, createdAt) · LeadEvent (id, leadId, type [created|status|note|email|sms|notify_failed|photo|contacted], data JSON, at) · NotificationLog (id, leadId, channel, to, status, error, at) · Project (id, slug, title, serviceId, town, approxLat, approxLng, year, description, status, coverImageId, published, sortOrder) · ProjectImage (id, projectId, url, caption, role [before|after|gallery], isSample, sortOrder) · VisitWindow (weekday, start, end) · BlockedDate (date) · VisitBooking (id, startsAt unique, endsAt, leadId) · Review (id, author, town, serviceLabel, rating, text, topics JSON, ownerResponse, source, date, isDemo) · Feedback (id, rating, text, name, contact, handled, createdAt) · ContentBlock (key, valueJson, hidden) · Faq (id, question, answer, serviceId?, sortOrder, hidden) · Better Auth tables for the owner.

## Seed data (all fictional; seed dates relative to "now")

The database ships with the project, so its dates would freeze on the day it was seeded. Mark every seeded lead, event, photo, visit booking, review and feedback isDemo. On backend start, while demo mode is on and no non-demo lead exists, shift all demo dates forward so the newest demo lead is 2 hours old, keeping their spacing (visit bookings stay in the future). Whoever opens the template sees "Last lead: 2 hours ago".

- Business, hours, 12 towns, 6 services with the copy and prices above, pricing rules above, 7 home FAQs above plus 3 per service, visit windows above.
- **Projects** (published, each with before, after (same angle) and 1–2 detail photos, approximate pins near the town centre):
  1. `bainbridge-roof-replacement`, "Roof replacement on a 1978 cedar-sided house", Bainbridge Island, Roofing, 2026: "Two layers of mossy 3-tab came off. We replaced six soft sheets of decking, added ice and water shield in the valleys, a ridge vent and 30-year architectural shingles in charcoal. Three working days including cleanup." Details: "Ice and water shield in the valleys", "New flashing at the chimney".
  2. `poulsbo-cedar-deck`, "Cedar deck over Liberty Bay", Poulsbo, Decks & porches, 2026: "The old deck had rotten joists and a loose ledger. We rebuilt the frame with a flashed ledger and hidden fasteners, then laid clear cedar decking with a black cable rail so the bay view stays open." Details: "New joists and flashed ledger", "Stairs down to the lawn".
  3. `silverdale-fiber-cement-siding`, "Fiber-cement siding and trim", Silverdale, Siding & trim, 2025: "Rotting T1-11 plywood siding replaced with fiber-cement lap siding and new trim. We found and fixed two window leaks behind the old siding." Details: "House wrap and flashing before siding", "Corner trim detail".
  4. `kingston-farmhouse-repaint`, "Exterior repaint, 1912 farmhouse", Kingston, Painting, 2026: "Full scrape and prime, rotted trim replaced, two coats in a warm white with a black front door." Details: "Scraping and priming the clapboard", "Front door in black".
  5. `bremerton-window-replacement`, "Window replacement, 1958 rambler", Bremerton, Windows & doors, 2025: "Nine original aluminum windows replaced with black-frame vinyl, flashed and trimmed for Northwest rain." Detail: "Interior trim after install".
  6. `port-orchard-cedar-fence`, "Cedar privacy fence and gate", Port Orchard, Fencing, 2026: "140 feet of 6-foot cedar privacy fence with steel posts set in concrete and a double gate wide enough for a mower." Detail: "Gate hardware".
- **Reviews** (12, isDemo, source "Google"): Maureen T. (Bainbridge Island, Roofing, 5★, 18 days ago): "They showed up the day they said they would and had the old roof off by lunch. Erik walked me through the soft spots before covering anything. Not one nail left in the garden." Owner response: "Thanks Maureen. Enjoy a dry attic this winter." · Dev P. (Silverdale, Siding & trim, 5★, 34 days): "Written estimate came two days after the visit, itemized down to the trim boards. The final bill matched it. They showed us photos of the rot before changing anything." · Carla R. (Poulsbo, Decks & porches, 5★, 46 days): "We have a steep lot and two other companies didn't want the job. Marisol's crew built the new deck in nine working days and left the yard cleaner than they found it." · Owen B. (Kingston, Painting, 5★, 60 days): "Scraped, primed and two coats on a 1912 farmhouse. They protected every shrub. Bea's colour advice saved us from a grey we would have hated." · Tom K. (Bremerton, Windows & doors, 4★, 75 days): "Two windows were back-ordered for two weeks, but they called to tell me before I had to ask. The install took one day and the inside trim looks factory." Owner response: "Thanks Tom. Sorry again about the wait on those two." · Priya S. (Port Orchard, Fencing, 5★, 90 days): "New cedar fence and a gate that actually latches. Fair price, and they checked the property line with me before digging." · Linda M. (Kingston, Roofing, 5★, 110 days): "A small leak by the chimney that two roofers couldn't find. They found it in an hour, fixed the flashing and charged what they quoted." · Hector A. (Silverdale, Decks & porches, 5★, 130 days): "Replaced rotten stair stringers and the top boards. Quick, tidy, and they hauled off all the old wood." · Nora W. (Bainbridge Island, Painting, 5★, 150 days): "Three bedrooms and a hallway in two days. Furniture went back where it was. Clean lines at the ceiling." · Sam O. (Poulsbo, Siding & trim, 5★, 180 days): "They replaced the rotten trim around every window and caulked it properly. Marisol sent a photo update each afternoon." · Gail F. (Port Orchard, Roofing, 5★, 210 days): "Moss damage was worse than we hoped. They gave us two options in writing and never pushed the expensive one." · Ray D. (Bremerton, Windows & doors, 5★, 240 days): "New front door and two windows. Draft gone, house is quieter, and they cleaned up every bit of foam." Give each review 1–3 topics from the chip list.
- **Leads** (14, all flagged isDemo with their events, photos and bookings; phones (360) 555-01xx, emails @example.com): Rachel Donnelly (New, 2 hours ago, Decks & porches, 98370 Poulsbo, "Back deck is about 12×16. Two boards are soft near the stairs and the railing wobbles. Repair or replace?", 2 different photos (soft deck boards by the stairs; a loose railing post), visit Thursday 8–10 AM, Google Ads: utm google/cpc/decks-fall with a gclid, ballpark $10,500) · Marcus Bell (New, 5 hours ago, Roofing, 98110, "Leak in the upstairs bathroom ceiling after last week's storm. Roof is about 20 years old.", 1 photo, Google Maps) · Ana Ferreira (New, yesterday 6:05 PM, Painting, 98346, "Exterior repaint, two-story farmhouse, peeling on the south side.", Friend or neighbor) · Wes Thibodeau (New, yesterday 1:20 PM, Fencing, 98366, "About 140 ft of 6 ft cedar privacy fence along the back, plus one gate.", Nextdoor utm, ballpark $3,900) · Nina Okafor (Contacted, 2 days ago, Windows & doors, 98312, "Replace 9 old aluminum windows in a 1958 rambler.", note "Called, visit Thu 3–5 PM. Wants black frames.") · Greg Lindstrom (Contacted, 3 days ago, Siding & trim, 98383, "Rot under two windows, siding is T1-11.", 1 photo) · Jen Costa (Contacted, 4 days ago, Decks & porches, 98110, "New front porch steps and railing.") · Bill Sutter (Estimate sent, 6 days ago, Roofing, 98392, "Full replacement, moss everywhere.", note "Estimate #2219 sent: $14,800 (two layers, 4 sheets decking).") · Karen Liu (Estimate sent, 8 days ago, Painting, 98370, "Four rooms and a hallway, interior.") · Dominic Russo (Estimate sent, 9 days ago, Windows & doors, 98366, "Sliding patio door replacement.", 1 photo) · Sofia Marques (Won, 12 days ago, Siding & trim, 98383, won $18,200, note "Signed. Start Nov 17.") · Ray Fitzgerald (Won, 16 days ago, Fencing, 98359, won $5,900) · Hannah Weiss (Won, 20 days ago, Roofing repair, 98346, won $1,150) · Leo Chambers (Lost, 15 days ago, Decks & porches, 98340, note "Went with a cheaper bid."). Give each a matching event timeline. Do not seed fake notification results.

## Images (generate them, AI people only)

Write one script (backend/scripts/generate-images.ts) that generates every image with `gpt-image-2.5-flare` through the project's proxied OPENAI_API_KEY at https://api.openai.com/v1/images/generations (quality "high", 1536x1024 unless noted), four at a time, skipping files that already exist, converting to JPEG (quality 82, max 1600px wide; hero 1920px) with sharp into webapp/public/images/. Start it in the background as soon as the schema exists and keep building while it runs; until a file exists the UI shows a navy box with the Sample chip. Style for all: photorealistic, eye-level, overcast Pacific Northwest light, natural colours, wet greenery and Douglas firs, no text, no logos, no watermarks, no people unless stated, faces natural.
- hero/exterior.jpg (1920x1088): a two-story Craftsman house in Poulsbo just renovated: charcoal architectural shingle roof, deep green fiber-cement lap siding, white trim, a new cedar front porch with tapered columns, wet lawn, firs behind, warm lights on inside at dusk, shot from the sidewalk.
- services/<slug>.jpg (6): gloved hands nailing charcoal shingles on a pitched roof (no face); new clear cedar deck boards with a black cable rail over a bay; deep green fiber-cement lap siding with crisp white corner trim and a flashed window; a brush cutting a clean warm-white line on farmhouse trim (no face); a new black-frame window in white trim with raindrops; a new 6-foot cedar privacy fence with a gate and black hardware.
- projects/<slug>/before.jpg, after.jpg, detail-1.jpg, detail-2.jpg: generate before.jpg first, then create after.jpg with `/v1/images/edits` using `gpt-image-2.5-sunburst` and before.jpg as the image, prompting "same house, same camera angle, framing, trees and sky; only change <the work>", so each before/after pair lines up in the slider. Details are separate generations.
- team/crew.jpg: four adult crew members of mixed backgrounds in work clothes, candid, left to right: a bearded man in a beanie, a woman with curly hair, a man in a hi-vis vest, a woman in a cap, standing in a driveway in front of a navy work van with "HARBOR & PINE" in white block letters and an amber side stripe.
- team/van.jpg: the navy full-size work van on a wet residential street with firs; side lettering in white condensed capitals "HARBOR & PINE" and smaller "HOME IMPROVEMENT · (360) 555-0142", an amber stripe, a ladder rack, no other brands.
- leads/*.jpg (4, 1024x1024, phone-snapshot look): soft deck boards near stairs; a brown water stain on a bathroom ceiling; peeling exterior paint on clapboard; a rotted T1-11 siding corner; a loose deck railing post. Seed these as the demo leads' private photos.
- og.jpg (1200x630): compose with sharp: the hero photo with the navy gradient, the logo mark and "Harbor & Pine / Home repairs and upgrades, done right the first time." in white bold condensed type. Use it as og:image and twitter:image.

## Local SEO

- index.html: title "Harbor & Pine Home Improvement | Roofing, Decks, Siding & Painting in Kitsap County, WA", a matching meta description, Open Graph and Twitter tags, theme-color #0A2340, the new favicons.
- Each public route sets its own title, description and absolute canonical (canonical domain from Settings, else window.location.origin at runtime) and injects HomeAndConstructionBusiness JSON-LD from the live Business facts (name, telephone, email, url, image, priceRange "$$", address with locality/region/postal code only, areaServed towns, openingHoursSpecification from Hours, a service catalogue with prices; no aggregateRating while reviews are demo).
- Prerender for crawlers: a small Vite plugin in vite.config.ts (closeBundle) writes dist/<route>/index.html for /, /services/<slug>, /projects, /projects/<slug>, /review and /privacy with that route's title, meta, canonical, JSON-LD and a plain semantic HTML snapshot of the page's main text inside #root (React replaces it on load). It reads the same seed data module the backend seeds from. It also writes dist/sitemap.xml, dist/robots.txt and dist/llms.txt (business facts, services with prices, towns, hours, contact, FAQ). Static files use absolute URLs only: take the Canonical domain from the backend at http://localhost:3000 during the build (retry for up to 60 seconds), else the seed data, or a VITE_SITE_URL env var; when no domain is known, leave the canonical tag and the robots Sitemap line out of the static files instead of writing relative URLs. Server-built absolute URLs (the live sitemap, llms.txt, alert-email links) always use https:// except on localhost or 127.0.0.1, because TLS ends before the app's proxy and X-Forwarded-Proto says http. Also serve live versions from the database at /api/seo/llms.txt and /api/seo/sitemap.xml.

## Motion, states, accessibility

- Framer Motion, subtle: sections fade up 12px once in view (400ms), cards stagger 60ms, header shadow on scroll, map pins pop in, accordion height animation, bottom bar slides up on load. Respect prefers-reduced-motion. No parallax.
- Loading: skeletons for the gallery, reviews and admin tables. Errors: a friendly inline message with the phone number on public pages, toasts in admin. Empty states with a clear next step ("No leads yet. Share your site or send a test lead.").
- Labels on every input, visible focus rings (amber on white, ice on navy), 44px touch targets, alt text on every image, lazy-loaded images except the hero.

## Build order (small files)

Build it in small source files (one component per file, no file over ~250 lines), in this order, and get each step running before the next:
1. Prisma schema, seed data modules (backend/src/seed/*.ts) and auto-seed on start; Better Auth with the seeded owner and sign-up disabled. Start the image script in the background.
2. Public API (/api/public/site returns everything the home page needs in one request; service, project and slot endpoints; POST /api/leads; POST /api/feedback; lead photo upload with token) and the notification pipeline. Test every route with curl against $BACKEND_URL.
3. Theme tokens, fonts, logo, layout (demo bar, top bar, header, mobile bottom bar, footer).
4. Home sections in order (each its own file under src/components/home/), then the quote forms and /thank-you, attribution capture and GTM.
5. /services/:slug, /projects, /projects/:slug with the before/after slider, /quote, /review, /privacy, 404.
6. Admin: login, layout, Today, Leads list/board/detail with CSV, Projects editor with bulk upload and map pin, Services & prices, Reviews and feedback, Content, Service area, Visit times, Settings with test lead and notification log, Publish checklist.
7. SEO plugin, favicon and OG image.
8. Run everything, then test at 1440px and 390px: submit the hero form and the full form with photos, confirm the leads appear in admin with their photos, change a status, add a note, export CSV, edit a service price and see it on the home page, upload project photos, rate 2 and 5 stars on /review. Fix every error and console warning. Before you finish, delete every record you created while testing (leads, events, notification logs, bookings, photos, feedback) and every Session and Verification row (your test sign-ins store IP addresses and browser user agents), and reset the reference counter: the admin must show exactly the 14 seeded leads, 12 reviews and no private feedback, and the next lead is HP-1015. backend/prisma/dev.db is copied into every remix, so then run VACUUM; and after it PRAGMA wal_checkpoint(TRUNCATE); on it (in that order), check that dev.db-wal is 0 bytes, and don't sign in again afterwards. Then delete refs/.

Build it fully, run it, and fix every error. Deliver the complete app, not a plan.
All templates