/* global React, ReactDOM, window */
const { useState: uSA, useEffect: uEA, useReducer } = React;

const STORAGE_KEY = 'gg.pricing-cart.v1';

// Generate a UUID v4 for quote identification (spec §3.2.4, quote_uuid is
// the key that ties calculator state, Attio leads, Stripe customers, and
// post-checkout onboarding together). Prefers crypto.randomUUID() (all
// evergreen browsers since 2021); falls back to a Date+random hybrid in
// older contexts so we never end up with a null uuid.
function _applyReferFlow(flow, state) {
  // 2026-07-01 (Option A): referring mode now keeps the full service flow so the
  // freelancer picks the services they would refer and sees the commission, rather
  // than a collapsed estimator step. No collapse.
  return flow;
}
function generateQuoteUuid() {
  try {
    if (window.crypto && typeof window.crypto.randomUUID === 'function') {
      return window.crypto.randomUUID();
    }
  } catch (e) { /* fall through */ }
  // Prefix-tagged so fallback uuids are identifiable in logs/analytics.
  return 'q-' + Date.now().toString(36) + '-'
       + Math.random().toString(36).slice(2, 10) + '-'
       + Math.random().toString(36).slice(2, 10);
}

// Public read API for external scripts (analytics, Stripe metadata, AE
// link generation, etc.) that need to tag events with the current quote.
// Reads from localStorage so it works outside the React tree, including
// before React mounts. Returns null only if storage is unreadable.
window.getQuoteUuid = function () {
  try {
    const raw = window.localStorage.getItem(STORAGE_KEY);
    if (!raw) return null;
    return JSON.parse(raw).quote_uuid || null;
  } catch (e) { return null; }
};

// Spec §3.1: localStorage quote-in-progress lifetime is up to 14 days, after
// which the user sees a "refresh prices" prompt. Beyond this window, the
// agency-saved retail prices may no longer match current GoGorilla wholesale
// rates, so we surface a non-blocking notice. The prompt is dismissable.
const STALE_THRESHOLD_MS = 14 * 24 * 60 * 60 * 1000; // 14 days

// Spec §3.2: URL parameters override localStorage. Parsed once per page load.
// `prefill` (base64-encoded JSON state) and `ref=naz_call_*` (AE-generated
// sign-up link) BOTH trigger an override that ignores localStorage entirely.
// Other ref types (referral loops, win-back) just stash for downstream
// attribution (Attio/W15, win-back/W7) without overriding state.
//
// URL param contract:
//   ?prefill=<base64(JSON.stringify(state))> , full or partial state to load
//   ?ref=naz_call_<lead_id>                  , AE sign-up link (override)
//   ?ref=client_<hash>|agency_<hash>|investor_<id> , referral attribution
//   ?winback=<client_id>                     , win-back loop
//   ?client=<hint>                           , client-type pre-hint
function getUrlState() {
  try {
    const params = new URLSearchParams(window.location.search || '');
    const ref = params.get('ref');
    const winback = params.get('winback');
    const client = params.get('client');
    const prefillRaw = params.get('prefill');
    const booked = params.get('booked') === '1';
    const path = params.get('path');
    let _pathFreelancer = false;
    try { _pathFreelancer = /^\/freelancer\/?$/i.test(window.location.pathname || ''); } catch (e) {}
    let prefilledState = null;
    if (prefillRaw) {
      try {
        // Expected encoding: btoa(JSON.stringify(state)). We try a URI-decoded
        // fallback for unicode-safe encodings the AE tool may use later.
        let decoded;
        try { decoded = atob(prefillRaw); }
        catch (_) { decoded = atob(decodeURIComponent(prefillRaw)); }
        let parsed;
        try { parsed = JSON.parse(decoded); }
        catch (_) { parsed = JSON.parse(decodeURIComponent(decoded)); }
        if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
          prefilledState = parsed;
        }
      } catch (e) {
        // Malformed prefill, silently fall through to localStorage path.
      }
    }
    const isAeLink = !!(ref && /^naz_call_/i.test(ref));
    return {
      prefilledState,
      ref: ref || null,
      winback: winback || null,
      client: client || null,
      isAeLink,
      booked,
      freelancer: (path === 'freelancer' || _pathFreelancer),
      hasOverride: !!(prefilledState || isAeLink),
    };
  } catch (e) {
    return { prefilledState: null, ref: null, winback: null, client: null, isAeLink: false, booked: false, freelancer: false, hasOverride: false };
  }
}
// Expose so external scripts (analytics, AE-link debug, etc.) can inspect.
window.getUrlState = getUrlState;
// Freelancer engine: true when the visitor arrived via the Typeform redirect
// or deep-link (?path=freelancer). Drives freelancer-specific framing on top of
// the reused agency engine. Session-scoped: the URL param or an in-page flag.
window.isFreelancerMode = function isFreelancerMode() {
  try {
    if (new URLSearchParams(window.location.search || '').get('path') === 'freelancer') return true;
    if (/^\/freelancer\/?$/i.test(window.location.pathname || '')) return true;
    if (window.__freelancerMode === true) return true;
    if (window.localStorage.getItem('gg.flMode') === '1') return true;
  } catch (e) {}
  return window.__freelancerMode === true;
};

const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
  "showMobileBar": true,
  "addonsDefault": "when-added",
  "showHomePreview": true
}/*EDITMODE-END*/;

// Build flow is now DYNAMIC by client type, see data.jsx → stepsForClient().
// Common shape:
//   step 0          = Client type
//   final step      = Checkout (YoureSetPage)
// Agencies: a dedicated White-Label step sits at index 1 (route-setting).
// Founders / investors: a Fundraising step sits second-to-last.
const initialBase = {
  step: 0,
  clientTypeId: null,
  intentId: null,
  referMode: false,
  selections: {},
  // Per-service commitment chosen via the tab group BEFORE the user has picked a
  // tier for that service. Stored separately so changing the commit toggle on a
  // not-yet-active service doesn't auto-select a tier. Flushed into the
  // selection on the first SET_TIER / SET_SERVICE-on for that service.
  pendingCommits: {},
  // Active promo / voucher code, shared across every Summary render. Lifted from
  // local useState so it survives the BuildPage → YoureSetPage step transition
  // (otherwise the Checkout page would always render an empty Summary mount and
  // lose the discount the user already applied). Shape: { code, pct, label } | null.
  promoApplied: null,
  // Per-quote UUID, generated on first load, persisted across sessions, and
  // used as the join key between calculator state, Attio leads, Stripe
  // customers, and the post-checkout onboarding form. Null here as a safety
  // default; loadState() always populates it (restore-or-generate) before
  // the App reducer consumes initial state.
  quote_uuid: null,
  // Referral (portal ?ref): { status, code, email, pct } once resolved, else null.
  referral: null,
  // Spec §3.1: true when restored localStorage state is older than the stale
  // threshold (14 days). When true, the calculator surfaces a dismissable
  // "your saved quote may have outdated prices" prompt above the breadcrumb.
  // Reset to false when the user dismisses or makes any new state change
  // (the next persistence write refreshes saved_at to now).
  isStale: false,
  // Spec §3.2 / W3, URL-derived attribution fields. Set on page load from
  // ?ref=naz_call_<id> (AE sign-up link) or ?ref=client_*/agency_*/investor_*
  // (referral loop). Persisted to localStorage so they outlive a page refresh
  // even if the user no longer has the URL param. Used by W15 (Attio link
  // generation) and downstream Stripe metadata for AE commission attribution.
  ae_ref: null,
  // Spec §3.2, set when user arrives via ?winback=<client_id>. Surfaces in
  // analytics/Attio so we know they came from a re-engagement campaign.
  winback_ref: null,
  // Sprint 1, Q0 capture (white-label agencies only): client name + industry.
  // Industry pre-fills Q1 ICP via INDUSTRY_TO_Q1_ICP on intent change.
  q0: { clientName: '', industry: '' },
  // Sprint 1, qualifier answers Q1, Q4 + conditional Q1.5. Drives warmth score
  // + cohort tag (A/B/C/D) computed via computeWarmth/computeCohort. Cohort D
  // triggers the bypass screen (book-a-call) instead of the full configurator.
  qualifier: { q1: null, q1a: null, q1aDtcAov: null, q1aDtcMargin: null, q1aDtcRepeat: null, saasCycle: null, bservicesCat: null, bservicesCycle: null, bcservicesCat: null, q1b: null, q1c: null, q2: null, q2b: null, q3: null, tradingLength: null, fundBacked: null, fundRaised: null, fundInvestorCount: null, q4: null, q5: null, q5b: null, urgency: null, priorActivities: [], priorOutboundChannels: [], priorSalesCycle: null, priorPaidPlatforms: [], priorPaidBudget: null, priorPaidRoas: null, emailSubscriberCount: null, emailCampaignsPerMonth: null, priorEmailPlatform: null, priorSocialFrequency: null, priorSocialPlatforms: [], hiringTime: null, fundraisingStage: null, fundraisingRaiseSize: null, fundraisingInvestorTypes: [], fundraisingTargetValuation: null, fundraisingFlags: [] },
  // Spec §17 (founders-step-1): once the qualifier first becomes ready
  // (isReadyToAdvance === true), the reducer auto-pre-selects the
  // recommended services from getGapRecommendations(qualifier) into
  // state.selections. This flag prevents the auto-apply from re-firing
  // on every subsequent state change. Cleared when q1 changes (user
  // picks a different business model → new gaps are relevant).
  // §5.2, How the founder wants to receive their proposal. Default 'schedule'
  // (recommended). Set via SET_CALL_PREFERENCE on the call-pref radio cards
  // on Step 6.
  callPreference: 'schedule',
  // 2026-05-29: ServicesInquiryModal on Step 6 (founders) / Step 4 (agency).
  // Soft-prompt for users who reach the final step with empty selections.
  // categories: array of service-need chips picked. notes: optional free
  // text. dismissed: true means user clicked Skip — modal stays closed
  // unless they re-open it via the chip below the title.
  servicesInquiry: { categories: [], notes: '', dismissed: false },
};

// ── Hydrate from URL params first (spec §3.2.1), then localStorage ──
function loadState() {
  const urlState = getUrlState();

  // 0. Loom 28 (2026-06-05): post-call entry via the Cal.com redirect (?booked=1)
  //    now CARRIES OVER the visitor's in-progress quote instead of wiping it. Someone
  //    who built a quote, went away to book a call, and came back should land with
  //    their selections intact, no re-entry. The post-call greeting and qualifier-first
  //    UI are still driven by isPostCallMode() reading ?booked=1 from the URL, and the
  //    name/email come from URL params, so none of that depends on clearing localStorage.
  //    (This reverses the earlier post-call reset; we simply fall through to the
  //    localStorage restore path below.)

  // 1. Spec §3.2.1, `prefill` overrides localStorage entirely. Build initial
  //    state directly from the decoded prefill payload. This handles the AE
  //    sign-up link case (?ref=naz_call_<lead>&prefill=<base64>) where the AE
  //    wants their recommendation surfaced exactly as configured.
  if (urlState.prefilledState) {
    const p = urlState.prefilledState;
    return {
      step: (typeof p.step === 'number' && p.step >= 0) ? p.step : 0,
      clientTypeId: p.clientTypeId || null,
      intentId: p.intentId || null,
      selections: (p.selections && typeof p.selections === 'object' && !Array.isArray(p.selections)) ? p.selections : {},
      pendingCommits: (p.pendingCommits && typeof p.pendingCommits === 'object' && !Array.isArray(p.pendingCommits)) ? p.pendingCommits : {},
      promoApplied: null, // promo doesn't carry through URL prefill
      quote_uuid: (typeof p.quote_uuid === 'string' && p.quote_uuid.length >= 8) ? p.quote_uuid : generateQuoteUuid(),
      isStale: false, // URL prefill is "fresh" by definition, AE just sent it
      ae_ref: urlState.ref || null,
      winback_ref: urlState.winback || null,
      q0: (p.q0 && typeof p.q0 === 'object') ? { clientName: p.q0.clientName || '', industry: p.q0.industry || '' } : { clientName: '', industry: '' },
      qualifier: (p.qualifier && typeof p.qualifier === 'object') ? { q1: null, q1a: null, q1aDtcAov: null, q1aDtcMargin: null, q1aDtcRepeat: null, saasCycle: null, bservicesCat: null, bservicesCycle: null, bcservicesCat: null, q1b: null, q1c: null, q2: null, q2b: null, q3: null, tradingLength: null, fundBacked: null, fundRaised: null, fundInvestorCount: null, q4: null, q5: null, q5b: null, urgency: null, priorActivities: [], ...p.qualifier } : { q1: null, q1a: null, q1aDtcAov: null, q1aDtcMargin: null, q1aDtcRepeat: null, saasCycle: null, bservicesCat: null, bservicesCycle: null, bcservicesCat: null, q1b: null, q1c: null, q2: null, q2b: null, q3: null, tradingLength: null, fundBacked: null, fundRaised: null, fundInvestorCount: null, q4: null, q5: null, q5b: null, urgency: null, priorActivities: [], priorOutboundChannels: [], priorSalesCycle: null, priorPaidPlatforms: [], priorPaidBudget: null, priorPaidRoas: null, emailSubscriberCount: null, emailCampaignsPerMonth: null, priorEmailPlatform: null, priorSocialFrequency: null, priorSocialPlatforms: [], hiringTime: null, fundraisingStage: null, fundraisingRaiseSize: null, fundraisingInvestorTypes: [], fundraisingTargetValuation: null, fundraisingFlags: [] },
      callPreference: p.callPreference || 'schedule',
      servicesInquiry: (p.servicesInquiry && typeof p.servicesInquiry === 'object')
        ? {
            categories: Array.isArray(p.servicesInquiry.categories) ? p.servicesInquiry.categories : [],
            notes:      typeof p.servicesInquiry.notes === 'string'      ? p.servicesInquiry.notes      : '',
            dismissed:  !!p.servicesInquiry.dismissed,
          }
        : { categories: [], notes: '', dismissed: false },
    };
  }

  // 2. Spec §3.2.1, `ref=naz_call_*` alone (no prefill) also overrides
  //    localStorage. Start fresh with AE attribution stamped in state.
  if (urlState.isAeLink) {
    return {
      ...initialBase,
      quote_uuid: generateQuoteUuid(),
      ae_ref: urlState.ref,
      winback_ref: urlState.winback || null,
    };
  }

  // 2b. Freelancer engine entry (Typeform redirect / deep-link ?path=freelancer).
  //     Reuses the agency engine: lands directly on the three doors (agency
  //     intents) with freelancer framing applied on top (see isFreelancerMode).
  //     Additive and hidden: no new client-type card, existing flows untouched.
  if (urlState.freelancer) {
    try { window.__freelancerMode = true; window.localStorage.setItem('gg.flMode', '1'); } catch (e) {}
    // 2026-07-01 (Batch 2): a genuine fresh arrival (no in-progress quote) lands
    // on the three doors. But if the visitor already entered a door and is mid
    // flow, fall through to the localStorage restore below so a browser refresh
    // keeps them where they were, including referring mode. Start fresh clears
    // storage, so it still returns them to the doors.
    let _hasSavedFl = false;
    try {
      const _rawFl = window.localStorage.getItem(STORAGE_KEY);
      _hasSavedFl = !!(_rawFl && JSON.parse(_rawFl) && JSON.parse(_rawFl).intentId);
    } catch (e) {}
    if (!_hasSavedFl) {
      return {
        ...initialBase,
        clientTypeId: 'agency',
        intentId: null,
        step: 0,
        quote_uuid: generateQuoteUuid(),
        ae_ref: urlState.ref || null,
        winback_ref: urlState.winback || null,
      };
    }
    // else: fall through to the localStorage restore path (section 3 below).
  }

  // 3. No URL override, fall back to existing localStorage flow.
  try {
    const raw = window.localStorage.getItem(STORAGE_KEY);
    if (!raw) {
      // First visit (or storage cleared), bootstrap a fresh quote_uuid so the
      // very first persistence cycle writes it to localStorage. Stash any
      // ref / winback URL params for attribution.
      return {
        ...initialBase,
        quote_uuid: generateQuoteUuid(),
        ae_ref: urlState.ref || null,
        winback_ref: urlState.winback || null,
      };
    }
    const saved = JSON.parse(raw);
    // Always restart at step 0 (Client type) on reload, saved selections persist,
    // but the user re-enters the flow from the beginning. This avoids landing on
    // a service page that no longer makes sense without re-confirming client type.
    let step = 0;
    // We always reset to step 0 on load (see comment above) but clamp anyway
    // against the dynamic step list for whatever client type was saved.
    const _rawFlow = window.stepsForClient ? window.stepsForClient(saved.clientTypeId, saved.intentId) : window.BUILD_STEPS;
    // 2026-07-01 (Batch 2): if the saved quote was in referring mode, clamp the
    // restored step against the collapsed refer flow (client, refer, checkout)
    // so the refresh lands back on the referring view.
    const flow = saved.referMode ? _applyReferFlow(_rawFlow, { referMode: true, clientTypeId: saved.clientTypeId }) : _rawFlow;
    const maxStep = Math.max(0, (flow?.length || 6) - 1);
    if (typeof saved.step === 'number' && saved.step >= 0 && saved.step <= maxStep) {
      step = saved.step;
    }
    // If they had no client type yet, force back to 0
    if (!saved.clientTypeId) step = 0;
    // Restore the promo only if it still validates against PROMO_CODES so a
    // renamed/expired code isn't silently re-applied across sessions.
    let promoApplied = null;
    if (saved.promoApplied && typeof saved.promoApplied === 'object' && typeof saved.promoApplied.code === 'string') {
      const codes = window.PROMO_CODES || {};
      const match = codes[saved.promoApplied.code];
      if (match && !(match.expires && Date.now() > new Date(match.expires).getTime())) promoApplied = { code: saved.promoApplied.code, ...match };
    }
    // Restore quote_uuid if present, else generate one. Older localStorage
    // payloads (pre-W4) won't have this field, so generating preserves
    // backward compatibility for returning users.
    const quote_uuid = (typeof saved.quote_uuid === 'string' && saved.quote_uuid.length >= 8)
      ? saved.quote_uuid
      : generateQuoteUuid();
    // Compute staleness from saved.saved_at (W5). Pre-W5 payloads have no
    // timestamp, so isStale defaults to false, they only see the prompt
    // after their next save (which writes saved_at = Date.now()) has aged
    // past the threshold. Acceptable trade-off for backward compatibility.
    const isStale = (typeof saved.saved_at === 'number'
                     && Number.isFinite(saved.saved_at)
                     && (Date.now() - saved.saved_at > STALE_THRESHOLD_MS));
    // Restore attribution fields. URL params still take priority (ref/winback
    // from the current visit override stored values), since the most recent
    // attribution is the most accurate. Spec §3.2, URL trumps storage.
    const ae_ref = urlState.ref || saved.ae_ref || null;
    const winback_ref = urlState.winback || saved.winback_ref || null;
    // 2026-07-02 (R-07): rehydrate the referral attribution bag so a refresh keeps
    // window.__ggReferral (code + partner attribution) that buildPlatformQuotePayload
    // forwards, else the referring partner silently loses commission credit.
    try { if (saved.ggReferral && typeof saved.ggReferral === 'object' && typeof window !== 'undefined') { window.__ggReferral = saved.ggReferral; } } catch (e) {}
    return {
      step,
      clientTypeId: saved.clientTypeId || null,
      intentId: saved.intentId || null,
      selections: saved.selections && typeof saved.selections === 'object' ? saved.selections : {},
      pendingCommits: saved.pendingCommits && typeof saved.pendingCommits === 'object' ? saved.pendingCommits : {},
      promoApplied,
      quote_uuid,
      referMode: !!saved.referMode,
      referral: (saved.referral && typeof saved.referral === 'object') ? saved.referral : null,
      isStale,
      ae_ref,
      winback_ref,
      q0: (saved.q0 && typeof saved.q0 === 'object') ? { clientName: saved.q0.clientName || '', industry: saved.q0.industry || '' } : { clientName: '', industry: '' },
      qualifier: (saved.qualifier && typeof saved.qualifier === 'object') ? { q1: null, q1a: null, q1aDtcAov: null, q1aDtcMargin: null, q1aDtcRepeat: null, saasCycle: null, bservicesCat: null, bservicesCycle: null, bcservicesCat: null, q1b: null, q1c: null, q2: null, q2b: null, q3: null, tradingLength: null, fundBacked: null, fundRaised: null, fundInvestorCount: null, q4: null, q5: null, q5b: null, urgency: null, priorActivities: [], ...saved.qualifier } : { q1: null, q1a: null, q1aDtcAov: null, q1aDtcMargin: null, q1aDtcRepeat: null, saasCycle: null, bservicesCat: null, bservicesCycle: null, bcservicesCat: null, q1b: null, q1c: null, q2: null, q2b: null, q3: null, tradingLength: null, fundBacked: null, fundRaised: null, fundInvestorCount: null, q4: null, q5: null, q5b: null, urgency: null, priorActivities: [], priorOutboundChannels: [], priorSalesCycle: null, priorPaidPlatforms: [], priorPaidBudget: null, priorPaidRoas: null, emailSubscriberCount: null, emailCampaignsPerMonth: null, priorEmailPlatform: null, priorSocialFrequency: null, priorSocialPlatforms: [], hiringTime: null, fundraisingStage: null, fundraisingRaiseSize: null, fundraisingInvestorTypes: [], fundraisingTargetValuation: null, fundraisingFlags: [] },
      callPreference: saved.callPreference || 'schedule',
      servicesInquiry: (saved.servicesInquiry && typeof saved.servicesInquiry === 'object')
        ? {
            categories: Array.isArray(saved.servicesInquiry.categories) ? saved.servicesInquiry.categories : [],
            notes:      typeof saved.servicesInquiry.notes === 'string'      ? saved.servicesInquiry.notes      : '',
            dismissed:  !!saved.servicesInquiry.dismissed,
          }
        : { categories: [], notes: '', dismissed: false },
    };
  } catch (e) {
    // On parse failure, still bootstrap a uuid so downstream code never sees null.
    return {
      ...initialBase,
      quote_uuid: generateQuoteUuid(),
      ae_ref: urlState.ref || null,
      winback_ref: urlState.winback || null,
    };
  }
}


// ── §17 auto-pre-select gap recommendations ───────────────────────
// Returns the next selections object with any recommended services that
// aren't already present added at their default tier. Pure function, no
// side effects. Only the founders flow consumes this (gated upstream).
function applyGapRecommendations(prevSelections, qualifier, intentId) {
  const gaps = window.getGapRecommendations ? window.getGapRecommendations(qualifier) : [];
  if (!Array.isArray(gaps) || gaps.length === 0) return prevSelections;
  const SERVICES = window.SERVICES || [];
  const tiersFor = window.tiersFor;
  const defaultTierFor = window.defaultTierForService;
  const canon = window._canonicalServiceId || ((x) => x);
  const next = { ...prevSelections };
  for (const rawId of gaps) {
    const svcId = canon(rawId);
    if (next[svcId]) continue; // user already has it; don't overwrite
    const svc = SERVICES.find(x => x.id === svcId);
    if (!svc) continue;
    const svcTiers = tiersFor ? tiersFor(svc) : window.TIERS;
    let tier = defaultTierFor ? defaultTierFor(svcId, qualifier, intentId) : null;
    if (tier && !svcTiers?.some(t => t.id === tier)) tier = null;
    if (!tier) {
      const popularTier = svcTiers?.find(t => t.badge === 'popular');
      tier = popularTier?.id || svcTiers?.[1]?.id || svcTiers?.[0]?.id || 'grow';
    }
    next[svcId] = { tier, addons: [] };
  }
  return next;
}

function reducer(s, a) {
  switch (a.type) {
    case 'SET_STEP': return { ...s, step: a.step };
    case 'SET_REFER_MODE': {
      // Loom 69 referring toggle. Turning it on clears the current plan
      // selections (the per-quote crossroads, white-label vs referral) and
      // enters referring mode; turning it off just exits, leaving the cleared
      // cart so the user re-picks a plan.
      if (a.value) {
        // 2026-07-01 (Option A): switching to referring KEEPS the current
        // selections and step. The whole quote flips from white-label to referral
        // (one or the other, per Alexander), so they see the exact commission on
        // the services they already picked. intentId stays agency-whitelabel so
        // the wholesale pricing basis is unchanged. Attribution is auto-stamped
        // for freelancers (they arrive via our Typeform outreach).
        return { ...s, referMode: true, intentId: s.intentId,
          qualifier: { ...(s.qualifier || {}), ...((typeof window !== 'undefined' && window.isFreelancerMode && window.isFreelancerMode()) ? { heardVia: ['outreach'] } : {}) } };
      }
      // Toggling off keeps the selections too, so they flip straight back to the
      // white-label view of the same quote.
      return { ...s, referMode: false };
    }
    case 'SET_REFERRAL': return { ...s, referral: a.referral };
    case 'SET_CLIENT': {
      // Reset intent when client type changes (or unchanged → keep).
      // Also clamp `step` against the new client's flow length so we don't
      // land on a step index that no longer exists (e.g. agency flow has no
      // 'fundraising' step, founder flow has no dedicated 'whitelabel' step).
      const sameClient = s.clientTypeId === a.id;
      const flow = window.stepsForClient ? window.stepsForClient(a.id, sameClient ? s.intentId : null) : window.BUILD_STEPS;
      const maxStep = Math.max(0, (flow?.length || 6) - 1);
      const nextStep = Math.min(s.step, maxStep);
      // Prune selections to only services that exist in the new flow. Without
      // this, services added under one persona (e.g. Sales & Demand Gen from
      // Founder flow) stay in state.selections after switching to Investor,
      // inflating the multi-service discount count and confusing the user.
      let nextSelections = s.selections;
      if (!sameClient && flow && Array.isArray(flow)) {
        const allowedIds = new Set();
        for (const step of flow) {
          if (Array.isArray(step.serviceIds)) {
            for (const id of step.serviceIds) allowedIds.add(id);
          }
        }
        const pruned = {};
        for (const [id, sel] of Object.entries(s.selections || {})) {
          if (allowedIds.has(id)) pruned[id] = sel;
        }
        if (Object.keys(pruned).length !== Object.keys(s.selections || {}).length) {
          nextSelections = pruned;
        }
      }
      // #55: When client type changes, qualifier answers are persona-specific so reset them.
      // CLEAR_FOR_CLIENT_SWITCH handles this when there are existing selections + confirmation;
      // this covers the no-selection path so stale answers never bleed into a new persona's flow.
      const _nextQualifier = sameClient ? s.qualifier : { q1: null, q1a: null, q1aDtcAov: null, q1aDtcMargin: null, q1aDtcRepeat: null, saasCycle: null, bservicesCat: null, bservicesCycle: null, bcservicesCat: null, q1b: null, q1c: null, q2: null, q2b: null, q3: null, tradingLength: null, fundBacked: null, fundRaised: null, fundInvestorCount: null, q4: null, q5: null, q5b: null, urgency: null, priorActivities: [], priorOutboundChannels: [], priorSalesCycle: null, priorPaidPlatforms: [], priorPaidBudget: null, priorPaidRoas: null, emailSubscriberCount: null, emailCampaignsPerMonth: null, priorEmailPlatform: null, priorSocialFrequency: null, priorSocialPlatforms: [], hiringTime: null, fundraisingStage: null, fundraisingRaiseSize: null, fundraisingInvestorTypes: [], fundraisingTargetValuation: null, fundraisingFlags: [] };
      return { ...s, clientTypeId: a.id, intentId: sameClient ? s.intentId : null, step: nextStep, selections: nextSelections, qualifier: _nextQualifier, referMode: s.referMode };
    }
    case 'SET_INTENT': {
      // Sprint 1, when switching to agency-whitelabel and industry is already
      // captured, pre-fill Q1 ICP via INDUSTRY_TO_Q1_ICP.
      let qualifier = s.qualifier || { q1: null, q1a: null, q1aDtcAov: null, q1aDtcMargin: null, q1aDtcRepeat: null, saasCycle: null, bservicesCat: null, bservicesCycle: null, bcservicesCat: null, q1b: null, q1c: null, q2: null, q2b: null, q3: null, tradingLength: null, fundBacked: null, fundRaised: null, fundInvestorCount: null, q4: null, q5: null, q5b: null, urgency: null, priorActivities: [], priorOutboundChannels: [], priorSalesCycle: null, priorPaidPlatforms: [], priorPaidBudget: null, priorPaidRoas: null, emailSubscriberCount: null, emailCampaignsPerMonth: null, priorEmailPlatform: null, priorSocialFrequency: null, priorSocialPlatforms: [], hiringTime: null, fundraisingStage: null, fundraisingRaiseSize: null, fundraisingInvestorTypes: [], fundraisingTargetValuation: null, fundraisingFlags: [] };
      // Batch 6 (Loom): switching the agency intent changes the qualifier scenario
      // (grow <-> resell). Clear the agency (aq_*) answers so the new scenario
      // starts at its first question instead of stacking on the previous answers.
      if (s.clientTypeId === 'agency' && a.id !== s.intentId) {
        const _kept = {};
        for (const _k in qualifier) { if (_k.indexOf('aq_') !== 0) _kept[_k] = qualifier[_k]; }
        qualifier = _kept;
      }
      const isWhitelabel = s.clientTypeId === 'agency' && a.id === 'agency-whitelabel';
      const industry = s.q0?.industry;
      if (isWhitelabel && industry && window.INDUSTRY_TO_Q1_ICP) {
        const mapped = window.INDUSTRY_TO_Q1_ICP[industry];
        if (mapped && !qualifier.q1) qualifier = { ...qualifier, q1: mapped };
      }
      // Whitelabel intent removes the talent step, clamp current step
      // against the new (potentially shorter) flow length.
      const _flowAfter = window.stepsForClient ? window.stepsForClient(s.clientTypeId, a.id) : window.BUILD_STEPS;
      const _maxStepAfter = Math.max(0, (_flowAfter?.length || 6) - 1);
      const _stepAfter = Math.min(s.step, _maxStepAfter);
      // QA #3: switching intent within a path starts fresh. Pruning by service id
      // is not enough (e.g. investor-portal is valid under both investor intents,
      // so a Partner Pro+ pick would carry into 'grow my portfolio companies'), so
      // clear the service selections and pending commits when the intent changes.
      const _intentChanged = a.id !== s.intentId;
      if (_intentChanged) { try { Object.keys(window.localStorage).filter(k => k.indexOf('gg.margin') === 0).forEach(k => window.localStorage.removeItem(k)); } catch (e) {} }
      return { ...s, intentId: a.id, step: _stepAfter, qualifier, referMode: s.referMode,
        selections: _intentChanged ? {} : s.selections,
        pendingCommits: _intentChanged ? {} : (s.pendingCommits || {}) };
    }
    case 'SET_SERVICE': {
      const next = { ...s.selections };
      const pending = { ...(s.pendingCommits || {}) };
      if (a.on) {
        if (!next[a.id]) {
          // Resolve default tier, services with custom tier overrides (e.g. Dedicated
          // Resources: parttime/fulltime) need a tier id that actually exists for them.
          const svc = window.SERVICES?.find?.(x => x.id === a.id);
          const svcTiers = window.tiersFor ? window.tiersFor(svc) : window.TIERS;
          // Sprint 2, qualifier-driven default. Falls back to popular/grow if
          // the qualifier is incomplete or returns a tier the service doesn't offer.
          let qualifierTier = window.defaultTierForService
            ? window.defaultTierForService(a.id, s.qualifier, s.intentId)
            : null;
          if (qualifierTier && !svcTiers?.some(t => t.id === qualifierTier)) qualifierTier = null;
          const popularTier = svcTiers?.find(t => t.badge === 'popular');
          const defaultTier = qualifierTier || popularTier?.id || svcTiers?.[1]?.id || svcTiers?.[0]?.id || 'grow';
          // Seed with any pending commit choice; otherwise default selection.
          // Channels intentionally NOT pre-selected. User must pick channels
          // themselves on the service card. (Removed Sprint 2 auto-rank pre-select.)
          const seed = { tier: defaultTier, addons: [] };
          // 2026-06-05 (Loom 25): pre-select all SDG channels for the tier except
          // LinkedIn, which stays the opt-in add-on. (WhatsApp is included from Grow.)
          if (a.id === 'sales') {
            const _seedCh = (window.channelsForTier ? window.channelsForTier('sales', defaultTier) : []).filter(c => c !== 'linkedin' && c !== 'instagram' && c !== 'more');
            if (_seedCh.length) seed.channels = _seedCh;
          }
          if (pending[a.id]) seed.commitId = pending[a.id];
          // Talent FT (dedicated-ft), auto-seed default role mix based on
          // Step 1 q1/q1a (founders-talent-solutions-conditional-tree §5)
          // and pre-check the Buyout add-on if any seeded role triggers the
          // Series A+ SDR/CSM rule (§8). Only applies on first selection;
          // subsequent toggle-off + toggle-on hits this same branch (since
          // !next[a.id]), so the defaults re-apply, that's intentional UX.
          if (a.id === 'dedicated-ft' && defaultTier === 'fulltime') {
            const ftDefaults = window.getDrFtDefaultRoles ? window.getDrFtDefaultRoles(s) : [];
            const tierRoleIds = new Set((svc?.roles?.fulltime || []).map(r => r.id));
            const seededRoles = ftDefaults.filter(rid => tierRoleIds.has(rid));
            if (seededRoles.length > 0) {
              seed.roles = seededRoles;
              const shouldBuyout = seededRoles.some(rid =>
                window.shouldDefaultDrFtBuyout ? window.shouldDefaultDrFtBuyout(s, rid) : false
              );
              if (shouldBuyout) seed.addons = ['buyout'];
            }
            // §3.2, recommend a shorter commit for early-stage / urgent founders.
            // Honours user's prior pending choice (e.g. via the commit toggle)
            // if it exists; otherwise applies the qualifier-driven default.
            if (!pending[a.id] && window.getDrFtRecommendedCommit) {
              seed.commitId = window.getDrFtRecommendedCommit(s);
            }
          }
          // Talent PT (dedicated-pt), auto-seed default role mix based on
          // Step 1 q1 / q1a / q3 (talent-spec §4.6). Same gate as FT: only
          // fires on first activation; the click that activated the service
          // is handled separately in DedicatedFlow's handleToggleRole.
          if (a.id === 'dedicated-pt' && defaultTier === 'parttime') {
            const ptDefaults = window.getDrPtDefaultRoles ? window.getDrPtDefaultRoles(s) : [];
            const tierRoleIds = new Set((svc?.roles?.parttime || []).map(r => r.id));
            const seededRoles = ptDefaults.filter(rid => tierRoleIds.has(rid));
            if (seededRoles.length > 0) {
              seed.roles = seededRoles;
              // Seed each role with a default 5-day package config so the
              // PT card shows actual numbers (not "Custom") on first render.
              const cfgs = {};
              seededRoles.forEach(rid => { cfgs[rid] = { days: 5 }; });
              seed.roleConfigs = cfgs;
            }
          }
          next[a.id] = seed;
        } else if (!next[a.id].tier) {
          // 2026-06-11: a standalone add-on created a tier-less shell.
          // Activating the service now upgrades the shell in place, keeping
          // the carted add-ons and quantities.
          const svc2 = window.SERVICES?.find?.(x => x.id === a.id);
          const svcTiers2 = window.tiersFor ? window.tiersFor(svc2) : window.TIERS;
          let qt2 = window.defaultTierForService
            ? window.defaultTierForService(a.id, s.qualifier, s.intentId)
            : null;
          if (qt2 && !svcTiers2?.some(t => t.id === qt2)) qt2 = null;
          const pop2 = svcTiers2?.find(t => t.badge === 'popular');
          const def2 = qt2 || pop2?.id || svcTiers2?.[1]?.id || svcTiers2?.[0]?.id || 'grow';
          next[a.id] = { ...next[a.id], tier: def2, ...(pending[a.id] ? { commitId: pending[a.id] } : {}) };
        }
        delete pending[a.id];
      } else {
        delete next[a.id];
        delete pending[a.id];
      }
      return { ...s, selections: next, pendingCommits: pending };
    }
    case 'SET_TIER': {
      const pending = { ...(s.pendingCommits || {}) };
      const seedCommit = pending[a.id];
      const cur = s.selections[a.id] || { tier: 'grow', addons: [], ...(seedCommit ? { commitId: seedCommit } : {}) };
      delete pending[a.id];
      // Prune channel selections that aren't available in the new tier's menu
      let nextChannels = Array.isArray(cur.channels) ? cur.channels : null;
      if (nextChannels && window.channelsForTier) {
        const allowed = new Set(window.channelsForTier(a.id, a.tier));
        nextChannels = nextChannels.filter(c => allowed.has(c));
      }
      // 2026-06-05 (Loom 25): SDG channels are pre-selected by default for the
      // tier except LinkedIn. Re-seed the defaults on tier change, keeping
      // LinkedIn only if the agency had opted into it.
      if (a.id === 'sales') {
        const _keepLi = Array.isArray(nextChannels) && nextChannels.includes('linkedin');
        // 2026-06-12 (Loom 44 1:26): Instagram is opt-in like LinkedIn, kept
        // only if it was already selected, never re-seeded.
        const _keepIg = Array.isArray(nextChannels) && nextChannels.includes('instagram');
        nextChannels = (window.channelsForTier ? window.channelsForTier('sales', a.tier) : []).filter(c => c !== 'more' && (c !== 'linkedin' || _keepLi) && (c !== 'instagram' || _keepIg));
      }
      // 2026-06-08 (Loom 31 04:59): the other channel-picker services pre-select
      // recommended channels too, up to the tier's allowance (e.g. a tier that
      // allows 2 pre-selects 2, a tier that allows 3 pre-selects 3). The options
      // order is the recommended priority; re-seeds on tier change so the count
      // always matches the plan.
      else if (['paid-ads', 'email', 'smm', 'content', 'motion'].includes(a.id) && window.channelsForTier) {
        const _opts = window.channelsForTier(a.id, a.tier).filter(c => c !== 'more');
        const _cfgCh = window.SERVICE_CHANNELS && window.SERVICE_CHANNELS[a.id];
        const _max = (_cfgCh && _cfgCh.max && _cfgCh.max[a.tier]) || _opts.length;
        // 2026-06-11 (Loom 38 items 2-3): tiers with an included channel count
        // seed only the included channels, never the selectable max, so Scale
        // (2 included, 6 selectable) never lands with chargeable extras
        // pre-picked and Enterprise opens on its 3-included story.
        const _seedN = (_cfgCh && _cfgCh.included && typeof _cfgCh.included[a.tier] === 'number') ? _cfgCh.included[a.tier] : _max;
        nextChannels = _opts.slice(0, _seedN);
      }
      // 2026-06-12 (Batch 23 backlog, 10 June): prune selected add-ons the new
      // tier makes included (inc, inc-custom, inc-in-full) or removes entirely,
      // so a paid pick never lingers as a stale £0 selection after a plan
      // switch. Quantities follow their add-on out. Matrix-less services pass
      // through untouched.
      let nextAddons = Array.isArray(cur.addons) ? cur.addons : null;
      let nextAddonQty = cur.addonQty;
      if (nextAddons && nextAddons.length && window.getAddonsForContext && window.ADDON_MATRIX && window.ADDON_MATRIX[a.id]) {
        const _svcDef = window.SERVICES?.find?.(x => x.id === a.id);
        if (_svcDef) {
          const _ctx = window.getAddonsForContext(_svcDef, a.tier, cur.commitId || '12', nextChannels || cur.channels);
          const _keep = new Set(_ctx.filter(x => !x.included && !x.includedInFull).map(x => x.id));
          const _pruned = nextAddons.filter(aid => _keep.has(aid));
          if (_pruned.length !== nextAddons.length) {
            const _qty = { ...(cur.addonQty || {}) };
            nextAddons.forEach(aid => { if (!_keep.has(aid)) delete _qty[aid]; });
            nextAddonQty = _qty;
            nextAddons = _pruned;
          }
        }
      }
      // Prune roles that aren't available under the new tier (services with per-tier `roles`)
      let nextRoles = Array.isArray(cur.roles) ? cur.roles : null;
      if (nextRoles) {
        const svc = window.SERVICES?.find?.(x => x.id === a.id);
        const allowedRoles = svc?.roles?.[a.tier] ? new Set(svc.roles[a.tier].map(r => r.id)) : null;
        nextRoles = allowedRoles ? nextRoles.filter(rid => allowedRoles.has(rid)) : [];
      }
      const updated = { ...cur, tier: a.tier };
      if (nextChannels) updated.channels = nextChannels;
      if (nextAddons) updated.addons = nextAddons;
      if (nextAddonQty !== cur.addonQty) updated.addonQty = nextAddonQty;
      if (nextRoles) updated.roles = nextRoles;
      // Special case: Dedicated Resources add-ons only apply to Full-Time tier.
      // Drop any selected add-ons (and their qty) when switching to Part-Time.
      if ((a.id === 'dedicated-pt' || a.id === 'dedicated-ft') && a.tier !== 'fulltime') {
        updated.addons = [];
        updated.addonQty = {};
      }
      // When tier changes on Dedicated, drop role configs (the schema differs
      // between Part-Time and Full-Time). Post-split each Dedicated service
      // is single-tier, so this is effectively dead code today, kept for
      // safety in case a future change reintroduces a tier toggle.
      if ((a.id === 'dedicated-pt' || a.id === 'dedicated-ft') && cur.tier !== a.tier) {
        updated.roleConfigs = {};
      }
      return { ...s, selections: { ...s.selections, [a.id]: updated }, pendingCommits: pending };
    }
    case 'SET_SERVICE_COMMIT': {
      // If the service has no selection yet, store the commit choice in
      // pendingCommits, do NOT auto-select a tier just because the user
      // tapped a commitment tab.
      if (!s.selections[a.id]) {
        return { ...s, pendingCommits: { ...(s.pendingCommits || {}), [a.id]: String(a.commitId) } };
      }
      const cur = s.selections[a.id];
      return { ...s, selections: { ...s.selections, [a.id]: { ...cur, commitId: String(a.commitId) } } };
    }
    case 'TOGGLE_PAY_UPFRONT': {
      // Per-service Pay Upfront toggle. When ON, subtotal for that service
      // (tier + addons) gets -10% applied at total time. Stored as a boolean
      // on the service selection. If the service has no selection yet, no-op
      // (the toggle is only meaningful once a tier is picked).
      if (!s.selections[a.id]) return s;
      const cur = s.selections[a.id];
      return { ...s, selections: { ...s.selections, [a.id]: { ...cur, payUpfront: !cur.payUpfront } } };
    }
    case 'TOGGLE_ADDON': {
      // 2026-06-11: no tier in the fallback shell, a standalone add-on can
      // be carted without silently selecting the Grow plan.
      const cur = s.selections[a.id] || { addons: [] };
      // 2026-06-06: guard like TOGGLE_CHANNEL/TOGGLE_ROLE do - selections
      // restored from prefill/share links or older carts may lack `addons`,
      // and `undefined.includes` here crashed the entire App on click.
      const _addons = Array.isArray(cur.addons) ? cur.addons : [];
      const has = _addons.includes(a.addonId);
      const next = has ? _addons.filter(x => x !== a.addonId) : [..._addons, a.addonId];
      // Initialise quantity to 1 when first selected (only if not already set)
      const qty = { ...(cur.addonQty || {}) };
      if (!has && qty[a.addonId] == null) qty[a.addonId] = 1;
      return { ...s, selections: { ...s.selections, [a.id]: { ...cur, addons: next, addonQty: qty } } };
    }
    case 'TOGGLE_ADDON_RECURRING': {
      // 2026-06-12 (Loom 40 3:35-4:16): one-off add-ons with a recurring
      // option can bill monthly at a discounted rate, keyed per add-on.
      const cur = s.selections[a.id] || { tier: 'grow', addons: [] };
      const rec = { ...(cur.addonRecurring || {}) };
      rec[a.addonId] = !rec[a.addonId];
      return { ...s, selections: { ...s.selections, [a.id]: { ...cur, addonRecurring: rec } } };
    }
    case 'SET_ADDON_QTY': {
      const cur = s.selections[a.id] || { addons: [] };
      const qty = { ...(cur.addonQty || {}) };
      let v = Math.max(1, Math.min(20000, Math.floor(Number(a.value) || 1)));
      // Loom 56: agencies have a 100-prospect floor on Custom List Building.
      if ((a.addonId === 'premium-sourcing' || a.addonId === 'premium-sourcing-pa') && typeof s.intentId === 'string' && s.intentId.indexOf('agency') === 0) v = Math.max(v, 100);
      qty[a.addonId] = v;
      return { ...s, selections: { ...s.selections, [a.id]: { ...cur, addonQty: qty } } };
    }
    case 'TOGGLE_ROLE': {
      // Toggle a role id within a service's `roles` array. Roles are scoped per tier
      // (the available role list differs between Part Time / Full Time on Dedicated Resources).
      const cur = s.selections[a.id] || { tier: 'grow', addons: [] };
      const rs = Array.isArray(cur.roles) ? cur.roles : [];
      const next = rs.includes(a.roleId) ? rs.filter(x => x !== a.roleId) : [...rs, a.roleId];
      // When un-selecting, drop any per-role configuration too.
      const cfgs = { ...(cur.roleConfigs || {}) };
      if (rs.includes(a.roleId)) {
        delete cfgs[a.roleId];
      } else {
        // Initialise with sensible defaults based on tier.
        if (cur.tier === 'fulltime') {
          // Default to the role's recommended location (from DEDICATED_ROLE_RECS)
          // rather than always defaulting to UK.
          const _recs = window.DEDICATED_ROLE_RECS || {};
          const _recEntry = _recs[a.roleId];
          const _recLocId = _recEntry
            ? ((window.DEDICATED_FT_LOCATIONS || []).find(l => l.cc === _recEntry.cc) || {}).id || 'philippines'
            : 'philippines';
          cfgs[a.roleId] = { location: _recLocId, seniority: 'mid', tasks: '' };
        } else if (cur.tier === 'parttime') {
          cfgs[a.roleId] = { days: 5 };
        } else {
          cfgs[a.roleId] = {};
        }
      }
      return { ...s, selections: { ...s.selections, [a.id]: { ...cur, roles: next, roleConfigs: cfgs } } };
    }
    case 'SET_ROLE_CONFIG': {
      // Patch a single role's config. a = { id (serviceId), roleId, patch }
      const cur = s.selections[a.id];
      if (!cur) return s;
      const cfgs = { ...(cur.roleConfigs || {}) };
      cfgs[a.roleId] = { ...(cfgs[a.roleId] || {}), ...a.patch };
      return { ...s, selections: { ...s.selections, [a.id]: { ...cur, roleConfigs: cfgs } } };
    }
    case 'TOGGLE_CHANNEL': {
      // Toggle a channel id within a service's `channels` array (selection-mode services only).
      // Enforces a max if provided, if at max and adding, drops the oldest.
      const cur = s.selections[a.id] || { tier: 'grow', addons: [] };
      const cs = Array.isArray(cur.channels) ? cur.channels : [];
      let next;
      if (cs.includes(a.channelId)) {
        next = cs.filter(x => x !== a.channelId);
      } else if (typeof a.max === 'number' && cs.length >= a.max) {
        // bump oldest selection to make room (keeps the toggle "swap" feel)
        next = [...cs.slice(1), a.channelId];
      } else {
        next = [...cs, a.channelId];
      }
      return { ...s, selections: { ...s.selections, [a.id]: { ...cur, channels: next } } };
    }
    case 'SET_AD_SPEND': {
      const cur = s.selections[a.id] || { tier: 'grow', addons: [] };
      return { ...s, selections: { ...s.selections, [a.id]: { ...cur, dailyAdSpend: a.value } } };
    }
    case 'SET_LINKEDIN_PROFILES': {
      // 2026-05-22: per-service LinkedIn profile count (Sales only). Each
      // profile adds £399/mo to the monthly retainer (computed in
      // linesForRail). Clamped to 1..10.
      const cur = s.selections[a.id] || { tier: 'grow', addons: [] };
      const n = Math.max(1, Math.min(10, Number(a.value) || 1));
      return { ...s, selections: { ...s.selections, [a.id]: { ...cur, linkedinProfiles: n } } };
    }
    case 'SET_OVERSEAS_COUNTRIES': {
      // 2026-05-22: list of country codes the user wants extended cold-call
      // coverage for, attached to the 'overseas-calling' addon on Sales.
      const cur = s.selections[a.id] || { tier: 'grow', addons: [] };
      const list = Array.isArray(a.value) ? a.value.filter(Boolean) : [];
      return { ...s, selections: { ...s.selections, [a.id]: { ...cur, overseasCountries: list } } };
    }
    case 'SET_MAIL_OPT': {
      // Physical Mail options (printed/handwritten + branded paper) for the
      // 'physical-mail' addon. a = { id, key, value }.
      const cur = s.selections[a.id] || { tier: 'grow', addons: [] };
      const mailOpts = { ...(cur.mailOpts || {}), [a.key]: a.value };
      return { ...s, selections: { ...s.selections, [a.id]: { ...cur, mailOpts } } };
    }
    case 'SET_LEAD_SOURCE_MODE': {
      // Monthly Lead Volume widget (SDG only). When user picks 'byol', also
      // add the existing 'byol' add-on so the 15% retainer-discount math
      // fires. When switching back to 'we-source', remove it.
      const cur = s.selections[a.id] || { tier: 'grow', addons: [] };
      const addons = Array.isArray(cur.addons) ? cur.addons.slice() : [];
      let byolListQuality = cur.byolListQuality;
      if (a.mode === 'byol' && !addons.includes('byol')) addons.push('byol');
      if (a.mode === 'byol' && !byolListQuality) byolListQuality = 'fully-enriched';
      // 2026-06-10 (Loom 32 2:13): 'none' = user unselected the active card.
      // Treated like we-source for cleanup, the byol add-on + quality reset.
      if (a.mode === 'we-source' || a.mode === 'none') {
        const i = addons.indexOf('byol');
        if (i !== -1) addons.splice(i, 1);
        byolListQuality = null;
      }
      return { ...s, selections: { ...s.selections, [a.id]: { ...cur, leadSourceMode: a.mode, addons, byolListQuality } } };
    }
    case 'SET_BYOL_LIST_QUALITY': {
      // Captures the user's answer to "What's in your list?". The percentage
      // shown (25/10/5) is the marketing rate; final discount confirmed at
      // onboarding (see yellow banner copy in the widget).
      const cur = s.selections[a.id] || { tier: 'grow', addons: [] };
      return { ...s, selections: { ...s.selections, [a.id]: { ...cur, byolListQuality: a.quality } } };
    }
    case 'SET_MONTHLY_LEADS': {
      // 2026-05-28: tier-aware floor. Starter 750, Grow 1,000, Scale 1,250.
      // Previously hardcoded 1,000 which stranded Starter users — slider
      // visible min was 750 but the reducer clamped any value < 1,000 back
      // up, leaving the thumb effectively stuck at ~1,050 on Starter.
      const cur = s.selections[a.id] || { tier: 'grow', addons: [] };
      const floor = (typeof window !== 'undefined' && typeof window.leadIncludedForTier === 'function')
        ? window.leadIncludedForTier(cur.tier)
        : 750;
      const v = Math.max(floor, Math.min(20000, Math.round(Number(a.value) || floor)));
      return { ...s, selections: { ...s.selections, [a.id]: { ...cur, monthlyLeads: v } } };
    }
    case 'SET_START_WINDOW': {
      // 2026-06-12 (Loom 41): how soon the client needs their specialist(s),
      // a proposal input collected in place of a date picker, not a booking.
      const cur = s.selections[a.id] || { tier: 'grow', addons: [] };
      return { ...s, selections: { ...s.selections, [a.id]: { ...cur, startWindow: a.value || null } } };
    }
    case 'SET_PT_BILLING': {
      // 2026-06-12 (Loom 41 2:37, Nicole's Option A): Part-Time billing mode.
      // null/'recurring' (default) keeps today's monthly pricing with the
      // commitment ladder. 'oneoff' books the selected days once at the full
      // day rate with no commitment discount.
      const cur = s.selections[a.id] || { tier: 'parttime', addons: [] };
      return { ...s, selections: { ...s.selections, [a.id]: { ...cur, ptBilling: a.value === 'oneoff' ? 'oneoff' : null } } };
    }
    case 'SET_PROMO': return { ...s, promoApplied: a.promo || null };

    case 'SET_Q0_FIELD': {
      // Sprint 1, white-label agency Q0 capture (clientName + industry).
      // When industry changes AND user is on agency-whitelabel intent, pre-fill
      // Q1 ICP via INDUSTRY_TO_Q1_ICP. User can override after seeing pre-fill.
      const q0 = { ...(s.q0 || { clientName: '', industry: '' }), [a.field]: a.value };
      let qualifier = s.qualifier || { q1: null, q1a: null, q1aDtcAov: null, q1aDtcMargin: null, q1aDtcRepeat: null, saasCycle: null, bservicesCat: null, bservicesCycle: null, bcservicesCat: null, q1b: null, q1c: null, q2: null, q2b: null, q3: null, tradingLength: null, fundBacked: null, fundRaised: null, fundInvestorCount: null, q4: null, q5: null, q5b: null, urgency: null, priorActivities: [], priorOutboundChannels: [], priorSalesCycle: null, priorPaidPlatforms: [], priorPaidBudget: null, priorPaidRoas: null, emailSubscriberCount: null, emailCampaignsPerMonth: null, priorEmailPlatform: null, priorSocialFrequency: null, priorSocialPlatforms: [], hiringTime: null, fundraisingStage: null, fundraisingRaiseSize: null, fundraisingInvestorTypes: [], fundraisingTargetValuation: null, fundraisingFlags: [] };
      const isWhitelabel = s.clientTypeId === 'agency' && s.intentId === 'agency-whitelabel';
      if (a.field === 'industry' && isWhitelabel && window.INDUSTRY_TO_Q1_ICP) {
        const mapped = window.INDUSTRY_TO_Q1_ICP[a.value];
        if (mapped) qualifier = { ...qualifier, q1: mapped };
      }
      return { ...s, q0, qualifier };
    }
    // 2026-05-26: SET_QUALIFIER_SKIP + qualifierSkipped removed. The Skip-
    // questions floater that dispatched this is gone; the plan-summary-card
    // and CheckoutForm now always render regardless of qualifier completion.
    case 'SET_QUALIFIER': {
      // Sets a single qualifier answer. When the user changes `q1`, the
      // entire downstream branch is cleared (the JSX layer is responsible
      // for surfacing a confirmation modal when >=3 downstream answers exist
      //, by the time SET_QUALIFIER fires for q1, the user has already
      // confirmed). When the user changes `fundBacked` away from 'yes', the
      // funding sub-chain is cleared. Multi-select chips (priorActivities)
      // are handled via SET_QUALIFIER_MULTI below.
      const cur = s.qualifier || { q1: null, q1a: null, q1aDtcAov: null, q1aDtcMargin: null, q1aDtcRepeat: null, saasCycle: null, bservicesCat: null, bservicesCycle: null, bcservicesCat: null, q1b: null, q1c: null, q2: null, q2b: null, q3: null, tradingLength: null, fundBacked: null, fundRaised: null, fundInvestorCount: null, q4: null, q5: null, q5b: null, urgency: null, priorActivities: [], priorOutboundChannels: [], priorSalesCycle: null, priorPaidPlatforms: [], priorPaidBudget: null, priorPaidRoas: null, emailSubscriberCount: null, emailCampaignsPerMonth: null, priorEmailPlatform: null, priorSocialFrequency: null, priorSocialPlatforms: [], hiringTime: null, fundraisingStage: null, fundraisingRaiseSize: null, fundraisingInvestorTypes: [], fundraisingTargetValuation: null, fundraisingFlags: [] };
      let next = { ...cur, [a.q]: a.value };
      if (a.q === 'q1') {
        // Clear every downstream field on a q1 change.
        next = { ...next,
          q1a: null,
          q1aDtcAov: null, q1aDtcMargin: null, q1aDtcRepeat: null,
          saasCycle: null,
          bservicesCat: null, bservicesCycle: null,
          bcservicesCat: null,
          q1b: null, q1c: null,
          q2: null, q2b: null, q3: null,
          tradingLength: null, fundBacked: null, fundRaised: null, fundInvestorCount: null,
          q4: null, q5: null, q5b: null,
          urgency: null, priorActivities: [],
        };
      }
      if (a.q === 'q1a' && cur.q1 === 'dtc' && a.value !== cur.q1a) {
        next.q1aDtcAov = null; next.q1aDtcMargin = null; next.q1aDtcRepeat = null;
      }
      if (a.q === 'q1aDtcAov' && a.value !== cur.q1aDtcAov) { next.q1aDtcMargin = null; next.q1aDtcRepeat = null; }
      if (a.q === 'q1aDtcMargin' && a.value !== cur.q1aDtcMargin) { next.q1aDtcRepeat = null; }
      if (a.q === 'q1a' && cur.q1 === 'saas' && a.value !== cur.q1a) { next.saasCycle = null; }
      if (a.q === 'q1a' && cur.q1 === 'bservices' && a.value !== cur.q1a) { next.bservicesCat = null; next.bservicesCycle = null; }
      if (a.q === 'bservicesCat' && a.value !== cur.bservicesCat) { next.bservicesCycle = null; }
      if (a.q === 'q1a' && cur.q1 === 'bcservices' && a.value !== cur.q1a) { next.bcservicesCat = null; }
      if (a.q === 'fundBacked' && a.value !== 'yes') {
        next.fundRaised = null; next.fundInvestorCount = null;
      }
      if (a.q === 'fundRaised' && cur.fundBacked === 'yes' && a.value !== cur.fundRaised) {
        next.fundInvestorCount = null;
      }
      // §17 auto-pre-select: when the qualifier transitions !ready → ready
      // for the founders flow, splice in the recommended services. Only
      // fires once per qualifier session (gapsApplied flag prevents repeat).
      const _wasReady = window.qualifierComplete ? window.qualifierComplete(s.qualifier, s.clientTypeId) : false;
      const _nowReady = window.qualifierComplete ? window.qualifierComplete(next, s.clientTypeId) : false;
      // Pre-select / gap-recommendation auto-apply DISABLED, qualifier is now
      // optional and lives on the last page. Users explicitly choose services.
      // (applyGapRecommendations definition retained for future re-enablement.)
      return { ...s, qualifier: next, selections: s.selections };
    }
    case 'SET_QUALIFIER_MULTI': {
      // Toggles a value in a multi-select array (priorActivities). If the
      // toggled value matches `exclusive`, ALL other selections clear. If a
      // different value is added while `exclusive` is already in the array,
      // the exclusive value clears.
      const cur = s.qualifier || { q1: null, q1a: null, q1aDtcAov: null, q1aDtcMargin: null, q1aDtcRepeat: null, saasCycle: null, bservicesCat: null, bservicesCycle: null, bcservicesCat: null, q1b: null, q1c: null, q2: null, q2b: null, q3: null, tradingLength: null, fundBacked: null, fundRaised: null, fundInvestorCount: null, q4: null, q5: null, q5b: null, urgency: null, priorActivities: [], priorOutboundChannels: [], priorSalesCycle: null, priorPaidPlatforms: [], priorPaidBudget: null, priorPaidRoas: null, emailSubscriberCount: null, emailCampaignsPerMonth: null, priorEmailPlatform: null, priorSocialFrequency: null, priorSocialPlatforms: [], hiringTime: null, fundraisingStage: null, fundraisingRaiseSize: null, fundraisingInvestorTypes: [], fundraisingTargetValuation: null, fundraisingFlags: [] };
      const arr = Array.isArray(cur[a.q]) ? [...cur[a.q]] : [];
      const exclusive = a.exclusive || null;
      const has = arr.includes(a.value);
      let nextArr;
      if (has) {
        nextArr = arr.filter(v => v !== a.value);
      } else if (a.value === exclusive) {
        nextArr = [exclusive];
      } else {
        nextArr = [...arr.filter(v => v !== exclusive), a.value];
      }
      let _nextMulti = { ...cur, [a.q]: nextArr };
      // Q6 spec §4, selecting 'none' wipes all cascade sub-fields. Toggling
      // off a primary chip clears just its cascade. Toggling on a chip leaves
      // sub-fields untouched (they re-mount empty anyway).
      if (a.q === 'priorActivities') {
        const CASCADE_MAP = {
          sales:       { priorOutboundChannels: [], priorSalesCycle: null },
          paid:        { priorPaidPlatforms: [], priorPaidBudget: null, priorPaidRoas: null },
          email:       { emailSubscriberCount: null, emailCampaignsPerMonth: null, priorEmailPlatform: null },
          smm:         { priorSocialFrequency: null, priorSocialPlatforms: [] },
          talent:      { hiringTime: null },
          fundraising: { fundraisingStage: null, fundraisingRaiseSize: null, fundraisingInvestorTypes: [], fundraisingTargetValuation: null, fundraisingFlags: [] },
        };
        // 'none' was just toggled ON → wipe every cascade.
        if (a.value === 'none' && nextArr.includes('none')) {
          for (const slug of Object.keys(CASCADE_MAP)) {
            _nextMulti = { ..._nextMulti, ...CASCADE_MAP[slug] };
          }
        }
        // A primary chip was just toggled OFF → clear that cascade.
        if (has && a.value !== 'none' && CASCADE_MAP[a.value]) {
          _nextMulti = { ..._nextMulti, ...CASCADE_MAP[a.value] };
        }
      }
      // Attribution (Batch 21, Nicole): deselecting a 'How did you hear about us?'
      // channel clears that channel's follow-up answers, so stale answers never
      // linger for an option the lead has removed.
      if (a.q === 'heardVia') {
        const HEARD_CASCADE = {
          referral: { heardReferralWho: null, heardReferralName: '' },
          youtube:  { heardYoutube: '' },
          outreach: { heardOutreachHow: [] },
          ad:       { heardAdPlatform: null },
          other:    { heardOther: '' },
        };
        if (has && HEARD_CASCADE[a.value]) {
          _nextMulti = { ..._nextMulti, ...HEARD_CASCADE[a.value] };
        }
      }
      // Pre-select / gap-recommendation auto-apply DISABLED (see SET_QUALIFIER).
      return { ...s, qualifier: _nextMulti, selections: s.selections };
    }
    case 'SET_PRIOR_SUB': {
      // Q6 cascade, single-select sub-field (e.g., priorSalesCycle,
      // priorPaidBudget, priorPaidRoas, emailSubscriberCount, etc.). Pass
      // a.field + a.value. Setting null clears.
      const cur = s.qualifier || {};
      const next = { ...cur, [a.field]: a.value };
      return { ...s, qualifier: next };
    }
    case 'TOGGLE_PRIOR_SUB': {
      // Q6 cascade, multi-select sub-field (priorOutboundChannels,
      // priorPaidPlatforms, priorSocialPlatforms, fundraisingInvestorTypes,
      // fundraisingFlags). Toggles a.value into a.field array.
      const cur = s.qualifier || {};
      const arr = Array.isArray(cur[a.field]) ? [...cur[a.field]] : [];
      const idx = arr.indexOf(a.value);
      const nextArr = idx >= 0 ? arr.filter(v => v !== a.value) : [...arr, a.value];
      return { ...s, qualifier: { ...cur, [a.field]: nextArr } };
    }
    case 'SET_SERVICES_INQUIRY': {
      // Partial-update. action.value is { categories?, notes?, dismissed? }.
      // Used by ServicesInquiryModal to save chips/notes or mark as dismissed.
      const cur = s.servicesInquiry || { categories: [], notes: '', dismissed: false };
      const upd = (a.value && typeof a.value === 'object') ? a.value : {};
      return {
        ...s,
        servicesInquiry: {
          categories: Array.isArray(upd.categories) ? upd.categories : cur.categories,
          notes:      typeof upd.notes === 'string' ? upd.notes      : cur.notes,
          dismissed:  typeof upd.dismissed === 'boolean' ? upd.dismissed : cur.dismissed,
        },
      };
    }
    case 'SET_CALL_PREFERENCE': {
      const v = a.value;
      const valid = ['schedule', 'email-first', 'already-had', 'contract-payment', 'refer-start'];
      if (!valid.includes(v)) return s;
      return { ...s, callPreference: v };
    }
    case 'CLEAR_CART': return { ...initialBase, clientTypeId: s.clientTypeId, quote_uuid: s.quote_uuid || generateQuoteUuid(), ae_ref: s.ae_ref || null, winback_ref: s.winback_ref || null };
    case 'CLEAR_FOR_CLIENT_SWITCH':
      // Spec §6.5.1 (Calculator_Workflow_and_Account_Architecture): user is
      // switching to a different client type while having selections. Clear
      // selections, intent, pending commits, promo, and route them through
      // the new client-type's flow. Step stays at 0 (Client Type page).
      // quote_uuid is PRESERVED here, same user, same session. The spec's
      // "treat as new quote" edge case (post-final-page + saved email) will
      // be implemented when that flag exists; until then, keep continuity.
      // Attribution (ae_ref, winback_ref) also persists, same lead, just
      // exploring a different client-type configuration.
      // Reset q0 + qualifier, they were tailored to the previous client type.
      return { ...initialBase, clientTypeId: a.id, referMode: s.referMode, quote_uuid: s.quote_uuid || generateQuoteUuid(), ae_ref: s.ae_ref || null, winback_ref: s.winback_ref || null, q0: { clientName: '', industry: '' }, qualifier: { q1: null, q1a: null, q1aDtcAov: null, q1aDtcMargin: null, q1aDtcRepeat: null, saasCycle: null, bservicesCat: null, bservicesCycle: null, bcservicesCat: null, q1b: null, q1c: null, q2: null, q2b: null, q3: null, tradingLength: null, fundBacked: null, fundRaised: null, fundInvestorCount: null, q4: null, q5: null, q5b: null, urgency: null, priorActivities: [], priorOutboundChannels: [], priorSalesCycle: null, priorPaidPlatforms: [], priorPaidBudget: null, priorPaidRoas: null, emailSubscriberCount: null, emailCampaignsPerMonth: null, priorEmailPlatform: null, priorSocialFrequency: null, priorSocialPlatforms: [], hiringTime: null, fundraisingStage: null, fundraisingRaiseSize: null, fundraisingInvestorTypes: [], fundraisingTargetValuation: null, fundraisingFlags: [] } };
    case 'DISMISS_STALE_PROMPT':
      // Spec §3.1 (W5): user acknowledged the "your saved quote is >14 days
      // old" notice. We just clear the flag, the next persistence write
      // updates saved_at to now anyway, so this dismissal also effectively
      // marks the data as freshly re-seen.
      return { ...s, isStale: false };
    case 'SET_QUOTE_SUBMITTED': {
      return { ...s, quoteSubmitted: !!a.value };
    }
    default: return s;
  }
}

// ── Mobile sticky bar ──
// `onOpen` , left chevron opens the bottom sheet with the live breakdown.
// `onNext` , right "Next" CTA advances the user one step (same behaviour as
//             the inline Next button inside the desktop summary). Falls back
//             to `onOpen` if not supplied so the bar never has a dead button.
function MobileBar({ state, onOpen, onNext }) {
  const agyMult = window.getAgencyMultiplier(state);
  let total = 0;
  let custom = false;
  let _paidCount = 0; // C-01: only services that actually charge (>£0) drive the bundle tier
  Object.entries(state.selections).forEach(([sid, sel]) => {
    const svc = window.SERVICES.find(x => x.id === sid);
    const tier = window.findTier(svc, sel.tier);
    if (!svc || !tier) return;
    if (tier.isEnterprise) { custom = true; return; }
    const opts = window.commitsFor(svc);
    const defaultCommitId = '12';
    const commitId = (opts && opts.some(o => o.id === sel.commitId)) ? sel.commitId : defaultCommitId;
    const p = window.priceFor(svc, tier.id, commitId);
    if (p.custom) { custom = true; return; }
    // Sprint 2, per-service multiplier so talent-on-whitelabel uses 0.85 not 0.60
    const _svcMult = window.getAgencyMultiplier(state, sid);
    // Per-service subtotal so we can apply the pay-upfront 10% discount
    // BEFORE bundling into the running total (mirrors Summary's math).
    let _svcSub = 0;
    if (!p.oneTime) _svcSub += p.value * _svcMult;
    const ctxAddons = window.getAddonsForContext
      ? window.getAddonsForContext(svc, sel.tier, commitId, sel.channels)
      : svc.addons;
    (sel.addons || []).forEach(aid => {
      let a = ctxAddons.find(x => x.id === aid);
      // 2026-06-12 (Loom 40): a one-off switched to recurring bills monthly
      // at its discounted rate, mirroring the Summary.
      if (a && a.recurringOption && sel.addonRecurring && sel.addonRecurring[aid]) {
        const _raw = (a.price || 0) * (1 - (a.recurringOption.savePct || 11) / 100);
        a = { ...a, price: (a.recurringOption.price != null ? a.recurringOption.price : (_raw >= 10 ? Math.round(_raw) : Math.round(_raw * 100) / 100)), oneTime: false };
      }
      if (a && !a.free && !a.custom && !a.included) _svcSub += a.price;
    });
    // Pay-upfront -10% per opted-in service.
    if (sel.payUpfront) _svcSub *= 0.9;
    // Monthly Lead Volume, SDG-only. Tier-aware included floor:
    //   Starter 750, Grow 1,000, Scale 1,250 (matches tier feature pills).
    // Additional leads beyond the tier's included get £4/lead added on top.
    // 2026-06-10: gate on source mode. Previously a raised slider kept its
    // additional-lead cost in the total even after switching to BYOL (or now
    // to unselected), with no visible line item explaining it.
    if (sid === 'sales' && sel.monthlyLeads && sel.leadSourceMode !== 'byol' && sel.leadSourceMode !== 'none' && typeof window.computeAdditionalLeadCost === 'function') {
      const { cost } = window.computeAdditionalLeadCost(sel.monthlyLeads, sel.tier);
      if (cost > 0) _svcSub += cost * _svcMult;
    }
    // 2026-06-11 (Loom 38 items 2-3): Paid Advertising channels beyond the
    // included count (Scale includes 2) add £495/£395/£295 a month by
    // commitment, mirroring the Summary's extra-channel line.
    if (sid === 'paid-ads' && Array.isArray(sel.channels) && sel.channels.length && window.SERVICE_CHANNELS) {
      const _chCfg = window.SERVICE_CHANNELS['paid-ads'];
      const _incl = _chCfg && _chCfg.included ? _chCfg.included[sel.tier] : undefined;
      if (typeof _incl === 'number') {
        const _allowed = (typeof window.channelsForTier === 'function') ? new Set(window.channelsForTier(sid, sel.tier)) : null;
        const _extraN = (_allowed ? sel.channels.filter(c => _allowed.has(c)) : sel.channels).length - _incl;
        const _per = _chCfg.extraPrice ? (_chCfg.extraPrice[commitId] != null ? _chCfg.extraPrice[commitId] : _chCfg.extraPrice['12']) : 0;
        if (_extraN > 0 && _per > 0) _svcSub += _extraN * _per * _svcMult;
      }
    }
    total += _svcSub;
    if (_svcSub > 0) _paidCount++;
  });
  // Sprint 2, tiered multi-service discount (5/15/25/35/45). Combined-discount
  // cap applies for whitelabel agencies (whitelabel% + multi-service%).
  const _msPctRaw = window.multiServiceDiscountPct ? window.multiServiceDiscountPct(_paidCount) : (_paidCount >= 3 ? 0.10 : 0);
  // Whitelabel cap parity with Summary: multi-service portion is capped at 10%
  // on top of the headline 40% whitelabel discount (combined 50%).
  const _msPct = state.intentId === 'agency-whitelabel' ? Math.min(_msPctRaw, 0.10) : _msPctRaw;
  total *= (1 - _msPct);
  // Apply any active promo code on top of the bundle discount, mirroring the
  // Summary's order of operations (subtotal → multi-service → promo). Without
  // this the mobile bar shows the pre-promo total while the sheet/summary
  // shows the post-promo total, confusing for users.
  const _promoPct = (state.promoApplied?.pct || 0) / 100;
  if (_promoPct > 0) total *= (1 - _promoPct);
  total = Math.max(0, total);
  const count = Object.keys(state.selections).length;

  return (
    <div className="mobile-bar">
      <button className="mobile-bar__expand" onClick={onOpen} aria-label="Expand quote">
        <span className="mobile-bar__chev">⌃</span>
      </button>
      <div className="mobile-bar__info">
        <div className="mobile-bar__label">{count} service{count === 1 ? '' : 's'}</div>
        <div className="mobile-bar__total">
          {custom && total === 0 ? 'Custom' : window.fmt(Math.round(total)) + '/mo'}
        </div>
      </div>
      <button
        className="btn btn--primary btn--sm mobile-bar__cta"
        onClick={onNext || onOpen}
      >
        Next <span className="btn__arrow">›</span>
      </button>
    </div>
  );
}

function MobileSheet({ state, dispatch, onClose }) {
  return (
    <div className="mobile-sheet" role="dialog" aria-modal="true">
      <div className="mobile-sheet__backdrop" onClick={onClose} />
      <div className="mobile-sheet__panel">
        <button className="mobile-sheet__close" onClick={onClose} aria-label="Close">×</button>
        <div className="mobile-sheet__grab" />
        <window.Summary state={state} dispatch={dispatch} />
      </div>
    </div>
  );
}

function TweaksPanel({ tweaks, setTweaks, onClose, onClearCart, savedCount }) {
  const set = (k, v) => {
    const next = { ...tweaks, [k]: v };
    setTweaks(next);
    window.parent.postMessage({ type: '__edit_mode_set_keys', edits: { [k]: v } }, '*');
  };
  return (
    <div className="tweaks">
      <div className="tweaks__head">
        <strong>Tweaks</strong>
        <button className="tweaks__close" onClick={onClose}>×</button>
      </div>
      <div className="tweaks__row">
        <label>Add-ons default state</label>
        <div className="tweaks__seg">
          {[
            ['always','Always open'],
            ['when-added','When added'],
            ['collapsed','Collapsed']
          ].map(([k, lbl]) => (
            <button key={k} className={tweaks.addonsDefault === k ? 'active' : ''} onClick={() => set('addonsDefault', k)}>
              {lbl}
            </button>
          ))}
        </div>
      </div>
      <div className="tweaks__row">
        <label>Show mobile sticky bar</label>
        <button className={`tweaks__toggle ${tweaks.showMobileBar ? 'on' : ''}`} onClick={() => set('showMobileBar', !tweaks.showMobileBar)}>
          <span />
        </button>
      </div>
      <div className="tweaks__row">
        <label>Show home page cart preview</label>
        <button className={`tweaks__toggle ${tweaks.showHomePreview ? 'on' : ''}`} onClick={() => set('showHomePreview', !tweaks.showHomePreview)}>
          <span />
        </button>
      </div>
      <div className="tweaks__row" style={{flexDirection: 'column', alignItems: 'stretch', gap: '0.5rem'}}>
        <label>Saved cart ({savedCount} service{savedCount === 1 ? '' : 's'})</label>
        <button
          className="btn btn--ghost btn--sm"
          onClick={onClearCart}
          disabled={savedCount === 0}
          style={{opacity: savedCount === 0 ? 0.5 : 1}}
        >
          Clear saved cart
        </button>
      </div>
      <div className="tweaks__hint">Cart auto-saves to this browser. Reload to verify persistence.</div>
    </div>
  );
}

function App() {
  const [state, dispatch] = useReducer(reducer, null, loadState);
  // Publish the current selection set so window.priceFor can apply the Founders
  // Pro "free when bundled with another service" rule synchronously, before
  // Summary, the tier cards, and MobileBar read prices in this same render.
  window.__ggSelectedIds = Object.keys(state.selections || {});
  const [mobileOpen, setMobileOpen] = uSA(false);
  const [tweaks, setTweaks] = uSA(TWEAK_DEFAULTS);
  const [tweaksOpen, setTweaksOpen] = uSA(false);
  // Track the count loaded from storage on first paint, for "Welcome back"
  const [initialSavedCount] = uSA(() => Object.keys(state.selections).length);

  // Persist on every change
  uEA(() => {
    try {
      window.localStorage.setItem(STORAGE_KEY, JSON.stringify({
        step: state.step,
        clientTypeId: state.clientTypeId,
        intentId: state.intentId,
        selections: state.selections,
        promoApplied: state.promoApplied,
        quote_uuid: state.quote_uuid,
        // 2026-07-01 (Batch 2): persist the referring-mode flag so a browser
        // refresh keeps the user in referring mode instead of dropping back to
        // the white-labelling intent. Start fresh still clears storage entirely.
        referMode: !!state.referMode,
        // 2026-07-02 (R-07): persist the referral so a refresh keeps the referral
        // discount + partner attribution (was lost, dropping the partner's commission).
        referral: (state.referral && typeof state.referral === 'object') ? state.referral : null,
        ggReferral: (typeof window !== 'undefined' && window.__ggReferral) || null,
        // Timestamp on every write, spec §3.1 / W5. Read back in loadState
        // to determine if the saved quote has crossed the 14-day staleness
        // threshold and the "refresh prices" prompt should be shown.
        saved_at: Date.now(),
        // Spec §3.2 / W3, persist URL-derived attribution so it survives
        // page reload even after the user has clicked through to a URL
        // without the original ref/winback params.
        ae_ref: state.ae_ref || null,
        winback_ref: state.winback_ref || null,
        // Sprint 1, qualifier + Q0 persistence so cohort tag survives reload.
        q0: state.q0 || { clientName: '', industry: '' },
        qualifier: state.qualifier || { q1: null, q1a: null, q1aDtcAov: null, q1aDtcMargin: null, q1aDtcRepeat: null, saasCycle: null, bservicesCat: null, bservicesCycle: null, bcservicesCat: null, q1b: null, q1c: null, q2: null, q2b: null, q3: null, tradingLength: null, fundBacked: null, fundRaised: null, fundInvestorCount: null, q4: null, q5: null, q5b: null, urgency: null, priorActivities: [], priorOutboundChannels: [], priorSalesCycle: null, priorPaidPlatforms: [], priorPaidBudget: null, priorPaidRoas: null, emailSubscriberCount: null, emailCampaignsPerMonth: null, priorEmailPlatform: null, priorSocialFrequency: null, priorSocialPlatforms: [], hiringTime: null, fundraisingStage: null, fundraisingRaiseSize: null, fundraisingInvestorTypes: [], fundraisingTargetValuation: null, fundraisingFlags: [] },
      }));
      // 2026-06-24: mirror the selected-services count into a cookie scoped to
      // the parent domain (.gogorilla.com) so the marketing site
      // (www.gogorilla.com, a different subdomain) can show it in a cart badge.
      // localStorage is origin-locked; a parent-domain cookie is shared across
      // *.gogorilla.com. Non-sensitive (just an integer count).
      try {
        var _ggCount = Object.keys(state.selections || {}).length;
        document.cookie = 'gg_cart_count=' + _ggCount +
          '; domain=.gogorilla.com; path=/; max-age=2592000; SameSite=Lax; Secure';
      } catch (e2) { /* ignore */ }
    } catch (e) { /* ignore */ }
  }, [state]);

  // ── Phase 1 autosave (Loom 58): keep a half-finished quote saved server-side ──
  // Once the lead has given an email (window.__leadEmail, set when they enter it)
  // and has a priced selection, debounce-save their progress to the platform
  // (quote_sessions upsert) and Airtable (Draft row, upsert on Quote UUID), plus
  // one final save when they leave the page. So a half-finished quote is captured
  // even with no submit click. Stops once they have actually submitted.
  const _autosaveReady = () => {
    try {
      // 2026-07-01 (Batch 3): on the call-booked confirmation endpoint there is
      // nothing to autosave and the lead is already captured, so bail early.
      if (window.inCalcBookingConfirm && window.inCalcBookingConfirm()) return false;
      // A fresh start is clearing this quote on purpose; do not let a trailing
      // autosave (debounce or page-leave flush) re-save the discarded picks.
      if (window.__ggStartingFresh) return false;
      // Post-call (Cal redirect, ?booked=1) is the exception: the lead has
      // "submitted" by booking, but may keep configuring before the scheduled
      // call. We want those edits autosaved to their row so the rep's resume link
      // stays current. So quoteSubmitted only blocks autosave OUTSIDE post-call.
      var _postCall = false;
      try { _postCall = new URLSearchParams(window.location.search).get('booked') === '1'; } catch (e) {}
      if (state.quoteSubmitted && !_postCall) return false;
      var email = String((window.__leadEmail) || '').trim();
      if (!email) return false;
      // Post-call (before the scheduled call): capture whatever the lead builds —
      // even a bare client type with no priced service yet — so the rep's resume
      // link reflects their latest progress. Outside post-call, keep the original
      // "priced selection" bar so empty drafts are never stored.
      if (_postCall) return !!state.clientTypeId || Object.keys(state.selections || {}).length > 0;
      if (!state.selections || !Object.keys(state.selections).length) return false;
      var live = window.__currentQuote || {};
      return (Number(live.monthlyTotal) || 0) > 0 || (Number(live.oneTimeTotal) || 0) > 0;
    } catch (e) { return false; }
  };
  const _autosaveBody = () => {
    var live = window.__currentQuote || {};
    return JSON.stringify({
      action: 'autosave',
      email: String(window.__leadEmail || '').trim(),
      clientType: live.clientTypeId || state.clientTypeId || '',
      clientHeading: live.clientHeading || '',
      intent: live.intent || '',
      intentId: live.intentId || state.intentId || '',
      promoCode: live.promoCode || '',
      promoPct: live.promoPct || 0,
      monthlyTotal: live.monthlyTotal || 0,
      oneTimeTotal: live.oneTimeTotal || 0,
      netProfit: live.netProfit || 0,
      retailByService: live.retailByService || '',
      lines: Array.isArray(live.lines) ? live.lines : [],
      commitText: live.commitText || '',
      quote_uuid: live.quote_uuid || state.quote_uuid || '',
      ae_ref: live.ae_ref || state.ae_ref || '',
      winback_ref: live.winback_ref || state.winback_ref || '',
      q0: live.q0 || state.q0 || { clientName: '', industry: '' },
      qualifier: live.qualifier || state.qualifier || {},
      warmth: typeof live.warmth === 'number' ? live.warmth : 0,
      cohort: live.cohort || '',
      selections: state.selections || {},
      servicesInquiry: state.servicesInquiry || {},
    });
  };
  uEA(() => {
    if (!_autosaveReady()) return;
    const t = setTimeout(() => {
      try { if (typeof window.ensurePlatformQuoteAsync === 'function') window.ensurePlatformQuoteAsync('autosave', state, String(window.__leadEmail || '').trim()); } catch (e) {}
      try { fetch('/api/send-quote', { method: 'POST', headers: { 'Content-Type': 'application/json' }, keepalive: true, body: _autosaveBody() }).catch(() => {}); } catch (e) {}
    }, 1200);
    return () => clearTimeout(t);
  }, [state]);
  uEA(() => {
    const flush = () => {
      if (!_autosaveReady()) return;
      try {
        const body = _autosaveBody();
        if (navigator.sendBeacon) navigator.sendBeacon('/api/send-quote', new Blob([body], { type: 'application/json' }));
        else fetch('/api/send-quote', { method: 'POST', headers: { 'Content-Type': 'application/json' }, keepalive: true, body: body }).catch(() => {});
      } catch (e) {}
    };
    const onVis = () => { if (document.visibilityState === 'hidden') flush(); };
    window.addEventListener('pagehide', flush);
    document.addEventListener('visibilitychange', onVis);
    return () => { window.removeEventListener('pagehide', flush); document.removeEventListener('visibilitychange', onVis); };
  }, [state]);

  uEA(() => {
    const handler = (e) => {
      if (e.data?.type === '__activate_edit_mode') setTweaksOpen(true);
      if (e.data?.type === '__deactivate_edit_mode') setTweaksOpen(false);
    };
    window.addEventListener('message', handler);
    window.parent.postMessage({ type: '__edit_mode_available' }, '*');
    return () => window.removeEventListener('message', handler);
  }, []);

  uEA(() => { window.scrollTo({ top: 0, behavior: 'smooth' }); }, [state.step]);

  const savedCount = Object.keys(state.selections).length;

  // 2026-07-01 (Batch 3): after an in-calculator Schedule-a-call booking, the Cal
  // redirect reloads with ?booked=1. Rather than entering post-call mode (which
  // loops back to step one), render the call-booked confirmation endpoint. All
  // hooks above have already run, so this early return is safe.
  if (window.inCalcBookingConfirm && window.inCalcBookingConfirm() && window.CallBookedConfirm) {
    return React.createElement(window.CallBookedConfirm);
  }

  // ── Top-of-page progress bar ──────────────────────────────────────────
  // Computes overall wizard completion as (state.step / flow.length) plus
  // sub-progress on step 0 from the qualifier answers, so the bar moves as
  // the user fills in each question rather than only on step transitions.
  const _ggProgress = (() => {
    const flow = _applyReferFlow(window.stepsForClient ? window.stepsForClient(state.clientTypeId, state.intentId) : (window.BUILD_STEPS || []), state);
    if (!flow.length) return 0;
    const total = flow.length;
    const step = Math.max(0, Math.min(state.step || 0, total - 1));
    let pct = step / total;
    if (step === 0) {
      // Per-question granularity inside step 0 (client type + qualifier)
      const qs = window.QUALIFIER_QUESTIONS || [];
      const visible = qs.filter(q => {
        if (!q.conditional) return true;
        const dep = state.qualifier?.[q.conditional.dependsOn];
        return q.conditional.show.includes(dep);
      });
      let answered = 0;
      if (state.clientTypeId) answered++;
      for (const q of visible) if (state.qualifier?.[q.id]) answered++;
      const totalSub = visible.length + 1;
      pct += (answered / totalSub) * (1 / total);
    } else if (step === total - 1) {
      pct = 1;
    } else {
      // For service steps, give a half-step bonus once user has picked at
      // least one service on the current step (feels more "alive").
      pct = (step + 0.5) / total;
    }
    return Math.max(0, Math.min(1, pct));
  })();

  return (
    <>
      {/* Thin blue progress bar pinned to the top of the viewport. */}
      <div className="gg-progress" role="progressbar" aria-label="Wizard progress"
        aria-valuenow={Math.round(_ggProgress * 100)} aria-valuemin={0} aria-valuemax={100}>
        <div className="gg-progress__fill" style={{ width: `${_ggProgress * 100}%` }} />
      </div>

      {/* Discount toast notifications, fires when any discount activates
          (multi-service, commitment, pay upfront, BYOL, promo code, agency).
          Hydration guard prevents toasts on page refresh. */}
      {window.DiscountToastManager && <window.DiscountToastManager state={state} />}

      {/* Referral modal: shows on landing for a valid-format ?ref code, captures
          the email, validates against the portal, and applies the referral.
          Soft-nudge dismiss; the checkout step remains the backstop. */}
      {window.ReferralModal && <window.ReferralModal state={state} dispatch={dispatch} />}

      {/* HomeCartPreview removed, the in-app "Welcome back" prompt below
          already surfaces a Resume CTA for returning visitors. */}
      

      {(() => {
        // Compute the active flow for the current client type.
        const flow = _applyReferFlow(window.stepsForClient(state.clientTypeId, state.intentId), state);
        const lastIdx = flow.length - 1; // checkout
        const isCheckout = state.step === lastIdx;
        return (
          <>
            {!isCheckout && (
              <window.BuildPage
                state={state}
                dispatch={dispatch}
                step={state.step}
                flow={flow}
                onJumpStep={(s) => dispatch({ type: 'SET_STEP', step: s })}
                onNext={() => {
                  if (state.step === 0 && !state.clientTypeId) return;
                  dispatch({ type: 'SET_STEP', step: Math.min(state.step + 1, lastIdx) });
                }}
                addonsDefaultOpen={tweaks.addonsDefault}
                savedCount={savedCount}
              />
            )}
            {isCheckout && (
              <window.YoureSetPage
                state={state}
                dispatch={dispatch}
                flow={flow}
                onBack={() => dispatch({ type: 'SET_STEP', step: lastIdx - 1 })}
              />
            )}
            {tweaks.showMobileBar && !isCheckout && (
              /* Mobile floating nav bar, visible on every step except the
                 checkout page (YoureSetPage). Previously we hid it when no
                 services were selected, but the user wants easy step-by-step
                 navigation from the very first screen. The Next handler is a
                 no-op when step 0 has no clientType yet, so users can't slip
                 past picking a path. */
              <MobileBar
                state={state}
                onOpen={() => setMobileOpen(true)}
                onNext={() => {
                  if (state.step === 0 && !state.clientTypeId) return;
                  dispatch({ type: 'SET_STEP', step: Math.min(state.step + 1, lastIdx) });
                }}
              />
            )}
          </>
        );
      })()}
      {mobileOpen && <MobileSheet state={state} dispatch={dispatch} onClose={() => setMobileOpen(false)} />}
      {tweaksOpen && (
        <TweaksPanel
          tweaks={tweaks}
          setTweaks={setTweaks}
          onClose={() => setTweaksOpen(false)}
          onClearCart={() => {
            try { window.localStorage.removeItem(STORAGE_KEY); } catch (e) {}
            dispatch({ type: 'CLEAR_CART' });
          }}
          savedCount={savedCount}
        />
      )}
    </>
  );
}

Promise.resolve(window.__pricesFetched).then(function() {
  ReactDOM.createRoot(document.getElementById('root')).render(<App />);
});
