docs/ARCHITECTURE.mdArchitecture
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:
loadPage(slug, pageKey)(lib/page.js) fetchesclients,client_branding,client_integrations,client_assets,client_page_content(legacy), andclient_pages+page_sectionsin parallel.- If section rows exist for the page, those are used as-is.
- Otherwise, sections are synthesised from the legacy
client_page_contentrow usingPAGE_DEFAULTSfromlib/sections/meta.js. Existing clients keep rendering without any DB backfill. buildBranding(...)(lib/branding.js) produces abrandobject that includes the resolved theme (viabuildClientThemeinlib/theme.js).- The page injects the theme as CSS variables in a
<style>tag on the root element. - Sections are mapped through
getSection()fromlib/sections/registry.jsand rendered insort_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 fromlib/clientDefaults.jsandlib/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#buildClientThemeranks 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 bchannel strings. themeToCssVars(theme)flattens those into--brand-rgb,--brand-dark-rgb,--accent-rgb, etc. — these are what Tailwind'stailwind.config.jsreads viargb(var(--brand-rgb) / <alpha-value>).lib/themePresets.jsdefines 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_rulesas 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:
- Address — Google Places autocomplete. When the user picks a
suggestion, the form fires
POST /api/eagleview/prefetchin the background and shows a "measuring roof" transition. - Roof size + stories — EagleView's measurement, prefilled and editable. If EagleView didn't return a number, the user types the roof square footage themselves.
- Details — roof age, complexity, material.
- 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:
- Validates required fields.
- Loads the client by slug.
- Calls
calculateEstimate(...)with the client's pricing rules. - Inserts a row into
leads(PII). - Inserts a row into
quotes(estimate + roof inputs +quote_numberandpublic_tokenvia column defaults). - Caches the monthly payment with
monthlyPayment(mid)and stores it on the quote. - 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. - Returns
{ publicToken, quoteNumber, low, mid, high, monthlyPayment }. - 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 thequotesrow — 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_detailssections via props. - If the token is missing or no row matches, the lookup returns
nulland 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.