Instant Quote · Admin

Docs

Project documentation rendered straight from /docs.

docs/ADMIN_GUIDE.md

Admin Guide

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

A walkthrough of the admin area for developers and ops. The same information will help a non-developer admin user, but this is written with engineering context in mind.

Admin nav

Every /admin/* page renders a shared AdminNav strip at the top (app/admin/AdminNav.jsx). Five items:

Item Status Notes
Clients live /admin/clients
Templates live /admin/templates — SMS + email lead-notification copy. See NOTIFICATION_TEMPLATES.
Submissions live /admin/submissions — cross-tenant feed, filterable by client / status / date range, full search.
Settings placeholder
Docs live /admin/docs renders this folder (/docs/*.md) inside the admin.

Placeholder items render as aria-disabled chips with a small muted dot (visible at md+ only) — no loud "Soon" pill. The label is explained through the title tooltip for hover/screen-reader users. The token query string is forwarded through every active nav link so admins don't lose their session jumping between sections.

Docs viewer (/admin/docs)

Server-rendered page that reads every .md file from /docs and renders it with the marked parser. The sidebar lists every file in the directory (README pinned first, then alphabetical); the right pane shows the active doc. The active file is controlled by ?file=<name>.md.

  • Source: files are read at request time with fs.readFileSync from process.cwd()/docs. next.config.js#outputFileTracingIncludes pins the markdown files into the /admin/docs serverless bundle so Vercel deploys include them — without this the route would 500 on Vercel even though it works in local dev.
  • Safety: the ?file= query parameter is whitelisted against the directory listing before any read, so a crafted path like ?file=../../etc/passwd resolves back to the default doc.
  • Rendering: GFM enabled (tables, fenced code, autolinks). The output HTML is styled by .prose-doc rules in app/globals.css — kept hand-rolled instead of adding @tailwindcss/typography.

Related docs: README, THEMING_AND_TEMPLATES, PRICING_LOGIC, INTEGRATIONS.

Access

  • All admin pages live under /admin/*.
  • Auth is a single shared ADMIN_TOKEN query string: https://<your-domain>/admin/clients?token=<ADMIN_TOKEN>.
  • The token is forwarded between admin pages and to admin API calls as a Bearer header.
  • If ADMIN_TOKEN is not set in the deploy, admin is wide open. Always set it in production.

Clients dashboard (/admin/clients)

The list page is the entry point for everything else. It has four parts:

  1. Page header — page title, lead-in copy, total client count, a prominent New client CTA, and a secondary link to the EagleView sandbox.

  2. Summary cards — at-a-glance metrics:

    • Total clients — count of clients rows.
    • Live quote pages — same count, with a sublabel noting how many are fully configured (setup complete).
    • Recently added — the most recent client by created_at.
    • Setup health — average completion across the per-client setup checklist. Clickable — opens /admin/health for a per-issue breakdown of every open item across every client. Tinted green / amber / rose depending on the average.
  3. Search + filters — a free-text search across name, slug, and live URL, plus three filter chips: All, Recent (30d), and Needs setup. Each chip shows a live count.

  4. Client cards — one card per client, in a responsive grid:

    • 1 column on mobile (<md)
    • 2 columns on tablet (md and up)
    • 3 columns on large desktops (xl and up)

    We deliberately cap at 3 columns; on wider screens each card has plenty of room rather than going narrower. Each card shows:

    • Brand-tinted avatar with the first letter of the company name.
    • Company name (wraps to 2 lines if needed; full name available via title tooltip) + status badge — inferred from setup completion until we add a real status column:
      • Live — pricing + colors + page configured, ≥70% complete.
      • Draft — minimum-viable config but partial.
      • Needs setup — missing the basics.
      • Paused — reserved for future use (manual flag).
    • Slug as a copyable code chip.
    • Created date.
    • Live URL with Copy button (writes the full origin + path to the clipboard; brief "Copied" confirmation).
    • Setup progress bar with checklist underneath. Label reads "X% Complete · N/M" so the wording matches the in-card status badge. Checklist items render at text-xs so they stay scannable on both desktop and mobile.
    • Action row with a clear hierarchy: Edit (primary, dark, full width) → Submissions (secondary, white + ring) → Open (tertiary, ghost button — icon + label, label hidden on the smallest mobile widths). The Copy button for the URL stays inline with the URL text itself.

Setup Health drill-down (/admin/health)

Click the Setup Health summary card to see every open setup issue grouped by category:

  • Missing logo
  • Missing phone number
  • Missing brand colors
  • Missing pricing rules
  • Missing financing copy
  • Missing tracking / integrations
  • Instant Quote page not configured
  • Thank-you page not reviewed

Each category card lists every affected client with a Fix button that jumps straight into that client's edit page. The header also shows a total open-issue count at the top right.

The "Test submission completed" item from the in-form checklist is not surfaced here — it's a per-admin localStorage flag, not a DB-derived one, so there's no centralised way to aggregate it.

Setup checklist

Each client gets a 7-item checklist showing what's already configured versus what still needs attention. The list page loads the minimum data needed to compute these flags in parallel with the clients query (one extra query per side table). Items:

Key Done when…
Logo uploaded A row in client_assets with asset_type = "logo" and is_active = true.
Phone added client_branding.phone is set.
Brand colors set client_branding.primary_color is set.
Pricing rules saved Any leaf in client_pricing_rules is non-null.
Financing copy client_branding.financing_copy is set.
Tracking / integrations Any of GTM ID, Forminit / GHL / HubSpot webhook URLs is set.
Quote page configured A client_pages row exists for instant-quote with is_published != false.

The progress bar color follows the same green / amber / rose threshold as the setup card. Hovering each checklist item shows a short tooltip.

Edit client (/admin/clients/[clientId])

The edit page has four parts:

  1. Top header — breadcrumbs + actions: View submissions, Duplicate client, Open live page, Back.
  2. Setup checklist banner — pulled to the top of the form so admins see what's still outstanding before they start editing. See the next section.
  3. Tab strip — the form proper, organised into Overview / Branding / Pricing / Integrations / Media tabs.
  4. Sticky live preview — the branding preview pinned to the right at lg+.

The active tab is highlighted in a sticky tab strip at the top of the form; switching tabs preserves all unsaved input and any in-flight asset uploads (every tab panel stays mounted).

Tab Contents
Overview Landing-pages card + company info (name, phone, email, service area) + Internal notes. The slug is shown read-only.
Branding Primary / secondary / accent / footer colors, review rating + count, license / warranty / financing trust copy.
Pricing Estimate mode preset, range spread, minimum job floor, material price per sq ft, complexity / story / pitch multipliers, age adders. See PRICING_LOGIC.
Integrations Forminit / GHL / HubSpot webhook URLs, notification email + phone, GTM container ID.
Media Logo, favicon, hero image, supporting visual. Each upload saves immediately.

Setup checklist (top of edit page)

A banner at the very top of the edit form lists every setup item for this client, with a progress bar and "N/M · X% Complete" label:

  • Auto-tracked items (derived from saved data):
    • Logo uploaded
    • Phone added
    • Brand colors set
    • Hero copy completed (a client_pages row exists for instant-quote)
    • Pricing rules saved
    • Financing copy added
    • Tracking configured (GTM or any webhook URL)
    • Thank-you page reviewed (a client_pages row exists for thank-you)
  • Manual items (admin ticks them off):
    • Test submission completed
    • Live URL checked

Manual items persist in localStorage keyed by client id — they're a launch checklist for the admin team, not part of the public state and not synced across devices.

Internal notes

Inside the Overview tab there's an Internal notes textarea on the Company info section. The text is admin-only: it lives in clients.notes, is never selected by lib/client.js or lib/page.js, and never renders on the public quote page.

Use it for ops context like:

  • "Uses GTM only — do not add Meta lead events."
  • "Pricing confirmed with client 2025-04-12."
  • "Uses CallRail tracking number — keep the phone in branding as-is."
  • "Architectural asphalt only — disable other materials in pricing."

Requires migration 011 (supabase/migrations/011_client_internal_notes.sql) to be run in Supabase. Until the migration is applied, the field renders normally but the value is dropped server-side with a warning in the logs. The rest of the save still succeeds.

Duplicate client

Each edit page has a Duplicate client button in the top header that opens a modal asking for a new company name + slug, then calls POST /api/admin/clients/:id/duplicate. After success the admin is redirected to the new client's edit page.

What carries over (the template parts):

  • client_branding (minus phone/email/service area — those are identity, not template)
  • client_pricing_rules (full snapshot)
  • client_pages + page_sections (every page the source client has)

What does NOT carry over (safety):

  • client_integrations — webhook URLs and GTM ID are intentionally dropped so the new client's leads can't accidentally route to the original contractor's CRM.
  • client_assets — logo and image files. Upload fresh ones.
  • clients.notes — internal admin context is per-client.
  • leads / quotes — historical data stays with the source client.

If any insert mid-flight fails, the duplicate endpoint deletes the new clients row; every side table cascades, so partial copies are cleaned up automatically.

Submissions dashboard

The Submissions surface is the database-first lead of record — every quote a homeowner submits is persisted in Supabase and surfaced here before we add any external delivery (Zapier, CRM, SMS, email). The team works the leads out of this view.

List (/admin/submissions)

Cross-tenant feed of every quote, newest first, paginated 50 per page.

Filters — all combine; changing any filter resets to page 1:

  • Search (free text) — matches across name, email, phone, address, city, ZIP, quote number. Filters the current page slice client-side, so wider searches paginate through the result set.
  • Client — dropdown of every client; "All clients" by default. The Submissions button on each client card on /admin/clients deep-links here with the client pre-filtered (?client=<slug>).
  • Zapier — delivery status. Any (default) / Sent / Failed / Skipped / Not configured. Reads from quotes.zapier_status. See INTEGRATIONS for the full Zapier model.
  • Status — defaults to "Active": shows new + reviewed, hides archived and test to keep the working queue clean. Other options: new, reviewed, archived, test, or all.
  • Date range7d, 30d, 90d, or all-time.

Row columns — Submitted timestamp · Client · Lead · Contact (email + phone) · Address · Estimate band (low–high + midpoint) · Material · Status badge · View action.

Detail (/admin/submissions/[id])

The detail page is sectioned for fast scanning and includes a side panel with copy buttons + the rendered lead summary:

  1. Contact info — first / last / full name, phone, email.
  2. Property — street, city, state, ZIP, full address (composed).
  3. Quote — low / mid / high, monthly financing estimate, roof square footage (with EagleView vs manual source tag), material, stories, roof age, complexity.
  4. Qualification — timeline, financing interest.
  5. Tracking — source URL, page slug, referrer, every UTM field, gclid, fbclid.
  6. System — submission ID, quote number, public token, client ID, lead ID, created/updated timestamps, status.

Quick actions (side panel):

  • Copy phone, Copy email, Copy full address.
  • Copy lead summary — primary action, copies a plain-text block designed to paste straight into Slack / email / a CRM until we build native delivery integrations.

Status control in the page header. Cycles through newreviewedtestarchived. PATCHes /api/admin/submissions/:id with the new status. The status badge in the list updates on the next page load.

PII is visible across the whole Submissions surface. Never link to these pages from a public surface; the loadPublicQuote helper in lib/quote.js is the sanitised path that strips PII for the public-facing thank-you page.

Legacy URL

/admin/clients/[id]/leads is a redirect to /admin/submissions?client=<slug> so any old bookmarks transparently forward to the new canonical view.

Submission storage flow

  1. The homeowner submits the form on /<clientSlug>/instant-quote.
  2. The form captures marketing attribution on mount (captureTracking() in components/QuoteForm.jsx): source_url, referrer, page_slug, all UTM fields, gclid, fbclid. Stored in a ref so URL changes mid-flow can't clobber them.
  3. On submit, the form POSTs to /api/quotes with the full payload including the tracking block.
  4. The API:
    • Validates required fields.
    • Resolves "Not sure" → safe defaults.
    • Runs calculateEstimate(...).
    • Inserts the lead row first (leads), then the quote row (quotes). Quote row carries: every form field, the estimate band, monthly payment estimate, tracking block, and status = 'new'.
    • Both inserts are tolerant of missing columns (migration 012 or 013 not applied) — the API strips the offending column group and retries rather than fail the user-facing submit.
    • Best-effort forwards to any configured webhook URLs on the client. Webhook failures never block the response.
  5. Form redirects the homeowner to /<clientSlug>/thank-you?quote=<publicToken> for their estimate display.

The lead + quote rows are the source of truth. Webhooks are best-effort; the database row is authoritative.

UTM + tracking capture

Field Source Captured when
source_url window.location.href Form mount
referrer document.referrer Form mount
page_slug last segment of window.location.pathname Form mount
utm_source / utm_medium / utm_campaign / utm_content / utm_term URL search params Form mount
gclid, fbclid URL search params Form mount

All values are captured once on form mount and stashed in a React ref so subsequent URL changes (SPA navigations, Google Maps URL rewrites, etc.) can't clobber the original landing context. Each field is optional; missing values are saved as NULL.

Submission status + test handling

Test-vs-real and lifecycle status are two independent flags. A lead can be Reviewed Test or New Real — the controls don't collide.

Lifecycle status (quotes.status)

Three values:

  • new (default on insert) — needs follow-up.
  • reviewed — admin has worked the lead.
  • archived — done / spam / dead. Hidden from the default Active view.

Set from the Submission detail page's Status dropdown.

Test flag (quotes.is_test)

Boolean, default false. Three independent ways to set it:

  1. URL parameter on the public form — visit the live URL with ?test=true (or ?mode=test) appended: https://your-host/<slug>/instant-quote?test=true. The form shows a quiet amber "Test mode" banner above the card so the admin can see the flag is on; real homeowner traffic never has the param and never sees the banner. The full submission still runs through Google autocomplete, EagleView, the estimate engine, and the thank-you page — only is_test differs.
  2. "Submit test lead" button on each client's edit page (/admin/clients/[id]) — opens that client's live URL with ?test=true already appended in a new tab.
  3. "Mark as test" toggle on the Submission detail page — flips is_test on an existing submission. Useful if you forgot the URL param mid-test or want to mark a stray real submission you know was a colleague QAing.

Filters

The Submissions list has two separate filter dropdowns:

  • Lead type: Real (default), Test only, All. Hides test leads by default so the working queue stays clean.
  • Status: Active (default), New only, Reviewed, Archived, All statuses.

Both can combine — e.g. "Reviewed + Test only" to audit which test runs have been processed.

Test badge

Every test submission shows a small amber TEST chip in the list row's Status column AND an amber banner across the top of its detail page. The detail page's System section also shows the raw Is test: true|false value.

Future external delivery

When we add Zapier / CRM / SMS / email plumbing, test leads must be skipped by default. Implementations should:

  • Check quote.is_test before sending to any external system.
  • Provide an explicit opt-in (forwardTestLeads: true) for test integrations that need to see the full feed.
  • Never silently send test leads alongside real ones.

This rule belongs anywhere we read quote from the DB for delivery purposes. The Admin UI is fine to render every submission regardless of flag — the filters above let the team toggle visibility.

The live preview (BrandingPreview) is sticky on the right at lg+ and reflects color and logo edits in real time.

Save state

The tab strip carries a save-state pill that reflects the form's current state in real time:

  • 🔘 No changes — form matches the last save.
  • 🟠 Unsaved changes — pending edits that haven't been saved yet.
  • 🟡 Saving… — submit in flight.
  • 🟢 Saved — last submission succeeded; nothing pending.

Settings changes are not autosaved — you must click Save changes on the sticky save bar at the bottom of the page. Asset uploads in the Media tab are an exception: each upload saves immediately, independently of the save button, and survives Save / Cancel.

Creating a client (New Client Wizard)

Onboarding is a six-step guided wizard at /admin/new-client. Full walkthrough lives in ONBOARDING; the quick version:

  1. Go to /admin/clients?token=... and click New client.
  2. Step 1 — Company: paste the contractor's website URL and click Pull Brand From Website to auto-detect their logo, favicon, theme color, name, and phone. Anything not detected you can type manually below. Slug auto-derives from the name but stays editable.
  3. Step 2 — Branding: brand color (auto-filled from detection when found), dark / accent / footer colors, review rating + count, license / warranty / financing trust copy.
  4. Step 3 — Content: hero eyebrow / headline / subhead / bullets, trust items, how-it-works, next-steps, footer copy.
  5. Step 4 — Pricing: pick a preset (Default asphalt / Premium / Metal / High-cost market / Budget market) and tweak, or set every field manually.
  6. Step 5 — Integrations: Zapier setup at the top, GTM, calendar URL, legacy webhooks collapsed below. Asset uploads (logo / favicon / hero / supporting visual) live on this step too.
  7. Step 6 — Launch: review checklist + Create client.

The wizard uses the same backend (POST /api/admin/clients) and the same payload shape as the previous long form. Existing client data is unaffected.

Brand detection details

Server-side fetch + HTML parse (no JavaScript rendering). Detects favicon, OG image, JSON-LD Organization data, theme-color meta. Image dominant-color extraction is not implemented yet — the wizard uses the detected theme-color or falls back to the existing theme presets. See ONBOARDING § Brand detection.

Pricing presets

Defined in lib/pricingPresets.js. Applying a preset patches the form state — admins can still edit each field individually. Quote math is unchanged; presets just seed realistic numbers.

Editing a client

/admin/clients/[clientId]?token=... loads the current settings via GET /api/admin/clients/[clientId] and lets you edit them. Saves go to PATCH /api/admin/clients/[clientId].

Slug is intentionally not editable here. Changing it would require coordinated renames of the public URL, GTM event filters, contractor-side bookmarks, and any uploaded-asset storage paths. If you ever need to rename a client, plan it explicitly — there's no button for it.

What each admin field controls

Pricing rules

Lives in client_pricing_rules. See PRICING_LOGIC for the formula. Notes:

  • Leaving a leaf blank stores nothing in the JSONB map and the default kicks back in at calculation time.
  • The Estimate mode chips at the top of the section are shortcuts: clicking one patches the range_spread field. Manual edits below the chips override the preset.
  • Minimum job floor stores null in the DB when blank or <= 0; that's treated as "use the default floor from DEFAULT_PRICING.min_job_floor."

Branding / theme

Lives in client_branding. The admin form includes a live preview (BrandingPreview.jsx) that runs the same buildClientTheme(...) the public page would use. The preview surfaces:

  • A miniature page with the brand applied.
  • Contrast ratio warnings (WCAG AA / AAA) when foreground / background contrast is too low.
  • "Visually too similar" warnings when two color slots are nearly identical (cheap RGB distance in lib/theme.js).

The Theme presets chip strip is a one-click reset to a curated palette. Picking a preset replaces the four color fields in the form state; nothing is saved until the form is submitted.

Content / copy

Two surfaces, and this trips people up:

  • The legacy client_page_content row holds hero / trust / how-it-works / next-steps copy. The new-client form still seeds this row so brand-new clients have content immediately.
  • After the client exists, page content is owned by the page builder (/admin/clients/[id]/pages/[pageKey]). The edit-client form does not show content fields and PATCH /api/admin/clients/[id] does not write client_page_content. Editing copy goes through the page builder.

Integrations

Lives in client_integrations. Each field is documented in INTEGRATIONS:

  • Forminit / GHL / HubSpot webhook URLs — POSTed on quote save.
  • Notification email + phone — stored, not read by the current quote route. Available to integrations / downstream services.
  • GTM container ID — must match GTM-XXXXXXX. Normalised to uppercase server-side.

Live preview section

BrandingPreview.jsx renders an inline preview of the theme as you edit it. On the page builder side, /admin/clients/[id]/pages/[key]/preview renders the actual public page in an iframe using the in-progress section content, so you can see real layout (not just colors) before saving.

The preview iframe does its own loadout of section components via HeroPreview.jsx etc. — if you add a new section type and forget to register it for preview, the preview will silently skip it. Search for PreviewFrame.jsx when wiring new sections.

Page builder

/admin/clients/[id]/pages/[key] is the section editor. From there you can:

  • Add a section (from any registered type the page allows — see pages on each entry in SECTION_META).
  • Reorder sections (drag).
  • Toggle visibility (is_visible on the row).
  • Pick a variant (when the section type defines variants).
  • Edit any field from the section's schema in the side panel.

The save endpoint is PUT /api/admin/clients/[id]/pages/[key], which upserts the client_pages row and replaces the page_sections rows for that page.

site_header and site_footer are non-removable (removable: false in meta.js). Everything else can be removed.

How saved settings affect the public quote page

Two write surfaces produce two public-page reads:

Admin action Writes to Read by
Edit client → Branding client_branding, client_assets loadPage + buildBranding → CSS variables on every page
Edit client → Pricing client_pricing_rules POST /api/quotescalculateEstimate
Edit client → Integrations client_integrations POST /api/quotes → webhooks; loadPage → GTM
Page builder → Save client_pages, page_sections loadPage → section list on public page

All public pages set export const dynamic = "force-dynamic", so saves are visible on the very next request. No deploy needed, no cache to bust.

Known limitations

  • Settings are not autosaved. The save-state pill makes the state explicit, but you still have to click Save changes. Asset uploads on the Media tab are the exception — those save immediately.
  • No device toggle in the live preview. The BrandingPreview component shows a desktop-sized brand summary plus contrast warnings. A dedicated mobile / tablet preview is future work — for now, use the page builder's iframe preview (which renders the real page at the real width).
  • The new-client onboarding form is the same long single-page form. The clients list page is the new "premium" surface; the onboarding flow itself was intentionally left alone in this pass to avoid rewriting 1,200 lines of working code. A wizard-style onboarding would be a natural follow-up.
  • Setup checklist booleans are heuristic. "Pricing rules saved" flips to true the moment any leaf in client_pricing_rules is non-null — so a client whose only override is a typo'd material price will still read as configured. The list page is for navigation, not validation; the edit form is the source of truth.
  • Single shared admin token, no user accounts. Everyone with the token has full access to every client. There is no audit log.
  • No "undo" on page builder saves. A save replaces the section list for that page. If a user accidentally deletes a section, their option is to add it back from the section library.
  • No multi-page editing. You can only edit one page at a time; there's no "apply this hero to every client" bulk action.
  • Slug rename is unsupported. See above.
  • ADMIN_TOKEN is in the URL query string. It survives browser history and Referer headers if your team posts admin links in chat. Treat it like a password.
  • EagleView sandbox is geographically limited. Test addresses outside the sandbox region will fail at the prefetch step. Use the Omaha sample address in the EagleView sandbox docs to validate.
  • Asset uploads are not deduplicated. Re-uploading the same logo three times leaves three rows in client_assets (only the latest active row is rendered, but the others sit there).
  • No bulk import. All clients are created one at a time through the onboarding form.
  • The form does not yet collect pitch. The pitch multiplier is fully wired into pricing but currently defaults to standard on every quote.