Instant Quote · Admin

Docs

Project documentation rendered straight from /docs.

docs/THEMING_AND_TEMPLATES.md

Theming and Templates

Last updated: May 19, 2026 at 5:27 PM ET

Per-client branding, theme math, and the section-based page builder.

Related docs: README, ARCHITECTURE, ADMIN_GUIDE.

Reusable template v2 — default landing-page structure

Every new client (and any existing client that hasn't customised their page in the builder yet) starts from this section order on /<slug>/instant-quote. Anything is hidden via the visibility toggle rather than deleted; the section type catalog stays unchanged.

Order Section Purpose
1 site_header Sticky top header — logo + phone
2 hero Form + headline + location line. Every variant ships the form.
3 trust_badges Logo / badge row (Google Reviews, BBB, etc.)
4 trust_strip Icon-based "5-Star Rated / Licensed & Insured / Warranty-Backed Work / Financing Available" row
5 credibility "Why you can trust the number" — moved up so the rationale runs before social proof
6 testimonials Real homeowner reviews
7 why_choose_us Benefit cards
8 how_it_works 3-step process
9 services_materials Roofing systems we install
10 faq Common questions
11 final_cta Closing CTA
12 trust_section, what_happens_next Closing reassurance
13 site_footer Disclaimer + brand chrome

Defaults live in:

  • lib/sections/meta.js — every section's defaultContent and the per-page section order in PAGE_DEFAULTS.
  • lib/clientDefaults.js — the new-client onboarding form seeds (hero copy, trust items, etc.). These feed the same defaults through a different entry point.

Edits to either file change what NEW clients render. Existing clients keep their saved page sections — see "Existing client data" below.

Hero v2 — eyebrow, location line, headline, subhead

The hero template now renders a tiny location line under the eyebrow ("Serving {Service Area}") that auto-fills from client_branding.service_area. Admins can override it per page via the section's location_line field; leaving it blank uses the auto-built form. All five hero variants surface the line in the right slot.

The default copy is:

  • Eyebrow: "Instant Roof Replacement Estimate"
  • Location: "Serving {Service Area}" (auto)
  • Headline: "See your roof replacement price range in about a minute."
  • Subheadline: "No phone tag. No sales pressure. No obligation. Just a fast estimate based on your roof details."

The form itself is the in-hero CTA. A separate sticky mobile CTA ("Start My Estimate") fades in once the user scrolls past the hero and scroll-targets #quote — see "Sticky mobile CTA" below.

Trust strip — default items

DEFAULT_TRUST_ITEMS in lib/clientDefaults.js:

Icon Title Subtext
star 5-Star Rated Verified homeowner reviews
shield Licensed & Insured Local, background-checked crews
badge Warranty-Backed Work Workmanship & material coverage
wrench Financing Available Simple monthly options

Each item is fully editable per client through the page builder's trust_strip section. The defaults are what new clients see on first render.

Sticky mobile CTA

components/StickyMobileCta.jsx renders a fixed-bottom button on mobile only (hidden at md+). It fades in once the user scrolls past the hero — measured against the #quote section's bottom position — and smooth-scrolls back to #quote on tap. Label defaults to "Start My Estimate".

Only mounted on the instant-quote page from app/[clientSlug]/[pageKey]/page.js. Respects iOS safe-area inset and pointer-events-none until visible so it never intercepts taps during the hero phase.

Reviews — extended schema

The review card schema (reviewSchema in lib/sections/meta.js) now supports two optional fields on top of the existing name / location / rating / quote:

  • project_type — short string ("Architectural shingle replacement", "Storm damage repair"). Rendered as a small brand-tinted chip on the card.
  • image_url + image_alt — for an optional before/after photo thumbnail. Schema is in place; the Cards layout doesn't render the image yet — that's a follow-up in the testimonials component. The field is editable in the admin so content can be authored before the renderer ships.

Default headline: "Real Reviews From Local Homeowners". Admins can swap to "Trusted By Homeowners In {Service Area}" or anything else — defaults are intentionally generic.

FAQ — default questions

Four questions framed around buyer hesitation:

  1. Is this the final price?
  2. Do I have to talk to someone to see the estimate?
  3. Can I finance the project?
  4. How accurate is the estimate?

Answers are short and admin-editable. The accordion variant works well for these short bodies; the two-column variant is also supported.

Roofing systems visibility

The services_materials section's items array controls what appears on the public page. What renders is fully admin-controlled — admins can add, hide, reorder, or replace any system per client through the page builder.

Defaults shown on every new client's page:

  • Architectural shingles
  • 3-tab shingles (via the asphalt grouping)
  • Metal roofing

Optional (admin opts in by adding to the items list):

  • Tile
  • Cedar shake
  • Flat / low-slope

These are display-only. The pricing engine still keeps the full material catalog (MATERIAL_OPTIONS in lib/estimate.js) and each client's client_pricing_rules.price_per_sqft map. Hiding a system from the marketing page doesn't remove it from pricing.

Form-side material visibility (the dropdown the homeowner picks from on Step 3) is configured separately via client_pricing_rules.visible_materials — see FORM_FIELDS.md.

Existing client data

Updates to template defaults only affect clients that don't have their own saved page_sections rows. Once a client's page is saved through the page builder, those rows are the source of truth and the defaults are no longer consulted.

To see the new defaults on an existing client:

  • Open the page builder for that client.
  • Delete the existing sections you want to refresh.
  • Re-add them — they'll seed from the new defaultContent.

Or apply the new defaults manually field-by-field. There's no bulk "reset to template" action today.

Template presets (future)

The structure above is designed to support named presets later — e.g. Premium Roofing, Storm Damage, Budget Replacement, Metal Roofing. Each preset would be a named bundle of:

  • A section order (an entry in PAGE_DEFAULTS)
  • Per-section defaultContent overrides
  • Optional theme preset (already supported via lib/themePresets.js)

Nothing is built yet. When we add presets, the natural place is a new lib/templatePresets.js modelled on lib/themePresets.js, plus a new-client form picker that swaps in the chosen preset's defaults before submit.

Thank-you page — conversion-focused defaults

The thank-you page is now the delivery of the tool's promise: the homeowner just submitted the form, so the estimate is the first thing they see and the next-step CTAs sit immediately under it. Section order (PAGE_DEFAULTS["thank-you"]):

  1. site_header
  2. thank_you_hero"Your Instant Roof Estimate Is Ready" + a one-line subhead. Tight by design so the estimate appears high on the page.
  3. quote_summary — the estimate card itself. Renders the schedule + call CTAs directly under the card.
  4. quote_details"What Your Estimate Is Based On" grid.
  5. what_happens_next — 4-step roadmap with shorter, "you're in control" wording.
  6. testimonials
  7. faq
  8. final_cta"Ready To Confirm Your Final Price?" with Schedule + Call buttons.
  9. trust_section
  10. site_footer

Schedule + Call CTAs (estimate card)

QuoteSummary renders two CTAs under the estimate card:

  • Primary — "Schedule My Free Inspection". Href is resolved through this fallback chain:
    1. The client's client_branding.calendar_url (admin-set).
    2. The client's phone link (tel:).
    3. Hidden entirely if neither is configured.
  • Secondary — "Call Now". Uses the client's phone. Hidden when no phone is on file.

The labels are admin-editable per page via the schedule_label and call_label fields on the quote_summary section's schema.

Calendar URL admin field

client_branding.calendar_url is added in migration 015 and exposed in the admin Edit-client form's Overview tab alongside phone / email / service area. Until migration 015 is applied:

  • The form's "Calendar / booking URL" input still saves nothing on the deploy (the API tolerates the missing column with a log warning).
  • The thank-you-page schedule CTA silently falls back to the phone link.
  • The final_cta primary button on the thank-you page also falls back to the phone link.

After the migration is applied, paste the contractor's Calendly / GoHighLevel / HouseCallPro booking URL and the CTA deep-links to their booking flow.

Quote Summary extras

The estimate card now also surfaces (when present on the saved quote):

  • Property address
  • Roof size — square footage, rounded.
  • Material — friendly label from the form. Hidden cleanly when the underlying value is missing.

The monthly-payment column collapses to a single-column layout when no monthly value is available, so we never render a "—" placeholder where a dollar amount belongs.

Fallback behaviour

Missing data What renders
quote.monthlyPayment Monthly column hidden; price card spans full width
quote.roofAreaSqft Roof-size row hidden
quote.materialLabel Material row hidden
quote.address Property row hidden
brand.calendarUrl Schedule CTA falls back to phone link
brand.phoneHref Call CTA hidden; if calendar is also missing, both CTAs hide
quote is null entirely (no token in URL) QuoteSummary renders a friendly empty state with a link back to the form

Page-specific default content

Some section types — currently just final_cta — ship different starter copy on different pages. The mechanism is the optional pageDefaultContent: { "thank-you": {...} } map on a section's SECTION_META entry. When the default-section synthesiser (lib/page.js#defaultSectionsForPage) seeds a section for the thank-you page, it merges the page-specific override on top of defaultContent. Use this anywhere a single section type wants different starter strings per page; admins can still edit per section after that.

Big picture

Every client gets the same template — same layout, same form, same sections — and customisation happens through three layers:

  1. Branding — colors, logo, contact info, license/warranty/financing copy. Stored in client_branding and client_assets.
  2. Theme — derived automatically from the branding colors via lib/theme.js#buildClientTheme. The output is a flat object of hex colors and r g b channel strings, flattened into CSS variables that drive Tailwind tokens.
  3. Page content — section-based, edited via the page builder. Stored in client_pages + page_sections. Legacy clients keep working from client_page_content via a synthesise-on-read fallback.

Logo

  • Uploaded via the admin Assets section into Supabase storage.
  • Persisted as a row in client_assets with asset_type = "logo".
  • Rendered in SiteHeader and (optionally) SiteFooter via components/BrandLogoImage.jsx, which the dynamic page passes through the section registry.
  • If no logo is set, the header falls back to the client's company name as text.

Other supported asset types: favicon, hero, visual. The page loader filters to is_active = true and orders by sort_order, so soft-deleting an image without removing the row is supported.

Primary, secondary, accent, and footer colors

Admin sets up to four hex colors:

  • primary_color — typically the brand's main CTA color.
  • secondary_color — usually the dark / anchor color (deep navy etc.).
  • accent_color — pop color used sparingly.
  • footer_color — optional override for the footer background.

The slot the admin types each color into is not authoritative. buildClientTheme(branding) collects every supplied color into a candidate pool and then assigns each role (CTA, dark, accent) to whichever color scores best for that role. This means a Cobex-style palette of navy + blue + green resolves to "blue CTA, navy dark, green accent" regardless of which admin slot held which color. See lib/theme.js for the scoring functions.

If only one color is provided, the missing roles are filled in by darken/lighten variants of that color. If no color is provided, the fallback is teal-700 (#0f766e), which is the original template default.

Footer color override

If footer_color is set in client_branding, the footer background uses it verbatim and the foreground text color flips automatically by luminance. Without an override, the footer reuses the resolved dark base color from the theme.

Theme presets

lib/themePresets.js defines a small list of designer-vetted palettes the admin can apply with one click. Each preset is just a patch on the form state's branding slice — no schema changes. Current presets:

  • Recommended (Default) — calm teal.
  • Bold Contractor — orange + slate + yellow.
  • Premium Residential — teal + navy + sandy gold.
  • Clean Modern — blue + slate + cyan.
  • Trust & Finance — green + blue + gold.

Adding a new preset = one entry in the THEME_PRESETS array.

Button colors

Buttons inherit from the theme — primary CTAs use --brand-rgb with --on-primary-rgb for the foreground; secondary uses --accent-rgb. Both foregrounds are auto-flipped by luminance (light brand colors get dark text, dark brand colors get white text). Components don't pick button colors directly; they reference the Tailwind tokens (bg-brand, text-on-primary, etc.) which are wired to the CSS variables.

Header style

Currently a single header section type, site_header, that the admin cannot remove (removable: false in lib/sections/meta.js). The section renders the logo, the company name as a fallback, and the phone number link. There are no variants on the header today — extending it would mean adding variants in meta.js and branching in components/sections/SiteHeader.jsx.

Footer style

Same shape as the header — one non-removable site_footer section. The copy is editable (defaults to the disclaimer about estimates being informational). Footer background follows the override-or-derive rule above.

Trust badges

Two related section types:

  • trust_strip — icon-based row of 2–4 quick trust signals (5-star rated, licensed & insured, warranty, financing). Auto-populated from brand defaults when the items list is empty.
  • trust_badges — visual logo / badge row for Google Reviews, BBB, Angi, certifications, awards. Each badge has an image, alt text, and optional click-through URL with per-item open-in-new-tab.

The two coexist on purpose; a page can have neither, one, or both.

Testimonials

Section type testimonials. Schema is a list of { author, body, rating } objects. Existing clients had their reviews migrated from the legacy client_page_content.reviews field by the synthesise-from-legacy fallback in lib/page.js.

Hero copy

The hero section drives the top of every landing page. Every hero variant ships the live quote form — variants only change the layout around the form, not whether it's visible. Available variants:

  • with-form — content left, form right (default).
  • form-left — form left, content right.
  • split-image — headline strip on top; image + form below.
  • image-bg — full-bleed background image; form floats on top.
  • centered — centered copy with the form stacked below.

Editable fields per the section schema: eyebrow, headline, subheadline, bullets (list), small reassurance line near the form, hero image URL, image alt text.

There's a separate thank_you_hero section type for the thank-you page, with its own variants (centered, image-below, video-below, split-media) and a CTA button list.

Financing copy

Two distinct surfaces:

  • Trust copyfinancing_copy in client_branding, shown alongside the warranty and licensed copy in the trust strip.
  • Monthly payment — calculated server-side via lib/financing.js#monthlyPayment(principal) using a hard-coded default of 8.99% APR over 120 months. The result is cached on the quote row at submit time so the thank-you page renders instantly.

The financing defaults are not editable per-client today. Making them editable means adding columns to client_pricing_rules (or a new client_financing table) and reading them in app/api/quotes/route.js.

Hero image settings (page builder)

The Hero section editor exposes per-page controls for how the hero image is cropped, zoomed, overlaid, and sized — all without touching code. The controls only render when the hero variant uses the image (image-bg for background, split-image for the side panel); other variants ignore them harmlessly.

Visual editor

The page-section editor surfaces these controls through a custom visual panel (app/admin/clients/[clientId]/pages/[pageKey]/HeroImagePanel.jsx) that bypasses the generic per-field renderer. Each control writes directly into the section's content blob using the stable keys listed below, so saved values from earlier dropdown-driven editors continue to load and render unchanged.

The panel is organised into four collapsible groups, each with its own Reset action:

  1. Image framing — desktop and mobile side-by-side, each with:
    • a 3×3 position grid (click a cell to anchor the image)
    • a zoom slider (70% → 160% in 5% steps; 70%–95% zooms out, 100% maps to CSS cover, 105%–160% zooms in)
  2. Overlay — overlay color picker with hex input + four quick swatches (Black / Navy / Slate / Dark brand), independent desktop and mobile darkness sliders (0%–90%), and a 2×3 visual gradient picker that previews each option using the live overlay color.
  3. Hero height — pill-button presets for desktop (Compact / Standard / Tall / Full screen) and mobile (Compact / Standard / Tall). Selected option fills dark; tooltip shows the min-height value applied.
  4. Readability warning — surfaces inline when the desktop overlay drops below 35% (checkHeroReadability in components/sections/Hero.jsx). Tells the admin which control to adjust without blocking the save.

Controls reference (stable keys)

Field Editor UI Effect Variants that honour it
image_url / image_alt Image upload + alt-text field (existing) The image and its alt text. image-bg, split-image
image_position_desktop 3×3 position grid CSS background-position on desktop. both
image_position_mobile 3×3 position grid Same, applied at max-width: 768px. both
image_zoom_desktop Slider (70%–160%) CSS background-size: 100% → cover; <100% letterboxes with overlay-coloured gaps; >100% zooms in (cropped by overflow:hidden). both
image_zoom_mobile Slider (70%–160%) Same, mobile breakpoint. both
overlay_color Color picker + hex field + 4 swatches Hex string (#000000 default). Drives the overlay layer's color. image-bg only
overlay_opacity_desktop Slider 0%–90% Scales the overlay layer's opacity. Stored as the underlying float (e.g. "0.55"). image-bg only
overlay_opacity_mobile Slider 0%–90% Same, mobile breakpoint. Defaults to 70%. image-bg only
overlay_gradient Visual preset grid with live previews none / left-to-right / right-to-left / top-to-bottom / bottom-to-top / radial-center. left-to-right is the default — matches the previous hand-rolled gradient. image-bg only
height_desktop Pill buttons compact (28rem) / standard (36rem) / tall (44rem) / full-screen (100vh). image-bg only
height_mobile Pill buttons compact (22rem) / standard (30rem) / tall (38rem). image-bg only

Defaults — existing pages stay unchanged

Field Default
image_position_desktop / _mobile center
image_zoom_desktop / _mobile 100% (= cover)
overlay_color #000000
overlay_opacity_desktop 0.6
overlay_opacity_mobile 0.7
overlay_gradient left-to-right
height_desktop standard
height_mobile standard

Tuned so existing client pages render visually identical to the pre-controls version. Editing any control changes only that one dimension.

How it renders

components/sections/Hero.jsx builds two CSS-variable sets:

  • buildSectionCssVars(image) — emits --hero-bg-pos-d, --hero-bg-pos-m, --hero-bg-size-d, --hero-bg-size-m, --hero-min-h-d, --hero-min-h-m on the section element.
  • buildOverlayStyle(image) — emits inline backgroundColor / backgroundImage (for gradients) + opacity on the overlay layer, plus a --hero-overlay-opacity-m variable.

app/globals.css binds those variables to real CSS properties (background-size, background-position, min-height, opacity) and swaps mobile vs. desktop values via a single @media (max-width: 768px) block. Fallbacks chain so older saved sections without these fields still render cover / center — matching the previous behaviour.

Storage

Every control lives in the page-section content JSON blob — no database migration. The keys above are stored verbatim on page_sections.content. Saved values survive section reorders, section duplications, and page-builder previews.

Variant compatibility

  • Form right / Form left / Centered — no background image rendered; image controls are inert (they save fine but don't show up in the rendered output).
  • Image + form (split-image) — image is a side panel. Position + zoom apply to that panel. Overlay + height controls are inert because the image isn't a full-bleed background.
  • Form over background (image-bg) — every control applies.

The editor schema lists every field regardless of variant; the field labels make the variant-specificity clear ("Overlay darkness (desktop)", etc.) and unused values are harmless storage.

Readability check

checkHeroReadability(content) in components/sections/Hero.jsx returns { passes, message } — it flags an overlay below 35% opacity as risky for white hero text on a busy photo. The visual panel calls this helper directly and renders the message inline as an amber alert inside the Overlay group when the threshold trips.

Live preview

The page-builder iframe preview at /admin/clients/[id]/pages/[key]/preview renders the same Hero component the public page uses, so all image controls update the preview as they change. The brand-preview card in the new-client wizard does not render the hero image (it's a brand-only preview), so use the page-builder preview to validate hero image tweaks.

CTA sections

The final_cta section type renders a dark CTA panel at the bottom of the page. It currently uses the resolved dark background color from the theme and the primary brand color for the button. The dark hero HUD SVGs, the dark CTA panel, and a few footer accents are intentionally not themed yet — they're still hardcoded gradients in the section components.

Client-specific customisations

What the admin can change without code:

  • Company info (name, phone, email, service area).
  • Colors (primary, secondary, accent, footer).
  • License / warranty / financing trust copy.
  • Review rating + count badge.
  • Every per-client field on every section (text, images, button URLs, bullets, items lists, FAQ Q&A, before/after images, …).
  • Section order, visibility, and variant.
  • Adding or removing any optional section (everything except site_header and site_footer).
  • Pricing rules (see PRICING_LOGIC).
  • Webhooks + GTM container ID + notification email/phone.
  • Page title and meta description.

What currently requires code changes

  • The form fields. roofAge, stories, complexity, material options are defined in lib/estimate.js. Adding a new question means updating both the form and the pricing rules.
  • Pitch on the form. The pitch multiplier is fully wired into calculateEstimate, but the public form doesn't yet collect pitch. Default of standard is applied server-side.
  • Section types. Adding a new section type means:
    1. Add an entry to SECTION_META in lib/sections/meta.js (schema, defaults, variants).
    2. Build the React component in components/sections/.
    3. Register the component in lib/sections/registry.js.
    4. (Optional) Add the type to PAGE_DEFAULTS if it should appear on new pages by default.
  • The hardcoded hero / CTA gradients and SVGs. These were left out of the Phase 1 theme rollout. Phase 2 should drive them off the same CSS variables.
  • Financing assumptions (APR + term) — see above.
  • Estimate mode presets — adding a fourth preset means editing ESTIMATE_MODE_PRESETS in lib/estimate.js.

Keeping the template reusable across roofing clients

Some rules to keep in mind when adding things:

  1. Never hardcode a client's name, slug, color, or copy. If the only difference between two clients is a string, that string belongs in the section content / branding row, not in the component.
  2. Defaults belong in lib/clientDefaults.js or lib/sections/meta.js. The new-client form seeds itself from these. Keeping the canonical template here means a brand new client ships with sensible content instead of empty placeholders.
  3. Section content shape is owned by the section schema. If you add a field to a section, add it to the schema array in SECTION_META[type]. The admin editor renders fields generically from that schema; no per-section editor code needed.
  4. Section components must tolerate missing content. Page content for old clients is synthesised from legacy data, which won't have every newly-added field. Default to a sensible empty state if a field is missing.
  5. The hero must always include the form on instant-quote. app/[clientSlug]/[pageKey]/page.js injects the form for any hero variant. Don't gate that on a section flag — it's the whole product.
  6. Avoid bundling the full section component code into the admin. lib/sections/meta.js is the client-safe copy of section metadata; the admin editor only imports from there. Keep the meta import-light (no React imports) so the admin bundle stays small.
  7. When in doubt, render off the resolved theme tokens (bg-brand, text-text, border-border, etc.) rather than hex literals. That way every client gets the new component automatically tinted.