docs/ONBOARDING.mdNew 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:
- ADMIN_GUIDE — admin nav + general layout
- THEMING_AND_TEMPLATES — what the colors / sections drive on the public page
- INTEGRATIONS — Zapier setup details
- PRICING_LOGIC — how the per-sqft + multipliers feed the estimate
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)
- HTML head parse — favicon, OG image, theme-color meta,
JSON-LD Organization,
<title>,og:site_name, JSON-LDtelephone/contactPoint.telephone. - CSS parse — every inline
<style>block plus the first linked stylesheet. Looks for CSS custom properties named--brand,--primary,--accent,--button,--cta,--theme,--mainand button-class background colors (.btn-primary,.button-primary, etc.). - Image dominant-color extraction —
sharpdecodes 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. - 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). - 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):
- Fetch the image with a 6 s timeout, capped at 4 MB.
- Decode + resize to 64×64 with
sharp({ failOn: "none" })so broken or partial files don't throw. - 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 < 18andmax < 230)
- transparent pixels (
- Bucket remaining pixels by
(R, G, B) / 32so visually similar pixels coalesce. - Weight each bucket by
count × (saturation + 0.15)— punchy brand colours beat muddy backgrounds even when the muddy area has more pixels. - 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:
- JSON-LD
Organization.telephone(highest signal). - JSON-LD
contactPoint[].telephone. <a href="tel:...">link in the body.- 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):
<meta name="theme-color">- CSS custom properties (
--brand,--primary, etc.) .btn-primary/.button-primarybackground-color- Logo image dominant color
- Favicon image dominant color
- 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:
- Zapier lead delivery — enable toggle + webhook URL + label
- Test Zapier Webhook button. See INTEGRATIONS for the full delivery model.
- Tracking — Google Tag Manager container ID.
- 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:
payloadpart — JSON of the form state minus the File assetsfile_logo,file_favicon,file_hero,file_visualparts 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
sessionStoragebacking 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