docs/ADMIN_GUIDE.mdAdmin 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.readFileSyncfromprocess.cwd()/docs.next.config.js#outputFileTracingIncludespins the markdown files into the/admin/docsserverless 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/passwdresolves back to the default doc. - Rendering: GFM enabled (tables, fenced code, autolinks). The
output HTML is styled by
.prose-docrules inapp/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_TOKENquery string:https://<your-domain>/admin/clients?token=<ADMIN_TOKEN>. - The token is forwarded between admin pages and to admin API calls as
a
Bearerheader. - If
ADMIN_TOKENis 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:
Page header — page title, lead-in copy, total client count, a prominent New client CTA, and a secondary link to the EagleView sandbox.
Summary cards — at-a-glance metrics:
- Total clients — count of
clientsrows. - 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/healthfor a per-issue breakdown of every open item across every client. Tinted green / amber / rose depending on the average.
- Total clients — count of
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.
Client cards — one card per client, in a responsive grid:
- 1 column on mobile (
<md) - 2 columns on tablet (
mdand up) - 3 columns on large desktops (
xland 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
titletooltip) + 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 attext-xsso 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.
- 1 column on mobile (
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:
- Top header — breadcrumbs + actions: View submissions, Duplicate client, Open live page, Back.
- 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.
- Tab strip — the form proper, organised into Overview / Branding / Pricing / Integrations / Media tabs.
- 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_pagesrow exists forinstant-quote) - Pricing rules saved
- Financing copy added
- Tracking configured (GTM or any webhook URL)
- Thank-you page reviewed (a
client_pagesrow exists forthank-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/clientsdeep-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, hidesarchivedandtestto keep the working queue clean. Other options:new,reviewed,archived,test, orall. - Date range —
7d,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:
- Contact info — first / last / full name, phone, email.
- Property — street, city, state, ZIP, full address (composed).
- Quote — low / mid / high, monthly financing estimate, roof square footage (with EagleView vs manual source tag), material, stories, roof age, complexity.
- Qualification — timeline, financing interest.
- Tracking — source URL, page slug, referrer, every UTM field,
gclid,fbclid. - 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 new →
reviewed → test → archived. 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
- The homeowner submits the form on
/<clientSlug>/instant-quote. - The form captures marketing attribution on mount
(
captureTracking()incomponents/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. - On submit, the form POSTs to
/api/quoteswith the full payload including thetrackingblock. - 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, andstatus = '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.
- 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:
- 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 — onlyis_testdiffers. - "Submit test lead" button on each client's edit page
(
/admin/clients/[id]) — opens that client's live URL with?test=truealready appended in a new tab. - "Mark as test" toggle on the Submission detail page — flips
is_teston 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_testbefore 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:
- Go to
/admin/clients?token=...and click New client. - 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.
- Step 2 — Branding: brand color (auto-filled from detection when found), dark / accent / footer colors, review rating + count, license / warranty / financing trust copy.
- Step 3 — Content: hero eyebrow / headline / subhead / bullets, trust items, how-it-works, next-steps, footer copy.
- Step 4 — Pricing: pick a preset (Default asphalt / Premium / Metal / High-cost market / Budget market) and tweak, or set every field manually.
- 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.
- 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_spreadfield. Manual edits below the chips override the preset. - Minimum job floor stores
nullin the DB when blank or<= 0; that's treated as "use the default floor fromDEFAULT_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_contentrow 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 andPATCH /api/admin/clients/[id]does not writeclient_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
pageson each entry inSECTION_META). - Reorder sections (drag).
- Toggle visibility (
is_visibleon 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/quotes → calculateEstimate |
| 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
BrandingPreviewcomponent 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_rulesis 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_TOKENis in the URL query string. It survives browser history andRefererheaders 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
standardon every quote.