// Main onboarding app — orchestrates the state machine, layout, transitions. // // Adapted from the design bundle's cc-onboarding.jsx. Two changes from the // design's prototype: // 1. TweaksPanel removed — it's a design-time tool, not a customer feature. // Accent palette is hardcoded to the Ecosystem blue→purple direction. // 2. Summary recommendation rewritten to map answers onto the 10-plan // catalog (5 segments x Basic/Pro) used by the production /pricing page. // The "See plans" CTA navigates to /pricing?plan=. const { useState, useEffect, useMemo, useRef } = React; // Inlined from cc-tweaks (which we don't ship): used by the brand panel + // summary card to pick palette colors. const ACCENT_OPTIONS = { blue: { color: '#4ea6ff', accent: '#8a5cf6', label: 'Ecosystem (blue → purple)' }, }; // Plan catalog mirror — must stay in sync with the seeds in // migrations/037_billing_restructure.sql. Kept in JS (rather than fetched) // so the questionnaire page is fully static — no API dependency, no flash // of unstyled content. const PLAN_TABLE = { small_shop_basic: { slug:'small_shop_basic', name:'Small Shop · Basic', monthly_price:'$30/mo', blurb:'1–4 employees, 1–2 bays. Booking, invoicing, customer records.' }, small_shop_pro: { slug:'small_shop_pro', name:'Small Shop · Pro', monthly_price:'$50/mo', blurb:'Adds SMS, advanced reports, an extra user, 100 emails/mo.' }, medium_shop_basic: { slug:'medium_shop_basic', name:'Medium Shop · Basic', monthly_price:'$60/mo', blurb:'Up to 20 employees. Booking, invoicing, bay scheduling.' }, medium_shop_pro: { slug:'medium_shop_pro', name:'Medium Shop · Pro', monthly_price:'$80/mo', blurb:'Adds SMS, advanced reports, an extra user, 100 emails/mo.' }, large_shop_basic: { slug:'large_shop_basic', name:'Large Shop · Basic', monthly_price:'$120/mo', blurb:'20+ employees, multi-location. Unlimited users, bay scheduling.' }, large_shop_pro: { slug:'large_shop_pro', name:'Large Shop · Pro', monthly_price:'$140/mo', blurb:'Adds SMS, advanced reports, 100 emails/mo.' }, mobile_detailer_basic:{ slug:'mobile_detailer_basic',name:'Mobile Detailer · Basic',monthly_price:'$30/mo', blurb:'Solo detailer. Booking, travel estimates, on-site invoicing.' }, mobile_detailer_pro: { slug:'mobile_detailer_pro', name:'Mobile Detailer · Pro', monthly_price:'$50/mo', blurb:'Adds SMS, advanced reports, an extra user, 100 emails/mo.' }, mobile_fleet_basic: { slug:'mobile_fleet_basic', name:'Mobile Fleet · Basic', monthly_price:'$60/mo', blurb:'Multiple mobile techs, shared schedule, on-site invoicing.' }, mobile_fleet_pro: { slug:'mobile_fleet_pro', name:'Mobile Fleet · Pro', monthly_price:'$80/mo', blurb:'Adds SMS, advanced reports, an extra user, 100 emails/mo.' }, }; // (op, size, volume, goal) → one of the 10 plan slugs. // volume thresholds bump a "cheaper / starting" answer up to Pro when the // throughput would otherwise outgrow Basic's quotas. function recommendPlan({ op, size, volume, goal }) { let segment; if (op === 'mobile') { segment = size === 'fleet' ? 'mobile_fleet' : 'mobile_detailer'; } else if (size === 'large') { segment = 'large_shop'; } else if (size === 'medium') { segment = 'medium_shop'; } else { segment = 'small_shop'; } let tier = (goal === 'scale' || goal === 'better') ? 'pro' : 'basic'; const volumeBump = { small_shop: 60, mobile_detailer: 60, medium_shop: 120, mobile_fleet: 120, large_shop: 200, }[segment]; if (typeof volume === 'number' && volume >= volumeBump) tier = 'pro'; return PLAN_TABLE[`${segment}_${tier}`]; } // Compute dynamic flow based on op type const buildFlow = (answers) => { const steps = [ { id: 'start', label: 'Starting point', short: 'Start', n: 1 }, { id: 'op', label: 'Operation type', short: 'Type', n: 2 }, { id: 'size', label: answers.op === 'mobile' ? 'Crew size' : 'Shop size', short: 'Size', n: 3 }, { id: 'volume', label: 'Weekly volume', short: 'Volume', n: 4 }, { id: 'goal', label: 'Your goal', short: 'Goal', n: 5 }, { id: 'square', label: 'Payments', short: 'Square', n: 6 }, ]; return steps; }; // Map answer keys to nice readable labels (recap card). const READABLE = { start: { replace: 'Switching from another tool', fresh: 'Starting fresh' }, op: { mobile: 'Mobile detailing', shop: 'Brick & mortar shop' }, size: { small: 'Small shop · 1–4 employees, 1–2 bays', medium: 'Medium shop · up to 20 employees, ~10 bays', large: 'Large / multi-location · 20+ employees', solo: 'Solo detailer', fleet: 'Fleet — multiple detailers', }, goal: { better: 'Better software', scale: 'Software that scales', cheaper: 'Cheaper option', starting: 'Just getting started', }, square: { has: 'Existing Square account', need: 'Will set one up', later: 'Decide later' }, }; // ─── Left brand panel ─────────────────────────────────────────── const BrandPanel = ({ activeIdx, steps, palette }) => (
Getting started

Let's find your plan.

Six quick questions. Most folks finish in under two minutes.

{steps.map((s, i) => { const isActive = i === activeIdx; const isDone = i < activeIdx; return (
{isDone ? ( ) : ( {String(s.n).padStart(2, '0')} )}
{s.label}
); })}
← Back to home
); // ─── Summary / Done screen ────────────────────────────────────── const Summary = ({ answers, palette, onRestart }) => { const rec = recommendPlan(answers) || PLAN_TABLE.small_shop_basic; const seePlansUrl = `/pricing?plan=${encodeURIComponent(rec.slug)}`; const summary = [ { kicker: 'Starting point', value: READABLE.start[answers.start] || '—' }, { kicker: 'Operation', value: READABLE.op[answers.op] || '—' }, { kicker: answers.op === 'mobile' ? 'Crew' : 'Shop size', value: READABLE.size[answers.size] || '—' }, { kicker: 'Weekly volume', value: `~${answers.volume}${answers.volume >= 200 ? '+' : ''} cars / week` }, { kicker: 'Primary goal', value: READABLE.goal[answers.goal] || '—' }, { kicker: 'Payments', value: READABLE.square[answers.square] || '—' }, ]; return (
All set

Here's the plan we'd start you on.

You can change plans, add features, or stack on add-ons any time from your subscription tab.

{/* Recommended plan card */}
Recommended plan
{rec.name}
{rec.monthly_price}
{rec.blurb}
{/* Answer summary */}
{summary.map((row, i) => (
{row.kicker}
{row.value}
))}
{ window.location.href = seePlansUrl; }}>See plans Open {rec.name} on the pricing page →
); }; // ─── Main App ─────────────────────────────────────────────────── const App = () => { // Tweaks panel removed — palette is fixed in production. const tweaks = { accent: 'blue', density: 'comfortable', showLogoPanel: true }; const palette = ACCENT_OPTIONS[tweaks.accent] || ACCENT_OPTIONS.blue; const [stepIdx, setStepIdx] = useState(0); const [answers, setAnswers] = useState({ start: null, op: null, size: null, volume: 25, goal: null, square: null, }); const [done, setDone] = useState(false); const [transitionKey, setTransitionKey] = useState(0); const steps = useMemo(() => buildFlow(answers), [answers.op]); const setAnswer = (key, val) => setAnswers(prev => ({ ...prev, [key]: val })); const currentStep = steps[stepIdx]; const isValid = (() => { if (!currentStep) return true; const v = answers[currentStep.id === 'size' ? 'size' : currentStep.id]; if (currentStep.id === 'volume') return answers.volume > 0; return v != null; })(); const goNext = () => { if (!isValid) return; if (stepIdx === steps.length - 1) { setDone(true); } else { setStepIdx(stepIdx + 1); setTransitionKey(k => k + 1); } }; const goBack = () => { if (done) { setDone(false); setTransitionKey(k => k + 1); return; } if (stepIdx > 0) { setStepIdx(stepIdx - 1); setTransitionKey(k => k + 1); } }; const restart = () => { setDone(false); setStepIdx(0); setTransitionKey(k => k + 1); }; // Auto-advance after a brief delay when a choice is made on simple radio steps. const autoAdvanceRef = useRef(null); const handleAnswer = (key, val, autoAdvance = true) => { setAnswer(key, val); if (autoAdvance) { if (autoAdvanceRef.current) clearTimeout(autoAdvanceRef.current); autoAdvanceRef.current = setTimeout(() => { setStepIdx(prev => { const flow = buildFlow({ ...answers, [key]: val }); if (prev === flow.length - 1) { setDone(true); return prev; } setTransitionKey(k => k + 1); return prev + 1; }); }, 380); } }; useEffect(() => () => { if (autoAdvanceRef.current) clearTimeout(autoAdvanceRef.current); }, []); // Render current question let questionEl = null; if (!done && currentStep) { switch (currentStep.id) { case 'start': questionEl = handleAnswer('start', v)} />; break; case 'op': questionEl = { setAnswers(p => ({ ...p, op: v, size: null })); setTimeout(() => { setStepIdx(s => s + 1); setTransitionKey(k => k + 1); }, 380); }} />; break; case 'size': questionEl = answers.op === 'mobile' ? handleAnswer('size', v)} /> : handleAnswer('size', v)} />; break; case 'volume': questionEl = setAnswer('volume', v)} />; break; case 'goal': questionEl = handleAnswer('goal', v)} />; break; case 'square': questionEl = handleAnswer('square', v)} />; break; } } const progressPct = done ? 100 : Math.round(((stepIdx) / steps.length) * 100); return (
{tweaks.showLogoPanel && ( )}
{done ? `Complete · 6 of 6` : `Step ${stepIdx + 1} of ${steps.length}`}
{done ? ( ) : questionEl}
{!done && (
{stepIdx > 0 ? ( Back ) : ( Press Enter to continue )}
{currentStep && currentStep.id === 'volume' && ( Drag the slider or pick a preset )} {stepIdx === steps.length - 1 ? 'Review setup' : 'Continue'}
)}
); }; // Keyboard: Enter advances window.addEventListener('keydown', (e) => { if (e.key === 'Enter') { const btn = document.querySelector('[data-cc-primary]'); if (btn && !btn.disabled) btn.click(); } }); ReactDOM.createRoot(document.getElementById('root')).render();