Instant Quote · Admin

Docs

Project documentation rendered straight from /docs.

docs/ARCHITECTURE.md

Architecture

Last updated: May 19, 2026 at 4:17 PM ET

How the Instant Quote Tool is wired together. See README for a higher-level overview and pointers to the other docs.

Frontend framework

  • Next.js 14 (App Router) with the Pages-style file routing under app/.
  • React 18.3 (Server Components by default; client components are opt-in with "use client").
  • Tailwind CSS 3 for styles, configured in tailwind.config.js. Brand colors are surfaced as Tailwind tokens (brand, brand-dark, accent, surface, etc.) backed by CSS variables that the theme builder injects per-client at SSR time.
  • No client-side data fetching libraries — the form posts to API routes via plain fetch, and per-page data comes from server components reading Supabase directly.

Routing structure

The whole app is one Next.js project. Routes:

/                                           Redirects to /admin/clients
/admin/clients                              Client list (token-gated)
/admin/clients/new                          Onboarding form
/admin/clients/[clientId]                   Edit client settings
/admin/clients/[clientId]/pages             Page list
/admin/clients/[clientId]/pages/[pageKey]   Page builder
  ↳ /preview                                Iframe preview of unsaved edits
/admin/eagleview-test                       EagleView sandbox
/[clientSlug]/instant-quote                 Public landing page
/[clientSlug]/thank-you                     Public thank-you page

API routes:

POST /api/quotes                                    Save lead + quote, forward webhooks
GET  /api/quotes/public/[token]                     Public read of one quote by token
POST /api/eagleview/prefetch                        Per-client form prefetch (slug-gated)
POST /api/eagleview/test                            Bearer-token-gated raw lookup
POST /api/admin/eagleview-test                      Admin-token wrapper around the test
POST /api/admin/clients                             Create client
GET  /api/admin/clients/[clientId]                  Load client + branding + pricing + integrations
PATCH /api/admin/clients/[clientId]                 Update client
GET  /api/admin/clients/[clientId]/pages/[pageKey]  Load page sections
PUT  /api/admin/clients/[clientId]/pages/[pageKey]  Save page sections
POST /api/admin/clients/[clientId]/assets           Upload an asset

All admin API routes are gated by Authorization: Bearer <ADMIN_TOKEN> when ADMIN_TOKEN is set; if unset, they're open (intended for local dev only). POST /api/quotes is open by design — that's the public form endpoint.

Client-specific page structure

Public pages live under the dynamic segment app/[clientSlug]/[pageKey]. Only two page keys are allowed: instant-quote and thank-you. Anything else returns 404 via notFound().

Each request:

  1. loadPage(slug, pageKey) (lib/page.js) fetches clients, client_branding, client_integrations, client_assets, client_page_content (legacy), and client_pages + page_sections in parallel.
  2. If section rows exist for the page, those are used as-is.
  3. Otherwise, sections are synthesised from the legacy client_page_content row using PAGE_DEFAULTS from lib/sections/meta.js. Existing clients keep rendering without any DB backfill.
  4. buildBranding(...) (lib/branding.js) produces a brand object that includes the resolved theme (via buildClientTheme in lib/theme.js).
  5. The page injects the theme as CSS variables in a <style> tag on the root element.
  6. Sections are mapped through getSection() from lib/sections/registry.js and rendered in sort_order.

On thank-you, the route additionally reads ?quote=<publicToken> and calls loadPublicQuote(token) (lib/quote.js). Sections of type quote_summary or quote_details receive the quote as a prop.

The hero section on instant-quote is special: it injects <QuoteForm clientSlug={...} nextSteps={...} />, no matter which hero variant the admin picked. nextSteps is pulled from the what_happens_next section's content if present.

Admin dashboard structure

/admin/* is the only admin surface. Pages are Server Components that read straight from Supabase using the service role key.

  • app/admin/clients/page.js — list rendered server-side.
  • app/admin/clients/new/page.js + NewClientForm.jsx — the onboarding form. Initial form state is seeded from lib/clientDefaults.js and lib/estimate.js#DEFAULT_PRICING, so new clients always start from the canonical template.
  • app/admin/clients/[clientId]/page.js + EditClientForm.jsx — edits branding, pricing, integrations, company info. Landing-page content is no longer edited here — it lives in the page builder.
  • app/admin/clients/[clientId]/pages/[pageKey]/PageSectionEditor.jsx — adds / reorders / hides / edits sections.
  • app/admin/clients/[clientId]/pages/[pageKey]/preview/ — renders the page in an iframe using draft section content so the admin can see changes before saving.

Auth is a single shared ADMIN_TOKEN query string (forwarded to admin API calls as a bearer token). No user accounts, no roles.

How client settings are stored and loaded

The Supabase schema is split across a small set of side tables, each keyed by client_id:

Table Purpose
clients One row per client. Holds slug, name, timestamps.
client_branding Colors, phone, email, service area, review rating/count, license/warranty/financing copy.
client_pricing_rules Pricing JSONB columns + range spread + min job floor.
client_integrations Webhook URLs, notification email/phone, GTM container ID.
client_assets Uploaded images (logo, hero image, etc.). Many rows per client; asset_type distinguishes them.
client_page_content Legacy flat row for hero/trust/how-it-works/next-steps. Still read as a fallback so old clients keep rendering.
client_pages Per-(client, page_key) metadata (title, meta description, published flag).
page_sections Typed, ordered, variant-aware sections — the page builder's storage.

lib/client.js#loadClientBySlug fetches everything except the page builder (used by the public quote submit API, which needs branding and integrations but not the landing-page content). lib/page.js#loadPage adds client_pages + page_sections and is what the public page renderer uses.

When the admin saves edits, the API route uses an upsert helper that does UPDATE ... WHERE client_id = ? first, falls back to INSERT only when zero rows were affected. This avoids relying on a synthetic primary key — the side tables are keyed by (client_id) or (client_id, page_key).

How template / theme settings work

See THEMING_AND_TEMPLATES for the full walkthrough. Short version:

  • Admin picks up to four colors (primary, secondary, accent, footer).
  • lib/theme.js#buildClientTheme ranks the supplied colors by role (CTA, dark, accent) regardless of which admin slot they were dropped in. So a navy + blue + green palette resolves to "blue CTA, navy dark sections, green accent" no matter how the admin entered them.
  • The output is a flat theme object with both hex values and r g b channel strings.
  • themeToCssVars(theme) flattens those into --brand-rgb, --brand-dark-rgb, --accent-rgb, etc. — these are what Tailwind's tailwind.config.js reads via rgb(var(--brand-rgb) / <alpha-value>).
  • lib/themePresets.js defines designer-vetted palettes the admin can apply with one click.

How pricing settings work

See PRICING_LOGIC. Short version:

  • Defaults live in lib/estimate.js#DEFAULT_PRICING.
  • Each client may override any leaf via the admin's pricing form, saved into client_pricing_rules as JSONB columns.
  • resolvePricing(rules) merges client values on top of defaults leaf-by-leaf — any leaf the client hasn't set falls back to the default. Saved values always win.
  • calculateEstimate(input, rules) is the single function every estimate flows through. The admin preview and the public POST handler both call it with the same arguments.

How form submissions work

The form is components/QuoteForm.jsx — a client component on the public landing page. It is split into 4 steps:

  1. Address — Google Places autocomplete. When the user picks a suggestion, the form fires POST /api/eagleview/prefetch in the background and shows a "measuring roof" transition.
  2. Roof size + stories — EagleView's measurement, prefilled and editable. If EagleView didn't return a number, the user types the roof square footage themselves.
  3. Details — roof age, complexity, material.
  4. Contact — name, phone, email.

On submit, the form POSTs to /api/quotes:

{
  "clientSlug": "summit-roofing",
  "address": "123 Maple St, Springfield, IL 62701",
  "addressComponents": { "streetAddress": "...", "city": "...", ... },
  "roofAreaSqFt": 2400,
  "roofAreaSource": "eagleview" | "fallback",
  "stories": "2",
  "roofAge": "10_20",
  "complexity": "moderate",
  "material": "asphalt_arch",
  "fullName": "...",
  "phone": "...",
  "email": "..."
}

The handler in app/api/quotes/route.js:

  1. Validates required fields.
  2. Loads the client by slug.
  3. Calls calculateEstimate(...) with the client's pricing rules.
  4. Inserts a row into leads (PII).
  5. Inserts a row into quotes (estimate + roof inputs + quote_number and public_token via column defaults).
  6. Caches the monthly payment with monthlyPayment(mid) and stores it on the quote.
  7. Best-effort POSTs the full payload to any configured webhook URLs in client_integrations (Forminit, GoHighLevel, HubSpot). Webhook failures are logged and swallowed — they never fail the response.
  8. Returns { publicToken, quoteNumber, low, mid, high, monthlyPayment }.
  9. The form redirects the browser to /<clientSlug>/thank-you?quote=<publicToken>.

How quote results are generated

See PRICING_LOGIC for the formula. The output shape is { low, mid, high, roofSqft, roofAreaSource }, all dollars rounded to the nearest $100 (mid is unrounded internally for the cached monthly payment).

How thank-you pages display quote results

  • Route: /[clientSlug]/thank-you?quote=<publicToken>.
  • loadPublicQuote(token) (lib/quote.js) reads only safe fields from the quotes row — lead PII (full name, phone, email) is never returned, because the URL is reachable by anyone with the link.
  • The sanitised quote object is passed into quote_summary / quote_details sections via props.
  • If the token is missing or no row matches, the lookup returns null and quote-aware sections render their empty state. The page still renders.

Database tables

Table Migration Notes
leads schema.sql PII: full_name, phone, email. RLS on, no public policies.
quotes schema.sql + 001, 002, 007, 009 Estimate band, address, roof inputs, public_token, quote_number, cached estimated_monthly_payment. RLS on.
clients added in multi-tenant migration (pre-001) slug is the public URL segment.
client_branding pre-001 + 010 (footer color) Colors + per-client trust copy.
client_pricing_rules pre-001 + 004 (min_job_floor, pitch_mult) JSONB pricing maps + range_spread + min_job_floor.
client_integrations pre-001 + 005 (gtm_container_id) Webhook URLs + notification email/phone + GTM.
client_page_content pre-001 Legacy flat row for landing-page copy. Still read as fallback by lib/page.js.
client_pages 008 Per-(client, page_key) metadata.
page_sections 008 Typed sections with variant, sort_order, is_visible, content (jsonb).
client_assets pre-001 Uploaded images. asset_type distinguishes them; is_active filters out soft-deleted.
eagleview_property_lookups 006 Optional log of raw EagleView responses. Only written when EAGLEVIEW_LOG_RESPONSES=1.
clients.notes 011 Admin-only internal notes column. Never selected by public-page loaders.

The quote_number_seq sequence (migration 009) generates human-readable quote numbers (Q-001001, Q-001002, …). The public_token is a UUID generated by gen_random_uuid() — unguessable, and it's the access control for the thank-you page.

Webhook / integration flow

When a quote is saved, the API best-effort POSTs

{
  "client": { "id": "...", "slug": "...", "name": "..." },
  "lead":   { /* full leads row, including PII */ },
  "quote":  { /* full quotes row */ }
}

to whichever of forminit_webhook_url, ghl_webhook_url, and hubspot_webhook_url are set in client_integrations. Each request:

  • has a 5 second timeout;
  • runs in parallel with the others (Promise.all);
  • never throws — any failure is logged and swallowed.

See INTEGRATIONS for the full integration story, including GTM, EagleView, and notification flows.