/* global React, window */
const { useState: uS, useMemo: uM, useEffect: uE, useRef: uR } = React;

// #72 Post-call mode: triggered by ?booked=1 in the URL (Cal.com redirect).
// When active, the final step shows call-aware copy and a send-selection CTA
// instead of prompting the user to book again.
function isPostCallMode() {
  try { return new URLSearchParams(window.location.search).get('booked') === '1'; }
  catch(e) { return false; }
}
// #72 Post-call prefill: Cal.com redirect carries the attendee's name + email
// as ?name= and ?email=. Only honoured alongside ?booked=1 so stray params on the
// normal flow never prefill. React escapes the value on render (safe to interpolate).
function _postCallParam(key) {
  try { return (new URLSearchParams(window.location.search).get(key) || '').trim(); }
  catch(e) { return ''; }
}
function postCallName()  { return isPostCallMode() ? _postCallParam('name')  : ''; }
function postCallEmail() { return isPostCallMode() ? _postCallParam('email') : ''; }

// ── PORTAL-BASED STEP LOCK TOOLTIP ──
// The standard inline .dis-tip gets trapped inside .step-indicator's
// stacking context (created by backdrop-filter), so even at max z-index
// the page content below can cover it. Portal-rendering into document.body
// escapes that stacking context entirely.
function StepLockTip({ reason, children }) {
  const [tipPos, setTipPos] = uS(null);
  const wrapRef = uR(null);
  const showTip = () => {
    if (!wrapRef.current) return;
    const r = wrapRef.current.getBoundingClientRect();
    setTipPos({ x: r.left + r.width / 2, y: r.bottom });
  };
  const hideTip = () => setTipPos(null);
  return (
    <>
      <span
        ref={wrapRef}
        className="step-lock-wrap"
        onMouseEnter={showTip}
        onMouseLeave={hideTip}
        onFocus={showTip}
        onBlur={hideTip}
      >
        {children}
      </span>
      {tipPos && ReactDOM.createPortal(
        <span
          className="step-lock-tip"
          role="tooltip"
          style={{ left: tipPos.x, top: tipPos.y }}
        >
          {reason}
        </span>,
        document.body
      )}
    </>
  );
}

// ── Universal portal-tooltip helper ─────────────────────────────────────
// Wraps any anchor element + tooltip JSX, rendering the tooltip into
// document.body via React portal. This guarantees the tooltip escapes
// every ancestor stacking context (backdrop-filter, transform, isolation,
// overflow:hidden, etc.) in the app and can never be covered by other UI.
// All inline tooltips (.summary__total-tip, .wl-tip, .dis-tip,
// .addon__capacity-tip, .gp-chip__tip) are routed through this helper.
function HoverPortalTip({
  children,
  tip,
  tipClassName = '',
  wrapClassName = '',
  wrapStyle,
  placement = 'above',
  as: WrapTag = 'span',
}) {
  const [pos, setPos] = uS(null);
  const wrapRef = uR(null);
  const show = () => {
    if (!wrapRef.current) return;
    const r = wrapRef.current.getBoundingClientRect();
    const vw = window.innerWidth || 1024;
    const PAD = 12;
    const halfTip = 140;
    const rawX = r.left + r.width / 2;
    const clampedX = Math.min(Math.max(rawX, halfTip + PAD), vw - halfTip - PAD);
    let x = clampedX;
    let y;
    if (placement === 'below') y = r.bottom;
    else if (placement === 'left') { x = r.left; y = r.top + r.height / 2; }
    else y = r.top;
    setPos({ x, y, caretShift: rawX - clampedX });
  };
  const hide = () => setPos(null);
  return (
    <WrapTag
      ref={wrapRef}
      className={wrapClassName}
      style={wrapStyle}
      onMouseEnter={show}
      onMouseLeave={hide}
      onFocus={show}
      onBlur={hide}
    >
      {children}
      {pos && ReactDOM.createPortal(
        <div
          className={`hover-portal-tip ${tipClassName}`}
          role="tooltip"
          style={{
            position: 'fixed',
            left: pos.x,
            top: pos.y,
            zIndex: 2147483647,
            '--portal-tip-caret-shift': `${pos.caretShift}px`,
          }}
        >
          {tip}
        </div>,
        document.body
      )}
    </WrapTag>
  );
}

// ── STEP INDICATOR ──
// Top-of-page progress strip. 6 numbered steps with 3D icons, connector lines,
// and three states per step:
//   • current  → orange filled circle, primary label, underline
//   • complete → black filled circle (icon shown), label active
//   • upcoming → grey, dimmed icon and label
// Mobile: condenses to icon-only with the current step's label below.
function StepIndicator({ step, flow, onJump, onNudge, canJumpTo, isStepLocked, lockReasonFor, clientTypeId, intentId }) {
  const steps = flow || window.BUILD_STEPS || [];
  return (
    <nav
      className="step-indicator"
      aria-label="Pricing flow progress"
    >
      <ol className="step-indicator__list">
        {steps.map((s, i) => {
          const state = i === step ? 'current' : (i < step ? 'complete' : 'upcoming');
          const reachable = typeof canJumpTo === 'function' ? canJumpTo(i) : i <= step;
          const locked = typeof isStepLocked === 'function' ? isStepLocked(i) : false;
          const lockReason = locked && typeof lockReasonFor === 'function' ? lockReasonFor(i) : null;
          const btnInner = (
            <>
              <span className="step-indicator__bubble" aria-hidden="true">
                <span className="step-indicator__icon-wrap">
                  <img src={s.icon} alt="" className="step-indicator__icon" />
                </span>
                {state === 'complete' && (
                  <span className="step-indicator__check" aria-hidden="true">
                    <svg viewBox="0 0 16 16" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
                      <polyline points="3 8.5 6.5 12 13 4.5"/>
                    </svg>
                  </span>
                )}
                {locked && (
                  <span className="step-indicator__lock" aria-hidden="true">
                    <svg viewBox="0 0 16 16" width="9" height="9" fill="currentColor">
                      <rect x="3" y="7" width="10" height="8" rx="2"/>
                      <path d="M5 7V5a3 3 0 0 1 6 0v2" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round"/>
                    </svg>
                  </span>
                )}
              </span>
              <span className="step-indicator__num" aria-hidden="true">{i + 1}</span>
              <span className="step-indicator__label">{(window.getStepLabel ? window.getStepLabel(s.id, clientTypeId, intentId, s.label) : s.label)}</span>
            </>
          );
          return (
            <React.Fragment key={s.id}>
              <li className={`step-indicator__item step-indicator__item--${state}${locked ? ' step-indicator__item--locked' : ''}`}>
                {(() => {
                  const tipText = lockReason
                    || (window.getStepTooltip ? window.getStepTooltip(s.id, clientTypeId, intentId) : '');
                  const btn = (
                    <button
                      type="button"
                      className="step-indicator__btn"
                      onClick={() => {
                        // #53 two-mode nav: completed/reachable steps jump freely;
                        // unvisited but ungated steps nudge the user to finish the
                        // current step (scroll to its Next button); gated steps stay locked.
                        if (reachable) { onJump && onJump(i); }
                        else if (!locked) { onNudge && onNudge(); }
                      }}
                      disabled={!reachable && locked}
                      aria-current={state === 'current' ? 'step' : undefined}
                    >
                      {btnInner}
                    </button>
                  );
                  // 2026-05-22: lockReason still uses the specialized StepLockTip
                  // styling. For non-locked steps, surface STEP_TOOLTIPS via the
                  // canonical white HoverPortalTip (matches the "i" tooltip the
                  // user referenced on the sidebar). Portal-rendered so they
                  // escape step-indicator overflow / stacking contexts.
                  if (lockReason) return <StepLockTip reason={lockReason}>{btn}</StepLockTip>;
                  if (!tipText) return btn;
                  return (
                    <HoverPortalTip
                      wrapClassName="step-indicator__tip-wrap"
                      tipClassName="step-indicator__tip"
                      placement="below"
                      tip={<span>{tipText}</span>}
                    >
                      {btn}
                    </HoverPortalTip>
                  );
                })()}
              </li>
              {i < steps.length - 1 && (
                <li className={`step-indicator__connector step-indicator__connector--${i < step ? 'complete' : 'upcoming'}`} aria-hidden="true" />
              )}
            </React.Fragment>
          );
        })}
      </ol>
    </nav>
  );
}
window.StepIndicator = StepIndicator;

// ── STALE-DATA PROMPT (spec §3.1 + W5) ──
// Renders at the top of BuildPage when the user's saved localStorage state
// has crossed the 14-day staleness threshold. Non-blocking; one CTA acknowl-
// edges the prompt, an × dismisses. Either action just clears the flag,
// the prices the user is currently seeing already reflect the latest data
// (window.SERVICES is loaded fresh on every page render), so there's no
// async refresh to do.
function StalePrompt({ onDismiss }) {
  return (
    <div className="stale-prompt" role="status" aria-live="polite">
      <div className="stale-prompt__icon" aria-hidden="true">⏱</div>
      <div className="stale-prompt__body">
        <strong>Heads up.</strong> Your saved quote was last updated more than 14 days ago. Prices may have changed since then, the totals shown now reflect current pricing.
      </div>
      <button
        type="button"
        className="btn btn--primary btn--sm stale-prompt__cta"
        onClick={onDismiss}
      >
        Got it
      </button>
      <button
        type="button"
        className="stale-prompt__close"
        onClick={onDismiss}
        aria-label="Dismiss"
      >
        ×
      </button>
    </div>
  );
}
window.StalePrompt = StalePrompt;

// ── CLIENT SWITCH CONFIRMATION MODAL (spec §6.5.1) ──
// Renders when the user, while having selections, clicks a different client-type
// ── Auto-scroll helper ──────────────────────────────────────────────────
// Smoothly scrolls a target element (selector or Node) to just below the top
// of the viewport. Retries on rAF for ~10 frames so it works even when the
// target hasn't mounted yet (e.g. the qualifier section only appears after
// the user picks a client type). Respects prefers-reduced-motion.
function _scrollToNext(target, opts = {}) {
  if (typeof window === 'undefined' || typeof document === 'undefined') return;
  const reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  const offset = typeof opts.offset === 'number' ? opts.offset : 90;
  const resolve = () => {
    if (typeof target === 'string') return document.querySelector(target);
    return target || null;
  };
  const tryScroll = () => {
    const el = resolve();
    if (!el) return false;
    const top = el.getBoundingClientRect().top + window.pageYOffset;
    window.scrollTo({ top: Math.max(0, top - offset), behavior: reduced ? 'auto' : 'smooth' });
    return true;
  };
  if (tryScroll()) return;
  let n = 0;
  const tick = () => {
    n++;
    if (tryScroll() || n > 12) return;
    requestAnimationFrame(tick);
  };
  requestAnimationFrame(tick);
}

// card on step 0. Cancel = no change; Confirm = dispatch CLEAR_FOR_CLIENT_SWITCH.
function ClientSwitchModal({ fromClientId, toClientId, selectionCount, onCancel, onConfirm }) {
  const toCt = window.CLIENT_TYPES.find(c => c.id === toClientId);
  const toName = toCt?.heading || 'a new client type';
  React.useEffect(() => {
    const onKey = (e) => { if (e.key === 'Escape') onCancel(); };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [onCancel]);
  return (
    <div className="cs-modal" role="dialog" aria-modal="true" aria-labelledby="cs-modal-title">
      <div className="cs-modal__backdrop" onClick={onCancel} />
      <div className="cs-modal__panel">
        <h2 id="cs-modal-title" className="cs-modal__title">Switch to {toName}?</h2>
        <p className="cs-modal__body">
          Your current selections (<strong>{selectionCount} service{selectionCount === 1 ? '' : 's'}</strong>) will be cleared. We'll start fresh with the {toName} flow.
        </p>
        <div className="cs-modal__actions">
          <button type="button" className="btn btn--ghost btn--sm" onClick={onCancel}>Cancel</button>
          <button type="button" className="btn btn--primary btn--sm" onClick={onConfirm}>Switch and clear my selections</button>
        </div>
      </div>
    </div>
  );
}
window.ClientSwitchModal = ClientSwitchModal;

// ── CLIENT TYPE SECTION (top of build page) ──
// ── Portal-based tooltip for intent cards ───────────────────────────────
// The intent-card sits inside <button> elements whose ancestors include
// .value-strip items with backdrop-filter (stacking-context) and various
// transform/filter parents. Inline .dis-tip tooltips get trapped in those
// stacking contexts and end up rendering BEHIND the value-strip pills above
// them. This helper portals the tooltip into document.body, guaranteed
// top-layer, regardless of any ancestor stacking context.
//
// Rendered span uses <span> (not <button>) to avoid nesting a button inside
// the intent-card button, and stops click propagation so clicking the (i)
// does NOT toggle the intent.
function IntentInfoTip({ body }) {
  const [tipPos, setTipPos] = uS(null);
  const anchorRef = uR(null);
  const showTip = () => {
    if (!anchorRef.current) return;
    const r = anchorRef.current.getBoundingClientRect();
    const vw = window.innerWidth || 1024;
    const halfTip = 150; // half of max-width (300) + small padding
    const PAD = 12;
    const rawX = r.left + r.width / 2;
    const clampedX = Math.min(Math.max(rawX, halfTip + PAD), vw - halfTip - PAD);
    setTipPos({
      x: clampedX,
      caretShift: rawX - clampedX,
      y: r.top,
    });
  };
  const hideTip = () => setTipPos(null);
  return (
    <>
      <span
        ref={anchorRef}
        className="intent-card__info"
        role="button"
        tabIndex={0}
        aria-label="More info"
        onMouseEnter={showTip}
        onMouseLeave={hideTip}
        onFocus={showTip}
        onBlur={hideTip}
        onClick={(e) => { e.stopPropagation(); e.preventDefault(); }}
        onKeyDown={(e) => {
          if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); }
          if (e.key === 'Escape') { hideTip(); }
        }}
      >
        <window.InfoIcon className="intent-card__info-i" />
      </span>
      {tipPos && ReactDOM.createPortal(
        <span
          className="info-tip-portal info-tip-portal--above intent-card__info-portal"
          role="tooltip"
          style={{
            left: tipPos.x,
            top: tipPos.y,
            '--info-tip-caret-shift': `${tipPos.caretShift || 0}px`,
          }}
        >
          <span className="info-tip-portal__body">{body}</span>
        </span>,
        document.body
      )}
    </>
  );
}

// ── INTENT DISAMBIGUATOR ─────────────────────────────────────────────────
// Renders below the client cards. The user has self-identified WHO they
// are (Founder / Investor / Agency); this picker captures what they WANT
// to do today: build a tailored proposal, sign up to the Founders Portal,
// or just explore pricing anonymously. Single-select; keyboard accessible
// (Arrow Left/Right or Up/Down to move focus, Space/Enter to select).
//
// Value lives in component state for the session, no localStorage. The
// `onIntentChange` callback fires on every selection change so a parent
// can branch the downstream flow when we wire it in later.
// ── INTENT DISAMBIGUATOR (persona-aware) ────────────────────────────────
// Routing table (parent reads `value` and routes accordingly):
//
// Intent     Persona       Next                                Notes
// proposal   founders      Step 1 quick-questions              Full proposal flow.
// proposal   investors     Step 1 investor-questions           Different Step 1 (out of scope).
// proposal   agencies      Step 1 agency-questions             Different Step 1 (out of scope).
// portal     founders      /founders-portal/signup             External handoff.
// portal     investors     /investors-portal/apply             Investor application form.
// portal     agencies      INVALID, option hidden entirely.
// explore    any           Anonymous price browser             Skip step 1.
//
// Side-effects on selection are parent-handled. The component only captures
// the value and auto-falls-back when persona changes invalidate the value.

function ClientTypeSection({ clientTypeId, setClientType, intentId, setIntent, selectionCount = 0, confirmClientSwitch, clientReadyChoice = null, setClientReadyChoice = null, onClientReadyYes = null }) {
  // Spec §6.5.1, when the user has selections AND clicks a DIFFERENT client-
  // type card, intercept with a confirmation modal before clearing.
  const [pendingSwitch, setPendingSwitch] = React.useState(null);
  // IntentDisambiguator value, session-local state, persists across renders.
  const [userIntent, setUserIntent] = React.useState('proposal');
  // #75: investor 'marketing due diligence' — which service areas to assess.
  const [ddAreas, setDdAreas] = React.useState([]);
  const INVESTOR_DD_AREAS = [
    { id: 'growth',          label: 'Growth' },
    { id: 'creative',        label: 'Creative' },
    { id: 'talent',          label: 'Talent' },
    { id: 'investor-portal', label: 'Investor Portal' },
  ];
  const onClickClientCard = (targetId) => {
    if (clientTypeId && targetId !== clientTypeId && selectionCount > 0) {
      setPendingSwitch(targetId);
      return;
    }
    setClientType(targetId);
    // Single-pick: advance to the intent disambiguator (.intent-disambig)
    // which mounts once a client type is picked. The qualifier section is
    // further down; we'll scroll there when the user picks an intent.
    _scrollToNext(targetId === 'agency' ? '.agency-intent-section' : targetId === 'investor' ? '.investor-intent-section' : '.intent-disambig');
  };
  const ct = window.CLIENT_TYPES.find(c => c.id === clientTypeId);
  const intents = ct?.intents || [];
  return (
    <div className="build-section">
      <div className="client-section-card">
        <div className="section-head section-head--compact">
          <div className="section-head__eyebrow">Client Type</div>
          <h1 className="section-head__title">Who are <em>you?</em></h1>
          <p className="section-head__sub">Select the client type that best describes you. We'll tailor pricing, tiers, and add-ons.</p>
        </div>
        <div className="client-grid client-grid--compact">
          {window.CLIENT_TYPES.map(c => {
            const active = clientTypeId === c.id;
            return (
            <button
              key={c.id}
              className={`client-card ${active ? 'client-card--active' : ''}`}
              onClick={() => onClickClientCard(c.id)}
              aria-pressed={active}
            >
              <div className="glass-frame" aria-hidden="true"></div>
              <span className={`client-card__radio ${active ? 'is-on' : ''}`} aria-hidden="true">
                {active && <window.Check size={18} />}
              </span>
              <div className="client-card__icon"><img src={c.icon} alt="" /></div>
              <div className="client-card__logo">
                <img src={c.logo} alt={`GoGorilla ${c.subBrand}`} className="client-card__logo-img" />
              </div>
              <div className="client-card__heading">{c.heading}</div>
              <div className="client-card__desc">{c.desc}</div>
            </button>
          );})}
        </div>

        {/* Quick value bullets, appears once a client type is picked. Pulls
            quickBullets[] from CLIENT_TYPES and renders 4-up. Glass-frame
            surface matches the rest of the design system. */}
        {clientTypeId && Array.isArray(ct?.quickBullets) && ct.quickBullets.length > 0 && (
          <div className="client-quick-bullets thin-glass-frame" role="list" aria-label="What you get">
            {((ct.quickBulletsByIntent && intentId && ct.quickBulletsByIntent[intentId]) || ct.quickBullets).map((label, i) => (
              <div key={i} className="client-quick-bullets__item" role="listitem">
                <span className="client-quick-bullets__check" aria-hidden="true">
                  <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round">
                    <polyline points="3 8 7 12 13 4" />
                  </svg>
                </span>
                <span className="client-quick-bullets__lbl">{label}</span>
              </div>
            ))}
          </div>
        )}

        {/* IntentDisambiguator removed, auto-advance on client-type pick
            takes the user straight to services (Step 1). No need for the
            "What brings you here?" path picker; intent defaults to 'proposal'. */}

        {/* Agency intent picker, only rendered when clientTypeId === 'agency'.
            Required to unlock the per-service MarginRow (rendered when
            intentId === 'agency-whitelabel') and to apply the correct
            agency multiplier (15% own / 40% whitelabel). */}
        {clientTypeId === 'agency' && intents.length > 0 && (
          <div className="agency-intent-section">
            <div className="agency-intent-section__head">
              <div className="agency-intent-section__eyebrow">For agencies</div>
              <div className="agency-intent-section__title">How are you planning to use GoGorilla?</div>
              <div className="agency-intent-section__sub">Sets your discount tier and how we deliver.</div>
            </div>
            <div className="agency-intent-grid">
              {intents.map(i => {
                const on = intentId === i.id;
                /* 2026-05-29: card is now a <div role="button"> rather than
                   a <button>, so it can contain nested buttons (Yes /
                   Not yet / Speak to us) for the inline client-ready
                   expansion on the white-label card. */
                const handleCardActivate = () => {
                  if (i.comingSoon) return;
                  setIntent(i.id);
                  _scrollToNext('.build-section--qualifier');
                };
                return (
                  <div
                    key={i.id}
                    role="button"
                    tabIndex={i.comingSoon ? -1 : 0}
                    className={`agency-intent-card thin-glass-frame ${on ? 'agency-intent-card--on' : ''}`}
                    style={i.comingSoon ? { opacity: 0.55, cursor: 'not-allowed' } : undefined}
                    onClick={handleCardActivate}
                    onKeyDown={(e) => {
                      if (i.comingSoon) return;
                      if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleCardActivate(); }
                    }}
                    aria-pressed={on}
                    aria-disabled={i.comingSoon || undefined}
                  >
                    <span className={`agency-intent-card__radio ${on ? 'is-on' : ''}`} aria-hidden="true">
                      {on && <window.Check size={14} />}
                    </span>
                    <div className="agency-intent-card__body">
                      <div className="agency-intent-card__title">
                        {i.title}
                        {!i.comingSoon && i.id === 'agency-own' && (
                          <HoverPortalTip
                            wrapClassName="intent-info-tip-wrap"
                            tipClassName="dis-tip dis-tip--above"
                            placement="above"
                            tip={<span>Covers <strong>Outbound Sales</strong> (15% partner discount) and <strong>Talent Solutions</strong> (GorillaPerks partner rates apply).</span>}
                          >
                            <span style={{marginLeft:'6px',opacity:0.6,cursor:'default',verticalAlign:'middle',display:'inline-flex',alignItems:'center'}} aria-label="More information">
                              <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="7"/><line x1="8" y1="7.5" x2="8" y2="11"/><circle cx="8" cy="5" r="0.6" fill="currentColor" stroke="none"/></svg>
                            </span>
                          </HoverPortalTip>
                        )}
                        {!i.comingSoon && i.id === 'agency-whitelabel' && (
                          <HoverPortalTip
                            wrapClassName="intent-info-tip-wrap"
                            tipClassName="dis-tip dis-tip--above"
                            placement="above"
                            tip={<span>Services on this path: <strong>Growth</strong> (Sales &amp; Demand Generation, Paid Advertising, Email Marketing) and <strong>Creative</strong> (Social Media Management). Talent Solutions are not available on the white-label path.</span>}
                          >
                            <span style={{marginLeft:'6px',opacity:0.6,cursor:'default',verticalAlign:'middle',display:'inline-flex',alignItems:'center'}} aria-label="More information">
                              <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="7"/><line x1="8" y1="7.5" x2="8" y2="11"/><circle cx="8" cy="5" r="0.6" fill="currentColor" stroke="none"/></svg>
                            </span>
                          </HoverPortalTip>
                        )}
                        {i.comingSoon && (
                          <img
                            src="assets/badges/COMING-SOON-GLASS.webp"
                            alt="Coming soon"
                            className="agency-intent-card__coming-soon"
                          />
                        )}
                      </div>
                      {typeof i.discount === 'number' && (
                        <div className="agency-intent-card__discount">
                          {Math.round(i.discount * 100)}% discount applied
                        </div>
                      )}
                      <div className="agency-intent-card__desc">{i.desc}</div>
                    </div>
                  </div>
                );
              })}
            </div>

          </div>
        )}

        {/* #75: investor intent picker — mirrors the agency card pattern.
            Surfaces the three investor intents; 'marketing due diligence'
            (investor-considering) expands inline to scope the assessment and
            book a free, context-carrying call instead of entering pricing. */}
        {clientTypeId === 'investor' && intents.length > 0 && (
          <div className="agency-intent-section investor-intent-section">
            <div className="agency-intent-section__head">
              <div className="agency-intent-section__eyebrow">For investors</div>
              <div className="agency-intent-section__title">What brings you to GoGorilla?</div>
              <div className="agency-intent-section__sub">Pick your intent so we can tailor the next step.</div>
            </div>
            <div className="agency-intent-grid">
              {intents.map(i => {
                const on = intentId === i.id;
                const isDd = i.id === 'investor-considering';
                const handleCardActivate = () => { setIntent(i.id); };
                const bookDd = (e) => {
                  e.stopPropagation();
                  const areaLabels = INVESTOR_DD_AREAS.filter(a => ddAreas.includes(a.id)).map(a => a.label);
                  if (window.Cal && window.Cal.ns && window.Cal.ns['book-a-call']) {
                    const notes = [
                      "PRE-INVESTMENT MARKETING DUE DILIGENCE — free assessment",
                      "",
                      "Context (from pricing calculator):",
                      "- Persona: investor (angel / VC / PE)",
                      "- Intent: marketing due diligence on a potential investment",
                      "- Funnel stage: client-type / intent step (before pricing)",
                      areaLabels.length ? ("- Areas to assess: " + areaLabels.join(", ")) : "- Areas to assess: not specified yet",
                      "",
                      "What I'd like to cover on the call:",
                      "- Scope the marketing diligence for the target company",
                      "- Walk through what a repeatable DD assessment covers for the areas above",
                      "",
                      "(Feel free to overwrite any of this with anything else you'd like to discuss.)",
                    ].join('\n');
                    window.Cal.ns['book-a-call']('modal', {
                      calLink: 'team/gogorilla/book-a-call',
                      config: { layout: 'month_view', notes },
                    });
                  }
                };
                return (
                  <div
                    key={i.id}
                    role="button"
                    tabIndex={0}
                    className={`agency-intent-card thin-glass-frame ${on ? 'agency-intent-card--on' : ''}`}
                    onClick={handleCardActivate}
                    onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleCardActivate(); } }}
                    aria-pressed={on}
                  >
                    <span className={`agency-intent-card__radio ${on ? 'is-on' : ''}`} aria-hidden="true">
                      {on && <window.Check size={14} />}
                    </span>
                    <div className="agency-intent-card__body">
                      <div className="agency-intent-card__title">{i.title}</div>
                      <div className="agency-intent-card__desc">{i.desc}</div>
                      {on && isDd && (
                        <div className="agency-intent-card__client-ready">
                          <div className="agency-intent-card__client-ready-q">Which services are relevant to this assessment?</div>
                          <div className="qualifier-q__opts qualifier-q__opts--pills" role="group">
                            {INVESTOR_DD_AREAS.map(area => {
                              const sel = ddAreas.includes(area.id);
                              return (
                                <button
                                  key={area.id}
                                  type="button"
                                  aria-pressed={sel}
                                  className={`qual-pill ${sel ? 'qual-pill--on' : ''}`}
                                  onClick={(e) => { e.stopPropagation(); setDdAreas(prev => prev.includes(area.id) ? prev.filter(x => x !== area.id) : [...prev, area.id]); }}
                                  onKeyDown={(e) => e.stopPropagation()}
                                >{area.label}</button>
                              );
                            })}
                          </div>
                          <div className="agency-intent-card__client-ready-cta-row">
                            <button
                              type="button"
                              className="agency-intent-card__client-ready-cta btn btn--secondary"
                              onClick={bookDd}
                              onKeyDown={(e) => e.stopPropagation()}
                            >Book your free assessment →</button>
                            <span className="agency-intent-card__client-ready-info">
                              Free, no obligation. A 30-minute scoping call; we assess the target's marketing and share diligence-ready notes.
                            </span>
                          </div>
                        </div>
                      )}
                    </div>
                  </div>
                );
              })}
            </div>
          </div>
        )}
      </div>
      {pendingSwitch && (
        <ClientSwitchModal
          fromClientId={clientTypeId}
          toClientId={pendingSwitch}
          selectionCount={selectionCount}
          onCancel={() => setPendingSwitch(null)}
          onConfirm={() => {
            confirmClientSwitch(pendingSwitch);
            setPendingSwitch(null);
            // Trigger the same auto-advance as a normal card click so non-agency
            // flows proceed to step 1 after confirming a client-type switch.
            setClientType(pendingSwitch);
          }}
        />
      )}
    </div>
  );
}

// ── ADD-ON renderer, wide card with price+desc+capacity ──
// Compute a deterministic onboarding capacity per addon id across 4 buckets:
// Open · Limited · Nearly Full · Full.
// Recommended/popular addons skew toward Open. Most others Limited.
const ADDON_CAP_STATUS = {
  open: {
    label: 'Open',
    pillLabel: 'ACCEPTING RESELLERS',
    fillPct: 22,
    pillCopy:
      'We currently have capacity to onboard new clients for this service. We recommend securing your spot soon, as we will move to a waiting list once our monthly limit is reached to ensure quality for our existing partners.',
  },
  limited: {
    label: 'Limited',
    pillLabel: 'ACCEPTING RESELLERS',
    fillPct: 58,
    pillCopy:
      'We currently have capacity to onboard new clients for this service. We recommend securing your spot soon, as we will move to a waiting list once our monthly limit is reached to ensure quality for our existing partners.',
  },
  'nearly-full': {
    label: 'Nearly Full',
    pillLabel: 'ACCEPTING RESELLERS',
    fillPct: 82,
    pillCopy:
      'We currently have capacity to onboard new clients for this service. We recommend securing your spot soon, as we will move to a waiting list once our monthly limit is reached to ensure quality for our existing partners.',
  },
  full: {
    label: 'Full',
    pillLabel: 'WAITING LIST',
    fillPct: 100,
    pillCopy:
      'We are currently at full capacity for this service. You can secure your place on our waiting list by clicking the button below and completing a short form. We will notify you via email as soon as a spot becomes available. Please note that we give priority access to partners who are on an Enterprise plan or are using two or more of our core services.',
  },
};

function addonAvailability(a) {
  // Explicit override: onboardingStatus field on the addon sets status directly.
  if (a.onboardingStatus && ADDON_CAP_STATUS[a.onboardingStatus]) {
    const status = a.onboardingStatus;
    const id = String(a.id || '');
    let h = 0;
    for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0;
    const base = ADDON_CAP_STATUS[status].fillPct;
    const jitter = ((h >> 7) % 9) - 4;
    return { status, fillPct: Math.max(8, Math.min(100, base + jitter)) };
  }
  // Stable hash of addon id
  const id = String(a.id || '');
  let h = 0;
  for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0;
  const bucket = h % 100;
  const isFeatured = a.badge === 'recommended' || a.badge === 'popular';
  // Distribution: featured items skew open. Roughly:
  //   featured:  60% open, 30% limited, 8% nearly-full, 2% full
  //   regular:   45% open, 35% limited, 14% nearly-full, 6% full
  let status;
  if (isFeatured) {
    if (bucket < 60) status = 'open';
    else if (bucket < 90) status = 'limited';
    else if (bucket < 98) status = 'nearly-full';
    else status = 'full';
  } else {
    if (bucket < 45) status = 'open';
    else if (bucket < 80) status = 'limited';
    else if (bucket < 94) status = 'nearly-full';
    else status = 'full';
  }
  // Slightly randomize fill within the bucket so bars don't all look identical.
  const base = ADDON_CAP_STATUS[status].fillPct;
  const jitter = ((h >> 7) % 9) - 4; // -4..+4
  const fillPct = Math.max(8, Math.min(100, base + jitter));
  return { status, fillPct };
}
window.addonAvailability = addonAvailability;

// ── OverseasCountryPicker ── multi-select pill picker for the
// "Overseas Cold Calling" addon. Click the trigger to open a panel with
// a checkbox list. Selected countries appear as inline pills. Designed
// to match the calculator's other inline panels (thin-glass-frame chrome,
// blue accent, comfortable hit targets).
const OVERSEAS_COUNTRIES = [
  { code: 'US',  flag: '🇺🇸', name: 'United States' },
  { code: 'CA',  flag: '🇨🇦', name: 'Canada' },
  { code: 'AU',  flag: '🇦🇺', name: 'Australia' },
  { code: 'NZ',  flag: '🇳🇿', name: 'New Zealand' },
  { code: 'IE',  flag: '🇮🇪', name: 'Ireland' },
  { code: 'DE',  flag: '🇩🇪', name: 'Germany' },
  { code: 'FR',  flag: '🇫🇷', name: 'France' },
  { code: 'ES',  flag: '🇪🇸', name: 'Spain' },
  { code: 'IT',  flag: '🇮🇹', name: 'Italy' },
  { code: 'NL',  flag: '🇳🇱', name: 'Netherlands' },
  { code: 'SE',  flag: '🇸🇪', name: 'Sweden' },
  { code: 'DK',  flag: '🇩🇰', name: 'Denmark' },
  { code: 'CH',  flag: '🇨🇭', name: 'Switzerland' },
  { code: 'AE',  flag: '🇦🇪', name: 'United Arab Emirates' },
  { code: 'SG',  flag: '🇸🇬', name: 'Singapore' },
  { code: 'HK',  flag: '🇭🇰', name: 'Hong Kong' },
  { code: 'JP',  flag: '🇯🇵', name: 'Japan' },
  { code: 'IN',  flag: '🇮🇳', name: 'India' },
  { code: 'BR',  flag: '🇧🇷', name: 'Brazil' },
  { code: 'MX',  flag: '🇲🇽', name: 'Mexico' },
];
// ── OverseasRegionPicker ── region-band multi-select for "Overseas Cold
// Calling". Calling wholesale is priced by region band (#120), so the agency
// picks the regions to call into directly. Exact countries are confirmed at
// onboarding.
function OverseasRegionPicker({ selected, onChange }) {
  const _W = (typeof window !== 'undefined' && window.WHOLESALE) ? window.WHOLESALE
    : { regions: ['uk', 'europe', 'north-america', 'apac', 'row'], regionLabels: {} };
  const sel = Array.isArray(selected) ? selected : [];
  // Single-select (radio): one region drives the per-region wholesale. Clicking
  // the active region clears it.
  const pick = (rc) => { onChange(sel[0] === rc ? [] : [rc]); };
  return (
    <div className="ov-rp">
      <div className="ov-cp__label">Which region should we call into?</div>
      <div className="ov-rp__pills" role="radiogroup" style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
        {(_W.regions || []).map(rc => {
          const on = sel[0] === rc;
          return (
            <button
              key={rc}
              type="button"
              role="radio"
              aria-checked={on}
              onClick={(e) => { e.stopPropagation(); pick(rc); }}
              style={{
                padding: '7px 14px', borderRadius: '999px', cursor: 'pointer', fontSize: '0.82rem', fontWeight: 600, lineHeight: 1.2,
                border: on ? '1.5px solid var(--gg-blue, #002ABF)' : '1.5px solid var(--gg-hairline, rgba(15,28,53,0.16))',
                color: on ? 'var(--gg-blue, #002ABF)' : 'var(--gg-body, #2C3142)',
                background: on ? 'rgba(0,42,191,0.06)' : '#fff',
              }}
            >
              {(_W.regionLabels && _W.regionLabels[rc]) || rc}
            </button>
          );
        })}
      </div>
    </div>
  );
}
function OverseasCountryPicker({ selected, onChange }) {
  const [open, setOpen] = uS(false);
  const [search, setSearch] = uS('');
  const wrapRef = uR(null);
  // Close on outside click + Esc.
  React.useEffect(() => {
    if (!open) return;
    const onDown = (e) => {
      if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false);
    };
    const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
    document.addEventListener('mousedown', onDown);
    document.addEventListener('keydown', onKey);
    return () => {
      document.removeEventListener('mousedown', onDown);
      document.removeEventListener('keydown', onKey);
    };
  }, [open]);
  const sel = Array.isArray(selected) ? selected : [];
  const toggle = (code) => {
    const next = sel.includes(code) ? sel.filter(c => c !== code) : [...sel, code];
    onChange(next);
  };
  const filtered = OVERSEAS_COUNTRIES.filter(c =>
    !search || c.name.toLowerCase().includes(search.toLowerCase()) || c.code.toLowerCase().includes(search.toLowerCase())
  );
  const lookup = (code) => OVERSEAS_COUNTRIES.find(c => c.code === code);
  return (
    <div className="ov-cp" ref={wrapRef}>
      <div className="ov-cp__label">Which markets should we cover?</div>
      <button
        type="button"
        className={`ov-cp__trigger ${open ? 'ov-cp__trigger--open' : ''}`}
        onClick={(e) => { e.stopPropagation(); setOpen(o => !o); }}
        aria-expanded={open}
      >
        {sel.length === 0 ? (
          <span className="ov-cp__placeholder">Select countries…</span>
        ) : (
          <span className="ov-cp__chips">
            {sel.map(code => {
              const c = lookup(code);
              if (!c) return null;
              return (
                <span key={code} className="ov-cp__chip">
                  <span className="ov-cp__chip-flag" aria-hidden="true">{c.flag}</span>
                  <span className="ov-cp__chip-name">{c.name}</span>
                  <button
                    type="button"
                    className="ov-cp__chip-x"
                    onClick={(e) => { e.stopPropagation(); toggle(code); }}
                    aria-label={`Remove ${c.name}`}
                  >×</button>
                </span>
              );
            })}
          </span>
        )}
        <span className="ov-cp__caret" aria-hidden="true">▾</span>
      </button>
      {open && (
        <div className="ov-cp__panel" role="listbox" aria-label="Countries">
          <input
            type="text"
            placeholder="Search countries…"
            className="ov-cp__search"
            value={search}
            onChange={(e) => setSearch(e.target.value)}
            onClick={(e) => e.stopPropagation()}
            autoFocus
          />
          <div className="ov-cp__list">
            {filtered.length === 0 && (
              <div className="ov-cp__empty">No matches</div>
            )}
            {filtered.map(c => {
              const on = sel.includes(c.code);
              return (
                <button
                  type="button"
                  key={c.code}
                  className={`ov-cp__opt ${on ? 'ov-cp__opt--on' : ''}`}
                  role="option"
                  aria-selected={on}
                  onClick={(e) => { e.stopPropagation(); toggle(c.code); }}
                >
                  <span className={`ov-cp__opt-check ${on ? 'is-on' : ''}`} aria-hidden="true">
                    {on && <window.Check size={11} />}
                  </span>
                  <span className="ov-cp__opt-flag" aria-hidden="true">{c.flag}</span>
                  <span className="ov-cp__opt-name">{c.name}</span>
                </button>
              );
            })}
          </div>
          {sel.length > 0 && (
            <div className="ov-cp__foot">
              <button
                type="button"
                className="ov-cp__clear"
                onClick={(e) => { e.stopPropagation(); onChange([]); }}
              >Clear all</button>
              <span className="ov-cp__count">{sel.length} selected</span>
            </div>
          )}
        </div>
      )}
    </div>
  );
}

function renderAddon(a, isOn, onToggle, qty, onSetQty, recommendedSet, countries, onSetCountries, intentId) {
  // Build price components
  let priceMain, priceSuffix, priceSuffixList, priceSegments;
  if (Array.isArray(a.priceSegments)) {
    priceSegments = a.priceSegments;
    priceMain = null; priceSuffix = null;
  } else if (a.included) {
    priceMain = 'Included';
    priceSuffix = null;
  } else if (a.free || a.priceLabel === 'Free') {
    priceMain = 'Free';
    priceSuffix = null;
  } else if (a.custom || a.priceLabel === 'Custom' || /contact for pricing/i.test(a.priceLabel || '')) {
    priceMain = a.priceLabel && /contact/i.test(a.priceLabel) ? 'Contact for pricing' : 'Custom';
    priceSuffix = a.oneTime ? ' (one-off)' : null;
  } else if (a.priceLabel && /^See /i.test(a.priceLabel)) {
    priceMain = a.priceLabel;
    priceSuffix = null;
  } else if (a.priceLabel) {
    priceMain = a.priceLabel;
    if (Array.isArray(a.units)) {
      priceSuffixList = a.units;
      priceSuffix = null;
    } else {
      priceSuffix = a.unit === 'one-time' ? ' one-time'
        : (a.unit ? (a.unit.startsWith('/') ? a.unit : ` ${a.unit}`) : null);
    }
  } else {
    priceMain = (a.negative ? window.fmt(a.price) : window.fmt(a.price));
    priceSuffix = a.unit === 'one-time' ? ' one-time'
      : (a.unit ? (a.unit.startsWith('/') ? a.unit : ` ${a.unit}`) : '/mo');
  }
  // Append "(excluding ad spend)" hint inline for paid-ads-related addons
  const showAdSpendNote = /ad spend|ad-?spend|excluding ad/i.test(a.desc || '') || /paid (advertising|ads|promotion)|ppc|google\s+(ads|shopping|display)|meta ads/i.test(`${a.name} ${a.desc || ''}`);
  const isIncluded = !!a.included;
  const isFree = !isIncluded && (a.free || a.priceLabel === 'Free');
  const isCustom = a.custom || a.priceLabel === 'Custom' || /contact for pricing/i.test(a.priceLabel || '');
  const isPricingRef = !!(a.priceLabel && /^See /i.test(a.priceLabel));
  const _qrec = recommendedSet && recommendedSet.has && recommendedSet.has(a.id);
  const isFeatured = a.badge === 'recommended' || a.badge === 'popular' || _qrec;

  // Per-unit add-ons get a quantity stepper that appears when selected.
  // We treat any /<word> unit that ISN'T /mo, /hr, /day or 'one-time' as per-unit.
  const unitStr = a.unit || '';
  const isPerUnit = !!a.unit && !isFree && !isCustom
    && /^\/[a-z]+$/i.test(unitStr)
    && !/^\/(mo|month|hr|hour|day|yr|year)$/i.test(unitStr);
  const unitNoun = isPerUnit ? unitStr.replace(/^\//, '') : '';
  // Pluralise the noun for the field label ("How many videos?")
  const unitNounPlural = (() => {
    if (!unitNoun) return '';
    if (unitNoun.endsWith('y')) return unitNoun.slice(0, -1) + 'ies';
    if (unitNoun.endsWith('s')) return unitNoun;
    return unitNoun + 's';
  })();
  const minQ = (typeof a.minQty === 'number' && a.minQty > 1) ? a.minQty : 1;
  const qVal = (typeof qty === 'number' && qty >= minQ) ? qty : minQ;
  const stepDown = (e) => { e.stopPropagation(); if (onSetQty) onSetQty(Math.max(minQ, qVal - 1)); };
  const stepUp = (e) => { e.stopPropagation(); if (onSetQty) onSetQty(Math.min(9999, qVal + 1)); };

  const cap = addonAvailability(a);
  const capInfo = ADDON_CAP_STATUS[cap.status];
  const isFull = cap.status === 'full';

  return (
    <div
      key={a.id}
      className={`addon ${isOn ? 'addon--on' : ''} ${isFeatured ? 'addon--featured' : ''} ${isIncluded ? 'addon--included' : ''}`}
      role="button"
      tabIndex={0}
      onClick={onToggle}
      onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggle(); } }}
      aria-pressed={isOn}
    >
      {isFeatured && (
        <img
          src={(_qrec || a.badge === 'recommended') ? 'assets/badges/recommended-ribbon.webp' : 'assets/badges/popular-ribbon.webp'}
          alt={_qrec ? 'Recommended for you' : (a.badge === 'popular' ? 'Popular' : 'Recommended')}
          className={`addon__ribbon-img addon__ribbon-img--${_qrec ? 'recommended' : (a.badge || 'recommended')}`}
        />
      )}

      <div
        className={`addon__check ${isOn ? 'addon__check--on' : ''}`}
        aria-hidden="true"
      >
        {isOn && <window.Check size={14}/>}
      </div>

      <div className="addon__main">
        <div className="addon__title-row">
          {a.icon && <img src={a.icon} alt="" className="addon__icon" />}
          <div className="addon__title">{a.name}</div>
          {a.standalone && (
            <HoverPortalTip
              wrapClassName="dis-tip-wrap"
              wrapStyle={{marginLeft: 'auto', flexShrink: 0}}
              tipClassName="dis-tip dis-tip--above"
              tip={"This add-on can be purchased on its own, you don't need to subscribe to the parent service."}
              placement="above"
            >
              <span className="addon__standalone-badge">Available separately</span>
            </HoverPortalTip>
          )}
        </div>

        <div className="addon__price-row">
          {priceSegments ? (
            priceSegments.map((seg, i) => (
              <React.Fragment key={i}>
                {i > 0 && <span className="addon__price-main" style={{marginLeft:'0.3em',marginRight:'0.1em'}}>·</span>}
                <span className="addon__price-main">{seg.amount}</span>
                <span className="addon__price-suffix">{seg.unit}</span>
              </React.Fragment>
            ))
          ) : (
            <>
              <span className={`addon__price-main ${isFree ? 'addon__price-main--free' : ''} ${isCustom ? 'addon__price-main--custom' : ''} ${(isIncluded || isPricingRef) ? 'addon__price-main--included' : ''} ${a.negative ? 'addon__price-main--neg' : ''}`}>
                {priceMain}
              </span>
              {priceSuffixList ? (
                priceSuffixList.map(u => <span key={u} className="addon__price-suffix">{u}</span>)
              ) : priceSuffix && (
                <span className="addon__price-suffix">{priceSuffix}{showAdSpendNote && !/ad spend/i.test(priceSuffix) ? ' (excluding ad spend)' : ''}</span>
              )}
              {!priceSuffix && !priceSuffixList && showAdSpendNote && (
                <span className="addon__price-suffix">(excluding ad spend)</span>
              )}
              {a._wasPrice && (
                <span className="addon__price-was">was {window.fmt(a._wasPrice)}</span>
              )}
            </>
          )}
          {a.desc && (
            a.tip ? (
              <HoverPortalTip
                wrapClassName="addon__info-wrap"
                tipClassName="addon__capacity-tip"
                tip={(() => {
                  const lines = a.tip.split('\n');
                  const head = lines[0];
                  const bullets = lines.slice(1).map(l => l.replace(/^[•\-]\s*/, ''));
                  return (<>
                    <span className={bullets.length > 0 ? "addon__capacity-tip-head" : "addon__capacity-tip-body"}>{head}</span>
                    {bullets.length > 0 && (
                      <span className="addon__capacity-tip-body">
                        <ul style={{margin:'4px 0 0 0',paddingLeft:'16px'}}>
                          {bullets.map((b, i) => <li key={i}>{b}</li>)}
                        </ul>
                      </span>
                    )}
                  </>);
                })()}
              >
                <button
                  type="button"
                  className="addon__info"
                  onClick={(e) => e.stopPropagation()}
                  aria-label="More info"
                  tabIndex={-1}
                >
                  <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
                    <circle cx="12" cy="12" r="10"/>
                    <line x1="12" y1="16" x2="12" y2="12"/>
                    <circle cx="12" cy="8" r="0.6" fill="currentColor"/>
                  </svg>
                </button>
              </HoverPortalTip>
            ) : (
              <button
                type="button"
                className="addon__info"
                onClick={(e) => e.stopPropagation()}
                aria-label="More info"
                tabIndex={-1}
              >
                <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
                  <circle cx="12" cy="12" r="10"/>
                  <line x1="12" y1="16" x2="12" y2="12"/>
                  <circle cx="12" cy="8" r="0.6" fill="currentColor"/>
                </svg>
              </button>
            )
          )}
        </div>

        {a.desc && <p className="addon__desc">{a.desc}</p>}

        {isOn && isPerUnit && a.useSlider && (
          <div className="addon__qty addon__qty--slider" onClick={(e) => e.stopPropagation()}>
            <div className="addon__qty-slider-head">
              <label className="addon__qty-label" htmlFor={`addon-qty-${a.id}`}>
                How many {unitNounPlural}?
              </label>
              <div className="addon__qty-slider-value">
                <span className="addon__qty-slider-count">{qVal.toLocaleString()}</span>
                <span className="addon__qty-slider-total">{window.fmt(qVal * (a.price || 0))}/mo</span>
              </div>
            </div>
            <input
              id={`addon-qty-${a.id}`}
              type="range"
              className="addon__qty-slider"
              min={minQ}
              max={a.sliderMax || 2000}
              step="1"
              value={qVal}
              onClick={(e) => e.stopPropagation()}
              onKeyDown={(e) => e.stopPropagation()}
              onChange={(e) => {
                const v = parseInt(e.target.value, 10);
                if (Number.isFinite(v) && onSetQty) onSetQty(Math.max(minQ, Math.min(a.sliderMax || 2000, v)));
              }}
              style={{ '--fill-pct': `${Math.round(((qVal - minQ) / Math.max(1, (a.sliderMax || 2000) - minQ)) * 100)}%` }}
              aria-label={`Number of ${unitNounPlural}`}
            />
            <div className="addon__qty-slider-foot">
              <span>{minQ.toLocaleString()} min ({window.fmt(minQ * (a.price || 0))})</span>
              <span>{(a.sliderMax || 2000).toLocaleString()} max</span>
            </div>
          </div>
        )}
        {isOn && isPerUnit && !a.useSlider && (
          <div className="addon__qty" onClick={(e) => e.stopPropagation()}>
            <label className="addon__qty-label" htmlFor={`addon-qty-${a.id}`}>
              How many {unitNounPlural}?
            </label>
            <div className="addon__qty-input-wrap">
              <button
                type="button"
                className="addon__qty-step addon__qty-step--down"
                onClick={stepDown}
                disabled={qVal <= minQ}
                aria-label={`Decrease ${unitNounPlural}`}
                tabIndex={-1}
              >
                <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" aria-hidden="true">
                  <path d="M4 8 L12 8"/>
                </svg>
              </button>
              <input
                id={`addon-qty-${a.id}`}
                className="addon__qty-input"
                type="number"
                min={minQ}
                max="9999"
                step="1"
                value={qVal}
                onClick={(e) => e.stopPropagation()}
                onKeyDown={(e) => e.stopPropagation()}
                onChange={(e) => {
                  const v = parseInt(e.target.value, 10);
                  if (Number.isFinite(v) && onSetQty) onSetQty(Math.max(minQ, Math.min(9999, v)));
                }}
                aria-label={`Number of ${unitNounPlural}`}
              />
              <button
                type="button"
                className="addon__qty-step addon__qty-step--up"
                onClick={stepUp}
                aria-label={`Increase ${unitNounPlural}`}
                tabIndex={-1}
              >
                <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" aria-hidden="true">
                  <path d="M8 4 L8 12 M4 8 L12 8"/>
                </svg>
              </button>
            </div>
            <span className="addon__qty-unit-hint">
              per {unitNoun}
            </span>
          </div>
        )}

        {/* 2026-05-22: Overseas Cold Calling — country multi-select. Renders
            when the addon is selected. Lets the user pick which markets we
            should extend coverage for (multi-select). */}
        {isOn && a.id === 'overseas-calling' && (
          <div className="addon__overseas" onClick={(e) => e.stopPropagation()}>
            <OverseasRegionPicker
              selected={Array.isArray(countries) ? countries : []}
              onChange={(next) => onSetCountries && onSetCountries(next)}
            />
          </div>
        )}

        {/* #120: white-label margin for the location-variable add-on. Region
            comes straight from the calling-region picker above. */}
        {isOn && a.id === 'overseas-calling' && intentId === 'agency-whitelabel' && window.AddonMarginRow && (
          <window.AddonMarginRow addon={a} regions={Array.isArray(countries) ? countries : []} />
        )}

        <div className={`addon__capacity capacity capacity--${cap.status}`}>
          <div className="addon__capacity-label">
            <span className="addon__capacity-title-row">
              <span className="addon__capacity-title">Onboarding Availability:</span>
              <HoverPortalTip
                wrapClassName="addon__capacity-info-wrap"
                tipClassName="addon__capacity-tip"
                placement="above"
                tip={<>
                  <span className="addon__capacity-tip-head">Onboarding Availability</span>
                  <span className="addon__capacity-tip-body">
                    To ensure we deliver exceptional results for every partner, our platform automatically monitors and forecasts our team's capacity in real time. We onboard a limited number of new clients each month to guarantee the high level of service and attention your business deserves. This status is updated daily based on our current capacity.
                  </span>
                  <span className={`addon__capacity-tip-pill addon__capacity-tip-pill--${isFull ? 'full' : 'open'}`}>
                    {capInfo.pillLabel}
                  </span>
                  <span className="addon__capacity-tip-body">{capInfo.pillCopy}</span>
                </>}
              >
                <button
                  type="button"
                  className="addon__capacity-info"
                  onClick={(e) => e.stopPropagation()}
                  aria-label="About onboarding availability"
                  tabIndex={-1}
                >
                  <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                    <circle cx="12" cy="12" r="10"/>
                    <line x1="12" y1="16" x2="12" y2="12"/>
                    <circle cx="12" cy="8" r="0.6" fill="currentColor"/>
                  </svg>
                </button>
              </HoverPortalTip>
            </span>
            <span className="addon__capacity-status">
              {cap.status === 'open' && (
                <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
                  <circle cx="8" cy="8" r="6.5"/>
                  <path d="M5 8.4 L7 10.2 L11 6"/>
                </svg>
              )}
              {(cap.status === 'limited' || cap.status === 'nearly-full') && (
                <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
                  <circle cx="8" cy="8" r="6.5"/>
                  <path d="M8 5 L8 8.5"/>
                  <circle cx="8" cy="11" r="0.8" fill="currentColor"/>
                </svg>
              )}
              {cap.status === 'full' && (
                <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
                  <circle cx="8" cy="8" r="6.5"/>
                  <line x1="5.5" y1="5.5" x2="10.5" y2="10.5"/>
                  <line x1="10.5" y1="5.5" x2="5.5" y2="10.5"/>
                </svg>
              )}
              {capInfo.label}
            </span>
          </div>
          <div className="addon__capacity-track">
            <window.CapacityVideo status={cap.status} />
          </div>
          <div className="addon__capacity-copy">
            {cap.status === 'full' && "Currently at full capacity. Select this add-on to join our waiting list, and we'll notify you when a spot opens."}
            {cap.status === 'nearly-full' && 'Nearly full, secure your spot before we move to a waiting list.'}
            {cap.status === 'limited' && 'Limited spots remaining, secure yours before we hit capacity.'}
            {cap.status === 'open' && 'Spots available. Schedule your consultation now to secure your place with our specialist team.'}
          </div>
        </div>
      </div>
    </div>
  );
}

// ── ADDONS DISCLOSURE, collapsed-by-default, with summary chip ──
function AddonsBlock({ service, selectedAddons, addonQty, onToggleAddon, onSetAddonQty, defaultOpen, requireServiceActive, serviceActive, onActivateService, onDeactivateService, qualifier, intentId, overseasCountries, onSetOverseasCountries }) {
  const _recSet = window.recommendedAddonIds ? window.recommendedAddonIds(service.id, qualifier, intentId) : new Set();
  const [open, setOpen] = uS(defaultOpen);

  // Auto-open when service becomes active (and was closed)
  uE(() => {
    if (serviceActive && !open) setOpen(true);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [serviceActive]);

  if (!service.addons || service.addons.length === 0) return null;

  // Hide the 'byol' add-on chip on the SDG service, the Monthly Lead Volume
  // widget's "You bring your own list" radio replaces it. The reducer still
  // syncs the add-on into selection.addons so the 15% retainer discount math
  // fires correctly; we just don't double-render the control.
  const _visibleAddonsRaw = service.id === 'sales'
    ? service.addons.filter(a => a.id !== 'byol')
    : service.addons;
  // 2026-05-25: Consolidate Airtable's "Premium Sourcing: <type>" add-ons into
  // a single compact group card. The underlying add-on IDs remain individually
  // toggleable (pricing math + Airtable persistence unchanged) — we just
  // present them as chip pills inside one card.
  const _psAddons = _visibleAddonsRaw.filter(a => /^Premium Sourcing:/i.test(a.name || ''));
  const _visibleAddons = _visibleAddonsRaw.filter(a => !/^Premium Sourcing:/i.test(a.name || ''));
  if (_visibleAddons.length === 0 && _psAddons.length === 0) return null;

  // Build groups
  const groups = [];
  const bucketMap = new Map();
  _visibleAddons.forEach(a => {
    const g = a.group || null;
    if (!bucketMap.has(g)) { const b = { name: g, items: [] }; bucketMap.set(g, b); groups.push(b); }
    bucketMap.get(g).items.push(a);
  });
  const hasGroups = groups.some(g => g.name);

  // Compute price summary for header
  const freeCount = _visibleAddons.filter(a => a.free).length;

  const handleToggle = (aid) => {
    // 2026-05-26: Preserve scroll anchor. When activating a service via
    // addon click, the SDG configurator (channels-panel + lead-vol) mounts
    // ABOVE the addons disclosure, pushing the disc ~500px down. Window
    // scrollY doesn't change, so the user perceives a jump: the addon they
    // just clicked is no longer where they were looking. Capture the disc's
    // viewport-top BEFORE the state change, then after React flushes,
    // scrollBy the delta to land it at the same Y. No-op when nothing shifts.
    let _discAnchorTop = null;
    try {
      if (typeof document !== 'undefined') {
        const _disc = document.querySelector('.svc__addons-disc--open');
        if (_disc) _discAnchorTop = _disc.getBoundingClientRect().top;
      }
    } catch (e) {}

    if (requireServiceActive && !serviceActive) {
      onActivateService();
    }
    onToggleAddon(aid);
    // Auto-fill minimum quantity on toggle-on when the addon declares a minQty
    // (e.g. SDG "Additional leads" has minQty: 63 = £250 minimum spend at £4/lead).
    // We only seed the qty if the addon isn't already selected (so we're about
    // to add it) and there's no existing qty stored.
    const _aDef = (_visibleAddons || []).find(x => x.id === aid);
    const _wasOn = selectedAddons.includes(aid);
    if (!_wasOn && _aDef && _aDef.minQty && _aDef.minQty > 1 && typeof onSetAddonQty === 'function') {
      const _existing = addonQty && typeof addonQty[aid] === 'number' ? addonQty[aid] : 0;
      if (_existing < _aDef.minQty) onSetAddonQty(aid, _aDef.minQty);
    }
    /* 2026-05-29: removed auto-deactivate-on-zero-addons.
       Previous behaviour: if the user removed the last selected addon,
       the entire service auto-deactivated (clearing the tier and causing
       a ~477px scroll jump as the tier UI collapsed). Tier is the source
       of truth for whether a service is in the cart — addons are
       optional extras. Removing them should never reset the tier. */

    // After React commits + layout, compensate scrollY so the addons disc
    // sits at the same viewport position it did before the click.
    if (_discAnchorTop != null && typeof window !== 'undefined') {
      requestAnimationFrame(() => {
        requestAnimationFrame(() => {
          try {
            const _disc = document.querySelector('.svc__addons-disc--open');
            if (!_disc) return;
            const newTop = _disc.getBoundingClientRect().top;
            const delta = newTop - _discAnchorTop;
            if (Math.abs(delta) > 4) {
              window.scrollBy({ top: delta, left: 0, behavior: 'instant' });
            }
          } catch (e) {}
        });
      });
    }
  };

  return (
    <div className={`svc__addons-disc glass-frame ${open ? 'svc__addons-disc--open' : ''}`}>
      <button
        type="button"
        className="svc__addons-toggle"
        onClick={() => setOpen(o => !o)}
        aria-expanded={open}
      >
        <div className="svc__addons-toggle-main">
          <div className="svc__addons-toggle-title">
            <span className="svc__addons-toggle-plus">+</span>
            <span>{_visibleAddons.length} add-on{_visibleAddons.length === 1 ? '' : 's'} available</span>
            {selectedAddons.length > 0 && (
              <span className="svc__addons-selected-pill" aria-label={`${selectedAddons.length} added`}>{selectedAddons.length}</span>
            )}
          </div>
          <div className="svc__addons-toggle-meta">
            {freeCount > 0 ? (
              <>{freeCount} free intro{freeCount === 1 ? '' : 's'}</>
            ) : (
              <>Optional extras</>
            )}
          </div>
        </div>
        <span className="svc__addons-chev" aria-hidden="true">
          <window.Chev dir={open ? 'up' : 'down'} size={16} />
        </span>
      </button>

      {open && (
        <div className="svc__addons">
          <div className="svc__addons-head">
            <span className="svc__addons-label">Add-ons for {service.name}</span>
            <span className="svc__addons-hint">
              {requireServiceActive && !serviceActive
                ? 'Selecting an add-on will add this service to your plan.'
                : `Optional · ${selectedAddons.length} selected`}
            </span>
          </div>
{hasGroups ? (
            <div className="svc__addons-groups">
              {groups.map((g, gi) => (
                <React.Fragment key={g.name || `g${gi}`}>
                  <div className="addon-group">
                    {g.name && <div className="addon-group__title">{g.name}</div>}
                    <div className="svc__addons-grid">
                      {(() => {
                        // Separate sub-grouped addons (rendered as exclusive chip cards) from regular ones
                        const _sgMap = new Map();
                        const _regItems = [];
                        g.items.forEach(a => {
                          if (a.subGroup) {
                            if (!_sgMap.has(a.subGroup)) _sgMap.set(a.subGroup, []);
                            _sgMap.get(a.subGroup).push(a);
                          } else {
                            _regItems.push(a);
                          }
                        });
                        // Sub-group metadata: title, description, exclusive=radio selection
                        const _SG_META = {
                          'usage-rights':      { title: 'Extended Usage Rights',             desc: 'Choose how long your extended licence runs. Only one option applies at a time.',         exclusive: true },
                          'talent-casting':    { title: 'Talent Casting and Management',     desc: 'Select the tier of talent for your production. Only one talent tier per project.',       exclusive: true },
                          '3d-anim-content':   { title: 'Additional 3D Animation Content',  desc: 'Choose the type of additional animation. Only one production type applies at a time.',   exclusive: true },
                          '3d-render-quality': { title: 'Render Quality Upgrade',            desc: 'Choose your render resolution. Only one resolution level applies per video.',            exclusive: true },
                          '3d-voice-over':     { title: 'Voice-Over',                        desc: 'Choose your voice-over option. Only one applies per video.',                            exclusive: true },
                          '3d-maintenance':    { title: 'Maintenance Plan',                  desc: 'Choose your monthly maintenance tier. Only one plan applies at a time.',                exclusive: true },
                          '3d-usage-rights':   { title: 'Extended Usage Rights',             desc: 'Choose how long your extended licence runs. Only one option applies at a time.',        exclusive: true },
                          '3d-localisation':   { title: 'Multi-Language Localisation',       desc: 'Choose your localisation package. Priced per video per language added.',                exclusive: true },
                        };
                        return <>
                          {_regItems.map(a => renderAddon(a, selectedAddons.includes(a.id), () => handleToggle(a.id), addonQty?.[a.id], (v) => onSetAddonQty && onSetAddonQty(a.id, v), _recSet, overseasCountries, onSetOverseasCountries, intentId))}
                          {[..._sgMap.entries()].map(([sgId, sgItems]) => {
                            const meta = _SG_META[sgId] || { title: sgId, desc: '', exclusive: false };
                            const sgActive = sgItems.some(a => selectedAddons.includes(a.id));
                            const sgCount  = sgItems.filter(a => selectedAddons.includes(a.id)).length;
                            const sgMin = sgItems.filter(a => !a.custom).reduce((m, a) => {
                              const p = typeof a.price === 'number' ? a.price : null;
                              return (p != null && (m == null || p < m)) ? p : m;
                            }, null);
                            const sgHeadline = sgMin != null ? `From £${sgMin.toLocaleString()}` : 'Contact for pricing';
                            const sgStatus = sgItems.some(a => a.onboardingStatus === 'full') ? 'full' : sgItems.some(a => a.onboardingStatus === 'limited') ? 'limited' : null;
                            const sgCap = sgStatus ? addonAvailability({ id: sgId, onboardingStatus: sgStatus }) : null;
                            const sgCapInfo = sgCap ? ADDON_CAP_STATUS[sgCap.status] : null;
                            // Exclusive click: deselect all others in group before toggling
                            const handleChipClick = (a) => {
                              if (meta.exclusive) {
                                sgItems.forEach(other => {
                                  if (other.id !== a.id && selectedAddons.includes(other.id)) {
                                    onToggleAddon && onToggleAddon(other.id);
                                  }
                                });
                                if (!selectedAddons.includes(a.id)) handleToggle(a.id);
                                // Clicking already-selected item deselects it
                                else onToggleAddon && onToggleAddon(a.id);
                              } else {
                                handleToggle(a.id);
                              }
                            };
                            return (
                              <div key={sgId} className={`addon ps-group ${sgActive ? 'addon--on' : ''}`}>
                                <div className={`addon__check ${sgActive ? 'addon__check--on' : ''}`} aria-hidden="true">
                                  {sgActive && <window.Check size={14}/>}
                                </div>
                                <div className="addon__main">
                                  <div className="addon__title-row">
                                    <div className="addon__title">{meta.title}</div>
                                    {sgCount > 0 && <span className="ps-group__pill">{sgCount} selected</span>}
                                  </div>
                                  <div className="addon__price-row">
                                    <span className="addon__price-main">{sgHeadline}</span>
                                  </div>
                                  {meta.desc && <p className="addon__desc">{meta.desc}</p>}
                                  {sgCap && sgCapInfo && (() => {
                                    const sgIsFull = sgCap.status === 'full';
                                    return (
                                      <div className={`addon__capacity capacity capacity--${sgCap.status}`}>
                                        <div className="addon__capacity-label">
                                          <span className="addon__capacity-title-row">
                                            <span className="addon__capacity-title">Onboarding Availability:</span>
                                            <HoverPortalTip wrapClassName="addon__capacity-info-wrap" tipClassName="addon__capacity-tip" placement="above"
                                              tip={<><span className="addon__capacity-tip-head">Onboarding Availability</span><span className="addon__capacity-tip-body">To ensure we deliver exceptional results for every partner, our platform automatically monitors and forecasts our team&#x2019;s capacity in real time. We onboard a limited number of new clients each month to guarantee the high level of service and attention your business deserves. This status is updated daily based on our current capacity.</span><span className={`addon__capacity-tip-pill addon__capacity-tip-pill--${sgIsFull ? 'full' : 'open'}`}>{sgCapInfo.pillLabel}</span><span className="addon__capacity-tip-body">{sgCapInfo.pillCopy}</span></>}
                                            >
                                              <button type="button" className="addon__capacity-info" onClick={(e) => e.stopPropagation()} aria-label="About onboarding availability" tabIndex={-1}>
                                                <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><circle cx="12" cy="8" r="0.6" fill="currentColor"/></svg>
                                              </button>
                                            </HoverPortalTip>
                                          </span>
                                          <span className="addon__capacity-status">
                                            {sgCap.status === 'open' && <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true"><circle cx="8" cy="8" r="6.5"/><path d="M5 8.4 L7 10.2 L11 6"/></svg>}
                                            {(sgCap.status === 'limited' || sgCap.status === 'nearly-full') && <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true"><circle cx="8" cy="8" r="6.5"/><path d="M8 5 L8 8.5"/><circle cx="8" cy="11" r="0.8" fill="currentColor"/></svg>}
                                            {sgIsFull && <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true"><circle cx="8" cy="8" r="6.5"/><line x1="5.5" y1="5.5" x2="10.5" y2="10.5"/><line x1="10.5" y1="5.5" x2="5.5" y2="10.5"/></svg>}
                                            {sgCapInfo.label}
                                          </span>
                                        </div>
                                        <div className="addon__capacity-track"><window.CapacityVideo status={sgCap.status} /></div>
                                        <div className="addon__capacity-copy">
                                          {sgIsFull && "Currently at full capacity. Select this add-on to join our waiting list, and we'll notify you when a spot opens."}
                                          {sgCap.status === 'nearly-full' && 'Nearly full, secure your spot before we move to a waiting list.'}
                                          {sgCap.status === 'limited' && 'Limited spots remaining, secure yours before we hit capacity.'}
                                          {sgCap.status === 'open' && 'Spots available. Schedule your consultation now to secure your place with our specialist team.'}
                                        </div>
                                      </div>
                                    );
                                  })()}
                                  <div className="ps-group__chips" onClick={(e) => e.stopPropagation()}>
                                    {sgItems.map(a => {
                                      const on = selectedAddons.includes(a.id);
                                      const priceTxt = a.custom ? 'Contact for pricing'
                                        : a.included ? 'Included'
                                        : (a.priceLabel || (typeof a.price === 'number'
                                          ? `£${a.price.toLocaleString()}${a.unit && a.unit !== 'one-time' ? ' ' + a.unit : ''}${a.oneTime ? ' (one-time)' : ''}`
                                          : ''));
                                      return (
                                        <div key={a.id} role="button" tabIndex={0} aria-pressed={on}
                                          className={`ps-chip thin-glass-frame ${on ? 'ps-chip--on' : ''}`}
                                          onClick={(e) => { e.stopPropagation(); handleChipClick(a); }}
                                          onKeyDown={(e) => { e.stopPropagation(); if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleChipClick(a); } }}
                                        >
                                          <div className="ps-chip__head">
                                            <span className="ps-chip__check" aria-hidden="true">{on && <window.Check size={11}/>}</span>
                                            <span className="ps-chip__body">
                                              <span className="ps-chip__name">
                                                {a.name}
                                                {a.desc && (
                                                  <HoverPortalTip tip={<span className="addon__capacity-tip-body">{a.desc}</span>} tipClassName="addon__capacity-tip">
                                                    <span className="ps-chip__info" style={{ color: '#6b7280', marginLeft: '4px' }} onClick={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} tabIndex={-1} aria-label="More info">
                                                      <svg viewBox="0 0 16 16" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" aria-hidden="true"><circle cx="8" cy="8" r="6.5"/><line x1="8" y1="7.5" x2="8" y2="11"/><circle cx="8" cy="5.2" r="0.6" fill="currentColor" stroke="none"/></svg>
                                                    </span>
                                                  </HoverPortalTip>
                                                )}
                                              </span>
                                              <span className="ps-chip__price">{priceTxt}</span>
                                            </span>
                                          </div>
                                        </div>
                                      );
                                    })}
                                  </div>
                                </div>
                              </div>
                            );
                          })}
                        </>;
                      })()}
                    </div>
                  </div>
                  {(g.name === 'Paid Advertising Layer' || (service.id === 'paid-ads' && g.name === 'B2B audience data for paid campaigns')) && _psAddons.length > 0 && (
                    <div className="addon-group">
                      <div className="addon-group__title">Premium Sourcing</div>
                      <div className="svc__addons-grid">
                        {_psAddons.length > 0 && (() => {
                                    const psActive = _psAddons.some(a => selectedAddons.includes(a.id));
                                    const psSelectedCount = _psAddons.filter(a => selectedAddons.includes(a.id)).length;
                                    // Lowest /prospect rate across the set, for the headline price.
                                    const psMinPrice = _psAddons.reduce((m, a) => {
                                      const p = typeof a.price === 'number' ? a.price : null;
                                      return (p != null && (m == null || p < m)) ? p : m;
                                    }, null);
                                    const psHeadline = psMinPrice != null ? `From £${psMinPrice}` : 'From £1.25';
                                    const psStatus = _psAddons.some(a => a.onboardingStatus === 'full') ? 'full' : _psAddons.some(a => a.onboardingStatus === 'limited') ? 'limited' : null;
                                    const psCap = psStatus ? addonAvailability({ id: 'ps-group', onboardingStatus: psStatus }) : null;
                                    const psCapInfo = psCap ? ADDON_CAP_STATUS[psCap.status] : null;
                                    const psClearAll = () => {
                                      // Click the parent card or its check while any chip is on
                                      // unchecks every Premium Sourcing addon. Convenient "clear all"
                                      // shortcut that mirrors the inner chip toggles.
                                      _psAddons.forEach(a => {
                                        if (selectedAddons.includes(a.id)) handleToggle(a.id);
                                      });
                                    };
                                    return (
                                      <div
                                        className={`addon ps-group ${psActive ? 'addon--on' : ''}`}
                                        role={psActive ? 'button' : undefined}
                                        tabIndex={psActive ? 0 : -1}
                                        aria-pressed={psActive}
                                        onClick={psActive ? psClearAll : undefined}
                                        onKeyDown={psActive ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); psClearAll(); } } : undefined}
                                        title={psActive ? 'Click to clear all selected sources' : undefined}
                                        style={psActive ? { cursor: 'pointer' } : undefined}
                                      >
                                        <div
                                          className={`addon__check ${psActive ? 'addon__check--on' : ''}`}
                                          aria-hidden="true"
                                        >
                                          {psActive && <window.Check size={14}/>}
                                        </div>
                                        <div className="addon__main">
                                          <div className="addon__title-row">
                                            <div className="addon__title">Premium Sourcing</div>
                                            {psSelectedCount > 0 && (
                                              <span className="ps-group__pill">{psSelectedCount} selected</span>
                                            )}
                                          </div>
                                          <div className="addon__price-row">
                                            <span className="addon__price-main">{psHeadline}</span>
                                            <span className="addon__price-suffix">/prospect</span>
                                          </div>
                                          <p className="addon__desc">Pick which gated and niche sources to enable. We'll source GDPR-validated, fully enriched prospects from each one you tick.</p>
                                          {psCap && psCapInfo && (() => {
                                            const psIsFull = psCap.status === 'full';
                                            return (
                                              <div className={`addon__capacity capacity capacity--${psCap.status}`}>
                                                <div className="addon__capacity-label">
                                                  <span className="addon__capacity-title-row">
                                                    <span className="addon__capacity-title">Onboarding Availability:</span>
                                                    <HoverPortalTip wrapClassName="addon__capacity-info-wrap" tipClassName="addon__capacity-tip" placement="above"
                                                      tip={<><span className="addon__capacity-tip-head">Onboarding Availability</span><span className="addon__capacity-tip-body">To ensure we deliver exceptional results for every partner, our platform automatically monitors and forecasts our team&#x2019;s capacity in real time. We onboard a limited number of new clients each month to guarantee the high level of service and attention your business deserves. This status is updated daily based on our current capacity.</span><span className={`addon__capacity-tip-pill addon__capacity-tip-pill--${psIsFull ? 'full' : 'open'}`}>{psCapInfo.pillLabel}</span><span className="addon__capacity-tip-body">{psCapInfo.pillCopy}</span></>}
                                                    >
                                                      <button type="button" className="addon__capacity-info" onClick={(e) => e.stopPropagation()} aria-label="About onboarding availability" tabIndex={-1}>
                                                        <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><circle cx="12" cy="8" r="0.6" fill="currentColor"/></svg>
                                                      </button>
                                                    </HoverPortalTip>
                                                  </span>
                                                  <span className="addon__capacity-status">
                                                    {psCap.status === 'open' && <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true"><circle cx="8" cy="8" r="6.5"/><path d="M5 8.4 L7 10.2 L11 6"/></svg>}
                                                    {(psCap.status === 'limited' || psCap.status === 'nearly-full') && <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true"><circle cx="8" cy="8" r="6.5"/><path d="M8 5 L8 8.5"/><circle cx="8" cy="11" r="0.8" fill="currentColor"/></svg>}
                                                    {psIsFull && <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true"><circle cx="8" cy="8" r="6.5"/><line x1="5.5" y1="5.5" x2="10.5" y2="10.5"/><line x1="10.5" y1="5.5" x2="5.5" y2="10.5"/></svg>}
                                                    {psCapInfo.label}
                                                  </span>
                                                </div>
                                                <div className="addon__capacity-track"><window.CapacityVideo status={psCap.status} /></div>
                                                <div className="addon__capacity-copy">
                                                  {psIsFull && "Currently at full capacity. Select this add-on to join our waiting list, and we'll notify you when a spot opens."}
                                                  {psCap.status === 'nearly-full' && 'Nearly full, secure your spot before we move to a waiting list.'}
                                                  {psCap.status === 'limited' && 'Limited spots remaining, secure yours before we hit capacity.'}
                                                  {psCap.status === 'open' && 'Spots available. Schedule your consultation now to secure your place with our specialist team.'}
                                                </div>
                                              </div>
                                            );
                                          })()}
                                          <div className="ps-group__chips" onClick={(e) => e.stopPropagation()}>
                                            {_psAddons.map(a => {
                                              const on = selectedAddons.includes(a.id);
                                              const subName = (a.name || '').replace(/^Premium Sourcing:\s*/i, '').trim();
                                              const priceTxt = a.priceLabel || (typeof a.price === 'number' ? `£${a.price}/prospect` : '');
                                              const qVal = (addonQty && typeof addonQty[a.id] === 'number' && addonQty[a.id] >= 1) ? addonQty[a.id] : 1;
                                              const onChipKey = (e) => {
                                                if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleToggle(a.id); }
                                              };
                                              return (
                                                <div
                                                  key={a.id}
                                                  role="button"
                                                  tabIndex={0}
                                                  aria-pressed={on}
                                                  className={`ps-chip thin-glass-frame ${on ? 'ps-chip--on' : ''}`}
                                                  onClick={(e) => { e.stopPropagation(); handleToggle(a.id); }}
                                                  onKeyDown={(e) => { e.stopPropagation(); onChipKey(e); }}
                                                >
                                                  <div className="ps-chip__head">
                                                    <span className="ps-chip__check" aria-hidden="true">
                                                      {on && <window.Check size={11}/>}
                                                    </span>
                                                    <span className="ps-chip__body">
                                                      <span className="ps-chip__name">{subName}</span>
                                                      <span className="ps-chip__price">{priceTxt}</span>
                                                    </span>
                                                  </div>
                                                  {on && (
                                                    <div className="ps-chip__qty" onClick={(e) => e.stopPropagation()}>
                                                      <label className="ps-chip__qty-label" htmlFor={`ps-qty-${a.id}`}>How many prospects?</label>
                                                      <div className="ps-chip__qty-input-wrap">
                                                        <button
                                                          type="button"
                                                          className="ps-chip__qty-step"
                                                          onClick={(e) => { e.stopPropagation(); onSetAddonQty && onSetAddonQty(a.id, Math.max(1, qVal - 1)); }}
                                                          disabled={qVal <= 1}
                                                          aria-label="Decrease prospects"
                                                          tabIndex={-1}
                                                        >
                                                          <svg viewBox="0 0 16 16" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" aria-hidden="true"><path d="M4 8 L12 8"/></svg>
                                                        </button>
                                                        <input
                                                          id={`ps-qty-${a.id}`}
                                                          className="ps-chip__qty-input"
                                                          type="number"
                                                          min="1"
                                                          max="99999"
                                                          step="1"
                                                          value={qVal}
                                                          onClick={(e) => e.stopPropagation()}
                                                          onKeyDown={(e) => e.stopPropagation()}
                                                          onChange={(e) => {
                                                            const v = parseInt(e.target.value, 10);
                                                            if (Number.isFinite(v) && onSetAddonQty) onSetAddonQty(a.id, Math.max(1, Math.min(99999, v)));
                                                          }}
                                                          aria-label="Number of prospects"
                                                        />
                                                        <button
                                                          type="button"
                                                          className="ps-chip__qty-step"
                                                          onClick={(e) => { e.stopPropagation(); onSetAddonQty && onSetAddonQty(a.id, Math.min(99999, qVal + 1)); }}
                                                          aria-label="Increase prospects"
                                                          tabIndex={-1}
                                                        >
                                                          <svg viewBox="0 0 16 16" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" aria-hidden="true"><path d="M8 4 L8 12 M4 8 L12 8"/></svg>
                                                        </button>
                                                      </div>
                                                    </div>
                                                  )}
                                                </div>
                                              );
                                            })}
                                          </div>
                                        </div>
                                      </div>
                                    );
                                  })()}
                      </div>
                    </div>
                  )}
                </React.Fragment>
              ))}
            </div>
          ) : (
            <div className="svc__addons-grid">
              {_visibleAddons.map(a => renderAddon(
                a,
                selectedAddons.includes(a.id),
                () => handleToggle(a.id),
                addonQty?.[a.id],
                (v) => onSetAddonQty && onSetAddonQty(a.id, v),
                _recSet,
                overseasCountries,
                onSetOverseasCountries,
                intentId
              ))}
            </div>
          )}

                  </div>
      )}
    </div>
  );
}

// ── CAPACITY VIDEO ── plays the matching webm for the given availability status.
// `status` is one of 'open' | 'limited' | 'nearly-full' | 'full'. The video
// animates the fill in once when it scrolls into view, then we pause on the
// final frame (so the bar reads as a static end-state, not a looping animation
// distracting the eye).
function CapacityVideo({ status }) {
  const ref = window.React.useRef(null);
  const src = `assets/capacity-${status}.webm`;
  const playFromStart = window.React.useCallback(() => {
    const v = ref.current;
    if (!v) return;
    try {
      v.currentTime = 0;
      v.play().catch(() => {});
    } catch (e) {}
  }, []);
  // Reload (back to frame 0) whenever the status changes so the new video starts fresh.
  window.React.useEffect(() => {
    const v = ref.current;
    if (!v) return;
    try { v.load(); } catch (e) {}
  }, [status]);
  // Play whenever the element becomes visible in the viewport. Replays each
  // time it re-enters so users who scroll back to it still see the animation.
  //
  // Also fire an unconditional play attempt on mount + after a short tick.
  // Mobile Safari sometimes rejects the initial autoplay attempt silently
  // (even with muted+playsInline) before the IntersectionObserver attaches,
  // and the first IO callback may not fire if the element is already in
  // view at mount-time. Belt + braces.
  window.React.useEffect(() => {
    const v = ref.current;
    if (!v) return;
    // Eager play on mount, covers mobile Safari where autoplay can be flaky.
    playFromStart();
    const tickT = setTimeout(playFromStart, 120);

    if (typeof IntersectionObserver === 'undefined') {
      return () => clearTimeout(tickT);
    }
    const io = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting && entry.intersectionRatio > 0.2) {
          playFromStart();
        }
      });
    }, { threshold: [0, 0.2, 0.6, 1] });
    io.observe(v);
    return () => { clearTimeout(tickT); io.disconnect(); };
  }, [playFromStart, status]);
  const onEnded = (e) => {
    // Pause to hold on the final rendered frame. (Setting currentTime = duration
    // gets snapped back to 0 by Chromium, so we just pause instead.)
    try { e.currentTarget.pause(); } catch (err) {}
  };
  return (
    <video
      ref={ref}
      className={`capacity-video capacity-video--${status}`}
      src={src}
      autoPlay
      muted
      playsInline
      preload="auto"
      onEnded={onEnded}
      aria-hidden="true"
    />
  );
}
window.CapacityVideo = CapacityVideo;

// ── CAPACITY BAR ── small status pill + filled track + copy
function CapacityBar({ status, fill, copy }) {
  const pct = Math.max(0, Math.min(1, fill || 0)) * 100;
  return (
    <div className={`capacity capacity--${status}`}>
      <div className="capacity__head">
        <span className="capacity__label">Onboarding Availability:</span>
        <span className="capacity__pill">
          <span className="capacity__pill-dot" aria-hidden="true" />
          {status === 'open' ? 'Open' : status === 'limited' ? 'Limited' : 'Full'}
        </span>
      </div>
      <div className="capacity__track" role="progressbar" aria-valuenow={Math.round(pct)} aria-valuemin="0" aria-valuemax="100">
        <CapacityVideo status={status} />
      </div>
      <div className="capacity__copy">{copy}</div>
    </div>
  );
}

// ── CHANNELS PANEL ── shown below tiers; informational or selectable
function ChannelsPanel({ service, selection, onToggleChannel, onSetAdSpend, onSetLinkedinProfiles, serviceActive, currentTierId }) {
  const cfg = window.SERVICE_CHANNELS[service.id];
  const cap = window.SERVICE_CAPACITY[service.id];
  if (!cfg) return null;

  // What channels to render?
  let renderedIds = [];
  let max = null;
  if (cfg.mode === 'included') {
    renderedIds = cfg.perTier?.[currentTierId] || cfg.perTier?.starter || [];
  } else if (cfg.mode === 'tier') {
    renderedIds = cfg.tierChannels?.[currentTierId] || cfg.tierChannels?.scale || cfg.tierChannels?.grow || cfg.tierChannels?.starter || [];
  } else {
    // select mode, options can be a flat array or per-tier object
    renderedIds = window.channelsForTier
      ? window.channelsForTier(service.id, currentTierId)
      : (Array.isArray(cfg.options) ? cfg.options : []);
    max = cfg.max?.[currentTierId];
  }

  const isInteractive = cfg.mode === 'select';
  const isReadOnly = cfg.mode === 'included' || cfg.mode === 'tier';
  const selectedChannels = selection?.channels || [];
  const hasOverflow = renderedIds.includes('more');

  // Default selection seeding: when service is first activated and user hasn't picked yet,
  // pre-select the first `max` options so a useful state is shown.
  const effectiveSelected = (() => {
    if (cfg.mode === 'included' || cfg.mode === 'tier') return renderedIds;
    return selectedChannels;
  })();

  const helpText = isInteractive ? cfg.helpActive : (cfg.helpInactive || cfg.helpActive);

  return (
    <div className={`channels-panel glass-frame ${isInteractive ? 'channels-panel--select' : 'channels-panel--info'}`}>
      <div className="channels-panel__main">
        <div className="channels-panel__head">
          <div className="channels-panel__title">
            {cfg.mode === 'select' ? (renderedIds.length === 1 ? 'Channel' : 'Channels') : 'Channels'}
            {isInteractive && typeof max === 'number' && max < renderedIds.length && (
              <span className="channels-panel__limit"> · pick up to {max}</span>
            )}
          </div>
          <div className="channels-panel__help">{helpText}</div>
        </div>

        <div className="channels-panel__grid">
          {renderedIds.map(cid => {
            const opt = window.CHANNEL_OPTIONS[cid];
            if (!opt) return null;
            const isSelected = effectiveSelected.includes(cid);
            const cls = [
              'channel-tile',
              isSelected ? 'channel-tile--on' : '',
              isReadOnly ? 'channel-tile--readonly' : '',
              cid === 'more' ? 'channel-tile--more' : '',
            ].filter(Boolean).join(' ');
            const tileBtn = (
              <button
                key={cid}
                type="button"
                className={cls}
                onClick={() => {
                  if (isReadOnly) return;
                  onToggleChannel(cid, max);
                }}
                aria-pressed={isInteractive ? isSelected : undefined}
                aria-label={opt.label}
              >
                <span className="channel-tile__icon" style={{ color: '#0B1838' }}>
                  {opt.icon}
                </span>
                {isSelected && (
                  <span className="channel-tile__check" aria-hidden="true">
                    <window.Check size={10} />
                  </span>
                )}
                {cid === 'more' && <span className="channel-tile__label">Other<br/>Channels</span>}
              </button>
            );
            // 2026-05-30: every channel with a tip gets a styled hover tooltip
            // (name + one-line description). Replaces the old native title that
            // surfaced the raw "Phone" OS tooltip the CEO flagged.
            if (opt.tip) {
              return (
                <HoverPortalTip
                  key={cid}
                  wrapClassName="channel-tile__tip-wrap"
                  tipClassName="channel-tile__tip"
                  placement="above"
                  tip={<span><strong>{opt.tipTitle || opt.label}</strong><br/>{opt.tip}</span>}
                >
                  {tileBtn}
                </HoverPortalTip>
              );
            }
            return tileBtn;
          })}
        </div>

        {/* 2026-05-22: LinkedIn profile-count stepper, Sales only. When the
            user picks LinkedIn as an outbound channel, ask how many LinkedIn
            profiles we should run for them. Each profile = £399/mo (added
            to monthly total via linesForRail). Default 1, range 1-10. */}
        {service.id === 'sales'
          && Array.isArray(selection?.channels)
          && selection.channels.includes('linkedin')
          && (() => {
            const profiles = Math.max(1, Math.min(10, Number(selection?.linkedinProfiles) || 1));
            const total = profiles * 399;
            return (
              <div className="channels-panel__extras ch-li-extras">
                <div className="ch-li-extras__row">
                  <span className="ch-li-extras__icon" style={{ color: '#0A66C2' }}>
                    {window.CHANNEL_OPTIONS?.linkedin?.icon}
                  </span>
                  <span className="ch-li-extras__label">
                    How many LinkedIn profiles do you want us to run?
                  </span>
                  <HoverPortalTip
                    wrapClassName="ch-li-extras__info-tip-wrap"
                    tipClassName="dis-tip dis-tip--above"
                    placement="above"
                    tip={<span>Each LinkedIn profile we run for outbound is an additional managed seat in our SDR pod. Adds <strong>£399/mo</strong> per profile.</span>}
                  >
                    <window.InfoIcon className="ch-li-extras__info" />
                  </HoverPortalTip>
                  <span className="ch-li-extras__stepper">
                    <button
                      type="button"
                      className="ch-li-extras__btn"
                      onClick={() => onSetLinkedinProfiles && onSetLinkedinProfiles(profiles - 1)}
                      disabled={profiles <= 1}
                      aria-label="Decrease LinkedIn profiles"
                    >–</button>
                    <span className="ch-li-extras__count"><strong>{profiles}</strong> profile{profiles === 1 ? '' : 's'}</span>
                    <button
                      type="button"
                      className="ch-li-extras__btn"
                      onClick={() => onSetLinkedinProfiles && onSetLinkedinProfiles(profiles + 1)}
                      disabled={profiles >= 10}
                      aria-label="Increase LinkedIn profiles"
                    >+</button>
                  </span>
                  <span className="ch-li-extras__price">
                    {profiles} × £399/mo · <strong>+£{total.toLocaleString('en-GB')}/mo</strong>
                  </span>
                </div>
              </div>
            );
          })()}

          </div>

      {cap && (
        <div className="channels-panel__cap">
          <CapacityBar status={cap.status} fill={cap.fill} copy={cap.copy} />
        </div>
      )}

      {/* Paid Ads, Estimated Daily Budget presets + custom input.
            Six tiles: Just Testing / Foundations / Growth / Scale-up / Aggressive
            / Custom. Surfaces a budget-mismatch warning when daily × 30 differs
            from the user's q4 monthly budget. */}
        {cfg.extras?.dailyAdSpend && (() => {
          const PRESETS = [
            { id: 'just-testing', label: 'Just testing',  range: '£25-50/day',   monthly: '£750-1.5k/mo',  blurb: '1 channel · learn the basics',       value: 35,  lo: 25,  hi: 50 },
            { id: 'foundations',  label: 'Foundations',   range: '£50-100/day',  monthly: '£1.5k, 3k/mo',   blurb: '1 channel · escape learning',        value: 75,  lo: 50,  hi: 100 },
            { id: 'growth',       label: 'Growth',        range: '£100-200/day', monthly: '£3k, 6k/mo',     blurb: '2 channels · meaningful scale',      value: 150, lo: 100, hi: 200 },
            { id: 'scale-up',     label: 'Scale-up',      range: '£200-500/day', monthly: '£6k, 15k/mo',    blurb: '2-3 channels · serious testing',     value: 350, lo: 200, hi: 500 },
            { id: 'aggressive',   label: 'Aggressive',    range: '£500+/day',    monthly: '£15k+/mo',      blurb: 'Multi-channel · full-funnel',        value: 750, lo: 500, hi: Infinity },
          ];
          const qualifier = window.__lastBuildPageState?.qualifier;
          const currentSpend = selection?.dailyAdSpend;
          // Which preset is currently "active" (current spend falls in its range)?
          const activePreset = (currentSpend != null && currentSpend > 0)
            ? PRESETS.find(p => currentSpend >= p.lo && currentSpend <= p.hi)
            : null;
          const isCustom = currentSpend != null && currentSpend > 0 && !activePreset;
          // Budget warning: compare daily × 30 to user's q4 monthly budget midpoint.
          const Q4_MIDPOINT = { 'sub500': 300, '500-2.5k': 1500, '2.5k-7.5k': 5000, '7.5k-20k': 13750, '20k-50k': 35000, '50kplus': 75000 };
          const budgetMid = qualifier && Q4_MIDPOINT[qualifier.q4];
          let warning = null;
          if (budgetMid && currentSpend != null && currentSpend > 0) {
            const monthly = currentSpend * 30;
            const pct = Math.round((monthly / budgetMid) * 100);
            if (monthly > budgetMid * 1.4) {
              warning = { kind: 'over', text: `£${currentSpend}/day = £${monthly.toLocaleString()}/mo in media spend · ${pct}% over your stated £${budgetMid.toLocaleString()}/mo budget` };
            } else if (monthly < budgetMid * 0.5) {
              warning = { kind: 'under', text: `£${currentSpend}/day = £${monthly.toLocaleString()}/mo in media spend · well under your stated £${budgetMid.toLocaleString()}/mo budget` };
            }
          }
          return (
            <div className="channels-panel__extras pa-spend">
              <div className="pa-spend__head">
                <span className="pa-spend__eyebrow">Estimated daily budget</span>
                <HoverPortalTip
                  wrapClassName="channels-panel__extra-info-tip-wrap"
                  tipClassName="dis-tip dis-tip--above"
                  placement="above"
                  tip={<span>Your daily ad spend is paid directly to the ad platforms (Meta, Google, etc.). It sits on top of GoGorilla&rsquo;s management fee.</span>}
                >
                  <window.InfoIcon className="channels-panel__extra-info" />
                </HoverPortalTip>
              </div>
              <div className="pa-spend__grid" role="radiogroup" aria-label="Estimated daily budget">
                {PRESETS.map(p => {
                  const isOn = activePreset?.id === p.id;
                  return (
                    <button
                      key={p.id}
                      type="button"
                      role="radio"
                      aria-checked={isOn}
                      className={`pa-spend__tile thin-glass-frame ${isOn ? 'pa-spend__tile--on' : ''}`}
                      onClick={() => onSetAdSpend(p.value)}
                    >
                      <span className="pa-spend__tile-label">{p.label}</span>
                      <span className="pa-spend__tile-range">{p.range}</span>
                      <span className="pa-spend__tile-monthly">{p.monthly}</span>
                      <span className="pa-spend__tile-blurb">{p.blurb}</span>
                      {/* 2026-05-29: always render the tick wrapper so an
                          empty-state checkbox affordance is visible on every
                          tile (matches the canonical premium-check pattern
                          used on role tiles, day-rate cards, PS chips). Check
                          icon only appears when on. */}
                      <span className={`pa-spend__tile-tick ${isOn ? 'is-on' : ''}`} aria-hidden="true">
                        {isOn && <window.Check size={12} />}
                      </span>
                    </button>
                  );
                })}
                <button
                  type="button"
                  role="radio"
                  aria-checked={isCustom}
                  className={`pa-spend__tile thin-glass-frame pa-spend__tile--custom ${isCustom ? 'pa-spend__tile--on' : ''}`}
                  onClick={() => {
                    if (!isCustom) onSetAdSpend(35);
                    requestAnimationFrame(() => {
                      const el = document.querySelector('.pa-spend__custom-input');
                      if (el) el.focus();
                    });
                  }}
                >
                  <span className="pa-spend__tile-label">Custom</span>
                  {/* 2026-05-29 v2: inline £/day input always visible on the
                      Custom tile. Empty (placeholder "0") reads as the
                      affordance when no value is set. Typing a number fires
                      onSetAdSpend, which auto-activates Custom mode because
                      the new value won't match any preset. */}
                  <span
                    className="pa-spend__tile-custom-input-wrap"
                    onClick={(e) => e.stopPropagation()}
                  >
                    <span className="pa-spend__custom-prefix">£</span>
                    <input
                      type="number"
                      inputMode="numeric"
                      min="0"
                      placeholder="0"
                      className="pa-spend__custom-input"
                      value={isCustom ? (selection?.dailyAdSpend ?? '') : ''}
                      onChange={(e) => onSetAdSpend(e.target.value === '' ? null : Math.max(0, Number(e.target.value)))}
                      onClick={(e) => e.stopPropagation()}
                      onKeyDown={(e) => e.stopPropagation()}
                      onFocus={(e) => e.stopPropagation()}
                      aria-label="Daily ad spend in pounds"
                    />
                    <span className="pa-spend__custom-suffix">/day</span>
                  </span>
                  <span className="pa-spend__tile-blurb">Set a specific amount</span>
                  {/* Same empty/on check pattern as the preset tiles. */}
                  <span className={`pa-spend__tile-tick ${isCustom ? 'is-on' : ''}`} aria-hidden="true">
                    {isCustom && <window.Check size={12} />}
                  </span>
                </button>
              </div>
              {currentSpend != null && currentSpend > 0 && (
                <div className="pa-spend__current">
                  Currently set at <strong>£{currentSpend}/day</strong>
                </div>
              )}
              {warning && (
                <div className={`pa-spend__warning pa-spend__warning--${warning.kind}`} role="note">
                  {warning.text}
                </div>
              )}
            </div>
          );
        })()}
    
    </div>
  );
}

// ── ROLES PANEL ── per-tier role catalogue (Dedicated Resources). Visually mirrors
// the channels panel: a heading, helper copy, and a grid of selectable tiles. Each
// tile is text-only (role name + price label) since these are job titles, not logos.
function RolesPanel({ service, selection, currentTierId, onToggleRole }) {
  const tierRoles = service.roles?.[currentTierId];
  if (!tierRoles || tierRoles.length === 0) return null;
  const selectedRoles = selection?.roles || [];
  const isPartTime = currentTierId === 'parttime';
  const heading = isPartTime ? 'Part-time roles' : 'Full-time roles';
  const help = isPartTime
    ? 'Choose from the following part-time roles. Each one is added to your build.'
    : 'Choose from the following full-time roles. We\'ll tailor pricing to your scope.';

  return (
    <div className="channels-panel channels-panel--select roles-panel">
      <div className="channels-panel__main">
        <div className="channels-panel__head">
          <div className="channels-panel__title">
            Roles
            <span className="channels-panel__limit"> · select any</span>
          </div>
          <div className="channels-panel__help">{help}</div>
        </div>

        <div className="roles-panel__grid">
          {tierRoles.map(role => {
            const isSelected = selectedRoles.includes(role.id);
            const cls = ['role-tile', isSelected ? 'role-tile--on' : ''].filter(Boolean).join(' ');
            return (
              <button
                key={role.id}
                type="button"
                className={cls}
                onClick={() => onToggleRole(role.id)}
                aria-pressed={isSelected}
              >
                <span className="role-tile__check" aria-hidden="true">
                  {isSelected && <window.Check size={12} />}
                </span>
                <span className="role-tile__name">{role.name}</span>
                <span className="role-tile__price">{role.priceLabel}</span>
              </button>
            );
          })}
        </div>
      </div>
    </div>
  );
}

// ── SERVICE BLOCK, Tiers always visible, addons collapsible ──

// ── "View Pricing Page" badge ────────────────────────────────────────────
// A subtle text-link badge that opens the public pricing page for the
// service in a new tab. Replaces the older (i) info icon, users now see
// the affordance clearly without needing to hover.
function PricingTip({ href, label }) {
  if (!href) return null;
  return (
    <a
      href={href}
      target="_blank"
      rel="noopener noreferrer"
      className="svc__pricing-link"
      aria-label={label}
      onClick={e => e.stopPropagation()}
    >
      <span className="svc__pricing-link-text">View pricing page</span>
      <svg
        xmlns="http://www.w3.org/2000/svg"
        width="10"
        height="10"
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        strokeWidth="2.4"
        strokeLinecap="round"
        strokeLinejoin="round"
        aria-hidden="true"
        className="svc__pricing-link-arrow"
      >
        <path d="M7 17 L17 7" />
        <path d="M8 7 L17 7 L17 16" />
      </svg>
    </a>
  );
}

// ── Reusable Frame component (from GoGorilla design guide) ──────────
// Wraps any positioned container with a 9-slice glass or metal border
// overlay. Drop <Frame variant="glass" /> (or "metal") as the FIRST
// child of any element with position:relative + overflow:hidden, and
// the corner/edge WebP slices auto-scale to fit. Purely decorative
// (aria-hidden, pointer-events:none).
//
// Host card setup (CSS):
//   .my-card { position: relative; overflow: hidden; }
//   .my-card > *:not(.gg-frame) { position: relative; z-index: 1; }
//
// Or use the .gg-frame-card convenience class on the host.
function Frame({ variant = 'glass', slice }) {
  const style = slice ? { '--gg-frame-slice': typeof slice === 'number' ? `${slice}px` : slice } : undefined;
  return (
    <span
      className={`gg-frame gg-frame--${variant}`}
      style={style}
      aria-hidden="true"
    />
  );
}
window.Frame = Frame;

// ── Generic portal info tooltip (i) ──────────────────────────────────────
// Drop-in helper for adding an explanatory tooltip to ANY UI element that
// users may need help understanding. Renders a small (i) icon that, on hover/
// focus, shows a white-card tooltip portal-rendered into document.body so it
// escapes parent stacking contexts. Used for commit-bar label, promo link,
// bundle tracker, etc.
function InfoTip({ head, body, placement }) {
  const [tipPos, setTipPos] = uS(null);
  const anchorRef = uR(null);
  const showTip = () => {
    if (!anchorRef.current) return;
    const r = anchorRef.current.getBoundingClientRect();
    // Clamp x so the centered tooltip never spills off-screen. The CSS applies
    // translateX(-50%) so anchor x is the tooltip centre. Half of max-width
    // (260px) + a safety padding gives our left/right margins.
    const vw = window.innerWidth || 1024;
    const halfTip = 140;
    const PAD = 12;
    const rawX = r.left + r.width / 2;
    const minX = halfTip + PAD;
    const maxX = vw - halfTip - PAD;
    const clampedX = Math.min(Math.max(rawX, minX), maxX);
    setTipPos({
      x: clampedX,
      caretShift: rawX - clampedX, // px to nudge the caret back toward the icon
      y: placement === 'below' ? r.bottom : r.top,
    });
  };
  const hideTip = () => setTipPos(null);
  return (
    <>
      <button
        ref={anchorRef}
        type="button"
        className="info-tip-icon"
        aria-label={head || 'More info'}
        onMouseEnter={showTip}
        onMouseLeave={hideTip}
        onFocus={showTip}
        onBlur={hideTip}
        onClick={e => e.preventDefault()}
        tabIndex={0}
      >
        <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
      </button>
      {tipPos && ReactDOM.createPortal(
        <span
          className={`info-tip-portal info-tip-portal--${placement || 'above'}`}
          role="tooltip"
          style={{
            left: tipPos.x,
            top: tipPos.y,
            '--info-tip-caret-shift': `${tipPos.caretShift || 0}px`,
          }}
        >
          {head && <span className="info-tip-portal__head">{head}</span>}
          {body && <span className="info-tip-portal__body">{body}</span>}
        </span>,
        document.body
      )}
    </>
  );
}

// ── Portal tooltip for summary waitlist pill ─────────────────────────────
// Same portal pattern as PricingTip, escapes .summary__list overflow-y:auto
// and .summary isolation:isolate stacking context.
function WaitlistTip() {
  const [tipPos, setTipPos] = uS(null);
  const pillRef = uR(null);

  const showTip = () => {
    if (!pillRef.current) return;
    const r = pillRef.current.getBoundingClientRect();
    setTipPos({ x: r.left, y: r.top + r.height / 2 });
  };
  const hideTip = () => setTipPos(null);

  return (
    <>
      <span
        ref={pillRef}
        className="summary__line-waitlist-pill"
        onMouseEnter={showTip}
        onMouseLeave={hideTip}
        onFocus={showTip}
        onBlur={hideTip}
        tabIndex={0}
        role="button"
        aria-label="On the waiting list, hover for details"
      >
        <span className="summary__line-waitlist-dot" aria-hidden="true" />
        Waiting list
      </span>
      {tipPos && ReactDOM.createPortal(
        <span
          className="waitlist-tip-portal"
          role="tooltip"
          style={{ left: tipPos.x, top: tipPos.y }}
        >
          <span className="waitlist-tip-portal__head">On the waiting list</span>
          <span className="waitlist-tip-portal__body">This service is full. Your spot is reserved, and we'll notify you by email when capacity opens. Your plan is saved and no charge applies in the meantime.</span>
          <span className="waitlist-tip-portal__meta">Priority given to clients with 2+ active services.</span>
        </span>,
        document.body
      )}
    </>
  );
}

// ── Per-service pricing page URLs ────────────────────────────────────────
const SERVICE_PRICING_URLS = {
  'sales':           'https://www.gogorilla.com/pricing',
  'paid-ads':        'https://www.gogorilla.com/pricing/paid-advertising#plans',
  'email':           'https://www.gogorilla.com/pricing/email-marketing#plans',
  'smm':             'https://www.gogorilla.com/pricing/smm#plans',
  'motion':          'https://www.gogorilla.com/pricing/3d-animation#plans',
  'content':         'https://www.gogorilla.com/pricing/content-creation#plans',
  'dedicated-pt':    'https://www.gogorilla.com/pricing/part-time-dedicated-resources#plans',
  'dedicated-ft':    'https://www.gogorilla.com/pricing/part-time-dedicated-resources#plans',
  'whitelabel':      'https://www.gogorilla.com/pricing/white-label#plans',
  'founders-portal': 'https://www.gogorilla.com/pricing/founders-portal#plans',
  'fundraising':     'https://www.gogorilla.com/pricing/fundraising-support#plans',
};


// ── GORILLA MATRIX, two-sided financial incentives strip ──
function GorillaMatrix({ incentives }) {
  if (!incentives || incentives.length === 0) return null;
  return (
    <div className="gm-strip">
      <span className="gm-strip__label">
        GorillaMatrix<sup>®</sup> two-sided financial incentives
      </span>
      <div className="gm-strip__items">
        {incentives.map((item, i) => (
          <span key={i} className="gm-item">
            <img
              src={`assets/badges/${item.type}.webp`}
              alt={item.type}
              className="gm-item__badge-img"
            />
            <span className="gm-item__label">{item.label}</span>
          </span>
        ))}
      </div>
    </div>
  );
}

// ── MONTHLY LEAD VOLUME widget (SDG single-source variant) ──────────────────
// Renders inside the SDG ServiceBlock after the channels picker. Single source
// Flat £4/lead beyond the included tier volume. 750 leads/mo free
// per tier; slider scales from 750 to 5,000. Two radio cards toggle the
// lead-source mode: "We source the leads" (default) or "You bring your own
// list" (BYOL, also adds the existing 'byol' add-on for the 15% retainer
// discount). In BYOL mode the slider + breakdown hide and a note replaces
// them, per spec §9 option (a).
function MonthlyLeadVolume({ selection, tierId, onSetMode, onSetLeads, onSetByolQuality }) {
  const mode = selection?.leadSourceMode || 'we-source';
  // 2026-05-26: tier-aware floor. Starter 750, Grow 1,000, Scale 1,250 —
  // matches the prospects/mo number on the tier card. Slider min, displayed
  // leads, and "included with tier" label all key off this.
  const tierIncluded = (window.leadIncludedForTier ? window.leadIncludedForTier(tierId) : 750);
  const leads = Math.max(tierIncluded, Math.min(5000, selection?.monthlyLeads || tierIncluded));
  const { additional, cost } = (window.computeAdditionalLeadCost || (() => ({ additional: 0, cost: 0 })))(leads, tierId);
  const leadRate = (tierId === 'starter') ? 2 : 4;
  const fmtN = (n) => n.toLocaleString('en-GB');

  return (
    <div className="lead-vol glass-frame">
      <div className="lead-vol__head">
        <div className="lead-vol__title">
          Monthly lead volume
          <HoverPortalTip
            wrapClassName="lead-vol__title-info-wrap"
            tipClassName="dis-tip dis-tip--above"
            placement="above"
            tip={<span>{fmtN(tierIncluded)} leads/month included with your tier. Add more at a flat <strong>£{leadRate}/lead</strong> beyond the included volume.{tierId === 'starter' && <> Starter has a reduced <strong>£2/lead</strong> rate to help you get started.</>}</span>}
          >
            <button type="button" className="lead-vol__info" aria-label="About monthly lead volume" tabIndex={-1}>
              <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true">
                <circle cx="8" cy="8" r="6.5"/>
                <line x1="8" y1="11" x2="8" y2="7" strokeLinecap="round"/>
                <circle cx="8" cy="5" r="0.5" fill="currentColor"/>
              </svg>
            </button>
          </HoverPortalTip>
        </div>
        <div className="lead-vol__count">
          {fmtN(leads)} <span className="lead-vol__count-suffix">leads / month</span>
        </div>
      </div>

      {/* Source-mode radio cards */}
      <div className="lead-vol__sources" role="radiogroup" aria-label="Lead source mode">
        <button
          type="button"
          role="radio"
          aria-checked={mode === 'we-source'}
          className={`lead-vol__src thin-glass-frame ${mode === 'we-source' ? 'is-on' : ''}`}
          onClick={() => onSetMode('we-source')}
        >
          <span className={`lead-vol__src-radio ${mode === 'we-source' ? 'is-on' : ''}`} aria-hidden="true">
            {mode === 'we-source' && <window.Check size={14}/>}
          </span>
          <div className="lead-vol__src-body">
            <div className="lead-vol__src-title">
              We source the leads
              {mode === 'we-source' && <span className="lead-vol__src-badge lead-vol__src-badge--default">DEFAULT</span>}
            </div>
            <div className="lead-vol__src-desc">
              Premium databases, GDPR-compliant. Feeds the SDR cadence and meeting forecast.
            </div>
          </div>
        </button>

        <button
          type="button"
          role="radio"
          aria-checked={mode === 'byol'}
          className={`lead-vol__src thin-glass-frame ${mode === 'byol' ? 'is-on' : ''}`}
          onClick={() => {
            onSetMode('byol');
            /* 2026-05-29: BYOL mode reveals the list-quality selector
               (.lead-vol__byol-quality) which mounts conditionally.
               Scroll-to-next so the user sees the next required input. */
            _scrollToNext('.lead-vol__byol-quality');
          }}
        >
          <span className={`lead-vol__src-radio ${mode === 'byol' ? 'is-on' : ''}`} aria-hidden="true">
            {mode === 'byol' && <window.Check size={14}/>}
          </span>
          <div className="lead-vol__src-body">
            <div className="lead-vol__src-title">
              You bring your own list
              <span className="lead-vol__src-badge lead-vol__src-badge--off">5-15% OFF</span>
            </div>
            <div className="lead-vol__src-desc">
              Already have a list? We enrich, dedupe, verify and run the cadence on it.
            </div>
          </div>
        </button>
      </div>

      {mode === 'we-source' && (
        <p className="lead-vol__hint">
          Your tier includes <strong>{fmtN(tierIncluded)} leads/mo at no extra cost</strong>. Add more at <strong>£{leadRate}/lead</strong>.
        </p>
      )}

      {mode === 'we-source' && (
        <>
          <div className="lead-vol__slider-wrap">
            <input
              type="range"
              min={tierIncluded}
              max="5000"
              step="100"
              value={leads}
              onChange={(e) => onSetLeads(Number(e.target.value))}
              aria-label="Monthly lead volume"
              className="lead-vol__slider"
              style={{ '--fill-pct': `${((leads - tierIncluded) / Math.max(1, 5000 - tierIncluded)) * 100}%` }}
            />
            <div className="lead-vol__slider-ticks" aria-hidden="true">
              <span>{fmtN(tierIncluded)}</span>
              <span>{fmtN(Math.round((tierIncluded + 5000) / 2 / 100) * 100)}</span>
              <span>5,000</span>
            </div>
          </div>

          <div className="lead-vol__breakdown">
            <div className="lead-vol__row">
              <span className="lead-vol__row-label">Included with tier</span>
              <span className="lead-vol__row-val lead-vol__row-val--free">{fmtN(tierIncluded)} leads · free</span>
            </div>
            {additional > 0 && (
              <div className="lead-vol__row">
                <span className="lead-vol__row-label">+ Additional leads</span>
                <span className="lead-vol__row-val">+{fmtN(additional)} leads · £{leadRate}/lead · £{fmtN(cost)}</span>
              </div>
            )}
          </div>
        </>
      )}

      {mode === 'byol' && (() => {
        const quality = selection?.byolListQuality || 'fully-enriched';
        return (
          <>
            <div className="lead-vol__byol-note" role="note">
              Bring your own list and save a flat 15% on your demand-generation retainer. To qualify, your list must include verified companies and contacts with verified email addresses and mobile numbers. Company-only or low-quality lists do not qualify; we will source the contacts for you instead.
            </div>
            <div className="lead-vol__byol-quality-single" role="note" style={{display:'flex',alignItems:'center',justifyContent:'space-between',gap:'0.75rem',marginTop:'0.5rem'}}>
              <span className="lead-vol__byol-quality-title">Qualifying verified list</span>
              <span className="lead-vol__byol-quality-pct">&minus;15%</span>
            </div>
            <div className="lead-vol__byol-foot" role="note">
              List quality is confirmed at onboarding. If it does not meet the threshold, we will let you know before any rate change applies.
            </div>
          </>
        );
      })()}
    </div>
  );
}
window.MonthlyLeadVolume = MonthlyLeadVolume;


function ServiceBlock({ service, selection, pendingCommitId, onSelect, onTier, onSetCommit, onToggleAddon, onSetAddonQty, onToggleChannel, onSetAdSpend, onToggleRole, onSetRoleConfig, onSetDedicatedStep, onTogglePayUpfront, onSetLeadSourceMode, onSetMonthlyLeads, onSetByolListQuality, agyMult, addonsDefaultOpen, qualifier, intentId, clientTypeId, onSetLinkedinProfiles, onSetOverseasCountries }) {
  const active = !!selection;
  const tierId = selection?.tier || null;
  const selectedAddons = selection?.addons || [];
  const addonQty = selection?.addonQty || {};
  const tier = window.findTier(service, tierId);
  // Commit options for this service (e.g. ['3','6','12']). Null if it doesn't have multi-commit pricing.
  const commitOpts = window.commitsFor(service);
  const defaultCommitId = '12';
  // Read order: existing selection > pending choice (made before any tier picked) > service default.
  const commitId = selection?.commitId || pendingCommitId || defaultCommitId;
  const commitLabel = window.commitLabelFor(service, commitId);

  // Build a context-filtered service for AddonsBlock, removes add-ons that
  // are Not Available for the current tier + commitment and adjusts prices.
  const _ctxSvc = window.getAddonsForContext
    ? { ...service, addons: window.getAddonsForContext(service, tierId || 'grow', commitId) }
    : service;
  // #82: Fundraising add-ons show the pricing-page per-tier discount on the card
  // (Starter 20% / Grow 25% / Scale 30%) with the RRP struck through. Display only;
  // the cart total applies the same discount (largest-wins) separately.
  const filteredSvc = (() => {
    if (service.id !== 'fundraising' || !window.fundraisingAddonMult) return _ctxSvc;
    const _m = window.fundraisingAddonMult(tierId || 'grow');
    if (_m >= 1) return _ctxSvc;
    return { ..._ctxSvc, addons: _ctxSvc.addons.map(a =>
      (a.discountable && typeof a.price === 'number' && !a.free && !a.custom)
        ? { ...a, price: Math.round(a.price * _m), _wasPrice: a.price }
        : a) };
  })();

  // Pay-upfront multiplier, when the user has toggled Pay upfront on for
  // this service, every displayed tier price drops 10%. The discount also
  // flows into the sidebar / breakdown via payUpfrontSavings in Summary
  // (this multiplier is the visual mirror so the tier cards stay in sync).
  const _upfrontMult = (selection?.payUpfront) ? 0.9 : 1;
  const priceLabelFor = (tid) => {
    const p = window.priceFor(service, tid, commitId);
    if (p.custom) return p.label;
    const val = p.value * agyMult * _upfrontMult;
    return window.fmt(Math.round(val));
  };

  // Lowest-tier price for header summary
  const startingPriceLabel = (() => {
    const firstTierId = window.tiersFor(service)[0]?.id || 'starter';
    const p = window.priceFor(service, firstTierId, commitId);
    if (p.custom) return p.label === 'Custom' ? 'Contact for pricing' : p.label;
    if (p.value === 0 && service.freeStarter) return 'Free';
    return window.fmt(Math.round(p.value * agyMult * _upfrontMult));
  })();

  const [cmpOpen, setCmpOpen] = React.useState(false);
  const handleTier = (tid) => {
    // Click same tier when service is active → remove the service
    if (active && tierId === tid) {
      onSelect(false);
      return;
    }
    // If service not yet added, picking a tier adds it
    if (!active) onSelect(true);
    onTier(tid);
    // 2026-05-29: re-enabled scroll-to-next after tier pick (was removed
    // in #345 due to a country-dropdown clipping side-effect that's now
    // fixed). Scrolls to the next REVEALED section inside the same
    // service card. Document order matches priority:
    //   1) .channels-panel  (SDG / Talent / Paid Ads — channel picker)
    //   2) .svc-margin      (white-label margin calculator)
    //   3) .svc__addons-disc (add-ons disclosure as fallback)
    // Scoped to [data-svc-id="..."] so other cards' channels/margins
    // don't accidentally win. Only fires on activation, not deactivation,
    // to avoid disorienting jumps when the user is toggling off.
    _scrollToNext(`[data-svc-id="${service.id}"] .channels-panel, [data-svc-id="${service.id}"] .svc-margin, [data-svc-id="${service.id}"] .svc__addons-disc`);
  };

  return (
    <div
      className={`svc svc--always ${active ? 'svc--active' : ''} ${(service.id === 'dedicated-pt' || service.id === 'dedicated-ft') ? 'svc--dedicated' : ''}`}
      data-svc-id={service.id}
    >
      <div className="svc__head">
        <div className="svc__icon"><img src={service.icon} alt="" /></div>
        <div className="svc__title-wrap">
          <div className="svc__cat">{service.category}</div>
          <div className="svc__title">
            {service.name}
            {(window.TIER_COMPARISON || {})[service.id] && (
              <HoverPortalTip
                wrapClassName="svc__title-cmp-tip-wrap"
                tipClassName="dis-tip dis-tip--above"
                placement="above"
                tip={<span>All <strong>{service.name}</strong> tiers side by side, features, pricing and quotas in one place.</span>}
              >
                <button
                  type="button"
                  className="svc__title-cmp-btn"
                  onClick={(e) => { e.stopPropagation(); setCmpOpen(true); }}
                  aria-label={`Compare all ${service.name} tiers`}
                >
                  <span className="svc__title-cmp-btn-text">Compare all tiers</span>
                  <span aria-hidden="true" className="svc__title-cmp-btn-arrow">↗</span>
                </button>
              </HoverPortalTip>
            )}
            {service.badge && <img src={`assets/badges/${service.badge}.webp`} alt="" className="svc__badge" />}
            {/* Service-header waiting list pill, surfaces tier-level waitlist
                state at the service level so visitors see capacity before they
                pick a tier. Driven by the same service.waitlistTiers list used
                by the per-tier styling. */}
            {Array.isArray(service.waitlistTiers) && service.waitlistTiers.length > 0 && (
              <HoverPortalTip
                wrapClassName="wl-tip-wrap svc__waitlist-pill-wrap"
                tipClassName="wl-tip wl-tip--above"
                placement="above"
                tip={<>
                  <span className="wl-tip__head">Waiting list</span>
                  <span className="wl-tip__body">Some plans in this service are currently at capacity. You can still add them to your quote. We confirm your spot once space opens.</span>
                  <span className="wl-tip__meta">No charge is added to your total until you're fully onboarded.</span>
                </>}
              >
                <span className="svc__waitlist-pill">
                  <span className="svc__waitlist-dot" aria-hidden="true" />
                  Waiting list
                </span>
              </HoverPortalTip>
            )}
          </div>
          <div className="svc__desc">{service.desc}</div>
        </div>
        <div className="svc__head-right">
          {/* Sprint 5: top-right RECOMMENDED badge removed per mockup. */}
          {/* Sprint 5: × close-button removed per mockup. */}
          {/* Sprint 5: compact commitment toggle in top-right corner. */}
          {(service.id !== 'dedicated-pt' && service.id !== 'dedicated-ft' && !service.oneTime) && (() => {
            const opts = (commitOpts && commitOpts.length > 1) ? commitOpts : window.COMMITMENTS;
            // §20, recommended commit length based on trading length + urgency.
            // Returns one of 'monthly' | '3' | '6' | '12'. Stays null when the
            // qualifier hasn't been answered.
            return (
              <div className="svc__commit-bar svc__commit-bar--corner" role="group" aria-label={`Minimum commitment for ${service.name}`}>
                {opts.map(opt => {
                  const on = String(commitId) === opt.id;
                  const saveTitle = opt.save > 0
                    ? `Save ${opt.save}% on every month vs. the shortest commitment.`
                    : undefined;
                  return (
                    <button
                      key={opt.id}
                      type="button"
                      className={`svc__commit-bar-btn ${on ? 'is-on' : ''}`}
                      onClick={(e) => {
                        e.stopPropagation();
                        if (onSetCommit) onSetCommit(opt.id);
                      }}
                      aria-pressed={on}
                    >
                      <span className="svc__commit-bar-text">{opt.months} mo</span>
                      {opt.save > 0 && (
                        <span className="svc__commit-bar-save">&minus;{opt.save}%</span>
                      )}
                      {/* Recommended-commit dot removed per latest spec - the save chips are sufficient signal. */}
                    </button>
                  );
                })}
              </div>
            );
          })()}
        </div>
      </div>

      {/* Sprint 3, S&DG 4-step stepper. Sales-only; visualises the configurator
          flow: Commitment → Tier → Channels → Add-ons. Steps light up as the
          user progresses through each section. */}
      {service.id === 'sales' && (() => {
        const sel = selection;
        const stepDone = {
          1: !!sel?.commitId || !!commitId,         // commitment selected (always has default)
          2: !!sel?.tier,                            // tier picked → service active
          3: Array.isArray(sel?.channels) && sel.channels.length > 0,
          4: Array.isArray(sel?.addons) && sel.addons.length > 0,
        };
        // step is "current" if previous done but this not done
        const stepCurrent = (n) => !stepDone[n] && (n === 1 || stepDone[n-1]);
        const labels = ['Commitment', 'Tier', 'Channels', 'Add-ons'];
        return (
          <div className="sdg-stepper" role="list" aria-label="S&DG configuration steps">
            {labels.map((lbl, i) => {
              const n = i + 1;
              const done = stepDone[n];
              const current = stepCurrent(n);
              return (
                <React.Fragment key={n}>
                  <div className={`sdg-stepper__step ${done ? 'is-done' : ''} ${current ? 'is-current' : ''}`} role="listitem">
                    <span className="sdg-stepper__num" aria-hidden="true">
                      {done ? <window.Check size={12}/> : n}
                    </span>
                    <span className="sdg-stepper__lbl">{lbl}</span>
                  </div>
                  {n < 4 && <div className={`sdg-stepper__line ${stepDone[n] ? 'is-done' : ''}`} aria-hidden="true" />}
                </React.Fragment>
              );
            })}
          </div>
        );
      })()}

      {/* Sprint 5: commitment toggle moved to .svc__head-right (top-right corner). */}

      {/* Dedicated Resources uses a custom multi-step flow in place of the
          standard tiers/roles/addons rendering. */}
      {(service.id === 'dedicated-pt' || service.id === 'dedicated-ft') && (
        <window.DedicatedFlow
          service={service}
          selection={selection}
          onSelect={onSelect}
          onTier={onTier}
          onToggleRole={onToggleRole}
          onSetRoleConfig={(rid, patch) => onSetRoleConfig && onSetRoleConfig(rid, patch)}
        />
      )}

      {/* Dedicated Resources · Full-Time add-ons (visa, buyout, IT, benefits etc.).
          Rendered inline after the dedicated flow when FT is selected so users can
          layer extras onto long-term placements. Hidden for Part-Time. */}
      {(service.id === 'dedicated-pt' || service.id === 'dedicated-ft') && tierId === 'fulltime' && filteredSvc.addons && filteredSvc.addons.length > 0 && (
        <AddonsBlock
          service={filteredSvc}
          selectedAddons={selectedAddons}
          addonQty={addonQty}
          onToggleAddon={onToggleAddon}
          overseasCountries={selection?.overseasCountries}
          onSetOverseasCountries={onSetOverseasCountries}
          onSetAddonQty={onSetAddonQty}
          defaultOpen={addonsDefaultOpen === 'always' || (addonsDefaultOpen === 'when-added' && active)}
          requireServiceActive={true}
          serviceActive={active}
          onActivateService={() => { if (!active) onSelect(true); }}
          onDeactivateService={() => onSelect(false)}
          qualifier={qualifier}
          intentId={intentId}
        />
      )}

      {/* Tiers ALWAYS visible, browsing mode if not added */}
      {(service.id !== 'dedicated-pt' && service.id !== 'dedicated-ft') && (
        <>
      <div className="svc__tier-label-row">
        <div className="svc__tier-label">
          {active ? 'Choose a tier' : 'Available tiers'}
        </div>
        {/* Pay-upfront pill toggle. Right-aligned in the tier-label row.
            Flips selection.payUpfront, which applies a -10% discount on
            this service's monthly lines at total time. */}
        <button
          type="button"
          className={`svc__upfront-toggle ${selection?.payUpfront ? 'is-on' : ''}`}
          role="switch"
          aria-checked={!!selection?.payUpfront}
          aria-label={`Pay upfront for ${service.name} and save 10%`}
          onClick={(e) => { e.stopPropagation(); onTogglePayUpfront && onTogglePayUpfront(); }}
        >
          <span className="svc__upfront-knob" aria-hidden="true" />
          <span className="svc__upfront-text">Pay upfront</span>
          <span className="svc__upfront-save">save 10%</span>
        </button>
      </div>
      {cmpOpen && (
        <CompareTiersModal service={service} onClose={() => setCmpOpen(false)} />
      )}
      <div className="tiers" style={{ gridTemplateColumns: `repeat(${window.tiersFor(service).length}, 1fr)` }}>
        {window.tiersFor(service).map(t => {
          // Tier-level waiting list, service.waitlistTiers lists tier ids that
          // can be added to a quote but show "Waiting list" instead of a price
          // and contribute £0 to the calculator total. Suppresses the
          // "popular" badge (it would conflict with a waitlist message).
          const onWaitlist = Array.isArray(service.waitlistTiers) && service.waitlistTiers.includes(t.id);

          return (
          <button
            key={t.id}
            className={`tier ${tierId === t.id && active ? 'tier--active' : ''} ${t.isEnterprise ? 'tier--ent' : ''} ${onWaitlist ? 'tier--waitlist' : ''} ${t.id === 'grow' ? 'tier--recommended' : ''}`}
            onClick={() => handleTier(t.id)}
            aria-pressed={tierId === t.id && active}
          >
            {t.id === 'grow' && !onWaitlist && <img src="assets/badges/recommended.webp" alt="Recommended" className="tier__rec-banner" />}
            <div className="tier__head">
              <div className="tier__head-main">
                <div className="tier__name-row">
                  <span className="tier__name">{t.name}</span>
                  {t.badge && !onWaitlist && <img src={`assets/badges/${t.badge}.webp`} alt="popular" className="tier__badge" />}
                </div>
                <div className="tier__blurb">{(service.tierDesc && service.tierDesc[t.id]) || t.blurb}</div>
              </div>
              <span className={`tier__radio ${tierId === t.id && active ? 'is-on' : ''}`} aria-hidden="true">
                {tierId === t.id && active && <window.Check size={16} />}
              </span>
            </div>
            {(() => {
              // Tier-feature pill chips, looked up from window.TIER_BADGES[service.id][tier.id].
              // Renders nothing if no badges configured for this combo.
              if (onWaitlist) return null;
              const map = window.TIER_BADGES || {};
              const badges = (map[service.id] && map[service.id][t.id]) || [];
              if (!badges.length) return null;
              return (
                <ul className="tier__badges" aria-label={`${t.name} features`}>
                  {badges.map((b, i) => (
                    <li key={i} className="tier__badge-chip">
                      <svg className="tier__badge-check" viewBox="0 0 16 16" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                        <polyline points="3 8 7 12 13 4" />
                      </svg>
                      <span>{b}</span>
                    </li>
                  ))}
                </ul>
              );
            })()}
            <div className="tier__price">
              {onWaitlist ? (
                <HoverPortalTip
                  wrapClassName="wl-tip-wrap tier__waitlist-pill-wrap"
                  tipClassName="wl-tip wl-tip--above"
                  placement="above"
                  tip={<>
                    <span className="wl-tip__head">You're joining a waiting list</span>
                    <span className="wl-tip__body">This plan is at capacity. Adding it reserves your place, we'll email you as soon as a spot opens, typically within 2-4 weeks.</span>
                    <span className="wl-tip__meta">£0 is added to your monthly total until onboarding begins.</span>
                  </>}
                >
                  <span className="tier__waitlist-pill">
                    <span className="tier__waitlist-dot" aria-hidden="true" />
                    Waiting list
                  </span>
                </HoverPortalTip>
              ) : t.isEnterprise
                ? (service.id === 'whitelabel'
                    ? (<a
                        className="tier__speak-btn"
                        data-cal-namespace="book-a-call"
                        data-cal-link="team/gogorilla/book-a-call"
                        data-cal-config='{"layout":"month_view","useSlotsViewOnSmallScreen":"true"}'
                        onClick={e => e.preventDefault()}
                      >Speak to us →</a>)
                    : 'Waiting list')
                : (() => {
                    const lbl = priceLabelFor(t.id);
                    return lbl === 'Custom' ? 'Contact for Pricing' : lbl;
                  })()}
              {!onWaitlist && !t.isEnterprise && priceLabelFor(t.id) !== 'Custom' && <span className="tier__price-sub">{service.oneTime ? 'one-time' : '/month'}</span>}
            </div>
            {/* One-time setup fee, rendered below the monthly price. */}
            {(() => {
              if (onWaitlist) return null;
              const fee = service.setupFees && service.setupFees[t.id];
              if (fee === undefined || fee === null) {
                // Enterprise tier shows "Custom setup" only when the service has
                // setup fees configured for other tiers (signals this is a
                // setup-fee service); avoids cluttering services that have no
                // setup fees at all.
                if (t.isEnterprise && service.setupFees) {
                  return <div className="tier__setup-fee tier__setup-fee--custom">+ Custom setup fee</div>;
                }
                return null;
              }
              const feeLabel = typeof fee === 'number'
                ? `£${fee.toLocaleString('en-GB')}`
                : String(fee);
              return (
                <div className="tier__setup-fee">+ {feeLabel} one-time setup fee</div>
              );
            })()}
          </button>
          );
        })}
      </div>

      {/* Whitelabel margin calculator, rendered ONLY when this service has an
          active tier picked AND the agency is on the whitelabel intent. Lets
          the agency type their retail price right next to where they pick the
          plan, so they can see margin vs GoGorilla wholesale in context. */}
      {intentId === 'agency-whitelabel' && active && tier && !tier.isEnterprise && (() => {
        const p = window.priceFor(service, tier.id, commitId);
        if (p.custom || p.oneTime || !p.value) return null;
        const _wlMult = window.getAgencyMultiplier ? window.getAgencyMultiplier({ clientTypeId, intentId }, service.id) : 0.6;
        // #120: read region-aware wholesale via window.WHOLESALE. Core services
        // have no explicit per-region rate yet, so this falls back to the agency
        // multiplier (identical to prior behaviour) until #83 populates them.
        let _wlRegion = 'uk';
        try { _wlRegion = localStorage.getItem('gg.wholesaleRegion') || 'uk'; } catch (e) {}
        const _wsRes = window.wholesaleService ? window.wholesaleService(service.id, tier.id, _wlRegion, p.value, _wlMult) : null;
        const wholesale = (_wsRes && typeof _wsRes.value === 'number') ? _wsRes.value : Math.round(p.value * _wlMult);
        return (
          <MarginRow
            wholesale={wholesale}
            rrp={p.value}
            serviceId={service.id}
            tierName={tier.name}
            commitId={commitId}
          />
        );
      })()}

      {/* Enterprise notice, shown in place of channels + addons when Enterprise is selected */}
      {tier?.isEnterprise && active && (
        <div className="svc__ent-notice">
          <div className="svc__ent-notice-body">
            <div className="svc__ent-notice-eyebrow">Enterprise scope</div>
            <div className="svc__ent-notice-title">Custom-built for {service.name.toLowerCase()}</div>
            <div className="svc__ent-notice-desc">Channels, add-ons, and pricing are scoped 1:1 with your team. Our specialists will reach out after you finalise your plan.</div>
          </div>
        </div>
      )}

      {/* Channels + capacity panel, only shown after the user picks a tier (service is active),
          and hidden on Enterprise tier (custom scope). */}
      {window.SERVICE_CHANNELS[service.id] && active && !tier?.isEnterprise && (
        <ChannelsPanel
          service={service}
          selection={selection}
          onToggleChannel={(cid, max) => onToggleChannel(cid, max)}
          onSetAdSpend={onSetAdSpend}
          onSetLinkedinProfiles={onSetLinkedinProfiles}
          serviceActive={active}
          currentTierId={tierId || 'starter'}
        />
      )}

      {/* Monthly Lead Volume widget, SDG only (see BUILD_SPEC_SDG_Monthly_Lead_Volume.md).
          Slots in immediately after channels picker, hidden for Enterprise. */}
      {service.id === 'sales' && active && !tier?.isEnterprise && (
        <MonthlyLeadVolume
          selection={selection}
          tierId={tierId}
          onSetMode={onSetLeadSourceMode}
          onSetLeads={onSetMonthlyLeads}
          onSetByolQuality={onSetByolListQuality}
        />
      )}

      {/* Sprint 5: Cost-per-Qualified-Meeting forecast (SDG only).
          Sits between the tier grid and the channels picker. All numbers
          derive from the existing pricing matrix in data.jsx (no per-quote
          Airtable lookup) + a small per-tier meeting-volume table. The
          "% vs in-house / agencies" benchmarks stay hardcoded, they're
          market-research figures, not derived. */}
      {false && service.id === 'sales' && active && !tier?.isEnterprise && (() => {
        const tierMeetings = {
          starter: { min: 3,  max: 5  },
          grow:    { min: 6,  max: 10 },
          scale:   { min: 10, max: 15 },
        };
        const range = tierMeetings[tierId] || tierMeetings.grow;
        const mid = (range.min + range.max) / 2;
        const cm = String(commitId || '12');
        const monthsMap = { '3': 3, '6': 6, '12': 12 };
        const months = monthsMap[cm] || 3;
        const p3 = (window.priceFor && window.priceFor(service, tierId, '3')) || { value: 0 };
        const pCur = (window.priceFor && window.priceFor(service, tierId, cm)) || { value: 0 };
        const monthly = pCur.value || 0;
        const savings = Math.max(0, Math.round((p3.value - monthly) * months));
        const costPerMeeting = mid > 0 ? Math.round(monthly / mid) : 0;
        // Effective cost per meeting estimates the net cost after the
        // outcome-linked success bonus is rebated through GorillaMatrix.
        // Calibrated against the screenshot's ~58% effective ratio.
        const effectivePer = Math.round(costPerMeeting * 0.58);
        // Expected meetings = upper bound of the per-month range.
        const expected = range.max;
        const tierName = (window.findTier && window.findTier(service, tierId)?.name) || (tierId || '').replace(/^./, c => c.toUpperCase());
        const commitLabel = months === 12 ? '12-month' : months === 6 ? '6-month' : '3-month';
        const fmtGBP = (n) => '£' + (Number(n) || 0).toLocaleString('en-GB');
        const InfoIcon = () => (
          <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
            <circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><circle cx="12" cy="8" r="0.6" fill="currentColor"/>
          </svg>
        );
        return (
          <div className="sdg-forecast" role="region" aria-label="Cost per qualified meeting forecast">
            <div className="sdg-forecast__head">
              <div className="sdg-forecast__icon" aria-hidden="true">
                <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
                  <rect x="3" y="5" width="18" height="16" rx="2"/>
                  <line x1="3" y1="10" x2="21" y2="10"/>
                  <line x1="8" y1="3" x2="8" y2="7"/>
                  <line x1="16" y1="3" x2="16" y2="7"/>
                </svg>
              </div>
              <div className="sdg-forecast__head-text">
                <div className="sdg-forecast__title">
                  <span>Estimated cost per qualified meeting</span>
                  <HoverPortalTip
                    wrapClassName="sdg-forecast__title-info-wrap"
                    tipClassName="summary__total-tip"
                    placement="above"
                    tip={<>
                      <span className="summary__total-tip-head">How we estimate this</span>
                      <span className="summary__total-tip-body">Management fee divided by the mid-point of expected qualified meetings per month for your tier. Your real number is calibrated to your ICP, deal size and sales cycle during onboarding. We confirm your final cost per meeting on a quick call or by email, no separate proposal needed.</span>
                    </>}
                  >
                    <button type="button" className="sdg-forecast__info" aria-label="About this estimate" tabIndex={-1}><InfoIcon/></button>
                  </HoverPortalTip>
                </div>
                <div className="sdg-forecast__subtitle">
                  <strong>{tierName}</strong> tier, <strong>{commitLabel}</strong> commit, £1k-10k deals, a standard-difficulty niche.
                </div>
                <div className="sdg-forecast__elasticity">
                  ⇅ Scale up or down any time after the initial term, costs forecast cleanly month-on-month.
                </div>
              </div>
            </div>

            <div className="sdg-forecast__metrics">
              <div className="sdg-forecast__metric">
                <div className="sdg-forecast__metric-label">COST / MEETING</div>
                <div className="sdg-forecast__metric-val">{fmtGBP(costPerMeeting)} <span className="sdg-forecast__metric-qualifier">qualified</span></div>
              </div>
              <div className="sdg-forecast__metric">
                <div className="sdg-forecast__metric-label">MEETINGS / MO</div>
                <div className="sdg-forecast__metric-val">{range.min}-{range.max}</div>
              </div>
              <div className="sdg-forecast__metric">
                <div className="sdg-forecast__metric-label">TOTAL / MO</div>
                <div className="sdg-forecast__metric-val sdg-forecast__metric-val--row">
                  {fmtGBP(monthly)}
                  {savings > 0 && <span className="sdg-forecast__save-chip">SAVE {fmtGBP(savings)}</span>}
                </div>
              </div>
              <div className="sdg-forecast__metric sdg-forecast__metric--effective">
                <div className="sdg-forecast__metric-label">EFFECTIVE</div>
                <div className="sdg-forecast__metric-val">{fmtGBP(effectivePer)}</div>
              </div>
              <div className="sdg-forecast__compare">
                <div className="sdg-forecast__compare-stat"><strong>78% cheaper</strong> vs in-house &middot; <strong>60% cheaper</strong> vs agencies</div>
                <div className="sdg-forecast__compare-hint">per meeting with your selected signals + add-ons</div>
                <div className="sdg-forecast__compare-expected">({expected} expected meetings)</div>
              </div>
            </div>

            <div className="sdg-forecast__notes">
              <span><strong>We book</strong>; your team closes <InfoIcon/></span>
              <span><strong>Qualified</strong> means ICP + decision-maker + defined need + booked call <InfoIcon/></span>
              <span><strong>Success bonus</strong> paid from our margin, not yours <InfoIcon/></span>
            </div>

            <details className="sdg-forecast__expand">
              <summary className="sdg-forecast__expand-summary">
                <span className="sdg-forecast__expand-arrow" aria-hidden="true">›</span>
                See the full forecast: pipeline, ROI, and how to lower this further
              </summary>
              <div className="sdg-forecast__panel">
                <div className="sdg-forecast__panel-title">Pipeline forecast (illustrative)</div>
                <div className="sdg-forecast__panel-grid">
                  <div className="sdg-forecast__panel-row sdg-forecast__panel-row--head">
                    <span>Month</span><span>Meetings booked</span><span>Pipeline (£1-10k ACV)</span>
                  </div>
                  {[1, 3, 6, 12].map((m) => {
                    const monthlyMeetings = m === 1 ? Math.max(2, range.min - 1) : (m === 3 ? range.min + 1 : range.max);
                    const cumulative = m * Math.round((range.min + range.max) / 2);
                    const pipelineMid = cumulative * 5500; // £5,500 mid-ACV
                    return (
                      <div key={m} className="sdg-forecast__panel-row">
                        <span>Month {m}</span>
                        <span>{monthlyMeetings} (cum. {cumulative})</span>
                        <span>{fmtGBP(pipelineMid)}</span>
                      </div>
                    );
                  })}
                </div>
                <div className="sdg-forecast__panel-hint">
                  How to lower this further: longer commit (-up to 45%), bring your own list (-15% retainer), bundle with Paid Ads / Email Marketing (multi-service discount up to 10%).
                </div>
              </div>
            </details>
          </div>
        );
      })()}

      {/* All-in cost-per-meeting card removed, was Sprint 3 worked-example.
          Replaced by simpler in-summary cost reporting; per user request 2026-05. */}

      {/* Roles panel, for services like Dedicated Resources that have per-tier role catalogues.
          Only shown when the service is active so the choice feels deliberate. */}
      {service.roles && active && (
        <RolesPanel
          service={service}
          selection={selection}
          currentTierId={tierId || Object.keys(service.roles)[0]}
          onToggleRole={onToggleRole}
        />
      )}

      {/* GorillaMatrix incentives strip, between tiers and add-ons */}
      {service.incentives && service.incentives.length > 0 && (
        <GorillaMatrix incentives={service.incentives} />
      )}

      {/* Sprint 4, Investor Portal flow notice. Adapts based on selected tier:
          Network Free → instant signup. Partner Pro → standard subscription.
          Partner Pro+ → confidential intake call. */}
      {service.id === 'investor-portal' && active && (() => {
        const t = selection?.tier || tierId;
        if (t === 'starter') {
          return (
            <div className="inv-notice inv-notice--free">
              <span className="inv-notice__icon" aria-hidden="true">✓</span>
              <div className="inv-notice__body">
                <strong>Instant access, no card required.</strong>
                <span>You'll get magic-link access to Network Free as soon as you confirm your email at checkout.</span>
              </div>
            </div>
          );
        }
        if (t === 'scale') {
          return (
            <div className="inv-notice inv-notice--call">
              <span className="inv-notice__icon" aria-hidden="true">◇</span>
              <div className="inv-notice__body">
                <strong>Partner Pro+ requires a confidential intake call.</strong>
                <span>We need to understand your acquisition thesis before activating dedicated buy-side deal origination. You will book this 30-minute call at the final step.</span>
              </div>
            </div>
          );
        }
        return (
          <div className="inv-notice inv-notice--standard">
            <span className="inv-notice__icon" aria-hidden="true">★</span>
            <div className="inv-notice__body">
              <strong>Partner Pro, standard monthly subscription.</strong>
              <span>You'll be billed monthly via Stripe. Cancel anytime from your account portal.</span>
            </div>
          </div>
        );
      })()}

      {/* Addons disclosure, hidden when this service is on Enterprise tier (custom scope).
          Special case: Dedicated Resources only surfaces add-ons on its Full-Time tier
          (visa sponsorship, buyout, IT, benefits etc. apply to long-term placements only). */}
      {(window.ADDON_MATRIX?.[service.id] ? true : !(tier?.isEnterprise && active)) &&
        filteredSvc.addons.length > 0 &&
        !((service.id === 'dedicated-pt' || service.id === 'dedicated-ft') && tierId !== 'fulltime') && (
        <AddonsBlock
          service={filteredSvc}
          selectedAddons={selectedAddons}
          addonQty={addonQty}
          onToggleAddon={onToggleAddon}
          overseasCountries={selection?.overseasCountries}
          onSetOverseasCountries={onSetOverseasCountries}
          onSetAddonQty={onSetAddonQty}
          defaultOpen={addonsDefaultOpen === 'always' || (addonsDefaultOpen === 'when-added' && active)}
          requireServiceActive={true}
          serviceActive={active}
          onActivateService={() => { if (!active) onSelect(true); }}
          onDeactivateService={() => onSelect(false)}
          qualifier={qualifier}
          intentId={intentId}
        />
      )}
        </>
      )}
    </div>
  );
}

// ── BUILD PAGE, combined Client + Services with persistent sidebar ──


// ── Q0: white-label agency client capture (Sprint 1) ─────────────────
// Only rendered when intentId === 'agency-whitelabel'. Captures client name
// (optional) and industry (drives Q1 ICP pre-fill via INDUSTRY_TO_Q1_ICP).
function Q0Section({ q0, onChange }) {
  return (
    <div className="q0-section glass-frame">
      <div className="q0-section__head">
        <div className="q0-section__eyebrow">Step 0 · White-label client</div>
        <h3 className="q0-section__title">Who are you building this quote for?</h3>
        <p className="q0-section__sub">Optional name + industry. Industry pre-fills the next question so you can move faster.</p>
      </div>
      <div className="q0-section__grid">
        <label className="q0-field">
          <span className="q0-field__label">Client name <span className="q0-field__hint">(optional)</span></span>
          <input
            type="text"
            className="q0-field__input"
            placeholder="e.g. Acme Corp"
            value={q0.clientName || ''}
            onChange={(e) => onChange('clientName', e.target.value)}
          />
        </label>
        <label className="q0-field">
          <span className="q0-field__label">Industry</span>
          <select
            className="q0-field__input"
            value={q0.industry || ''}
            onChange={(e) => onChange('industry', e.target.value)}
          >
            <option value="">Select industry…</option>
            {(window.INDUSTRIES || []).map(i => (
              <option key={i.id} value={i.id}>{i.label}</option>
            ))}
          </select>
        </label>
      </div>
    </div>
  );
}
window.Q0Section = Q0Section;

// ── Qualifier (Q1, Q4 + conditional Q1.5) (Sprint 1) ────────────────────
function QualifierSection({ qualifier, onAnswer, onAnswerMulti, missingIds, persona = 'founders', onConfirmQ1Change, onSetPriorSub, onTogglePriorSub }) {
  // 2026-05-25 Batch 10: agency persona uses a separate question bank.
  const allQuestions = persona === 'agencies'
    ? (window.AGENCY_QUALIFIER_QUESTIONS || [])
    : (window.QUALIFIER_QUESTIONS || []);
  const _missing = missingIds || new Set();
  const optRefs = React.useRef({});
  const [openTip, setOpenTip] = React.useState(null);
  const [pendingQ1, setPendingQ1] = React.useState(null);

  // Persona gate, founders-only flow per spec.
  const isFounder = persona === 'founders';
  const isAgency  = persona === 'agencies';

  // Compute visible questions.
  // Agency questions: simple visibleIf filter only — no foundersOnly or q1a branch logic.
  // Founder questions: honour foundersOnly flag + q1a branch de-dup.
  const visible = React.useMemo(() => {
    if (isAgency) {
      return allQuestions.filter(q => {
        // aq_scenario is auto-derived from intentId and must stay hidden once set
        // (its visibleIf is "show only when unset"); never sticky-rescue it.
        if (q.id === 'aq_scenario') return typeof q.visibleIf === 'function' ? !!q.visibleIf(qualifier || {}) : true;
        const _v = qualifier?.[q.id];
        const _has = q.multi ? (Array.isArray(_v) && _v.length > 0) : (_v != null && _v !== '');
        if (_has) return true;
        if (typeof q.visibleIf === 'function') return !!q.visibleIf(qualifier || {});
        return true;
      });
    }
    return allQuestions.filter(q => {
      if (q.foundersOnly && !isFounder) return false;
      // De-dupe q1a panels: only render the one whose branch matches state.q1.
      if (q.id === 'q1a' && q.branch && qualifier?.q1 !== q.branch) return false;
      const _v = qualifier?.[q.id];
      const _has = q.multi ? (Array.isArray(_v) && _v.length > 0) : (_v != null && _v !== '');
      if (_has) return true;
      if (typeof q.visibleIf === 'function') return !!q.visibleIf(qualifier || {});
      return true;
    });
  }, [qualifier, isFounder, isAgency, allQuestions]);

  // Progressive reveal: show questions one by one. Each question is shown
  // only after all previous questions in the visible list have been answered.
  // Purely derived from qualifier state — no extra useState needed.
  const shownUpTo = React.useMemo(() => {
    // Reveal up to the furthest answered question plus the next one, so clearing
    // an earlier answer does not hide the questions after it.
    let lastAnswered = -1;
    for (let i = 0; i < visible.length; i++) {
      const q = visible[i];
      const v = qualifier?.[q.id];
      const has = q.multi ? (Array.isArray(v) && v.length > 0) : (v != null && v !== '');
      if (has) lastAnswered = i;
    }
    return Math.min(lastAnswered + 1, visible.length - 1);
  }, [visible, qualifier]);

  // Close tooltip on Escape / outside click.
  React.useEffect(() => {
    if (!openTip) return;
    const onKey = (e) => { if (e.key === 'Escape') setOpenTip(null); };
    const onClick = (e) => {
      if (!e.target.closest(`[data-tip-for="${openTip}"]`) &&
          !e.target.closest(`[data-tip-anchor="${openTip}"]`)) setOpenTip(null);
    };
    window.addEventListener('keydown', onKey);
    window.addEventListener('click', onClick);
    return () => { window.removeEventListener('keydown', onKey); window.removeEventListener('click', onClick); };
  }, [openTip]);

  const totalQs = visible.length;

  return (
    <div className="qualifier-section">
      {visible.map((q, qIdx) => {
        const _isMissing = _missing.has && _missing.has(q.id);
        const cols = q.cols || 2;
        const cur = qualifier?.[q.id];
        const isMulti = !!q.multi;
        if (!optRefs.current[q.id]) optRefs.current[q.id] = [];
        const titleId = `qq-title-${q.id}-${q.branch || 'main'}`;

        const handleKey = (idx) => (e) => {
          if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
            e.preventDefault();
            const nx = (idx + 1) % q.options.length;
            optRefs.current[q.id][nx]?.focus();
          } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
            e.preventDefault();
            const pv = (idx - 1 + q.options.length) % q.options.length;
            optRefs.current[q.id][pv]?.focus();
          } else if (e.key === ' ' || e.key === 'Enter') {
            e.preventDefault();
            handlePick(q.options[idx]);
          }
        };

        const handlePick = (opt) => {
          if (isMulti) {
            // Multi-select chips: reducer's SET_QUALIFIER_MULTI already toggles
            // existing values off, so just delegate.
            onAnswerMulti && onAnswerMulti(q.id, opt.id, q.exclusive || null);
            return;
          }
          // Re-clicking the currently-selected card toggles it off (spec §7.2).
          // The SET_QUALIFIER reducer already clears branch-downstream fields
          // when the value transitions to null, so a re-click on q1 wipes the
          // entire downstream tree, a re-click on q1aDtcAov wipes DTC margin
          // + repeat, etc. No path-change modal, re-click is unambiguous
          // "I want to clear this answer", not "I want to switch path".
          if (cur === opt.id) {
            // Re-click clears just this answer. Visibility + shownUpTo below are sticky,
            // so clearing an earlier answer no longer hides later ones.
            onAnswer(q.id, null);
            return;
          }
          // q1 change with >=3 downstream answers → defer to confirmation modal.
          if (q.id === 'q1' && qualifier?.q1 && qualifier.q1 !== opt.id) {
            const downstream = window.countDownstreamAnswers ? window.countDownstreamAnswers('q1', qualifier) : 0;
            if (downstream >= 3) {
              setPendingQ1(opt.id);
              return;
            }
          }
          onAnswer(q.id, opt.id);
          // After React re-renders (a new follow-up panel may have just
          // mounted), scroll smoothly to the next unanswered visible
          // qualifier question. Two rAFs lets the DOM settle before the
          // measurement. Stops at the next visible question so the user
          // is gently nudged forward, never overshooting.
          requestAnimationFrame(() => requestAnimationFrame(() => {
            const panels = Array.from(document.querySelectorAll('[data-q-id]'));
            const liveState = window.__lastBuildPageState;
            const curIdx = panels.findIndex(p => p.dataset.qId === q.id);
            for (let i = curIdx + 1; i < panels.length; i++) {
              const nextId = panels[i].dataset.qId;
              const v = liveState?.qualifier?.[nextId];
              const isMulti = nextId === 'priorActivities';
              const isEmpty = isMulti
                ? (!Array.isArray(v) || v.length === 0)
                : (v == null || v === '');
              if (isEmpty) {
                _scrollToNext(panels[i]);
                return;
              }
            }
          }));
        };

        // Sequential reveal: hide questions that haven't been unlocked yet.
        if (qIdx > shownUpTo) return null;

        return (
          <React.Fragment key={`${q.id}-${q.branch || 'main'}`}>
            {/* Question N of M counter removed per latest spec. */}
            <div
              className={`qualifier-q qualifier-q--cols-${cols} ${_isMissing ? 'qualifier-q--missing' : ''}`}
              data-q-id={q.id}
              aria-invalid={_isMissing ? 'true' : 'false'}
              role="group"
              aria-labelledby={titleId}
            >
              <div className="qualifier-q__head">
                <h3 id={titleId} className="qualifier-q__label">
                  {q.label}
                  {q.info && (
                    <HoverPortalTip
                      wrapClassName="qualifier-q__info-wrap"
                      tipClassName="dis-tip dis-tip--above"
                      placement="above"
                      tip={<span>{q.info}</span>}
                    >
                      <button
                        type="button"
                        className="qualifier-q__info-btn"
                        aria-label={`More info: ${q.label}`}
                      >
                        <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true">
                          <circle cx="8" cy="8" r="6.5"/>
                          <line x1="8" y1="11" x2="8" y2="7" strokeLinecap="round"/>
                          <circle cx="8" cy="5" r="0.5" fill="currentColor"/>
                        </svg>
                      </button>
                    </HoverPortalTip>
                  )}
                </h3>
                {q.sub && <p className="qualifier-q__sub">{q.sub}</p>}
              </div>
              <div
                className={`qualifier-q__opts ${q.id === 'priorActivities' ? 'qualifier-q__opts--pills' : ''}`}
                role={isMulti ? 'group' : 'radiogroup'}
                aria-labelledby={titleId}
              >
                {q.options.map((o, i) => {
                  const on = isMulti
                    ? (Array.isArray(cur) && cur.includes(o.id))
                    : (cur === o.id);
                  const hasDesc = !!o.desc;
                  const aria = o.desc ? `${o.label}: ${o.desc}` : o.label;
                  const focusableTab = on || (!cur && i === 0) ? 0 : -1;
                  // Compact pill variant for Q6 priorActivities, flow chips
                  // in rows instead of large 2-column cards.
                  if (q.id === 'priorActivities') {
                    return (
                      <button
                        key={o.id}
                        ref={el => { optRefs.current[q.id][i] = el; }}
                        type="button"
                        role={isMulti ? 'button' : 'radio'}
                        aria-checked={!isMulti ? on : undefined}
                        aria-pressed={isMulti ? on : undefined}
                        aria-label={aria}
                        tabIndex={isMulti ? 0 : focusableTab}
                        className={`qual-pill ${on ? 'qual-pill--on' : ''}`}
                        onClick={() => handlePick(o)}
                        onKeyDown={isMulti ? undefined : handleKey(i)}
                      >
                        {o.label}
                      </button>
                    );
                  }
                  return (
                    <button
                      key={o.id}
                      ref={el => { optRefs.current[q.id][i] = el; }}
                      type="button"
                      role={isMulti ? 'button' : 'radio'}
                      aria-checked={!isMulti ? on : undefined}
                      aria-pressed={isMulti ? on : undefined}
                      aria-label={aria}
                      tabIndex={isMulti ? 0 : focusableTab}
                      className={`qualifier-opt ${on ? 'qualifier-opt--on' : ''} ${hasDesc ? 'qualifier-opt--with-desc' : 'qualifier-opt--label-only'}`}
                      onClick={() => handlePick(o)}
                      onKeyDown={isMulti ? undefined : handleKey(i)}
                    >
                      <span className={`qualifier-opt__radio ${on ? 'is-on' : ''}`} aria-hidden="true">
                        {on && <window.Check size={12} />}
                      </span>
                      <div className="qualifier-opt__body">
                        <div className="qualifier-opt__title">{o.label}</div>
                        {hasDesc && <div className="qualifier-opt__desc">{o.desc}</div>}
                      </div>
                    </button>
                  );
                })}
              </div>
              {q.id === 'priorActivities' && window.Q6CascadeBlock && (
                <window.Q6CascadeBlock
                  qualifier={qualifier}
                  onSetSub={(field, value) => onSetPriorSub && onSetPriorSub(field, value)}
                  onToggleSub={(field, value) => onTogglePriorSub && onTogglePriorSub(field, value)}
                />
              )}
            </div>
          </React.Fragment>
        );
      })}

      {/* Path-change confirmation modal: shown when user clicks a different
          q1 with >= 3 downstream answers already filled in. */}
      {pendingQ1 && (
        <div className="cs-modal" role="dialog" aria-modal="true" aria-labelledby="q1-change-title">
          <div className="cs-modal__backdrop" onClick={() => setPendingQ1(null)} />
          <div className="cs-modal__panel">
            <h2 id="q1-change-title" className="cs-modal__title">Change your business type?</h2>
            <p className="cs-modal__body">
              Your answers below will be cleared because the questions branch differently for this path.
              We'll re-ask the deal size, LTV, revenue, budget, and gap selections.
            </p>
            <div className="cs-modal__actions">
              <button type="button" className="btn btn--ghost btn--sm" onClick={() => setPendingQ1(null)}>Cancel</button>
              <button type="button" className="btn btn--primary btn--sm" onClick={() => {
                onAnswer('q1', pendingQ1);
                if (onConfirmQ1Change) onConfirmQ1Change(pendingQ1);
                setPendingQ1(null);
                // Scroll to the newly-revealed q1a panel.
                requestAnimationFrame(() => {
                  const el = document.querySelector(`[data-q-id="q1a"]`);
                  if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
                });
              }}>Change and clear</button>
            </div>
          </div>
        </div>
      )}
    </div>
  );
}
// ── Q6 PRIOR ACTIVITIES CASCADE ──────────────────────────────────────────────
// Sub-question cascades that mount inside the priorActivities panel of
// QualifierSection. One block per selected primary chip. Verbatim copy from
// founders-q6-prior-activities-spec.md §§4-11.
//
// Each sub-panel is a thin-glass-frame block with a chip group. Single-select
// chips toggle via onSetSub(field, value) | re-click → null. Multi-select
// chips toggle via onToggleSub(field, value). 'Skip' is a real stored value
// for analytics; isReadyToAdvance treats it as answered.
function Q6CascadeBlock({ qualifier, onSetSub, onToggleSub }) {
  const q = qualifier || {};
  const arr = Array.isArray(q.priorActivities) ? q.priorActivities : [];
  if (arr.length === 0) return null;
  if (arr.includes('none')) return null;

  // Helpers, render single + multi chip groups as compact pills (matches the
  // priorActivities chip group above). Pills flow in rows; selected pill fills
  // with brand blue.
  const renderSingle = (field, value, options) => (
    <div className="qualifier-q__opts qualifier-q__opts--pills" role="radiogroup">
      {options.map(o => {
        const on = value === o.id;
        return (
          <button
            key={o.id}
            type="button"
            role="radio"
            aria-checked={on}
            className={`qual-pill ${on ? 'qual-pill--on' : ''} ${o.id === 'skip' ? 'qual-pill--skip' : ''}`}
            onClick={() => onSetSub(field, on ? null : o.id)}
          >
            {o.label}
          </button>
        );
      })}
    </div>
  );
  const renderMulti = (field, values, options) => (
    <div className="qualifier-q__opts qualifier-q__opts--pills" role="group">
      {options.map(o => {
        const on = Array.isArray(values) && values.includes(o.id);
        return (
          <button
            key={o.id}
            type="button"
            aria-pressed={on}
            className={`qual-pill ${on ? 'qual-pill--on' : ''}`}
            onClick={() => onToggleSub(field, o.id)}
          >
            {o.label}
          </button>
        );
      })}
    </div>
  );
  const Panel = ({ title, sub, children }) => (
    <div className="q6-sub-panel thin-glass-frame">
      <div className="q6-sub-panel__head">
        <h4 className="q6-sub-panel__title">{title}</h4>
        {sub && <p className="q6-sub-panel__sub">{sub}</p>}
      </div>
      {children}
    </div>
  );

  // Chip option lists (verbatim from spec)
  const OUTBOUND_CHANNELS = [
    { id: 'email',        label: 'Email outreach' },
    { id: 'linkedin',     label: 'LinkedIn outreach' },
    { id: 'cold-calling', label: 'Cold calling' },
    { id: 'other',        label: 'Other / mixed' },
  ];
  const SALES_CYCLE = [
    { id: 'sub-30',   label: 'Under 30 days' },
    { id: '30-90',    label: '30 to 90 days' },
    { id: '90-180',   label: '90 to 180 days' },
    { id: '180-plus', label: '180+ days' },
    { id: 'skip',     label: 'Skip' },
  ];
  const PAID_PLATFORMS = [
    { id: 'meta',      label: 'Meta (FB + IG)' },
    { id: 'google',    label: 'Google' },
    { id: 'tiktok',    label: 'TikTok' },
    { id: 'linkedin',  label: 'LinkedIn' },
    { id: 'pinterest', label: 'Pinterest' },
    { id: 'microsoft', label: 'Microsoft' },
    { id: 'other',     label: 'Other' },
  ];
  const PAID_BUDGET = [
    { id: 'sub-1.5k', label: 'Under £1.5k/mo' },
    { id: '1.5-3k',   label: '£1.5k-3k/mo' },
    { id: '3-6k',     label: '£3k-6k/mo' },
    { id: '6-15k',    label: '£6k-15k/mo' },
    { id: '15kplus',  label: '£15k+/mo' },
    { id: 'skip',     label: 'Skip' },
  ];
  const PAID_ROAS = [
    { id: 'sub-1x',       label: 'Under 1×' },
    { id: '1-2x',         label: '1-2×' },
    { id: '2-3x',         label: '2-3×' },
    { id: '3-5x',         label: '3-5×' },
    { id: '5xplus',       label: '5×+' },
    { id: 'not-tracking', label: 'Not tracking' },
    { id: 'skip',         label: 'Skip' },
  ];
  const EMAIL_SUBSCRIBERS = [
    { id: 'sub-500',   label: 'Under 500' },
    { id: '500-1k',    label: '500 to 1,000' },
    { id: '1k-5k',     label: '1,000 to 5,000' },
    { id: '5k-25k',    label: '5,000 to 25,000' },
    { id: '25k-100k',  label: '25,000 to 100,000' },
    { id: '100kplus',  label: '100,000+' },
    { id: 'skip',      label: 'Skip' },
  ];
  const EMAIL_CAMPAIGNS = [
    { id: 'none-yet', label: 'None yet' },
    { id: '1-4',      label: '1 to 4' },
    { id: '5-12',     label: '5 to 12' },
    { id: '13-30',    label: '13 to 30' },
    { id: '30plus',   label: '30+' },
    { id: 'skip',     label: 'Skip' },
  ];
  const EMAIL_PLATFORM = [
    { id: 'klaviyo',         label: 'Klaviyo' },
    { id: 'mailchimp',       label: 'Mailchimp' },
    { id: 'omnisend',        label: 'Omnisend' },
    { id: 'drip',            label: 'Drip' },
    { id: 'shopify-email',   label: 'Shopify Email' },
    { id: 'other',           label: 'Other' },
    { id: 'no-platform-yet', label: 'No platform yet' },
  ];
  const SOCIAL_FREQUENCY = [
    { id: 'daily',   label: 'Daily' },
    { id: 'weekly',  label: 'A few times a week' },
    { id: 'monthly', label: 'A few times a month' },
    { id: 'rare',    label: 'Rarely / inconsistently' },
  ];
  const SOCIAL_PLATFORMS = [
    { id: 'instagram', label: 'Instagram' },
    { id: 'linkedin',  label: 'LinkedIn' },
    { id: 'tiktok',    label: 'TikTok' },
    { id: 'facebook',  label: 'Facebook' },
    { id: 'x',         label: 'X (Twitter)' },
    { id: 'youtube',   label: 'YouTube' },
    { id: 'pinterest', label: 'Pinterest' },
  ];
  const HIRING_TIME = [
    { id: 'within-14-days',  label: 'Within 14 days' },
    { id: 'within-1-month',  label: 'Within 1 month' },
    { id: 'within-3-months', label: 'Within 3 months' },
    { id: '6plus-months',    label: '6+ months' },
    { id: 'not-sure',        label: 'Not sure yet' },
    { id: 'skip',            label: 'Skip' },
  ];
  const FUNDRAISING_STAGE = [
    { id: 'preseed-not-raising', label: 'Pre-seed / not actively raising' },
    { id: 'raising-seed',        label: 'Actively raising seed' },
    { id: 'seriesA',             label: 'Series A (raising or just raised)' },
    { id: 'seriesB',             label: 'Series B / scaling growth' },
    { id: 'seriesC-plus',        label: 'Series C+ / PE-backed' },
    { id: 'late-exit',           label: 'Late-stage / exit prep' },
    { id: 'skip',                label: 'Skip' },
  ];
  const RAISE_SIZE = [
    { id: 'sub-250k', label: 'Under £250k' },
    { id: '250k-1m',  label: '£250k - £1M' },
    { id: '1m-3m',    label: '£1M - £3M' },
    { id: '3m-plus',  label: '£3M+' },
    { id: 'skip',     label: 'Skip' },
  ];
  const INVESTOR_TYPES = [
    { id: 'angels',         label: 'Angels' },
    { id: 'vcs',            label: 'VCs (institutional)' },
    { id: 'pe-growth',      label: 'PE / Growth equity' },
    { id: 'crowdfunding',   label: 'Crowdfunding (Crowdcube / Seedrs / Republic)' },
    { id: 'strategic-cvc',  label: 'Strategic / Corporate VC' },
  ];
  const TARGET_VAL_SERIESA = [
    { id: '5-25m',     label: '£5M - £25M' },
    { id: '25-100m',   label: '£25M - £100M' },
    { id: '100m-plus', label: '£100M+' },
    { id: 'not-sure',  label: 'Not sure' },
  ];
  const TARGET_VAL_EXIT = [
    { id: '10-50m',   label: '£10M - £50M' },
    { id: '50-250m',  label: '£50M - £250M' },
    { id: '250m-1b',  label: '£250M - £1B' },
    { id: '1bplus',   label: '£1B+' },
    { id: 'not-sure', label: 'Not sure' },
  ];
  const FUNDRAISING_FLAGS = [
    { id: 'raised-before',           label: 'I have raised before' },
    { id: 'accelerator-grad',        label: 'Accelerator graduate (YC / Techstars / Antler / EF / similar)' },
    { id: 'previous-exit',           label: 'I have exited a previous company' },
    { id: 'existing-institutional',  label: 'Existing institutional investors on cap table' },
    { id: 'open-to-acquisition',     label: 'Open to acquisition offers too' },
  ];

  const fundraisingStage = q.fundraisingStage;
  const needsTargetVal = ['seriesA', 'late-exit'].includes(fundraisingStage);
  const targetValOpts = fundraisingStage === 'seriesA' ? TARGET_VAL_SERIESA : TARGET_VAL_EXIT;

  return (
    <div className="q6-cascade">
      {/* ── sales ── */}
      {arr.includes('sales') && (
        <>
          <div className="q6-cascade-label">Cold sales / outbound</div>
          <Panel title="Which outbound channels are you running?" sub="Calibrates outbound cadence and channel mix in your plan.">
            {renderMulti('priorOutboundChannels', q.priorOutboundChannels, OUTBOUND_CHANNELS)}
          </Panel>
          <Panel title="Average sales cycle length" sub="From first contact to closed deal. Calibrates outbound cadence (faster cycles = denser touchpoints, slower cycles = ABM-style nurture).">
            {renderSingle('priorSalesCycle', q.priorSalesCycle, SALES_CYCLE)}
          </Panel>
        </>
      )}

      {/* ── paid ── */}
      {arr.includes('paid') && (
        <>
          <div className="q6-cascade-label">Paid advertising</div>
          <Panel title="Which platforms are you advertising on?">
            {renderMulti('priorPaidPlatforms', q.priorPaidPlatforms, PAID_PLATFORMS)}
          </Panel>
          <Panel title="What's your current monthly ad-spend budget?" sub="We use this to calibrate the paid-advertising forecast and tier recommendation.">
            {renderSingle('priorPaidBudget', q.priorPaidBudget, PAID_BUDGET)}
          </Panel>
          <Panel title="What blended ROAS are you currently seeing across paid?" sub="ROAS = Revenue ÷ Ad Spend. Blended means across all platforms.">
            {renderSingle('priorPaidRoas', q.priorPaidRoas, PAID_ROAS)}
          </Panel>
        </>
      )}

      {/* ── email ── */}
      {arr.includes('email') && (
        <>
          <div className="q6-cascade-label">Email marketing</div>
          <Panel title="How many email subscribers do you currently have?">
            {renderSingle('emailSubscriberCount', q.emailSubscriberCount, EMAIL_SUBSCRIBERS)}
          </Panel>
          <Panel title="How many campaigns do you send per month?">
            {renderSingle('emailCampaignsPerMonth', q.emailCampaignsPerMonth, EMAIL_CAMPAIGNS)}
          </Panel>
          <Panel title="Which email platform do you use today?" sub="We use this to plan the migration path. 'No platform yet' means we'll set one up at kickoff.">
            {renderSingle('priorEmailPlatform', q.priorEmailPlatform, EMAIL_PLATFORM)}
          </Panel>
        </>
      )}

      {/* ── smm ── */}
      {arr.includes('smm') && (
        <>
          <div className="q6-cascade-label">Social media management</div>
          <Panel title="How often do you post on social today?">
            {renderSingle('priorSocialFrequency', q.priorSocialFrequency, SOCIAL_FREQUENCY)}
          </Panel>
          <Panel title="Which platforms are you posting on?">
            {renderMulti('priorSocialPlatforms', q.priorSocialPlatforms, SOCIAL_PLATFORMS)}
          </Panel>
        </>
      )}

      {/* ── talent ── */}
      {arr.includes('talent') && (
        <div className="q6-cascade-group">
          <div className="q6-cascade-label">Hiring specialists or contractors</div>
          <Panel title="When would you ideally have this person in seat?" sub="Drives the recruitment urgency boost on the Talent recommendation engine and shifts the Talent capacity bar toward 'Limited' if urgent.">
          {renderSingle('hiringTime', q.hiringTime, HIRING_TIME)}
          </Panel>
        </div>
      )}

      {/* ── fundraising ── */}
      {arr.includes('fundraising') && (
        <>
          <div className="q6-cascade-label">Fundraising or planning an exit</div>
          <Panel title="Where are you in the fundraising journey?">
            {renderSingle('fundraisingStage', q.fundraisingStage, FUNDRAISING_STAGE)}
          </Panel>
          {q.fundraisingStage && (
            <Panel title="How much are you raising (or have raised)?">
              {renderSingle('fundraisingRaiseSize', q.fundraisingRaiseSize, RAISE_SIZE)}
            </Panel>
          )}
          {q.fundraisingRaiseSize && (
            <Panel title="Which investor types are you targeting (or have on cap table)?">
              {renderMulti('fundraisingInvestorTypes', q.fundraisingInvestorTypes, INVESTOR_TYPES)}
            </Panel>
          )}
          {needsTargetVal && Array.isArray(q.fundraisingInvestorTypes) && q.fundraisingInvestorTypes.length >= 1 && (
            <Panel title="What target valuation are you working toward?">
              {renderSingle('fundraisingTargetValuation', q.fundraisingTargetValuation, targetValOpts)}
            </Panel>
          )}
          {Array.isArray(q.fundraisingInvestorTypes) && q.fundraisingInvestorTypes.length >= 1 &&
            (!needsTargetVal || q.fundraisingTargetValuation) && (
            <Panel title="Anything we should know?" sub="Optional context that helps us route you to the right partner and case studies.">
              {renderMulti('fundraisingFlags', q.fundraisingFlags, FUNDRAISING_FLAGS)}
            </Panel>
          )}
        </>
      )}

      {/* ── portal-active banner ── */}
      {arr.includes('portal-active') && (
        <div className="q6-portal-banner thin-glass-frame">
          <span className="q6-portal-banner__check" aria-hidden="true"><window.Check size={14} /></span>
          <span>You're already in the Portal. We'll skip the Founders Portal pitch in Step 5 and slot you straight onto the partner-discount track at kick-off.</span>
        </div>
      )}
    </div>
  );
}
window.Q6CascadeBlock = Q6CascadeBlock;



window.QualifierSection = QualifierSection;

// ── Cohort D bypass screen (Sprint 1) ──────────────────────────────────
// When qualifier is complete and cohort = D, replace the configurator entry
// point with a "let's talk" screen routing to the standard 15-min Cal.com
// link. User can still go back to change qualifier answers.

// ── Whitelabel: per-service margin row ──────────────────────────────────
// Compact margin calculator that renders inline inside each ServiceBlock
// when the agency is on the whitelabel intent. Shows wholesale + retail
// input + margin output + loss warning. Each row owns its own retail state
// so the user can experiment per service without affecting siblings.
function MarginRow({ wholesale, rrp, serviceId, tierName, commitId, service, selectedAddons, mult, overseasCountries, title, flat }) {
  const storageKey = `gg.margin.${serviceId}`;
  const [retail, setRetail] = React.useState(() => {
    try { return localStorage.getItem(storageKey) || ''; } catch (e) { return ''; }
  });
  React.useEffect(() => {
    try { localStorage.setItem(storageKey, retail); } catch (e) {}
  }, [retail, storageKey]);

  // #120 (2026-05-31): per-region wholesale for the location-variable add-on
  // (Overseas Cold Calling). The region follows the markets the agency selects
  // in the add-on's own country picker (selection.overseasCountries), so the
  // wholesale reflects where the calling actually happens. The pills below let
  // them override; until they do, the region tracks the selected markets.
  const _W = (typeof window !== 'undefined' && window.WHOLESALE) ? window.WHOLESALE : null;
  const _ocKey = (Array.isArray(overseasCountries) ? overseasCountries : []).join(',');
  const _derivedRegion = window.regionForCountries ? window.regionForCountries(overseasCountries) : { region: null, byQuote: false, regions: [], hasSelection: false };
  const [wlRegion, setWlRegion] = React.useState(() => {
    if (_derivedRegion.hasSelection && _derivedRegion.region) return _derivedRegion.region;
    try { return localStorage.getItem('gg.wholesaleRegion') || (_W ? _W.defaultRegion : 'uk'); } catch (e) { return 'uk'; }
  });
  const [regionTouched, setRegionTouched] = React.useState(false);
  // Unless the user has manually overridden the region, keep it synced to the
  // costliest band among the selected calling markets.
  React.useEffect(() => {
    if (!regionTouched && _derivedRegion.hasSelection && _derivedRegion.region) {
      setWlRegion(_derivedRegion.region);
      try { localStorage.setItem('gg.wholesaleRegion', _derivedRegion.region); } catch (e) {}
    }
  }, [_ocKey, _derivedRegion.region, _derivedRegion.hasSelection, regionTouched]);
  const changeWlRegion = (r) => {
    setRegionTouched(true);
    setWlRegion(r);
    try { localStorage.setItem('gg.wholesaleRegion', r); } catch (e) {}
  };
  const _regionAddons = (_W && service && Array.isArray(service.addons))
    ? service.addons.filter(a => Array.isArray(selectedAddons) && selectedAddons.includes(a.id) && _W.addons && Object.prototype.hasOwnProperty.call(_W.addons, a.id))
    : [];
  const _hasRegionVar = _regionAddons.length > 0;
  // Resolve each region-variable add-on to its per-region wholesale at wlRegion
  // (which tracks the selected markets). "by quote" (Rest of World / unpriced
  // markets) is surfaced and excluded from the numeric total.
  const _regionLines = _regionAddons.map(a => {
    const w = window.wholesaleAddon ? window.wholesaleAddon(a.id, wlRegion, a.price, mult) : null;
    const markets = (a.id === 'overseas-calling') ? (_derivedRegion.regions || []) : [];
    return { id: a.id, name: a.name, rrp: (typeof a.price === 'number' ? a.price : 0), wholesale: (w && typeof w.value === 'number') ? w.value : null, byQuote: !!(w && w.byQuote), markets: markets };
  });
  const _addonWholesale = _regionLines.reduce((s, l) => s + (typeof l.wholesale === 'number' ? l.wholesale : 0), 0);
  const _addonRrp = _regionLines.reduce((s, l) => s + (l.byQuote ? 0 : l.rrp), 0);
  const _hasByQuote = _regionLines.some(l => l.byQuote);
  // Package wholesale/RRP = service tier + numeric region-variable add-ons.
  // This is what the agency actually pays GoGorilla, so the maths below (your
  // cost, you keep, loss warning, quick fill) all run off these totals.
  const effWholesale = wholesale + _addonWholesale;
  const effRrp = (typeof rrp === 'number' ? rrp : 0) + _addonRrp;

  const retailVal = parseFloat(retail);
  const hasRetail = !isNaN(retailVal) && retailVal > 0;
  const margin = hasRetail ? retailVal - effWholesale : 0;
  const marginPct = hasRetail && retailVal > 0 ? margin / retailVal : 0;
  const isLoss = hasRetail && margin < 0;
  const isGood = hasRetail && margin > 0;

  // Quick-fill multipliers: 1.5x, 2x, 3x of the package wholesale.
  const fillSuggested = (m) => {
    setRetail(String(Math.round(effWholesale * m / 5) * 5));
  };

  const stateClass = isLoss ? 'svc-margin--loss' : (isGood ? 'svc-margin--good' : 'svc-margin--neutral');

  return (
    <div className={`svc-margin ${stateClass}${flat ? ' svc-margin--flat' : ''}`} role="region" aria-label="White-label margin calculator">
      <div className="svc-margin__head">
        <div className="svc-margin__head-icon" aria-hidden="true">
          <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
            <path d="M12 2v20" />
            <path d="M17 6H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
          </svg>
        </div>
        <div className="svc-margin__head-text">
          <div className="svc-margin__title">{title || 'White-label margin'}</div>
          <div className="svc-margin__hint">Type the price you charge your client to see what you keep.</div>
        </div>
      </div>

      <div className="svc-margin__calc">
        <div className="svc-margin__cell svc-margin__cell--cost">
          <div className="svc-margin__cell-lbl">
            <HoverPortalTip
              wrapClassName="svc-margin__cell-lbl-tip-wrap"
              tipClassName="dis-tip dis-tip--above"
              placement="above"
              tip={<>
                <span className="dis-tip__body">This is your wholesale cost, the rate GoGorilla charges you. You bill your client separately at whatever price you choose.</span>
              </>}
            >
              <span className="svc-margin__cell-lbl-text">
                Your wholesale cost
                <window.InfoIcon className="svc-margin__cell-lbl-info" />
              </span>
            </HoverPortalTip>
          </div>
          <div className="svc-margin__cell-num">{window.fmt(effWholesale)}</div>
          <div className="svc-margin__cell-unit">/month</div>
          {effRrp > effWholesale && (
            <div className="svc-margin__cell-rrp" style={{fontSize:'0.72rem', color:'var(--gg-muted, #5a647d)', marginTop:'2px'}}>RRP {window.fmt(effRrp)}/mo</div>
          )}
          {_addonWholesale > 0 && (
            <div className="svc-margin__cell-rrp" style={{fontSize:'0.68rem', color:'var(--gg-muted, #5a647d)', marginTop:'2px'}}>incl. region add-ons</div>
          )}
        </div>

        <div className="svc-margin__op" aria-hidden="true">
          <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
            <path d="M5 12h14" />
            <path d="M13 6l6 6-6 6" />
          </svg>
        </div>

        <div className="svc-margin__cell svc-margin__cell--retail">
          <div className="svc-margin__cell-lbl">
            <HoverPortalTip
              wrapClassName="svc-margin__cell-lbl-tip-wrap"
              tipClassName="dis-tip dis-tip--above"
              placement="above"
              tip={<>
                <span className="dis-tip__body">You bill your client directly, so you choose the retail price and keep the margin. GoGorilla operates anonymously behind the scenes, and your client never sees us. We only show you the wholesale cost so you can decide how much to mark it up. If you ever onboard a client who has already worked with GoGorilla directly, we will let you know.</span>
              </>}
            >
              <span className="svc-margin__cell-lbl-text">
                Your retail price
                <window.InfoIcon className="svc-margin__cell-lbl-info" />
              </span>
            </HoverPortalTip>
          </div>
          <div className="svc-margin__cell-input">
            <span className="svc-margin__currency">£</span>
            <input
              id={`retail-${serviceId}`}
              type="number"
              inputMode="numeric"
              min="0"
              step="50"
              placeholder={String(Math.round(effWholesale * 2 / 5) * 5)}
              value={retail}
              onChange={(e) => setRetail(e.target.value)}
              onClick={(e) => e.stopPropagation()}
              onFocus={(e) => e.target.select()}
            />
          </div>
          <div className="svc-margin__cell-unit">/month</div>
        </div>

        <div className="svc-margin__op" aria-hidden="true">
          <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
            <path d="M5 12h14" />
          </svg>
        </div>

        <div className="svc-margin__cell svc-margin__cell--result">
          <div className="svc-margin__cell-lbl">You keep</div>
          <div className="svc-margin__cell-num">
            {hasRetail ? window.fmt(Math.round(margin)) : <span className="svc-margin__cell-num--mute">£,</span>}
          </div>
          {hasRetail && (
            <span className={`svc-margin__pct ${isLoss ? 'svc-margin__pct--loss' : 'svc-margin__pct--good'}`}>
              {margin >= 0 ? '+' : ''}{Math.round(marginPct * 100)}% margin
            </span>
          )}
          {!hasRetail && <div className="svc-margin__cell-unit">enter retail</div>}
        </div>
      </div>

      {_hasRegionVar && _W && (
        <div className="svc-margin__region" style={{ marginTop: '12px', paddingTop: '12px', borderTop: '1px solid var(--gg-hairline, rgba(15,28,53,0.08))' }} onClick={(e) => e.stopPropagation()}>
          <div className="svc-margin__region-lbl" style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.8rem', fontWeight: 600, color: 'var(--gg-heading, #0F1C35)', marginBottom: '7px' }}>
            Wholesale by calling region
            <HoverPortalTip
              wrapClassName="svc-margin__region-tip-wrap"
              tipClassName="dis-tip dis-tip--above"
              placement="above"
              tip={<span className="dis-tip__body">Overseas calling costs more to deliver in some markets, so your wholesale follows the markets you choose on the add-on above. You can override the region here. We confirm exact per-country rates at onboarding.</span>}
            >
              <span style={{ display: 'inline-flex' }}><window.InfoIcon className="svc-margin__region-info" /></span>
            </HoverPortalTip>
          </div>
          <div className="svc-margin__region-pills" style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
            {(_W.regions || []).map(rc => {
              const on = wlRegion === rc;
              return (
                <button
                  key={rc}
                  type="button"
                  className={`svc-margin__region-pill ${on ? 'is-on' : ''}`}
                  aria-pressed={on}
                  onClick={(e) => { e.stopPropagation(); changeWlRegion(rc); }}
                  style={{
                    padding: '5px 11px', borderRadius: '999px', cursor: 'pointer',
                    fontSize: '0.78rem', fontWeight: 600, lineHeight: 1.2,
                    border: on ? '1.5px solid var(--gg-blue, #002ABF)' : '1.5px solid var(--gg-hairline, rgba(15,28,53,0.14))',
                    color: on ? 'var(--gg-blue, #002ABF)' : 'var(--gg-body, #2C3142)',
                    background: on ? 'rgba(0,42,191,0.06)' : '#fff',
                  }}
                >
                  {(_W.regionLabels && _W.regionLabels[rc]) || rc}
                </button>
              );
            })}
          </div>
          <div className="svc-margin__region-rows" style={{ marginTop: '9px', display: 'flex', flexDirection: 'column', gap: '5px' }}>
            {_regionLines.map(l => {
              const regionLabel = (_W.regionLabels && _W.regionLabels[wlRegion]) || wlRegion;
              const marketsTxt = (l.markets && l.markets.length) ? l.markets.map(rc => (_W.regionLabels && _W.regionLabels[rc]) || rc).join(', ') : null;
              const display = l.byQuote ? 'By quote' : (typeof l.wholesale === 'number' ? `${window.fmt(l.wholesale)}/mo wholesale` : '—');
              return (
                <div key={l.id} className="svc-margin__region-row" style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: '8px', fontSize: '0.8rem' }}>
                  <span style={{ color: 'var(--gg-body, #2C3142)' }}>{l.name} <span style={{ color: 'var(--gg-muted, #6B7280)' }}>· {marketsTxt || regionLabel}</span></span>
                  <span style={{ fontWeight: 700, color: 'var(--gg-heading, #0F1C35)', whiteSpace: 'nowrap' }}>{display}</span>
                </div>
              );
            })}
          </div>
        </div>
      )}

      <div className="svc-margin__quick">
        <span className="svc-margin__quick-lbl">Quick fill:</span>
        <button type="button" className="svc-margin__quick-btn" onClick={(e) => { e.stopPropagation(); fillSuggested(1.5); }}>1.5× ({window.fmt(Math.round(effWholesale * 1.5 / 5) * 5)})</button>
        <button type="button" className="svc-margin__quick-btn" onClick={(e) => { e.stopPropagation(); fillSuggested(2); }}>2× ({window.fmt(Math.round(effWholesale * 2 / 5) * 5)})</button>
        <button type="button" className="svc-margin__quick-btn" onClick={(e) => { e.stopPropagation(); fillSuggested(3); }}>3× ({window.fmt(Math.round(effWholesale * 3 / 5) * 5)})</button>
      </div>

      {isLoss && (
        <div className="svc-margin__warn" role="alert">
          <span className="svc-margin__warn-icon" aria-hidden="true">⚠</span>
          <span>Loss pricing. Raise your retail above {window.fmt(effWholesale)}/mo to break even on this service.</span>
        </div>
      )}
    </div>
  );
}
window.MarginRow = MarginRow;

// ── #120 (2026-05-31): per-add-on white-label margin, co-located with the
// add-on so the agency sees the regional wholesale right where they pick the
// markets. Region derives from the add-on's own country picker (costliest band;
// by-quote if any unpriced market); the pills override it. Reuses MarginRow for
// the wholesale -> your price -> you keep maths so it stays visually consistent.
function AddonMarginRow({ addon, regions }) {
  const _W = (typeof window !== 'undefined' && window.WHOLESALE) ? window.WHOLESALE : null;
  const _cr = window.callingRegion ? window.callingRegion(regions) : { region: null, byQuote: false, regions: [], hasSelection: false };
  if (!_W || !addon) return null;
  if (!_cr.hasSelection) {
    return (
      <div className="addon__margin" onClick={(e) => e.stopPropagation()} style={{ marginTop: '10px', fontSize: '0.8rem', color: 'var(--gg-muted, #6B7280)' }}>
        Pick a calling region above to see your wholesale cost and margin.
      </div>
    );
  }
  const w = window.wholesaleAddon ? window.wholesaleAddon(addon.id, _cr.region, addon.price, _W.fallbackMultiplier) : null;
  const byQuote = !!(w && w.byQuote);
  const wholesale = (w && typeof w.value === 'number') ? w.value : null;
  return (
    <div className="addon__margin" onClick={(e) => e.stopPropagation()} style={{ marginTop: '10px' }}>
      {byQuote ? (
        <div style={{ fontSize: '0.8rem', color: 'var(--gg-body, #2C3142)', padding: '9px 11px', borderRadius: '10px', background: 'rgba(15,28,53,0.04)' }}>
          Wholesale for this market is confirmed by quote at onboarding.
        </div>
      ) : (
        <window.MarginRow
          wholesale={wholesale}
          rrp={addon.price}
          serviceId={`addon-${addon.id}`}
          tierName={addon.name}
          title={`${addon.name} margin`}
          flat
        />
      )}
    </div>
  );
}
window.AddonMarginRow = AddonMarginRow;

// ── SPRINT 2: WHITE-LABEL MARGIN CALCULATOR ──────────────────────────
// Renders only on agency-whitelabel intent. For each enabled service, lets
// the agency type their retail price and shows margin vs GoGorilla wholesale.
// Surfaces a red warning if retail < wholesale ("loss-pricing warning").
// LEGACY: kept for backwards compat but no longer rendered. The MarginRow
// component above is now used inline inside each ServiceBlock instead.
function WhiteLabelMarginCalculator({ state }) {
  const [retail, setRetail] = React.useState({}); // serviceId -> string
  const selections = state.selections || {};
  const ids = Object.keys(selections);
  if (ids.length === 0) {
    return (
      <div className="margin-calc margin-calc--empty">
        <div className="margin-calc__head">
          <strong>White-label margin calculator</strong>
          <span className="margin-calc__sub">Add services to see margin vs your retail price.</span>
        </div>
      </div>
    );
  }
  return (
    <div className="margin-calc">
      <div className="margin-calc__head">
        <strong>White-label margin calculator</strong>
        <span className="margin-calc__sub">Type the price you'd charge your client. We'll show your margin vs the GoGorilla wholesale rate.</span>
      </div>
      <div className="margin-calc__grid">
        {ids.map(sid => {
          const svc = window.SERVICES.find(x => x.id === sid);
          const sel = selections[sid];
          if (!svc || !sel) return null;
          const tier = window.findTier(svc, sel.tier);
          if (!tier || tier.isEnterprise) return null;
          const opts = window.commitsFor(svc);
          const cid = (opts && opts.some(o => o.id === sel.commitId)) ? sel.commitId : '12';
          const p = window.priceFor(svc, sel.tier, cid);
          if (p.custom || p.oneTime) return null;
          const wholesale = Math.round(p.value * window.getAgencyMultiplier(state, sid));
          const retailVal = parseFloat(retail[sid] || '');
          const hasRetail = !isNaN(retailVal) && retailVal > 0;
          const margin = hasRetail ? retailVal - wholesale : 0;
          const marginPct = hasRetail && retailVal > 0 ? margin / retailVal : 0;
          const isLoss = hasRetail && margin < 0;
          return (
            <div key={sid} className={`margin-row ${isLoss ? 'margin-row--loss' : ''}`}>
              <div className="margin-row__svc">
                <div className="margin-row__name">{svc.name}</div>
                <div className="margin-row__tier">{tier.name} · {cid}-mo</div>
              </div>
              <div className="margin-row__nums">
                <div className="margin-row__num">
                  <div className="margin-row__num-lbl">Wholesale</div>
                  <div className="margin-row__num-val">{window.fmt(wholesale)}/mo</div>
                </div>
                <div className="margin-row__num">
                  <div className="margin-row__num-lbl">Your retail</div>
                  <div className="margin-row__num-input">
                    <span>£</span>
                    <input
                      type="number"
                      min="0"
                      placeholder="0"
                      value={retail[sid] || ''}
                      onChange={(e) => setRetail(r => ({ ...r, [sid]: e.target.value }))}
                    />
                  </div>
                </div>
                <div className="margin-row__num">
                  <div className="margin-row__num-lbl">Margin</div>
                  <div className={`margin-row__num-val margin-row__num-val--${isLoss ? 'loss' : (hasRetail ? 'good' : 'mute')}`}>
                    {hasRetail ? `${window.fmt(Math.round(margin))} (${Math.round(marginPct * 100)}%)` : ','}
                  </div>
                </div>
              </div>
              {isLoss && (
                <div className="margin-row__warn" role="alert">
                  ⚠ Loss-pricing. Your retail is below GoGorilla wholesale. Raise it above £{wholesale}/mo to break even.
                </div>
              )}
            </div>
          );
        })}
      </div>
    </div>
  );
}
window.WhiteLabelMarginCalculator = WhiteLabelMarginCalculator;

// ── QUALIFIER VALIDATION MODAL ───────────────────────────────────────
// Shown when user clicks Next on the qualifier step with unanswered questions.

// Sprint 4, GorillaPerks chip strip. Renders below GorillaMatrix incentives
// on every active paid plan; auto-pulls perks via window.gorillaPerksFor(client).

// ── ANSWERS RECAP ────────────────────────────────────────────────────────
// Renders just below the section header on the FIRST service step (step 1)
// only. Shows pill chips for the user's client-type pick + each answered
// qualifier question, plus an "Edit" link that jumps back to step 0 where
// they were originally answered. Helps the user verify their context as
// they move into service selection without having to scroll back up.

// ── COMPARE ALL TIERS MODAL ──────────────────────────────────────────────
// Opens from a "Compare all tiers" link next to "CHOOSE A TIER" on a service
// card. Renders a side-by-side matrix using the per-service data shape
// defined in window.TIER_COMPARISON. Portalled to document.body so it
// escapes the parent stacking context. Closes on backdrop click, × button,
// Escape key, or the "Got it" button.
function CompareTiersModal({ service, onClose }) {
  const comp = (window.TIER_COMPARISON || {})[service.id];
  React.useEffect(() => {
    const onKey = (e) => { if (e.key === 'Escape') onClose(); };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [onClose]);
  if (!comp) return null;
  const tiers = window.tiersFor(service);
  const recommendedId = tiers.find(t => t.id === 'grow') ? 'grow' : null;
  const renderCheckCell = (val) => {
    if (val === 'yes')   return <span className="cmp-cell cmp-cell--yes" aria-label="Included"><svg viewBox="0 0 12 12" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="2 6.5 5 9.5 10 3.5"/></svg></span>;
    if (val === 'addon') return <span className="cmp-cell cmp-cell--addon" aria-label="Available as add-on">add-on</span>;
    return <span className="cmp-cell cmp-cell--no" aria-label="Not included">-</span>;
  };
  // Group rows into sections so each tier card can render its own section
  // stack. Header rows start a new section; subsequent check/text/best rows
  // belong to it until the next header.
  const sections = (() => {
    const out = [];
    let cur = null;
    comp.rows.forEach((row) => {
      if (row.type === 'header') {
        cur = { label: row.label, items: [] };
        out.push(cur);
      } else if (cur) {
        cur.items.push(row);
      }
    });
    return out;
  })();
  return ReactDOM.createPortal(
    <div className="cmp-modal" role="dialog" aria-modal="true" aria-labelledby="cmp-modal-title">
      <div className="cmp-modal__backdrop" onClick={onClose} />
      <div className="cmp-modal__panel" onClick={(e) => e.stopPropagation()}>
        <button type="button" className="cmp-modal__close" onClick={onClose} aria-label="Close comparison">
          <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
            <line x1="3" y1="3" x2="13" y2="13" />
            <line x1="13" y1="3" x2="3" y2="13" />
          </svg>
        </button>
        <h2 id="cmp-modal-title" className="cmp-modal__title">{service.name} tiers compared</h2>
        {comp.subtitle && <p className="cmp-modal__subtitle">{comp.subtitle}</p>}

        <div className="cmp-modal__body">
          <div className="cmp-cards" style={{ gridTemplateColumns: `repeat(${tiers.length}, 1fr)` }}>
            {tiers.map(t => {
              const isRec = t.id === recommendedId;
              const priceLabel = (() => {
                if (t.isEnterprise) return 'Custom';
                const p = window.priceFor(service, t.id, 12);
                return p.custom ? 'Custom' : window.fmt(p.value);
              })();
              return (
                <div
                  key={t.id}
                  className={`cmp-card ${isRec ? 'cmp-card--rec' : ''} ${t.isEnterprise ? 'cmp-card--ent' : ''}`}
                >
                  {isRec && <span className="cmp-card__rec-pill">Most popular</span>}
                  <div className="cmp-card__head">
                    <div className="cmp-card__tier-name">{t.name}</div>
                    <div className="cmp-card__tier-price">
                      {priceLabel}
                      {!t.isEnterprise && priceLabel !== 'Custom' && <span className="cmp-card__tier-sub">/mo</span>}
                    </div>
                  </div>
                  <div className="cmp-card__sections">
                    {sections.map((sec, si) => {
                      const rendered = sec.items.map((row, ri) => {
                        if (row.type === 'check') {
                          const v = row.tiers?.[t.id] || 'no';
                          const cls =
                            v === 'yes'   ? 'cmp-card__row--yes' :
                            v === 'addon' ? 'cmp-card__row--addon' :
                                            'cmp-card__row--no';
                          const suffix = v === 'addon' ? ' - add-on' : '';
                          return (
                            <li key={ri} className={`cmp-card__row ${cls}`}>
                              <span className="cmp-card__mark" aria-hidden="true">{v === 'yes' ? '✓' : '−'}</span>
                              <span className="cmp-card__text">{row.name}{suffix}</span>
                            </li>
                          );
                        }
                        const val = row.tiers?.[t.id];
                        if (!val) return null;
                        const isBest = row.type === 'best';
                        return (
                          <li key={ri} className={`cmp-card__row ${isBest ? 'cmp-card__row--best' : 'cmp-card__row--text'}`}>
                            <span className="cmp-card__text">{val}</span>
                          </li>
                        );
                      }).filter(Boolean);
                      if (rendered.length === 0) return null;
                      return (
                        <div key={si} className="cmp-card__sect">
                          <div className="cmp-card__sect-eyebrow">{sec.label}</div>
                          <ul className="cmp-card__sect-list">{rendered}</ul>
                        </div>
                      );
                    })}
                  </div>
                </div>
              );
            })}
          </div>
        </div>

        <div className="cmp-modal__actions">
          <button type="button" className="btn btn--primary" onClick={onClose}>Got it</button>
        </div>
      </div>
    </div>,
    document.body
  );
}
window.CompareTiersModal = CompareTiersModal;

// ── QUALIFIER SKIP FLOATER ───────────────────────────────────────────────
// Sticky banner pinned to the top of the viewport whenever the qualifier
// section is in view. Lets the user bypass the 3 questions if they just
// want to browse pricing, we'll cover the same questions on the call.
// Uses IntersectionObserver to fade in/out as the section enters/leaves
// the viewport, so it's not always pinned. Hidden once user has skipped or
// already answered everything (no point showing the offer if no work left).

// ── ScrollProgressDot ─────────────────────────────────────────────────
// 2026-05-29: subtle floating bottom-right indicator. Circular ring fills
// as the user scrolls; contains a down-arrow until they reach the bottom,
// then flips to an up-arrow + top-line. Clickable both ways.
function ScrollProgressDot() {
  const [scrollProgress, setScrollProgress] = React.useState(0);
  const [atBottom, setAtBottom] = React.useState(false);
  const [scrollable, setScrollable] = React.useState(false);

  React.useEffect(() => {
    const update = () => {
      const doc = document.documentElement;
      const max = doc.scrollHeight - doc.clientHeight;
      if (max < 120) { setScrollable(false); return; }
      setScrollable(true);
      const p = Math.min(1, Math.max(0, window.scrollY / max));
      setScrollProgress(p);
      // Within ~2% of the bottom counts as "at bottom" so we don't have to
      // hit pixel-perfect to flip the icon.
      setAtBottom(p >= 0.98);
    };
    window.addEventListener('scroll', update, { passive: true });
    window.addEventListener('resize', update);
    // Also re-evaluate periodically while content reflows (collapsibles, etc.)
    const interval = setInterval(update, 800);
    update();
    return () => {
      window.removeEventListener('scroll', update);
      window.removeEventListener('resize', update);
      clearInterval(interval);
    };
  }, []);

  const handleClick = () => {
    if (atBottom) {
      window.scrollTo({ top: 0, behavior: 'smooth' });
    } else {
      window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'smooth' });
    }
  };

  if (!scrollable) return null;

  const R = 16;
  const CIRC = 2 * Math.PI * R;
  const dashOffset = CIRC * (1 - scrollProgress);

  return (
    <button
      type="button"
      className={`scroll-progress-dot ${atBottom ? 'scroll-progress-dot--top' : ''}`}
      onClick={handleClick}
      aria-label={atBottom ? 'Scroll to top of page' : 'Scroll to bottom of page'}
    >
      <svg viewBox="0 0 40 40" width="40" height="40" aria-hidden="true">
        {/* Track */}
        <circle cx="20" cy="20" r={R} fill="none" stroke="rgba(0, 42, 191, 0.14)" strokeWidth="1.6" />
        {/* Progress arc */}
        <circle
          cx="20" cy="20" r={R}
          fill="none"
          stroke="rgba(0, 42, 191, 0.62)"
          strokeWidth="1.6"
          strokeDasharray={CIRC}
          strokeDashoffset={dashOffset}
          transform="rotate(-90 20 20)"
          strokeLinecap="round"
          style={{ transition: 'stroke-dashoffset 100ms linear' }}
        />
        {atBottom ? (
          <g>
            {/* Top line (above the arrow) — visual cue for "scroll to top" */}
            <line x1="14" y1="12.5" x2="26" y2="12.5"
                  stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
            {/* Up arrow */}
            <path d="M20 26 L20 17 M16 21 L20 17 L24 21"
                  fill="none" stroke="currentColor" strokeWidth="1.7"
                  strokeLinecap="round" strokeLinejoin="round" />
          </g>
        ) : (
          /* Down arrow */
          <path d="M20 14 L20 25 M16 21 L20 25 L24 21"
                fill="none" stroke="currentColor" strokeWidth="1.7"
                strokeLinecap="round" strokeLinejoin="round" />
        )}
      </svg>
    </button>
  );
}
window.ScrollProgressDot = ScrollProgressDot;

// ── InfoIcon ─────────────────────────────────────────────────────────
// 2026-05-29: canonical info icon. Mirrors the SVG used on the sidebar's
// .summary__total-info (the icon next to Monthly Total / Setup fees).
// Props: className (appended), title (native tooltip + aria-label),
// onClick (optional), size (default 13). Renders a <button> so it gets
// focus/hover affordances out of the box.
function InfoIcon({ className = '', title, onClick, size = 13, tabIndex = -1 }) {
  /* 2026-05-29 v2: no longer renders native `title` attribute (which
     triggers the browser's built-in dark tooltip). Callers that want a
     visible hover tip should wrap this in <window.HoverPortalTip> with
     the canonical .dis-tip class. The title prop is still accepted and
     used as aria-label so screen readers get the same descriptive copy. */
  return (
    React.createElement(
      'button',
      {
        type: 'button',
        className: `info-icon ${className}`.trim(),
        'aria-label': title || 'More info',
        onClick,
        tabIndex,
      },
      React.createElement(
        'svg',
        {
          viewBox: '0 0 24 24', width: size, height: size, fill: 'none',
          stroke: 'currentColor', strokeWidth: 2.2, strokeLinecap: 'round',
          strokeLinejoin: 'round', 'aria-hidden': 'true',
        },
        React.createElement('circle', { cx: 12, cy: 12, r: 10 }),
        React.createElement('line', { x1: 12, y1: 16, x2: 12, y2: 12 }),
        React.createElement('circle', { cx: 12, cy: 8, r: 0.6, fill: 'currentColor' })
      )
    )
  );
}
window.InfoIcon = InfoIcon;

function BuildPage({ state, dispatch, onNext, addonsDefaultOpen, savedCount, step, flow, onJumpStep }) {
  // Sprint 3, make state accessible from ServiceBlock children for cost-per-meeting math.
  if (typeof window !== 'undefined') window.__lastBuildPageState = state;
  const { clientTypeId, intentId, selections, pendingCommits } = state;
  const ct = window.CLIENT_TYPES.find(c => c.id === clientTypeId);
  const isAgency = clientTypeId === 'agency';
  const agencyIntent = isAgency ? ct?.intents?.find(i => i.id === intentId) : null;
  const agyMult = window.getAgencyMultiplier(state);
  const count = Object.keys(selections).length;
  // Active flow comes from props (computed in App per client-type). Fall back
  // to the static BUILD_STEPS for safety.
  const activeFlow = flow || window.BUILD_STEPS;
  const stepDef = activeFlow[step] || activeFlow[0];
  const isClientStep = step === 0;
  // Qualifier (28-panel tree) was moved to Step 6 (Ready to launch) inside
  // YoureSetPage per task #248. BuildPage no longer hosts a qualifier on
  // Step 0, so this is always false. Keeping the alias means downstream
  // checks (visibleServices, currentStepSatisfied, isCohortDBypass) treat
  // BuildPage as qualifier-free.
  const isQualifierStep = false;
  const isWhitelabelAgency = clientTypeId === 'agency' && intentId === 'agency-whitelabel';
  const warmth = window.computeWarmth ? window.computeWarmth(state.qualifier) : 0;
  const cohort = window.computeCohort ? window.computeCohort(warmth) : 'D';
  // 2026-05-26: qualifierSkipped state removed. qualifierIsComplete now
  // depends purely on whether the user actually completed the qualifier.
  const qualifierIsComplete = window.qualifierComplete ? window.qualifierComplete(state.qualifier, state.clientTypeId) : false;
  const isCohortDBypass = isQualifierStep && qualifierIsComplete && cohort === 'D';
  // Sprint 2 v3, collect ids of unanswered qualifier questions for popup + highlighting.
  const _missingQs = (() => {
    if (!isQualifierStep) return [];
    const qs = window.QUALIFIER_QUESTIONS || [];
    const out = [];
    const founderLike = !state.clientTypeId || state.clientTypeId === 'founder';
    for (const q of qs) {
      if (q.foundersOnly && !founderLike) continue;
      if (!state.qualifier?.[q.id]) out.push({ id: q.id, label: q.label });
    }
    return out;
  })();
  const _missingIds = new Set(_missingQs.map(q => q.id));
  const [_qualifierErrorOpen, _setQualifierErrorOpen] = uS(false);
  const [_qualifierErrorTick, _setQualifierErrorTick] = uS(0);
  // #92 — agency-whitelabel "client ready?" gate (null = unanswered, 'yes'/'no')
  const [clientReadyChoice, setClientReadyChoice] = uS(null);
  // §17, modal that surfaces what services were auto-pre-selected. Opens
  // §17 pre-select modal removed, qualifier is now optional and on last page,
  // so we no longer auto-apply gap recommendations or surface the "we picked
  // services for you" dialog.
  // 2026-05-22: was window.SERVICES.filter(...) which preserved the SERVICES
  // array definition order. Switched to a map+lookup over stepDef.serviceIds
  // so the on-page order matches the per-step serviceIds array exactly
  // (e.g. Talent step shows Full-Time before Part-Time as configured).
  const visibleServices = (isClientStep || isQualifierStep)
    ? []
    : stepDef.serviceIds.map(id => window.SERVICES.find(s => s.id === id)).filter(Boolean);
  // Map step.id → page header copy
  const headers = {
    whitelabel:  { eyebrow: 'White-label plan',    titleA: 'Pick your ',                titleB: 'white-label', titleC: ' plan.', sub: 'Select your white-label tier. This locks in your 40% discount and unbranded fulfilment pathway.' },
    growth:      { eyebrow: 'Growth services',    titleA: 'Pick the engines for ',     titleB: 'demand',     titleC: '.', sub: 'Sales, paid advertising, and email marketing. Pick what mix fits your goals.' },
    creative:    { eyebrow: 'Creative services',  titleA: 'Now build the ',            titleB: 'content',    titleC: '.', sub: 'Social, content, and 3D animation. Your owned channel and brand engine.' },
    talent:      { eyebrow: 'Talent solutions',   titleA: 'Embed the ',                titleB: 'team',       titleC: '.',              sub: 'Dedicated specialists embedded with your team. We handle recruitment, HR, payroll, and compliance; you brief the work.' },
    fundraising: { eyebrow: 'Fundraising',        titleA: 'Get investor-',             titleB: 'ready',      titleC: '.', sub: 'Pitch materials, investor research, and our Founders Portal, all aligned with your raise.' },
  };
  const header = (stepDef.id === 'whitelabel' && isAgency && !isWhitelabelAgency)
    ? { eyebrow: 'Agency plan', titleA: 'Choose your ', titleB: 'agency', titleC: ' plan.', sub: 'Select your agency plan. This sets your discount across all GoGorilla services.' }
    : headers[stepDef.id];
  // Reachability: a future step is reachable if all earlier steps are 'satisfied'.
  // Step 0 (Client) requires clientTypeId. Service-step satisfaction is "any service
  // count ≥ 0" (i.e. always satisfied, picking nothing on a step is allowed).
  // Which steps are gated (require a prior action to unlock).
  // Gate 1: any step > 0 requires clientTypeId.
  // Gate 2: agencies must select a white-label plan before steps after whitelabel.
  const whitelabelStepIdx = isAgency ? activeFlow.findIndex(s => s.id === 'whitelabel') : -1; // -1 for Path A (no whitelabel step)
  const whitelabelSelected = !!selections['whitelabel'];
  // Sprint 1, qualifier step always sits at index 1 (right after client) when
  // a clientTypeId is set. Steps beyond it are locked until qualifier complete.
  const qualifierStepIdx = clientTypeId ? activeFlow.findIndex(s => s.id === 'qualifier' || s.isQualifier) : -1;
  const isStepLocked = (target) => {
    if (target <= step) return false;  // current + completed steps are never locked visually
    if (target === 0) return false;
    if (!clientTypeId) return true;
    if (clientTypeId === 'agency' && !intentId && target > 0) return true;
    if (qualifierStepIdx > -1 && target > qualifierStepIdx && !qualifierIsComplete) return true;
    if (isWhitelabelAgency && whitelabelStepIdx > -1 && target > whitelabelStepIdx && !whitelabelSelected) return true;
    return false;
  };
  const lockReasonFor = (target) => {
    if (target <= step) return null;   // no lock tooltip on current or completed steps
    if (target === 0) return null;
    if (!clientTypeId) return 'Select a client type first to unlock this step';
    if (clientTypeId === 'agency' && !intentId && target > 0) return 'Choose how you plan to use GoGorilla first to unlock this step';
    if (qualifierStepIdx > -1 && target > qualifierStepIdx && !qualifierIsComplete) return 'Answer the qualifier questions to unlock this step';
    if (isWhitelabelAgency && whitelabelStepIdx > -1 && target > whitelabelStepIdx && !whitelabelSelected) return 'Choose a white-label plan on the previous step first';
    return null;
  };
  const canJumpTo = (target) => {
    if (target <= step) return true;       // always allow going back
    if (isStepLocked(target)) return false; // hard gate
    return false;                          // Batch 27: block all forward tab nav — use the Next button
  };
  // Whether the current step's own gate is satisfied (controls Next button + inline prompt).
  const isWhitelabelStep = stepDef?.id === 'whitelabel';
  // Sprint 1, qualifier step requires Q1, Q4 (and Q1.5 when conditional fires).
  // Cohort D bypass is its own terminal state, Next is hidden, replaced with
  // book-a-call CTA inside the bypass component.
  const currentStepSatisfied =
    !(isClientStep && !clientTypeId) &&
    !(isClientStep && clientTypeId === 'agency' && !intentId) &&
    !(isQualifierStep && !qualifierIsComplete) &&
    !(isWhitelabelAgency && isWhitelabelStep && !whitelabelSelected);
  const nextDisabledReason = !currentStepSatisfied
    ? (isClientStep && !clientTypeId)
      ? 'Select a client type to continue'
      : (isClientStep && clientTypeId === 'agency' && !intentId)
        ? 'Choose how you plan to use GoGorilla above to continue'
          : (isQualifierStep && !qualifierIsComplete)
            ? 'Answer all questions to continue'
            : 'Choose a whitelabel option to continue'
    : null;

  // ── Waitlist toast ── shown when user adds a 'full' capacity service.
  // We watch `selections` for newly-added full-capacity service ids via an
  // effect, and trigger an imperative DOM-based toast (the React-state
  // approach was being mysteriously reset by another render somewhere).
  const prevSelectionIdsRef = uR(new Set(Object.keys(selections)));
  uE(() => {
    const prev = prevSelectionIdsRef.current;
    const curr = new Set(Object.keys(selections));
    let newlyAddedFullId = null;
    curr.forEach(id => {
      if (!prev.has(id)) {
        const cap = window.SERVICE_CAPACITY?.[id];
        const sel = selections[id];
        const isEnt = sel?.tier === 'enterprise';
        if (cap?.status === 'full' && !isEnt && !newlyAddedFullId) {
          newlyAddedFullId = id;
        }
      }
    });
    prevSelectionIdsRef.current = curr;
    if (newlyAddedFullId) {
      const svc = window.SERVICES.find(s => s.id === newlyAddedFullId);
      if (svc && typeof window.showWaitlistToast === 'function') {
        window.showWaitlistToast(svc.name);
      }
    }
  }, [selections]);

  // ── Add-on waitlist toast ── parallel to the service-level toast above.
  // Watches every selected service's add-on set, and when a newly-toggled-on
  // add-on has 'full' onboarding availability, fires a waitlist toast naming
  // that addon (so the user understands why no charge appeared in the calc).
  // 2026-05-23: seed with the CURRENT addon keys so we don't fire a "newly
  // added" toast for every already-selected full-capacity add-on the first
  // time this effect runs after a BuildPage remount (e.g. clicking Back from
  // Step 6 / checkout). Without this seed the toast loop re-triggered every
  // time the user navigated back, even though the addon was already on.
  const prevAddonKeysRef = uR((() => {
    const s = new Set();
    Object.entries(selections).forEach(([sid, sel]) => {
      (sel.addons || []).forEach(aid => s.add(`${sid}:${aid}`));
    });
    return s;
  })());
  uE(() => {
    const curr = new Set();
    Object.entries(selections).forEach(([sid, sel]) => {
      (sel.addons || []).forEach(aid => curr.add(`${sid}:${aid}`));
    });
    const prev = prevAddonKeysRef.current;
    let toastedAddon = null;
    curr.forEach(key => {
      if (prev.has(key) || toastedAddon) return;
      const [sid, aid] = key.split(':');
      const svc = window.SERVICES.find(s => s.id === sid);
      const a = svc?.addons?.find(x => x.id === aid);
      if (!a) return;
      const cap = window.addonAvailability ? window.addonAvailability(a) : null;
      if (cap?.status === 'full' && !a.custom && !a.free) {
        const _sgPrefixes = {
          'usage-rights': 'Extended Usage Rights: ',
          'talent-casting': 'Talent Casting and Management: ',
          '3d-anim-content': 'Additional 3D Animation Content: ',
          '3d-render-quality': 'Render Quality Upgrade: ',
          '3d-voice-over': 'Voice-Over: ',
          '3d-maintenance': 'Maintenance Plan: ',
          '3d-usage-rights': 'Extended Usage Rights: ',
          '3d-localisation': 'Multi-Language Localisation: ',
        };
        toastedAddon = (a.subGroup && _sgPrefixes[a.subGroup] ? _sgPrefixes[a.subGroup] : '') + a.name;
      }
    });
    prevAddonKeysRef.current = curr;
    if (toastedAddon && typeof window.showWaitlistToast === 'function') {
      window.showWaitlistToast(toastedAddon);
    }
  }, [selections]);

  // ── Auto-scroll to services section once an intent is picked ──
  // Skip the very first render so loading the page with a saved intent doesn't yank the user down.
  const hasMountedIntentRef = uR(false);
  uE(() => {
    if (!hasMountedIntentRef.current) {
      hasMountedIntentRef.current = true;
      return;
    }
    if (!intentId) return;
    // Defer to next tick so the just-revealed section has measured layout.
    const t = setTimeout(() => {
      const el = document.getElementById('services-section');
      if (!el) return;
      const rect = el.getBoundingClientRect();
      const top = window.scrollY + rect.top - 24; // tiny breathing room above
      window.scrollTo({ top, behavior: 'smooth' });
    }, 80);
    return () => clearTimeout(t);
  }, [intentId]);

  return (
    <section className="page">
      <div className="container">
        {/* Sprint 4, AE arrival banner. Shown when user lands via ?ref=naz_call_<lead_id>. */}
        {state.ae_ref && /^naz_call_/i.test(state.ae_ref) && (
          <div className="ae-banner" role="status">
            <span className="ae-banner__icon" aria-hidden="true">
              <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
            </span>
            <span className="ae-banner__body">
              <strong>Welcome. Your account exec has prepared this quote for you.</strong>
              <span className="ae-banner__sub">We've pre-filled what we discussed on the call. Review the recommendations below, tweak anything, and we'll pick up where we left off.</span>
            </span>
          </div>
        )}
        <window.StepIndicator step={step} flow={activeFlow} onJump={onJumpStep} onNudge={() => {
          // #53: scroll to the bottom of the current step (its Next button) so an
          // unvisited tab click guides the user forward instead of doing nothing.
          const el = document.querySelector('.page__foot--steps') || document.querySelector('.page__foot');
          if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
          else window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'smooth' });
        }} canJumpTo={canJumpTo} isStepLocked={isStepLocked} lockReasonFor={lockReasonFor} clientTypeId={clientTypeId} intentId={intentId} />

        {/* Stale-data prompt, spec §3.1 / W5: shown when the restored
            localStorage state is more than 14 days old. */}
        {state.isStale && (
          <StalePrompt onDismiss={() => dispatch({ type: 'DISMISS_STALE_PROMPT' })} />
        )}

        {/* "Start fresh" link, spec §3.3 + Open Q5: visible only when there's
            saved data to clear. One click clears localStorage + hard-reloads
            so we boot from initialBase with no prefilled state. */}
        {savedCount > 0 && (
          <div className="start-fresh-row">
            <button
              type="button"
              className="start-fresh-link"
              onClick={() => {
                try { window.localStorage.removeItem('gg.pricing-cart.v1'); } catch (e) {}
                try { window.location.reload(); } catch (e) {}
              }}
              aria-label="Start fresh, clear all selections and reload"
            >
              <span aria-hidden="true">↻</span> Start fresh
            </button>
          </div>
        )}

        {savedCount > 0 && isClientStep && (
          <div className="welcome-back">
            <div className="welcome-back__icon">↻</div>
            <div className="welcome-back__body">
              <strong>Welcome back.</strong> You have <strong>{savedCount} service{savedCount === 1 ? '' : 's'}</strong> saved in your plan from last time.
            </div>
            <button
              className="btn btn--primary btn--sm"
              onClick={() => onJumpStep && onJumpStep(1)}
            >
              Resume <span className="btn__arrow">›</span>
            </button>
          </div>
        )}

        {/* Single grid, calculator sidebar persists across both sections */}
        <div className="calc__grid">
          <div className="calc__main">
            {isClientStep ? (
              /* ── STEP 0 (Sprint 5, combined): top heading + client type + qualifier ── */
              <>
                {/* Top heading per the mockup, eyebrow + headline + subtitle. */}
                <div className="build-section build-section--intro">
                  <div className="section-head">
                    <div className="section-head__eyebrow section-head__eyebrow--accent">GorillaMatch&trade; &middot; AI-Powered Pricing Engine</div>
                    <h2 className="section-head__title">
                      {isPostCallMode()
                        ? (postCallName() ? <>Thanks for booking your call, <em>{postCallName()}.</em></> : <>Thanks for <em>scheduling.</em></>)
                        : <>Build your <em>plan.</em></>}
                    </h2>
                    <p className="section-head__sub">
                      {isPostCallMode()
                        ? 'Select the services you are interested in and tell us a little bit more so we can make the most of your call.'
                        : 'Match with the right services and pricing in under 60 seconds. Start by telling us who you are.'}
                    </p>
                  </div>
                </div>

                <ClientTypeSection
                  clientTypeId={clientTypeId}
                  setClientType={(id) => {
                    // Auto-advance: pick client type → directly to services.
                    // Default intent to 'proposal' if user hasn't set one;
                    // the intent disambiguator now lives elsewhere (Step 6).
                    dispatch({ type: 'SET_CLIENT', id });
                    if (id === 'agency') {
                      // Stay on step 0 -- agency intent picker must be answered first.
                      // setIntent below advances to step 1 once a path is chosen.
                      return;
                    }
                    if (id === 'investor') {
                      // #75: investors must pick an investment intent before progressing.
                      return;
                    }
                    if (!state.intentId) dispatch({ type: 'SET_INTENT', id: 'proposal' });
                    dispatch({ type: 'SET_STEP', step: 1 });
                  }}
                  intentId={intentId}
                  setIntent={(id) => {
                    dispatch({ type: 'SET_INTENT', id });
                    setClientReadyChoice(null); // reset when intent changes
                    // #75: marketing due-diligence (investor-considering) books a free
                    // assessment from step 0, so don't advance into the priced flow.
                    // Batch 17 (Alexander Loom 15): white-label no longer gates on a
                    // client-ready question; it advances straight into the calculator.
                    if (id === 'investor-considering') return;
                    dispatch({ type: 'SET_STEP', step: 1 });
                  }}
                  selectionCount={count}
                  confirmClientSwitch={(id) => dispatch({ type: 'CLEAR_FOR_CLIENT_SWITCH', id })}
                  /* 2026-05-29: client-ready question now lives INSIDE the
                     white-label card. State + advance handler are passed in. */
                  clientReadyChoice={clientReadyChoice}
                  setClientReadyChoice={setClientReadyChoice}
                  onClientReadyYes={() => dispatch({ type: 'SET_STEP', step: 1 })}
                />

{/* Client-ready question now lives INSIDE the white-label card itself
                    (see ClientTypeSection > agency-intent-card__client-ready).
                    The old standalone block was removed 2026-05-29. */}

                {/* Qualifier moved to Step 6 (Ready to launch) per latest spec. Step 0
                    is now client-type only, clicking a card auto-advances to
                    services. Intent disambiguator + agency intent picker
                    inside ClientTypeSection still control flow shape. */}
              </>
            ) : (
              /* ── STEPS 1-4: services for this group ── */
              <div className="build-section">
                <div className="section-head">
                  <div className="section-head__eyebrow">{header?.eyebrow}</div>
                  <h2 className="section-head__title">
                    {(() => {
                      // Move trailing punctuation (. ? ! …) on the LAST chunk into the <em>
                      // so the period/question mark renders in brand blue alongside titleB.
                      const tail = header?.titleC || '';
                      const m = tail.match(/^(.*?)([.?!…]+)\s*$/);
                      if (m) {
                        const [, mid, punct] = m;
                        return <>{header?.titleA}<em>{header?.titleB}{mid}{punct}</em></>;
                      }
                      return <>{header?.titleA}<em>{header?.titleB}</em>{tail}</>;
                    })()}
                  </h2>
                  <p className="section-head__sub">{header?.sub}</p>
                </div>

                {/* "Your answers" recap removed (qualifier moved to Step 6, no
                    longer relevant above the first service card). */}

                {isAgency && agencyIntent && (
                  <div className="alert alert--info">
                    <span className="alert__icon">✨</span>
                    <div>
                      <strong>
                        {agencyIntent.id === 'agency-whitelabel'
                          ? 'White-label discount applied.'
                          : 'Agency partner discount applied.'}
                      </strong>{' '}
                      {Math.round((1 - agyMult) * 100)}% off every managed-service tier, automatically. Talent is at GorillaPerks partner rates.
                    </div>
                  </div>
                )}
                {isAgency && !agencyIntent && !isWhitelabelStep && (
                  <div className="alert alert--info alert--soft">
                    <span className="alert__icon">↑</span>
                    <div>
                      <strong>Pick how you'll use GoGorilla on the previous step</strong> to apply your agency discount (15% own-agency or 40% white-label).
                    </div>
                  </div>
                )}

                <div className="svc-list">
                  {visibleServices.map(s => (
                    <ServiceBlock
                      key={s.id}
                      service={s}
                      selection={selections[s.id]}
                      pendingCommitId={pendingCommits?.[s.id]}
                      qualifier={state.qualifier}
                      intentId={intentId}
                      clientTypeId={clientTypeId}
                      onSelect={(on) => dispatch({ type: 'SET_SERVICE', id: s.id, on })}
                      onTier={(tier) => dispatch({ type: 'SET_TIER', id: s.id, tier })}
                      onSetCommit={(cid) => dispatch({ type: 'SET_SERVICE_COMMIT', id: s.id, commitId: cid })}
                      onSetLeadSourceMode={(mode) => dispatch({ type: 'SET_LEAD_SOURCE_MODE', id: s.id, mode })}
                      onSetMonthlyLeads={(value) => dispatch({ type: 'SET_MONTHLY_LEADS', id: s.id, value })}
                      onSetByolListQuality={(quality) => dispatch({ type: 'SET_BYOL_LIST_QUALITY', id: s.id, quality })}
                      onTogglePayUpfront={() => {
                        if (!selections[s.id]) dispatch({ type: 'SET_SERVICE', id: s.id, on: true });
                        dispatch({ type: 'TOGGLE_PAY_UPFRONT', id: s.id });
                      }}
                      onToggleAddon={(aid) => dispatch({ type: 'TOGGLE_ADDON', id: s.id, addonId: aid })}
                      onSetOverseasCountries={(value) => {
                        if (!selections[s.id]) dispatch({ type: 'SET_SERVICE', id: s.id, on: true });
                        dispatch({ type: 'SET_OVERSEAS_COUNTRIES', id: s.id, value });
                      }}
                      onSetAddonQty={(aid, value) => dispatch({ type: 'SET_ADDON_QTY', id: s.id, addonId: aid, value })}
                      onToggleChannel={(cid, max) => {
                        if (!selections[s.id]) dispatch({ type: 'SET_SERVICE', id: s.id, on: true });
                        dispatch({ type: 'TOGGLE_CHANNEL', id: s.id, channelId: cid, max });
                      }}
                      onSetAdSpend={(value) => {
                        if (!selections[s.id]) dispatch({ type: 'SET_SERVICE', id: s.id, on: true });
                        dispatch({ type: 'SET_AD_SPEND', id: s.id, value });
                      }}
                      onSetLinkedinProfiles={(value) => {
                        if (!selections[s.id]) dispatch({ type: 'SET_SERVICE', id: s.id, on: true });
                        dispatch({ type: 'SET_LINKEDIN_PROFILES', id: s.id, value });
                      }}
                      onToggleRole={(rid) => {
                        if (!selections[s.id]) dispatch({ type: 'SET_SERVICE', id: s.id, on: true });
                        dispatch({ type: 'TOGGLE_ROLE', id: s.id, roleId: rid });
                      }}
                      onSetRoleConfig={(rid, patch) => {
                        dispatch({ type: 'SET_ROLE_CONFIG', id: s.id, roleId: rid, patch });
                      }}
                      agyMult={agyMult}
                      addonsDefaultOpen={addonsDefaultOpen}
                    />
                  ))}
                </div>

              </div>
            )}

            {/* Footer nav: Back · Step counter.
                #76: Hidden on step 0 because every valid selection there already
                auto-advances via card click or Yes/No button — "Next page" is
                redundant friction. Shown from step 1 onwards as normal. */}
            {!isClientStep && (
            <div className="page__foot page__foot--steps">
              <button
                className="btn btn--ghost btn--lg"
                onClick={() => onJumpStep && onJumpStep(Math.max(0, step - 1))}
                disabled={step === 0}
              >
                <span className="btn__arrow btn__arrow--back">‹</span> Back
              </button>
              
              <span className="dis-tip-wrap page__foot-next-wrap">
                <button
                  className="btn btn--primary btn--lg page__foot-next-btn"
                  onClick={() => {
                    // Sprint 2 v3, qualifier step intercept: if incomplete, fire
                    // a toast + tint missed cards red. Otherwise advance.
                    // Qualifier-missing toast removed per latest spec, qualifier
                    // is now optional on Step 6 (the essentials gate covers the
                    // minimum-info case). Step 1 has no qualifier any more, so
                    if (!currentStepSatisfied) return;
                    onNext();
                  }}
                  disabled={!isQualifierStep && !currentStepSatisfied}
                >
                  {step === activeFlow.length - 2 ? 'Review & finish' : 'Next page'} <span className="btn__arrow">›</span>
                </button>
                {nextDisabledReason && !isQualifierStep && (
                  <span className="dis-tip dis-tip--above" role="tooltip">{nextDisabledReason}</span>
                )}
              </span>
            </div>
            )}
          </div>

          <aside className="summary-wrap">
            <window.Summary state={state} dispatch={dispatch} onNext={onNext} step={step} flow={activeFlow} />
          </aside>
        </div>
      </div>
      <ScrollProgressDot />
    </section>
  );
}

// ── ROLE SUMMARY LINE, expandable line for each Dedicated Resources role ──
// Click to expand and see configured location/seniority/tasks (FT) or days (PT).
function RoleSummaryLine({ line, dispatch }) {
  const [expanded, setExpanded] = React.useState(false);
  const cfg = line.roleConfig || {};
  const tier = line.roleTier;
  const isFT = tier === 'fulltime';
  // 2026-05-25: commitId now also makes the role expandable (otherwise the
  // chevron wouldn't appear on a default-12mo role with no other config).
  const hasDetails = isFT
    ? (!!cfg.location || !!cfg.seniority || !!cfg.tasks || !!cfg.commitId)
    : (typeof cfg.days === 'number' || !!cfg.commitId);
  const loc = isFT && cfg.location ? (window.DEDICATED_FT_LOCATIONS || []).find(l => l.id === cfg.location) : null;
  const sen = isFT && cfg.seniority ? (window.DEDICATED_FT_SENIORITY || []).find(s => s.id === cfg.seniority) : null;

  return (
    <>
      <div className={`summary__line summary__line--addon summary__line--role ${expanded ? 'is-expanded' : ''}`}>
        <span className="summary__line-label">
          <button
            className="summary__line-remove summary__line-remove--addon"
            aria-label={`Remove ${line.label}`}
            onClick={() => dispatch({ type: 'TOGGLE_ROLE', id: line.sid, roleId: line.rid })}
          >×</button>
          <button
            type="button"
            className="summary__role-toggle"
            onClick={() => hasDetails && setExpanded(v => !v)}
            disabled={!hasDetails}
            aria-expanded={expanded}
            title={hasDetails ? (expanded ? 'Hide details' : 'Show details') : ''}
          >
            <span>{line.label}</span>
            {hasDetails && (
              <svg className={`summary__role-chev ${expanded ? 'is-open' : ''}`} viewBox="0 0 12 12" width="10" height="10" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
                <polyline points="3 4.5 6 7.5 9 4.5"/>
              </svg>
            )}
          </button>
        </span>
        <span className="summary__line-val">{line.custom ? (line.customLabel || 'Custom') : window.fmt(line.value)}</span>
      </div>
      {expanded && hasDetails && (
        <div className="summary__role-details">
          {isFT ? (
            <>
              {loc && (
                <div className="summary__role-detail">
                  <span className="summary__role-detail-k">Location</span>
                  <span className="summary__role-detail-v">
                    {loc.cc && <img src={`https://flagcdn.com/${loc.cc}.svg`} alt="" className="summary__role-flag" width="16" height="11" />}
                    {loc.label}
                  </span>
                </div>
              )}
              {sen && (
                <div className="summary__role-detail">
                  <span className="summary__role-detail-k">Seniority</span>
                  <span className="summary__role-detail-v">{sen.label}</span>
                </div>
              )}
              {cfg.tasks && (
                <div className="summary__role-detail summary__role-detail--multi">
                  <span className="summary__role-detail-k">Tasks</span>
                  <span className="summary__role-detail-v">{cfg.tasks}</span>
                </div>
              )}
              <div className="summary__role-detail">
                <span className="summary__role-detail-k">Commitment</span>
                <span className="summary__role-detail-v">{cfg.commitId ? cfg.commitId : '12'} months</span>
              </div>
            </>
          ) : (
            <>
              {typeof cfg.days === 'number' && (
                <div className="summary__role-detail">
                  <span className="summary__role-detail-k">Days/month</span>
                  <span className="summary__role-detail-v">{cfg.days} day{cfg.days === 1 ? '' : 's'}</span>
                </div>
              )}
              {/* #28: Show per-day rate so monthly cost vs day rate is distinct */}
              {typeof cfg.days === 'number' && !line.custom && line.value > 0 && (
                <div className="summary__role-detail">
                  <span className="summary__role-detail-k">Day rate</span>
                  <span className="summary__role-detail-v">{window.fmt(Math.round(line.value / cfg.days))}/day</span>
                </div>
              )}
            </>
          )}
        </div>
      )}
    </>
  );
}

// ── SUMMARY (brass-framed, sticky) ──
// Demo promo codes, server-side validation in production.
const PROMO_CODES = {
  'WELCOME10': { pct: 10, label: 'Welcome offer' },
  'GG20':      { pct: 20, label: 'Partner referral' },
  'FOUNDER25': { pct: 25, label: 'Founders Club' },
};
// Expose so app.v2.jsx's loadState() can re-validate a persisted code.
window.PROMO_CODES = PROMO_CODES;

function Summary({ state, dispatch, onNext, step, flow }) {
  const { clientTypeId, intentId, selections } = state;
  const ct = window.CLIENT_TYPES.find(c => c.id === clientTypeId);
  // Discount-code UI state stays local (open/closed, input buffer, error msg),
  // but the *applied* promo lives on the global reducer state so it survives
  // unmount/remount when the BuildPage gives way to the YoureSetPage at the
  // checkout step. Without this, the Summary inside the Checkout view would
  // mount with an empty `promoApplied` and silently drop the user's discount.
  const [promoOpen, setPromoOpen] = React.useState(false);
  // Multi-service discount is collapsed by default to save vertical space.
  // Header (eyebrow + current discount summary) is always visible; chevron
  // toggles the tier list + footer.
  const [msOpen, setMsOpen] = React.useState(false);
  const [promoInput, setPromoInput] = React.useState('');
  const [promoError, setPromoError] = React.useState('');
  const promoApplied = state.promoApplied || null; // { code, pct, label } | null
  const setPromoApplied = (p) => dispatch({ type: 'SET_PROMO', promo: p });
  const promoInputRef = React.useRef(null);
  React.useEffect(() => { if (promoOpen && promoInputRef.current) promoInputRef.current.focus(); }, [promoOpen]);
  const applyPromo = () => {
    const code = promoInput.trim().toUpperCase();
    if (!code) { setPromoError('Enter a code'); return; }
    const match = PROMO_CODES[code];
    if (!match) { setPromoError("That code isn't valid."); return; }
    setPromoApplied({ code, ...match });
    setPromoError('');
    setPromoInput('');
    setPromoOpen(false);
  };
  const removePromo = () => { setPromoApplied(null); setPromoError(''); };
  const isAgency = clientTypeId === 'agency';
  const agyMult = window.getAgencyMultiplier(state);
  const intent = ct?.intents?.find(i => i.id === intentId);
  const agencyDiscountPct = isAgency && intent ? Math.round((1 - agyMult) * 100) : 0;

  const lines = uM(() => {
    const list = [];

    Object.entries(selections).forEach(([sid, sel]) => {
      const svc = window.SERVICES.find(x => x.id === sid);
      const tier = window.findTier(svc, sel.tier);
      if (!svc || !tier) return;
      // Resolve the user's per-service commitment (falls back to the service default).
      const opts = window.commitsFor(svc);
      const defaultCommitId = '12';
      const commitId = (opts && opts.some(o => o.id === sel.commitId)) ? sel.commitId : defaultCommitId;
      const commitMonths = opts?.find(o => o.id === commitId)?.months || (12);
      const commitSuffix = svc.oneTime ? '' : ` · ${commitMonths}mo min`;
      // Capacity check, services with status 'full' are added as waiting-list
      // entries with no $ contribution to the calculator total.
      const cap = window.SERVICE_CAPACITY[sid];
      // Two ways a line ends up on the waiting list:
      //  1. SERVICE-level: window.SERVICE_CAPACITY[sid].status === 'full'
      //  2. TIER-level:    svc.waitlistTiers includes the chosen tier id
      // Tier-level wins when only a couple of plans are at capacity (e.g. the
      // top two White Label tiers) without taking the whole service offline.
      const isTierWaitlist = Array.isArray(svc.waitlistTiers) && svc.waitlistTiers.includes(tier.id);
      const isWaitlist = (cap?.status === 'full' || isTierWaitlist) && !tier.isEnterprise;
      if (tier.isEnterprise) {
        list.push({ id: sid, sid, label: `${svc.name} · Enterprise${commitSuffix}`, value: 0, custom: true });
      } else if (isWaitlist) {
        list.push({ id: sid, sid, label: `${svc.name} · ${tier.name}${commitSuffix}`, value: 0, waitlist: true });
      } else {
        const p = window.priceFor(svc, tier.id, commitId);
        if (p.custom) {
          list.push({ id: sid, sid, label: `${svc.name} · ${tier.name}${commitSuffix}`, value: 0, custom: true });
        } else {
          const _svcMult = window.getAgencyMultiplier ? window.getAgencyMultiplier(state, sid) : agyMult;
          const price = p.value * _svcMult;
          const suffix = p.oneTime ? ' (one-time)' : '';
          list.push({ id: sid, sid, label: `${svc.name} · ${tier.name}${commitSuffix}${suffix}`, value: price, rrp: Math.round(p.value), oneTime: p.oneTime, ...(svc.discountExcluded ? { discountExcluded: true } : {}) });
        }
      }
      // Channel selections, surface user-picked channels as a ↳ sub-line so
      // they appear in the breakdown, the Airtable Quote Snapshot JSON, AND
      // the human-readable Services Selected text column. Channels are no
      // longer auto-selected (post Sprint-2 removal) so manual picks are the
      // only signal the AE has.
      if (Array.isArray(sel.channels) && sel.channels.length && window.SERVICE_CHANNELS?.[sid]) {
        // 2026-05-30: filter to channels still valid for the current tier so a
        // retired channel (e.g. the old 'phone') left in stale state does not show.
        const _allowedCh = window.channelsForTier ? new Set(window.channelsForTier(sid, sel.tier)) : null;
        const _chans = _allowedCh ? sel.channels.filter(c => _allowedCh.has(c)) : sel.channels;
        const labels = _chans.map(cid => {
          const opt = window.CHANNEL_OPTIONS?.[cid];
          return opt?.label || cid;
        });
        if (labels.length) list.push({
          id: `${sid}:channels`,
          sid,
          label: `↳ Channels: ${labels.join(', ')}`,
          value: 0,
          addon: true,
          free: true,
        });
        // 2026-05-22: paid channel extras on Sales.
        // - LinkedIn: £399/mo per profile (default 1, range 1-10)
        // - WhatsApp follow-up: flat £199/mo
        if (sid === 'sales') {
          const _svcMultCh = window.getAgencyMultiplier ? window.getAgencyMultiplier(state, sid) : agyMult;
          if (sel.channels.includes('linkedin')) {
            const _liProfiles = Math.max(1, Math.min(10, Number(sel.linkedinProfiles) || 1));
            list.push({
              id: `${sid}:linkedin-profiles`,
              sid,
              label: `↳ LinkedIn outbound · ${_liProfiles} profile${_liProfiles === 1 ? '' : 's'}`,
              value: Math.round(_liProfiles * 399 * _svcMultCh),
              breakdown: `${_liProfiles} × £399/profile/mo`,
              addon: true,
            });
          }
          if (sel.channels.includes('whatsapp')) {
            list.push({
              id: `${sid}:whatsapp-followup`,
              sid,
              label: `↳ WhatsApp follow-up`,
              value: Math.round(199 * _svcMultCh),
              breakdown: 'Flat add-on, billed monthly',
              addon: true,
            });
          }
        }
      }
      // One-time setup fee, surface it in the breakdown right under the
      // service line so the customer sees what they'll be invoiced once at
      // the start of the engagement. Feeds into oneTimeSubtotal so the
      // "Setup fees" total row at the bottom of the summary sums correctly.
      // Skip for waitlisted services (price is £0 until onboarded).
      if (!isWaitlist) {
        const setupFee = svc.setupFees && svc.setupFees[tier.id];
        if (tier.isEnterprise && svc.setupFees) {
          // Enterprise has no numeric setup fee, but signal "Custom" if the
          // service uses setup fees on its other tiers.
          list.push({
            id: `${sid}:setup`,
            sid,
            label: `↳ Setup fee`,
            value: 0,
            custom: true,
            customLabel: 'Custom',
            addon: true,
            oneTime: true,
            setupFee: true,
          });
        } else if (typeof setupFee === 'number' && setupFee > 0) {
          const _svcMultS = window.getAgencyMultiplier ? window.getAgencyMultiplier(state, sid) : agyMult;
          list.push({
            id: `${sid}:setup`,
            sid,
            label: `↳ Setup fee (one-time)`,
            value: setupFee * _svcMultS,
            rrp: Math.round(setupFee),
            addon: true,
            oneTime: true,
            setupFee: true,
          });
        }
      }
      // If the parent service is waitlisted, still render its add-ons in the
      // breakdown but force value:0 so they don't affect the total.
      // Resolve addons against the current tier+commit context so prices
      // reflect the selected plan (not the default data.jsx prices).
      const ctxAddons = window.getAddonsForContext
        ? window.getAddonsForContext(svc, sel.tier, sel.commitId || '12')
        : svc.addons;
      (sel.addons || []).forEach(aid => {
        const a = ctxAddons.find(x => x.id === aid);
        if (!a) return;
        // Per-unit addons (e.g. "/video", "/landingpage") multiply by quantity.
        // Time-period units (/mo, /hr, /day, /yr) are NOT per-unit and don't multiply.
        const unitStr = a.unit || '';
        const isPerUnit = !!a.unit
          && /^\/[a-z]+$/i.test(unitStr)
          && !/^\/(mo|month|hr|hour|day|yr|year)$/i.test(unitStr);
        const q = isPerUnit ? Math.max(1, Number(sel.addonQty?.[aid]) || 1) : 1;
        const qtyLabel = (isPerUnit && q > 1) ? ` × ${q}` : '';
        // Capacity check, addons whose onboarding availability is 'full' are
        // shown as waiting-list lines with no $ contribution. Custom-priced
        // and free addons aren't subject to this (no charge to gate).
        const addonCap = window.addonAvailability ? window.addonAvailability(a) : null;
        // Custom add-ons normally show as 'Custom', but ones we have explicitly
        // marked onboardingStatus:'full' (e.g. the Talent Enterprise-support add-ons)
        // should join the waiting list like any other full add-on.
        const isAddonWaitlist = addonCap?.status === 'full' && !a.free && (!a.custom || a.onboardingStatus === 'full');
        // Cross-service dw-setup deduplication: if Paid Advertising already has
        // dw-setup selected, show it as included (£0) for all other services.
        if (aid === 'dw-setup' && sid !== 'paid-ads') {
          const paAddons = selections['paid-ads']?.addons || [];
          if (paAddons.includes('dw-setup')) {
            list.push({ id: `${sid}:${aid}`, sid, aid, label: `↳ ${a.name}`, value: 0, free: true, addon: true, breakdown: 'Included in your Paid Advertising plan' });
            return;
          }
        }
        if (a.custom && !isAddonWaitlist) {
          list.push({ id: `${sid}:${aid}`, sid, aid, label: `↳ ${a.name}${qtyLabel}`, value: 0, custom: true, addon: true });
          return;
        }
        if (a.free) {
          list.push({ id: `${sid}:${aid}`, sid, aid, label: `↳ ${a.name}${qtyLabel}`, value: 0, free: true, addon: true });
          return;
        }
        if (isAddonWaitlist) {
          list.push({ id: `${sid}:${aid}`, sid, aid, label: `↳ ${a.name}${qtyLabel}`, value: 0, waitlist: true, addon: true });
          return;
        }
        const _svcMultA = window.getAgencyMultiplier ? window.getAgencyMultiplier(state, sid) : agyMult;
        // #82: Fundraising add-ons take the pricing-page per-tier discount (20/25/30%).
        // Largest single discount wins (agency rate vs tier), no stacking, and they are
        // excluded from the multi-service bundle (discountExcluded below).
        let _addonMult = a.discountable ? _svcMultA : 1;
        let _frDisc = false;
        if (a.discountable && sid === 'fundraising' && window.fundraisingAddonMult) {
          _addonMult = Math.min(_svcMultA, window.fundraisingAddonMult(sel.tier || 'grow'));
          _frDisc = true;
        }
        let aVal = isWaitlist ? 0 : (a.price || 0) * _addonMult * q;
        let _aRrp = Math.round((a.price || 0) * q);
        // 2026-05-22: enrich addon line with breakdown text where applicable.
        let _addonBreakdown = null;
        if (aid === 'overseas-calling' && Array.isArray(sel.overseasCountries) && sel.overseasCountries.length) {
          // #120: selection holds region bands. On white-label, the calling
          // wholesale (what the agency pays) is the costliest selected region's
          // absolute rate; reflect it in the breakdown + monthly total. RoW =
          // by quote (£0 here, confirmed at onboarding). Non-white-label keeps
          // the flat list price.
          const _regs = sel.overseasCountries;
          const _Wc = window.WHOLESALE;
          const _labels = (_Wc && _Wc.regionLabels) ? _Wc.regionLabels : {};
          const _cr = window.callingRegion ? window.callingRegion(_regs) : { region: null, byQuote: false, regions: [], hasSelection: false };
          if (_cr.hasSelection) {
            _addonBreakdown = `Region: ${_cr.regions.map(rc => _labels[rc] || rc).join(', ')}`;
            if (!isWaitlist && state.intentId === 'agency-whitelabel' && window.wholesaleAddon) {
              const _ww = window.wholesaleAddon('overseas-calling', _cr.region, a.price, _Wc ? _Wc.fallbackMultiplier : 0.6);
              if (_ww && _ww.byQuote) {
                aVal = 0; _aRrp = 0;
                _addonBreakdown += ' · by quote at onboarding';
              } else if (_ww && typeof _ww.value === 'number') {
                aVal = _ww.value;
                _aRrp = _ww.value;
              }
            }
          }
        } else if (isPerUnit && q > 1 && a.price) {
          const unitN = a.unit ? a.unit.replace(/^\//, '') : 'unit';
          _addonBreakdown = `${q} × £${a.price.toLocaleString('en-GB')}/${unitN}`;
        }
        list.push({
          id: `${sid}:${aid}`,
          sid, aid,
          label: `↳ ${a.name}${qtyLabel}`,
          value: aVal,
          rrp: _aRrp,
          breakdown: _addonBreakdown,
          negative: a.negative,
          addon: true,
          oneTime: !!a.oneTime,
          waitlist: !!isWaitlist,
          ...((a.discountExcluded || _frDisc) ? { discountExcluded: true } : {}),
        });
      });
      // Selected roles (Dedicated Resources): each role is a custom-priced line, since
      // both Part-Time ("From £X/day") and Full-Time ("Contact for pricing") aren't
      // monthly retainers, they're scope items the GoGorilla team will quote.
      const tierRoles = svc.roles?.[sel.tier];
      if (tierRoles && Array.isArray(sel.roles)) {
        sel.roles.forEach(rid => {
          const role = tierRoles.find(r => r.id === rid);
          if (!role) return;
          const cfg = sel.roleConfigs?.[rid] || null;
          const isPT = sel.tier === 'parttime';
          // For PT roles, once the user picks a day plan, compute real monthly cost
          // (dayRate × days) so the right-side breakdown reflects their selection.
          // FT and unconfigured PT remain "Custom" until the user has chosen days.
          let computed = null;
          if (isPT && cfg && typeof cfg.days === 'number' && cfg.days >= 1 && role.price) {
            const days = cfg.days;
            let dayRate;
            if (role.dayRates) {
              if (days === 1) dayRate = role.dayRates.d1;
              else if (days === 5) dayRate = role.dayRates.d5;
              else if (days === 10) dayRate = role.dayRates.d10;
              else if (days >= 11) dayRate = role.dayRates.custom;
              else dayRate = role.dayRates.d1;
            } else {
              let discount = 0;
              if (days === 5) discount = 0.10;
              else if (days === 10) discount = 0.12;
              else if (days >= 11) discount = 0.15;
              dayRate = Math.round(role.price * (1 - discount));
            }
            const _svcMultD = window.getAgencyMultiplier ? window.getAgencyMultiplier(state, sid) : agyMult;
            computed = Math.round(dayRate * days * _svcMultD);
          }
          if (computed != null) {
            // Real numeric line, feeds monthlySubtotal & flows through bundle/promo math.
            const _ptBreakdown = (() => {
              if (!cfg || typeof cfg.days !== 'number') return null;
              const days = cfg.days;
              let dayRate;
              if (role.dayRates) {
                if (days === 1) dayRate = role.dayRates.d1;
                else if (days === 5) dayRate = role.dayRates.d5;
                else if (days === 10) dayRate = role.dayRates.d10;
                else if (days >= 11) dayRate = role.dayRates.custom;
                else dayRate = role.dayRates.d1;
              } else {
                let discount = 0;
                if (days === 5) discount = 0.10;
                else if (days === 10) discount = 0.12;
                else if (days >= 11) discount = 0.15;
                dayRate = Math.round(role.price * (1 - discount));
              }
              const _ptCommitMo = cfg && cfg.commitId ? String(cfg.commitId) : '12';
              return `${days} day${days === 1 ? '' : 's'} × £${dayRate.toLocaleString('en-GB')}/day · ${_ptCommitMo}-month commit`;
            })();
            list.push({
              id: `${sid}:role:${rid}`,
              sid,
              rid,
              label: `↳ ${role.name}${cfg && typeof cfg.days === 'number' ? ` (${cfg.days} day${cfg.days === 1 ? '' : 's'}/month)` : ''}`,

              value: computed,
              breakdown: _ptBreakdown,
              addon: true,
              role: true,
              roleConfig: cfg,
              roleTier: sel.tier,
              commitMo: cfg && cfg.commitId ? String(cfg.commitId) : '12',
            });
          } else {
            // Unconfigured FT or PT, keep as "Custom" placeholder.
            // For FT roles: if the user has selected a location, compute the
            // location-specific hourly range instead of defaulting to the
            // Philippines floor stored in role.priceLabel.
            let customLabel = role.priceLabel;
            if (!isPT && cfg && cfg.location && typeof role.hourlyFrom === 'number') {
              const locs = window.DEDICATED_FT_LOCATIONS || [];
              const loc = locs.find(l => l.id === cfg.location);
              if (loc) {
                const lo = Math.round(role.hourlyFrom * loc.multLo);
                const hi = Math.round(role.hourlyFrom * loc.multHi);
                customLabel = `From £${lo}-£${hi}/hour`;
              }
            }
            const _ftCommitMo = cfg && cfg.commitId ? String(cfg.commitId) : '12';
            const _ftBreakdown = (() => {
              if (isPT) {
                const base = role.price ? `Day rate from £${role.price.toLocaleString('en-GB')}/day, configure days in the role panel` : null;
                return base ? `${base} · ${_ftCommitMo}-month commit` : `${_ftCommitMo}-month commit`;
              }
              if (cfg && cfg.location) {
                const locs = window.DEDICATED_FT_LOCATIONS || [];
                const loc = locs.find(l => l.id === cfg.location);
                if (loc) return `Location: ${loc.label || loc.name || loc.id} · ${_ftCommitMo}-month commit`;
              }
              return `Quoted bespoke, scope-dependent · ${_ftCommitMo}-month commit`;
            })();
            list.push({
              id: `${sid}:role:${rid}`,
              sid,
              rid,
              label: `↳ ${role.name}${!isPT ? ' (20 days/month)' : ''}`,

              value: 0,
              custom: true,
              customLabel,
              breakdown: _ftBreakdown,
              addon: true,
              role: true,
              roleConfig: cfg,
              roleTier: sel.tier,
              commitMo: _ftCommitMo,
            });
          }
        });
      }
      // FT Dedicated Resources, one £250 refundable deposit per selected role,
      // so the total clearly reflects how much is due at checkout today.
      // The FT role lines carry value:0 (monthly fee billed post-placement).
      if (svc && svc.id === 'dedicated-ft' && sel.tier === 'fulltime' && Array.isArray(sel.roles) && sel.roles.length > 0) {
        const ftRoleDefs = svc.roles?.['fulltime'] || [];
        sel.roles.forEach(rid => {
          const roleDef = ftRoleDefs.find(r => r.id === rid);
          const roleName = roleDef ? roleDef.name : rid;
          list.push({
            id: `${sid}:deposit:${rid}`,
            sid,
            label: `↳ ${roleName}, deposit (refundable)`,
            value: window.DEDICATED_DEPOSIT || 250,
            breakdown: 'Refundable, held until placement. Returned if no hire is made.',
            addon: true,
            deposit: true,
            oneTime: true,
          });
        });
      }
    });
    // ── SDG additional-leads line (per Monthly Lead Volume widget) ──────────
    // Adds a "+N leads" addon-line for each SDG service whose monthlyLeads
    // exceeds the 1,000 included with the tier. Mirrors the Pay-upfront math
    // by also multiplying through the white-label agency multiplier.
    Object.entries(selections).forEach(([sid, sel]) => {
      if (sid !== 'sales' || !sel || sel.leadSourceMode === 'byol') return;
      // 2026-05-26: tier-aware included floor (Starter 750 / Grow 1000 /
      // Scale 1250) — gate + overage math both key off the picked tier.
      const _tier = sel.tier;
      const _included = (window.leadIncludedForTier ? window.leadIncludedForTier(_tier) : 750);
      const leads = sel.monthlyLeads || _included;
      if (leads <= _included) return;
      if (typeof window.computeAdditionalLeadCost !== 'function') return;
      const { additional, cost } = window.computeAdditionalLeadCost(leads, _tier);
      if (!additional || !cost) return;
      const _svcMult = window.getAgencyMultiplier ? window.getAgencyMultiplier(state, sid) : 1;
      list.push({
        id: `${sid}:extra-leads`,
        sid,
        label: `↳ +${additional.toLocaleString('en-GB')} leads`,
        value: Math.round(cost * _svcMult),
        breakdown: `${additional.toLocaleString('en-GB')} × £${_tier === 'starter' ? 2 : 4}/lead, billed monthly`,
        addon: true,
      });
    });
    return list;
  }, [selections, isAgency, intentId]);

  // 2026-05-25: include custom-priced lines (FT Dedicated Resources parent
  // + role sub-lines) so the breakdown modal shows them. They carry value:0
  // so they don't inflate the subtotal. The renderer at .bdwn-modal__line-val
  // already handles l.custom by showing the customLabel.
  const monthlyLines = lines.filter(l => !l.oneTime && !l.waitlist);
  const oneTimeLines = lines.filter(l => l.oneTime);
  const onlyOneTime = monthlyLines.length === 0 && oneTimeLines.length > 0;
  const monthlySubtotal = monthlyLines.reduce((s, l) => s + l.value, 0);
  // Pay-upfront discount, applied per-service. When the service's selection
  // has payUpfront: true, sum up all its monthly line values and apply -10%.
  // Applied BEFORE bundle + promo so the user sees layered savings transparently.
  const payUpfrontSavings = monthlyLines.reduce((acc, l) => {
    const sel = selections[l.sid];
    if (sel && sel.payUpfront) return acc + l.value * 0.10;
    return acc;
  }, 0);
  const oneTimeSubtotal = oneTimeLines.reduce((s, l) => s + l.value, 0);
  // 2026-05-30 (#114): white-label agency margin at RRP. Per monthly / setup
  // line, margin = RRP (undiscounted) minus what the agency pays.
  const _isWlAgency = state.clientTypeId === 'agency' && state.intentId === 'agency-whitelabel';
  const _lineRrp = (l) => (typeof l.rrp === 'number' ? l.rrp : l.value);
  const agencyMonthlyMargin = _isWlAgency ? monthlyLines.filter(l => !l.custom).reduce((s, l) => s + (_lineRrp(l) - l.value), 0) : 0;
  const agencySetupMargin = _isWlAgency ? oneTimeLines.filter(l => l.setupFee && !l.custom && !l.waitlist).reduce((s, l) => s + (_lineRrp(l) - l.value), 0) : 0;
  // 2026-05-22: MSD now counts the union of monthly + one-time non-addon
  // service lines (was monthly only). Without this, services like Content
  // Creation + 3D Animation (which ship as one-time priced) didn't count
  // toward the MSD tier, so the tracker stuck at 0% even with 3 services
  // visible in the breakdown. Setup-fee sub-lines and channel sub-lines are
  // excluded via the addon/setupFee flags so they don't inflate the count.
  // 2026-05-25: dropped the `l.custom` filter so that custom-priced services
  // (FT Dedicated Resources parent line, Enterprise tier with no fixed
  // price) still count toward MSD. The user has explicitly selected the
  // service; the fact that its price is quoted bespoke shouldn't disqualify
  // it from the bundle. Custom-priced ADDONS / role sub-lines still carry
  // addon:true so they continue to be excluded.
  const _msCountedSids = new Set();
  lines.forEach(l => {
    if (l.addon || l.setupFee || l.waitlist) return;
    if (!l.sid) return;
    _msCountedSids.add(l.sid);
  });
  const monthlyServiceCount = _msCountedSids.size;
  // Sprint 2, tiered multi-service discount (5/15/25/35/45). For whitelabel
  // agencies, combined (whitelabel% + multi-service%) is capped at 50% so the
  // multi-service portion is reduced when whitelabel is in play.
  const _msPctRaw = window.multiServiceDiscountPct
    ? window.multiServiceDiscountPct(monthlyServiceCount)
    : (monthlyServiceCount >= 3 ? 0.10 : (monthlyServiceCount >= 2 ? 0.05 : 0));
  let _msPctFinal = _msPctRaw;
  if (state.intentId === 'agency-whitelabel') {
    // Whitelabel headline = 40%. Combined cap = 50%. So multi-service contribution
    // in headline-display terms can be at most 10% (40 + 10 = 50). The whitelabel
    // discount is already baked into priced lines via getAgencyMultiplier, so the
    // "additional multi-service discount on top" we apply here is min(raw, 0.10).
    _msPctFinal = Math.min(_msPctRaw, 0.10);
  }
  // Pay-upfront savings come off first (per-service), then bundle, then promo.
  const subtotalAfterUpfront = Math.max(0, monthlySubtotal - payUpfrontSavings);
  // 2026-05-22: bundle discount also applies to one-time service lines
  // (Content / Motion / etc) so the savings are real when those make up
  // the bulk of the user's spend. setup fees are excluded.
  const oneTimeDiscountableSubtotal = oneTimeLines
    .filter(l => !l.setupFee && !l.addon && !l.discountExcluded)
    .reduce((s, l) => s + l.value, 0);
  // Dedicated-resource lines (dedicated-pt, dedicated-ft, sdr add-on) carry
  // discountExcluded:true so the bundle discount never applies to staffing costs.
  // We subtract their post-upfront value from the discountable base before
  // computing bundleDiscountMonthly so only managed-service retainers are reduced.
  const _excludedMonthlyVal = monthlyLines
    .filter(l => l.discountExcluded)
    .reduce((acc, l) => {
      const _selX = selections[l.sid];
      return acc + (_selX?.payUpfront ? l.value * 0.90 : l.value);
    }, 0);
  const _discountableBase = Math.max(0, subtotalAfterUpfront - _excludedMonthlyVal);
  const bundleDiscountMonthly = monthlyServiceCount >= 2 ? _discountableBase * _msPctFinal : 0;
  const bundleDiscountOneTime = monthlyServiceCount >= 2 ? oneTimeDiscountableSubtotal * _msPctFinal : 0;
  const bundleDiscount = bundleDiscountMonthly + bundleDiscountOneTime;
  const afterBundle = Math.max(0, subtotalAfterUpfront - bundleDiscountMonthly);
  const oneTimeAfterBundle = Math.max(0, oneTimeSubtotal - bundleDiscountOneTime);
  const promoDiscount = promoApplied ? afterBundle * (promoApplied.pct / 100) : 0;
  // FT role lines are `custom` (to show an estimated range label) but must NOT
  // suppress the numeric total, they contribute £0 and the real £250 deposit
  // flows as a regular line. Only non-role custom items block the total display.
  const hasEnterprise = lines.some(l => l.custom && !l.role);
  const hasWaitlist = lines.some(l => l.waitlist);
  const total = Math.max(0, afterBundle - promoDiscount);
  const annual = total * 12 + oneTimeAfterBundle;
  const animTotal = window.useAnimatedValue(Math.round(total));

  // Publish the freshly-computed quote so other components on the page
  // (notably <EmailQuoteForm /> in <YoureSetPage />) can read exactly what
  // the visitor sees in the Summary, without re-running the line-building
  // logic. Updates on every meaningful input change.
  //
  // Sprint 1-4, this payload is also the source of truth for everything the
  // /api/send-quote endpoint forwards to Airtable. Any new piece of state the
  // calculator captures (qualifier answers, Q0, GorillaGrants opt-in, AE
  // attribution, warmth/cohort scoring, persistent quote_uuid) needs to land
  // here, otherwise it won't show up on the saved-quote record. Keep the keys
  // flat + JSON-serialisable; the API stringifies the body as-is.
  const qualifier   = state.qualifier   || { q1: null, q2: null, q3: null, q4: null, q15: null };
  const q0          = state.q0          || { clientName: '', industry: '' };
  const quote_uuid  = state.quote_uuid  || '';
  const ae_ref      = state.ae_ref      || '';
  const winback_ref = state.winback_ref || '';
  const warmth      = window.computeWarmth ? window.computeWarmth(qualifier) : 0;
  const cohort      = window.computeCohort ? window.computeCohort(warmth) : null;

  React.useEffect(() => {
    window.__currentQuote = {
      // Identity / attribution, let Airtable join across sessions + AEs.
      quote_uuid,
      ae_ref,
      winback_ref,
      // Client + intent (unchanged from pre-Sprint-1).
      clientTypeId,
      clientHeading: ct?.heading || '',
      intent: intent ? `${intent.name || intent.id}` : '',
      intentId: intentId || '',
      // Sprint 1, Q0 (white-label agency: client name + industry).
      q0: { clientName: q0.clientName || '', industry: q0.industry || '' },
      // Full qualifier snapshot, publishes ALL 28+ panel answers (Q1 tree,
      // Q6 cascade, fundraising, etc.) so every payload site has access to
      // the full state via window.__currentQuote without needing to read
      // state directly. Was previously truncated to just 5 keys, which
      // silently dropped 38+ fields from every Airtable submission.
      qualifier: { ...qualifier },
      // Selections shape, per-service tier, commit, addons, payUpfront,
      // byolListQuality, leadSourceMode, monthlyLeads. Lets Airtable record
      // per-service config beyond just the lines array's display labels.
      selections,
      // Sprint 1, derived scoring. Helpful for SDR routing logic + reporting.
      warmth,
      cohort: cohort || '',
      // Sprint 4, GorillaGrants opt-in.
      // Strip non-serialisable fields before sending to the API.
      lines: lines.map(l => ({
        id: l.id, sid: l.sid, aid: l.aid,
        label: l.label, value: l.value,
        addon: !!l.addon, oneTime: !!l.oneTime,
        custom: !!l.custom, waitlist: !!l.waitlist, free: !!l.free,
      })),
      monthlyTotal: Math.round(total),
      oneTimeTotal: Math.round(oneTimeSubtotal),
      promoCode: promoApplied?.code || '',
      promoPct: promoApplied?.pct || 0,
    };
  }, [
    clientTypeId, intentId, lines, total, oneTimeSubtotal, promoApplied, ct, intent,
    qualifier, selections,
    q0.clientName, q0.industry, quote_uuid, ae_ref, winback_ref, warmth, cohort,
  ]);


  // Estimated cost per qualified meeting, only meaningful when S&DG is
  // selected with a non-enterprise tier and the qualifier deal-size band is
  // known. Returns a formatted £value or ', ' when not applicable.
  // CPM math, exposes both the formatted value (for the totals row) and
  // the underlying components (for the expanded breakdown panel).
  const cpmDetails = (() => {
    const salesSel = state.selections?.sales;
    if (!salesSel || !salesSel.tier || salesSel.tier === 'enterprise') return null;
    const q3 = qualifier?.q3;
    const meetings = (window.SDG_MEETINGS_PER_MONTH || {})[salesSel.tier]?.[q3];
    const leads    = (window.SDG_LEADS_PER_MONTH    || {})[salesSel.tier];
    if (!meetings || !leads) return null;
    const svc = (window.SERVICES || []).find(s => s.id === 'sales');
    if (!svc) return null;
    const opts = window.commitsFor ? window.commitsFor(svc) : null;
    const cid  = (opts && opts.some(o => o.id === salesSel.commitId)) ? salesSel.commitId : '12';
    const p    = window.priceFor ? window.priceFor(svc, salesSel.tier, cid) : null;
    if (!p || p.custom) return null;
    const retainer = Math.round(p.value * agyMult);
    const perLead  = ['10k-100k','gt-100k'].includes(q3) ? 4.5 : 3;
    const totalMonthly = Math.round(retainer + (perLead * leads));
    const cpm = Math.round(totalMonthly / meetings);
    return { retainer, perLead, leads, meetings, totalMonthly, cpm };
  })();
  const cpmDisplay = cpmDetails ? window.fmt(cpmDetails.cpm) : ', ';

  // Toggle for the inline expanded breakdown panel below the button.
  // Persisted in localStorage so users who like it open stay open.
  const [showFullBreakdown, setShowFullBreakdown] = React.useState(() => {
    try { return localStorage.getItem('gg_showFullBreakdown') === '1'; } catch { return false; }
  });
  React.useEffect(() => {
    try { localStorage.setItem('gg_showFullBreakdown', showFullBreakdown ? '1' : '0'); } catch {}
  }, [showFullBreakdown]);

  return (
    <div className="summary">
      <Frame variant="metal" />
      {/* Sprint 5: inner scroll wrapper so the scrollbar lives INSIDE the
          metal frame's right edge (with the frame's padding as breathing
          room). The outer .summary keeps the frame; .summary__scroll
          handles overflow. */}
      <div className="summary__scroll">
      <div className="summary__list">
        <div className={`summary__plan summary__plan--inline ${ct ? 'summary__plan--set' : 'summary__plan--empty'}`} key={`${clientTypeId || 'none'}-${intentId || 'no'}`}>
          <div className="summary__plan-icon">
            {ct ? <img src={ct.icon} alt="" /> : <span className="summary__plan-icon-empty">?</span>}
          </div>
          <div style={{flex: 1, minWidth: 0}}>
            <div className="summary__kicker summary__kicker--inline">Your Plan</div>
            <div className="summary__plan-name">
              {ct ? (
                <>GoGorilla<em>{ct.subBrand}</em><sup>®</sup></>
              ) : (
                <span className="summary__plan-name-empty">Pick your client type ↑</span>
              )}
            </div>
            {(!ct || agencyDiscountPct > 0) && (
              <div className="summary__plan-meta">
                {ct ? (
                  agencyDiscountPct > 0 && <span className="summary__plan-chip">−{agencyDiscountPct}% agency</span>
                ) : (
                  'Tailors pricing, tiers, and add-ons'
                )}
              </div>
            )}
          </div>
        </div>
        <div className="summary__list-title">
          <span>Services &amp; Add-ons</span>
          <span>{lines.length}</span>
        </div>
        {lines.length === 0 && (
          <div className="summary__empty"><em>Nothing selected yet</em></div>
        )}
        {lines.map(l => l.role ? (
          <RoleSummaryLine key={l.id} line={l} dispatch={dispatch} />
        ) : (
          <div key={l.id} className={`summary__line ${l.addon ? 'summary__line--addon' : ''} ${l.negative ? 'summary__line--discount' : ''} ${l.waitlist ? 'summary__line--waitlist' : ''}`}>
            <span className="summary__line-label">
              {!l.addon && (
                <button className="summary__line-remove" aria-label={`Remove ${l.label}`} onClick={() => dispatch({ type: 'SET_SERVICE', id: l.id, on: false })}>×</button>
              )}
              {l.addon && l.sid && l.aid && (
                <button className="summary__line-remove summary__line-remove--addon" aria-label={`Remove ${l.label}`} onClick={() => dispatch({ type: 'TOGGLE_ADDON', id: l.sid, addonId: l.aid })}>×</button>
              )}
              <span>{l.label}</span>
            </span>
            <span className="summary__line-val">
              {l.waitlist ? (
                <WaitlistTip />
              ) : l.custom ? (l.customLabel || 'Custom') : window.fmt(l.value)}
            </span>
          </div>
        ))}
        {/* Applied savings, summarises each per-service discount that's already
            baked into the displayed line prices. Informational only, the
            running total is unaffected. Lets the user see at a glance what
            deals stack up in their plan. */}
        {(() => {
          const savingLines = [];
          // 1. Commitment savings per service (compared to 3mo baseline)
          for (const [sid, sel] of Object.entries(selections || {})) {
            if (!sel) continue;
            const cm = sel.commitMonths;
            if (cm === 12 || cm === 6) {
              const pct = cm === 12 ? 40 : 20;
              const svc = (window.SERVICES || []).find(s => s.id === sid);
              if (!svc) continue;
              savingLines.push({
                key: 'commit-' + sid,
                label: '⏱ ' + cm + 'mo commitment on ' + (svc.name || sid),
                pct: pct,
              });
            }
          }
          // 2. Pay upfront per service (-10%)
          for (const [sid, sel] of Object.entries(selections || {})) {
            if (sel?.payUpfront) {
              const svc = (window.SERVICES || []).find(s => s.id === sid);
              if (!svc) continue;
              savingLines.push({
                key: 'upfront-' + sid,
                label: '💰 Pay upfront on ' + (svc.name || sid),
                pct: 10,
              });
            }
          }
          // 3. BYOL list quality discount per service
          const QUALITY_PCT = { 'fully-enriched': 15, 'companies-contacts': 10, 'company-only': 5 };
          const QUALITY_LABEL = { 'fully-enriched': 'Fully enriched', 'companies-contacts': 'Companies + contacts', 'company-only': 'Company list only' };
          for (const [sid, sel] of Object.entries(selections || {})) {
            if (sel?.leadSourceMode === 'byol' && sel?.byolListQuality) {
              const pct = QUALITY_PCT[sel.byolListQuality];
              if (!pct) continue;
              const svc = (window.SERVICES || []).find(s => s.id === sid);
              if (!svc) continue;
              savingLines.push({
                key: 'byolq-' + sid,
                label: '📋 BYOL ' + (QUALITY_LABEL[sel.byolListQuality] || 'list') + ' on ' + (svc.name || sid),
                pct: pct,
              });
            }
          }
          // 4. Agency / White-label discount applies to ALL services uniformly
          if (agyMult < 1) {
            const pct = Math.round((1 - agyMult) * 100);
            const isWl = state.intentId === 'agency-whitelabel';
            savingLines.push({
              key: 'agency',
              label: (isWl ? '🏢 White-label' : '🏢 Agency partner') + ' discount on all tiers',
              pct: pct,
            });
          }
          if (savingLines.length === 0) return null;
          return (
            <div className="summary__applied-savings" aria-label="Applied savings">
              <div className="summary__applied-savings-head">Applied savings</div>
              {savingLines.map(s => (
                <div key={s.key} className="summary__applied-saving">
                  <span className="summary__applied-saving-label">{s.label}</span>
                  <span className="summary__applied-saving-pct">−{s.pct}%</span>
                </div>
              ))}
            </div>
          );
        })()}

        {/* Multi-service bundle row removed, now consolidated into the
            MULTI-SERVICE DISCOUNT tracker that sits just below
            which shows both the £ amount and the % off side-by-side. */}

      </div>

      {/* Multi-service discount, stacked tier list with radio-dot indicators.
          Always shown (regardless of selection count) so the user can see
          the savings ladder upfront. The current tier (= highest tier whose
          count ≤ selections) is highlighted in brand colour; everything
          below stays muted. Sits between the lines list and the discount
          code/totals so the user sees their bundle savings BEFORE the totals. */}
      {(() => {
        // Use the count of actually-priced services, not raw selection keys.
        // Stale selections from prior personas would otherwise inflate this.
        const totalCount  = monthlyServiceCount;
        const isWhitelabel = state.intentId === 'agency-whitelabel';
        const TIERS = [
          { n: 1, pct: 0,  label: '1 service'    },
          { n: 2, pct: 5,  label: '2 services'   },
          { n: 3, pct: 10, label: '3+ services'  },
        ];
        const currentN = totalCount >= 3 ? 3 : Math.max(1, totalCount);
        const subtitle = totalCount === 0
          ? 'No services selected yet'
          : `${totalCount} service${totalCount === 1 ? '' : 's'} selected`;
        const currentPct = TIERS.find(t => t.n === currentN)?.pct ?? 0;
        return (
          <div className={`ms-stack ${msOpen ? 'ms-stack--open' : 'ms-stack--closed'}`} role="region" aria-label="Multi-service discount">
            <button
              type="button"
              className="ms-stack__head"
              aria-expanded={msOpen}
              aria-controls="ms-stack-body"
              onClick={() => setMsOpen(o => !o)}
            >
              <span className="ms-stack__lbl">
                MULTI-SERVICE DISCOUNT
                <HoverPortalTip
                  wrapClassName="ms-stack__info-wrap"
                  tipClassName="channel-tile__tip"
                  placement="above"
                  tip={<span>Running more services lets us be fully accountable for your revenue growth, which opens the door to outcome-linked pricing and success bonuses. As a reward you unlock multi-service discounts:<br/><br/>Add 2 core services and save <strong>5% off retainers</strong>. Add 3 or more and save <strong>10% off retainers</strong>. The discount applies to managed-service retainers only. Add-ons, Dedicated Resources, and one-off charges are excluded.</span>}
                >
                  <span
                    className="ms-stack__info"
                    role="button"
                    tabIndex={-1}
                    aria-label="About the multi-service discount"
                    onClick={(e) => e.stopPropagation()}
                  >
                    <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true">
                      <circle cx="8" cy="8" r="6.5"/>
                      <line x1="8" y1="11" x2="8" y2="7" strokeLinecap="round"/>
                      <circle cx="8" cy="5" r="0.5" fill="currentColor"/>
                    </svg>
                  </span>
                </HoverPortalTip>
              </span>
              <span className="ms-stack__head-pct">
                {bundleDiscount > 0 && (
                  <>
                    <span className="ms-stack__head-amount">−{window.fmt(bundleDiscount)}</span>
                    <span className="ms-stack__head-sep" aria-hidden="true">·</span>
                  </>
                )}
                <span className="ms-stack__head-pct-num">{currentPct}% off</span>
              </span>
              <span className="ms-stack__chevron" aria-hidden="true">
                <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
                  <polyline points="4 6 8 10 12 6" />
                </svg>
              </span>
            </button>
            {msOpen && (
              <div className="ms-stack__body" id="ms-stack-body">
                <div className="ms-stack__subtitle">{subtitle}</div>
                <div className="ms-stack__list gg-stepper" role="list">
                  {TIERS.map(t => {
                    const isCurrent = t.n === currentN;
                    // Fill the rail up to and including the current tier.
                    const isActive = t.n <= currentN;
                    return (
                      <div key={t.n} role="listitem" className={`gg-stepper__step ms-stack__row ${isActive ? 'gg-stepper__step--active' : ''} ${isCurrent ? 'ms-stack__row--current' : ''}`}>
                        <div className="gg-stepper__indicator" aria-hidden="true">
                          <span className="gg-stepper__rail gg-stepper__rail--top" />
                          <span className="gg-stepper__dot" />
                          <span className="gg-stepper__rail gg-stepper__rail--bot" />
                        </div>
                        <span className="ms-stack__row-label">{t.label}</span>
                        <span className="ms-stack__row-pct">{t.pct}% off</span>
                      </div>
                    );
                  })}
                </div>
                <div className="ms-stack__footer">
                  Bundle 2 core services and save <strong>5% off retainers</strong>; add a third or more and save <strong>10% off</strong>. Add-ons and Dedicated Resources are excluded. Commit-length discounts and bundle savings apply automatically. Promotional codes are validated with your Account Executive.
                  {isWhitelabel && <><br/><br/><strong>White-label agencies:</strong> your 40% white-label discount and the bundle discount stack additively. For example: two services at white-label rates = base &times; (1 &minus; 40%) &times; (1 &minus; 5%) = 57% of list price.</>}
                </div>
              </div>
            )}
          </div>
        );
      })()}

      {/* Promo code, collapsed link expands to a small input row.
          Once a code is applied, the same slot shows the applied code (only one allowed). */}
      <div className={`summary__promo ${promoOpen ? 'summary__promo--open' : ''} ${promoApplied ? 'summary__promo--applied' : ''}`}>
        {promoApplied ? (
          <div className="summary__promo-applied">
            <span className="summary__promo-applied-label">
              <span aria-hidden="true">🏷️</span>
              <span>{promoApplied.label} <code className="summary__promo-tag">{promoApplied.code}</code> (−{promoApplied.pct}%)</span>
            </span>
            <span className="summary__promo-applied-val">−{window.fmt(promoDiscount)}</span>
            <button type="button" className="summary__promo-applied-remove" aria-label={`Remove promo code ${promoApplied.code}`} onClick={removePromo}>×</button>
          </div>
        ) : (
          !promoOpen ? (
            <button
              type="button"
              className="summary__promo-toggle"
              onClick={() => setPromoOpen(true)}
            >
              <span aria-hidden="true">＋</span> Have a discount code?
            </button>
          ) : (
            <div className="summary__promo-form">
              <input
                ref={promoInputRef}
                type="text"
                className={`summary__promo-input ${promoError ? 'summary__promo-input--err' : ''}`}
                placeholder="Enter code"
                value={promoInput}
                onChange={(e) => { setPromoInput(e.target.value); if (promoError) setPromoError(''); }}
                onKeyDown={(e) => {
                  if (e.key === 'Enter') { e.preventDefault(); applyPromo(); }
                  if (e.key === 'Escape') { setPromoOpen(false); setPromoInput(''); setPromoError(''); }
                }}
                autoCapitalize="characters"
                autoCorrect="off"
                spellCheck={false}
              />
              <button type="button" className="summary__promo-apply" onClick={applyPromo}>Apply</button>
              <button type="button" className="summary__promo-cancel" onClick={() => { setPromoOpen(false); setPromoInput(''); setPromoError(''); }} aria-label="Cancel">×</button>
              {promoError && <div className="summary__promo-error">{promoError}</div>}
            </div>
          )
        )}
      </div>

      <div className="summary__total">
        {/* Monthly total, primary line */}
        <div className="summary__total-row">
            <div className="summary__total-label">
              <span>{onlyOneTime ? 'Setup fees' : 'Monthly total'}</span>
              <HoverPortalTip
                wrapClassName="summary__total-info-wrap"
                tipClassName="summary__total-tip"
                placement="above"
                tip={(() => {
                  const _isWL  = state.intentId === 'agency-whitelabel';
                  const _msPct = Math.round(_msPctFinal * 100);
                  const _hasMsDiscount = _msPct > 0 && bundleDiscount > 0;
                  const _hasAgyDiscount = agencyDiscountPct > 0;
                  return (
                    <>
                      <span className="summary__total-tip-head">About this total</span>
                      <span className="summary__total-tip-body">
                        Your monthly total <strong>excludes VAT</strong> and <strong>does not include one-off setup fees</strong>. Any setup fees are shown separately below and billed once at the start of your engagement.
                        {_hasAgyDiscount && (
                          <> This price already reflects your <strong>{agencyDiscountPct}% {_isWL ? 'white-label' : 'agency partner'} discount</strong>.</>
                        )}
                        {_hasMsDiscount && (
                          <> A <strong>{_msPct}% multi-service bundle discount</strong> has been applied to eligible retainers.</>
                        )}
                        {_hasAgyDiscount && _hasMsDiscount && (
                          <> Both discounts stack additively.</>
                        )}
                      </span>
                    </>
                  );
                })()}
              >
                <button
                  type="button"
                  className="summary__total-info"
                  aria-label="About this total"
                  tabIndex={-1}
                >
                  <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                    <circle cx="12" cy="12" r="10"/>
                    <line x1="12" y1="16" x2="12" y2="12"/>
                    <circle cx="12" cy="8" r="0.6" fill="currentColor"/>
                  </svg>
                </button>
              </HoverPortalTip>
            </div>
            <div className="summary__total-val">{hasEnterprise && total === 0 ? 'Custom' : window.fmt(animTotal)}</div>
        </div>
        {_isWlAgency && agencyMonthlyMargin > 0 && (
          <div className="summary__total-row">
            <div className="summary__total-label">
              <span>Margin at RRP</span>
              <HoverPortalTip wrapClassName="summary__total-info-wrap" tipClassName="summary__total-tip" placement="above"
                tip={<><span className="summary__total-tip-head">Your margin at RRP</span><span className="summary__total-tip-body">If you bill your client at our public RRP, this is your monthly profit (RRP minus your wholesale cost), across services and add-ons. Charge more to increase it.{agencySetupMargin > 0 && <> You also keep <strong>{window.fmt(Math.round(agencySetupMargin))}</strong> on one-off setup fees.</>}</span></>}>
                <button type="button" className="summary__total-info" aria-label="About your margin" tabIndex={-1}>
                  <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><circle cx="12" cy="8" r="0.6" fill="currentColor"/></svg>
                </button>
              </HoverPortalTip>
            </div>
            <div className="summary__total-val" style={{ color: 'var(--gg-orange-deep, #C77800)', fontSize: '1.15rem' }}>{window.fmt(Math.round(agencyMonthlyMargin))}/mo</div>
          </div>
        )}
        {/* "ex. VAT" subtext removed from sidebar (still surfaced in the
            full breakdown modal where the line says "Monthly total (ex. VAT)"). */}

        {/* Setup & one-off fees, always shown, orange peer styling */}
        <div className="summary__total-row summary__total-row--setup-fees">
          <div className="summary__total-label summary__total-label--setup-fees">
            <span>Setup &amp; one-off fees</span>
            <HoverPortalTip
              wrapClassName="summary__total-info-wrap"
              tipClassName="summary__total-tip"
              placement="above"
              tip={<><span className="summary__total-tip-body">One-time charges (e.g. account setup, data migration, brand kit). Billed once at the start of your engagement, separate from your monthly retainer.</span></>}
            >
              <button type="button" className="summary__total-info" aria-label="About setup fees" tabIndex={-1}>
                <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><circle cx="12" cy="8" r="0.6" fill="currentColor"/></svg>
              </button>
            </HoverPortalTip>
          </div>
          <div className="summary__total-val summary__total-val--setup-fees">{window.fmt(oneTimeAfterBundle)}</div>
        </div>

        {/* Estimated cost per meeting row removed, moved to expanded breakdown only. */}

        {/* 2026-05-23: "Next cohort kickoff" sidebar row removed per request.
            Helper window.getNextCohortKickoffLabel preserved for other consumers. */}

        {/* See-full-breakdown button, opens a centred modal dialog with
            the detailed plan breakdown (no longer expands inline). */}
        <button
          type="button"
          className="summary__breakdown-btn"
          onClick={(e) => { e.preventDefault(); setShowFullBreakdown(true); }}
          aria-haspopup="dialog"
          aria-controls="summary-breakdown-modal"
        >
          <span>See full breakdown</span>
          <span className="summary__breakdown-btn-arrow" aria-hidden="true">›</span>
        </button>
      </div>

      {/* Modal dialog, opened by the See full breakdown CTA. Portalled to
          document.body so it sits above the sidebar's metal frame and any
          parent stacking contexts. Closes on backdrop click, the × button,
          or the Done button. */}
      {showFullBreakdown && ReactDOM.createPortal(
        (() => {
          const hasAnyLines = monthlyLines.length > 0 || oneTimeLines.length > 0;
          const handleClose = () => setShowFullBreakdown(false);
          const handleCopy = () => {
            try {
              const lines = [];
              lines.push('Your plan, line by line');
              lines.push('');
              if (monthlyLines.length > 0) {
                lines.push('Recurring monthly:');
                monthlyLines.forEach(l => {
                  const val = l.custom ? (l.customLabel || 'Custom') : l.waitlist ? 'Waitlist' : l.free ? 'Free' : window.fmt(l.value);
                  lines.push('  ' + (l.addon ? '+ ' : '') + l.label + ': ' + val);
                });
                lines.push('  Subtotal: ' + window.fmt(monthlySubtotal));
                if (bundleDiscount > 0) lines.push('  Multi-service bundle (-' + Math.round(_msPctFinal * 100) + '%): -' + window.fmt(bundleDiscount));
                if (promoDiscount > 0 && promoApplied) lines.push('  ' + (promoApplied.label || 'Promo') + ' ' + promoApplied.code + ' (-' + promoApplied.pct + '%): -' + window.fmt(promoDiscount));
                lines.push('  Monthly total (ex. VAT): ' + window.fmt(total));
                lines.push('');
              }
              if (oneTimeLines.length > 0) {
                lines.push('One-off fees:');
                oneTimeLines.forEach(l => {
                  const val = l.custom ? (l.customLabel || 'Custom') : l.free ? 'Free' : window.fmt(l.value);
                  lines.push('  ' + l.label + ': ' + val);
                });
                lines.push('  Setup total: ' + window.fmt(oneTimeSubtotal));
                lines.push('');
              }
              if (cpmDetails) {
                lines.push('Estimated cost per qualified meeting: ' + window.fmt(cpmDetails.cpm));
              }
              navigator.clipboard.writeText(lines.join('\n'));
            } catch (err) { /* clipboard not available */ }
          };
          return (
            <div
              id="summary-breakdown-modal"
              className="bdwn-modal"
              role="dialog"
              aria-modal="true"
              aria-labelledby="bdwn-modal-title"
            >
              <div className="bdwn-modal__backdrop" onClick={handleClose} />
              <div className="bdwn-modal__panel" onClick={(e) => e.stopPropagation()}>
                <button
                  type="button"
                  className="bdwn-modal__close"
                  onClick={handleClose}
                  aria-label="Close breakdown"
                >
                  <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
                    <line x1="3" y1="3" x2="13" y2="13" />
                    <line x1="13" y1="3" x2="3" y2="13" />
                  </svg>
                </button>

                <h2 id="bdwn-modal-title" className="bdwn-modal__title">Your plan breakdown</h2>
                <p className="bdwn-modal__subtitle">
                  Here is everything in your monthly retainer. Setup and one-off fees are listed at the bottom.
                </p>

                <div className="bdwn-modal__body">
                  {!hasAnyLines ? (
                    <div className="bdwn-modal__empty">
                      <div className="bdwn-modal__empty-title">Nothing to break down yet.</div>
                      <div className="bdwn-modal__empty-desc">
                        Pick a tier above and select any add-ons you want. We'll itemise everything here, base retainer, discounts, channel adds, capacity, signals, retargeting, physical mail, field sales, so you can see exactly what you're paying for.
                      </div>
                    </div>
                  ) : (
                    <>
                      {monthlyLines.length > 0 && (
                        <div className="bdwn-modal__section">
                          <div className="bdwn-modal__section-title">Recurring monthly</div>
                          <ul className="bdwn-modal__list">
                            {monthlyLines.map(l => (
                              <li key={l.id} className={`bdwn-modal__line ${l.addon ? 'bdwn-modal__line--addon' : ''}`}>
                                <span className="bdwn-modal__line-main">
                                  <span className="bdwn-modal__line-label">{l.addon ? '+ ' : ''}{l.label}</span>
                                  {l.breakdown && (
                                    <span className="bdwn-modal__line-breakdown">{l.breakdown}</span>
                                  )}
                                </span>
                                <span className="bdwn-modal__line-val">{
                                  l.custom    ? (l.customLabel || 'Custom') :
                                  l.waitlist  ? 'Waitlist' :
                                  l.free      ? 'Free' :
                                  window.fmt(l.value)
                                }</span>
                              </li>
                            ))}
                          </ul>
                          <div className="bdwn-modal__subtotal">
                            <span>Subtotal</span>
                            <span>{window.fmt(monthlySubtotal)}</span>
                          </div>
                          {payUpfrontSavings > 0 && (
                            <div className="bdwn-modal__discount">
                              <span>Pay upfront (&minus;10% per opted-in service)</span>
                              <span>&minus;{window.fmt(payUpfrontSavings)}</span>
                            </div>
                          )}
                          {bundleDiscount > 0 && (
                            <div className="bdwn-modal__discount">
                              <span>Multi-service bundle ({monthlyServiceCount} services &middot; &minus;{Math.round(_msPctFinal * 100)}%)</span>
                              <span>&minus;{window.fmt(bundleDiscount)}</span>
                            </div>
                          )}
                          {promoDiscount > 0 && promoApplied && (
                            <div className="bdwn-modal__discount">
                              <span>{promoApplied.label || 'Promo'} <code className="bdwn-modal__code">{promoApplied.code}</code> (&minus;{promoApplied.pct}%)</span>
                              <span>&minus;{window.fmt(promoDiscount)}</span>
                            </div>
                          )}
                          <div className="bdwn-modal__total">
                            <span>Monthly total <span className="bdwn-modal__vat">(ex. VAT)</span></span>
                            <span>{window.fmt(total)}</span>
                          </div>
                        </div>
                      )}

                      {oneTimeLines.length > 0 && (
                        <div className="bdwn-modal__section">
                          <div className="bdwn-modal__section-title">One-off fees</div>
                          <ul className="bdwn-modal__list">
                            {oneTimeLines.map(l => (
                              <li key={l.id} className="bdwn-modal__line">
                                <span className="bdwn-modal__line-main">
                                  <span className="bdwn-modal__line-label">{l.label}</span>
                                  {l.breakdown && (
                                    <span className="bdwn-modal__line-breakdown">{l.breakdown}</span>
                                  )}
                                </span>
                                <span className="bdwn-modal__line-val">{
                                  l.custom   ? (l.customLabel || 'Custom') :
                                  l.free     ? 'Free' :
                                  window.fmt(l.value)
                                }</span>
                              </li>
                            ))}
                          </ul>
                          <div className="bdwn-modal__subtotal bdwn-modal__subtotal--setup">
                            <span>Setup total</span>
                            <span>{window.fmt(oneTimeAfterBundle)}</span>
                          </div>
                        </div>
                      )}

                      {cpmDetails && (
                        <div className="bdwn-modal__section">
                          <div className="bdwn-modal__section-title">Cost per meeting</div>
                          <div className="bdwn-modal__formula">
                            <span>{window.fmt(cpmDetails.retainer)} retainer + &pound;{cpmDetails.perLead}/lead &times; {cpmDetails.leads.toLocaleString()} leads</span>
                            <span>= {window.fmt(cpmDetails.totalMonthly)}/mo &divide; {cpmDetails.meetings} meetings</span>
                            <span className="bdwn-modal__formula-result">= <strong>{window.fmt(cpmDetails.cpm)} per meeting</strong></span>
                          </div>
                        </div>
                      )}
                    </>
                  )}

                  {/* Headline summary card, three top-line numbers, always
                      visible at the bottom of the modal body. */}
                  <div className="bdwn-modal__summary">
                    <div className="bdwn-modal__summary-row">
                      <span>Setup &amp; kick-off (one-off)</span>
                      <span>{window.fmt(oneTimeAfterBundle)}</span>
                    </div>
                    <div className="bdwn-modal__summary-row">
                      <span>Monthly retainer</span>
                      <span>{window.fmt(total)}</span>
                    </div>
                    {cpmDetails && (
                      <div className="bdwn-modal__summary-row">
                        <span>Estimated cost per qualified meeting</span>
                        <span>{window.fmt(cpmDetails.cpm)}</span>
                      </div>
                    )}
                  </div>
                </div>

                <p className="bdwn-modal__vat" style={{fontSize:'0.72rem', color:'var(--gg-muted, #5a647d)', margin:'0 0 0.5rem', textAlign:'center'}}>All prices exclude VAT. VAT is charged to UK businesses only.</p>

                <div className="bdwn-modal__actions">
                  <button type="button" className="bdwn-modal__btn bdwn-modal__btn--primary" onClick={handleClose}>Done</button>
                </div>
              </div>
            </div>
          );
        })(),
        document.body
      )}


      </div>{/* /.summary__scroll */}
    </div>
  );
}

// ── CHECKOUT-PAGE ANALYTICS ──
// Fire-and-forget logger used by every CTA on the YoureSetPage (Pick a time,
// Email send, Continue to Stripe). The /api/send-quote function inspects the
// `action` field and logs every call to Airtable; only `action: 'email_quote'`
// also triggers a Resend email. We don't await the fetch in click handlers so
// Cal.com / navigation behaviour isn't blocked by a slow network round-trip.
function trackCheckoutAction(action, state, commitText, email) {
  try {
    const live = window.__currentQuote || {};
    const payload = {
      action,
      email:         email || '',
      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,
      lines:         Array.isArray(live.lines) ? live.lines : [],
      commitText:    commitText || '',
      // Sprint 1-4, forward everything the Summary publishes so /api/send-quote
      // can log it to Airtable. Server-side function can ignore unknown fields
      // safely (Airtable mapping is allow-list driven), so adding more here is
      // additive only.
      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 || '',
    };
    return fetch('/api/send-quote', {
      method:  'POST',
      headers: { 'Content-Type': 'application/json' },
      body:    JSON.stringify(payload),
      // keepalive lets the request survive page navigation (handy for Stripe
      // hand-off where we'd otherwise lose the request mid-flight).
      keepalive: true,
    }).catch(() => { /* best-effort logging, never break the UX on failure */ });
    // Stage 2.O PR2: also create a quote_sessions row on the platform so admin sees this lead.
    // Fire-and-forget; never blocks UX. Only fires for actions that represent intent
    // (book_call). email_quote/email_capture have their own ensurePlatformQuoteAsync calls
    // inside their dedicated form handlers below. sign_up_stripe is handled by
    // StripeSignupCard's own createPlatformQuote chain.
    if (action === 'book_call' && typeof window.ensurePlatformQuoteAsync === 'function') {
      window.ensurePlatformQuoteAsync('book_call', state, email);
    }
  } catch (e) { /* swallow */ }
}

// ── LEAD EMAIL CAPTURE ──
// Sits at the top of the convert grid. Captures email upfront so every CTA
// click (Book a call, Stripe, Email Quote) is recorded with an email address.
function LeadEmailCapture({ leadEmail, setLeadEmail, state, commitText }) {
  const [input, setInput]   = React.useState(leadEmail || '');
  const [status, setStatus] = React.useState(leadEmail ? 'saved' : 'idle');
  const [error, setError]   = React.useState('');

  const onSubmit = async (e) => {
    e.preventDefault();
    const trimmed = input.trim();
    if (!EMAIL_RE.test(trimmed)) { setError('Please enter a valid email.'); return; }
    setError('');
    setStatus('saving');
    setLeadEmail(trimmed);
    try {
      const live = window.__currentQuote || {};
      fetch('/api/send-quote', {
        method:  'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          action:        'email_capture',
          email:         trimmed,
          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,
          lines:         Array.isArray(live.lines) ? live.lines : [],
          commitText:    commitText || '',
          // Sprint 1-4, mirror trackCheckoutAction payload so the Airtable
          // record carries the same context whether the visitor captures
          // their email upfront or clicks a downstream CTA.
          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 || '',
        }),
        keepalive: true,
      }).catch(() => {});
      // Stage 2.O PR2: also create a quote_sessions row on the platform.
      if (typeof window.ensurePlatformQuoteAsync === 'function') {
        window.ensurePlatformQuoteAsync('email_capture', state, trimmed);
      }
    } catch (_) {}
    setStatus('saved');
  };

  if (status === 'saved') {
    return (
      <div className="lead-capture lead-capture--saved">
        <window.Check size={14} />
        <span>Quote saved · <strong>{input || leadEmail}</strong>, check your inbox shortly.</span>
      </div>
    );
  }

  return (
    <div className="lead-capture">
      <div className="lead-capture__body">
        <div className="lead-capture__text">
          <strong>Get a copy of this quote</strong>
          <span>Enter your email, we’ll send a full breakdown so you can review it or share with your team.</span>
        </div>
        <form className="lead-capture__form" onSubmit={onSubmit} noValidate>
          <input
            type="email"
            className="convert__email-input"
            placeholder="you@company.com"
            value={input}
            onChange={e => { setInput(e.target.value); if (error) setError(''); }}
            disabled={status === 'saving'}
            autoComplete="email"
          />
          <button type="submit" className="btn btn--primary btn--sm" disabled={status === 'saving'}>
            {status === 'saving' ? 'Saving…' : 'Send me the quote'}
          </button>
        </form>
        {error && <div className="lead-capture__error">{error}</div>}
      </div>
    </div>
  );
}

// ── EMAIL QUOTE FORM ──
// Sits inside the YoureSetPage convert grid. POSTs the live quote (read from
// window.__currentQuote, which <Summary /> publishes on each render) to the
// /api/send-quote serverless function, which formats a presentable HTML email
// via Resend and logs the submission to Airtable.
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function EmailQuoteForm({ state, commitText, leadEmail, setLeadEmail }) {
  const [email, setEmail]     = React.useState(leadEmail || '');
  const [status, setStatus]   = React.useState('idle'); // idle | sending | sent | error
  const [error, setError]     = React.useState('');
  const [resultId, setResultId] = React.useState('');

  React.useEffect(() => { if (leadEmail && !email) setEmail(leadEmail); }, [leadEmail]);

  const onSubmit = async (e) => {
    e.preventDefault();
    if (status === 'sending' || status === 'sent') return;
    const trimmed = email.trim();
    if (!EMAIL_RE.test(trimmed)) { setError('Please enter a valid email address.'); return; }
    setError('');
    setStatus('sending');
    try {
      const live = window.__currentQuote || {};
      const payload = {
        action:        'email_quote',
        email:         trimmed,
        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,
        lines:         Array.isArray(live.lines) ? live.lines : [],
        commitText:    commitText || '',
        // Sprint 1-4, same expanded payload as trackCheckoutAction so the
        // Airtable record for an emailed quote carries qualifier, scoring,
        // and attribution alongside the price breakdown.
        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 || '',
      };
      const resp = await fetch('/api/send-quote', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(payload),
      });
      const json = await resp.json().catch(() => ({}));
      if (!resp.ok || !json.ok) {
        const msg = (json && (json.error || json.emailError || json.airtableError)) || `Server error (${resp.status})`;
        setError(String(msg));
        setStatus('error');
        return;
      }
      setResultId(json.submissionId || '');
      if (setLeadEmail) setLeadEmail(trimmed);
      setStatus('sent');
      // Stage 2.O PR2: also create a quote_sessions row on the platform.
      if (typeof window.ensurePlatformQuoteAsync === 'function') {
        window.ensurePlatformQuoteAsync('email_quote', state, trimmed);
      }
    } catch (err) {
      setError(String(err && err.message || err));
      setStatus('error');
    }
  };

  if (status === 'sent') {
    return (
      <div className="convert-card">
        <div className="convert-card__icon"><img src="assets/icons/email.webp" alt="" /></div>
        <div className="convert-card__title">Quote sent ✓</div>
        <div className="convert-card__desc">
          Check <strong>{email}</strong>, you should see your full breakdown in a minute or two. We've also logged it on our side so we can pick up the thread when you're ready.
        </div>
        <div className="convert-card__meta"><window.Check size={12}/> Reference {resultId || 'logged'} · Reply to that email any time</div>
      </div>
    );
  }

  return (
    <div className="convert-card">
      <div className="convert-card__icon"><img src="assets/icons/email.webp" alt="" /></div>
      <div className="convert-card__title">Email me the quote</div>
      <div className="convert-card__desc">We'll send the full breakdown, price, scope, services, so you can think it over or share.</div>
      <form className="convert__email-form" onSubmit={onSubmit} noValidate>
        <input
          type="email"
          className="convert__email-input"
          placeholder="you@company.com"
          value={email}
          onChange={(e) => { setEmail(e.target.value); if (error) setError(''); }}
          disabled={status === 'sending'}
          autoComplete="email"
          aria-invalid={!!error}
          aria-describedby={error ? 'email-quote-err' : undefined}
        />
        <button type="submit" className="btn btn--primary btn--sm" disabled={status === 'sending'}>
          {status === 'sending' ? 'Sending…' : 'Send'}
        </button>
      </form>
      {error && (
        <div id="email-quote-err" role="alert" style={{marginTop:8,fontSize:12,color:'#c33',lineHeight:1.4}}>
          {error}
        </div>
      )}
      <div className="convert-card__meta"><window.Check size={12}/> No spam · unsubscribe in-email</div>
    </div>
  );
}

// ── STRIPE SIGN-UP CARD ──
// Wires the "Continue to Stripe" button to the platform's Stripe Checkout
// endpoint. Flow: trackCheckoutAction (analytics, fire-and-forget) →
// POST /api/v1/quotes (create quote_sessions row) → POST /api/v1/quotes/checkout
// (create Stripe Checkout session) → window.location.assign(stripe.url).
// On failure, falls back to the existing card with an error + Try-again button.
// Helpers live in src/platform-api.js (loaded as a plain <script> in index.html).
function StripeSignupCard({ state, commitText, leadEmail }) {
  // Status machine — matches the EmailQuoteForm / CheckoutForm pattern
  // elsewhere in this file. 'idle' → 'redirecting' → ('error' | navigation away).
  const [status, setStatus] = React.useState('idle');
  const [error, setError]   = React.useState('');

  const onClick = async () => {
    if (status === 'redirecting') return;
    setStatus('redirecting');
    setError('');

    // Preserve the existing Airtable click-tracking pipeline (fire-and-forget).
    try { trackCheckoutAction('sign_up_stripe', state, commitText, leadEmail); }
    catch (_) { /* swallow — analytics never blocks UX */ }

    // Real Stripe Checkout flow (Stage 2.P phase 2).
    try {
      if (typeof window.buildPlatformQuotePayload !== 'function'
       || typeof window.createPlatformQuote      !== 'function'
       || typeof window.createStripeCheckout     !== 'function') {
        throw new Error('Checkout client not loaded — please refresh the page.');
      }

      const payload = window.buildPlatformQuotePayload(state, leadEmail);
      if (!payload.email) {
        throw new Error('We need your email to start checkout — please complete the form above first.');
      }
      if (payload.monthly_total === 0 && payload.setup_total === 0) {
        throw new Error('Your plan currently totals £0 — add at least one paid service before continuing.');
      }

      const quote = await window.createPlatformQuote(payload);
      if (!quote || !quote.id) throw new Error('Quote service returned no ID. Please try again.');

      const session = await window.createStripeCheckout(quote.id, {
        success_url: window.location.origin + '/?stripe=success&quote=' + encodeURIComponent(quote.id),
        cancel_url:  window.location.origin + '/?stripe=cancel&quote='  + encodeURIComponent(quote.id),
      });
      if (!session || !session.url) throw new Error('Checkout session returned no URL. Please try again.');

      // Hand off to Stripe — assign (not replace) so the user can hit Back.
      window.location.assign(session.url);
    } catch (e) {
      setError(String(e && e.message || e));
      setStatus('error');
    }
  };

  // Error state — same card chrome, swapped copy + retry button.
  if (status === 'error') {
    return (
      <div className="convert-card convert-card--primary">
        <img src="assets/badges/recommended.webp" alt="" className="convert-card__badge" />
        <div className="convert-card__icon"><img src="assets/icons/plane.webp" alt="" /></div>
        <div className="convert-card__title">Something went wrong</div>
        <div className="convert-card__desc">
          {error || 'We couldn’t start your Stripe checkout. Please try again, or email '}
          {!error && <a href="mailto:hello@gogorilla.com">hello@gogorilla.com</a>}
          {!error && '.'}
        </div>
        <button
          className="btn btn--primary btn--block"
          onClick={() => { setStatus('idle'); setError(''); }}
        >
          Try again <window.Arrow />
        </button>
        <div className="convert-card__meta"><window.Lock size={12}/> Secure · Stripe · 256-bit</div>
      </div>
    );
  }

  return (
    <div className="convert-card convert-card--primary">
      <img src="assets/badges/recommended.webp" alt="" className="convert-card__badge" />
      <div className="convert-card__icon"><img src="assets/icons/plane.webp" alt="" /></div>
      <div className="convert-card__title">I'm ready to sign up</div>
      <div className="convert-card__desc">Proceed to Stripe Checkout with exactly this scope. Your pod kicks off within 48 hours.</div>
      <button
        className="btn btn--primary btn--block"
        onClick={onClick}
        disabled={status === 'redirecting'}
      >
        {status === 'redirecting' ? 'Opening secure checkout…' : <>Continue to Stripe <window.Arrow /></>}
      </button>
      <div className="convert-card__meta"><window.Lock size={12}/> Secure · Stripe · 256-bit</div>
    </div>
  );
}

// ── PAGE 3, You're Set ──
// ── CHECKOUT FORM ──────────────────────────────────────────────────────
// Replaces the older CombinedActionCard. Standard contact fields + Google
// reCAPTCHA v2 verification before submitting. POSTs the live quote +
// contact info to /api/send-quote; on success surfaces a Book-a-call CTA.
// ── ESSENTIAL QUESTIONS HINT BLOCK ─────────────────────────────────────
// 2026-05-26: this block originally gated submission when the user clicked
// the Skip-questions floater on Step 6. Both the floater + the
// qualifierSkipped state are now removed (task #408). The 4-question hint
// list still renders as a soft prompt for users who haven't answered the
// full qualifier yet, but validate() no longer enforces it — users can
// submit at any time.
const ESSENTIAL_QUALIFIER_IDS = ['q1', 'q3', 'q4', 'urgency'];
window.ESSENTIAL_QUALIFIER_IDS = ESSENTIAL_QUALIFIER_IDS;

function getMissingEssentialQs(state) {
  const q = state?.qualifier || {};
  return ESSENTIAL_QUALIFIER_IDS.filter(id => !q[id]);
}
window.getMissingEssentialQs = getMissingEssentialQs;

function EssentialQuestionsBlock({ state, dispatch }) {
  const qualifier = state?.qualifier || {};
  // Look up question definitions by id from the canonical QUALIFIER_QUESTIONS.
  const allQs = window.QUALIFIER_QUESTIONS || [];
  const essentials = ESSENTIAL_QUALIFIER_IDS
    .map(id => allQs.find(q => q.id === id))
    .filter(Boolean);
  const answered = essentials.filter(q => qualifier[q.id]).length;
  const total = essentials.length;

  if (essentials.length === 0) return null;

  return (
    <div className="essentials-gate" role="region" aria-labelledby="essentials-gate-title">
      <div className="essentials-gate__head">
        <div className="essentials-gate__eyebrow">Before we send your quote</div>
        <h3 id="essentials-gate-title" className="essentials-gate__title">
          A few essentials we need first
        </h3>
        <p className="essentials-gate__sub">
          You skipped the questions earlier, these four help the team make a meaningful proposal. Takes under a minute.
        </p>
        <div className="essentials-gate__progress" aria-live="polite">
          <span className="essentials-gate__progress-label">{answered} of {total} answered</span>
          <div className="essentials-gate__progress-bar" aria-hidden="true">
            <div className="essentials-gate__progress-fill" style={{ width: `${(answered / total) * 100}%` }} />
          </div>
        </div>
      </div>
      <div className="essentials-gate__list">
        {essentials.map(q => {
          const cur = qualifier[q.id];
          const titleId = `eg-${q.id}-label`;
          const _isMissing = !cur;
          return (
            <div
              key={q.id}
              className={`qualifier-q qualifier-q--cols-2 ${_isMissing ? 'qualifier-q--missing' : ''}`}
              role="group"
              aria-labelledby={titleId}
            >
              <div className="qualifier-q__head">
                <h3 id={titleId} className="qualifier-q__label">{q.label}</h3>
                {q.sub && <p className="qualifier-q__sub">{q.sub}</p>}
              </div>
              <div className="qualifier-q__opts" role="radiogroup" aria-labelledby={titleId}>
                {q.options.map(opt => {
                  const on = cur === opt.id;
                  const hasDesc = !!opt.desc;
                  return (
                    <button
                      key={opt.id}
                      type="button"
                      role="radio"
                      aria-checked={on}
                      aria-label={opt.desc ? `${opt.label}: ${opt.desc}` : opt.label}
                      className={`qualifier-opt ${on ? 'qualifier-opt--on' : ''} ${hasDesc ? 'qualifier-opt--with-desc' : 'qualifier-opt--label-only'}`}
                      onClick={() => {
                        // Toggle off if re-clicked (same UX as the Step 1 tree)
                        if (on) dispatch({ type: 'SET_QUALIFIER', q: q.id, value: null });
                        else    dispatch({ type: 'SET_QUALIFIER', q: q.id, value: opt.id });
                      }}
                    >
                      <span className={`qualifier-opt__radio ${on ? 'is-on' : ''}`} aria-hidden="true">
                        {on && <window.Check size={12} />}
                      </span>
                      <div className="qualifier-opt__body">
                        <div className="qualifier-opt__title">{opt.label}</div>
                        {hasDesc && <div className="qualifier-opt__desc">{opt.desc}</div>}
                      </div>
                    </button>
                  );
                })}
              </div>
            </div>
          );
        })}
      </div>
    </div>
  );
}
window.EssentialQuestionsBlock = EssentialQuestionsBlock;

function CheckoutForm({ state, dispatch, commitText }) {
  const [form, setForm] = React.useState({
    firstName: postCallName() || '', lastName: '', company: '', website: '', email: postCallEmail() || '', phone: '', phoneCountry: 'GB', budget: '', clientsManaged: '',
  });
  const [errors, setErrors] = React.useState({});
  const [captchaToken, setCaptchaToken] = React.useState('');
  const [status, setStatus] = React.useState('idle'); // idle | sending | sent
  const captchaContainerRef = React.useRef(null);
  const captchaWidgetIdRef = React.useRef(null);

  // 2026-05-30: Partner Pro+ (Investor Portal 'scale') includes a required
  // confidential intake call. On the final step we force the Schedule a call
  // preference and disable the call-optional options so it is the only path.
  const _isPartnerProPlus = state.selections?.['investor-portal']?.tier === 'scale';
  React.useEffect(() => {
    if (_isPartnerProPlus && (state.callPreference || 'schedule') !== 'schedule' && dispatch) {
      dispatch({ type: 'SET_CALL_PREFERENCE', value: 'schedule' });
    }
  }, [_isPartnerProPlus]);

  // 2026-05-23: when the user successfully submits the quote, mount an
  // inline Cal.com booking widget in place of the old "Book a strategy
  // call" button. The Cal namespace is initialised in index.html as
  // Cal("init", "book-a-call", ...), and the inline embed signature is
  // Cal.ns["book-a-call"]("inline", { elementOrSelector, calLink, config }).
  React.useEffect(() => {
    if (status !== 'sent') return;
    const _calPref = state.callPreference || 'schedule';
    if (_calPref !== 'schedule' && _calPref !== 'email-first') return;
    if (typeof window.Cal !== 'function' || !window.Cal.ns || !window.Cal.ns['book-a-call']) return;
    // 2026-05-25: prefill Cal.com booker with the contact details the user
    // just entered on the checkout form. Cal.com's inline embed accepts
    // name / email / smsReminderNumber in the config.config object — those
    // map to the booker form's standard fields.
    const _fullName = (form.firstName + ' ' + form.lastName).trim();
    const _phoneE164 = (() => {
      const ph = (form.phone || '').trim();
      if (!ph) return '';
      try {
        const country = typeof findCountry === 'function' ? findCountry(form.phoneCountry) : null;
        const nsn = typeof normalizePhone === 'function' ? normalizePhone(ph, form.phoneCountry) : null;
        if (country && nsn) return country.dial + nsn;
      } catch (e) {}
      return ph;
    })();
    // Build a short plan-summary the sales team can read at a glance when
    // the calendar invite lands. Pre-fills Cal.com's "What would you like
    // to discuss on the call?" notes textarea.
    const _notes = (() => {
      const lines = [];
      const live = window.__currentQuote || {};
      const personaMap = { founder: 'Founder', investor: 'Investor', agency: 'Agency' };
      const persona = personaMap[live.clientTypeId || state?.clientTypeId] || 'Founder';
      const heading = (live.clientHeading && live.clientHeading !== 'Your custom quote') ? live.clientHeading : '';
      const headerLine = heading ? (persona + ' · ' + heading) : persona;
      lines.push(headerLine + ' inquiry');
      // Services list, dedupe by sid, skip addons/setup/waitlist/deposits.
      const planLines = Array.isArray(live.lines) ? live.lines : [];
      const seen = new Set();
      const svcSummary = [];
      planLines.forEach(l => {
        if (l.addon || l.setupFee || l.waitlist || l.deposit) return;
        if (!l.sid || seen.has(l.sid)) return;
        seen.add(l.sid);
        // Strip the leading "↳ " and any prefix added by indenting.
        const label = (l.label || '').replace(/^[↳+\s]+/, '').trim();
        if (label) svcSummary.push(label);
      });
      if (svcSummary.length > 0) {
        lines.push('Services: ' + svcSummary.join(' · '));
      }
      // Totals.
      const fmt = typeof window.fmt === 'function' ? window.fmt : (n => '£' + n);
      const mt = Number(live.monthlyTotal) || 0;
      const ot = Number(live.oneTimeTotal) || 0;
      if (mt > 0 || ot > 0) {
        const parts = [];
        if (mt > 0) parts.push('Monthly ' + fmt(mt));
        if (ot > 0) parts.push('Setup ' + fmt(ot));
        lines.push(parts.join(' · '));
      }
      if (commitText) lines.push(commitText);
      if (live.quote_uuid) lines.push('Quote ref: ' + live.quote_uuid);
      return lines.join('\n');
    })();
    // Wait one tick so the container is in the DOM
    const t = setTimeout(() => {
      try {
        window.Cal.ns['book-a-call']('inline', {
          elementOrSelector: '#cal-inline-success',
          calLink: 'team/gogorilla/book-a-call',
          config: {
            layout: 'month_view',
            // 2026-05-26: default the phone-number country selector to UK so
            // British users don't have to switch from the US fallback. Cal.com
            // overrides this when smsReminderNumber starts with another dial
            // code, so users from elsewhere who filled the phone field
            // upstream still see their actual country.
            country: 'gb',
            name: _fullName,
            email: (form.email || '').trim(),
            smsReminderNumber: _phoneE164,
            notes: _notes,
          },
        });
      } catch (e) { /* swallow, fall back to nothing */ }
    }, 40);
    return () => clearTimeout(t);
  }, [status]);

  // Validation regexes.
  // Email: standard ASCII local-part + dot-separated domain with TLD ≥ 2 letters.
  const EMAIL_RE = /^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$/;
  // Name: letters (incl. accented), spaces, hyphens, apostrophes, dots, 1-50 chars.
  const NAME_RE = /^[\p{L}][\p{L}\s'\-.]{0,49}$/u;
  // Country dial-code table for the phone input. Each entry knows its
  // ISO 3166-1 alpha-2 code, flag emoji, international dial prefix, NSN
  // length range, and (optional) a UK-style first-digit allow-list for
  // stricter validation. The list is GoGorilla's most common client
  // geographies, extend as needed.
  const PHONE_COUNTRIES = [
    { code: 'GB', name: 'United Kingdom', flag: '🇬🇧', dial: '+44', placeholder: '7700 900123', nsnMin: 10, nsnMax: 10, nsnFirst: '123578' },
    { code: 'US', name: 'United States',  flag: '🇺🇸', dial: '+1',  placeholder: '(555) 123-4567', nsnMin: 10, nsnMax: 10 },
    { code: 'CA', name: 'Canada',         flag: '🇨🇦', dial: '+1',  placeholder: '(416) 555-0100', nsnMin: 10, nsnMax: 10 },
    { code: 'IE', name: 'Ireland',        flag: '🇮🇪', dial: '+353', placeholder: '85 123 4567',   nsnMin: 7,  nsnMax: 11 },
    { code: 'AU', name: 'Australia',      flag: '🇦🇺', dial: '+61',  placeholder: '4xx xxx xxx',   nsnMin: 9,  nsnMax: 9  },
    { code: 'NZ', name: 'New Zealand',    flag: '🇳🇿', dial: '+64',  placeholder: '21 123 4567',   nsnMin: 8,  nsnMax: 10 },
    { code: 'DE', name: 'Germany',        flag: '🇩🇪', dial: '+49',  placeholder: '151 12345678',  nsnMin: 6,  nsnMax: 13 },
    { code: 'FR', name: 'France',         flag: '🇫🇷', dial: '+33',  placeholder: '6 12 34 56 78', nsnMin: 9,  nsnMax: 9  },
    { code: 'NL', name: 'Netherlands',    flag: '🇳🇱', dial: '+31',  placeholder: '6 12345678',    nsnMin: 9,  nsnMax: 9  },
    { code: 'ES', name: 'Spain',          flag: '🇪🇸', dial: '+34',  placeholder: '612 34 56 78',  nsnMin: 9,  nsnMax: 9  },
    { code: 'IT', name: 'Italy',          flag: '🇮🇹', dial: '+39',  placeholder: '312 345 6789',  nsnMin: 9,  nsnMax: 11 },
    { code: 'CH', name: 'Switzerland',    flag: '🇨🇭', dial: '+41',  placeholder: '78 123 45 67',  nsnMin: 9,  nsnMax: 9  },
    { code: 'SE', name: 'Sweden',         flag: '🇸🇪', dial: '+46',  placeholder: '70 123 45 67',  nsnMin: 7,  nsnMax: 13 },
    { code: 'SG', name: 'Singapore',      flag: '🇸🇬', dial: '+65',  placeholder: '8123 4567',     nsnMin: 8,  nsnMax: 8  },
    { code: 'AE', name: 'UAE',            flag: '🇦🇪', dial: '+971', placeholder: '50 123 4567',   nsnMin: 8,  nsnMax: 9  },
    { code: 'IN', name: 'India',          flag: '🇮🇳', dial: '+91',  placeholder: '98765 43210',   nsnMin: 10, nsnMax: 10 },
    { code: 'ZA', name: 'South Africa',   flag: '🇿🇦', dial: '+27',  placeholder: '71 123 4567',   nsnMin: 9,  nsnMax: 9  },
  ];
  const findCountry = (code) => PHONE_COUNTRIES.find(c => c.code === code) || PHONE_COUNTRIES[0];

  // Phone validator. Strips formatting, then checks the digits against the
  // selected country's rules. Returns null on failure, NSN string on success.
  function normalizePhone(raw, countryCode) {
    if (!raw) return null;
    const country = findCountry(countryCode);
    let s = String(raw).trim().replace(/[\s().\-_]/g, '');
    const dialDigits = country.dial.replace(/[^\d]/g, '');
    // 00 international prefix → +
    if (s.startsWith('00')) s = '+' + s.slice(2);
    // Bare dial-code without + at the start (rare typo)
    if (s.startsWith(dialDigits) && !s.startsWith('+')) s = '+' + s;
    let nsn = null;
    if (s.startsWith(country.dial)) {
      nsn = s.slice(country.dial.length);
      // Trunk-prefix quirk e.g. +44 (0)..., strip the leading 0.
      if (nsn.startsWith('0')) nsn = nsn.slice(1);
    } else if (s.startsWith('0')) {
      nsn = s.slice(1);
    } else if (/^\d+$/.test(s)) {
      // Already in NSN form (no country code, no leading 0)
      nsn = s;
    } else {
      return null;
    }
    if (!/^\d+$/.test(nsn)) return null;
    if (nsn.length < country.nsnMin || nsn.length > country.nsnMax) return null;
    if (country.nsnFirst && !country.nsnFirst.includes(nsn[0])) return null;
    return nsn;
  }
  // Website: hostname with TLD (allow http(s):// prefix, with or without path/query).
  const WEBSITE_RE = /^(https?:\/\/)?([A-Za-z0-9](-?[A-Za-z0-9])*\.)+[A-Za-z]{2,}(\/[^\s]*)?$/;
  // Google reCAPTCHA v2 TEST site key, always passes, swap for production key.
  // https://developers.google.com/recaptcha/docs/faq#id-like-to-run-automated-tests-with-recaptcha
  const RECAPTCHA_SITE_KEY = '6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI';

  // reCAPTCHA loading is disabled until the production site key is wired up.
  // The widget injection + token state stay in place so re-enabling is a
  // one-line revert in this useEffect + the validate() check below.
  // See RECAPTCHA_SETUP.md for the setup steps.
  const CAPTCHA_ENABLED = false;
  React.useEffect(() => {
    if (!CAPTCHA_ENABLED) return;
    function renderWidget() {
      if (!window.grecaptcha || !captchaContainerRef.current) return;
      if (captchaWidgetIdRef.current != null) return;
      try {
        captchaWidgetIdRef.current = window.grecaptcha.render(captchaContainerRef.current, {
          sitekey: RECAPTCHA_SITE_KEY,
          callback: (token) => setCaptchaToken(token),
          'expired-callback': () => setCaptchaToken(''),
          'error-callback': () => setCaptchaToken(''),
        });
      } catch (e) {}
    }
    if (window.grecaptcha && window.grecaptcha.render) { renderWidget(); return; }
    if (!document.querySelector('script[src*="recaptcha/api.js"]')) {
      window.gg_recaptcha_loaded = renderWidget;
      const script = document.createElement('script');
      script.src = 'https://www.google.com/recaptcha/api.js?onload=gg_recaptcha_loaded&render=explicit';
      script.async = true;
      script.defer = true;
      document.head.appendChild(script);
    } else {
      const poll = setInterval(() => {
        if (window.grecaptcha && window.grecaptcha.render) { clearInterval(poll); renderWidget(); }
      }, 100);
      return () => clearInterval(poll);
    }
  }, []);

  function setField(k, v) {
    setForm((prev) => ({ ...prev, [k]: v }));
    if (errors[k]) setErrors((prev) => ({ ...prev, [k]: '' }));
  }

  function validate() {
    const e = {};
    // First name, required, letters/spaces/hyphens/apostrophes, 1-50 chars.
    const fn = form.firstName.trim();
    if (!fn) e.firstName = 'Please enter your first name.';
    else if (fn.length < 2) e.firstName = 'Too short, please enter your full first name.';
    else if (!NAME_RE.test(fn)) e.firstName = 'Letters, spaces, hyphens and apostrophes only.';
    // Last name, same rules.
    const ln = form.lastName.trim();
    if (!ln) e.lastName = 'Please enter your last name.';
    else if (ln.length < 2) e.lastName = 'Too short, please enter your full last name.';
    else if (!NAME_RE.test(ln)) e.lastName = 'Letters, spaces, hyphens and apostrophes only.';
    // Company, required, 2-100 chars.
    const co = form.company.trim();
    if (!co) e.company = 'Please enter your company name.';
    else if (co.length < 2) e.company = 'Too short, please enter your full company name.';
    else if (co.length > 100) e.company = 'Please use 100 characters or fewer.';
    // Email, required, valid format. Personal email providers are accepted
    // for early-stage founders who don't have a work email yet.
    const em = form.email.trim();
    if (!em) e.email = 'Please enter your email.';
    else if (em.length > 100) e.email = 'Please use 100 characters or fewer.';
    else if (!EMAIL_RE.test(em)) e.email = 'That does not look like a valid email address.';
    // Website, optional, but must be a valid URL/domain if provided.
    const ws = form.website.trim();
    if (ws && !WEBSITE_RE.test(ws)) {
      e.website = 'Please enter a valid website (e.g. https://yourcompany.com).';
    }
    // Phone, optional, but if provided must be a valid number for the selected country.
    const ph = form.phone.trim();
    if (ph) {
      if (!normalizePhone(ph, form.phoneCountry)) {
        const c = findCountry(form.phoneCountry);
        e.phone = `Please enter a valid ${c.name} phone number (e.g. ${c.dial} ${c.placeholder}).`;
      }
    }
    // Captcha check disabled until reCAPTCHA keys are wired up.
    // Re-enable by uncommenting the next line once the production site key
    // is in place (see RECAPTCHA_SETUP.md).
    // if (!captchaToken) e.captcha = 'Please verify you are not a robot.';
    // 2026-05-23: essentials gate as a SUBMIT BLOCKER removed per user feedback,
    // skip should mean skip. The EssentialQuestionsBlock UI is still rendered
    // above the form as an optional helper (so users who didn't skip yet
    // see what would help us write a meaningful proposal), but it no longer
    // blocks the submit button. Sales team can pick up the missing context
    // on the booked call.
    setErrors(e);
    return Object.keys(e).length === 0;
  }

  async function onSubmit(e) {
    e.preventDefault();
    if (status === 'sending') return;
    if (!validate()) return;
    setStatus('sending');
    try {
      const live = window.__currentQuote || {};
      await fetch('/api/send-quote', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          action:        'email_quote',
          // Standard contact fields (Sprint 5)
          firstName:     form.firstName.trim(),
          lastName:      form.lastName.trim(),
          company:       form.company.trim(),
          website:       form.website.trim(),
          budget:        form.budget || '',
          clientsManaged: form.clientsManaged || '',
          phone:         (() => {
            const ph = form.phone.trim();
            if (!ph) return '';
            const nsn = normalizePhone(ph, form.phoneCountry);
            if (!nsn) return ph;
            return findCountry(form.phoneCountry).dial + nsn;
          })(),
          phoneCountry:  form.phoneCountry,
          email:         form.email.trim(),
          captchaToken,
          // Existing quote payload
          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,
          lines:         Array.isArray(live.lines) ? live.lines : [],
          commitText:    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 || '',
          // Step 6 + Sprint 4, call preference (how user wants the proposal)
          callPreference: state?.callPreference || 'schedule',
          // 2026-05-26: grantsOptIn + gapsApplied removed (auto-preselect was
          // removed in task #280; no GorillaGrants UI exists). Airtable
          // GorillaGrants Applied + Gaps Applied columns will stop populating;
          // existing rows retain historical values.
          // 2026-05-22: per-service config so api/send-quote.js can derive the
          // Pay Upfront / BYOL List Quality / Lead Source Mode / Monthly Lead
          // Volume / Channels / Commit Months / Tiers columns. Without this,
          // those columns landed blank on every Airtable row.
          selections:    state?.selections || {},
        }),
      });
    } catch (_) { /* keep going, log to console only */ }
    setStatus('sent');
    if (typeof dispatch === 'function') dispatch({ type: 'SET_QUOTE_SUBMITTED', value: true });
    // Stage 2.O PR2: also create a quote_sessions row on the platform with contact fields.
    if (typeof window.ensurePlatformQuoteAsync === 'function') {
      window.ensurePlatformQuoteAsync('email_quote', state, form.email.trim(), {
        firstName: form.firstName.trim(),
        lastName:  form.lastName.trim(),
        company:   form.company.trim(),
        website:   form.website.trim(),
        phone:     form.phone.trim(),
        phoneCountry: form.phoneCountry,
        callPreference: state?.callPreference || 'schedule',
      });
    }
    // Intentionally NOT calling trackCheckoutAction here. The Airtable record
    // is written exactly once by the fetch above. Adding an analytics ping
    // would create a duplicate row.
  }

  async function onBookCall() {
    // Open Cal.com booking. Do NOT POST to /api/send-quote, the quote has
    // already been saved by onSubmit. Sending again would duplicate the row.
    // Cal.com handles the booking attribution via the data-cal-link attribute
    // on the button itself.
  }

    if (status === 'sent') {
    const _pref = state.callPreference || 'schedule';
    const _isAgency = state.clientTypeId === 'agency';
    const _showCal = _pref === 'schedule' || _pref === 'email-first';
    const _email = <strong>{form.email}</strong>;
    let _body;
    if (_pref === 'email-first') {
      _body = <>Your proposal is on its way to {_email}, and we will send it within one business day. Booking a call is optional. Pick a slot below if you would like to talk it through.</>;
    } else if (_pref === 'already-had') {
      _body = <>Your proposal is on its way to {_email}, and we will send it within one business day. Your team already has the context from our call, so no further steps are needed from you.</>;
    } else if (_pref === 'contract-payment') {
      _body = _isAgency
        ? <>Your details are in. We will email your payment link to {_email} within one to two hours so you can start straight away. No contract is required.</>
        : <>Your details are in. We will email your contract and payment link to {_email} within one to two hours.</>;
    } else {
      _body = <>Your quote is on its way to {_email}. Pick a slot below to review it with the team.</>;
    }
    return (
      <div className="convert-card convert-card--action checkout-form">
        <div className="checkout-form__success">
          <window.Check size={18} />
          <div>
            <div className="checkout-form__success-title">Thanks, {form.firstName}.</div>
            <div className="checkout-form__success-body">{_body}</div>
          </div>
        </div>
        {/* Inline Cal.com booking widget. The Cal namespace is initialised
            in index.html; the useEffect above mounts the inline embed into
            this container when status becomes 'sent'. */}
        {_showCal && <div id="cal-inline-success" className="cal-inline-success" style={{minHeight:720,width:'100%'}}></div>}
        <div className="cac__meta">
          <window.Check size={11} /> {_showCal ? 'No commitment, cancel any time.' : 'We will be in touch by email shortly.'}
        </div>
      </div>
    );
  }

  return (
    <div className="convert-card convert-card--action checkout-form">
      <div className="cac__header">
        <img className="cac__icon" src="assets/icons/step-checkout.webp" alt="" />
        <div className="cac__intro">
          <div className="cac__title">{(() => {
            if (isPostCallMode()) return postCallName() ? ('Thanks, ' + postCallName() + '. Send your selections to the team.') : 'Send your service selections to the team.';
            if (_isPartnerProPlus) return 'Book your intake call.';
            const p = state.callPreference || 'schedule';
            if (p === 'email-first')  return 'We will email your proposal within one business day.';
            if (p === 'already-had') return 'We will send your proposal to your team.';
            if (p === 'contract-payment') return 'We will send your payment link shortly.';
            return 'Choose how you would like to receive your proposal.';
          })()}</div>

        </div>
      </div>

      {/* §5.2, Call-preference radio cards. Hidden in post-call mode (?booked=1)
          because the user has already scheduled and just needs to send their selection. */}
      {!isPostCallMode() && (() => {
        const callPref = state.callPreference || 'schedule';
        const PREFS = [
          { id: 'schedule',         title: 'Schedule a call to review it',  desc: 'Recommended. We email your proposal, then you choose a time to review it with our team.' },
          { id: 'email-first',      title: 'Email me the proposal first',          desc: 'We send your proposal by email first. Book a call later only if you would like to discuss it.' },
          { id: 'already-had',      title: 'I have already had a call',            desc: 'Send the proposal to my team, who already have the context from our call.' },
          { id: 'contract-payment', title: state.clientTypeId === 'agency' ? 'Request a payment link' : 'Request contract and payment link', desc: state.clientTypeId === 'agency' ? 'We send your payment link within one to two hours so you can start straight away. No contract is required for agencies.' : 'You are ready to proceed. We send your contract and payment link within one to two hours.' },
        ];
        return (
          <div className="call-pref" role="radiogroup" aria-label="How would you like to receive your proposal?">
            {_isPartnerProPlus && (
              <p className="call-pref__required-note" style={{ fontSize: '0.8rem', color: 'var(--gg-heading, #0f1c35)', lineHeight: 1.5, marginBottom: '0.85rem' }}>
                You selected Partner Pro+, which includes a required confidential intake call. We use it to understand your acquisition thesis before activating dedicated buy-side deal origination. Please book your call below.
              </p>
            )}
            <div className="call-pref__grid">
              {PREFS.map(p => {
                const on = callPref === p.id;
                const _disabled = _isPartnerProPlus && p.id !== 'schedule';
                return (
                  <button
                    key={p.id}
                    type="button"
                    role="radio"
                    aria-checked={on}
                    aria-disabled={_disabled}
                    disabled={_disabled}
                    title={_disabled ? 'A confidential intake call is required for Partner Pro+' : undefined}
                    style={_disabled ? { opacity: 0.45, cursor: 'not-allowed' } : undefined}
                    className={`call-pref__card thin-glass-frame ${on ? 'call-pref__card--on' : ''}`}
                    onClick={() => {
                      if (_disabled) return;
                      if (dispatch) dispatch({ type: 'SET_CALL_PREFERENCE', value: p.id });
                      /* 2026-05-29: auto-scroll to the first form input
                         after a call-pref is picked. The pref cards live
                         above the form on Step 6; once the user has
                         committed to a flow we want their attention on
                         filling in details. */
                      _scrollToNext('.checkout-form__grid input, .checkout-form__grid textarea');
                    }}
                  >
                    <span className={`call-pref__card-check ${on ? 'is-on' : ''}`} aria-hidden="true">
                      {on && <window.Check size={16} />}
                    </span>
                    <div className="call-pref__card-body">
                      <div className="call-pref__card-title">{p.title}</div>
                      <div className="call-pref__card-desc">{p.desc}</div>
                    </div>
                  </button>
                );
              })}
            </div>
            <p style={{ fontSize: '0.78rem', color: 'var(--gg-muted, #5a647d)', lineHeight: 1.45, marginTop: '0.85rem' }}>
              Have a budget in mind but not sure which services fit? We can propose the right mix. Just ask on your call or reply to your quote email.
            </p>
          </div>
        );
      })()}

      <form className="checkout-form__grid" onSubmit={onSubmit} noValidate>
        <label className="checkout-form__field">
          <span className="checkout-form__label">First name</span>
          <input
            type="text"
            className={`checkout-form__input ${errors.firstName ? 'checkout-form__input--err' : ''}`}
            value={form.firstName}
            onChange={(e) => setField('firstName', e.target.value)}
            autoComplete="given-name"
            required
          />
          {errors.firstName && <span className="checkout-form__err">{errors.firstName}</span>}
        </label>

        <label className="checkout-form__field">
          <span className="checkout-form__label">Last name</span>
          <input
            type="text"
            className={`checkout-form__input ${errors.lastName ? 'checkout-form__input--err' : ''}`}
            value={form.lastName}
            onChange={(e) => setField('lastName', e.target.value)}
            autoComplete="family-name"
            required
          />
          {errors.lastName && <span className="checkout-form__err">{errors.lastName}</span>}
        </label>

        <label className="checkout-form__field checkout-form__field--full">
          <span className="checkout-form__label">Email</span>
          <input
            type="email"
            className={`checkout-form__input ${errors.email ? 'checkout-form__input--err' : ''}`}
            placeholder="you@company.com"
            value={form.email}
            onChange={(e) => setField('email', e.target.value)}
            autoComplete="email"
            required
          />
          {errors.email && <span className="checkout-form__err">{errors.email}</span>}
        </label>

        <label className="checkout-form__field">
          <span className="checkout-form__label">{state.clientTypeId === 'agency' ? 'Agency name' : 'Company'}</span>
          <input
            type="text"
            className={`checkout-form__input ${errors.company ? 'checkout-form__input--err' : ''}`}
            value={form.company}
            onChange={(e) => setField('company', e.target.value)}
            autoComplete="organization"
            required
          />
          {errors.company && <span className="checkout-form__err">{errors.company}</span>}
        </label>

        <label className="checkout-form__field">
          <span className="checkout-form__label">{state.clientTypeId === 'agency' ? 'Agency website' : 'Website'} <span className="checkout-form__optional">(optional)</span></span>
          <input
            type="url"
            className={`checkout-form__input ${errors.website ? 'checkout-form__input--err' : ''}`}
            placeholder="https://"
            value={form.website}
            onChange={(e) => setField('website', e.target.value)}
            autoComplete="url"
          />
          {errors.website && <span className="checkout-form__err">{errors.website}</span>}
        </label>

        <label className="checkout-form__field">
          <span className="checkout-form__label">Approximate monthly budget <span className="checkout-form__optional">(optional)</span></span>
          <select className="checkout-form__input" value={form.budget} onChange={(e) => setField('budget', e.target.value)}>
            <option value="">Prefer not to say</option>
            <option value="under-2k">Under £2,000/mo</option>
            <option value="2k-5k">£2,000 to £5,000/mo</option>
            <option value="5k-10k">£5,000 to £10,000/mo</option>
            <option value="10k-25k">£10,000 to £25,000/mo</option>
            <option value="25k-plus">£25,000+/mo</option>
          </select>
        </label>

        {state.clientTypeId === 'agency' && (
          <label className="checkout-form__field">
            <span className="checkout-form__label">Clients you manage <span className="checkout-form__optional">(optional)</span></span>
            <select className="checkout-form__input" value={form.clientsManaged} onChange={(e) => setField('clientsManaged', e.target.value)}>
              <option value="">Prefer not to say</option>
              <option value="1-3">1 to 3</option>
              <option value="4-10">4 to 10</option>
              <option value="11-25">11 to 25</option>
              <option value="25-plus">25+</option>
            </select>
          </label>
        )}

        <label className="checkout-form__field checkout-form__field--full">
          <span className="checkout-form__label">Phone <span className="checkout-form__optional">(optional)</span></span>
          <div className={`checkout-form__phone ${errors.phone ? 'checkout-form__phone--err' : ''}`}>
            <div className="checkout-form__phone-trigger">
              <span className="checkout-form__phone-flag" aria-hidden="true">
                {findCountry(form.phoneCountry).flag}
              </span>
              <svg className="checkout-form__phone-caret" viewBox="0 0 12 12" width="10" height="10" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                <polyline points="3 5 6 8 9 5" />
              </svg>
              <select
                className="checkout-form__phone-country"
                value={form.phoneCountry}
                onChange={(e) => setField('phoneCountry', e.target.value)}
                aria-label="Country dial code"
              >
                {PHONE_COUNTRIES.map(c => (
                  <option key={c.code} value={c.code}>{c.flag} {c.name} ({c.dial})</option>
                ))}
              </select>
            </div>
            <span className="checkout-form__phone-dial">{findCountry(form.phoneCountry).dial}</span>
            <input
              type="tel"
              className="checkout-form__phone-input"
              placeholder={findCountry(form.phoneCountry).placeholder}
              value={form.phone}
              onChange={(e) => setField('phone', e.target.value)}
              autoComplete="tel-national"
            />
          </div>
          {errors.phone && <span className="checkout-form__err">{errors.phone}</span>}
        </label>

        {CAPTCHA_ENABLED && (
          <div className="checkout-form__captcha">
            <div ref={captchaContainerRef} />
            {errors.captcha && <span className="checkout-form__err">{errors.captcha}</span>}
          </div>
        )}

        {errors.essentials && (
          <div className="checkout-form__err checkout-form__err--block" role="alert">
            {errors.essentials}
          </div>
        )}

        {/* 2026-05-30: removed the "already-had" early-exit caption above the
            submit button. It duplicated the trust line shown below the button. */}

        <button
          type="submit"
          className="btn btn--primary btn--block checkout-form__submit"
          disabled={status === 'sending'}
        >
          {status === 'sending'
            ? 'Sending…'
            : isPostCallMode() ? 'Send my selection to the team' : (window.getStep6SubmitCopy ? window.getStep6SubmitCopy(state.callPreference || 'schedule') : 'Send me the quote')}
        </button>
      </form>

      {/* §5.7, Trust line below submit; swaps by call preference. */}
      <div className="cac__meta">
        <window.Check size={11} /> {isPostCallMode() ? 'Your selections will be sent to our team so we can make the most of your call.' : (window.getStep6TrustLine ? window.getStep6TrustLine(state.callPreference || 'schedule') : 'No commitment · Cancel anytime')}
      </div>
    </div>
  );
}

function YoureSetPage({ state, dispatch, onBack, flow }) {
  const ct = window.CLIENT_TYPES.find(c => c.id === state.clientTypeId);
  const count = Object.keys(state.selections).length;
  // Build a short list of distinct minimum-commitment durations across selected
  // ongoing services. e.g. {3, 6} → "3 and 6-month commitments"
  const monthSet = new Set();
  Object.keys(state.selections || {}).forEach(sid => {
    const svc = window.SERVICES.find(x => x.id === sid);
    if (!svc || svc.oneTime) return;
    const sel = state.selections[sid];
    const opts = window.commitsFor(svc);
    const defaultCommitId = '12';
    const commitId = (opts && opts.some(o => o.id === sel.commitId)) ? sel.commitId : defaultCommitId;
    const m = opts?.find(o => o.id === commitId)?.months || (12);
    monthSet.add(m);
  });
  const months = [...monthSet].sort((a, b) => a - b);
  const commitText = months.length === 0
    ? 'one-time deliverables only'
    : months.length === 1
      ? `${months[0]}-month minimum commitment`
      : `${months.slice(0, -1).join(', ')} and ${months[months.length - 1]}-month minimum commitments`;

  return (
    <section className="page">
      <div className="container">
        {/* Step indicator, same breadcrumb as BuildPage so the user sees
            they're on the final step (6/6) and can jump back via the
            breadcrumb nodes. */}
        {window.StepIndicator && Array.isArray(flow) && flow.length > 0 && (
          <window.StepIndicator
            step={flow.length - 1}
            flow={flow}
            onJump={(idx) => dispatch({ type: 'SET_STEP', step: idx })}
            canJumpTo={() => true}
            isStepLocked={() => false}
            lockReasonFor={() => null}
            clientTypeId={state.clientTypeId}
            intentId={state.intentId}
          />
        )}
        <div className="youreset__grid">
          <div className="youreset__summary">
            <window.Summary state={state} dispatch={dispatch} />
          </div>

          <div className="convert__grid convert__grid--stack">
            {/* Loom 3: Edit shortcut pinned to the very top of the right column
                so it is always visible on laptop viewports without scrolling,
                regardless of whether the qualifier is in progress below. */}
            {!state.quoteSubmitted && (
              <div className="youreset__edit-bar">
                <button
                  type="button"
                  className="youreset__edit-btn"
                  onClick={() => dispatch({ type: 'SET_STEP', step: 0 })}
                >
                  ‹ Edit service selection
                </button>
              </div>
            )}
            {/* 2026-05-22: section-head moved INTO the right column so the
                sidebar starts at the top of the layout (matching BuildPage),
                not below the headline. Eliminates the empty band to the
                left/right of the sidebar's top edge. */}
            <div className="section-head">
              <div className="section-head__eyebrow">{state.intentId === 'agency-whitelabel' ? "Your client's quote" : 'Your plan'}</div>
              <h1 className="section-head__title">Ready to <em>launch.</em></h1>
              <p className="section-head__sub section-head__sub--full">
                {state.intentId === 'agency-whitelabel'
                  ? "Review your client's quote. Book a scoping call when you're ready, or email it to share with your team."
                  : "Review what you've built. Book the kick-off when you're ready, or email the plan to yourself to share with the team."}
              </p>
            </div>
            {/* Full qualifier, moved from Step 0. Optional but encouraged;
                the team uses these answers to write a meaningful proposal.
                Renders for founders persona only (investors/agencies use a
                different shape). */}
            {state.clientTypeId === 'founder' && !state.quoteSubmitted && (
              <div className="build-section build-section--qualifier youreset-qualifier">
                <QualifierSection
                  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: [] }}
                  onAnswer={(q, value) => dispatch({ type: 'SET_QUALIFIER', q, value })}
                  onAnswerMulti={(q, value, exclusive) => dispatch({ type: 'SET_QUALIFIER_MULTI', q, value, exclusive })}
                  onSetPriorSub={(field, value) => dispatch({ type: 'SET_PRIOR_SUB', field, value })}
                  onTogglePriorSub={(field, value) => dispatch({ type: 'TOGGLE_PRIOR_SUB', field, value })}
                  missingIds={new Set()}
                  persona="founders"
                />
              </div>
            )}
            {/* 2026-05-25 Batch 10: Agency qualifier block — mirrors the founder
                block above. Shown on the Schedule a Call step for all agency
                client types. Uses AGENCY_QUALIFIER_QUESTIONS with three-scenario
                routing (Resell / Grow / Partner). Sequential reveal via
                shownUpTo is inherited from QualifierSection. Text-input
                questions (client name, website, brand assets) deferred to
                the proposal generator feature. */}
            {state.clientTypeId === 'agency' && !state.quoteSubmitted && (
              <div className="build-section build-section--qualifier youreset-qualifier">
                <QualifierSection
                  qualifier={(() => {
                    // Batch 13: auto-derive aq_scenario from state.intentId so Q1 is never shown.
                    // agency-whitelabel → 'resell', agency-own → 'grow'.
                    const _base = state.qualifier || {};
                    const _intent = state.intentId || '';
                    const _auto = _intent === 'agency-whitelabel' ? 'resell'
                      : _intent === 'agency-own' ? 'grow' : null;
                    return (_auto && !_base.aq_scenario)
                      ? { ..._base, aq_scenario: _auto }
                      : _base;
                  })()}
                  onAnswer={(q, value) => dispatch({ type: 'SET_QUALIFIER', q, value })}
                  onAnswerMulti={(q, value, exclusive) => dispatch({ type: 'SET_QUALIFIER_MULTI', q, value, exclusive })}
                  onSetPriorSub={(field, value) => dispatch({ type: 'SET_PRIOR_SUB', field, value })}
                  onTogglePriorSub={(field, value) => dispatch({ type: 'TOGGLE_PRIOR_SUB', field, value })}
                  missingIds={new Set()}
                  persona="agencies"
                />
              </div>
            )}
            {/* 2026-05-26: Skip-questions floater removed by request. The
                QualifierSkipFloater component definition is kept in place
                (~line 4072) in case the feature is wanted back later. */}
            {/* §4.1, Plan-summary card wraps the checkout form with a header
                that recaps the user's Step 1 highlights + plan title + an
                edit-plan link back to Step 0.
                2026-05-26: gated render — hidden while a founder or agency
                is mid-qualifier so the questions above keep focus. Shown
                once the qualifier is complete, the quote has been submitted,
                or the user isn't a persona that has a qualifier (investor /
                no-client). Skip-questions flow + qualifierSkipped removed
                in task #408; the only "done" paths are real completion or
                an already-submitted quote. */}
            {(() => {
              const isFounder = state.clientTypeId === 'founder';
              const isAgency  = state.clientTypeId === 'agency';
              const needsQualifier = isFounder || isAgency;
              const founderDone = isFounder
                && window.qualifierComplete
                && window.qualifierComplete(state.qualifier || {}, state.clientTypeId);
              const agencyDone = isAgency
                && window.agencyQualifierComplete
                && window.agencyQualifierComplete(state.qualifier || {}, state.intentId);
              const qDone = !needsQualifier
                || state.quoteSubmitted
                || founderDone
                || agencyDone;
              if (!qDone) return null;
              return (
                <div className="plan-summary-card glass-frame">
                  <div className="plan-summary-card__head">
                    <div className="plan-summary-card__eyebrow">
                      {state.intentId === 'agency-whitelabel'
                        ? <>Built for your <strong>client</strong></>
                        : state.clientTypeId === 'agency'
                          ? <>Built for your <strong>agency</strong></>
                          : <>Built for your <strong>{(window.summarizeForEyebrow && window.summarizeForEyebrow(state)) || 'plan'}</strong></>}
                    </div>
                    <div className="plan-summary-card__row">
                      <h2 className="plan-summary-card__title">{(window.getPlanTitle && window.getPlanTitle(state.clientTypeId, state.intentId)) || 'Founders growth plan'}</h2>
                    </div>
                  </div>
                  <CheckoutForm state={state} dispatch={dispatch} commitText={commitText} />
                </div>
              );
            })()}
          </div>
        </div>

        <div className="page__foot page__foot--sticky">
          <button className="btn btn--ghost btn--lg" onClick={onBack}>‹ Back to services</button>
          <div className="page__foot-meta"><window.Lock size={12}/> Your quote is saved in this browser</div>
          <div style={{width: 120}} />
        </div>
      </div>
      <ScrollProgressDot />
    </section>
  );
}

// ── FAQ ──
function Faq() {
  const [open, setOpen] = uS(0);
  return (
    <section className="faq">
      <div className="container">
        <div className="section-head__eyebrow">Frequently Asked Questions</div>
        <h2 className="section-head__title">Answers before you ask.</h2>
        <div className="faq__list">
          {window.FAQS.map((f, i) => (
            <div key={i} className={`faq__item ${open === i ? 'faq__item--open' : ''}`}>
              <button className="faq__q" onClick={() => setOpen(open === i ? -1 : i)}>
                <span className="faq__num">{String(i + 1).padStart(2, '0')}</span>
                <span className="faq__q-text">{f.q}</span>
                <span className="faq__icon">{open === i ? '−' : '+'}</span>
              </button>
              {open === i && <div className="faq__a">{f.a}</div>}
            </div>
          ))}
        </div>
      </div>
    </section>
  );
}

// ── FOOTER ──
function Footer() {
  return (
    <footer className="foot">
      <div className="container">
        <div className="foot__grid">
          <div>
            <div className="foot__brand">
              <div className="nav__logo">G</div>
              GoGorilla<span className="foot__brand-dot">.com</span>
            </div>
            <p className="foot__blurb">Premium, financially-aligned growth services. Dedicated pods across UK, US, and APAC. Transparent pricing, flexible commitments, and a team that shares your success.</p>
          </div>
          <div className="foot__col">
            <div className="foot__col-title">Product</div>
            <a href="#">Growth services</a><a href="#">Creative studio</a><a href="#">Talent on-demand</a><a href="#">White label</a><a href="#">app.gogorilla.com</a>
          </div>
          <div className="foot__col">
            <div className="foot__col-title">Company</div>
            <a href="#">About</a><a href="#">Careers</a><a href="#">Press</a><a href="#">Contact</a><a href="#">Log in</a>
          </div>
          <div className="foot__col">
            <div className="foot__col-title">Legal</div>
            <a href="#">Terms</a><a href="#">Privacy</a><a href="#">Security</a><a href="#">DPA</a><a href="#">Cookie policy</a>
          </div>
        </div>
        <div className="foot__bottom">
          <div><span className="foot__flag">🇬🇧 United Kingdom</span> · © 2026 GoGorilla Ltd. Registered in England &amp; Wales · No. 12345678</div>
          <nav className="foot__bottom-nav">
            <a href="#">Status</a><a href="#">Changelog</a><a href="#">Twitter</a><a href="#">LinkedIn</a>
          </nav>
        </div>
      </div>
    </footer>
  );
}

Object.assign(window, { ClientTypeSection, ClientSwitchModal, StalePrompt, Q0Section, QualifierSection, MarginRow, WhiteLabelMarginCalculator, BuildPage, YoureSetPage, Summary, Faq, Footer });

// ── Imperative waitlist toast ──
// We tried React-state-based rendering but the toast state was being reset
// by some other render path before we could observe it. Going imperative is
// 100% reliable: we mount a single DOM element under <body> and rebuild its
// inner content each time. Self-dismisses after 8s.
(function () {
  let toastEl = null;
  let dismissTimer = null;

  function ensureToastEl() {
    if (toastEl && document.body.contains(toastEl)) return toastEl;
    toastEl = document.createElement('div');
    toastEl.className = 'waitlist-toast';
    toastEl.setAttribute('role', 'status');
    toastEl.setAttribute('aria-live', 'polite');
    document.body.appendChild(toastEl);
    return toastEl;
  }

  function dismiss() {
    if (dismissTimer) { clearTimeout(dismissTimer); dismissTimer = null; }
    if (toastEl && toastEl.parentNode) toastEl.parentNode.removeChild(toastEl);
  toastEl = null;
  }

  window.showWaitlistToast = function (serviceName) {
    if (!serviceName) return;
    const el = ensureToastEl();
    // Replay the entry animation by toggling a class
    el.classList.remove('waitlist-toast--in');
    // Force reflow so animation re-runs
    void el.offsetWidth;
    el.classList.add('waitlist-toast--in');
    el.innerHTML = `
      <div class="waitlist-toast__icon" aria-hidden="true">
        <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
          <circle cx="12" cy="12" r="10"></circle>
          <polyline points="12 6 12 12 15.5 14"></polyline>
        </svg>
      </div>
      <div class="waitlist-toast__body">
        <div class="waitlist-toast__title"></div>
        <div class="waitlist-toast__copy">You will be added to our waiting list and we will notify you once we have availability. We prioritise existing clients for faster onboarding.</div>
        <div class="waitlist-toast__meta">No charge added to your monthly total until you're onboarded.</div>
      </div>
      <button type="button" class="waitlist-toast__close" aria-label="Dismiss">
        <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round">
          <path d="M3 3 L13 13 M13 3 L3 13"></path>
        </svg>
      </button>
    `;
    // Set title via textContent to avoid HTML injection issues
    const titleEl = el.querySelector('.waitlist-toast__title');
    if (titleEl) titleEl.textContent = serviceName + ' added to waiting list';
    const closeBtn = el.querySelector('.waitlist-toast__close');
    if (closeBtn) closeBtn.addEventListener('click', dismiss);
    if (dismissTimer) clearTimeout(dismissTimer);
    dismissTimer = setTimeout(dismiss, 8000);
  };
})();

// ──────────────────────────────────────────────────────────────────────
// QUALIFIER VALIDATION TOAST, same DOM pattern as waitlist toast, but
// surfaces the list of unanswered qualifier questions and a "jump to first"
// link. Self-dismisses after 8s.
(function () {
  let qToastEl = null;
  let qDismissTimer = null;
  function ensureToast() {
    if (qToastEl && document.body.contains(qToastEl)) return qToastEl;
    qToastEl = document.createElement('div');
    qToastEl.className = 'waitlist-toast waitlist-toast--alert';
    qToastEl.setAttribute('role', 'status');
    qToastEl.setAttribute('aria-live', 'polite');
    document.body.appendChild(qToastEl);
    return qToastEl;
  }
  function dismiss() {
    if (qDismissTimer) { clearTimeout(qDismissTimer); qDismissTimer = null; }
    if (qToastEl && qToastEl.parentNode) qToastEl.parentNode.removeChild(qToastEl);
    qToastEl = null;
  }
  window.showQualifierMissingToast = function (missingLabels, onJump) { return; /* DISABLED, qualifier optional on Step 6 */ // eslint-disable-next-line no-unreachable
    if (!Array.isArray(missingLabels) || missingLabels.length === 0) return;
    const el = ensureToast();
    el.classList.remove('waitlist-toast--in');
    void el.offsetWidth;
    el.classList.add('waitlist-toast--in');
    const count = missingLabels.length;
    const items = missingLabels.map(l => `<li></li>`).join('');
    el.innerHTML = `
      <div class="waitlist-toast__icon" aria-hidden="true">
        <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
          <path d="M12 9v4"></path>
          <path d="M12 17h.01"></path>
          <path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
        </svg>
      </div>
      <div class="waitlist-toast__body">
        <div class="waitlist-toast__title"></div>
        <ul class="waitlist-toast__list"></ul>
        <button type="button" class="waitlist-toast__jump">Jump to the first one →</button>
      </div>
      <button type="button" class="waitlist-toast__close" aria-label="Dismiss">
        <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round">
          <path d="M3 3 L13 13 M13 3 L3 13"></path>
        </svg>
      </button>
    `;
    const titleEl = el.querySelector('.waitlist-toast__title');
    if (titleEl) titleEl.textContent = count === 1
      ? '1 question still needs an answer'
      : `${count} questions still need an answer`;
    const listEl = el.querySelector('.waitlist-toast__list');
    if (listEl) {
      missingLabels.forEach(label => {
        const li = document.createElement('li');
        li.textContent = label;
        listEl.appendChild(li);
      });
    }
    const closeBtn = el.querySelector('.waitlist-toast__close');
    if (closeBtn) closeBtn.addEventListener('click', dismiss);
    const jumpBtn = el.querySelector('.waitlist-toast__jump');
    if (jumpBtn && typeof onJump === 'function') {
      jumpBtn.addEventListener('click', () => { onJump(); dismiss(); });
    }
    if (qDismissTimer) clearTimeout(qDismissTimer);
    qDismissTimer = setTimeout(dismiss, 10000);
  };
})();
