Instant Quote · Admin

Docs

Project documentation rendered straight from /docs.

docs/ONBOARDING.md

New Client Onboarding

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

The New Client Wizard (/admin/new-client) creates a fully-configured client in six guided steps. It replaces the previous long-form single-page onboarding so the team can stand up a new roofing contractor in a couple of minutes — pull brand from their website, apply a pricing preset, paste a Zapier hook, done.

Nothing about how a created client actually behaves changed:

  • The wizard POSTs the same payload to the same endpoint (/api/admin/clients) as the old long form.
  • Every per-section component (Company / Branding / Content / Pricing / Integrations / Assets) is reused inside the wizard.
  • Public quote pages, EagleView, pricing math, submissions dashboard, Zapier delivery, and existing-client data are all unaffected.

Related docs:

Step structure

# Step What it sets
1 Company Name, slug (auto from name, editable), phone, email, service area + the optional Pull Brand From Website detector
2 Branding Logo, favicon, brand color, dark / accent / footer colors, review rating + count, license / warranty / financing trust copy
3 Content Hero eyebrow / headline / subhead / bullets, trust items, how-it-works steps, next-steps, footer copy
4 Pricing Pricing presets + per-sqft prices, complexity / story / pitch multipliers, age adders, range spread, minimum job floor
5 Integrations GTM container ID, Zapier (enable + URL + label + test button), calendar URL, legacy webhooks (collapsed), asset uploads
6 Launch Review checklist + final Create button

A progress bar across the top renders Step X of 6: {Label} plus clickable chips for each step — admins can jump to any step at any time (the form state is preserved across jumps).

Navigation

  • Back — previous step. Disabled on step 1.
  • Save & Continue — next step. Form state lives in React; no draft persistence yet (see "Known limitations").
  • Create client — final step only. Submits the full payload to /api/admin/clients.

Pressing Enter inside any field triggers Save & Continue (the form is the same <form> it's always been; the wizard just gates which section renders).

Brand detection (Step 1)

Optional. Paste a contractor's marketing site URL, hit Pull Brand From Website, and the wizard calls POST /api/admin/brand-detect. The server fetches the page with a short timeout (8s) and runs a layered detection pipeline — each layer fills gaps the previous one didn't reach.

Detection pipeline (in order)

  1. HTML head parse — favicon, OG image, theme-color meta, JSON-LD Organization, <title>, og:site_name, JSON-LD telephone / contactPoint.telephone.
  2. CSS parse — every inline <style> block plus the first linked stylesheet. Looks for CSS custom properties named --brand, --primary, --accent, --button, --cta, --theme, --main and button-class background colors (.btn-primary, .button-primary, etc.).
  3. Image dominant-color extractionsharp decodes the logo, favicon, and OG image, samples pixels at a 64×64 thumbnail resolution, builds a histogram with reduced colour buckets, filters near-white / near-black / desaturated greys, and returns the most-saturated dominant bucket per image.
  4. Phone fallback — when JSON-LD didn't ship a phone, we scan the whole HTML for <a href="tel:..."> links first, then common phone-number patterns in visible body text (with <script> / <style> / <svg> stripped so JS strings can't false-positive).
  5. Palette synthesis — the strongest non-neutral colour candidate becomes primary. Dark, accent, and footer are derived from primary using darken / lighten / mix helpers. Contrast against white is checked against WCAG AA (4.5:1); when primary fails, we suggest a darker variant.

Per-image color sampling

extractImageDominantColor() (in lib/brandDetection.js):

  1. Fetch the image with a 6 s timeout, capped at 4 MB.
  2. Decode + resize to 64×64 with sharp({ failOn: "none" }) so broken or partial files don't throw.
  3. Walk the raw RGBA buffer, skip:
    • transparent pixels (alpha < 128)
    • near-white (R,G,B > 240)
    • near-black (R,G,B < 16)
    • low-chroma greys (max - min < 18 and max < 230)
  4. Bucket remaining pixels by (R, G, B) / 32 so visually similar pixels coalesce.
  5. Weight each bucket by count × (saturation + 0.15) — punchy brand colours beat muddy backgrounds even when the muddy area has more pixels.
  6. Return the highest-weighted bucket's average RGB as a hex.

Sharp supports PNG, JPEG, WebP, AVIF, GIF, and SVG (via librsvg) out of the box, so vector logos and raster logos go through the same code path.

Phone detection

Priority order:

  1. JSON-LD Organization.telephone (highest signal).
  2. JSON-LD contactPoint[].telephone.
  3. <a href="tel:..."> link in the body.
  4. Common patterns in visible body text: (555) 123-4567 / 555-123-4567 / +1 555 123 4567.

Body text is scanned after stripping <script>, <style>, <svg>, and HTML comments so phone-shaped junk inside JS strings can't win.

Color signal priority

pickPrimary() walks candidates in this order and returns the first one that's clearly brand-y (not near-white, not near-black, saturation ≥ 0.18):

  1. <meta name="theme-color">
  2. CSS custom properties (--brand, --primary, etc.)
  3. .btn-primary / .button-primary background-color
  4. Logo image dominant color
  5. Favicon image dominant color
  6. OG image dominant color

This means a site that doesn't publish theme-color but has a visible logo still gets a brand colour suggestion.

Palette + contrast

Once a primary is picked:

  • darkBg = darken(primary, 0.7) — used for the dark CTA section background.
  • accent = lighten(primary, 0.25) — used for hover states and small accents.
  • footer = darken(primary, 0.65) — used when the admin doesn't set a dedicated footer color.

Contrast check compares primary against white using the WCAG AA formula ((L_light + 0.05) / (L_dark + 0.05) ≥ 4.5). When primary fails, the wizard suggests darken(primary, 0.25) (or a darker variant if needed). The Apply suggested palette button uses the contrast-suggested primary automatically when the original fails.

Detection summary UI

After a successful run the wizard renders three panels:

  • Detected from page — name, phone, theme-color meta.
  • Images + extracted colors — three cards (logo, favicon, OG image) each showing the image thumbnail + the dominant color swatch we extracted. CSS variables found in stylesheets are rendered as a chip row below.
  • Suggested palette — four swatches (primary, dark, accent, footer) + contrast pass/fail banner + Apply suggested palette button.

When a signal isn't found, the row reads "Not found automatically. You can enter this manually." instead of just "—" so the UI feels collaborative rather than failed.

Behaviour

  • Soft apply on detection — running detection only fills EMPTY form fields. Re-running won't overwrite manual edits.
  • Hard apply on button click — pressing Apply suggested palette intentionally replaces the four brand colors with the suggested palette (using the contrast-suggested primary when the detected primary fails AA).
  • No JavaScript rendering — the page fetch is a single GET. SPA marketing sites that hydrate in the browser will look empty. Roofing-contractor sites (mostly CMS-built) work fine.
  • Failures degrade cleanly — image fetch errors, timeouts, decoding failures, and stylesheet errors are all caught individually. The endpoint returns what it found and the wizard shows a friendly warning for missing signals.
  • Image URLs aren't stored. Detection produces absolute URLs for the wizard to display; the existing asset uploader handles ingestion to Supabase storage when an admin uploads a file manually.

Pricing presets (Step 4)

Five named bundles in lib/pricingPresets.js. Each preset writes into the existing state.pricing slot — no new pricing dimensions, no math change.

Preset Use for
Default asphalt roofing Same numbers as DEFAULT_PRICING. Safe pick when in doubt.
Premium roofing Upmarket pricing, tighter range, larger floor (~$15k).
Metal roofing focused Specialty installer — metal priced higher than asphalt.
High-cost market Coastal / metro — higher labour rates + larger floor.
Budget market Lower COL regions — leaner prices, wider range.

Clicking a preset patches the form state and shows a brief "✓ Applied" confirmation. The admin can still edit any pricing field individually before saving.

Integrations (Step 5)

Same fields as the Edit Client page's Integrations tab, in the same order:

  1. Zapier lead delivery — enable toggle + webhook URL + label
    • Test Zapier Webhook button. See INTEGRATIONS for the full delivery model.
  2. Tracking — Google Tag Manager container ID.
  3. Legacy webhooks — collapsed <details> with Forminit / GHL / HubSpot URLs + notification email / phone, and an amber warning about duplicate delivery if combined with Zapier.

Asset uploads (logo / favicon / hero / supporting visual) sit under the integrations step as well so the entire "what makes the site work end-to-end" cluster is in one place.

Review & Launch (Step 6)

The final step renders the future live URL (/{slug}/instant-quote) plus a two-tier checklist:

Required

  • Company name set
  • Slug set
  • Phone number
  • Brand colors selected
  • Pricing rules saved

Optional

  • Logo uploaded
  • Hero copy customised (vs. template default)
  • Tracking (GTM) configured
  • Zapier lead delivery configured
  • Calendar / booking URL

Each row has a Fix button (when missing) or Edit button (when done) that jumps directly to the step where that field lives. If any required item is missing, an amber blocker banner appears under the checklist naming the gaps.

Clicking Create client submits the same multipart payload the old form did:

  • payload part — JSON of the form state minus the File assets
  • file_logo, file_favicon, file_hero, file_visual parts for any uploaded files

The API response (/api/admin/clients) is unchanged. The success screen renders the new client's slug, URL, and a link to its Edit page.

Live preview

The existing BrandingPreview component lives in the right column of every step. It updates in real time as the form state changes — so picking a preset on the Pricing step won't move the preview, but changing the brand color on the Branding step recolors the preview immediately.

The preview is currently desktop-only. A mobile / thank-you mode toggle is noted as future work (the page builder's iframe preview at /admin/clients/[id]/pages/[key]/preview is already the canonical "render the page at the real width" surface, and the wizard preview is for at-a-glance brand feedback only).

Known limitations

  • No Save Draft. Form state lives in React. Refreshing the page resets the wizard. We could add sessionStorage backing later; today the wizard is fast enough that admins typically complete it in one sitting.
  • No JS-rendered site support. Brand detection only sees server-rendered HTML. SPA marketing sites that hydrate in the browser will look empty.
  • One linked stylesheet inspected. We fetch the first <link rel="stylesheet"> for CSS color hints. Sites that split their brand variables across multiple stylesheets may need a manual primary entry.
  • No mobile preview toggle. Future work; for full-page preview use the page builder's iframe view post-create.
  • No "Create and submit test lead" combined action. Today the flow is: Create client → open the edit page → click "Submit test lead" from the header. The wizard's success screen links into the edit page in one click.
  • Detection latency depends on image weight. Sharp resize is fast (~100 ms for a typical logo) but the bottleneck is the outbound image fetches. Each image has a 6 s timeout and a 4 MB byte cap so a slow image can't strand the request.

Testing checklist (manual)

After deploying:

Wizard mechanics

  • /admin/new-client?token=… opens to step 1
  • Filling in company name auto-derives a slug
  • Switching from step 2 → 3 keeps Branding values intact
  • Pricing preset "Premium roofing" updates all five material prices + range spread
  • Zapier "Test Zapier Webhook" button still works (Integrations step)
  • Review step shows green checks for everything you filled in
  • Required-item missing blocks Create with a clear message
  • Click "Create client" with a complete form → new client appears in /admin/clients
  • Visit the new client's /instant-quote → form loads, EagleView prefetch fires, submit works end-to-end
  • Submission shows up in /admin/submissions
  • Existing clients still render correctly

Brand detection — site mixes (each should return a usable primary color):

  • Site with <meta name="theme-color"> published → theme color wins
  • Site with no theme-color but a visible PNG/SVG logo → logo dominant color wins
  • Site with only a favicon (no logo / OG image) → favicon dominant color wins
  • Site with phone number in the header → tel: link detected
  • Site with phone number only in the footer body → body-text regex detected
  • Site behind a 404 / unreachable domain → friendly inline warning, manual fields stay usable
  • Site with a low-contrast brand color → palette card surfaces the contrast warning + suggested darker variant
  • Re-running detection on the same site → manually-typed fields are NOT overwritten
  • Clicking Apply suggested palette → all four branding color fields update at once