/* 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 {
    // 2026-07-01 (Batch 3): an in-calculator Schedule-a-call booking also
    // redirects to ?booked=1 (it shares the Cal event type with the website
    // nav book flow). That must NOT drop the visitor into post-call mode, which
    // loops them back to step one. When the in-calc booking flag is set we are
    // on the call-booked confirmation endpoint instead, so post-call mode is off.
    if (inCalcBookingConfirm()) return false;
    return new URLSearchParams(window.location.search).get('booked') === '1';
  }
  catch(e) { return false; }
}
// 2026-07-01 (Batch 3): true only right after an in-calculator Schedule-a-call
// booking. The in-calc Schedule embed stamps gg.inCalcBook when it mounts; the
// booking redirect then reloads with ?booked=1. Both together mean we should render the
// call-booked confirmation endpoint rather than post-call mode. The 30-minute
// window stops a stale flag from hijacking a genuine later website-nav booking.
function inCalcBookingConfirm() {
  try {
    if (new URLSearchParams(window.location.search).get('booked') !== '1') return false;
    var raw = window.localStorage.getItem('gg.inCalcBook');
    if (!raw) return false;
    var o = JSON.parse(raw);
    if (!o || typeof o.t !== 'number') return false;
    return (Date.now() - o.t) < 30 * 60 * 1000;
  } catch (e) { return false; }
}
if (typeof window !== 'undefined') window.inCalcBookingConfirm = inCalcBookingConfirm;
// 2026-07-01 (Batch 3): the endpoint shown after an in-calculator booking. A
// simple confirmation card telling the visitor their call is booked and to
// check their email, with a way back to the website. Ends the booking path
// instead of connecting into post-call mode.
function CallBookedConfirm() {
  React.useEffect(() => { try { document.title = 'Your call is booked'; } catch (e) {} }, []);
  var _leave = function () {
    try { window.localStorage.removeItem('gg.inCalcBook'); } catch (e) {}
    try { window.location.href = 'https://www.gogorilla.com/'; } catch (e) {}
  };
  // 2026-07-01 (Batch 8): "Start fresh" clears the saved quote and the booking
  // flag, then reloads the calculator from the top so they can build another
  // quote and keep exploring.
  var _startFresh = function () {
    var _wasFl = false; try { _wasFl = !!(window.isFreelancerMode && window.isFreelancerMode()); } catch (e) {}
    try { window.__ggStartingFresh = true; } catch (e) {}
    try { window.localStorage.removeItem('gg.inCalcBook'); } catch (e) {}
    try { window.localStorage.removeItem('gg.pricing-cart.v1'); } catch (e) {}
    try { window.localStorage.removeItem('gg.flMode'); } catch (e) {}
    try { window.location.href = window.location.origin + (_wasFl ? '/freelancer' : '/'); } catch (e) {}
  };
  return (
    <div style={{ minHeight: '70vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '2rem 1rem' }}>
      <div className="glass-frame" style={{ maxWidth: 560, width: '100%', textAlign: 'center', padding: '2.75rem 2rem', borderRadius: 20, background: '#ffffff' }}>
        <div style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 64, height: 64, borderRadius: '50%', background: 'rgba(34,197,94,0.12)', color: '#16A34A', marginBottom: '1.25rem' }}><window.Check size={30} /></div>
        <h2 style={{ fontSize: '1.6rem', fontWeight: 700, margin: '0 0 0.85rem' }}>Your call is booked</h2>
        <p style={{ fontSize: '1rem', lineHeight: 1.6, color: 'var(--ink-soft, #475467)', margin: '0 0 0.9rem' }}>We have sent the details to your email, including the calendar invite and the joining link. Please check your inbox, and your spam folder just in case.</p>
        <p style={{ fontSize: '1rem', lineHeight: 1.6, color: 'var(--ink-soft, #475467)', margin: '0 0 1.7rem' }}>We look forward to speaking with you. There is nothing more you need to do now, so you can build another quote or head back to our website.</p>
        <div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center', flexWrap: 'wrap' }}>
          <button type="button" className="btn btn--ghost btn--lg" onClick={_leave} aria-label="Back to the GoGorilla website">Back to website <span aria-hidden="true" className="btn__arrow btn__arrow--diag">↗</span></button>
          <button type="button" className="btn btn--primary btn--lg" onClick={_startFresh} aria-label="Start a fresh quote">Start fresh</button>
        </div>
      </div>
    </div>
  );
}
if (typeof window !== 'undefined') window.CallBookedConfirm = CallBookedConfirm;
// #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') || _postCallParam('attendeeName'))  : ''; }
function postCallEmail() { return isPostCallMode() ? _postCallParam('email') : ''; }
// Rep resume-link greeting: ?rn=<first name> (set by api/send-quote.js on the
// short ?quote= / ?prefill= resume link). Personalises the welcome-back banner
// when a rep opens a lead's saved quote. React escapes the value on render.
function repResumeName() { return _postCallParam('rn').slice(0, 40); }
// Best-effort phone prefill. Cal.com forwards a phone param only for phone-based
// event types (not for custom-question phone fields), so this is often empty; we
// read the common keys in case it arrives.
function postCallPhone() { return isPostCallMode() ? (_postCallParam('phone') || _postCallParam('attendeePhone') || _postCallParam('attendeePhoneNumber') || _postCallParam('smsReminderNumber')) : ''; }
// Freelancer Typeform prefill. The Typeform redirect carries the freelancer's
// name, email, and phone as URL params so the last-step form is pre-filled and
// they do not retype it. Gated on freelancer mode so stray params never prefill
// the agency or founder flows.
function _flPrefillParam(k) { try { return new URLSearchParams(window.location.search || '').get(k) || ''; } catch (e) { return ''; } }
function flPrefillName()  { try { if (!(window.isFreelancerMode && window.isFreelancerMode())) return ''; return String(_flPrefillParam('name') || _flPrefillParam('firstName') || '').trim().replace(/[<>]/g, '').slice(0, 60); } catch (e) { return ''; } }
function flPrefillEmail() { try { if (!(window.isFreelancerMode && window.isFreelancerMode())) return ''; return String(_flPrefillParam('email') || '').trim().replace(/[<>]/g, '').slice(0, 120); } catch (e) { return ''; } }
function flPrefillPhone() { try { if (!(window.isFreelancerMode && window.isFreelancerMode())) return ''; return String(_flPrefillParam('phone') || _flPrefillParam('tel') || '').trim().replace(/[<>]/g, '').slice(0, 30); } catch (e) { return ''; } }

// ── 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 hideTimer = uR(null);
  const hideTip = () => { clearTimeout(hideTimer.current); hideTimer.current = setTimeout(() => setTipPos(null), 220); };
  const cancelHide = () => clearTimeout(hideTimer.current);
  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"
          onMouseEnter={cancelHide}
          onMouseLeave={hideTip}
          style={{ left: tipPos.x, top: tipPos.y, pointerEvents: 'auto' }}
        >
          {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.
// 2026-06-26 (Loom 64 polish): registry so an opening overlay can dismiss any
// open portal tooltip. Tooltips render at max z-index, so without this the
// bubble floats on top of the overlay it just opened.
if (typeof window !== 'undefined' && !window.ggCloseAllTips) {
  window.__ggTipClosers = window.__ggTipClosers || new Set();
  window.ggCloseAllTips = function () { try { (window.__ggTipClosers || new Set()).forEach(function (fn) { try { fn(); } catch (_e) {} }); } catch (_e2) {} };
}

function HoverPortalTip({
  children,
  tip,
  tipClassName = '',
  wrapClassName = '',
  wrapStyle,
  placement = 'above',
  as: WrapTag = 'span',
  interactive = true, // 2026-06-25 (Loom 60): default to interactive so all tooltips stay open on hover-into (margin calc, add-ons, tier cards)
}) {
  const [pos, setPos] = uS(null);
  const wrapRef = uR(null);
  const hideTimer = uR(null);
  React.useEffect(() => {
    if (!pos) return undefined;
    const _close = () => setPos(null);
    const _reg = (window.__ggTipClosers || (window.__ggTipClosers = new Set()));
    _reg.add(_close);
    return () => { _reg.delete(_close); };
  }, [pos]);
  const show = () => {
    clearTimeout(hideTimer.current);
    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 = () => {
    if (!interactive) { setPos(null); return; }
    clearTimeout(hideTimer.current);
    hideTimer.current = setTimeout(() => setPos(null), 220);
  };
  const cancelHide = () => clearTimeout(hideTimer.current);
  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 ${interactive ? 'hover-portal-tip--interactive' : ''} ${tipClassName}`}
          role="tooltip"
          onMouseEnter={interactive ? cancelHide : undefined}
          onMouseLeave={interactive ? hide : undefined}
          style={{
            position: 'fixed',
            left: pos.x,
            top: pos.y,
            zIndex: 2147483647,
            '--portal-tip-caret-shift': `${pos.caretShift}px`,
          }}
        >
          {tip}
        </div>,
        document.body
      )}
    </WrapTag>
  );
}

// ── SAVING ICON ──
// Outlined (stroke) icons for the Applied Savings rows. Brand blue via
// .applied-saving-icon { color: var(--gg-blue) } + stroke=currentColor.
function SavingIcon({ name }) {
  const p = {
    width: 14, height: 14, viewBox: '0 0 24 24', fill: 'none',
    stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round',
    strokeLinejoin: 'round', 'aria-hidden': true, className: 'applied-saving-icon',
  };
  switch (name) {
    case 'clock':
      return (<svg {...p}><circle cx="12" cy="12" r="9" /><polyline points="12 7 12 12 16 14" /></svg>);
    case 'banknote':
      return (<svg {...p}><rect x="2" y="6" width="20" height="12" rx="2" /><circle cx="12" cy="12" r="2.5" /><path d="M6 12h.01M18 12h.01" /></svg>);
    case 'clipboard':
      return (<svg {...p}><rect x="8" y="3" width="8" height="4" rx="1" /><path d="M16 5h2a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2" /><path d="M9 12h6M9 16h6" /></svg>);
    case 'building':
      return (<svg {...p}><rect x="5" y="3" width="14" height="18" rx="1.5" /><path d="M9 21v-4h6v4" /><path d="M9 7h.01M15 7h.01M9 11h.01M15 11h.01" /></svg>);
    case 'tag':
      return (<svg {...p}><path d="M20.6 13.4 13.4 20.6a2 2 0 0 1-2.8 0L3 13V3h10l7.6 7.6a2 2 0 0 1 0 2.8Z" /><circle cx="7.5" cy="7.5" r="1.2" /></svg>);
    case 'layers':
      return (<svg {...p}><path d="m12 2 9 5-9 5-9-5 9-5Z" /><path d="m3 12 9 5 9-5" /><path d="m3 17 9 5 9-5" /></svg>);
    default:
      return null;
  }
}
window.SavingIcon = SavingIcon;

// ── 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.
// 2026-06-12 (Loom 44 0:12, Nicole's spec): per-step selection counter.
// Counts the selected services on a step, their selected add-ons, selected
// talent roles, and the priced outbound channels (LinkedIn, Instagram), so
// the corner badge mirrors what the step has added to the quote. Steps with
// no services (client, qualifier, checkout) return null and keep the check.
function countStepSelections(state, stepDef) {
  const ids = (stepDef && stepDef.serviceIds) || [];
  if (!ids.length || !state || !state.selections) return null;
  // 2026-06-12 (Nicole): identical to the sidebar Services-and-Add-ons count,
  // the same builder and the same rule, grouped by this step's services, so
  // the step badges always sum to the sidebar number. Cached per state object.
  let cache = window.__ggStepLineCache;
  if (!cache || cache.state !== state) {
    cache = { state, lines: (window.buildQuoteLines && window.countableQuoteLines) ? window.countableQuoteLines(window.buildQuoteLines(state)) : [] };
    window.__ggStepLineCache = cache;
  }
  const idSet = new Set(ids);
  return cache.lines.filter(l => idSet.has(l.sid || l.id)).length;
}
window.countStepSelections = countStepSelections;

function StepIndicator({ step, flow, onJump, onNudge, canJumpTo, isStepLocked, lockReasonFor, clientTypeId, intentId, startFresh, countFor }) {
  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>
                {(() => {
                  // 2026-06-12 (Loom 44 0:12): service steps carry a selection
                  // counter in the corner slot, persisting in place of the
                  // check. Non-service steps keep the completion check.
                  const _selCount = typeof countFor === 'function' ? countFor(s) : null;
                  if (_selCount != null && !locked) {
                    return (
                      <span className={`step-indicator__numbadge step-indicator__selcount ${_selCount > 0 ? 'step-indicator__selcount--filled' : ''}`} aria-hidden="true">{_selCount}</span>
                    );
                  }
                  if (state === 'complete') {
                    return (
                      <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>
                    );
                  }
                  return null;
                })()}
                {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>
                )}
                {/* 2026-06-12 (Loom 44 0:12): the step number moved to a
                    circled prefix beside the name, the corner slot now belongs
                    to the selection counter (service steps) or the check. */}
              </span>
              <span className="step-indicator__label"><span className="step-indicator__numpre" aria-hidden="true">{i + 1}</span>{(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>
      {/* Loom 43 (1:17): Portal login and Start fresh sit beside the steps as
          circular icon buttons, taking no row of their own, so the next
          button stays in the laptop viewport. */}
      <div className="step-indicator__utils">
        {startFresh}
        {/* 2026-07-02: return to the GoGorilla marketing site, opens in a new tab. */}
        {window.HoverPortalTip ? (
          <window.HoverPortalTip wrapClassName="step-utility-tipwrap" tipClassName="dis-tip dis-tip--below dis-tip--compact" placement="below" tip={"Back to gogorilla.com"}>
            <a className="step-utility-btn" href="https://www.gogorilla.com/" target="_blank" rel="noopener noreferrer" aria-label="Back to the GoGorilla website">
              <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><line x1="3" y1="12" x2="21" y2="12"/><ellipse cx="12" cy="12" rx="4" ry="9"/></svg>
            </a>
          </window.HoverPortalTip>
        ) : (
          <a className="step-utility-btn" href="https://www.gogorilla.com/" target="_blank" rel="noopener noreferrer" aria-label="Back to the GoGorilla website">{'\u2197'}</a>
        )}
        {window.HoverPortalTip ? (
          <window.HoverPortalTip wrapClassName="step-utility-tipwrap" tipClassName="dis-tip dis-tip--below dis-tip--compact" placement="below" tip={"Portal login"}>
            <a className="step-utility-btn" href="https://portal.gogorilla.com/login" target="_blank" rel="noopener noreferrer" aria-label="Portal login">
              <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>
            </a>
          </window.HoverPortalTip>
        ) : (
          <a className="step-utility-btn" href="https://portal.gogorilla.com/login" target="_blank" rel="noopener noreferrer" aria-label="Portal login">↗</a>
        )}
      </div>
    </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);
}

// 2026-06-10 (Loom 33): scroll to an add-on card from tooltip links. Opens the
// owning service's add-ons disclosure first (AddonsBlock listens for the
// gg:open-addons event), then scrolls to the card and pulses it. Deliberately
// does NOT auto-select the add-on, a paid add-on should stay an explicit
// user choice; the pulse just shows exactly where it lives.
// 2026-06-12 (Nicole): the reverse hop, from the Custom List Building add-on
// up to the Monthly lead volume engine, where recurring leads live.
window.ggScrollToLeadVol = function () {
  try { if (document.activeElement && document.activeElement.blur) document.activeElement.blur(); } catch (e) { /* no-op */ }
  const el = document.querySelector('.lead-vol');
  if (!el) return;
  const reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  el.scrollIntoView({ behavior: reduced ? 'auto' : 'smooth', block: 'center' });
  el.classList.add('addon--locate-pulse');
  setTimeout(() => el.classList.remove('addon--locate-pulse'), 2400);
};

window.ggScrollToAddon = function (addonId) {
  // Blur the trigger first: a focused trigger button makes the browser snap
  // the scroll back to it when the locate-pulse animation completes.
  try { if (document.activeElement && document.activeElement.blur) document.activeElement.blur(); } catch (e) { /* no-op */ }
  try { window.dispatchEvent(new CustomEvent('gg:open-addons', { detail: { addonId } })); } catch (e) { /* no-op */ }
  const sel = '[data-addon-id="' + addonId + '"]';
  const reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  let tries = 0;
  const go = () => {
    const el = document.querySelector(sel);
    // 2026-06-12 (Batch 45 hotfix): 30 animation frames (~0.5s) was too short
    // when the disclosure has to mount its content first, the scroll silently
    // gave up. Timed retries give it a 3-second window instead.
    if (!el) { if (++tries < 30) setTimeout(go, 100); return; }
    // Focus the card itself (programmatic, no outline) so any late browser
    // re-anchoring keeps THE TARGET in view rather than the trigger.
    try { el.setAttribute('tabindex', '-1'); el.focus({ preventScroll: true }); } catch (e) { /* no-op */ }
    el.scrollIntoView({ behavior: reduced ? 'auto' : 'smooth', block: 'center' });
    el.classList.add('addon--locate-pulse');
    setTimeout(() => el.classList.remove('addon--locate-pulse'), 2400);
    // The disclosure's open animation keeps shifting layout (and late browser
    // re-anchoring can snap the viewport away), so a guard loop re-centres
    // for ~3.5 seconds, rescheduling EVERY pass, in view or not.
    let fixes = 0;
    const recentre = () => {
      const e2 = document.querySelector(sel);
      if (!e2) return;
      const r = e2.getBoundingClientRect();
      if (r.top < -8 || r.top > window.innerHeight - 80) {
        // Drive the window directly: scrollIntoView can no-op against the
        // disclosure's overflow ancestors mid-transition, and smooth scrolls
        // get cancelled by its layout shifts.
        window.scrollTo({ top: Math.max(0, r.top + window.scrollY - Math.round(window.innerHeight / 2 - Math.min(r.height, window.innerHeight * 0.8) / 2)), behavior: 'auto' });
      }
      if (++fixes < 10) setTimeout(recentre, 350);
    };
    setTimeout(recentre, 250);
  };
  // two frames so the disclosure can mount its content first
  requestAnimationFrame(() => requestAnimationFrame(go));
};

// 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, anchorClassName = 'intent-card__info', iconClassName = 'intent-card__info-i', portalClassName = 'intent-card__info-portal' }) {
  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={anchorClassName}
        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 as="span" className={iconClassName} />
      </span>
      {tipPos && ReactDOM.createPortal(
        <span
          className={`info-tip-portal info-tip-portal--above ${portalClassName}`}
          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 FreelancerReferralEstimator() {
  const [val, setVal] = React.useState(function () { try { return (window.__flReferAvg != null) ? String(window.__flReferAvg) : '1500'; } catch (e) { return '1500'; } });
  const _v = Math.max(0, parseFloat(val) || 0);
  // 2026-07-01: publish the average client value into a shared bridge so the
  // calculator sidebar can show a live commission figure. Window global + custom
  // event, the same pattern the sidebar already uses for VAT and margin retail.
  React.useEffect(function () {
    try { window.__flReferAvg = _v; window.dispatchEvent(new CustomEvent('gg:refer-avg', { detail: _v })); } catch (e) {}
  }, [_v]);
  const _fmt = (x) => '\u00a3' + Math.round(x).toLocaleString('en-GB');
  const _rate = (nn) => (nn >= 5 ? 0.20 : nn >= 2 ? 0.15 : 0.10); // 2026-07-02: ratified commission ladder
  const steps = [1, 3, 5, 10];
  return (
    <div className="fl-ref-estimator thin-glass-frame" style={{ marginTop: '18px', background: 'rgba(255, 255, 255, 0.55)', border: '1px solid #e3e7f1', borderRadius: '14px', padding: '16px 18px' }}>
      <div style={{ fontWeight: 800, fontSize: '0.98rem', color: '#002abf', marginBottom: '2px' }}>Estimate your referral commission</div>
      <div style={{ fontSize: '0.82rem', color: '#5a647d', marginBottom: '12px' }}>You earn 10% recurring on every client you refer, rising to 15% from your second active referral and 20% from your fifth, for the length of each client's minimum commitment. This stacks with your own-use discount.</div>
      <div style={{ display: 'flex', alignItems: 'center', gap: '10px', flexWrap: 'wrap', marginBottom: '14px' }}>
        <label htmlFor="fl-ref-val" style={{ fontSize: '0.82rem', color: '#0f1c35', fontWeight: 600 }}>Average client value per month</label>
        <span style={{ display: 'inline-flex', alignItems: 'center', border: '1px solid #cdd5e6', borderRadius: '10px', padding: '0 10px', background: '#fff' }}>
          <span style={{ color: '#002abf', fontSize: '0.95rem', fontWeight: 800 }}>{'\u00a3'}</span>
          <input id="fl-ref-val" type="text" inputMode="numeric" autoComplete="off" name="gg-ref-avg-nofill" data-lpignore="true" data-1p-ignore="true" data-form-type="other" spellCheck={false} value={val ? Number(val).toLocaleString('en-GB') : ''} onChange={(e) => setVal(e.target.value.replace(/[^0-9]/g, ''))} placeholder="1,500" style={{ border: 'none', outline: 'none', width: '104px', padding: '8px 4px', fontSize: '0.92rem', background: 'transparent', color: '#0f1c35' }} aria-label="Average client value per month" />
        </span>
      </div>
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(118px, 1fr))', gap: '10px' }}>
        {steps.map((nn) => (
          <div key={nn} className="thin-glass-frame" style={{ background: '#fff', border: '1px solid #e3e7f1', borderRadius: '12px', padding: '12px 10px', textAlign: 'center' }}>
            <div style={{ fontSize: '0.72rem', color: '#5a647d', fontWeight: 600 }}>{nn} client{nn > 1 ? 's' : ''} at {Math.round(_rate(nn) * 100)}%</div>
            <div style={{ fontSize: '1.15rem', fontWeight: 800, color: '#002abf', margin: '3px 0 0' }}>{_fmt(_rate(nn) * _v * nn)}</div>
            <div style={{ fontSize: '0.68rem', color: '#8a93a8' }}>per month</div>
          </div>
        ))}
      </div>
    </div>
  );
}
window.FreelancerReferralEstimator = FreelancerReferralEstimator;

function ClientTypeSection({ clientTypeId, setClientType, intentId, setIntent, selectionCount = 0, confirmClientSwitch, clientReadyChoice = null, setClientReadyChoice = null, onClientReadyYes = null, onDdAreasChange = null, onEnterReferMode = null, referredDeclared = false, onToggleReferred = null, referralStatus = null, referMode = false, onToggleReferMode = 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 || [];
  const _fl = !!(typeof window !== 'undefined' && window.isFreelancerMode && window.isFreelancerMode());
  const [referOv, setReferOv] = React.useState(false);
  return (
    <div className="build-section">
      {!_fl && (
        <div className="alert alert--info alert--metal gg-frame-card refer-firstpage" style={{ alignItems: 'center', margin: '0 0 1.1rem' }}>
          {/* 2026-07-02: first-page referral entry. A referrer can refer any client
              type, so the "Switch to referring" toggle lives here above the cards. */}
          <span className="gg-frame gg-frame--metal" style={{ '--gg-frame-slice': '18px' }} aria-hidden="true" />
          <span className="alert__icon"><img src="assets/icons/check.webp" alt="" /></span>
          {referMode ? (
            <div><strong>You are now referring.</strong> Choose the type of business you're referring below, and we'll forecast your commission on the right.</div>
          ) : (
            <div><strong>Referring a business?</strong> Switch to referring to forecast the commission you could earn. You earn <strong>10% recurring</strong> on each business you refer, for the length of their minimum commitment period.</div>
          )}
          {referMode ? (
            <button type="button" className="svc__upfront-toggle is-on fl-refer-toggle" role="switch" aria-checked={true} aria-label="Switch back out of referring" onClick={() => { if (onToggleReferMode) onToggleReferMode(false); }} style={{ marginLeft: 'auto', flex: '0 0 auto' }}>
              <span className="svc__upfront-knob" aria-hidden="true" />
              <span className="svc__upfront-text">Switch back</span>
            </button>
          ) : (
            <button type="button" className="svc__upfront-toggle fl-refer-toggle" role="switch" aria-checked={false} aria-label="Switch to referring and earn 10% recurring commission" onClick={() => { if (onToggleReferMode) onToggleReferMode(true); }} style={{ marginLeft: 'auto', flex: '0 0 auto' }}>
              <span className="svc__upfront-knob" aria-hidden="true" />
              <span className="svc__upfront-text">Switch to referring</span>
              <span className="svc__upfront-save">10% recurring commission</span>
            </button>
          )}
        </div>
      )}
      <div className="client-section-card">
        {!_fl && (
        <div className="section-head section-head--compact">
          <div className="section-head__eyebrow">Client Type</div>
          <h1 className="section-head__title">{referMode ? (<>Who are <em>you referring?</em></>) : (<>Who are <em>you?</em></>)}</h1>
          <p className="section-head__sub">{referMode ? "Pick the type of business you're referring, and we'll forecast what you could earn as you build their plan." : "Select the client type that best describes you. We'll tailor pricing, tiers, and add-ons."}</p>
        </div>
        )}
        {!_fl && (<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}{c.cardTip && <IntentInfoTip body={c.cardTip} anchorClassName="client-card__info" iconClassName="client-card__info-i" portalClassName="client-card__info-portal" />}</div>
            </button>
          );})}
        </div>)}

        {!_fl && !referMode && referralStatus !== 'applied' && referralStatus !== 'self' && (
          <div className="referred-declare">
            {/* 2026-07-02: low-friction 'I have been referred' declaration, deferred confirmation (no code required up front). */}
            <button type="button" className={`referred-declare__btn ${referredDeclared ? 'is-on' : ''}`} role="switch" aria-checked={referredDeclared} onClick={() => { if (onToggleReferred) onToggleReferred(); }}>
              <div className="glass-frame" aria-hidden="true"></div>
              <span className={`client-card__radio ${referredDeclared ? 'is-on' : ''}`} aria-hidden="true">{referredDeclared && (<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 8 7 12 13 4" /></svg>)}</span>
              <span className="referred-declare__txt">
                <span className="referred-declare__label">I've been referred by an existing client or partner</span>
                <span className="referred-declare__sub">No code needed, and your 10% discount applies straight away.</span>
              </span>
              {!referredDeclared && <span className="referred-declare__hint">Optional</span>}
            </button>
            {referredDeclared && (
              <div className="referred-declare__note"><svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 8 7 12 13 4" /></svg> Once you complete the sign-up process, we'll confirm the referral.</div>
            )}
          </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" style={_fl ? { borderTop: 'none', marginTop: 0, paddingTop: 0 } : undefined}>
            {!_fl && (
            <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.com?</div>
              <div className="agency-intent-section__sub">Sets your discount tier and how we deliver.</div>
            </div>
            )}
            {_fl && (
            <div className="agency-intent-section__head">
              <div className="agency-intent-section__eyebrow">Your options</div>
              <div className="agency-intent-section__title">How would you like to work with us?</div>
              <div className="agency-intent-section__sub">Pick the one that fits. You can change it at any time.</div>
            </div>
            )}
            <div className="agency-intent-grid">
              {intents.filter(i => i.id !== 'agency-referral').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.id === 'agency-referral') { if (onEnterReferMode) { onEnterReferMode(); } else { setReferOv(true); } return; }
                  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">
                        {_fl && i.flTitle ? i.flTitle : 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>Sales &amp; Demand Generation</strong> (15% partner discount) and <strong>Talent Solutions</strong> (GorillaPerks partner rates apply). The 15% is unlocked once you have one active referral. It suits freelancers starting their first agency and established agencies scaling up, with our extended team behind you.</span>}
                          >
                            <span style={{marginLeft:'0.4rem',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), <strong>Creative</strong> (Social Media Management), and <strong>Talent Solutions</strong>. You complete this for one client at a time, so once you have sent this client's proposal, start fresh to build the next one.</span>}
                          >
                            <span style={{marginLeft:'0.4rem',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{i.id === 'agency-own' ? ' available with one active referral or white-label client' : ' applied'}
                          {(i.id === 'agency-own' || i.id === 'agency-whitelabel') && (
                            <HoverPortalTip wrapClassName="agency-intent-card__discount-tip-wrap" tipClassName="dis-tip dis-tip--above" placement="above" tip={i.id === 'agency-own' ? "The 15% partner discount is available once you have at least one active referral, and it applies across our services and add-ons." : "The 40% reseller discount applies across our services, add-ons, and part-time dedicated resources. The cost per meeting runs on a 60/40 split, and full-time dedicated talent earns 10% recurring commission for the minimum commitment instead."}>
                              <window.InfoIcon className="agency-intent-card__discount-info" />
                            </HoverPortalTip>
                          )}
                        </div>
                      )}
                      <div className="agency-intent-card__desc">{_fl && i.flDesc ? i.flDesc : i.desc}</div>
                      {_fl && i.id === 'agency-own' && (
                        <div className="fl-ladder" style={{ display: 'flex', alignItems: 'center', gap: '6px', marginTop: '11px', flexWrap: 'wrap' }}>
                          {[['Standard', 'now'], ['15% off', 'first client'], ['Up to 30%', 'as you grow']].map((r, _li) => (
                            <React.Fragment key={_li}>
                              {_li > 0 && <span style={{ color: '#b8c0d4', fontWeight: 700 }} aria-hidden="true">{'\u2192'}</span>}
                              <span style={{ display: 'inline-flex', flexDirection: 'column', alignItems: 'center', background: '#eef2fe', borderRadius: '9px', padding: '5px 11px' }}>
                                <span style={{ fontSize: '0.78rem', fontWeight: 800, color: '#002abf' }}>{r[0]}</span>
                                <span style={{ fontSize: '0.62rem', color: '#5a647d' }}>{r[1]}</span>
                              </span>
                            </React.Fragment>
                          ))}
                        </div>
                      )}
                    </div>
                  </div>
                );
              })}
            </div>
            {_fl && (
              <div className="fl-office-perk" style={{ marginTop: '14px', display: 'flex', gap: '12px', alignItems: 'flex-start', background: '#f7f9fe', border: '1px solid #e3e7f1', borderRadius: '14px', padding: '14px 16px' }}>
                {/* 2026-07-02: office perk productised (Voice note 2, ratified by Nicole). Styled to match the success-share card, which now lives inside the careers block. */}
                <span style={{ flex: '0 0 auto', width: '34px', height: '34px', borderRadius: '9px', background: '#eef2fe', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }} aria-hidden="true"><img src="assets/icons/perks.webp" alt="" style={{ width: '20px', height: '20px' }} /></span>
                <div>
                  <div style={{ fontWeight: 800, fontSize: '0.95rem', color: '#0f1c35', marginBottom: '2px' }}>A London desk, on us</div>
                  <div style={{ fontSize: '0.86rem', color: '#5a647d', lineHeight: 1.5 }}>Land one active client, referral, or service and use our Chelsea office one day a week. Five unlock the full week, and higher tiers are coming soon. Subject to availability, and worth around {'\u00a3'}600 a month.</div>
                </div>
              </div>
            )}

          </div>
        )}

        {referOv && <GMOverlayModal data={getSavingsOverlay(clientTypeId, 'agency-referral')} initialTabLabel="Refer a business" onClose={() => setReferOv(false)} clientTypeId={clientTypeId} intentId="agency-referral" crossSell onGotoIntent={(id) => { setReferOv(false); setIntent(id); _scrollToNext('.build-section--qualifier'); }} />}

        {/* #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.com?</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.filter(i => i.id !== 'agency-referral').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">
                        {_fl && i.flTitle ? i.flTitle : i.title}
                        {i.waitlist && (
                          <span className="tier__waitlist-pill intent-waitlist-pill" style={{marginLeft:'8px',verticalAlign:'middle',fontSize:'11px',padding:'2px 9px 2px 7px'}}>
                            <span className="tier__waitlist-dot" aria-hidden="true" />
                            Waiting list
                          </span>
                        )}
                        {i.tip && (
                          <HoverPortalTip
                            wrapClassName="intent-info-tip-wrap"
                            tipClassName="dis-tip dis-tip--above"
                            placement="above"
                            tip={<span>{i.tip}</span>}
                          >
                            <span style={{marginLeft:'0.4rem',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>
                        )}
                      </div>
                      <div className="agency-intent-card__desc">{_fl && i.flDesc ? i.flDesc : i.desc}</div>
                      {_fl && i.id === 'agency-own' && (
                        <div className="fl-ladder" style={{ display: 'flex', alignItems: 'center', gap: '6px', marginTop: '11px', flexWrap: 'wrap' }}>
                          {[['Standard', 'now'], ['15% off', 'first client'], ['Up to 30%', 'as you grow']].map((r, _li) => (
                            <React.Fragment key={_li}>
                              {_li > 0 && <span style={{ color: '#b8c0d4', fontWeight: 700 }} aria-hidden="true">{'\u2192'}</span>}
                              <span style={{ display: 'inline-flex', flexDirection: 'column', alignItems: 'center', background: '#eef2fe', borderRadius: '9px', padding: '5px 11px' }}>
                                <span style={{ fontSize: '0.78rem', fontWeight: 800, color: '#002abf' }}>{r[0]}</span>
                                <span style={{ fontSize: '0.62rem', color: '#5a647d' }}>{r[1]}</span>
                              </span>
                            </React.Fragment>
                          ))}
                        </div>
                      )}
                      {/* 2026-06-08 (Loom 30 1:33-2:25): the inline "Book your free
                          assessment" CTA was removed per Alexander. Investors can book
                          from the nav bar at any time, so this option now behaves like
                          the others and leads straight on to service selection. */}
                      {typeof i.discount === 'number' && (
                        <div className="agency-intent-card__discount">
                          {Math.round(i.discount * 100)}% discount applied
                        </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: 'OPEN',
    fillPct: 22,
    pillCopy:
      'We have capacity to onboard new clients for this add-on right away. Spots are confirmed in the order they arrive, so adding it now locks yours in.',
  },
  limited: {
    label: 'Limited',
    pillLabel: 'LIMITED',
    fillPct: 58,
    pillCopy:
      'We can still onboard new clients for this add-on this month, though capacity is filling. Once the monthly limit is reached it moves to a waiting list, so adding it now secures your spot.',
  },
  'nearly-full': {
    label: 'Nearly Full',
    pillLabel: 'NEARLY FULL',
    fillPct: 82,
    pillCopy:
      'Only a few onboarding spots remain for this add-on this month. Once they fill it moves to a waiting list, and we prioritise existing clients for waiting list onboarding.',
  },
  full: {
    label: 'Full',
    pillLabel: 'WAITING LIST',
    fillPct: 100,
    pillCopy:
      'This add-on is at full capacity. Adding it reserves your place on the waiting list, and we email you as soon as a spot opens, typically within 2 to 4 weeks. We prioritise existing clients for waiting list onboarding.',
  },
};

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
  // 2026-06-11: the hashed buckets never yield 'full' any more. A random
  // 'full' made the add-on silently unbuyable (£0 waitlist line in the
  // sidebar, Instagram Story Design was the reported case). Full capacity
  // is now always an explicit onboardingStatus decision in data.jsx.
  let status;
  if (isFeatured) {
    if (bucket < 60) status = 'open';
    else if (bucket < 90) status = 'limited';
    else status = 'nearly-full';
  } else {
    if (bucket < 45) status = 'open';
    else if (bucket < 80) status = 'limited';
    else status = 'nearly-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: {}, addons: {} };
  const _rates = (_W.addons && _W.addons['overseas-calling']) || {};
  // 2026-06-12 (Nicole): the UK is home turf, not overseas, so it leaves the
  // picker. Multi-select returns, and each region adds its own monthly rate.
  const regions = (_W.regions || []).filter(rc => rc !== 'uk');
  const sel = (Array.isArray(selected) ? selected : []).filter(rc => rc !== 'uk');
  const toggle = (rc) => { onChange(sel.includes(rc) ? sel.filter(x => x !== rc) : [...sel, rc]); };
  const REGION_TIPS = {
    europe: 'Ireland, Germany, France, Spain, Italy, the Netherlands, and more, called from local in-country mobile numbers in each market’s business hours.',
    'north-america': 'The United States and Canada, called from local US and Canadian mobile numbers, typically with around four hours of East Coast overlap.',
    apac: 'Australia, New Zealand, Singapore, Hong Kong, Japan, and more, called from local in-country numbers in sensible local windows, never graveyard shifts.',
    row: 'Anywhere beyond these bands. We confirm coverage and your exact rate at onboarding.',
  };
  const _total = sel.reduce((s2, rc) => s2 + (typeof _rates[rc] === 'number' ? _rates[rc] : 0), 0);
  const _hasQuote = sel.some(rc => !(typeof _rates[rc] === 'number'));
  return (
    <div className="ov-rp">
      <div className="ov-cp__label">
        Which regions should we call into?
        {window.HoverPortalTip && (
          <window.HoverPortalTip wrapClassName="df-loc-tip-wrap" wrapStyle={{marginLeft:'0.4rem',display:'inline-flex',verticalAlign:'middle'}} tipClassName="dis-tip dis-tip--above" placement="above" tip={"We use local mobile numbers for each country we call into, so your prospects see a native dialling code rather than an overseas one. Each region you select adds its own monthly rate."}>
            <window.InfoIcon title="About overseas calling" onClick={(e) => e.stopPropagation()} />
          </window.HoverPortalTip>
        )}
      </div>
      <div className="ov-rp__pills" role="group" aria-label="Calling regions" style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
        {regions.map(rc => {
          const on = sel.includes(rc);
          const _r = _rates[rc];
          const pill = (
            <button
              type="button"
              role="checkbox"
              aria-checked={on}
              className={`qual-pill ${on ? 'qual-pill--on' : ''}`}
              onClick={(e) => { e.stopPropagation(); toggle(rc); }}
            >
              {(_W.regionLabels && _W.regionLabels[rc]) || rc}
            </button>
          );
          if (!window.HoverPortalTip || !REGION_TIPS[rc]) return <React.Fragment key={rc}>{pill}</React.Fragment>;
          return (
            <window.HoverPortalTip key={rc} wrapClassName="ov-rp__tipwrap" tipClassName="dis-tip dis-tip--above" placement="above" tip={REGION_TIPS[rc]}>
              {pill}
            </window.HoverPortalTip>
          );
        })}
      </div>
      {sel.length > 0 && _hasQuote && (
        <div className="ov-rp__total">Rest of World quoted at onboarding</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 PhysicalMailOptions({ opts, onSet }) {
  const type = (opts && opts.type) || 'printed';
  const branded = !!(opts && opts.branded);
  const pick = (key, value) => (e) => { e.stopPropagation(); if (onSet) onSet(key, value); };
  return (
    <div className="addon__mail" onClick={(e) => e.stopPropagation()}>
      <div className="addon__mail-label">Letter type</div>
      <div className="addon__mail-opts" role="radiogroup" aria-label="Letter type">
        <button type="button" role="radio" aria-checked={type === 'printed'} tabIndex={-1}
          className={`addon__mail-opt ${type === 'printed' ? 'addon__mail-opt--on' : ''}`}
          onClick={pick('type', 'printed')}>
          <span className="addon__mail-opt-name">Printed</span>
          <span className="addon__mail-opt-price">£9 /letter</span>
        </button>
        <button type="button" role="radio" aria-checked={type === 'handwritten'} tabIndex={-1}
          className={`addon__mail-opt ${type === 'handwritten' ? 'addon__mail-opt--on' : ''}`}
          onClick={(e) => { e.stopPropagation(); if (onSet) { onSet('type', 'handwritten'); if (branded) onSet('branded', false); } }}>
          <span className="addon__mail-opt-name">Handwritten
            <HoverPortalTip as="span" wrapClassName="addon__mail-opt-info" tipClassName="dis-tip dis-tip--above" placement="above" tip={<><span className="dis-tip__body">Each letter is individually written by hand for a personal touch. Branded paper is available on printed letters only.</span></>}>
              <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>
            </HoverPortalTip>
          </span>
          <span className="addon__mail-opt-price">£14 /letter</span>
        </button>
      </div>
      {type === 'printed' && (
        <div className={`addon__mail-branded ${branded ? 'addon__mail-branded--on' : ''}`}>
          <button type="button" role="checkbox" aria-checked={branded} tabIndex={-1} className="addon__mail-check" aria-label="Add our branded paper" onClick={pick('branded', !branded)}>{branded ? '✓' : ''}</button>
          <span className="addon__mail-branded-text">Add our branded paper, <strong>+£1.50 /letter</strong></span>
        </div>
      )}
    </div>
  );
}

// 2026-06-08: multi-option add-on "mis-click" nudge. These sub-group cards
// (Render Quality, Advanced VFX, etc.) can ONLY be actioned by clicking one of
// their option chips; clicking the title/price/description does nothing, which
// confused users. When a click lands inside the card but not on a chip (or any
// real control / the margin calc), pulse the chips with an orange ring + show a
// brief hint so it's obvious where to click. Imperative (no React state) so it
// works for the inline-mapped groups without a refactor.
function _nudgeAddonChips(e) {
  if (e.target.closest('.ps-chip, .svc-margin, button, a, input, textarea, select, [contenteditable]')) return;
  const grp = e.currentTarget;
  const chips = grp.querySelector('.ps-group__chips');
  if (!chips) return;
  grp.classList.remove('is-nudging');
  void grp.offsetWidth; // force reflow so the animation restarts on rapid re-clicks
  grp.classList.add('is-nudging');
  try { window.clearTimeout(grp._nudgeT); } catch (_) {}
  grp._nudgeT = window.setTimeout(() => grp.classList.remove('is-nudging'), 1600);
}

// ── Custom List Building data-detail levels (Loom 47, 0:59) ─────────────────
// Buyers pick how much detail the custom list carries; the per-prospect price
// follows the level. window.CLB_LEVEL_PRICE is read by both this card and the
// quote engine so the displayed price and the charge always agree.
const CLB_LEVELS = [
  { id: 'company-only',       name: 'Companies + website',    price: 2.25, tip: null },
  { id: 'companies-contacts', name: 'Companies + emails + LinkedIn URLs', price: 3.50, tip: 'Each company comes with the named decision-maker, a verified business email address, and their LinkedIn profile URL where available.' },
  { id: 'fully-enriched',     name: 'Companies + emails + phone numbers',       price: 4.50, tip: "Adds direct dial and mobile numbers where available. Where there is no mobile, we find a landline or the decision-maker's best direct contact wherever possible." },
];
window.CLB_LEVEL_PRICE = { 'company-only': 2.25, 'companies-contacts': 3.50, 'fully-enriched': 4.50 };
window.CLB_LEVEL_WHOLESALE = { 'company-only': 1.50, 'companies-contacts': 2.50, 'fully-enriched': 3.00 };
const CLB_FLOOR_PRICE = 2.25;

// Pence-accurate count-up. The sidebar animator rounds to whole pounds, which
// would mangle figures like £2.25 / £3.50, so we scale to pennies: it ticks
// smoothly and the final frame lands on the exact figure.
function AnimatedGBPp({ value, prefix = '' }) {
  const v = window.useAnimatedValue(Math.round((value || 0) * 100));
  return <>{prefix}£{(v / 100).toFixed(2)}</>;
}

// The three-level detail picker shown inside the CLB add-on once it is on.
function CLBLevelPicker({ level, onSet, wlMult }) {
  const cur = level;
  return (
    <div className="ov-rp" onClick={(e) => e.stopPropagation()}>
      <div className="ov-cp__label">How much detail do you need?</div>
      <div className="ov-rp__pills" role="radiogroup" aria-label="List detail level" style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
        {CLB_LEVELS.map((L) => {
          const on = cur === L.id;
          const pill = (
            <button type="button" role="radio" aria-checked={on} tabIndex={-1}
              className={`qual-pill ${on ? 'qual-pill--on' : ''}`}
              onClick={(e) => { e.stopPropagation(); if (onSet) onSet('clbLevel', L.id); }}>
              {L.name}
            </button>
          );
          if (!window.HoverPortalTip || !L.tip) return <React.Fragment key={L.id}>{pill}</React.Fragment>;
          return (
            <window.HoverPortalTip key={L.id} wrapClassName="ov-rp__tipwrap" tipClassName="dis-tip dis-tip--above" placement="above" tip={L.tip}>
              {pill}
            </window.HoverPortalTip>
          );
        })}
      </div>
    </div>
  );
}

function renderAddon(a, isOn, onToggle, qty, onSetQty, recommendedSet, countries, onSetCountries, intentId, mailOpts, onSetMailOpt, wlMult, onOpenOverlay, svcId, ctxIds, recOn, onToggleRecurring) {
  // Build price components
  let priceMain, priceSuffix, priceSuffixList, priceSegments, priceAnimatedValue = null, priceFromAnimated = null;

  // CLB (Custom List Building) price follows the chosen data-detail level.
  const _isCLB = a.id === 'premium-sourcing';
  const _clbHasLevel = _isCLB && !!(mailOpts && mailOpts.clbLevel);
  const _clbLevel = _isCLB ? ((mailOpts && mailOpts.clbLevel) || 'company-only') : null;
  // 2026-06-18 (Nicole): when CLB recurring is toggled on it bills like the
  // monthly lead-volume engine - a flat 4/prospect RRP (a.price carries the
  // recurringOption price) and 2.40 white-label (x0.6), regardless of the
  // data-detail level. One-off keeps the per-level ladder. The quote already
  // charges this; this keeps the card figure in step.
  const _clbRec = _isCLB && !!a._recurringOn;
  const _clbPrice = _isCLB ? (_clbRec ? (a.price || 4) : ((window.CLB_LEVEL_PRICE && window.CLB_LEVEL_PRICE[_clbLevel]) || 4.5)) : null;
  const _clbWl = _isCLB && typeof wlMult === 'number' && wlMult > 0 && wlMult < 1;
  const _clbWhole = _isCLB ? (_clbRec ? Math.round((a.price || 4) * wlMult * 100) / 100 : ((window.CLB_LEVEL_WHOLESALE && window.CLB_LEVEL_WHOLESALE[_clbLevel]) || _clbPrice)) : null;
  const _clbMainVal = _isCLB ? (_clbWl ? _clbWhole : _clbPrice) : null;
  const _clbFloorVal = _isCLB ? (_clbWl ? ((window.CLB_LEVEL_WHOLESALE && window.CLB_LEVEL_WHOLESALE['company-only']) || CLB_FLOOR_PRICE) : CLB_FLOOR_PRICE) : null;

  // Overseas Cold Calling: once regions are picked, the card shows the specific
  // selected-region monthly sum (matching the picker + quote) instead of the
  // static "From <floor>".
  const _isOv = a.id === 'overseas-calling';
  let _ovSel = false, _ovRetail = null, _ovRowOnly = false;
  if (_isOv && window.callingRegion) {
    const _ovcr = window.callingRegion(Array.isArray(countries) ? countries : []);
    const _ovrc = (window.WHOLESALE && window.WHOLESALE.addons && window.WHOLESALE.addons['overseas-calling']) || {};
    const _ovreg = (_ovcr && _ovcr.hasSelection ? (_ovcr.regions || []) : []);
    const _ovnum = _ovreg.filter(r => typeof _ovrc[r] === 'number');
    if (_ovnum.length) { _ovSel = true; _ovRetail = (window.ggRound5 || (x => x))(_ovnum.reduce((s2, r) => s2 + _ovrc[r], 0) * (window.commitScaleFactor ? window.commitScaleFactor(a._ctxCommitId) : 1)); }
    else if (_ovreg.length) { _ovRowOnly = true; }
  }
  const _ovWl = _isOv && typeof wlMult === 'number' && wlMult > 0 && wlMult < 1;
  const _ovMainSel = _ovSel ? (_ovWl ? (window.ggRound5 || (x => x))(_ovRetail * wlMult) : _ovRetail) : null;
  // 2026-06-12 (review): card prices carry the same partner discount as the
  // tier cards and the sidebar lines (Native CRM read From £149 on the card
  // against From £127 in the sidebar for partner intents).
  const _cardMult = (window.getAgencyMultiplier && window.__lastBuildPageState) ? window.getAgencyMultiplier(window.__lastBuildPageState, svcId) : 1;
  if (Array.isArray(a.priceSegments)) {
    priceSegments = a.priceSegments;
    priceMain = null; priceSuffix = null;
  } else if (a.included) {
    priceMain = a.includedLabel || 'Included';
    priceSuffix = null;
  } else if (a.includedInFull) {
    priceMain = 'Included in Full';
    priceSuffix = null;
  } else if (a.free || a.priceLabel === 'Free') {
    priceMain = 'Free';
    priceSuffix = null;
  } else if (a.custom || a.priceLabel === 'Custom' || /contact for pricing|bespoke pricing/i.test(a.priceLabel || '')) {
    priceMain = a.priceLabel && /contact|bespoke/i.test(a.priceLabel) ? 'Bespoke 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 && /^From £[\d,]+(\.\d+)?$/.test(a.priceLabel.trim()) && typeof a.price === 'number' && a.price >= 10 && !a.negative) {
    // Numeric From-labels resolve through the partner discount and count
    // like the sidebar. Pence-priced add-ons keep their static labels.
    priceFromAnimated = (window.ggRound5 || (x => x))(Math.round(a.price * _cardMult));
    priceMain = null;
    priceSuffix = a.unit === 'one-time' ? ' one-off'
      : (a.unit ? (a.unit.startsWith('/') ? (a.unit + (a.oneTime ? ' (one-off)' : '')) : ` ${a.unit}`) : 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-off'
        : (a.unit ? (a.unit.startsWith('/') ? (a.unit + (a.oneTime ? ' (one-off)' : '')) : ` ${a.unit}`) : null);
    }
  } else {
    // 2026-06-12 (review): resolved numeric prices count up like the sidebar
    // when the tier or commitment changes them. Static From-labels stay put,
    // they mark the floor before a plan resolves the real figure.
    if (typeof a.price === 'number' && !a.negative && a.price >= 10) {
      priceAnimatedValue = (window.ggRound5 || (x => x))(Math.round(a.price * _cardMult));
      priceMain = null;
    } else {
      priceMain = (a.negative ? window.fmt(a.price) : window.fmt(a.price));
    }
    priceSuffix = a.unit === 'one-time' ? ' one-off'
      : (a.unit ? (a.unit.startsWith('/') ? (a.unit + (a.oneTime ? ' (one-off)' : '')) : ` ${a.unit}`) : '/mo');
  }
  // 2026-06-12 (Loom 40, reviewed): recurring mode bills monthly and the
  // unit says so on the price row.
  if (a._recurringOn && priceSuffix && priceSuffix.indexOf('per month') === -1) priceSuffix = priceSuffix + ' per month';
  // 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 || !!a.includedInFull;
  const isFree = !isIncluded && (a.free || a.priceLabel === 'Free');
  const isCustom = a.custom || a.priceLabel === 'Custom' || /contact for pricing|bespoke pricing/i.test(a.priceLabel || '');
  const isPricingRef = !!(a.priceLabel && /^See /i.test(a.priceLabel));
  // 2026-06-08 (Loom 29 09:57): on white-label, show the discounted add-on price with the RRP in grey, like the tier cards.
  let priceRrp = null;
  if (_isCLB) {
    // CLB shows its RRP (full level price) on white-label once selected; the
    // discounted main figure is rendered via AnimatedGBPp above.
    if (_clbWl) priceRrp = (isOn && _clbHasLevel) ? `£${_clbPrice.toFixed(2)}` : `£${CLB_FLOOR_PRICE.toFixed(2)}`;
  } else if (_isOv && _ovSel) {
    // Overseas: white-label shows the discounted price + the retail region-sum RRP.
    priceRrp = _ovWl ? window.fmt(_ovRetail) : null;
  } else if (_isOv && _ovRowOnly) {
    priceRrp = null;
  } else if (typeof wlMult === 'number' && wlMult > 0 && wlMult < 1 && typeof a.price === 'number' && a.price > 0
      && !a.negative && !a.included && !a.free && !isCustom && !isPricingRef && !Array.isArray(a.priceSegments)) {
    const _fromPrefix = /^From\b/i.test(a.priceLabel || '') ? 'From ' : '';
    const _wlMainVal = a.price * wlMult;
    priceMain = `${_fromPrefix}${(_wlMainVal < 1 || (a.unit === '/letter' && !Number.isInteger(_wlMainVal))) ? '£' + _wlMainVal.toFixed(2) : window.fmt(Math.round(_wlMainVal))}`;
    priceRrp = window.fmt(a.price);
  }
  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.perUnit || (/^\/[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') && !/[aeiou]y$/i.test(unitNoun)) return unitNoun.slice(0, -1) + 'ies';
    if (unitNoun.endsWith('s')) return unitNoun;
    return unitNoun + 's';
  })();
  // QA note 6: a tooltip next to the per-unit label explaining what one unit is,
  // for the vague units (asset / project / campaign). Definition comes from the
  // addon's unitTip; self-explanatory units (mo, day, letter, etc.) have none.
  const _unitTip = a.unitTip ? (
    <HoverPortalTip
      placement="above"
      wrapClassName="addon__qty-min-info-wrap"
      tipClassName="addon__capacity-tip"
      tip={<><span className="addon__capacity-tip-head">{`What counts as one ${unitNoun}`}</span><span className="addon__capacity-tip-body">{a.unitTip}</span></>}
    >
      <window.InfoIcon className="addon__qty-min-info" title={`What counts as one ${unitNoun}`} onClick={(e) => e.stopPropagation()} />
    </HoverPortalTip>
  ) : null;
  let minQ = (typeof a.minQty === 'number' && a.minQty > 1) ? a.minQty : 1;
  // Loom 56: agencies have a 100-prospect floor on Custom List Building.
  if ((a.id === 'premium-sourcing' || a.id === 'premium-sourcing-pa') && typeof intentId === 'string' && intentId.indexOf('agency') === 0) minQ = Math.max(minQ, 100);
  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(a.maxQty || 9999, qVal + 1)); };

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

  return (
    <div
      key={a.id}
      data-addon-id={a.id}
      className={`addon ${isOn ? 'addon--on' : ''} ${isFeatured ? 'addon--featured' : ''} ${isIncluded ? 'addon--included' : ''}`}
      role="button"
      tabIndex={0}
      onClick={(e) => { if (isIncluded) return; onToggle(e); }}
      onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (!isIncluded) onToggle(e); } }}
      aria-pressed={isOn}
      aria-disabled={isIncluded || undefined}
    >
      {isFeatured && (() => {
        const _ribbon = (
          <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')}`}
          />
        );
        return (a.badgeTip && window.HoverPortalTip) ? (
          <window.HoverPortalTip wrapClassName="addon__ribbon-tip-wrap" tipClassName="dis-tip dis-tip--above" placement="above" tip={a.badgeTip}>
            {_ribbon}
          </window.HoverPortalTip>
        ) : _ribbon;
      })()}
      {isFeatured && a.standalone && (
        <HoverPortalTip
          wrapClassName="addon__standalone-badge-top"
          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
        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>
          {/* 2026-06-11: the rich tip describes the add-on, so it sits with the name; pricing copy uses priceTip on the price row. */}
          {a.desc && (
            a.tip ? (
              <HoverPortalTip
                wrapClassName="addon__info-wrap"
                tipClassName="addon__capacity-tip"
                interactive
                tip={(() => {
                  // 2026-06-11 (Nicole): plain lines after the first render as
                  // spaced paragraphs, bullet lines keep the list treatment.
                  const lines = a.tip.split('\n').filter(l => l.trim() !== '');
                  const head = lines[0];
                  const rest = lines.slice(1);
                  const bullets = rest.filter(l => /^[•\-]\s/.test(l)).map(l => l.replace(/^[•\-]\s*/, ''));
                  const paras = rest.filter(l => !/^[•\-]\s/.test(l));
                  return (<>
                    {/* 2026-06-11: only bullet-style tips get the bold head line.
                        Paragraph tips read as plain prose throughout. */}
                    <span className={bullets.length > 0 ? "addon__capacity-tip-head" : "addon__capacity-tip-body"}>{head}</span>
                    {paras.map((p, i) => (
                      <span key={'p' + i} className="addon__capacity-tip-body" style={{display:'block', marginTop:'0.55em'}}>{p}</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>
                    )}
                    {(() => {
                      // 2026-06-11: one curated companion per add-on, the name
                      // scrolls to the companion card (Custom List Building
                      // link in the lead-volume tooltip is the pattern).
                      const _pid = ((window.ADDON_PAIRS || {})[svcId] || {})[a.id];
                      const _pnm = _pid && window.ADDON_LOOKUP && window.ADDON_LOOKUP[svcId] && window.ADDON_LOOKUP[svcId][_pid];
                      if (!_pnm) return null;
                      // Companion locked at the current tier reads as a plain
                      // name with the plan it unlocks on, no dead-end click.
                      const _pAvail = !ctxIds || ctxIds.has(_pid);
                      const _pTier = !_pAvail && window.addonMinTier ? window.addonMinTier(svcId, _pid) : null;
                      return (
                        <span className="addon__capacity-tip-body" style={{display:'block', marginTop:'0.55em'}}>
                          <strong>Best paired with:</strong>{' '}
                          {_pAvail ? (
                            <button type="button" className="lead-vol__clb-link" onClick={(e) => { e.preventDefault(); e.stopPropagation(); if (window.ggScrollToAddon) window.ggScrollToAddon(_pid); }}>{_pnm}</button>
                          ) : (
                            <>{_pnm} <span style={{opacity:0.75}}>({_pTier ? `available from the ${_pTier} plan` : 'available on higher plans'})</span></>
                          )}
                        </span>
                      );
                    })()}
                    {ADDON_OVERLAYS[a.id] && (
                      <button type="button" className="tip-overlay-link" onClick={(e) => { e.preventDefault(); e.stopPropagation(); if (onOpenOverlay) onOpenOverlay(a.id); }}>Learn more</button>
                    )}
                  </>);
                })()}
              >
                <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>
            )
          )}
          {/* 2026-06-12 (Loom 40, reviewed): the recurring switch is a pill in
              the head row, the Pay-upfront pattern at add-on scale, rendered
              permanently so toggling never reflows the card. */}
          {a.recurringOption && onToggleRecurring && (
            /* 2026-06-17: Custom List Building now uses the standard recurring
               toggle for consistency with the other recurring add-ons (was a
               "Switch to recurring" button that cleared the one-off batch and
               jumped to the Monthly lead-volume engine). */
            <button
              type="button"
              className={`addon__rec-toggle ${recOn ? 'is-on' : ''}`}
              role="switch"
              aria-checked={!!recOn}
              aria-label={`Switch ${a.name} to recurring and save ${a.recurringOption.savePct || 11}%`}
              onClick={(e) => { e.stopPropagation(); onToggleRecurring(); }}
            >
              <span className="addon__rec-knob" aria-hidden="true" />
              <span className="addon__rec-text">Recurring</span>
              <span className="addon__rec-save">Save {a.recurringOption.savePct || 11}%</span>
            </button>
          )}
          {a.standalone && !isFeatured && (
            <HoverPortalTip
              wrapClassName="dis-tip-wrap"
              wrapStyle={{marginLeft: a.recurringOption ? '6px' : '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 || (_isOv && _ovRowOnly)) ? 'addon__price-main--custom' : ''} ${(isIncluded || isPricingRef) ? 'addon__price-main--included' : ''} ${a.negative ? 'addon__price-main--neg' : ''}`}>
                {_isCLB ? ((isOn && _clbHasLevel) ? <AnimatedGBPp value={_clbMainVal} /> : <AnimatedGBPp value={_clbFloorVal} prefix="From " />) : (_isOv ? (_ovRowOnly ? 'Custom' : <AnimatedGBP value={_ovSel ? _ovMainSel : (priceFromAnimated != null ? priceFromAnimated : (a.price || 0))} prefix={_ovSel ? '' : 'From '} />) : (priceFromAnimated != null ? <AnimatedGBP value={priceFromAnimated} prefix="From " /> : (priceAnimatedValue != null ? <AnimatedGBP value={priceAnimatedValue} /> : priceMain)))}
              </span>
              {priceSuffixList ? (
                priceSuffixList.map(u => <span key={u} className="addon__price-suffix">{u}</span>)
              ) : (priceSuffix && !(_isOv && _ovRowOnly)) && (
                <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>
              )}
              {priceRrp && (
                <span className="addon__price-rrp" style={{ marginLeft: '4px', fontWeight: 400, fontStyle: 'italic', color: 'var(--gg-blue, #002abf)', fontSize: '0.85em' }}>(RRP {priceRrp}{a._ctxCommitId ? `, ${a._ctxCommitId} mo` : ''})</span>
              )}
              {a._wasPrice && (
                <span className="addon__price-was">was {window.fmt(a._wasPrice)}</span>
              )}
            </>
          )}
          {a.priceTip && (
            <HoverPortalTip wrapClassName="addon__info-wrap" tipClassName="dis-tip dis-tip--above" placement="above" tip={<span className="dis-tip__body">{a.priceTip}</span>}>
              <button type="button" className="addon__info" onClick={(e) => e.stopPropagation()} aria-label="About this price" 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>
          )}
        </div>

        {/* 2026-06-12 (review): a grey justifier note under the price where
            the data carries one (Follower and Community Growth first). */}
        {a.priceNote && (
          <div className="addon__price-note">{a.priceNote}</div>
        )}
        {/* 2026-06-12 (review): add-ons can carry a one-off setup fee, shown
            under the price like the tier cards (Overseas Cold Calling, £195). */}
        {a.setupFee ? <div className="addon__setup-fee">+ £{a.setupFee.toLocaleString('en-GB')} one-off setup fee</div> : null}
        {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}?{_unitTip}
              </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)}%`, '--fill-frac': ((qVal - minQ) / Math.max(1, (a.sliderMax || 2000) - minQ)) }}
              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()}>
            {/* 2026-06-11: preset packs (addon.qtyPresets). Pills set the
                quantity directly, the stepper below stays as the custom
                amount, so the existing minimum still applies. */}
            {Array.isArray(a.qtyPresets) && a.qtyPresets.length > 0 && (
              <div className="addon__qty-presets" role="group" aria-label={`Choose a ${unitNoun} package`}>
                {a.qtyPresets.map(n => (
                  <button
                    key={n}
                    type="button"
                    className={`addon__qty-preset ${qVal === n ? 'is-on' : ''}`}
                    onClick={(e) => { e.stopPropagation(); if (onSetQty) onSetQty(n); }}
                    aria-pressed={qVal === n}
                  >
                    <span className="addon__qty-preset-count">{n.toLocaleString()}</span>
                    {!_isCLB && <span className="addon__qty-preset-price">{window.fmt(n * (a.price || 0))}</span>}
                  </button>
                ))}
              </div>
            )}
            <label className={`addon__qty-label ${Array.isArray(a.qtyPresets) && a.qtyPresets.length > 0 ? 'addon__qty-label--sentence' : ''}`} htmlFor={`addon-qty-${a.id}`}>
              {Array.isArray(a.qtyPresets) && a.qtyPresets.length > 0 ? `Or enter a custom number of ${unitNounPlural}` : <>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>
              <QtyInput
                id={`addon-qty-${a.id}`}
                className="addon__qty-input"
                value={qVal}
                min={minQ}
                max={a.maxQty || 9999}
                onCommit={(v) => onSetQty && onSetQty(v)}
                ariaLabel={`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">
              {/* 2026-06-12 (review, Nicole): the hint shows the computed total
                  for the chosen quantity, the same pattern as the slider
                  variant's running total, animated like the tier prices. The
                  rate and unit already live on the price row above. */}
              {(a.price || 0) > 0 ? <><AnimatedGBP value={Math.round(qVal * (_isCLB ? _clbMainVal : (window.ggRound5 || (x => x))((a.price || 0) * ((window.getAgencyMultiplier && window.__lastBuildPageState) ? window.getAgencyMultiplier(window.__lastBuildPageState, svcId) : 1))))} />{a._recurringOn ? '/mo' : ''}</> : null}{_unitTip}
              {minQ > 1 && a.price ? (
                <HoverPortalTip
                  placement="above"
                  wrapClassName="addon__qty-min-info-wrap"
                  tipClassName="addon__capacity-tip"
                  tip={<>
                    <span className="addon__capacity-tip-head">Minimum order</span>
                    <span className="addon__capacity-tip-body">This add-on has a minimum order, so it starts at {minQ} {unitNounPlural}, which is £{(minQ * a.price).toLocaleString('en-GB')} at £{a.price} per {unitNoun}.</span>
                  </>}
                >
                  <button type="button" className="addon__qty-min-info" onClick={(e) => e.stopPropagation()} aria-label={`Why the minimum is ${minQ} ${unitNounPlural}`} tabIndex={-1}><window.InfoIcon/></button>
                </HoverPortalTip>
              ) : null}
            </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>
        )}

        {/* Custom List Building data-detail picker (Loom 47, 0:59). Choosing a
            level sets selection.mailOpts.clbLevel, which drives the per-prospect
            price on the card and in the quote. */}
        {isOn && _isCLB && (
          <CLBLevelPicker level={_clbHasLevel ? _clbLevel : null} onSet={onSetMailOpt} wlMult={wlMult} />
        )}


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

        {/* Loom 28 follow-up: white-label margin box for every other priced add-on,
            mirroring the Overseas one. Writes gg.margin.addon-<id>, which the sidebar
            net profit reads, so each add-on's margin rolls into NET PROFIT. */}
        {a.id !== 'overseas-calling' && intentId === 'agency-whitelabel' && wlMult < 1 && !a.free && !a.custom && typeof a.price === 'number' && a.price > 0 && window.MarginRow && (() => {
          const _q = Math.max(1, Number(qty) || 1);
          const _m = (typeof wlMult === 'number') ? wlMult : 1;
          const _w = _isCLB ? Math.round((_clbWhole || 0) * _q) : Math.round((a.price || 0) * _m * _q);
          const _rrpv = _isCLB ? (_clbPrice || 0) * _q : (a.price || 0) * _q;
          return (
            <div className="addon__margin" onClick={(e) => e.stopPropagation()} style={{ marginTop: '10px' }}>
              <window.MarginRow wholesale={_w} rrp={_rrpv} serviceId={`addon-${a.id}`} tierName={a.name} title={`${a.name} margin`} unitWord={a.oneTime ? 'one-off' : 'month'} flat autoFill />
            </div>
          );
        })()}

        <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">{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} />
            <span className="addon__capacity-frame thin-glass-frame" aria-hidden="true" />
          </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 ──
// ── Add-on overlays (2026-06-11). Same engine as the role overlays,
// clickable grey ⓘ turns blue on hover, modal state is hoisted to
// AddonsBlock, never mounted inside the tooltip wrapper. ──
// 2026-06-12 (decision confirmed): the two Landing Page add-ons stay
// separate (different services, a one-off build versus monthly optimisation
// of existing pages) and SHARE this overlay, which explains the difference
// with a comparison table.
const _LP_OVERLAY = {
  title: 'Landing Pages, the build and the optimisation',
  tabs: [
    { label: 'At a glance', blocks: [
      { t: 'p', x: 'Two different services that pair well. The build creates a brand-new page for your campaign, and the optimisation keeps the pages you already have improving month after month.' },
      { t: 'table', head: ['', 'Landing Page Build', 'Existing Page Optimisation'], rows: [
        ['What it is', 'A new conversion-focused page, designed and built for your campaign', 'Ongoing management and A/B testing of pages you already have'],
        ['Billing', '£905, one-off', 'From £149 a month by commitment'],
        ['Best for', 'Launching a campaign without a strong page to land on', 'Lifting conversion rates on pages already taking traffic'],
        ['Deliverable', 'One finished, QA-tested page', 'Continuous test-and-improve cycles with reporting'],
      ] },
    ] },
    { label: 'The build', blocks: [
      { t: 'p', x: 'We design, build, and QA test a conversion-focused landing page for your campaign, ready for traffic. A strong page leans on proof, so please prepare video and written testimonials in advance and we will build around them.' },
      { t: 'checks', items: [
        'Conversion-first design in your brand style',
        'Built, tested, and QA checked before launch',
        'Copy structured around your offer and proof',
        'Handover ready for your campaigns and tracking',
      ] },
    ] },
    { label: 'Ongoing optimisation', blocks: [
      { t: 'p', x: 'We manage the landing pages you already have, running structured A/B tests so conversion rates keep improving over time rather than staying static.' },
      { t: 'checks', items: [
        'Structured A/B testing with clear hypotheses',
        'Copy, layout, and call-to-action refinements',
        'Monthly reporting on what moved and why',
        'Works on pages we built or pages you already own',
      ] },
    ] },
  ],
  faqs: [
    { q: 'Do I need both?', a: 'They pair well but stand alone. If you have no page yet, start with the build. If you already have pages taking traffic, the optimisation lifts what is there, and many clients add it after a build once traffic is flowing.' },
    { q: 'How long does the build take?', a: 'Most builds are ready within 5 to 10 working days once we have your brief, brand kit, and testimonials.' },
    { q: 'Can you optimise a page you did not build?', a: 'Yes. The optimisation service works on any existing landing page, wherever it was built, provided we can access it to make changes.' },
  ],
};

const ADDON_OVERLAYS = {
  'lp-build': _LP_OVERLAY,
  'landing': _LP_OVERLAY,
  "insta-story": {
    title: "Instagram Story Design & Posting",
    tabs: [
      { label: "What you get", blocks: [
        { t: "p", x: "Each pack contains 10 on-brand Stories, designed by our team in your brand style and posted for you. Everything is built at 1080 by 1920 pixels with safe zones respected, so nothing important hides behind Instagram's interface." },
        { t: "checks", items: [
          "10 designed Stories per pack, ready to publish",
          "Sized for Stories with safe zones respected",
          "Scheduling and posting handled by us",
          "Designed to match your brand kit and tone",
        ] },
      ] },
      { label: "From scratch or your assets", blocks: [
        { t: "p", x: "Both routes work. Share product shots, footage, or existing creative, and we design around them, which usually gives the strongest results. Starting from nothing works too, we build each Story from scratch using your brand kit." },
        { t: "p", x: "For speed and authenticity, we recommend running day-to-day Stories in-house, and saving the designed packs for launches, campaigns, and polished evergreen sets." },
      ] },
      { label: "Working with us", blocks: [
        { t: "bullets", items: [
          { h: "Briefing", b: "a short brief per pack covers goals, content, and any assets you want used." },
          { h: "Design", b: "we design the full set so the 10 Stories read as one sequence." },
          { h: "Approval", b: "you review and approve the set before anything is scheduled or posted." },
        ] },
      ] },
    ],
    faqs: [
      { q: "How many revisions do I get?", a: "You review the full pack before anything is posted, and we polish it until it is on brand. The scope of changes is agreed at briefing, so bigger reworks are scoped openly rather than squeezed in." },
      { q: "Do the 10 Stories share one design or vary?", a: "Both. The pack is designed as one sequence with a consistent look, whilst each Story carries its own content and layout. If you want 10 standalone designs, say so in the brief." },
      { q: "Is posting included?", a: "Yes. We schedule and publish each Story to your account, and you keep full ownership and access throughout." },
      { q: "Can you reuse my existing assets?", a: "Yes, and it is encouraged. Product photography, campaign shots, and footage all slot into the designs, and anything missing we create from your brand kit." },
      { q: "Can the Stories be used in paid ads?", a: "Yes, the files are sized for Story placements, and you own them outright. For a light push, the Paid Advertising Post Promotion add-on boosts your strongest posts. For full campaigns, our Paid Advertising service runs them as managed ads, which we recommend, so we can be accountable for your growth end to end." },
    ],
  },
};

function AddonsBlock({ service, selectedAddons, addonQty, onToggleAddon, onSetAddonQty, defaultOpen, requireServiceActive, serviceActive, onActivateService, onDeactivateService, qualifier, intentId, overseasCountries, onSetOverseasCountries, mailOpts, onSetMailOpt, addonRecurring, onToggleAddonRecurring }) {
  // 2026-06-12 (Loom 40): resolve a one-off add-on to its recurring variant
  // when the user has switched it, the discounted rate bills monthly.
  const _recPrice = (a) => {
    if (a.recurringOption && a.recurringOption.price != null) return a.recurringOption.price;
    const raw = (a.price || 0) * (1 - ((a.recurringOption && a.recurringOption.savePct) || 11) / 100);
    return raw >= 10 ? Math.round(raw) : Math.round(raw * 100) / 100;
  };
  const _recEff = (a) => (a.recurringOption && addonRecurring && addonRecurring[a.id])
    ? { ...a, price: _recPrice(a), oneTime: false, priceLabel: null, _recurringOn: true }
    : a;
  // Add-on overlay modal, hoisted here per the GorillaMatrix pattern.
  const [ovAddon, setOvAddon] = uS(null);
  const _recSet = window.recommendedAddonIds ? window.recommendedAddonIds(service.id, qualifier, intentId) : new Set();
  const _addonWlMult = (intentId === 'agency-whitelabel' && window.getAgencyMultiplier) ? window.getAgencyMultiplier({ clientTypeId: 'agency', intentId }, service.id) : 1;
  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]);

  // 2026-06-10 (Loom 33): tooltip links can ask for an add-on to be located;
  // open this disclosure when the requested add-on belongs to this service.
  uE(() => {
    const onLocate = (e) => {
      const aid = e && e.detail && e.detail.addonId;
      if (!aid) return;
      if ((service.addons || []).some(a => a.id === aid)) setOpen(true);
    };
    window.addEventListener('gg:open-addons', onLocate);
    return () => window.removeEventListener('gg:open-addons', onLocate);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [service]);

  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' && a.id !== 'domains')
    : 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 || ''));
  // 2026-06-11: ids available in the current tier context, drives the
  // Best-paired-with link versus its from-a-higher-plan note.
  const _ctxIdSet = new Set(_visibleAddons.map(x => x.id));
  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 _standaloneCount = _visibleAddons.filter(a => a && a.standalone).length;

  const handleToggle = (aid, clickedEl) => {
    // 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(`[data-svc-id="${service.id}"] .svc__addons-disc--open`) || document.querySelector('.svc__addons-disc--open');
        if (_disc) _discAnchorTop = _disc.getBoundingClientRect().top;
      }
    } catch (e) {}

    // 2026-06-11: standalone add-ons never auto-activate the parent
    // service, they can sit in the cart without a plan (no auto-Grow).
    const _aDefPre = (_visibleAddons || []).find(x => x.id === aid);
    const _willActivate = requireServiceActive && !serviceActive && !(_aDefPre && _aDefPre.standalone);
    if (_willActivate) {
      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 (_willActivate) {
      // 2026-06-22: this add-on click just activated the service, so the tier /
      // configurator UI mounts ABOVE the add-ons disc and shoves it down, which
      // reads as the page jumping up. Pin the disc to its pre-click viewport
      // position across a few frames (scroll-behavior forced to auto + direct
      // scrollTop, since html scrolls smoothly). No nudge on activation.
      const _pinDisc = () => {
        try {
          const _disc = document.querySelector(`[data-svc-id="${service.id}"] .svc__addons-disc--open`) || document.querySelector('.svc__addons-disc--open');
          if (!_disc || _discAnchorTop == null) return;
          const _root = document.scrollingElement || document.documentElement;
          const _d = _disc.getBoundingClientRect().top - _discAnchorTop;
          if (Math.abs(_d) > 2) {
            const _prevSB = _root.style.scrollBehavior;
            _root.style.scrollBehavior = 'auto';
            _root.scrollTop = Math.max(0, _root.scrollTop + _d);
            _root.style.scrollBehavior = _prevSB;
          }
        } catch (e) {}
      };
      let _afPin = 0;
      const _pinLoop = () => { _pinDisc(); if (++_afPin < 22) requestAnimationFrame(_pinLoop); };
      requestAnimationFrame(_pinLoop);
    } else if (!_wasOn) {
      // 2026-06-22 (Loom): on select, nudge down to the NEXT add-on below the one
      // just clicked so the list guides the user along. html has scroll-behavior:
      // smooth (which defeats scrollIntoView/scrollTo), and a single rAF write gets
      // reset by React's re-render churn, so set scrollTop directly with the
      // behaviour forced to auto and re-apply across a few frames until it sticks.
      // Only scroll down.
      const _vh0 = window.innerHeight || 800;
      const _minNudge = Math.round(_vh0 * 0.12);
      let _nudgeTarget = null;
      const _nudgeNext = () => {
        try {
          const _root = document.scrollingElement || document.documentElement;
          if (_nudgeTarget == null) {
            const _card = document.querySelector(`[data-svc-id="${service.id}"]`);
            const _rows = _card ? Array.from(_card.querySelectorAll('.addon[data-addon-id]')) : [];
            const _idx = _rows.findIndex(r => r.getAttribute('data-addon-id') === aid);
            if (_idx < 0) { _nudgeTarget = -1; return; }
            // Config-heavy add-ons (CLB, Overseas, qty-based) expand options and
            // steppers in place, so do not nudge past them to the next add-on.
            if (_rows[_idx] && _rows[_idx].querySelector('.addon__qty, .ov-rp__pills')) { _nudgeTarget = -1; return; }
            const _hasNext = _idx + 1 < _rows.length;
            const _next = _rows[_idx + 1] || _rows[_idx];
            const _delta = _next.getBoundingClientRect().top - _vh0 * 0.6;
            // Below ~60%: bring it up to 60%. Already at or above 60% (e.g. selecting
            // the same add-on again after a deselect): still scroll down a little so
            // every select gives the same downward nudge. Never past the page bottom.
            let _amt = _delta > 4 ? _delta : (_hasNext ? _minNudge : 0);
            const _room = (_root.scrollHeight - _vh0) - _root.scrollTop;
            if (_amt > _room) _amt = _room;
            if (_amt <= 2) { _nudgeTarget = -1; return; }
            _nudgeTarget = _root.scrollTop + _amt;
          }
          if (_nudgeTarget < 0) return;
          const _prevSB = _root.style.scrollBehavior;
          _root.style.scrollBehavior = 'auto';
          _root.scrollTop = Math.max(0, _nudgeTarget);
          _root.style.scrollBehavior = _prevSB;
        } catch (e) {}
      };
      requestAnimationFrame(() => requestAnimationFrame(_nudgeNext));
      setTimeout(_nudgeNext, 160);
      setTimeout(_nudgeNext, 360);
    } else if (_discAnchorTop != null && typeof window !== 'undefined') {
      requestAnimationFrame(() => {
        requestAnimationFrame(() => {
          try {
            const _disc = document.querySelector(`[data-svc-id="${service.id}"] .svc__addons-disc--open`) || document.querySelector('.svc__addons-disc--open');
            if (!_disc) return;
            const _root = document.scrollingElement || document.documentElement;
            const delta = _disc.getBoundingClientRect().top - _discAnchorTop;
            if (Math.abs(delta) > 4) {
              const _prevSB = _root.style.scrollBehavior;
              _root.style.scrollBehavior = 'auto';
              _root.scrollTop = Math.max(0, _root.scrollTop + delta);
              _root.style.scrollBehavior = _prevSB;
            }
          } 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>{ovAddon && ADDON_OVERLAYS[ovAddon] ? <GMOverlayModal data={ADDON_OVERLAYS[ovAddon]} onClose={() => setOvAddon(null)} intentId={intentId} /> : null}
            {selectedAddons.length > 0 && (
              <span className="svc__addons-selected-pill" aria-label={`${selectedAddons.length} added`}>{selectedAddons.length}</span>
            )}
          </div>
          <div className="svc__addons-toggle-meta" style={{ marginLeft: 'calc(26px + 0.55rem)' }}>
            {_standaloneCount > 0 ? (
              <span style={{ display: 'inline-flex', alignItems: 'center', gap: '5px' }}>
                <span style={{ display: 'inline-flex', alignItems: 'center', background: 'rgba(0,42,191,0.08)', color: '#002ABF', fontWeight: 600, borderRadius: '5px', padding: '1px 7px' }}>{_standaloneCount} available without a plan</span>
                <HoverPortalTip wrapStyle={{ display: 'inline-flex', alignItems: 'center' }} tipClassName="dis-tip dis-tip--above" placement="above" tip={<span className="dis-tip__body">Standalone add-ons are available separately. You can buy them on their own without purchasing the plan or service itself, so you just choose the add-on you want.</span>}>
                  <span className="info-icon" role="img" aria-label="About standalone add-ons" onClick={(e) => e.stopPropagation()} style={{ cursor: 'help' }}><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></span>
                </HoverPortalTip>
              </span>
            ) : freeCount > 0 ? (
              <>{freeCount} free intro{freeCount === 1 ? '' : 's'}</>
            ) : null}
          </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, tip: 'Your content comes with a standard usage licence. This extends how long you can run the work across your channels and ads, with each option setting the licence length that fits your campaign.' },
                          'talent-casting':    { title: 'Talent Casting and Management',     desc: 'Select the tier of talent for your production. Only one talent tier per project.',       exclusive: true, tip: 'Real presenters and voice artists for your production. The tiers set the calibre and exclusivity of the talent we cast, source, and manage for you, one tier per project.' },
                          '3d-render-quality': { title: 'Render Quality Upgrade',            desc: 'Choose your render resolution. Only one resolution level applies per video.',            exclusive: true, tip: 'Your videos render in HD as standard. These upgrades raise the finish to QHD, 4K, or 8K for large-format, retail, and broadcast use, one resolution per video.' },
                          '3d-voice-over':     { title: 'Voice-Over',                        desc: 'Choose your voice-over option. Only one applies per video.',                            exclusive: true, tip: 'Professional voice for your animation, recorded, mixed, and synced into the final edit. Choose the option that fits each video.' },
                          '3d-maintenance':    { title: 'Maintenance Plan',                  desc: 'Choose your monthly maintenance tier. Only one plan applies at a time.',                exclusive: true, tip: 'Keeps delivered work current after handover. We revisit, refresh, and re-export your assets each month, one plan tier at a time.' },
                          '3d-usage-rights':   { title: 'Extended Usage Rights',             desc: 'Choose how long your extended licence runs. Only one option applies at a time.',        exclusive: true, tip: 'Your animations come with a standard usage licence. This extends how long you can run the work across your channels and ads, with each option setting the licence length that fits your campaign.' },
                          '3d-localisation':   { title: 'Multi-Language Localisation',       desc: 'Choose your localisation package. Priced per video per language added.',                exclusive: true, tip: 'Takes a finished video into new markets. Each package adapts subtitles, voice, and on-screen text for every language you add, priced per video per language.' },
                          'sdg-signals':       { title: 'GorillaSignals™: Buying-Signal Intelligence',        desc: 'Add buying signal data to your outbound to improve lead scoring and contact prospects who are actively buying or looking for your solution.',                          exclusive: false, tip: 'GorillaSignals™ layers live buying-signal data onto your outbound, so the team prioritises companies already showing intent. It covers five signals you can mix and match, web intent and visitor de-anonymisation, hiring and job-change, financial and funding, tech-stack change, and custom signals you define. Each runs as a monthly feed.', pair: 'premium-sourcing' },
                          'content-maintenance':{ title: 'Maintenance Plan',                 desc: 'Choose your monthly maintenance tier. Only one plan applies at a time.',                 exclusive: true, tip: 'Keeps delivered work current after handover. We revisit, refresh, and re-export your assets each month, one plan tier at a time.' },
                          '3d-vfx':            { title: 'Advanced VFX & Simulations',        desc: 'Add advanced visual effects. Pick any that apply.',                                     exclusive: false, tip: 'Cinematic extras for your animation, fluid dynamics, particle systems, and physics-based motion. Pick any that apply, priced per video.' },
                        };
                        return <>
                          {_regItems.map(a => renderAddon(_recEff(a), selectedAddons.includes(a.id), (e) => handleToggle(a.id, (e && e.target && e.target.closest) ? e.target.closest('.addon') : null), addonQty?.[a.id], (v) => onSetAddonQty && onSetAddonQty(a.id, v), _recSet, overseasCountries, onSetOverseasCountries, intentId, mailOpts, onSetMailOpt, _addonWlMult, (aid) => setOvAddon(aid), service.id, _ctxIdSet, !!(addonRecurring && addonRecurring[a.id]), (onToggleAddonRecurring ? () => onToggleAddonRecurring(a.id) : null)))}
                          {[..._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 sgMinItem = sgItems.filter(a => !a.custom && !a.included && typeof a.price === 'number').reduce((b, a) => (b == null || a.price < b.price) ? a : b, null);
                            // When every option in the group is included (e.g. Enterprise tier),
                            // show a styled "Included" rather than "From £0".
                            const sgAllIncluded = sgItems.length > 0 && sgItems.every(a => a.included);
                            // 2026-06-09 (Loom 5:48, buying signals): show the unit (e.g. /mo) on the sub-group headline so it reads per month.
                            const _sgSelSum = sgItems.filter(a => selectedAddons.includes(a.id) && !a.custom && typeof a.price === 'number').reduce((s2, a) => s2 + a.price * ((addonQty && addonQty[a.id]) || 1), 0);
                            const _sgWl = intentId === 'agency-whitelabel' && typeof _addonWlMult === 'number' && _addonWlMult > 0 && _addonWlMult < 1;
                            const _sgFloor = sgMinItem != null ? sgMinItem.price : null;
                            const _sgOn = sgActive && _sgSelSum > 0;
                            const _sgMainNum = _sgOn ? (_sgWl ? Math.round(_sgSelSum * _addonWlMult) : _sgSelSum) : (_sgFloor != null ? (_sgWl ? Math.round(_sgFloor * _addonWlMult) : _sgFloor) : null);
                            const _sgRrpNum = _sgWl ? (_sgOn ? _sgSelSum : _sgFloor) : null;
                            const sgHeadline = sgAllIncluded ? 'Included' : (_sgMainNum != null ? `${_sgOn ? '' : 'From '}£${_sgMainNum.toLocaleString()}` : 'Bespoke Pricing');
                            // 2026-06-09: unit rendered in the small muted price-suffix style, matching main add-ons.
                            const sgSuffix = (sgAllIncluded || sgMinItem == null) ? '' : (sgMinItem.unit === 'one-time' ? ' one-off' : (sgMinItem.unit ? (sgMinItem.unit.startsWith('/') ? sgMinItem.unit : ` ${sgMinItem.unit}`) : (sgMinItem.oneTime ? ' one-time' : '')));
                            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, _ev) => {
                              // 2026-06-22: scroll compensation on activation is handled centrally
                              // in handleToggle (it pins the add-ons disc), so no per-chip anchor here.
                              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' : ''} ${sgAllIncluded ? 'addon--included' : ''}`} onClick={sgAllIncluded ? undefined : _nudgeAddonChips}>
                                <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>
                                    {/* 2026-06-11: group-level overview tip; the chips keep the specifics. */}
                                    {meta.tip && (
                                      <HoverPortalTip interactive placement="above" wrapClassName="dis-tip-wrap" tipClassName="dis-tip dis-tip--above" tip={<>
                                        <span className="dis-tip__body" style={{display:'block'}}>{meta.tip}</span>
                                        {meta.pair && window.ADDON_LOOKUP && window.ADDON_LOOKUP[service.id] && window.ADDON_LOOKUP[service.id][meta.pair] && (
                                          <span className="dis-tip__body" style={{display:'block', marginTop:'0.55em'}}><strong>Best paired with:</strong>{' '}<button type="button" className="lead-vol__clb-link" onClick={(e) => { e.preventDefault(); e.stopPropagation(); if (window.ggScrollToAddon) window.ggScrollToAddon(meta.pair); }}>{window.ADDON_LOOKUP[service.id][meta.pair]}</button></span>
                                        )}
                                      </>}>
                                        <window.InfoIcon onClick={(e) => e.stopPropagation()} />
                                      </HoverPortalTip>
                                    )}
                                    {sgCount > 0 && <span className="ps-group__pill"><span className="ps-group__pill-n">{sgCount}</span><span className="ps-group__pill-t"> selected</span></span>}
                                  </div>
                                  <div className="addon__price-row">
                                    <span className={`addon__price-main ${sgAllIncluded ? 'addon__price-main--included' : ''}`}>{sgHeadline}</span>
                                    {sgSuffix && <span className="addon__price-suffix">{sgSuffix}</span>}
                                    {_sgRrpNum != null && <span className="addon__price-rrp" style={{ marginLeft: '4px', fontWeight: 400, fontStyle: 'italic', color: 'var(--gg-blue, #002abf)', fontSize: '0.85em' }}>(RRP £{_sgRrpNum.toLocaleString()})</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">{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} /><span className="addon__capacity-frame thin-glass-frame" aria-hidden="true" /></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__nudge-hint" aria-hidden="true"><svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M8 3v8"/><path d="M4.5 7.5L8 11l3.5-3.5"/></svg>Pick an option below to add this</div><div className={`ps-group__chips ${sgId === 'sdg-signals' ? 'ps-group__chips--wide' : ''}`}>
                                    {sgItems.map(a => {
                                      const on = selectedAddons.includes(a.id);
                                      // 2026-06-19 (Loom): on the agency path the chip shows the discounted
                                      // (wholesale) price the agency pays, with the RRP beside it, instead of
                                      // sitting on the RRP and looking like no discount applied.
                                      const _chipWl = (intentId === 'agency-whitelabel' && typeof _addonWlMult === 'number' && _addonWlMult > 0 && _addonWlMult < 1 && typeof a.price === 'number' && !a.custom && !a.included);
                                      const _chipUnit = `${a.unit && a.unit !== 'one-time' ? ' ' + a.unit : ''}${a.oneTime ? ' (one-time)' : ''}`;
                                      const priceTxt = a.custom ? 'Bespoke Pricing'
                                        : a.included ? <span style={{ fontWeight: 700, fontStyle: 'italic', color: 'var(--gg-muted, #6b7280)' }}>Included</span>
                                        : (typeof a.price === 'number'
                                          ? (_chipWl
                                            ? <>{`£${(window.ggRound5 || ((x) => Math.round(x)))(a.price * _addonWlMult).toLocaleString()}${_chipUnit}`}<span style={{ fontWeight: 400, fontStyle: 'italic', color: 'var(--gg-blue, #002abf)', fontSize: '0.82em', marginLeft: '3px' }}>(RRP £{a.price.toLocaleString()})</span></>
                                            : `£${a.price.toLocaleString()}${_chipUnit}`)
                                          : (a.priceLabel || '').replace(/^\s*from\s+/i, ''));
                                      return (
                                        <div key={a.id} data-addon-id={a.id} role="button" tabIndex={a.included ? -1 : 0} aria-pressed={on} aria-disabled={a.included || undefined}
                                          className={`ps-chip thin-glass-frame ${on ? 'ps-chip--on' : ''} ${a.included ? 'ps-chip--included' : ''}`}
                                          onClick={(e) => { e.stopPropagation(); if (a.included) return; handleChipClick(a, e); }}
                                          onKeyDown={(e) => { e.stopPropagation(); if (a.included) return; if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleChipClick(a, 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">
                                                {a.name}
                                                {a.desc && (
                                                  <HoverPortalTip interactive tip={<>{String(a.desc).split('\n').map((para, i) => <span key={i} className="addon__capacity-tip-body" style={{ display: 'block', marginTop: i ? '0.55em' : 0 }}>{para.split(/\[\[(.+?)\]\]/g).map((seg, j) => j % 2 === 1 ? <button key={j} type="button" className="lead-vol__clb-link" onClick={(e) => { e.preventDefault(); e.stopPropagation(); if (window.ggScrollToLeadVol) window.ggScrollToLeadVol(); }}>{seg}</button> : seg)}</span>)}</>} tipClassName="addon__capacity-tip">
                                                    <span className="ps-chip__info" style={{ color: '#6b7280', marginLeft: '0.4rem' }} 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>
                                  {/* 2026-06-08 (Loom 31 bug): sub-group add-ons now show the agency
                                      white-label margin calculator for the selected option(s), which
                                      previously only appeared on standard (non sub-group) add-ons. */}
                                  {intentId === 'agency-whitelabel' && window.MarginRow && (() => {
                                    // 2026-06-30 (Loom 68 follow-up): in the no-plan preview (no tier picked)
                                    // show a margin calculator for every priced signal chip; once a plan is
                                    // picked keep the original behaviour (calculators for selected chips only).
                                    const _sgPriced = sgItems.filter(a => !a.free && !a.custom && !a.included && typeof a.price === 'number' && a.price > 0);
                                    const _sgPicked = _sgPriced.filter(a => selectedAddons.includes(a.id));
                                    // Show every priced chip's calculator by default; once chips are
                                    // selected narrow to those, and restore all when they are deselected.
                                    const _sgSel = _sgPicked.length ? _sgPicked : _sgPriced;
                                    if (!_sgSel.length) return null;
                                    const _sgMult = (typeof _addonWlMult === 'number' ? _addonWlMult : 1);
                                    return _sgSel.map(a => {
                                      const _q = (addonQty && addonQty[a.id]) || 1;
                                      return <window.MarginRow key={a.id} wholesale={Math.round(a.price * _sgMult * _q)} rrp={a.price * _q} serviceId={`addon-${a.id}`} tierName={a.name} title={`${a.name} margin`} unitWord={a.oneTime ? 'one-off' : 'month'} flat autoFill />;
                                    });
                                  })()}
                                </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"><span className="ps-group__pill-n">{psSelectedCount}</span><span className="ps-group__pill-t"> selected</span></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">{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} /><span className="addon__capacity-frame thin-glass-frame" aria-hidden="true" /></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>
                                                        <QtyInput
                                                          id={`ps-qty-${a.id}`}
                                                          className="ps-chip__qty-input"
                                                          value={qVal}
                                                          min={1}
                                                          max={99999}
                                                          onCommit={(v) => onSetAddonQty && onSetAddonQty(a.id, v)}
                                                          ariaLabel="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(
                _recEff(a),
                selectedAddons.includes(a.id),
                (e) => handleToggle(a.id, (e && e.target && e.target.closest) ? e.target.closest('.addon') : null),
                addonQty?.[a.id],
                (v) => onSetAddonQty && onSetAddonQty(a.id, v),
                _recSet,
                overseasCountries,
                onSetOverseasCountries,
                intentId,
                mailOpts,
                onSetMailOpt,
                _addonWlMult,
                (aid) => setOvAddon(aid),
                service.id,
                _ctxIdSet,
                !!(addonRecurring && addonRecurring[a.id]),
                (onToggleAddonRecurring ? () => onToggleAddonRecurring(a.id) : null)
              ))}
            </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} />
        <span className="capacity__frame thin-glass-frame" aria-hidden="true" />
      </div>
      <div className="capacity__copy">{copy}</div>
    </div>
  );
}

// ── CHANNELS PANEL ── shown below tiers; informational or selectable
// 2026-06-19 (Loom): SDG channel prices (LinkedIn / Instagram) carry the same
// commitment discount as the tiers and add-ons. The base figure is the 12-month
// rate; shorter commitments pay more (6mo and 3mo), rounded to a clean figure.
function _chanCommitRrp(base, commitId) {
  const cid = String(commitId || '12');
  const factor = cid === '3' ? (1 / 0.6) : cid === '6' ? (0.8 / 0.6) : 1;
  if (factor === 1) return base;
  const r5 = window.ggRound5 || ((x) => Math.round(x / 5) * 5);
  return r5(base * factor);
}
// Wholesale figure for a channel at a given commitment, rounded exactly like the
// rail so the box, margin calc and summary all agree to the pound.
function _chanWl(base, commitId, mult) {
  const r5 = window.ggRound5 || ((x) => Math.round(x / 5) * 5);
  return r5(Math.round(_chanCommitRrp(base, commitId) * mult));
}
function ChannelsPanel({ service, selection, onToggleChannel, onSetAdSpend, onSetLinkedinProfiles, serviceActive, currentTierId, isEnterprise, intentId, wlMult }) {
  const _chCommit = String((selection && selection.commitId) || '12');
  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'}
            {(() => {
              // 2026-06-11 (Loom 38 items 2-3): tiers with an included channel
              // count read "N included" with a pricing tooltip instead of a
              // hard pick-up-to cap. Other tiers keep the original cap text.
              const _incl = cfg.included?.[currentTierId];
              if (isInteractive && typeof _incl === 'number') {
                const _cid = String(selection?.commitId || '12');
                const _per = cfg.extraPrice ? (cfg.extraPrice[_cid] != null ? cfg.extraPrice[_cid] : cfg.extraPrice['12']) : 0;
                return (
                  <>
                    <span className="channels-panel__limit"> · {isEnterprise ? `${_incl} or more included` : `${_incl} included`}</span>
                    <HoverPortalTip
                      wrapClassName="channels-panel__limit-info-wrap"
                      tipClassName="dis-tip dis-tip--above"
                      placement="above"
                      tip={isEnterprise
                        ? <span>Your Enterprise plan includes {_incl} or more ad channels. Pick every channel you're interested in, and the exact scope and pricing are finalised in your proposal.</span>
                        : <span>Your plan includes any {_incl} of the {renderedIds.length} ad channels. Each additional channel adds <strong>£{_per}/mo</strong> on your {_cid}-month commitment, and it joins your quote automatically as you pick.</span>}
                    >
                      <button type="button" className="info-tip-icon" aria-label="About included channels and additional channel pricing" tabIndex={-1}>
                        <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>
                    </HoverPortalTip>
                  </>
                );
              }
              return (isInteractive && typeof max === 'number' && max < renderedIds.length)
                ? <span className="channels-panel__limit"> · pick up to {max}</span>
                : null;
            })()}
          </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 _isRec = (window.RECOMMENDED_CHANNELS?.[service.id] || []).includes(cid);
            const cls = [
              'channel-tile',
              isSelected ? 'channel-tile--on' : '',
              isReadOnly ? 'channel-tile--readonly' : '',
              cid === 'more' ? 'channel-tile--more' : '',
              _isRec ? 'channel-tile--recommended' : '',
            ].filter(Boolean).join(' ');
            const tileBtn = (
              <button
                key={cid}
                type="button"
                className={cls}
                onClick={() => {
                  if (isReadOnly) return;
                  const _wasOn = isSelected;
                  onToggleChannel(cid, max);
                  // 2026-06-05 (Loom 13 1:32): on selecting a channel, gently nudge
                  // down to the next section so the user is guided through the card.
                  if (!_wasOn && service) {
                    _scrollToNext(
                      `[data-svc-id="${service.id}"] .pa-spend, [data-svc-id="${service.id}"] .lead-vol, [data-svc-id="${service.id}"] .svc__addons-disc`,
                      { offset: Math.max(140, Math.min(240, Math.round((window.innerHeight || 800) * 0.22))) }
                    );
                  }
                }}
                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.
            // 2026-05-31: per-service override (SERVICE_CHANNEL_TIPS) so the same
            // channel reads correctly per service; falls back to the shared tip.
            const _svcTip   = window.SERVICE_CHANNEL_TIPS?.[service.id]?.[cid];
            const _tipText  = (_svcTip && _svcTip.tip)      || opt.tip;
            const _tipTitle = (_svcTip && _svcTip.tipTitle) || opt.tipTitle || opt.label;
            if (_tipText) {
              return (
                <HoverPortalTip
                  key={cid}
                  wrapClassName="channel-tile__tip-wrap"
                  tipClassName="channel-tile__tip"
                  placement="above"
                  tip={<span>{_isRec && <span className="channel-tile__rec-tag">Recommended</span>}<strong>{_tipTitle}</strong><br/>{_tipText}</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 = £395/mo (added
            to monthly total via linesForRail). Default 1, range 1-10. */}
        {service.id === 'sales'
          && Array.isArray(selection?.channels)
          && selection.channels.includes('linkedin')
          && (
            <div className="channels-panel__extras ch-li-extras thin-glass-frame">
              <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">
                  LinkedIn outreach runs on one managed profile
                </span>
                <HoverPortalTip
                  wrapClassName="ch-li-extras__info-tip-wrap"
                  tipClassName="dis-tip dis-tip--above"
                  placement="above"
                  tip={isEnterprise ? <span>One managed LinkedIn profile for outbound, a managed seat in our SDR pod, scoped in your proposal.</span> : <><span className="dis-tip__body" style={{display:'block'}}>One managed LinkedIn profile for outbound, a managed seat in our SDR pod. We send the messages and manage the responses on your behalf, with meetings booked straight into your calendar. Adds <strong>£395/mo</strong>, under £100 a week, including outreach costs.</span><span className="dis-tip__body" style={{display:'block',marginTop:'0.55em'}}>We recommend starting with one profile. LinkedIn allows roughly 100 to 200 connection requests a week per profile, which suits around 400 to 800 prospects a month, and you can add more as you grow.</span></>}
                >
                  <window.InfoIcon className="ch-li-extras__info" />
                </HoverPortalTip>
                {isEnterprise ? (
                  <span className="ch-li-extras__price">1 profile · <strong>priced in your proposal</strong></span>
                ) : (typeof wlMult === 'number' && wlMult < 1) ? (
                  <span className="ch-li-extras__price">1 profile · <strong>+£{_chanWl(395, _chCommit, wlMult).toLocaleString('en-GB')}/mo</strong><span style={{fontWeight:400, fontStyle:'italic', fontSize:'0.82em', marginLeft:'4px', color:'var(--gg-blue, #002abf)'}}>(RRP £{_chanCommitRrp(395, _chCommit).toLocaleString('en-GB')})</span></span>
                ) : (
                  <span className="ch-li-extras__price">1 profile · <strong>+£395/mo</strong></span>
                )}
              </div>
            </div>
          )}

        {/* 2026-06-12 (Loom 44 1:26): Instagram outreach, Scale and Enterprise
            only, one managed profile (Instagram's follow limits), £295/mo. */}
        {service.id === 'sales'
          && Array.isArray(selection?.channels)
          && selection.channels.includes('instagram')
          && (
            <div className="channels-panel__extras ch-li-extras ch-ig-extras thin-glass-frame">
              <div className="ch-li-extras__row">
                <span className="ch-li-extras__icon" style={{ color: '#E1306C' }}>
                  {window.CHANNEL_OPTIONS?.instagram?.icon}
                </span>
                <span className="ch-li-extras__label">
                  Instagram growth runs on one managed profile
                </span>
                <HoverPortalTip
                  wrapClassName="ch-li-extras__info-tip-wrap"
                  tipClassName="dis-tip dis-tip--above"
                  placement="above"
                  tip={isEnterprise ? <span>Manual follows and DMs from one managed profile, all done by hand from an iPhone in our London office, scoped in your proposal.</span> : <><span className="dis-tip__body" style={{display:'block'}}>Instagram tightly caps how many follows and cold DMs one account can send each day, and pushing past the limits gets a profile restricted.</span><span className="dis-tip__body" style={{display:'block',marginTop:'0.55em'}}>We recommend starting with one profile, which is also the safe maximum. One carefully paced profile is exactly what we run, yours or one we set up. It is all done manually from an iPhone in our London office, every follow and DM by hand. Adds <strong>£495/mo</strong>. If you would like to build a personal brand, our Social Media Management service in Creative services can handle that.</span></>}
                >
                  <window.InfoIcon className="ch-li-extras__info" />
                </HoverPortalTip>
                {isEnterprise ? (
                  <span className="ch-li-extras__price">1 profile · <strong>priced in your proposal</strong></span>
                ) : (typeof wlMult === 'number' && wlMult < 1) ? (
                  <span className="ch-li-extras__price">1 profile · <strong>+£{_chanWl(495, _chCommit, wlMult).toLocaleString('en-GB')}/mo</strong><span style={{fontWeight:400, fontStyle:'italic', fontSize:'0.82em', marginLeft:'4px', color:'var(--gg-blue, #002abf)'}}>(RRP £{_chanCommitRrp(495, _chCommit).toLocaleString('en-GB')})</span></span>
                ) : (
                  <span className="ch-li-extras__price">1 profile · <strong>+£495/mo</strong></span>
                )}
              </div>
            </div>
          )}

          </div>

      {cap && (
        <div className="channels-panel__cap">
          <CapacityBar status={cap.status} fill={cap.fill} copy={cap.copy} />
        </div>
      )}
      {/* 2026-06-18 (Loom 48): per-channel margin calculators, full-width below the
          channels + onboarding-availability row (mirrors the Paid Ads daily-budget layout). */}
      {service.id === 'sales' && intentId === 'agency-whitelabel' && typeof wlMult === 'number' && wlMult < 1 && Array.isArray(selection?.channels) && (selection.channels.includes('linkedin') || selection.channels.includes('instagram')) && window.MarginRow && (
        <div className="channels-panel__chan-margins">
          {selection.channels.includes('linkedin') && (
            <div className="chan-margin-box thin-glass-frame" onClick={(e) => e.stopPropagation()}>
              <window.MarginRow wholesale={_chanWl(395, _chCommit, wlMult)} rrp={_chanCommitRrp(395, _chCommit)} serviceId="channel-linkedin" tierName="LinkedIn outreach" title="LinkedIn outreach margin" unitWord="month" monthlyTip="Your monthly wholesale cost for one managed LinkedIn profile and the price you charge your client. This channel has no setup fee." flat autoFill />
            </div>
          )}
          {selection.channels.includes('instagram') && (
            <div className="chan-margin-box thin-glass-frame" onClick={(e) => e.stopPropagation()}>
              <window.MarginRow wholesale={_chanWl(495, _chCommit, wlMult)} rrp={_chanCommitRrp(495, _chCommit)} serviceId="channel-instagram" tierName="Instagram growth" title="Instagram growth margin" unitWord="month" monthlyTip="Your monthly wholesale cost for one managed Instagram profile and the price you charge your client. This channel has no setup fee." flat autoFill />
            </div>
          )}
        </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 && (() => {
          // 2026-06-12 (review, Loom 38): Enterprise budgets start substantially
          // higher, so its tiles collect a realistic enterprise media range
          // whilst the standard tiers keep the original ladder.
          const PRESETS = isEnterprise ? [
            { id: 'ent-foundation', label: 'Foundation',    range: '£250-500/day', monthly: '£7.5k, 15k/mo', blurb: '3 channels · enterprise baseline',   value: 350,  lo: 250,  hi: 500 },
            { id: 'ent-expansion',  label: 'Expansion',     range: '£500-1k/day',  monthly: '£15k, 30k/mo',  blurb: '3-4 channels · compounding reach',   value: 750,  lo: 500,  hi: 1000 },
            { id: 'ent-accelerate', label: 'Acceleration',  range: '£1k-2.5k/day', monthly: '£30k, 75k/mo',  blurb: '4-5 channels · full-funnel scale',   value: 1750, lo: 1000, hi: 2500 },
            { id: 'ent-leadership', label: 'Market leader', range: '£2.5k+/day',   monthly: '£75k+/mo',      blurb: 'All channels · category leadership', value: 3500, lo: 2500, hi: Infinity },
          ] : [
            { 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={isEnterprise
                    ? <span>Your daily ad spend is paid directly to the ad platforms (Meta, Google, etc.) and sits on top of GoGorilla.com&rsquo;s management fee. For enterprise campaigns we recommend at least &#163;7,500 a month, around &#163;250 a day, so every channel gathers enough data to escape its learning phase and optimise quickly.</span>
                    : <span>Your daily ad spend is paid directly to the ad platforms (Meta, Google, etc.) and sits on top of GoGorilla.com&rsquo;s management fee. There is no minimum, but we recommend at least &#163;1,500 a month, around &#163;50 a day, so each platform gathers enough data to escape its learning phase and optimise quickly.</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(isEnterprise ? 500 : 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 one or more of 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 hideTimer = uR(null);
  const hideTip = () => { clearTimeout(hideTimer.current); hideTimer.current = setTimeout(() => setTipPos(null), 220); };
  const cancelHide = () => clearTimeout(hideTimer.current);
  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"
          onMouseEnter={cancelHide}
          onMouseLeave={hideTip}
          style={{
            left: tipPos.x,
            top: tipPos.y,
            pointerEvents: 'auto',
            '--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({ body, meta }) {
  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">{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">{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',
};

// Item 2 (Loom 0:24): plain-English tooltips for the GorillaMatrix incentives,
// keyed by incentive name so they read correctly in every path. Copy signed
// off in the Bea handover (section 6).
const GM_INCENTIVE_TIPS = {
  'Algorithmic Team Profit Sharing': 'Built into every plan at no extra cost. We tie each team member\u2019s bonus to your KPIs and allocate it algorithmically each quarter, so the better we deliver on your goals the more the team earns. It comes out of your existing management fee, so you never pay more, and it keeps the team accountable to your results.',
  'Performance Recognition Tips': 'Optional. When the team goes above and beyond you can choose to recognise them with a tip. It sits on top of the built-in profit sharing, it is entirely up to you, and GoGorilla.com takes no commission on tips.',
  'Outcome-Linked Success Bonus': 'An optional add-on where part of our fee is earned only when a specific business outcome you agree with us is achieved. It keeps everyone focused on one priority result, without changing how the work is delivered day to day. We agree it together, and it begins after we have worked together for a few months and both know what good looks like.',
};
// ── GORILLA MATRIX, two-sided financial incentives strip ──
const GM_TIPS_BY_SERVICE = {
  'content': { 'Algorithmic Team Profit Sharing': { paras: ['Every quarter, a ring-fenced portion of your management fee is allocated into the Financial-Success Matrix™ bonus pool and distributed across your pod by our algorithm, covering your content strategists, writers, editors, designers, and producers. Allocation is based on a Quality Performance Index (QPI), response times, delivery quality, peer feedback, 360 evaluations, and your own 1 to 10 feedback scores at milestones. High performers earn more, and anyone coasting earns less.', 'The mechanic sits inside your existing management fee, at no additional cost to you, and exists so no one on your pod is on a flat salary whilst your campaign coasts. Every hand on your pod has direct financial skin in the game.'] } },
  'motion': { 'Algorithmic Team Profit Sharing': { paras: ['Every quarter, a ring-fenced portion of your management fee is allocated into the Financial-Success Matrix™ bonus pool and distributed across your pod by our algorithm, covering your 3D artists, animators, modellers, lighting and render specialists, and motion designers. Allocation is based on a Quality Performance Index (QPI), response times, delivery quality, peer feedback, 360 evaluations, and your own 1 to 10 feedback scores at milestones. High performers earn more, and anyone coasting earns less.', 'The mechanic sits inside your existing management fee, at no additional cost to you, and exists so no one on your pod is on a flat salary whilst your campaign coasts. Every hand on your pod has direct financial skin in the game.'] } },
  'paid-ads': { 'Algorithmic Team Profit Sharing': { paras: ['Every quarter, a ring-fenced portion of your management fee is allocated into the Financial-Success Matrix™ bonus pool and distributed across your pod by our algorithm, covering your media buyers, paid-media strategists, creative specialists, and analysts. Allocation is based on a Quality Performance Index (QPI), response times, delivery quality, peer feedback, 360 evaluations, and your own 1 to 10 feedback scores at milestones. High performers earn more, and anyone coasting earns less.', 'The mechanic sits inside your existing management fee, at no additional cost to you, and exists so no one on your pod is on a flat salary whilst your campaign coasts. Every hand on your pod has direct financial skin in the game.'] } },
  'email': { 'Algorithmic Team Profit Sharing': { paras: ['Every quarter, a ring-fenced portion of your management fee is allocated into the Financial-Success Matrix™ bonus pool and distributed across your pod by our algorithm, covering your email strategists, copywriters, designers, deliverability specialists, and automation builders. Allocation is based on a Quality Performance Index (QPI), response times, delivery quality, peer feedback, 360 evaluations, and your own 1 to 10 feedback scores at milestones. High performers earn more, and anyone coasting earns less.', 'The mechanic sits inside your existing management fee, at no additional cost to you, and exists so no one on your pod is on a flat salary whilst your campaign coasts. Every hand on your pod has direct financial skin in the game.'] } },
  'smm': { 'Algorithmic Team Profit Sharing': { paras: ['Every quarter, a ring-fenced portion of your management fee is allocated into the Financial-Success Matrix™ bonus pool and distributed across your pod by our algorithm, covering your community managers, content creators, designers, social strategists, and analysts. Allocation is based on a Quality Performance Index (QPI), response times, delivery quality, peer feedback, 360 evaluations, and your own 1 to 10 feedback scores at milestones. High performers earn more, and anyone coasting earns less.', 'The mechanic sits inside your existing management fee, at no additional cost to you, and exists so no one on your pod is on a flat salary whilst your campaign coasts. Every hand on your pod has direct financial skin in the game.'] } },
  sales: {
    'Algorithmic Team Profit Sharing': {
      paras: [
        'Every quarter, a ring-fenced portion of your management fee is allocated into the Financial-Success Matrix™ bonus pool and distributed across your pod by our algorithm, covering list-builders, enrichment analysts, copywriters, strategists, SDRs, and appointment setters. Allocation is based on a Quality Performance Index (QPI), response times, delivery quality, peer feedback, 360 evaluations, and your own 1 to 10 feedback scores at milestones. High performers earn more, and anyone coasting earns less.',
        'The mechanic sits inside your existing management fee, at no additional cost to you, and exists so no one on your pod is on a flat salary whilst your campaign coasts. Every hand on your pod has direct financial skin in the game.'
      ]
    },
    'Performance Recognition Tips': {
      paras: [
        'Recognise exceptional work with direct tips to your team. 100% of any tip goes to the individuals or squad you select, with zero platform fees. For Enterprise accounts, this includes team-wide recognition options and milestone-based tipping to celebrate major wins, such as closed deals or exceeding quarterly targets.'
      ]
    },
    'Outcome-Linked Success Bonus': {
      paras: [
        'Built into Sales & Demand Generation. Your pricing already works this way, the cost per meeting means part of our fee is only earned as qualified meetings are delivered, so our incentives are tied to your pipeline from day one.',
        'A wider outcome-linked arrangement, such as a revenue or profit share, can be agreed on top. We only consider this after we have worked together for a few months, once we both know what good looks like. Please book a call for more details.'
      ],
      agencyWlExtra: ['For white-label partners, you take 40% off the outcome-linked bonus when we hit the agreed target.']
    }
  }
};

const GM_OVERLAYS_BY_SERVICE = {
  'content': { 'Algorithmic Team Profit Sharing': { title: 'Algorithmic Team Profit Sharing', intro: 'Because part of our reward is tied to the results your content drives, our team is motivated to keep ideas fresh and quality high. This keeps your content working harder for your brand and your audience. Here are some examples of how our built-in team profit sharing encourages our team to go above and beyond in key areas:', proHead: 'Proactive Management with GorillaMatrix®', conHead: 'Reactive Management without GorillaMatrix®', rows: [
        { pro: 'Frequent content refreshes and new formats guided by performance data', why: 'Adapting topics and formats to what is resonating keeps your content relevant and compounding in reach.', con: 'Set content calendars with few mid-flight changes', impact: 'A fixed calendar lets underperforming themes run on whilst opportunities pass.' },
        { pro: 'Rapid testing of headlines, hooks, and thumbnails to lift engagement', why: 'Quick tests reveal what earns attention so each piece performs better than the last.', con: 'Standard production with limited experimentation', impact: 'Without testing, weak hooks suppress views and the content underdelivers.' },
        { pro: 'Faster turnaround on briefs and revisions', why: 'Moving quickly on priority pieces keeps your content timely and tied to live moments.', con: 'Routine production timelines regardless of priority', impact: 'Slow turnarounds mean content lands late and loses relevance.' },
        { pro: 'Ongoing research and repurposing to get more from every asset', why: 'Reworking strong pieces across formats and channels stretches the value of every asset.', con: 'One-and-done content with little repurposing', impact: 'Treating each piece as one-off leaves reach and return on the table.' },
        { pro: 'Prompt reallocation of specialists to the content that needs them', why: 'Putting the right people on the right work keeps quality consistent across the whole output.', con: 'Fixed roles regardless of where quality dips', impact: 'A fixed setup lets quality slip on the pieces that matter most.' }
      ], closing: 'Sharing the risk gives our specialists the extra drive to keep ideas fresh and quality high, so your content keeps building your brand rather than blending in.', link: { text: 'Learn more about our Algorithmic Team Profit Sharing', url: 'https://www.gogorilla.com/fintech-platform/algorithmic-team-profit-sharing' }, faqs: [ { q: 'How do I know the bonus is allocated fairly?', a: 'Fairness is at the core of our model. The bonus allocation is not based on opinion or office politics. It is determined by a proprietary algorithm that analyses hundreds of data points, including your direct feedback, your project’s key performance metrics, and our own internal quality scores. This data-driven process ensures that the team members who make the most significant contribution to your success receive the largest reward.' }, { q: 'Does this cost me extra?', a: 'No. The Algorithmic Team Profit Sharing is a built-in feature of our partnership. The funds for the bonus pool come directly from a portion of your existing management fee. It is our way of putting “skin in the game” and ensuring our team is fully invested in your success at no additional cost to you.' } ] } },
  'motion': { 'Algorithmic Team Profit Sharing': { title: 'Algorithmic Team Profit Sharing', intro: 'Because part of our reward is tied to the impact your animation delivers, our team is motivated to push quality and craft on every frame. This keeps your visuals sharp, on-brand, and built to convert. Here are some examples of how our built-in team profit sharing encourages our team to go above and beyond in key areas:', proHead: 'Proactive Management with GorillaMatrix®', conHead: 'Reactive Management without GorillaMatrix®', rows: [
        { pro: 'Proactive refinement of models, lighting, and materials for higher realism', why: 'Extra craft on detail and lighting lifts realism, which is what makes 3D work persuasive.', con: 'Standard quality passes with limited iteration', impact: 'Limited iteration leaves visuals looking flat and less convincing.' },
        { pro: 'Faster iteration on storyboards and animatics from feedback', why: 'Quick loops on storyboards lock the creative direction early and avoid costly late changes.', con: 'Set revision rounds regardless of need', impact: 'Rigid revision rounds let issues surface late, when they are expensive to fix.' },
        { pro: 'Rapid testing of styles and motion to find what converts', why: 'Trying alternative styles and motion reveals what holds attention and drives action.', con: 'One fixed creative direction with little testing', impact: 'Committing to one direction untested risks animation that looks good but underperforms.' },
        { pro: 'Efficient pipelines and reusable assets to lower render costs', why: 'Reusable rigs and optimised render pipelines cut turnaround and cost without losing quality.', con: 'Rebuilding assets each time with little reuse', impact: 'Rebuilding from scratch each time raises cost and slows delivery.' },
        { pro: 'Prompt reallocation of specialists when a shot needs more craft', why: 'Moving the right artists onto the toughest shots keeps the whole piece at a high standard.', con: 'Fixed roles regardless of where the work is hardest', impact: 'A fixed setup lets the hardest shots drag down the finished result.' }
      ], closing: 'Sharing the risk gives our specialists the extra drive to push craft and efficiency, so your animation lands with impact rather than just ticking a box.', link: { text: 'Learn more about our Algorithmic Team Profit Sharing', url: 'https://www.gogorilla.com/fintech-platform/algorithmic-team-profit-sharing' }, faqs: [ { q: 'How do I know the bonus is allocated fairly?', a: 'Fairness is at the core of our model. The bonus allocation is not based on opinion or office politics. It is determined by a proprietary algorithm that analyses hundreds of data points, including your direct feedback, your project’s key performance metrics, and our own internal quality scores. This data-driven process ensures that the team members who make the most significant contribution to your success receive the largest reward.' }, { q: 'Does this cost me extra?', a: 'No. The Algorithmic Team Profit Sharing is a built-in feature of our partnership. The funds for the bonus pool come directly from a portion of your existing management fee. It is our way of putting “skin in the game” and ensuring our team is fully invested in your success at no additional cost to you.' } ] } },
  'paid-ads': { 'Algorithmic Team Profit Sharing': { title: 'Algorithmic Team Profit Sharing', intro: 'Because we share in your campaign’s upside, we’re naturally motivated to seize every opportunity that boosts returns. This approach encourages prompt, data-driven decisions that help your paid ads thrive in a competitive market. Here are some examples of how our built-in team profit sharing encourages our team to go above and beyond in key areas:', proHead: 'Proactive Management with GorillaMatrix®', conHead: 'Reactive Management without GorillaMatrix®', rows: [
        { pro: 'Swift reallocation of ad spend to top-performing channels or ads based on performance insights', why: 'Moving budget to the best-performing ads and channels as the data comes in protects your return on ad spend and stops budget leaking into underperformers.', con: 'Routine campaign adjustments instead of real-time optimisations', impact: 'Waiting for the next review cycle to rebalance means spend keeps flowing to weaker ads, raising your cost per acquisition.' },
        { pro: 'Rapid testing and implementation of latest ad formats, tools, and strategies to gain first-mover advantage', why: 'Being first to trial new ad formats and platform features captures cheaper impressions and attention before competitors crowd in.', con: 'Limited testing and slower implementation of new ad strategies', impact: 'Adopting new formats late means paying premium rates once everyone else has moved in.' },
        { pro: 'Faster ad creation and landing page updates', why: 'Quick ad and landing-page iterations keep your creative fresh and your conversion path sharp, so campaigns keep improving week to week.', con: 'Standard creative update timelines for campaigns', impact: 'Slow creative refreshes let ad fatigue set in, pushing click-through rates down and costs up.' },
        { pro: 'Rapid creative innovations to make advertising campaigns more profitable', why: 'Constant creative experimentation finds the angles that convert most efficiently, lifting profit rather than just traffic.', con: 'Higher campaign costs due to periodic strategy updates', impact: 'Periodic creative updates leave winning angles undiscovered and budget on the table.' },
        { pro: 'Prompt reassignment of paid advertising tasks for improved results', why: 'Shifting the right specialists onto the work that needs them keeps momentum on the campaigns that matter most.', con: 'Limited flexibility in team composition during campaigns', impact: 'A fixed team shape means bottlenecks go unaddressed and results stall.' }
      ], closing: 'Sharing the risk gives our specialists the extra drive to test fresh tactics and optimise in real time, so your paid media keeps compounding returns rather than coasting.', link: { text: 'Learn more about our Algorithmic Team Profit Sharing', url: 'https://www.gogorilla.com/fintech-platform/algorithmic-team-profit-sharing' }, faqs: [ { q: 'How do I know the bonus is allocated fairly?', a: 'Fairness is at the core of our model. The bonus allocation is not based on opinion or office politics. It is determined by a proprietary algorithm that analyses hundreds of data points, including your direct feedback, your project’s key performance metrics, and our own internal quality scores. This data-driven process ensures that the team members who make the most significant contribution to your success receive the largest reward.' }, { q: 'Does this cost me extra?', a: 'No. The Algorithmic Team Profit Sharing is a built-in feature of our partnership. The funds for the bonus pool come directly from a portion of your existing management fee. It is our way of putting “skin in the game” and ensuring our team is fully invested in your success at no additional cost to you.' } ] } },
  'email': { 'Algorithmic Team Profit Sharing': { title: 'Algorithmic Team Profit Sharing', intro: 'Sharing the risk gives us extra drive to incorporate fresh tactics fast and encourage rapid experimentation, so you always get agile improvements that keep your subscribers interested and engaged. Here are some examples of how our built-in team profit sharing model encourages our team to go above and beyond in key areas:', proHead: 'Proactive Management with GorillaMatrix®', conHead: 'Reactive Management without GorillaMatrix®', rows: [
        { pro: 'More frequent testing of new creative approaches and advanced automation sequences', why: 'A steady stream of creative and automation tests surfaces the subject lines, flows, and offers that lift engagement and revenue per send.', con: 'Fixed test windows with minimal reactive changes', impact: 'Testing only in fixed windows lets underperforming templates run for months before anyone reacts.' },
        { pro: 'Faster pivot on deliverability insights for optimal inbox placement', why: 'Acting on deliverability signals quickly protects your sender reputation and keeps emails landing in the inbox rather than the spam folder.', con: 'Routine checks on deliverability signals', impact: 'Routine-only monitoring means placement problems are caught after opens and clicks have already dropped.' },
        { pro: 'Agile content updates when audience engagement fluctuates', why: 'Refreshing content as engagement shifts keeps subscribers interested and reduces unsubscribes and list fatigue.', con: 'Sporadic updates driven by periodic reviews', impact: 'Periodic reviews let stale content linger, eroding open rates and long-term list value.' },
        { pro: 'Nimble adoption of newly emerging email marketing tools', why: 'Trialling promising new tools early unlocks better segmentation, personalisation, and automation before competitors catch up.', con: 'Gradual uptake of novel email tools', impact: 'Slow tool adoption leaves easy gains in engagement and efficiency on the table.' },
        { pro: 'Proactive reallocation of roles to counter underperforming content', why: 'Moving the right specialists onto weak spots quickly keeps every part of the programme performing.', con: 'Reactively switching roles only after deep underperformance', impact: 'Waiting for deep underperformance before reacting means lost revenue that is hard to recover.' }
      ], closing: 'Sharing the risk gives our specialists the extra drive to experiment and adapt fast, so your email programme keeps lifting engagement and lifetime value rather than standing still.', link: { text: 'Learn more about our Algorithmic Team Profit Sharing', url: 'https://www.gogorilla.com/fintech-platform/algorithmic-team-profit-sharing' }, faqs: [ { q: 'How do I know the bonus is allocated fairly?', a: 'Fairness is at the core of our model. The bonus allocation is not based on opinion or office politics. It is determined by a proprietary algorithm that analyses hundreds of data points, including your direct feedback, your project’s key performance metrics, and our own internal quality scores. This data-driven process ensures that the team members who make the most significant contribution to your success receive the largest reward.' }, { q: 'Does this cost me extra?', a: 'No. The Algorithmic Team Profit Sharing is a built-in feature of our partnership. The funds for the bonus pool come directly from a portion of your existing management fee. It is our way of putting “skin in the game” and ensuring our team is fully invested in your success at no additional cost to you.' } ] } },
  'smm': { 'Algorithmic Team Profit Sharing': { title: 'Algorithmic Team Profit Sharing', intro: 'With a direct stake in how well your community grows, our team is primed to spot engagement opportunities and roll out fresh ideas sooner. This spirit of rapid adaptation keeps your audience engaged and coming back for more meaningful experiences. Here are some examples of how our built-in team profit sharing model encourages our team to go above and beyond in key areas:', proHead: 'Proactive Management with GorillaMatrix®', conHead: 'Reactive Management without GorillaMatrix®', rows: [
        { pro: 'Frequent content updates and creative ideation aligned with audience engagement data', why: 'Posting and iterating in step with live engagement data keeps your feed relevant and your community growing.', con: 'Longer intervals between content updates and creative ideas', impact: 'Long gaps between updates let momentum fade and the audience drift to more active competitors.' },
        { pro: 'Faster response times to comments and enquiries through live monitoring', why: 'Replying quickly through live monitoring turns comments and questions into relationships and shows the brand is present.', con: 'Standard response times to audience comments or enquiries', impact: 'Standard response times leave questions unanswered and goodwill cooling.' },
        { pro: 'Timely updates to underperforming posts or hashtags based on live insights', why: 'Adjusting weak posts and hashtags as insights land protects reach and keeps content discoverable.', con: 'Routine adjustments to underperforming posts', impact: 'Routine-only tweaks let underperforming posts drag down reach before anyone reacts.' },
        { pro: 'Creative efficiencies through ongoing content research and testing to reduce community building costs', why: 'Ongoing research and testing finds what resonates for less, lowering the cost of building an engaged community.', con: 'Refinement of content strategies based on traditional review cycles', impact: 'Relying on traditional review cycles makes community building slower and more expensive.' },
        { pro: 'Rapid realignment of social media roles in response to performance dips', why: 'Shifting the right people onto the work that needs them keeps performance steady through dips.', con: 'Delayed adjustments to underperforming social tasks', impact: 'Delayed reshuffles let underperformance persist and engagement slide.' }
      ], closing: 'Sharing the risk gives our specialists the extra drive to spot opportunities and adapt fast, so your community keeps growing rather than plateauing.', link: { text: 'Learn more about our Algorithmic Team Profit Sharing', url: 'https://www.gogorilla.com/fintech-platform/algorithmic-team-profit-sharing' }, faqs: [ { q: 'How do I know the bonus is allocated fairly?', a: 'Fairness is at the core of our model. The bonus allocation is not based on opinion or office politics. It is determined by a proprietary algorithm that analyses hundreds of data points, including your direct feedback, your project’s key performance metrics, and our own internal quality scores. This data-driven process ensures that the team members who make the most significant contribution to your success receive the largest reward.' }, { q: 'Does this cost me extra?', a: 'No. The Algorithmic Team Profit Sharing is a built-in feature of our partnership. The funds for the bonus pool come directly from a portion of your existing management fee. It is our way of putting “skin in the game” and ensuring our team is fully invested in your success at no additional cost to you.' } ] } },
  sales: {
    'Algorithmic Team Profit Sharing': {
      title: 'Algorithmic Team Profit Sharing',
      intro: 'We’re built on a partnership model where everyone in our team has skin in the game. At no extra cost to you, we algorithmically allocate a portion of your project fee across our team as performance-based quarterly bonuses, tied to the results we generate for you. Our FinTech platform links each team member’s work directly to your core KPIs and rewards them without unconscious bias. The better we deliver on your goals, the more we earn. This means you get a fully accountable, results-driven partnership that is laser-focused on your success.',
      proHead: 'Proactive Management with GorillaMatrix®',
      conHead: 'Reactive Management without GorillaMatrix®',
      rows: [
        { pro: 'More frequent testing of new outreach messaging and advanced prospecting sequences', why: 'A steady flow of experimentation helps reveal high-impact sales strategies faster. This ensures your sales development is not just an outreach function, but a powerful, data-driven engine for building a predictable revenue pipeline.', con: 'Fixed outreach cadences with minimal reactive changes', impact: 'Using the same messaging for too long can let stale outreach persist, slowing pipeline growth and giving competitors an edge.' },
        { pro: 'Faster pivot on pipeline health signals for optimal connect rates', why: 'Swiftly interpreting data on connect rates and domain health means our specialists can adjust targeting or technical setup before issues escalate. This protects your outreach effectiveness and safeguards a critical revenue pipeline.', con: 'Routine checks on outreach performance', impact: 'Delays in spotting poor connect rates or domain reputation issues could hamper pipeline generation more deeply.' },
        { pro: 'Agile messaging updates when prospect engagement metrics begin to slip', why: 'Rapidly refining outreach copy, value propositions, or calls to action helps retain prospect interest whenever engagement metrics begin to slip. This protects your pipeline value and prevents account fatigue.', con: 'Sporadic updates driven by periodic reviews', impact: 'Key messaging tweaks may come too late to re-engage prospects who have mentally tuned out or marked your outreach as irrelevant.' },
        { pro: 'Nimble adoption of newly emerging sales technologies', why: 'Quick integration of promising features, like advanced prospect intelligence or AI-powered cadence optimisation, keeps your sales engine at the cutting edge and demonstrates a commitment to leveraging new technology for a competitive advantage.', con: 'Gradual uptake of novel sales tools', impact: 'Slower adoption may let competitors establish stronger pipelines or achieve higher conversion rates.' },
        { pro: 'Proactive reallocation of roles to counter underperforming pipeline segments', why: 'Our platform algorithmically shifts responsibilities at the first sign of stagnation. This helps sustain results and avoids the pipeline decay that can erode the value of your target addressable market.', con: 'Reactively switching roles only after deep underperformance', impact: 'Late-stage corrections require more effort to rebuild lost momentum in pipeline velocity and sales targets.' },
        { pro: 'Cohort LTV modelling embedded in test design and outreach strategy.', why: 'Tests are planned based on lifetime value (LTV) and payback windows, not just on meetings booked. This improves the capital efficiency of your sales spend and focuses our efforts on long-term profitability.', con: 'Disconnected performance data and financial metrics', impact: 'Without a direct link between sales performance and financial actuals, it is difficult to prove the true ROI of your sales development efforts. This makes it harder to justify the budget and can lead to a focus on vanity metrics, such as meetings booked, rather than long-term customer value.' },
        { pro: 'Board-ready performance reporting with defensible attribution', why: 'Our platform reconciles performance data with your finance system’s actuals. This produces audit-ready numbers that give your leadership team and investors a clear, defensible view of your marketing ROI.', con: 'Lack of a formal attribution model in reporting', impact: 'Standard sales reporting often relies on last-touch attribution, which fails to capture the full impact of outreach on the customer journey. This can lead to an underestimation of the channel’s value and missed opportunities to nurture high-value accounts.' }
      ],
      closing: 'Sharing the risk gives our specialists the extra drive to incorporate fresh tactics and encourage the rapid experimentation needed to increase customer lifetime value. This ensures you always get agile improvements that keep your target accounts interested and engaged.',
      link: { text: 'Learn more about our Algorithmic Team Profit Sharing', url: 'https://www.gogorilla.com/fintech-platform/algorithmic-team-profit-sharing' },
      faqs: [
        { q: 'How do I know the bonus is allocated fairly?', a: 'Fairness is at the core of our model. The bonus allocation is not based on opinion or office politics. It is determined by a proprietary algorithm that analyses hundreds of data points, including your direct feedback, your project’s key performance metrics, and our own internal quality scores. This data-driven process ensures that the team members who make the most significant contribution to your success receive the largest reward.' },
        { q: 'Does this cost me extra?', a: 'No. The Algorithmic Team Profit Sharing is a built-in feature of our partnership. The funds for the bonus pool come directly from a portion of your existing management fee. It is our way of putting “skin in the game” and ensuring our team is fully invested in your success at no additional cost to you.' }
      ]
    },
    'Performance Recognition Tips': {
      title: 'Performance Recognition Tips',
      intro: 'This optional incentive was created by popular demand from partners who were so impressed with our team’s performance that they asked for a direct way to reward them. It works in four simple steps.',
      steps: [
        { h: 'Tell us your KPIs', b: 'Every engagement begins by defining the key performance indicators (KPIs) that matter most to your success. This ensures our team is aligned with your objectives from day one, creating a data-driven foundation for performance.' },
        { h: 'Watch our team go above and beyond', b: 'Our built-in profit sharing model ensures our team is already financially incentivised to deliver results. The possibility of a personal tip, however, drives our experts to push even further.' },
        { h: 'Send optional tips to those who deserve it most', b: 'When a contribution stands out, you have the flexibility to send a personal reward. Whether for a single standout individual or the key members who made a difference, you can direct your appreciation precisely where it is deserved. As a guide, many clients select an amount between 5 and 20% of their monthly management fee.' },
        { h: 'Automated payment and allocation', b: 'You have two simple options. You can complete a secure one-off card payment for immediate allocation, or add the tip as a pass-through line item on your next invoice.' }
      ],
      stepsAgency: [
        { h: 'Tell us your client’s KPIs', b: 'As part of your client’s onboarding process, you will define the key performance indicators (KPIs) that are most important to their success. This gives our team a clear definition of success to strive for and exceed.' },
        { h: 'Watch our team go above and beyond', b: 'Our built-in profit sharing model ensures our team is already financially incentivised to deliver results. The possibility of a personal tip, however, drives our experts to push even further.' },
        { h: 'Send optional tips to those who deserve it most', b: 'When a contribution stands out, you have the flexibility to send a personal reward. Whether for a single standout individual or the key members who made a difference, you can direct your appreciation precisely where it is deserved. As a guide, many agencies select an amount between 5 and 20% of the project’s monthly management fee, but the amount is entirely up to you.' },
        { h: 'Automated payment and allocation', b: 'You have two simple options. You can complete a secure one-off card payment through our own gateways for immediate allocation, or add the tip as a pass-through line item on your next invoice.' }
      ],
      note: 'This feature is entirely optional and is designed to complement our built-in Algorithmic Team Profit Sharing model. It gives you a direct way to acknowledge and reward exceptional performance that goes above and beyond. GoGorilla.com takes no commission on any tips.',
      faqs: [
        { q: 'Does GoGorilla.com take any cut from my tip?', a: ['No. GoGorilla.com takes zero commission or fees from your tip. We are committed to complete transparency, which means every pound you choose to tip is passed directly to the team member(s) you want to reward. Your gesture of appreciation reaches its intended recipient in full.', { t: 'For any questions regarding our tip allocation process or further details, please ', link: { text: 'get in touch with us', url: 'https://www.gogorilla.com/contact-us' }, post: '.' }] },
        { q: 'Is tipping mandatory?', a: ['Not at all. Tipping is entirely optional and is simply a way for you to show extra appreciation for outstanding service. Your decision to tip (or not) does not affect the quality or scope of service you receive in any way, and there is absolutely no expectation or requirement to provide a tip.', { t: 'For more details on our tipping policy, please ', link: { text: 'get in touch with us', url: 'https://www.gogorilla.com/contact-us' }, post: '.' }] },
        { q: 'Can I include a personal message with my tip?', a: 'Yes, and we strongly encourage it. Including a brief, personal message allows you to explain what you particularly appreciated about the service, whether it was an innovative strategy, exceptional responsiveness, or creative problem-solving. Your note is shared privately with the recipients and is not published externally. This personal touch is often the most meaningful part of the recognition for our team.' },
        { q: 'When and how will the team receive my tip?', a: 'You have the flexibility to authorise immediate allocation. For invoice pass-throughs, allocation happens immediately, with the amount appearing as a line item on your next invoice for reconciliation. For one-off card payments, allocation is also immediate. A detailed confirmation will arrive in your inbox within minutes.' },
        { q: 'What payment methods are accepted for tipping?', a: [{ t: 'You can tip through a secure one-off card payment or add the tip as a pass-through line item on your next invoice. Your payment details are fully protected. If you need an alternative arrangement, please ', link: { text: 'get in touch with us', url: 'https://www.gogorilla.com/contact-us' }, post: '.' }] }
      ]
    },
    'Outcome-Linked Success Bonus': {
      title: 'Outcome-Linked Success Bonus',
      showOn: ['founder', 'investor', 'agency'],
      intentAliases: { 'agency-proposal': 'agency-whitelabel' },
      intro: 'This optional add-on is designed to transform our relationship into a true, outcome-driven partnership. It allows us to move beyond a standard retainer and tie our fees directly to the tangible business results we generate. This de-risks your marketing investment and ensures our entire team is financially aligned with the metrics that matter most to you and your investors.',
      introInv: 'This optional add-on is designed to transform our relationship into a true, outcome-driven partnership. It allows us to move beyond a standard retainer and tie our fees directly to the tangible business results we generate for your portfolio company. This de-risks the investment and ensures our entire team is financially aligned with the metrics that matter most to you and your portfolio company.',
      tabs: [
        { label: 'How It Works', blocks: [
          { t: 'p', x: 'Our Outcome-Linked Success Bonus aligns our financial success directly with yours. This model allows us to share the risk and reward of your growth journey, creating a powerful win-win scenario.' },
          { t: 'p', x: 'We offer two ways for you to structure this partnership:' },
          { t: 'models', items: [
            { h: 'The Standard Partnership', b: 'In this model, we agree on a key outcome, and we take a fixed percentage of the value we help you create. If the target is not met, there is no bonus and no penalty. This is a low-risk, high-reward way to ensure we are both focused on the same goal.', ex: 'We agree to take a 1% share of all revenue growth. If your quarterly revenue grows by £100,000, our success fee is £1,000.' },
            { h: 'The Guaranteed Outcome', b: 'For companies that require maximum certainty, we can offer a guaranteed result. In exchange for this guarantee, we take a higher percentage of the success fee. If we fail to hit the guaranteed target, a portion of your management fee is credited back to you.', ex: 'We guarantee to book 50 qualified meetings in a quarter. In exchange, we take a higher fee of £150 per meeting booked.' }
          ] },
          { t: 'sub', x: 'Examples of Outcome-Based Models We Can Propose' },
          { t: 'p', x: 'Here are a few examples of how you can structure our partnership:' },
          { t: 'boxes', items: [
            { h: 'Revenue & Profit Share', b: 'We take a small percentage of the revenue or profit growth that we help generate over a set period (e.g. quarterly). This is a simple and powerful way to align our success directly with your financial performance.', best: 'eCommerce, established businesses, and any company focused on top-line or bottom-line growth' },
            { h: 'User & KPI-Based Fees', b: 'We agree on a fixed fee for achieving a specific, non-revenue key performance indicator (KPI). This is ideal for businesses that need to prove user traction or build a sales pipeline before focusing on monetisation.', best: 'Pre-revenue startups, consumer apps, and B2B companies where lead volume is the primary goal' },
            { h: 'Funding Round Success Fee', b: 'We align our success directly with your ultimate business goal: securing capital. We take a small percentage of the total amount raised in the next funding round that we help you achieve.', best: 'Any startup that is actively preparing for its next funding round (e.g. Seed, Series A)' }
          ] }
        ] },
        { label: 'How We Drive Valuation', blocks: [
          { t: 'p', x: 'As a founder or business leader, you don’t just value marketing activity. You value the increase in company valuation that marketing drives. Our model is designed to focus on the two primary paths to increasing this valuation:', xInv: 'As an investor, you don’t just value marketing activity. You value the increase in your portfolio company’s valuation that marketing drives. Our model is designed to focus on the two primary paths to increasing this valuation:' },
          { t: 'bullets', items: [
            { h: 'For Growth-Focused Companies', b: 'For businesses seeking their next round of funding, valuations are often based on a multiple of revenue and future growth potential. Our strategies are designed to hit the aggressive top-line metrics that lead to a higher valuation from VCs and other investors.' },
            { h: 'For Profit-Focused Companies', b: 'For businesses preparing for a sale, acquisition, or simply aiming for sustainable profitability, valuations are typically based on a multiple of profit (EBITDA). Our model incentivises us to focus on profitable growth, including advising on pricing strategy and breakeven ad spend to maximise the final outcome.' }
          ] }
        ] },
        { label: 'Qualification & Next Steps', blocks: [
          { t: 'p', x: 'To ensure this model is set up for success, this add-on is available for founders, business leaders, and leadership teams that meet the following criteria:', xInv: 'To ensure this model is set up for success, this add-on is available for investors and the portfolio companies they support that meet the following criteria:' },
          { t: 'checks', items: [
            'You must be on a minimum six-month commitment, which can be a six-month or twelve-month plan. This provides enough time for our strategies to generate measurable results.',
            'You must be using our Paid Advertising or Sales & Demand Generation services, as these have the most direct impact on the outcomes this model tracks.'
          ] },
          { t: 'p', x: 'If you meet these criteria, your Account Executive may propose an Outcome-Linked Success Bonus during your strategy sessions. They will work with you to define the terms and agree on a formal addendum to your existing agreement.' },
          { t: 'link', text: 'Learn more about our Outcome-Linked Success Bonus', url: 'https://www.gogorilla.com/fintech-platform/outcome-linked-success-bonus' }
        ] }
      ],
      faqs: [
        { q: 'What is the difference between the “Standard Partnership” and the “Guaranteed Outcome” models?', a: [
          'The Standard Partnership is our most common and collaborative model. It operates on a pure “win-win” basis:',
          { bullet: true, b: 'How It Works', t: 'We collaboratively agree on a specific, pre-agreed result (e.g. a growth target). If, at the end of the measurement period, we have successfully exceeded that target, we receive a pre-agreed percentage of the value created.' },
          { bullet: true, b: 'Key Rule', t: 'If the target is not met, there is absolutely no success fee and no penalty. We are only rewarded for delivering clear, measurable outperformance.' },
          { bullet: true, b: 'Best For', t: 'This model is ideal for most businesses. It fosters a low-risk, high-reward partnership where both parties are financially motivated to achieve ambitious growth, but without the pressure of a punitive downside.' },
          'The Guaranteed Outcome is a premium model designed for clients who require absolute certainty and have a non-negotiable target that must be achieved:',
          { bullet: true, b: 'How It Works', t: 'We contractually guarantee to deliver a specific, pre-agreed result (e.g. a minimum Return on Ad Spend or a specific number of new users).' },
          { bullet: true, b: 'Key Rule', t: 'In exchange for us taking on the full performance risk, we negotiate a higher percentage of the success fee if the target is met. If we fail to hit the guaranteed target, a significant portion of our management fee is credited back to you, providing you with financial protection.' },
          { bullet: true, b: 'Best For', t: 'This model is best suited for high-stakes, board-level objectives where achieving a specific KPI is mandatory, such as securing a funding round or meeting a crucial profitability target. It provides you with “investment insurance” for your most critical campaigns.' }
        ] },
        { q: 'Why should I consider an Outcome-Linked Success Bonus?', a: 'Starting a business is inherently risky. Industry data shows that up to 85% of startups fail within the first few years. Our standard services are designed to give you the best chance of success, but this bonus provides an extra layer of “investment insurance.” It ensures that our financial interests are perfectly aligned with yours, making us a true partner in your growth, not just a service provider.' },
        { q: 'Who sets the targets and KPIs for the success bonus?', a: 'The targets are set collaboratively. You will work directly with your dedicated Account Executive to define the specific KPIs, baselines, and growth targets for your business. We then formalise this in a simple addendum to your agreement before the tracking period begins.' },
        { q: 'How is “revenue growth” or “profit growth” measured and verified?', a: 'To ensure transparency, we use data directly from your financial or analytics platforms (e.g. Stripe, Shopify, Google Analytics). The specific source of truth is agreed upon before the engagement starts. We measure the uplift against a pre-agreed baseline from the previous period.' },
        { q: 'Can the Outcome-Linked Success Bonus be based on non-revenue goals?', a: [
          'Yes, absolutely. Whilst revenue and profit are the most common outcomes, the Outcome-Linked Success Bonus is designed to be flexible. We can structure the bonus around any high-value, measurable KPI that is critical to your business. This is particularly useful for early-stage or pre-revenue companies.',
          { bullet: true, t: 'For a pre-revenue consumer app, the goal could be achieving a specific number of monthly active users (MAUs).' },
          { bullet: true, t: 'For a B2B SaaS company, the goal could be generating a certain number of qualified leads or product demos.' },
          'The principle is always the same: we work with you to identify the single most important metric that will drive your business forward, and we align our success directly to that.'
        ] },
        { q: 'Does this replace the standard Algorithmic Team Profit Sharing model?', a: 'No, it complements it. This Outcome-Linked Success Bonus is an optional layer that aligns our high-level commercial success with yours. It works in conjunction with our standard Algorithmic Team Profit Sharing, which is our internal system for ensuring our team is consistently motivated to deliver high-quality work. Using both creates the ultimate performance-driven partnership.' }
      ],
      intentVariants: {
        'agency-whitelabel': {
          title: 'Outcome-Linked Success Bonus',
          intro: 'This optional add-on is designed to transform your client relationships from a service agreement into a true, outcome-driven partnership. It allows you to move beyond standard retainers and tie your fees directly to the tangible results you generate, creating a powerful new revenue stream for your agency and de-risking the investment for your clients. Instead of setting complex quarterly goals, you can offer your clients a success fee based on the metrics that matter most to them and their investors.',
          tabs: [
            { label: 'How It Works', blocks: [
              { t: 'p', x: 'Our Outcome-Linked Success Bonus aligns our financial success directly with your client’s. This model allows you to offer your clients a powerful form of “investment insurance”, ensuring that their marketing spend is a direct driver of profitable growth and increased company valuation.' },
              { t: 'p', x: 'We offer two ways for you to structure this with your clients:' },
              { t: 'models', items: [
                { h: 'The Standard Partnership', b: 'In this model, you and your client agree on a key outcome, and you take a fixed percentage of the value we help you create. If the target is not met, there is no bonus and no penalty. This is a low-risk, high-reward way to create a powerful win-win scenario.', ex: 'You agree to take 1% of all revenue growth for an e-commerce client. If their quarterly revenue grows by £100,000, your agency earns a £1,000 success bonus. This bonus is then shared between your agency and GoGorilla.com based on our agreed terms.' },
                { h: 'The Guaranteed Outcome', b: 'For clients who require maximum certainty, you can offer a guaranteed result. In exchange for this guarantee, you can negotiate a higher percentage of the success fee. If we fail to hit the guaranteed target, a portion of our management fee is credited back to your client, which you can pass on to them.', ex: 'You guarantee to increase a SaaS client’s user base by 2,000 new users. In exchange, you negotiate a higher fee of £10 per user. This higher success bonus is then shared between your agency and GoGorilla.com based on our agreed terms.' }
              ] },
              { t: 'sub', x: 'Examples of Outcome-Based Models You Can Offer' },
              { t: 'p', x: 'Here are a few examples of how you can structure your proposal:' },
              { t: 'boxes', items: [
                { h: 'Revenue & Profit Share', b: 'You propose to take a small percentage of the revenue or profit growth that you help generate over a set period (e.g. quarterly). This is a simple and powerful way to align your success directly with their financial performance.', best: 'eCommerce, established businesses, and any company focused on top-line or bottom-line growth', ex: '“In addition to our management fee, we will take a 1% share of all quarterly revenue growth above your current baseline.”' },
                { h: 'User & KPI-Based Fees', b: 'You agree on a fixed fee for achieving a specific, non-revenue key performance indicator (KPI). This is ideal for businesses that need to prove user traction or build a sales pipeline before focusing on monetisation.', best: 'Pre-revenue startups, consumer apps, and B2B companies where lead volume is the primary goal', ex: '“We will charge a fixed fee of £50 for every qualified sales lead we generate,” or “We will receive a £5,000 bonus for every 10,000 new active users we acquire.”' },
                { h: 'Funding Round Success Fee', b: 'You align your success directly with their ultimate business goal: securing capital. You propose to take a small percentage of the total amount raised in the next funding round that you help them achieve.', best: 'Any startup that is actively preparing for its next funding round (e.g. Seed, Series A)', ex: '“For helping you hit the growth metrics required to secure your next round, we will take a 1% success fee from the total capital raised.”' }
              ] }
            ] },
            { label: 'How We Drive Valuation', blocks: [
              { t: 'p', x: 'Business owners, founders, and investors often overlook the value of marketing activities. They value the increase in company valuation that marketing drives. Our model is designed to focus on the two primary paths to increasing this valuation:' },
              { t: 'bullets', items: [
                { h: 'For Growth-Focused Companies', b: 'For businesses seeking their next round of funding, valuations are often based on a multiple of revenue and future growth potential. Our strategies are designed to hit the aggressive top-line metrics that lead to a higher valuation from VCs and other investors.' },
                { h: 'For Profit-Focused Companies', b: 'For businesses preparing for a sale, acquisition, or simply aiming for sustainable profitability, valuations are typically based on a multiple of profit (EBITDA). Our model incentivises us to focus on profitable growth, including advising on pricing strategy and breakeven ad spend to maximise the final outcome.' }
              ] }
            ] },
            { label: 'Qualification & Next Steps', blocks: [
              { t: 'p', x: 'To ensure this model is set up for success, this add-on is available for client projects that meet the following criteria:' },
              { t: 'checks', items: [
                'Your client must be on a minimum six-month commitment, which can be a six-month or twelve-month plan. This provides enough time for our strategies to generate measurable results.',
                'Your client must be using our Paid Advertising or Sales & Demand Generation services, as these have the most direct impact on the outcomes this model tracks.'
              ] },
              { t: 'p', x: 'If your client meets these criteria and you would like to propose an Outcome-Linked Success Bonus, the next step is to discuss it with your dedicated Account Executive. They will work with you to structure the agreement, define the terms, and prepare a formal proposal that you can present to your client.' },
              { t: 'link', text: 'Learn more about our Outcome-Linked Success Bonus', url: 'https://www.gogorilla.com/fintech-platform/outcome-linked-success-bonus' }
            ] }
          ],
          faqs: [
            { q: 'Can I offer the Outcome-Linked Success Bonus to some of my clients but not others?', a: 'Yes, of course. The Outcome-Linked Success Bonus is a powerful tool in your sales toolkit, but it is not mandatory. You have complete flexibility to offer it to the clients you believe it is best suited for (typically those with sophisticated, board-level goals where a performance-based model is most compelling). For other clients, you can continue to use a standard retainer model. Our partnership is designed to give you the flexibility to structure your client relationships in the way that makes the most sense for your agency.' },
            { q: 'How should I position the “Standard” vs. “Guaranteed” models to my clients?', a: [
              'The Standard Partnership is our most common and collaborative model:',
              { bullet: true, b: 'How It Works', t: 'Frame it as a pure “win-win” partnership. You can tell your client, “We are so confident in our ability to deliver results that we are willing to tie a portion of our fees directly to the growth we generate for you. If you win, we win. If you don’t, there is no extra cost to you.”' },
              { bullet: true, b: 'Best For', t: 'This is the ideal model for most client engagements. It demonstrates confidence and alignment without introducing the complexity of a formal guarantee, making it a very compelling and low-friction offer.' },
              'The Guaranteed Outcome is a premium model designed for clients who require absolute certainty and have a non-negotiable target that must be achieved:',
              { bullet: true, b: 'How It Works', t: 'Frame it as “investment insurance” for their most critical goals. You can tell your client, “For this campaign, the primary objective is to guarantee [the specific result]. We will contractually guarantee this outcome. In exchange for us taking on 100% of the performance risk, we will take a higher share of the success fee.”' },
              { bullet: true, b: 'Best For', t: 'This model is perfect for sophisticated clients with a board of directors or investors to answer to. It is a powerful tool for closing high-value deals where the client’s primary concern is not cost, but the certainty of achieving a specific, non-negotiable outcome.' }
            ] },
            { q: 'Who sets the targets and KPIs for the success bonus?', a: 'The targets are set collaboratively. You will work directly with your dedicated Account Executive to define the specific KPIs, baselines, and growth targets for your client. We then formalise this in a simple addendum to your agreement before the tracking period begins.' },
            { q: 'How is “revenue growth” or “profit growth” measured and verified?', a: 'To ensure transparency, we typically use data directly from the client’s financial or analytics platforms (e.g. Stripe, Shopify, Google Analytics). The specific source of truth is agreed upon before the engagement starts. We measure the uplift against a pre-agreed baseline from the previous period.' },
            { q: 'What happens if my client cancels their contract before the end of the six-month measurement period?', a: 'The Outcome-Linked Success Bonus is contingent on completing the minimum six-month commitment, as this gives our strategies enough time to generate a measurable impact. If a client cancels their contract before this period is complete, the success bonus agreement for that period becomes void.' },
            { q: 'Can the Outcome-Linked Success Bonus be based on non-revenue goals?', a: [
              'Yes, absolutely. Whilst revenue and profit are the most common outcomes, the Outcome-Linked Success Bonus is designed to be flexible. You can work with your client to structure the bonus around any high-value, measurable KPI that is critical to their business. This is particularly useful for their early-stage or pre-revenue companies.',
              { bullet: true, t: 'For a pre-revenue consumer app, the goal could be achieving a specific number of monthly active users (MAUs).' },
              { bullet: true, t: 'For a B2B SaaS company, the goal could be generating a certain number of qualified leads or product demos.' },
              'The principle is always the same: you work with your client to identify the single most important metric that will drive their business forward, and we align our team’s success directly to that.'
            ] },
            { q: 'Who bills my end client for the success bonus?', a: 'You do. To ensure you maintain full control over your client relationship, the success bonus is included as a line item on your agency’s invoice to your client. You bill them for the full amount, and then we simply invoice you for GoGorilla.com’s pre-agreed share of that bonus. This keeps the financial relationship clean and simple, and ensures that from your client’s perspective, they are only dealing with you.' },
            { q: 'Does this replace the standard Algorithmic Team Profit Sharing model?', a: 'No, it complements it. This Outcome-Linked Success Bonus is an optional, client-facing layer that aligns our high-level commercial success with yours. It works in conjunction with our standard Algorithmic Team Profit Sharing, which is our internal system for ensuring our team is consistently motivated to deliver high-quality work. Using both creates the ultimate performance-driven partnership.' },
            { q: 'How is the success bonus shared between my agency and GoGorilla.com?', a: [
              'Our partnership model is designed to be simple and transparent, ensuring your agency is significantly rewarded for the value you help create. The success bonus generated from your client’s campaign is shared between your agency and GoGorilla.com based on a straightforward, pre-agreed ratio.',
              { b: 'Example', t: '' },
              { bullet: true, t: 'You offer a “Standard Partnership” to your e-commerce client, proposing to take a 1% share of all quarterly revenue growth.' },
              { bullet: true, t: 'The client agrees, and together we generate £100,000 in new revenue for them that quarter.' },
              { bullet: true, t: 'The total success bonus generated is £1,000 (1% of £100,000).' },
              { bullet: true, t: 'If you and your GoGorilla.com Account Executive agreed to a 50/50 split, your agency would receive £500 of that bonus, and GoGorilla.com would receive £500.' },
              'To ensure clarity and fairness for all parties, the partnership is governed by a few key principles:',
              { bullet: true, t: 'The specific split is always negotiable and is determined on a case-by-case basis with your Account Executive. It often depends on the level of involvement required from our team versus yours.' },
              { bullet: true, t: 'The bonus is always calculated on the final success fee after any of your client’s costs or our management fees have been accounted for, ensuring a clean and fair calculation.' },
              { bullet: true, t: 'All payments are reconciled and paid out at the end of the measurement period, once the final performance metrics have been verified and agreed upon by your client.' }
            ] }
          ]
        }
      }
    }
  }
};

const GM_TIPS_DEFAULT = {
  'Performance Recognition Tips': GM_TIPS_BY_SERVICE.sales['Performance Recognition Tips'],
  // 2026-06-10 (Loom 33): no longer aliased to the sales tip. SDG's tip now
  // describes the BUILT-IN cost-per-meeting model; every other service keeps
  // this standard optional-arrangement copy.
  'Outcome-Linked Success Bonus': {
    paras: [
      'An optional, mutually agreed performance model that ties a portion of our compensation to a single, high-priority business outcome, such as qualified meetings booked or pipeline value generated. It begins only after we have worked together for a few months, once we both know what good looks like.',
      'If the target is achieved, a success premium is added to your management fee. If it is not met, the base fee applies and an automatic discount is credited back to you. All calculations are handled transparently by GorillaMatrix.',
      'It can be combined with any GoGorilla.com service and is especially suited to Enterprise bonus models, including revenue or profit share. Please book a call for more details.'
    ],
    agencyWlExtra: ['For white-label partners, you take 40% off the outcome-linked bonus when we hit the agreed target.']
  }
};
const GM_OVERLAYS_DEFAULT = {
  'Performance Recognition Tips': GM_OVERLAYS_BY_SERVICE.sales['Performance Recognition Tips'],
  'Outcome-Linked Success Bonus': GM_OVERLAYS_BY_SERVICE.sales['Outcome-Linked Success Bonus']
};

function GMFaq({ n, q, a }) {
  const [open, setOpen] = uS(false);
  return (
    <div className={`gm-ov__faq ${open ? 'is-open' : ''}`}>
      <button type="button" className="gm-ov__faq-q" onClick={() => setOpen(o => !o)} aria-expanded={open}>
        <span className="gm-ov__faq-n">{String(n).padStart(2, '0')}</span>
        <span className="gm-ov__faq-qt">{q}</span>
        <span className="gm-ov__faq-toggle" aria-hidden="true">{open ? (
          <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="7" y1="12" x2="17" y2="12"/></svg>
        ) : (
          <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="7" y1="12" x2="17" y2="12"/><line x1="12" y1="7" x2="12" y2="17"/></svg>
        )}</span>
      </button>
      {open && (
        <div className="gm-ov__faq-a">
          {(() => {
            const items = Array.isArray(a) ? a : [a];
            const out = []; let grp = [];
            const flush = () => { if (grp.length) { out.push(<ul key={'u' + out.length} className="gm-ov__faq-ul">{grp}</ul>); grp = []; } };
            const inl = (para) => (<>{para.pre || ''}{para.b && <><strong>{para.b}{para.noColon ? '' : ':'}</strong>{para.noColon ? null : ' '}</>}{para.t}{para.link && <a href={para.link.url} target="_blank" rel="noopener noreferrer" className="gm-tip-portal__link">{para.link.text}</a>}{para.post || ''}</>);
            items.forEach((para, i) => {
              if (typeof para === 'object' && para.bullet) { grp.push(<li key={i}>{inl(para)}</li>); }
              else { flush(); out.push(typeof para === 'string' ? <p key={i} className="gm-ov__faq-p">{para}</p> : <p key={i} className="gm-ov__faq-p">{inl(para)}</p>); }
            });
            flush();
            return out;
          })()}
        </div>
      )}
    </div>
  );
}

const GM_GUARANTEED_EX = {
  'paid-ads': 'We guarantee a minimum 3x return on ad spend for the quarter. In exchange, we take a higher share of the revenue generated above that return.',
  'email': 'We guarantee a 20% uplift in revenue from your email programme this quarter. In exchange, we take a higher share of the extra revenue we generate.',
  'smm': 'We guarantee 10,000 new engaged followers in a quarter. In exchange, we take a higher fee for every engaged follower above that figure.',
  'content': 'We guarantee 25 articles ranking on page one within the quarter. In exchange, we take a higher fee for each one that ranks.',
  'motion': 'We guarantee 12 finished, broadcast-ready animations in a quarter. In exchange, we take a higher fee tied to the conversion lift they drive.'
};
const GM_GUARANTEED_EX_WL = {
  'sales': 'You guarantee a client 50 qualified meetings in a quarter. In exchange, you take a higher fee of £150 per meeting booked. This bonus is then shared between your agency and GoGorilla.com based on our agreed terms.',
  'paid-ads': 'You guarantee a client a minimum 3x return on ad spend for the quarter. In exchange, you take a higher share of the revenue above that return. This bonus is then shared between your agency and GoGorilla.com based on our agreed terms.',
  'email': 'You guarantee a client a 20% uplift in email revenue for the quarter. In exchange, you take a higher share of the extra revenue. This bonus is then shared between your agency and GoGorilla.com based on our agreed terms.',
  'smm': 'You guarantee a client 10,000 new engaged followers in a quarter. In exchange, you take a higher fee for every engaged follower above that figure. This bonus is then shared between your agency and GoGorilla.com based on our agreed terms.',
  'content': 'You guarantee a client 25 articles ranking on page one within the quarter. In exchange, you take a higher fee for each one that ranks. This bonus is then shared between your agency and GoGorilla.com based on our agreed terms.',
  'motion': 'You guarantee a client 12 finished, broadcast-ready animations in a quarter. In exchange, you take a higher fee tied to the conversion lift they drive. This bonus is then shared between your agency and GoGorilla.com based on our agreed terms.'
};
// 2026-06-19 (Loom 52): referrer CTA inside the Refer tab of the savings
// overlay. Calculator side of the calculator<->portal handshake. On submit it
// calls window.ggCreateAffiliate(email), which posts to the portal's
// create-affiliate endpoint and returns a real share URL. The link is never
// generated in the browser, and a failed call shows an error rather than a
// fake success (the previous placeholder mocked a ?ref= link and always
// claimed dashboard access was sent).
function GMReferralCta({ crossSell, onGotoIntent } = {}) {
  const [phase, setPhase] = uS('idle'); // idle | sending | done | error
  const [email, setEmail] = uS('');
  const [link, setLink] = uS('');
  const [dashSent, setDashSent] = uS(false);
  const [copied, setCopied] = uS(false);
  const [err, setErr] = uS('');
  const _valid = (e) => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(e);
  // Resolve the portal create-affiliate response into a structured result.
  // Prefers the platform-api.js wrapper; falls back to a direct call with the
  // same handling if that script has not loaded. Never returns a fake link.
  const _create = async (em) => {
    if (typeof window.ggCreateAffiliate === 'function') {
      return await window.ggCreateAffiliate(em);
    }
    try {
      const base = (typeof window.PLATFORM_API_BASE === 'string' && window.PLATFORM_API_BASE)
        ? window.PLATFORM_API_BASE.replace(/\/+$/, '') : 'https://portal.gogorilla.com';
      const resp = await fetch(base + '/api/v1/public/referrals/create-affiliate', {
        method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: em }),
      });
      let json = null; try { json = await resp.json(); } catch (_) { /* keep null */ }
      const data = (json && json.data && typeof json.data === 'object') ? json.data : null;
      if (resp.ok && data && data.shareUrl && !(json && json.error)) {
        return { ok: true, shareUrl: String(data.shareUrl), code: data.code ? String(data.code) : '', dashboardEmailSent: !!data.dashboardEmailSent };
      }
      let ecode = '';
      if (json && json.error) { ecode = String((typeof json.error === 'object' ? (json.error.code || json.error.type) : json.error) || '').toUpperCase(); }
      let kind = 'error';
      if (resp.status === 400 || ecode === 'VALIDATION_ERROR') kind = 'validation';
      else if (resp.status === 429) kind = 'rate_limit';
      else if (resp.status === 503 || ecode === 'NOT_ENABLED') kind = 'not_enabled';
      return { ok: false, kind: kind };
    } catch (e) { return { ok: false, kind: 'error' }; }
  };
  const _submit = async () => {
    const em = (email || '').trim();
    if (!_valid(em)) { setErr('Please enter a valid email address.'); setPhase('idle'); return; }
    setErr(''); setPhase('sending');
    let res;
    try { res = await _create(em); } catch (e) { res = { ok: false, kind: 'error' }; }
    if (res && res.ok && res.shareUrl) {
      setLink(String(res.shareUrl)); setDashSent(!!res.dashboardEmailSent); setPhase('done'); return;
    }
    const kind = (res && res.kind) || 'error';
    let msg = 'Something went wrong, please try again.';
    if (kind === 'validation') msg = 'Please enter a valid email address.';
    else if (kind === 'rate_limit') msg = 'You have requested this a few times. Please wait a little while, then try again.';
    else if (kind === 'not_enabled') msg = 'Referral links are not available right now. Please try again later.';
    setErr(msg); setPhase('error');
  };
  const _copy = () => { try { navigator.clipboard.writeText(link); setCopied(true); setTimeout(() => setCopied(false), 1800); } catch (e) {} };
  const wrap = { border: '1.5px solid #D6DEEE', background: 'rgba(255, 255, 255, 0.55)', borderRadius: '12px', padding: '0.95rem 1rem', marginTop: '0.65rem' };
  const inp = { width: '100%', boxSizing: 'border-box', padding: '0.65rem 0.8rem', borderRadius: '10px', border: '1.5px solid #D6DEEE', fontSize: '0.92rem' };
  if (phase === 'done') {
    return (
      <div className="fl-create-link thin-glass-frame" style={wrap}>
        <div style={{ fontSize: '0.95rem', fontWeight: 800, color: '#002abf', marginBottom: '0.3rem' }}>Your referral link is ready</div>
        <p style={{ fontSize: '0.86rem', color: '#475A86', margin: '0 0 0.6rem', lineHeight: 1.45 }}>Share this link to start earning.{dashSent ? ' We have also sent dashboard access to ' + email + ' so you can track every referral.' : ''}</p>
        <div style={{ display: 'flex', gap: '0.5rem', alignItems: 'stretch', flexWrap: 'wrap' }}>
          <input type="text" readOnly value={link} onFocus={(e) => e.target.select()} style={{ ...inp, flex: '1 1 220px', fontWeight: 600, color: '#0F1C35' }} />
          <button type="button" className="btn btn--primary" onClick={_copy} style={{ whiteSpace: 'nowrap', justifyContent: 'center' }}>{copied ? 'Copied' : 'Copy link'}</button>
        </div>
      </div>
    );
  }
  return (
    <div className="fl-create-link thin-glass-frame" style={wrap}>
      <div style={{ fontSize: '0.95rem', fontWeight: 800, color: '#002abf', marginBottom: '0.3rem' }}>Create your referral link</div>
      <p style={{ fontSize: '0.86rem', color: '#475A86', margin: '0 0 0.6rem', lineHeight: 1.45 }}>Enter your email and we will create your link and send dashboard access, so you can start referring right away.</p>
      <div style={{ display: 'flex', gap: '0.5rem', alignItems: 'stretch', flexWrap: 'wrap' }}>
        <input type="email" value={email} placeholder="you@company.com" disabled={phase === 'sending'} onChange={(e) => { setEmail(e.target.value); if (err) setErr(''); }} onKeyDown={(e) => { if (e.key === 'Enter') _submit(); }} style={{ ...inp, flex: '1 1 200px' }} />
        <button type="button" className="btn btn--primary" onClick={_submit} disabled={phase === 'sending'} style={{ whiteSpace: 'nowrap', justifyContent: 'center' }}>{phase === 'sending' ? 'Creating your link' : 'Create my referral link'} <span className="btn__arrow">›</span></button>
      </div>
      {err ? <div style={{ color: '#B42318', fontSize: '0.82rem', marginTop: '0.45rem' }}>{err}</div> : null}
    </div>
  );
}

function GMOverlayModal({ data: rawData, onClose, clientTypeId, intentId, serviceId, initialTabLabel, crossSell, onGotoIntent, onSwitchToReferring }) {
  const [tab, setTab] = uS(() => {
    try {
      if (initialTabLabel && Array.isArray(rawData.tabs)) {
        const _ix = rawData.tabs.findIndex((t) => t && t.label === initialTabLabel);
        if (_ix >= 0) return _ix;
      }
    } catch (e) {}
    return 0;
  });
  const vKey = (rawData.intentAliases && rawData.intentAliases[intentId]) || intentId;
  const _dataRaw = (rawData.intentVariants && vKey && rawData.intentVariants[vKey]) ? rawData.intentVariants[vKey] : rawData;
  const isVariant = _dataRaw !== rawData;
  const data = (typeof window !== 'undefined' && window.isFreelancerMode && window.isFreelancerMode() && window.flAgencyDeep) ? window.flAgencyDeep(_dataRaw) : _dataRaw;
  const steps = (clientTypeId === 'agency' && intentId !== 'agency-own' && data.stepsAgency) ? data.stepsAgency : data.steps;
  const isInvestor = clientTypeId === 'investor';
  const _referTabIx = (data.tabs && Array.isArray(data.tabs)) ? data.tabs.findIndex((t) => t && t.label === 'Refer a business') : -1;
  const renderBlock = (block, i) => {
    switch (block.t) {
      case 'p': return <p key={i} className="gm-ov__p">{(isInvestor && block.xInv) ? block.xInv : block.x}</p>;
      // 2026-06-12: simple comparison table (head: column labels, rows:
      // [row label, cell, cell]). First cell of each row renders as the
      // row header. Built for the Landing Pages overlay, reusable anywhere.
      case 'table': return (
        <div key={i} className="gm-ov__table-wrap">
          <table className="gm-ov__table">
            {Array.isArray(block.head) && (
              <thead><tr>{block.head.map((h, j) => <th key={j}>{h}</th>)}</tr></thead>
            )}
            <tbody>
              {(block.rows || []).map((r, j) => (
                <tr key={j}>{r.map((c, k) => k === 0 ? <th key={k} scope="row">{c}</th> : <td key={k}>{c}</td>)}</tr>
              ))}
            </tbody>
          </table>
        </div>
      );
      case 'sub': return <h3 key={i} className="gm-ov__subhead">{block.x}</h3>;
      case 'models': return <div key={i}>{block.items.map((m, j) => { let ex = m.ex; if (serviceId && /Guaranteed/.test(m.h)) { if (isVariant && GM_GUARANTEED_EX_WL[serviceId]) ex = GM_GUARANTEED_EX_WL[serviceId]; else if (!isVariant && GM_GUARANTEED_EX[serviceId]) ex = GM_GUARANTEED_EX[serviceId]; } return (<div className="gm-ov__model" key={j}><div className="gm-ov__model-h">{(j + 1)}. {m.h}</div><p className="gm-ov__model-b">{m.b}</p><div className="gm-ov__ex"><strong>Example:</strong> {(typeof window !== 'undefined' && window.isFreelancerMode && window.isFreelancerMode() && window.flAgencyStr) ? window.flAgencyStr(ex) : ex}</div></div>); })}</div>;
      case 'boxes': return <div key={i}>{block.items.map((bx, j) => (<div className="gm-ov__box" key={j}><div className="gm-ov__box-h">{bx.h}</div><p className="gm-ov__box-b">{bx.b}</p><div className="gm-ov__box-best"><strong>Best For:</strong> {bx.best}</div>{bx.ex && <div className="gm-ov__box-ex"><strong>Example:</strong> {bx.ex}</div>}</div>))}</div>;
      case 'bullets': return <ul key={i} className="gm-ov__bullets">{block.items.map((it, j) => (<li key={j}><strong>{it.h}:</strong> {it.b}</li>))}</ul>;
      case 'checks': return <ul key={i} className="gm-ov__checks">{block.items.map((x, j) => (<li key={j}><svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="6.5"/><path d="M5 8.4 L7 10.2 L11 6"/></svg><span>{x}</span></li>))}</ul>;
      case 'link': return <p key={i} className="gm-ov__closing"><a href={block.url} target="_blank" rel="noopener noreferrer" className={"gm-tip-portal__link" + (block.cls ? ' ' + block.cls : '')}>{block.text}<span className="gm-tip-portal__newtab" aria-hidden="true">{'\u00a0\u2197'}</span></a></p>;
      case 'hr': return <hr key={i} className="gm-ov__hr" />;
      case 'subtabs': return <GMSubTabs key={i} items={block.items} renderBlock={renderBlock} />;
      case 'refcta': return <GMReferralCta key={i} crossSell={crossSell} onGotoIntent={onGotoIntent} />;
      case 'refestimator': return <FreelancerReferralEstimator key={i} />;
      case 'copytpl': return <GMCopyBlock key={i} title={block.title} body={block.body} note={block.note} badge={block.badge} />;
      default: return null;
    }
  };
  React.useEffect(() => {
    if (window.ggCloseAllTips) window.ggCloseAllTips();
    const onKey = (e) => { if (e.key === 'Escape') onClose(); };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [onClose]);
  return ReactDOM.createPortal(
    <div className={"cmp-modal gm-ov" + (data.wide ? " gm-ov--wide" : "")} role="dialog" aria-modal="true" aria-label={data.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">
          <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>
        <div className="cmp-modal__title-row" style={{ paddingRight: '2.5rem' }}>
          <h2 className="cmp-modal__title" style={{ margin: 0 }}>{data.title}</h2>
        </div>
        <div className="cmp-modal__body gm-ov__body">
          <p className="gm-ov__intro">{(isInvestor && data.introInv) ? data.introInv : data.intro}</p>
          {data.tabs && (
            <>
              <div className="gm-ov__tabs" role="tablist">
                {data.tabs.map((t, i) => (
                  <button key={i} type="button" role="tab" aria-selected={tab === i} className={`gm-ov__tab ${tab === i ? 'is-active' : ''}`} onClick={() => setTab(i)}>{t.label}{t.badge ? <span className="channel-tile__rec-tag" style={{ marginBottom: 0, marginLeft: '5px', fontSize: '0.5rem', verticalAlign: 'middle', ...(tab === i ? { background: '#fff', color: '#B45309' } : {}) }}>{t.badge}</span> : null}</button>
                ))}
              </div>
              <div className="gm-ov__tabpanel">{((data.tabs[tab] && data.tabs[tab].blocks) || []).map(renderBlock)}</div>
            </>
          )}
          {data.rows && (
            <>
              <div className="gm-ov__cols-head">
                <span className="gm-ov__cols-head-pro">{data.proHead}</span>
                <span className="gm-ov__cols-head-con">{data.conHead}</span>
              </div>
              {data.rows.map((row, i) => (
                <div className="gm-ov__row" key={i}>
                  <div className="gm-ov__point">
                    <svg className="gm-ov__ic gm-ov__ic--pro" viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="6.5"/><path d="M5 8.4 L7 10.2 L11 6"/></svg>
                    <span>{row.pro}</span>
                  </div>
                  <div className="gm-ov__point">
                    <svg className="gm-ov__ic gm-ov__ic--con" viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="6.5"/><path d="M8 5 L8 8.6"/><circle cx="8" cy="11" r="0.55" fill="currentColor"/></svg>
                    <span>{row.con}</span>
                  </div>
                  <div className="gm-ov__subbox gm-ov__subbox--pro"><strong>Why it matters</strong>{row.why}</div>
                  <div className="gm-ov__subbox gm-ov__subbox--con"><strong>Potential impact</strong>{row.impact}</div>
                </div>
              ))}
            </>
          )}
          {steps && steps.map((st, i) => (
            <div className="gm-ov__step" key={i}>
              <h3 className="gm-ov__step-h">{(i + 1)}. {st.h}</h3>
              <p className="gm-ov__step-b">{st.b}</p>
            </div>
          ))}
          {data.note && (
            <div className="gm-ov__note"><strong>Note:</strong> {data.note}</div>
          )}
          {data.closing && <p className="gm-ov__closing">{data.closing}</p>}
          {data.link && (
            <p className="gm-ov__closing">
              <a href={data.link.url} target="_blank" rel="noopener noreferrer" className="gm-tip-portal__link">{data.link.text}<span className="gm-tip-portal__newtab" aria-hidden="true">{'\u00a0\u2197'}</span></a>
            </p>
          )}
          {(() => {
            const _tabFaqs = (data.tabs && data.tabs[tab] && data.tabs[tab].faqs) || [];
            const _allFaqs = _tabFaqs.concat(data.faqs || []);
            return _allFaqs.length > 0 ? (
            <div className="gm-ov__faqs">
              <h3 className="gm-ov__faqs-title">FAQs</h3>
              {_allFaqs.map((f, i) => <GMFaq key={i} n={i + 1} q={f.q} a={f.a} />)}
            </div>
          ) : null; })()}
        </div>
        {onSwitchToReferring && (
          <div className="gm-ov__footer" style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.6rem', padding: '0.85rem 0 0', marginTop: '0.3rem', borderTop: '1px solid rgba(15,28,53,0.08)' }}>
            <button type="button" className="btn btn--primary" onClick={onSwitchToReferring}>Switch to referring <span className="btn__arrow">{'\u203a'}</span></button>
          </div>
        )}
      </div>
    </div>,
    document.body
  );
}

// Role overlays (Loom 33 2:06): GMOverlayModal is reused by dedicated-flow.jsx,
// which loads BEFORE this file, so the modal is exposed on window and looked up
// lazily at render time over there.
window.GMOverlayModal = GMOverlayModal;

// Nested sub-tabs inside an overlay tab panel (broad roles only, e.g. the
// Full-Stack Marketer's Typical work tab). Pill style so the hierarchy reads
// clearly under the folder-style main tabs.
function GMSubTabs({ items, renderBlock }) {
  const [st, setSt] = uS(0);
  return (
    <div className="gm-ov__subtabs-wrap">
      <div className="gm-ov__subtabs" role="tablist">
        {items.map((s, i) => (
          <button key={i} type="button" role="tab" aria-selected={st === i} className={`gm-ov__subtab ${st === i ? 'is-active' : ''}`} onClick={() => setSt(i)}>{s.label}</button>
        ))}
      </div>
      <div className="gm-ov__subtab-panel">{((items[st] && items[st].blocks) || []).map(renderBlock)}</div>
    </div>
  );
}

function GMTipRich({ label, data, hasOverlay, onOpenOverlay }) {
  const [pos, setPos] = uS(null);
  const wrapRef = uR(null);
  const hideTimer = uR(null);
  React.useEffect(() => {
    if (!pos) return undefined;
    const _close = () => setPos(null);
    const _reg = (window.__ggTipClosers || (window.__ggTipClosers = new Set()));
    _reg.add(_close);
    return () => { _reg.delete(_close); };
  }, [pos]);
  const show = () => {
    clearTimeout(hideTimer.current);
    if (!wrapRef.current) return;
    const r = wrapRef.current.getBoundingClientRect();
    const vw = window.innerWidth || 1024;
    const halfTip = 165; const PAD = 12;
    const rawX = r.left + r.width / 2;
    const clampedX = Math.min(Math.max(rawX, halfTip + PAD), vw - halfTip - PAD);
    setPos({ x: clampedX, y: r.top, caretShift: rawX - clampedX });
  };
  const scheduleHide = () => { hideTimer.current = setTimeout(() => setPos(null), 220); };
  const cancelHide = () => clearTimeout(hideTimer.current);
  return (
    <span ref={wrapRef} className="gm-tip-wrap" onMouseEnter={show} onMouseLeave={scheduleHide} onFocus={show} onBlur={scheduleHide}>
      <button type="button" className="info-tip-icon" aria-label={label} 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>
      {pos && ReactDOM.createPortal(
        <span className="info-tip-portal info-tip-portal--above gm-tip-portal" role="tooltip" style={{ left: pos.x, top: pos.y, '--info-tip-caret-shift': `${pos.caretShift}px` }} onMouseEnter={cancelHide} onMouseLeave={scheduleHide}>
          <span className="info-tip-portal__head">{label}</span>
          {data.paras.map((para, i) => <span key={i} className="gm-tip-portal__p">{para}</span>)}
          {data.link && (
            <span className="gm-tip-portal__p gm-tip-portal__more">
              {data.link.pre}{' '}
              <a href={data.link.url} target="_blank" rel="noopener noreferrer" className="gm-tip-portal__link" onClick={(e) => e.stopPropagation()}>{data.link.text}<span className="gm-tip-portal__newtab" aria-hidden="true">{'\u00a0\u2197'}</span></a>
            </span>
          )}
          {hasOverlay && (
            <button type="button" className="tip-overlay-link" onClick={(e) => { e.preventDefault(); e.stopPropagation(); if (onOpenOverlay) onOpenOverlay(); }}>Learn more</button>
          )}
        </span>,
        document.body
      )}
    </span>
  );
}

function GorillaMatrix({ incentives, serviceId, intentId, clientTypeId }) {
  if (!incentives || incentives.length === 0) return null;
  const perSvc = GM_TIPS_BY_SERVICE[serviceId] || {};
  const perSvcOv = GM_OVERLAYS_BY_SERVICE[serviceId] || {};
  const [openOv, setOpenOv] = uS(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) => {
          const rich = perSvc[item.label] || GM_TIPS_DEFAULT[item.label];
          let ov = perSvcOv[item.label] || GM_OVERLAYS_DEFAULT[item.label];
          if (ov && ov.showOn && !ov.showOn.includes(clientTypeId)) ov = null;
          const tip = (rich && rich.agencyWlExtra && intentId === 'agency-whitelabel') ? { ...rich, paras: rich.paras.concat(rich.agencyWlExtra) } : rich;
          return (
          <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>
            {rich ? (
              <GMTipRich label={item.label} data={tip} hasOverlay={!!ov} onOpenOverlay={() => setOpenOv(ov)} />
            ) : GM_INCENTIVE_TIPS[item.label] ? (
              <InfoTip head={item.label} body={GM_INCENTIVE_TIPS[item.label]} placement="above" />
            ) : null}
          </span>
          );
        })}
      </div>
      {openOv && <GMOverlayModal data={openOv} onClose={() => setOpenOv(null)} clientTypeId={clientTypeId} intentId={intentId} serviceId={serviceId} />}
    </div>
  );
}

// ── LEAD SOURCING tip + overlay (Loom 32, 0:57–1:48) ────────────────────
// "We source the leads" gets the GorillaMatrix incentive treatment: a grey
// info icon that turns blue on hover (clickable), a hover tooltip summarising
// where leads come from, and a tabbed GMOverlayModal listing every sourcing
// category. All sources are included in the standard lead price; only a
// custom scrape of a new source is quoted separately.
const LEAD_SOURCING_TIP = {
  paras: [
    'Your leads are sourced and GDPR-validated across 300+ databases in six categories, from social and professional networks to review platforms, business registries, marketplaces, and local directories, all included in your standard lead price.',
    'Need a source we don’t already cover, like a trade show exhibitor list? We’ll build a bespoke scraper for it, quoted at onboarding.',
  ],
};

const LEAD_SOURCING_OVERLAY = {
  title: 'Where we source your leads',
  wide: true, // all six tabs on one line on desktop
  intro: 'Every lead is sourced and GDPR-validated across 300+ databases in six categories, all included in your standard lead price. Pick a tab to see what each category covers. If you need a source we don’t already have, we’ll build it for you.',
  tabs: [
    { label: 'Social & Professional', blocks: [
      { t: 'p', x: 'Prospects sourced from 15 social and professional networks, with profile, role, and engagement data. Included in your standard lead price.' },
      { t: 'checks', items: ['LinkedIn', 'X (Twitter)', 'TikTok', 'Reddit', 'Instagram', 'YouTube', 'Substack'] },
      { t: 'p', x: 'Each lead arrives with the profile, role, and engagement context behind it, so your cadence can reference what a prospect posts, follows, and engages with. Best for social-first and creator-led targeting, from B2B buyers active on LinkedIn to audiences gathered around creators.' },
    ]},
    { label: 'Review & Reputation', blocks: [
      { t: 'p', x: 'Prospects sourced from 7 review and reputation platforms, with reviewer, company, and rating data. Included in your standard lead price.' },
      { t: 'checks', items: ['Trustpilot', 'G2', 'Capterra', 'Glassdoor', 'Yelp'] },
      { t: 'p', x: 'Review activity is one of the strongest buying signals, since reviewers are mid-evaluation by definition. Effective for displacing competitors, reaching their unhappy customers whilst the frustration is fresh, and catching buyers already weighing up adjacent products.' },
    ]},
    { label: 'Directories & Registries', blocks: [
      { t: 'p', x: 'Prospects sourced from 10 business directories and registries, with full technographics. Included in your standard lead price.' },
      { t: 'checks', items: ['Crunchbase', 'BuiltWith', 'SimilarWeb', 'Wellfound', 'Indeed', 'GitHub'] },
      { t: 'p', x: 'Full technographics let you target by what a company runs and where it is heading, the stack it uses, its funding stage, web traffic, and headcount growth. Ideal for reaching companies that just raised, are hiring fast, or are running a competitor product you integrate with or replace.' },
    ]},
    { label: 'Marketplaces & Commerce', blocks: [
      { t: 'p', x: 'Prospects sourced from 10 e-commerce and marketplace platforms, with seller, category, and revenue-band data. Included in your standard lead price.' },
      { t: 'checks', items: ['Amazon', 'Shopify', 'Etsy', 'TikTok Shop', 'Fiverr', 'Upwork', 'Apple App Store and Google Play'] },
      { t: 'p', x: 'Seller, category, and revenue-band data shows who is actually trading and at what scale, so you can pitch sellers, app builders, and storefront owners with offers that match their volume. Ideal for DTC, e-commerce, and app targeting.' },
    ]},
    { label: 'Local & Hospitality', blocks: [
      { t: 'p', x: 'Prospects sourced from 6 local and geo-bounded databases. Included in your standard lead price.' },
      { t: 'checks', items: ['Google Business Profile', 'Google Maps', 'Zillow', 'Airbnb', 'Booking.com', 'Yellow Pages'] },
      { t: 'p', x: 'Local and geo-bounded data is built for territory-led campaigns. Target by city, region, or radius, from hospitality operators on booking platforms to property and local-service businesses. Popular with local-service, property-tech, and hospitality targeting.' },
    ]},
    { label: 'Custom & Bespoke', blocks: [
      { t: 'p', x: 'Need a source we don’t already cover? Nominate it and we’ll build a GDPR-compliant scraper for it. Typical requests look like these.' },
      { t: 'checks', items: ['Trade show and exhibitor directories', 'Industry association registers', 'Niche and sector-specific job boards', 'Specialist marketplaces and communities'] },
      { t: 'p', x: '£399 setup plus £4.50 per prospect, dropping to £3.50 and £2.75 at volume, with a 200-prospect minimum. The setup covers scoping, build, compliance checks, and a sample for sign-off, so you approve the quality before the full run. First records arrive in your sequence in 5 to 10 working days.' },
    ]},
  ],
  faqs: [
    { q: 'Do these sources cost extra?', a: 'No. Every source in these tabs is included in your standard lead price, whichever mix of categories your ideal customer profile calls for. The only paid exception is a custom scrape of a source we don’t already cover, which carries a £399 setup and per-prospect pricing, quoted and agreed at onboarding before anything is built.' },
    { q: 'How do you keep sourcing GDPR-compliant?', a: 'We collect business-relevant, publicly available professional data only, validate every record before it enters your cadence, and suppress anyone who has previously opted out. Custom scrapes go through the same compliance checks before the first record is delivered, and you can request removals at any time.' },
    { q: 'Can I choose where my leads come from?', a: 'Yes. Share your ideal customer profile on your strategy call and we’ll weight sourcing towards the categories that fit it best, such as review platforms for competitor switchers, or marketplaces for e-commerce sellers. You can rebalance the mix at any point as you learn which sources convert best for you.' },
    { q: 'What if I already have a list?', a: [
      { pre: 'Select ', b: 'You bring your own list', noColon: true, t: ', the option card right beside this one. You’ll see it as soon as you close this overlay.' },
      'We’ll enrich, verify, and run your list through the same cadence. It also unlocks our BYOL retainer discount, with the full 15% applying when your list includes verified mobile numbers, since reaching mobiles gets us through to more decision makers.',
    ] },
  ],
};

// Span-based variant of GMTipRich so it can sit inside the radio-card
// <button> without nesting buttons. Opens the overlay on click; hover shows
// the summary tooltip with the standard "Click the ⓘ icon" hint.
function LeadSourcingTip({ onOpenOverlay }) {
  const [pos, setPos] = uS(null);
  const wrapRef = uR(null);
  const hideTimer = uR(null);
  React.useEffect(() => {
    if (!pos) return undefined;
    const _close = () => setPos(null);
    const _reg = (window.__ggTipClosers || (window.__ggTipClosers = new Set()));
    _reg.add(_close);
    return () => { _reg.delete(_close); };
  }, [pos]);
  const show = () => {
    clearTimeout(hideTimer.current);
    if (!wrapRef.current) return;
    const r = wrapRef.current.getBoundingClientRect();
    const vw = window.innerWidth || 1024;
    const halfTip = 165; const PAD = 12;
    const rawX = r.left + r.width / 2;
    const clampedX = Math.min(Math.max(rawX, halfTip + PAD), vw - halfTip - PAD);
    setPos({ x: clampedX, y: r.top, caretShift: rawX - clampedX });
  };
  const scheduleHide = () => { hideTimer.current = setTimeout(() => setPos(null), 220); };
  const cancelHide = () => clearTimeout(hideTimer.current);
  const open = (e) => { e.preventDefault(); e.stopPropagation(); };
  return (
    <span ref={wrapRef} className="gm-tip-wrap" onMouseEnter={show} onMouseLeave={scheduleHide} onFocus={show} onBlur={scheduleHide}>
      <span role="button" tabIndex={0} className="info-tip-icon" aria-label="Where we source your leads"
        onClick={open}
        onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') open(e); }}>
        <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>
      </span>
      {pos && ReactDOM.createPortal(
        <span className="info-tip-portal info-tip-portal--above gm-tip-portal" role="tooltip" style={{ left: pos.x, top: pos.y, '--info-tip-caret-shift': `${pos.caretShift}px` }} onMouseEnter={cancelHide} onMouseLeave={scheduleHide}>
          <span className="info-tip-portal__head">Where we source your leads</span>
          {LEAD_SOURCING_TIP.paras.map((para, i) => <span key={i} className="gm-tip-portal__p">{para}</span>)}
          <button type="button" className="tip-overlay-link" onClick={(e) => { e.preventDefault(); e.stopPropagation(); if (onOpenOverlay) onOpenOverlay(); }}>Learn more</button>
        </span>,
        document.body
      )}
    </span>
  );
}

// ── 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).
// ── ANIMATED PRICE (Loom 33 1:03) ───────────────────────────────────────────
// Same 400ms eased count-up the sidebar total uses (window.useAnimatedValue),
// so prices animate when the commitment, tier, or a discount changes them
// instead of cutting straight to the new figure.
function AnimatedGBP({ value, prefix = '' }) {
  const v = window.useAnimatedValue(Math.round(value));
  return <>{prefix}{window.fmt(v)}</>;
}

// ── BREAKDOWN COMMITMENT PILL (Loom 32 5:04) ────────────────────────────────
// Small months pill on each Commitment row of the full breakdown. Opens a
// dropdown of the service's commit options so the term can be switched right
// from the overlay; totals and line prices recompute live. When a longer
// commitment is available, hovering the pill nudges towards it (the nudge
// hides while the menu is open so the tooltip never overlaps it).
function BdCommitPill({ opts, cid, onPick }) {
  const [open, setOpen] = uS(false);
  const wrapRef = uR(null);
  React.useEffect(() => {
    if (!open) return;
    const onDoc = (e) => { if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false); };
    document.addEventListener('mousedown', onDoc);
    return () => document.removeEventListener('mousedown', onDoc);
  }, [open]);
  const cur = opts.find(o => o.id === cid) || opts[opts.length - 1];
  const longest = opts[opts.length - 1];
  const showNudge = !open && cur.id !== longest.id;
  const pill = (
    <button
      type="button"
      className="bd-commit-pill"
      aria-haspopup="listbox"
      aria-expanded={open}
      aria-label={'Change commitment, currently ' + cur.months + ' months'}
      onClick={(e) => { e.stopPropagation(); setOpen(o => !o); }}
    >
      {cur.months} mo
      <svg className="bd-commit-pill__chev" viewBox="0 0 12 12" width="9" height="9" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="3 4.5 6 8 9 4.5"/></svg>
    </button>
  );
  return (
    <span ref={wrapRef} className="bd-commit-wrap" onClick={(e) => e.stopPropagation()}>
      {showNudge ? (
        <HoverPortalTip as="span" wrapClassName="bd-commit-tip-wrap" tipClassName="dis-tip dis-tip--above" placement="above"
          tip={<span>Consider the {longest.months}-month plan for the lowest monthly rate{longest.save ? <>, currently {longest.save}% off</> : null}.</span>}>
          {pill}
        </HoverPortalTip>
      ) : pill}
      {open && (
        <span className="bd-commit-menu" role="listbox" aria-label="Commitment length">
          {opts.map(o => (
            <button
              key={o.id}
              type="button"
              role="option"
              aria-selected={o.id === cur.id}
              className={`bd-commit-menu__opt ${o.id === cur.id ? 'is-on' : ''}`}
              onClick={(e) => { e.stopPropagation(); setOpen(false); if (o.id !== cur.id) onPick(o.id); }}
            >
              <span>{o.months} mo</span>
              {o.save > 0 && <span className="bd-commit-menu__save">−{o.save}%</span>}
            </button>
          ))}
        </span>
      )}
    </span>
  );
}

function MonthlyLeadVolume({ selection, tierId, onSetMode, onSetLeads, onSetByolQuality, marginSlot, isEnterprise, wlMult }) {
  const mode = (selection?.leadSourceMode === 'byol') ? 'byol' : '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(20000, selection?.monthlyLeads || tierIncluded));
  const { additional, cost } = (window.computeAdditionalLeadCost || (() => ({ additional: 0, cost: 0 })))(leads, tierId);
  const leadRate = (tierId === 'starter') ? 2 : 4;
  // 2026-06-18 (Loom 48): sending infrastructure included in the per-lead cost.
  // ~15 emails a day per inbox, so roughly one mailbox per 150 leads a month and
  // three mailboxes per domain (industry standard), rounded up.
  const mailboxes = Math.max(1, Math.ceil(leads / 150));
  const domains = Math.max(1, Math.ceil(mailboxes / 3));
  // 2026-06-17 (Loom 47 0:48, Nicole): on white-label show the agency's wholesale
  // per-lead rate (RRP x the agency multiplier) with the RRP beside it, so the
  // cost-per-lead box matches the rest of the white-label pricing and the total.
  const _wlLead = (typeof wlMult === 'number' && wlMult < 1);
  const _whRate = _wlLead ? leadRate * wlMult : leadRate;
  const _whCost = _wlLead ? Math.round(cost * wlMult) : cost;
  const _fmtRate = (r) => Number.isInteger(r) ? String(r) : r.toFixed(2);
  const _rrpStyle = { fontStyle: 'italic', color: 'var(--gg-blue, #002abf)', fontWeight: 400, marginLeft: '4px' };
  // Loom 6: the per-lead rate only "includes cold calling" when the Calling
  // channel is actually selected, so the tooltips mention it only then.
  const hasCalling = Array.isArray(selection?.channels) && (selection.channels.includes('calling') || selection.channels.includes('phone'));
  const fmtN = (n) => n.toLocaleString('en-GB');
  // 2026-06-03 (Loom 19 0:00): drive the slider thumb + value bubble from local
  // state so they track the drag instantly, without waiting for the global
  // pricing re-render. onChange still propagates to onSetLeads.
  const [dispLeads, setDispLeads] = React.useState(leads);
  React.useEffect(() => { setDispLeads(leads); }, [leads]);
  // 2026-06-22 (Loom 54): the lead volume is always included with the plan, so
  // the source can never be 'none'. Normalise any legacy 'none' cart to we-source.
  React.useEffect(() => { if (selection?.leadSourceMode === 'none') onSetMode('we-source'); }, [selection?.leadSourceMode]);
  // 2026-06-10 (Loom 32): sourcing-overlay state lives HERE, not inside the
  // tip wrap, so the modal is never a React child of the hover wrapper
  // (portal events would bubble into it and re-show the tooltip over the
  // dialog, blocking the tab row).
  const [srcOvOpen, setSrcOvOpen] = React.useState(false);

  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 lead-vol__tip-wide"
            placement="above"
            interactive
            tip={isEnterprise ? <span><span style={{display:'block'}}>Tell us the monthly lead volume you need. On Enterprise, this shapes your proposal, with volume and pricing scoped 1:1 with your team.</span><span style={{display:'block',marginTop:'0.55em'}}>The closer the volume is to your real need, the sharper your proposal will be.</span></span> : <span><span style={{display:'block'}}>{fmtN(tierIncluded)} leads/month included with your tier. Add more at a flat <strong>£{_fmtRate(_whRate)}/lead</strong>, a monthly increase in your cost per lead that covers sourcing, enriching, and verifying every lead.{hasCalling && <> It also covers our team cold calling every lead.</>}</span><span style={{display:'block',marginTop:'0.55em'}}>Additional leads are a recurring monthly charge, pay as you go, so you can scale up or down each month, and when campaigns are flying we handle the extra sending domains, SDRs, and calling capacity behind the scenes, all included in the per-lead cost.{tierId === 'starter' && <> Starter has a reduced <strong>£2/lead</strong> rate (email and LinkedIn only) to help you get started.</>}</span><span style={{display:'block',marginTop:'0.55em'}}>For a one-off batch instead at <strong>£4.50/lead</strong>, see the <button type="button" className="lead-vol__clb-link" onClick={(e) => { e.preventDefault(); e.stopPropagation(); if (window.ggScrollToAddon) window.ggScrollToAddon('premium-sourcing'); }}>Custom List Building</button> add-on below.</span><span style={{display:'block',marginTop:'0.55em'}}>If you add <button type="button" className="lead-vol__clb-link" onClick={(e) => { e.preventDefault(); e.stopPropagation(); if (window.ggScrollToAddon) window.ggScrollToAddon('gorilla-signals'); }}>Web Intent & Visitor De-anonymisation</button>, it works across this same volume, so increasing your leads also raises its monthly fee.</span></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(mode === 'none' ? tierIncluded : leads)} <span className="lead-vol__count-suffix">leads/month</span>
        </div>
      </div>

      {/* 2026-06-22 (Loom 54): the lead volume is always included with the plan,
          so the two source options act as radios (one is always selected) and can
          no longer be deselected. Custom List Building is a separate add-on layered
          on top, not an alternative. */}

      {/* Source-mode radio cards */}
      <div className="lead-vol__sources" role="group" aria-label="Lead source mode">
        <button
          type="button"
          role="checkbox"
          aria-checked={mode === 'we-source'}
          className={`lead-vol__src thin-glass-frame ${mode === 'we-source' ? 'is-on' : ''}`}
          onClick={() => { if (mode !== 'we-source') 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>}
              {/* 2026-06-10: icon sits after the DEFAULT badge per Nicole. */}
              <LeadSourcingTip onOpenOverlay={() => setSrcOvOpen(true)} />
            </div>
            <div className="lead-vol__src-desc">
              300+ third-party data providers plus our own custom-built tools, fully GDPR-compliant. Every lead is enriched and verified before it reaches your cadence.
            </div>
          </div>
        </button>

        <button
          type="button"
          role="checkbox"
          aria-checked={mode === 'byol'}
          className={`lead-vol__src thin-glass-frame ${mode === 'byol' ? 'is-on' : ''}`}
          onClick={() => { if (mode !== 'byol') onSetMode('byol'); }}
        >
          <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
              {!isEnterprise && <span className="lead-vol__src-badge lead-vol__src-badge--off">15% OFF</span>}
              {!isEnterprise && <span onClick={(e) => e.stopPropagation()} style={{display:'inline-flex',alignItems:'center',opacity:0.6,cursor:'default'}}><HoverPortalTip as="span" wrapClassName="lead-vol__byol-info" tipClassName="dis-tip dis-tip--above" placement="above" tip={<span className="dis-tip__body">The 15% discount applies when your list includes verified mobile numbers, since reaching mobiles gets us through to more decision makers.</span>}><window.InfoIcon as="span" /></HoverPortalTip></span>}
            </div>
            <div className="lead-vol__src-desc">
              Already have a verified list with mobile and email data? We enrich, dedupe, and run the full cadence on it.
            </div>
          </div>
        </button>
      </div>

      {mode !== 'byol' && isEnterprise && (
        <p className="lead-vol__hint">
          Set the monthly volume you have in mind and we'll size your engagement around it.
        </p>
      )}
      {mode !== 'byol' && !isEnterprise && (
        <p className="lead-vol__hint">
          Your tier includes <strong>{fmtN(tierIncluded)} leads/mo at no extra cost</strong>. Add more at <strong style={{ color: '#b45309' }}>£{_fmtRate(_whRate)}/lead</strong>{_wlLead && <span style={_rrpStyle}>(RRP £{leadRate}/lead)</span>}.
          <HoverPortalTip wrapClassName="lead-vol__hint-info-wrap" tipClassName="dis-tip dis-tip--above" placement="above" tip={<span>The £{_fmtRate(_whRate)}/lead rate covers sourcing, enriching, and verifying every lead.{hasCalling && <> It also covers our team cold calling every lead.</>}{_wlLead && <> This is your wholesale rate; your client pays the £{leadRate}/lead RRP.</>}</span>}>
            <button type="button" className="lead-vol__hint-info" onClick={(e) => e.stopPropagation()} aria-label="What the per-lead price includes" tabIndex={-1}><window.InfoIcon as="span"/></button>
          </HoverPortalTip>
        </p>
      )}

      {mode === 'we-source' && (
        <>
          <div className="lead-vol__slider-wrap">
            <div className="lead-vol__slider-bubble" aria-hidden="true" style={{ left: `calc(${((dispLeads - tierIncluded) / Math.max(1, 20000 - tierIncluded)) * 100}% + ${(0.5 - (dispLeads - tierIncluded) / Math.max(1, 20000 - tierIncluded)) * 20}px)` }}>{fmtN(dispLeads)}</div>
            <input
              type="range"
              min={tierIncluded}
              max="20000"
              step="100"
              value={dispLeads}
              onChange={(e) => { const v = Number(e.target.value); setDispLeads(v); onSetLeads(v); }}
              aria-label="Monthly lead volume"
              className="lead-vol__slider"
              style={{ '--fill-pct': `${((dispLeads - tierIncluded) / Math.max(1, 20000 - tierIncluded)) * 100}%`, '--fill-frac': ((dispLeads - tierIncluded) / Math.max(1, 20000 - tierIncluded)) }}
            />
            <div className="lead-vol__slider-ticks" aria-hidden="true">
              <span>{fmtN(tierIncluded)}</span>
              <span>{fmtN(Math.round((tierIncluded + 20000) / 2 / 100) * 100)}</span>
              <span>20,000</span>
            </div>
          </div>

          <div className="lead-vol__breakdown">
            {/* 2026-06-05: "Included with tier" row removed per Alexander —
                the included allowance is already stated above the slider. */}
            {/* 2026-06-18 (Loom 48): domains + mailboxes included, shown from the tier's
                included volume up, in a soft highlight box, with the formula in the tooltip. */}
            {!isEnterprise && (
              <div className="lead-vol__row lead-vol__incl-box" style={{ background: 'rgba(0,42,191,0.045)', border: '1px solid rgba(0,42,191,0.16)', borderRadius: '12px', padding: '9px 12px', marginBottom: additional > 0 ? '8px' : 0 }}>
                <span className="lead-vol__row-label" style={{ color: '#002ABF', fontWeight: 600 }}>{fmtN(domains)} secondary {domains === 1 ? 'domain' : 'domains'} + {fmtN(mailboxes)} {mailboxes === 1 ? 'mailbox' : 'mailboxes'} are included in the cost<HoverPortalTip wrapClassName="lead-vol__hint-info-wrap" tipClassName="dis-tip dis-tip--above" placement="above" tip={<><span className="dis-tip__body" style={{display:'block'}}>These are secondary sending domains, a close variant of your main one, such as acme.io or tryacme.com if your site is acme.com. We run your outbound from these rather than your primary domain, so your main domain's deliverability and reputation are never put at risk, and we warm them all up for you separately.</span><span className="dis-tip__body" style={{display:'block',marginTop:'0.55em'}}>It is all included in your lead cost. The domains and mailboxes are bought on a rolling quarterly basis, so additional lead volume runs on a three-month minimum, and as a guide we add roughly one mailbox for every 150 leads a month and one sending domain for every three mailboxes, so deliverability stays safe as you scale.</span></>}><button type="button" className="lead-vol__hint-info" onClick={(e) => e.stopPropagation()} aria-label="How the secondary domains and mailboxes are calculated" tabIndex={-1}><window.InfoIcon as="span"/></button></HoverPortalTip></span>
                {additional > 0 && <span className="lead-vol__row-val">+{fmtN(additional)} leads · <span style={{ color: '#b45309' }}>£{_fmtRate(_whRate)}/lead</span> · £{fmtN(_whCost)}/mo{_wlLead && <span style={_rrpStyle}>(RRP £{leadRate}/lead · £{fmtN(cost)}/mo)</span>}</span>}
              </div>
            )}
            {isEnterprise && 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 · scoped in your proposal</span>
              </div>
            )}
            {/* 2026-06-12 (Nicole): the amber deliverability note (added 2 Jun,
                38e52af) retires, the per-lead cost already covers the extra
                domains and the tooltip explains it. */}
            {/* 2026-06-10 (Loom 33): the recurring vs one-off note moved into the
                Monthly-lead-volume tooltip (with a clickable Custom List Building
                link) to give back the vertical space. */}
          </div>
        </>
      )}

      {/* 2026-06-01: BYOL explanatory notes removed per request; the "15% OFF" badge on the card conveys the discount. */}

      {/* 2026-06-03 (Loom 19 2:10): the White-label margin calculator now sits
          inside this box, below the lead volume, so resellers see the cost and
          their margin together. SDG only (the only service with a lead-vol box). */}
      {marginSlot && (
        <div className="lead-vol__margin-slot" style={{ marginTop: '1rem', paddingTop: '1rem', borderTop: '1px solid rgba(15, 23, 42, 0.08)' }}>
          {marginSlot}
        </div>
      )}
      {srcOvOpen && <GMOverlayModal data={LEAD_SOURCING_OVERLAY} onClose={() => setSrcOvOpen(false)} serviceId={'sales'} />}
    </div>
  );
}
window.MonthlyLeadVolume = MonthlyLeadVolume;

const TALENT_WL_OVERLAY = {
  title: 'How white-labelling talent works',
  intro: 'Offer your clients dedicated specialists under your own brand. You introduce it, we deliver everything behind the scenes, and you earn on every placement. White-labelling is available on the agency partnership only.',
  tabs: [
    {
      label: 'How it works',
      blocks: [
        { t: 'p', x: 'You stay the face of the relationship whilst our team handles the delivery. A typical placement runs like this.' },
        { t: 'bullets', items: [
          { h: 'You introduce it', b: 'You tell your client they can access dedicated specialists through you, whether that is an executive assistant, an AI implementation specialist, or another role they need.' },
          { h: 'We shortlist', b: 'We send you a short Loom introduction and a couple of vetted CVs. You forward them to your client as your own.' },
          { h: 'Your client decides', b: 'Your client books a call and confirms. They never have to see us unless you want them to.' },
          { h: 'We run it behind the scenes', b: 'Once they sign up, we handle compliance, onboarding, and the client contract, so the whole engagement runs under your brand.' },
        ] },
      ],
    },
    {
      label: 'Your economics',
      blocks: [
        { t: 'p', x: 'How you earn depends on whether the resource is full-time or part-time. The starting deposit is discounted either way.' },
        { t: 'table', head: ['', 'Full-time', 'Part-time'], rows: [
          ['Starting deposit', '40% off, at the wholesale rate', '40% off, at the wholesale rate'],
          ['Ongoing placement', 'Standard rate to your client', '40% reseller discount'],
          ['What you earn', '10% recurring commission for the minimum commitment', '40% reseller margin'],
          ['Talent buyout', 'You earn 10% of the buyout', 'You earn 10% of the buyout'],
        ] },
        { t: 'p', x: 'The exact figures are confirmed on the scoping call, and billing, including any VAT, is handled through our system.' },
      ],
    },
  ],
  faqs: [
    { q: 'What can I white-label?', a: 'You can resell our full dedicated talent offering under your own brand, both full-time and part-time specialists across roles such as executive assistants, AI implementation specialists, and operations support. White-labelling is available on the agency partnership only, so your clients always experience it as your service rather than ours.' },
    { q: 'How do I share the role details?', a: 'By email to start with. You send us the role and the must-haves, and for a full-time placement we will ask for a short job description so we can shortlist accurately. We will move this into a simple form later, but email keeps it quick to get the first roles moving.' },
    { q: 'Who holds the client contract?', a: 'We draft the contract for your client and handle compliance and onboarding behind the scenes. The agreement sits under your brand, so the relationship stays entirely yours whilst we do the heavy lifting on delivery.' },
    { q: 'What about the starting deposit?', a: 'You get the 40% reseller discount, the wholesale rate, on the starting deposit. On full-time placements, that discount on the deposit sits alongside the 10% recurring commission you earn once the placement proceeds, so you make margin upfront and an ongoing return. Part-time resources are on the 40% reseller discount throughout, including the deposit.' },
    { q: 'What happens if my client buys out a resource?', a: 'If your client decides to bring a dedicated resource fully in-house, you earn 10% of the buyout. We manage the transition and the paperwork, so it stays smooth for your client, and you are still rewarded for the introduction and the relationship you built.' },
    { q: 'How is VAT handled?', a: 'VAT is managed through our billing system and applied where required, so it appears clearly on every invoice. Your wholesale rate and the price you charge your client sit separately from VAT, which is added on top at the prevailing rate, and we handle the mechanics behind the scenes.' },
  ],
};

function ServiceBlock({ service, selection, pendingCommitId, onSelect, onTier, onSetCommit, onToggleAddon, onSetAddonQty, onToggleAddonRecurring, onSetStartWindow, onSetPtBilling, onToggleChannel, onSetAdSpend, onToggleRole, onSetRoleConfig, onSetDedicatedStep, onTogglePayUpfront, onSetLeadSourceMode, onSetMonthlyLeads, onSetByolListQuality, agyMult, addonsDefaultOpen, qualifier, intentId, clientTypeId, onSetLinkedinProfiles, onSetOverseasCountries, onSetMailOpt }) {
  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, selection?.channels, selection?.monthlyLeads).map(a => ({ ...a, _ctxCommitId: String(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) }
        : 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 && !service.oneTime && !service.fixedDuration) ? 0.9 : 1;
  const priceLabelFor = (tid) => {
    const p = window.priceFor(service, tid, commitId);
    if (p.custom) return p.label;
    if (p.free) return 'Free';
    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' ? 'Bespoke 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 [talentOv, setTalentOv] = 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`,
      // 2026-06-01: land the revealed section more centred (was pinned ~90px
      // from the top). Larger top offset = less scroll-down; viewport-relative
      // (~22% of the window height) and clamped so it works on any screen.
      { offset: Math.max(140, Math.min(240, Math.round((window.innerHeight || 800) * 0.22))) }
    );
  };

  return (
    <div
      className={`svc svc--always ${active ? 'svc--active' : ''} ${(service.id === 'dedicated-pt' || service.id === 'dedicated-ft') ? 'svc--dedicated' : ''} ${intentId === 'agency-whitelabel' ? 'svc--wl' : ''}`}
      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}
            {service.nameTip && window.HoverPortalTip && (<window.HoverPortalTip wrapClassName="svc__title-cmp-tip-wrap" tipClassName="dis-tip dis-tip--above" placement="above" tip={service.nameTip}><window.InfoIcon title="About these roles" onClick={(e) => e.stopPropagation()} /></window.HoverPortalTip>)}
            {(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. */}
{/* 2026-06-12 (Loom 41 review): the service-header waiting-list pill is
                retired, every waitlisted tier card now carries its own badge
                beside the plan name so the header signal was redundant. */}
          </div>
          <div className="svc__desc">{service.desc}</div>
        </div>
        <div className="svc__head-right">
          {/* 2026-06-18 (Loom): full-time dedicated talent on the white-label path
              earns a 10% recurring commission rather than a wholesale margin. */}
          {(service.id === 'dedicated-ft' || service.id === 'dedicated-pt') && intentId === 'agency-whitelabel' && (
            <div className="svc__talent-comm">
              <span className="svc__talent-comm-head">
                {service.id === 'dedicated-ft' ? (
                  <><span className="svc__talent-comm-badge">10% recurring commission</span>
                  <HoverPortalTip wrapClassName="svc__talent-comm-tip-wrap" tipClassName="dis-tip dis-tip--above" placement="above" tip={"On full-time dedicated talent you earn a 10% recurring commission for the duration of the minimum commitment, rather than a wholesale margin. The price shown is the standard rate your client pays, confirmed on the scoping call."}>
                    <window.InfoIcon className="svc__talent-comm-info" />
                  </HoverPortalTip></>
                ) : (
                  <><span className="svc__talent-comm-badge">40% reseller margin</span>
                  <HoverPortalTip wrapClassName="svc__talent-comm-tip-wrap" tipClassName="dis-tip dis-tip--above" placement="above" tip={"Part-time dedicated resources are offered at the standard 40% reseller discount, the same as our other services, so you keep the margin between the wholesale price and the price your client pays."}>
                    <window.InfoIcon className="svc__talent-comm-info" />
                  </HoverPortalTip></>
                )}
              </span>
              <button type="button" className="svc__talent-wl-link" onClick={(e) => { e.stopPropagation(); setTalentOv(true); }}><span className="svc__talent-wl-link__text">How white-labelling talent works</span> <span className="svc__talent-wl-link__arrow" aria-hidden="true">{'\u203a'}</span></button>
            </div>
          )}
          {(service.id === 'dedicated-ft' || service.id === 'dedicated-pt') && intentId === 'agency-whitelabel' && talentOv && window.GMOverlayModal && (
            <window.GMOverlayModal data={TALENT_WL_OVERLAY} onClose={() => setTalentOv(false)} clientTypeId={clientTypeId} intentId={intentId} serviceId={service.id} />
          )}
          {/* 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 && !service.fixedDuration) && (() => {
            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-wrap" style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '5px', marginTop: '-0.85rem' }}>
              <div className="svc__cat" style={{ display: 'inline-flex', alignItems: 'center', gap: '4px', margin: 0 }}>MINIMUM COMMITMENT PRICING<HoverPortalTip wrapClassName="svc__commit-eyebrow-tip" tipClassName="dis-tip dis-tip--above" placement="above" tip={<><span className="dis-tip__body" style={{display:'block'}}>Get up to 40% off by choosing the 12-month minimum commitment. We have different pricing for each minimum commitment period.</span><span className="dis-tip__body" style={{display:'block',marginTop:'0.55em'}}>Six months is our most popular, balancing a strong saving with the flexibility of a shorter term.{intentId === 'agency-whitelabel' ? ' For white-label agencies, we also draft the client contract for you based on the services and minimum commitment you select.' : ''}</span></>}><window.InfoIcon className="svc__commit-eyebrow-info" /></HoverPortalTip></div>
              <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' : ''} ${opt.months === 6 ? 'svc__commit-bar-btn--popular' : ''}`}
                      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>
                      )}
                      {/* 2026-06-25: Most popular badge, half-out at the top of the 6-month pill. */}
                      {opt.months === 6 && (
                        <img src="assets/badges/popular.webp" alt="Most popular" className="svc__commit-pop-badge" aria-hidden="true" />
                      )}
                    </button>
                  );
                })}
              </div>
              </div>
            );
          })()}
{/* 2026-06-12 (Loom 41 review): the service-header waiting-list pill is
                retired, every waitlisted tier card now carries its own badge
                beside the plan name so the header signal was redundant. */}
        </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. */}
      {['sales','paid-ads','email','smm','content','motion','fundraising','investor-portal'].includes(service.id) && (() => {
        const sel = selection;
        // 2026-06-11 (Loom 35 follow-up, Nicole): steps are now derived from
        // what the service actually configures. Channels appears wherever the
        // service has a real channels panel (window.SERVICE_CHANNELS, today
        // Sales AND Paid Ads), Commitment only where the commitment pills
        // render (not one-off or fixed-duration services), and the stepper
        // also covers Fundraising Support and the Investor Portal.
        const hasChannels = !!(window.SERVICE_CHANNELS && window.SERVICE_CHANNELS[service.id]);
        const hasCommit = !service.oneTime && !service.fixedDuration;
        const labels = [];
        const preds = [];
        if (hasCommit) { labels.push('Commitment'); preds.push(() => !!sel?.commitId || !!commitId); }
        labels.push('Tier'); preds.push(() => !!sel?.tier);
        if (hasChannels) { labels.push('Channels'); preds.push(() => Array.isArray(sel?.channels) && sel.channels.length > 0); }
        labels.push('Add-ons'); preds.push(() => Array.isArray(sel?.addons) && sel.addons.length > 0);
        const stepDone = {};
        labels.forEach((_, i) => { stepDone[i + 1] = preds[i](); });
        const total = labels.length;
        const stepCurrent = (n) => !stepDone[n] && (n === 1 || stepDone[n-1]);
        return (
          <div className="sdg-stepper" role="list" aria-label="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 < total && <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)}
          onSetStartWindow={onSetStartWindow}
          onSetPtBilling={onSetPtBilling}
        />
      )}

      {/* 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}
          mailOpts={selection?.mailOpts}
          onSetMailOpt={onSetMailOpt}
          onSetAddonQty={onSetAddonQty}
          addonRecurring={selection?.addonRecurring}
          onToggleAddonRecurring={onToggleAddonRecurring}
          defaultOpen={addonsDefaultOpen === 'always' || (addonsDefaultOpen === 'when-added' && active)}
          requireServiceActive={true}
          serviceActive={!!(selection && selection.tier)}
          onActivateService={() => { if (!(selection && selection.tier)) 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.
            2026-06-11 (Loom 36 0:28): hidden on one-off and fixed-duration
            services, paying upfront is meaningless on a one-off charge. */}
        {!service.oneTime && !service.fixedDuration && (
        <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)} />
      )}
      {intentId === 'agency-whitelabel' && (
        <p className="tiers__wl-note" style={{ fontSize: '0.78rem', color: 'var(--gg-muted, #5a647d)', fontStyle: 'italic', margin: '0 0 0.85rem' }}>Prices shown are your wholesale cost. You set your own client price.</p>
      )}
      <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>
                  {/* 2026-06-12 (review): the waiting-list badge sits beside the
                      plan name, compact, on every Enterprise plan and on
                      genuinely waitlisted tiers, so the price row carries the
                      pricing alone. */}
                  {((t.isEnterprise && service.id !== 'whitelabel') || onWaitlist) && (
                    <HoverPortalTip
                      wrapClassName="wl-tip-wrap tier__waitlist-pill-wrap tier__waitlist-pill-wrap--name"
                      tipClassName="wl-tip wl-tip--above"
                      placement="above"
                      tip={t.isEnterprise ? <>
                        <span className="wl-tip__head">You're joining a waiting list</span>
                        <span className="wl-tip__body">Enterprise places are limited, so adding this plan reserves your spot whilst your proposal is prepared. Your plan is saved and no charge applies in the meantime.</span>
                        <span className="wl-tip__meta">Pricing is confirmed in your proposal.</span>
                      </> : <>
                        <span className="wl-tip__head">You're joining a waiting list</span>
                        <span className="wl-tip__body">This plan is at capacity, so adding it reserves your place and we email you as soon as a spot opens, typically within 2 to 4 weeks.</span>
                        <span className="wl-tip__body" style={{display:'block', marginTop:'0.55em'}}>We prioritise existing clients for waiting list onboarding, so you're likely to be onboarded faster if you're already using another of our services, and we can give you more accurate timeframes once you're on board.</span>
                        <span className="wl-tip__meta">£0 is added to your monthly total until onboarding begins.</span>
                      </>}
                    >
                      <span className="tier__waitlist-pill tier__waitlist-pill--sm">
                        <span className="tier__waitlist-dot" aria-hidden="true" />
                        Waiting list
                      </span>
                    </HoverPortalTip>
                  )}
                  {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">
              {/* 2026-06-11 (Loom 36 1:03-1:52): the pill no longer replaces the
                  price, pricing stays visible on waitlisted plans. The tip now
                  carries the duration and the existing-client priority story. */}
              {/* 2026-06-12 (review): the waiting-list badge moved up beside the
                  plan name, so the price row holds Bespoke Pricing alone. */}
              {t.isEnterprise && service.id !== 'whitelabel' && (
                <HoverPortalTip
                  wrapClassName="wl-tip-wrap tier__waitlist-pill-wrap"
                  tipClassName="wl-tip wl-tip--above"
                  placement="above"
                  tip={<>
                    <span className="wl-tip__head">Bespoke pricing</span>
                    <span className="wl-tip__body">Enterprise engagements are scoped and priced 1:1 with your team, so the plan card carries no list price. Pick the channels and options you're interested in and we'll build your proposal around them.</span>
                    <span className="wl-tip__meta">£0 is added to your monthly total until your proposal is agreed.</span>
                  </>}
                >
                  <span className="tier__bespoke-text">Bespoke Pricing</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>)
                    : null)
                : (() => {
                    const p = window.priceFor(service, t.id, commitId);
                    if (p.custom) return p.label === 'Custom' ? 'Contact for Pricing' : p.label;
                    if (p.free) return 'Free';
                    // 2026-06-10 (Loom 33): count-up like the sidebar total.
                    return <AnimatedGBP value={(window.ggRound5 || (x => x))(p.value * agyMult * _upfrontMult)} />;
                  })()}
              {!t.isEnterprise && priceLabelFor(t.id) !== 'Custom' && priceLabelFor(t.id) !== 'Free' && <span className="tier__price-sub">{service.oneTime ? 'one-off' : '/month'}</span>}
              {/* 2026-06-08 (Loom 29 3:32): RRP inline in brackets on the price line, matching the setup-fee RRP. */}
              {intentId === 'agency-whitelabel' && !onWaitlist && !t.isEnterprise && (() => {
                const _pr = window.priceFor(service, t.id, commitId);
                if (_pr.custom || !_pr.value) return null;
                return <span className="tier__price-rrp" style={{fontWeight:400, fontStyle:'italic', fontSize:'0.65rem', marginLeft:'4px', color: 'var(--gg-blue, #002abf)'}}>(RRP {window.fmt(Math.round(_pr.value))}{service.oneTime ? '' : '/month'})</span>;
              })()}
            </div>
            {service.id === 'founders-portal' && t.id === 'grow' && (
              <div className="tier__free-note" style={{ fontSize: '0.74rem', fontWeight: 600, color: 'var(--gg-muted, #6b7280)', marginTop: '0.35rem' }}>Free when you sign up to our services</div>
            )}
            {/* 2026-06-08 (Loom 29 3:32): RRP moved inline onto the price line above. */}
            {/* One-time setup fee, rendered below the monthly price. */}
            {(() => {
              if (onWaitlist) return null;
              const fee = service.setupFees && service.setupFees[t.id];
              const _isSalesSetup = service.id === 'sales' && !!service.setupFees;
              const _isAgencyCard = clientTypeId === 'agency';
              const _sdgSep = <span aria-hidden="true" style={{ opacity: 0.5, margin: '0 0.15rem' }}>|</span>;
              // Loom 60: every Sales tier shows "+ Cost per meeting" with this shared tooltip.
              const _sdgTip = (
                <HoverPortalTip wrapClassName="tier__setup-info-wrap" tipClassName="dis-tip dis-tip--above" placement="above" tip={<>
                  <span className="dis-tip__body" style={{ display: 'block' }}>Once you have completed the questions, we give you a cost per meeting quote. As a general guide, it starts at £200 per approved meeting, and the exact figure depends on how hard your meetings are to book, which we gauge from factors like your industry and deal size. You keep a fixed 40% of the price on every meeting, with us on 60%.</span>
                  <span className="dis-tip__body" style={{ display: 'block', marginTop: '0.6em' }}>You only pay for meetings you approve, and every booking comes with the call recording and transcript so you can check it is a good fit before you approve it.</span>
                </>}>
                  <span className="info-icon" role="img" aria-label="About cost per meeting and the setup fee" onClick={(e) => e.stopPropagation()} style={{ marginLeft: '4px' }}>
                    <svg viewBox="0 0 16 16" width="11" height="11" 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>
              );
              if (fee === undefined || fee === null) {
                if (t.isEnterprise && service.setupFees) {
                  if (_isSalesSetup) return <div className="tier__setup-fee tier__setup-fee--sdg">+ {_isAgencyCard ? <>Cost per meeting {_sdgSep} </> : null}Custom setup fee{_isAgencyCard ? _sdgTip : null}</div>;
                  return <div className="tier__setup-fee tier__setup-fee--custom">+ Custom setup fee</div>;
                }
                return null;
              }
              const _wlSetup = intentId === 'agency-whitelabel' && typeof agyMult === 'number' && agyMult < 1;
              const _discFee = (_wlSetup && typeof fee === 'number') ? (window.ggRound5 || (x => x))(fee * agyMult) : fee;
              if (typeof fee !== 'number') {
                if (_isSalesSetup) return <div className="tier__setup-fee tier__setup-fee--sdg">+ {_isAgencyCard ? <>Cost per meeting {_sdgSep} </> : null}{String(fee)} setup fee{_isAgencyCard ? _sdgTip : null}</div>;
                return <div className="tier__setup-fee">+ {String(fee)} one-off setup fee</div>;
              }
              if (_isSalesSetup) {
                return (
                  <>
                    <div className="tier__setup-fee tier__setup-fee--sdg">+ {_isAgencyCard ? <>Cost per meeting {_sdgSep} </> : null}<AnimatedGBP value={_discFee} /> setup fee{_isAgencyCard ? _sdgTip : null}</div>
                    {_wlSetup && _discFee !== fee ? <div className="tier__setup-rrp tier__setup-rrp--line" style={{ fontWeight: 400, fontStyle: 'italic', fontSize: '0.65rem', color: 'var(--gg-blue, #002abf)', marginTop: '0.1rem' }}>(RRP £{fee.toLocaleString('en-GB')} one-off)</div> : null}
                  </>
                );
              }
              return (
                <div className="tier__setup-fee" style={(_wlSetup && _discFee !== fee) ? { fontSize: '0.58rem' } : undefined}>+ <AnimatedGBP value={_discFee} /> {(_wlSetup && _discFee !== fee) ? 'setup fee' : 'one-off setup fee'}{_wlSetup && _discFee !== fee ? <span className="tier__setup-rrp" style={{display:'inline', fontWeight:400, fontStyle:'italic', fontSize:'0.92em', marginLeft:'4px', color: 'var(--gg-blue, #002abf)'}}>(RRP £{fee.toLocaleString('en-GB')} one-off)</span> : null}</div>
              );
            })()}
          {service.tierDuration && service.tierDuration[t.id] && (
            <div className="tier__duration" style={{ fontSize: '0.78rem', fontWeight: 600, color: 'var(--gg-muted, #5a647d)', marginTop: '0.35rem' }}>Project duration · {service.tierDuration[t.id]}</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. */}
      {/* 2026-06-04: SDG (sales) now renders here too, right after the tiers,
          with the neumorphic design - same as every other service. Its lead
          + paid-channel costs are folded in below. (Was a flat variant inside
          the Monthly-lead-volume box.) */}
      {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;
        // 2026-06-03 (Loom 19 0:00): fold the SDG additional-lead cost into the
        // margin so the reseller's wholesale, RRP, and net profit reflect it.
        let _leadWholesale = 0, _leadRrp = 0;
        if (service.id === 'sales' && selection && selection.leadSourceMode !== 'byol' && selection.leadSourceMode !== 'none' && typeof window.computeAdditionalLeadCost === 'function') {
          const _inc = window.leadIncludedForTier ? window.leadIncludedForTier(tier.id) : 750;
          const _leads = Math.max(_inc, Math.min(20000, selection.monthlyLeads || _inc));
          if (_leads > _inc) {
            const _r = window.computeAdditionalLeadCost(_leads, tier.id);
            if (_r && _r.additional && _r.cost) { _leadRrp = _r.cost; _leadWholesale = Math.round(_r.cost * _wlMult); }
          }
        }
        // Paid SDG channels (LinkedIn per profile, WhatsApp follow-up) also
        // flow into the margin wholesale + RRP (ported from the old in-box slot).
        // 2026-06-18 (Loom 48): LinkedIn and Instagram now have their own per-channel
        // margin boxes (see ChannelsPanel), so they are excluded from this consolidated
        // breakdown to avoid double-counting net profit.
        let _chWholesale = 0, _chRrp = 0;
        // 2026-06-26 (Loom 61): our prices end in 5, but the wholesale resolver can return
        // values that do not (e.g. 1497 for a 1495 plan), so round to the nearest 5 to align.
        const _baseWhole = (() => { const _raw = (_wsRes && typeof _wsRes.value === 'number') ? _wsRes.value : Math.round(p.value * _wlMult); return Math.round(_raw / 5) * 5; })();
        // 2026-06-04 (Loom 23 2:36): itemise each monthly component as wholesale -> our price.
        const _items = [{ name: `${tier.name} retainer`, wholesale: _baseWhole, rrp: p.value }];
        if (_leadWholesale > 0) _items.push({ name: 'Additional leads', wholesale: _leadWholesale, rrp: _leadRrp });
        // 2026-06-18 (Loom 48): LinkedIn and Instagram channel margins moved to their own
        // per-channel boxes (ChannelsPanel), so they are no longer line items here.
        // 2026-06-04 (Loom 23 3:58): fold every selected add-on in. Monthly add-ons
        // join the monthly breakdown + total; one-off add-ons and the setup fee go
        // to a separate One-off section. Pricing mirrors the sidebar lines build
        // (2026-06-08: every add-on now takes the agency white-label discount, not only discountable ones).
        let _addonWholeMonthly = 0, _addonRrpMonthly = 0;
        const _oneoffItems = []; let _oneoffWhole = 0, _oneoffRrp = 0;
        const _Wg = window.WHOLESALE;
        const _ctxAddons = (window.getAddonsForContext ? window.getAddonsForContext(service, tier.id, commitId, selection?.channels, selection?.monthlyLeads) : service.addons) || [];
        (Array.isArray(selection.addons) ? selection.addons : []).forEach((aid) => {
          const a = _ctxAddons.find((x) => x.id === aid);
          if (!a || a.free) return;
          if (_Wg && _Wg.addons && Object.prototype.hasOwnProperty.call(_Wg.addons, a.id)) return; // region-variable, shown in region section
          const _cap = window.addonAvailability ? window.addonAvailability(a) : null;
          const _onWl = _cap && _cap.status === 'full' && (!a.custom || a.onboardingStatus === 'full');
          if (a.custom || _onWl) return;
          const _u = a.unit || '';
          const _perUnit = !!a.unit && (a.perUnit || (/^\/[a-z]+$/i.test(_u) && !/^\/(mo|month|hr|hour|day|yr|year)$/i.test(_u)));
          const _q = _perUnit ? Math.max(1, Number(selection.addonQty && selection.addonQty[aid]) || 1) : 1;
          const _am = _wlMult;
          let _r, _w;
          _r = Math.round((a.price || 0) * _q); _w = Math.round((a.price || 0) * _am * _q);
          if (!_r) return;
          const _nm = a.name + (_perUnit && _q > 1 ? ` × ${_q}` : '');
          if (a.oneTime) { _oneoffItems.push({ name: _nm, wholesale: _w, rrp: _r, aid: a.id }); _oneoffWhole += _w; _oneoffRrp += _r; }
          else { _items.push({ name: _nm, wholesale: _w, rrp: _r, aid: a.id }); _addonWholeMonthly += _w; _addonRrpMonthly += _r; }
        });
        const _setupFee = (service.setupFees && typeof service.setupFees[tier.id] === 'number') ? service.setupFees[tier.id] : 0;
        if (_setupFee > 0) { const _sw = Math.round(_setupFee * _wlMult / 5) * 5; _oneoffItems.unshift({ name: 'Setup fee', wholesale: _sw, rrp: _setupFee }); _oneoffWhole += _sw; _oneoffRrp += _setupFee; }
        const wholesale = Math.round((_baseWhole + _leadWholesale + _chWholesale + _addonWholeMonthly) / 5) * 5;
        const _mtgRange = ({ starter: { min: 3, max: 5 }, grow: { min: 6, max: 10 }, scale: { min: 10, max: 15 } })[tier.id] || null;
        const _cpm = (service.id === 'sales' && _mtgRange && _baseWhole > 0) ? Math.round(_baseWhole / ((_mtgRange.min + _mtgRange.max) / 2)) : null;
        return (
          <MarginRow
            wholesale={wholesale}
            rrp={p.value + _leadRrp + _chRrp + _addonRrpMonthly}
            serviceId={service.id}
            tierName={tier.name}
            commitId={commitId}
            commitOpts={window.commitsFor && window.commitsFor(service) && window.commitsFor(service).length > 1 ? window.commitsFor(service) : window.COMMITMENTS}
            onSetCommit={onSetCommit}
            cpm={_cpm}
            cpmRange={service.id === 'sales' ? _mtgRange : null}
            items={_items}
            oneoffItems={_oneoffItems}
            oneoffWholesale={_oneoffWhole}
            oneoffRrp={_oneoffRrp}
          />
        );
      })()}

      {/* 2026-06-30 (Loom 68): the white-label margin calculator stays visible before a
          plan is picked, prompting the agency to select one. No tier is pre-selected.
          Only for services with monthly markup tier pricing, so it skips dedicated
          talent, one-off, and bespoke services and matches the post-pick calculator. */}
      {intentId === 'agency-whitelabel' && !tier && service.id !== 'dedicated-pt' && service.id !== 'dedicated-ft' && (() => {
        const _pt = (window.tiersFor(service) || []).find(t => !t.isEnterprise);
        const _p = _pt ? window.priceFor(service, _pt.id, commitId) : null;
        if (!_p || _p.custom || _p.oneTime || !_p.value) return null;
        const _hasSetup = !!(service.setupFees && Object.values(service.setupFees).some(v => typeof v === 'number' && v > 0));
        return (
          <MarginRow
            noTier
            cpmNoTier={service.id === 'sales'}
            noTierOneoff={_hasSetup}
            wholesale={0}
            rrp={0}
            serviceId={service.id}
            tierName={null}
            commitId={commitId}
            commitOpts={window.commitsFor && window.commitsFor(service) && window.commitsFor(service).length > 1 ? window.commitsFor(service) : window.COMMITMENTS}
            onSetCommit={onSetCommit}
            cpm={null}
            items={[]}
            oneoffItems={[]}
            oneoffWholesale={0}
            oneoffRrp={0}
          />
        );
      })()}

      {/* 2026-06-12 (review): the Enterprise scope notice box was removed, the
          Bespoke Pricing text, the waiting-list badge, and the sidebar tags
          carry the story without an extra amber box below the cards. */}

      {/* Channels + capacity panel, only shown after the user picks a tier (service is active).
          2026-06-11 (Loom 38), Enterprise shows it too. Channel picks, ad budget, and
          LinkedIn seats become proposal inputs, with fee copy hidden (bespoke pricing). */}
      {window.SERVICE_CHANNELS[service.id] && active && (
        <ChannelsPanel
          service={service}
          selection={selection}
          onToggleChannel={(cid, max) => onToggleChannel(cid, max)}
          onSetAdSpend={onSetAdSpend}
          onSetLinkedinProfiles={onSetLinkedinProfiles}
          serviceActive={active}
          currentTierId={tierId || 'starter'}
          isEnterprise={!!tier?.isEnterprise}
          intentId={intentId}
          wlMult={intentId === 'agency-whitelabel' && window.getAgencyMultiplier ? window.getAgencyMultiplier({ clientTypeId, intentId }, service.id) : 1}
        />
      )}

      {/* Monthly Lead Volume widget, SDG only (see BUILD_SPEC_SDG_Monthly_Lead_Volume.md).
          Slots in immediately after channels picker. 2026-06-11 (Loom 38),
          Enterprise shows it too as a proposal input, with rate copy hidden. */}
      {service.id === 'sales' && active && (
        <MonthlyLeadVolume
          selection={selection}
          tierId={tierId}
          isEnterprise={!!tier?.isEnterprise}
          wlMult={intentId === 'agency-whitelabel' && window.getAgencyMultiplier ? window.getAgencyMultiplier({ clientTypeId: 'agency', intentId: 'agency-whitelabel' }, service.id) : null}
          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 (accountability 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} serviceId={service.id} intentId={intentId} clientTypeId={clientTypeId} />
      )}

      {/* 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}
          mailOpts={selection?.mailOpts}
          onSetMailOpt={onSetMailOpt}
          onSetAddonQty={onSetAddonQty}
          addonRecurring={selection?.addonRecurring}
          onToggleAddonRecurring={onToggleAddonRecurring}
          defaultOpen={addonsDefaultOpen === 'always' || (addonsDefaultOpen === 'when-added' && active)}
          requireServiceActive={true}
          serviceActive={!!(selection && selection.tier)}
          onActivateService={() => { if (!(selection && selection.tier)) 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).
// 2026-06-26 (Alexander): drag-and-drop / file-picker upload for agency brand
// assets, alongside the paste-a-link field. Uploads each file to the existing
// /api/upload-brand-asset endpoint and stores { name, url } in q0.brandAssetUploads.
function BrandAssetUploader({ uploads, onChange }) {
  const [items, setItems] = uS(() => Array.isArray(uploads) ? uploads.map((u, i) => ({ id: 'init-' + i, name: u && u.name, status: 'done', url: u && u.url })) : []);
  const [dragOver, setDragOver] = uS(false);
  const inputRef = uR(null);
  const idRef = uR(0);
  const mounted = uR(false);
  React.useEffect(() => {
    if (!mounted.current) { mounted.current = true; return; }
    onChange('brandAssetUploads', items.filter((i) => i.status === 'done' && i.url).map((i) => ({ name: i.name, url: i.url })));
  }, [items]);
  const ALLOWED = { 'image/png': 1, 'image/jpeg': 1, 'image/svg+xml': 1, 'application/pdf': 1 };
  const MAX = 5 * 1024 * 1024;
  const typeFromExt = (name) => ({ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', svg: 'image/svg+xml', pdf: 'application/pdf' }[String(name || '').split('.').pop().toLowerCase()] || '');
  const patch = (id, fields) => setItems((prev) => prev.map((it) => (it.id === id ? Object.assign({}, it, fields) : it)));
  const uploadOne = async (file, id, ftype) => {
    try {
      const resp = await fetch('/api/upload-brand-asset', { method: 'POST', headers: { 'Content-Type': ftype, 'x-filename': encodeURIComponent(file.name) }, body: file });
      if (!resp.ok) {
        let msg = 'Upload failed, please try again or paste a link below.';
        if (resp.status === 500) msg = 'Uploads are not available right now, please paste a link below.';
        else if (resp.status === 415) msg = 'Use a PNG, JPG, SVG, or PDF file.';
        else if (resp.status === 413) msg = 'Keep each file under 5 MB.';
        patch(id, { status: 'failed', error: msg });
        return;
      }
      const data = await resp.json().catch(() => null);
      if (data && data.url) patch(id, { status: 'done', url: String(data.url) });
      else patch(id, { status: 'failed', error: 'Upload failed, please try again or paste a link below.' });
    } catch (e) {
      patch(id, { status: 'failed', error: 'Upload failed, please check your connection and try again.' });
    }
  };
  const addFiles = (fileList) => {
    Array.from(fileList || []).forEach((file) => {
      const id = 'f-' + (++idRef.current);
      const ftype = file.type || typeFromExt(file.name);
      if (!ALLOWED[ftype]) { setItems((prev) => prev.concat({ id, name: file.name, status: 'failed', error: 'Use a PNG, JPG, SVG, or PDF file.' })); return; }
      if (file.size > MAX) { setItems((prev) => prev.concat({ id, name: file.name, status: 'failed', error: 'Keep each file under 5 MB.' })); return; }
      setItems((prev) => prev.concat({ id, name: file.name, status: 'uploading' }));
      uploadOne(file, id, ftype);
    });
  };
  const remove = (id) => setItems((prev) => prev.filter((it) => it.id !== id));
  const openPicker = () => { if (inputRef.current) inputRef.current.click(); };
  return (
    <div className="q0-field q0-field--full">
      <span className="q0-field__label">Or upload your logo and brand files <span className="q0-field__hint">(optional)</span></span>
      <div
        className={'brand-upload' + (dragOver ? ' brand-upload--over' : '')}
        role="button" tabIndex={0}
        onClick={openPicker}
        onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openPicker(); } }}
        onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
        onDragLeave={(e) => { e.preventDefault(); setDragOver(false); }}
        onDrop={(e) => { e.preventDefault(); setDragOver(false); addFiles(e.dataTransfer && e.dataTransfer.files); }}
      >
        <input ref={inputRef} type="file" multiple accept=".png,.jpg,.jpeg,.svg,.pdf,image/png,image/jpeg,image/svg+xml,application/pdf" style={{ display: 'none' }} onChange={(e) => { addFiles(e.target.files); e.target.value = ''; }} />
        <svg className="brand-upload__icon" viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M12 16V4" /><path d="M7 9l5-5 5 5" /><path d="M5 16v2a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-2" /></svg>
        <span className="brand-upload__main">Drag and drop files here, or click to browse</span>
        <span className="brand-upload__hint">PNG, JPG, SVG, or PDF, up to 5 MB each</span>
      </div>
      {items.length > 0 && (
        <ul className="brand-upload__list">
          {items.map((it) => (
            <li key={it.id} className={'brand-upload__item brand-upload__item--' + it.status}>
              <span className="brand-upload__file">
                <span className="brand-upload__name">{it.name}</span>
                <span className="brand-upload__state">{it.status === 'uploading' ? 'Uploading…' : it.status === 'done' ? 'Uploaded' : it.error}</span>
              </span>
              <button type="button" className="brand-upload__remove" aria-label={'Remove ' + (it.name || 'file')} onClick={(e) => { e.preventDefault(); e.stopPropagation(); remove(it.id); }}>
                <svg viewBox="0 0 16 16" width="13" height="13" 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>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

function Q0Section({ q0, onChange }) {
  return (
    <div className="q0-section glass-frame">
      <div className="q0-section__head">
        <h3 className="q0-section__title">Add your branding for the proposal</h3>
      </div>
      <div className="q0-section__grid">
        <label className="q0-field q0-field--full">
          <span className="q0-field__label">Logo / brand assets link <span className="q0-field__hint">(optional)</span></span>
          <input type="url" className="q0-field__input" placeholder="https:// link to your logo or brand kit" value={q0.brandAssetsUrl || ''} onChange={(e) => onChange('brandAssetsUrl', e.target.value)} />
        </label>
        <BrandAssetUploader uploads={q0.brandAssetUploads} onChange={onChange} />
      </div>
    </div>
  );
}

window.Q0Section = Q0Section;

// ── Qualifier (Q1, Q4 + conditional Q1.5) (Sprint 1) ────────────────────
function QualifierSection({ qualifier, onAnswer, onAnswerMulti, missingIds, persona = 'founders', onConfirmQ1Change, onSetPriorSub, onTogglePriorSub, brandingNode = null }) {
  // 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];
      // Text follow-ups (referral name, YouTube channel) are optional, so they
      // must never stall the reveal of the questions after them.
      const has = q.text ? true : (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]);
          }
        };

        // Nudge-scroll to the next unanswered question after the one just
        // answered. Retries across frames because conditional follow-ups
        // (aq_proposal_a, the attribution follow-ups) mount a render or two
        // later, and re-centres a couple of times on non-text targets because a
        // late layout shift can move the target after the first scroll (this is
        // what made the agency proposal question look skipped). Runs for single
        // picks AND the attribution multi-selects so their follow-ups, including
        // the free-text ones, are nudged into view too.
        const _runNudge = () => {
          // Each nudge bumps a global generation so re-centres and retries from an
          // earlier pick no-op once a newer pick has started (a late re-centre
          // yanking back to the previous question read as the proposal skip).
          const _gen = (window.__ggNudgeGen = (window.__ggNudgeGen || 0) + 1);
          const _alive = () => window.__ggNudgeGen === _gen;
          let _qAttempts = 0;
          const _advanceQualifier = () => {
            if (!_alive()) return true;
            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 _isText = nextId === 'heardReferralName' || nextId === 'heardYoutube' || nextId === 'heardOther';
              const _nextMulti = nextId === 'priorActivities' || nextId === 'heardVia' || nextId === 'heardOutreachHow';
              const isEmpty = _nextMulti
                ? (!Array.isArray(v) || v.length === 0)
                : (v == null || v === '');
              if (isEmpty) {
                const _tgt = panels[i];
                _scrollToNext(_tgt);
                // Re-centre only non-text targets; re-scrolling a text field
                // would fight the user as they start typing in it.
                if (!_isText) {
                  setTimeout(() => { if (_alive()) _scrollToNext(_tgt); }, 180);
                  setTimeout(() => { if (_alive()) _scrollToNext(_tgt); }, 460);
                }
                return true;
              }
            }
            return false;
          };
          requestAnimationFrame(() => requestAnimationFrame(() => {
            if (_advanceQualifier()) return;
            const _retry = () => { if (!_alive() || _advanceQualifier() || ++_qAttempts > 30) return; requestAnimationFrame(_retry); };
            requestAnimationFrame(_retry);
          }));
        };

        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);
            // Attribution multi-selects nudge to their follow-ups; other multis
            // (e.g. priorActivities) stay put while the user keeps selecting.
            if (q.id === 'heardVia' || q.id === 'heardOutreachHow') _runNudge();
            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);
          _runNudge();
        };

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

        return (
          <React.Fragment key={`${q.id}-${q.branch || 'main'}`}>
            {/* 2026-06-26 (Loom 64): branding capture renders just before the attribution question. */}
            {q.id === 'heardVia' && brandingNode}
            {/* 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.text ? (
                  <input
                    type="text"
                    className="qualifier-q__textfield"
                    placeholder={q.placeholder || ''}
                    value={typeof cur === 'string' ? cur : ''}
                    maxLength={120}
                    onChange={(e) => onAnswer && onAnswer(q.id, e.target.value)}
                    onClick={(e) => e.stopPropagation()}
                    aria-label={q.label}
                    style={{ gridColumn: '1 / -1', width: '100%', boxSizing: 'border-box', padding: '12px 14px', border: '1px solid var(--gg-border, #d4dae6)', borderRadius: '12px', fontSize: '0.95rem', fontFamily: 'inherit', color: 'var(--gg-body, #2C3142)', background: '#fff' }}
                  />
                ) : 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.
// 2026-06-25 (Loom 60): Cost Per Meeting Success Share - hover tip + clickable overlay (Nicole's piece).
const CPM_SHARE_TIP = {
  paras: [
    'You set the cost per meeting you charge your client, with a £200 minimum, and we run on a fixed 60/40 success share. We take 60%, you keep 40%, whatever you charge.',
    'Price it to the value of what your client sells, and your fixed 40% margin holds at every level.',
  ],
};
const CPM_SHARE_OVERLAY = {
  title: 'Cost Per Meeting Success Share',
  intro: 'You set the price you charge your client for each approved meeting, and we run on a fixed 60/40 success share. We take 60%, you keep 40%, and that margin holds whatever you charge above the £200 minimum.',
  tabs: [
    { label: 'How the split works', blocks: [
      { t: 'p', x: 'The cost per meeting is the price you charge your client for each meeting you approve. You set it, with a minimum of £200, and you can price as high as the value of what your client sells will support.' },
      { t: 'checks', items: [
        'You set the per-meeting price, from £200 upwards',
        'We take a fixed 60%, which funds the whole team that books the meeting',
        'You keep a fixed 40% on every meeting, whatever you charge',
        'You only pay for meetings you approve',
      ] },
      { t: 'p', x: 'Because the split is fixed, your margin never moves. Charge £200 and you keep £80. Charge £500 and you keep £200. The more value you price in, the more you keep.' },
    ] },
    { label: 'Where your 60% goes', blocks: [
      { t: 'p', x: 'Our 60% is not a flat fee, it is shared across the whole team that delivers your meetings. We spread it algorithmically, so everyone who touches a booking is paid fairly from the same pot.' },
      { t: 'table', head: ['What your share funds', 'What it covers'], rows: [
        ['Research and data', 'Building and scoring your target list, and the signals behind it'],
        ['SDR pod', 'Multichannel outreach across email, LinkedIn, SMS, and WhatsApp'],
        ['Calling and booking', 'Callers who qualify interest and lock meetings into your calendar'],
        ['Tooling', 'Sending infrastructure, dialler, CRM, and the call recording stack'],
        ['Quality and management', 'Managers who check fit, handle replies, and keep the campaign on track'],
      ] },
      { t: 'p', x: 'You see the result rather than the moving parts. Every booking comes with the call recording and transcript, so you can check the fit before you approve and pay.' },
    ] },
    { label: 'How to price meetings', blocks: [
      { t: 'p', x: 'Price each meeting to the value of what your client sells. A meeting that can turn into a six-figure contract is worth far more than the £200 floor, and your 40% scales with it.' },
      { t: 'table', head: ['You charge', 'We take (60%)', 'You keep (40%)'], rows: [
        ['£200', '£120', '£80'],
        ['£300', '£180', '£120'],
        ['£500', '£300', '£200'],
        ['£750', '£450', '£300'],
      ] },
      { t: 'p', x: 'There is no upper limit. You can charge as much as your client will support, and your margin stays at a clean 40% the whole way up.' },
    ] },
  ],
  faqs: [
    { q: 'Is the 40% really fixed?', a: 'Yes. Whatever you charge above the £200 minimum, you keep 40% and we take 60%. The split never changes, so your margin is the same on a £200 meeting as it is on a £2,000 one.' },
    { q: 'Why is there a £200 minimum?', a: 'The £200 floor is what lets us run the full team behind each meeting, from research and outreach through to calling and quality checks. You are free to price above it.' },
    { q: 'When do I pay?', a: 'You only pay for meetings you approve. Every booking arrives with a call recording and transcript, so you can check the fit first, and anything that does not match is not charged.' },
    { q: 'Can I charge my client more than your share?', a: 'Yes, and that is the point. You set the retail price, we take our fixed 60%, and the rest is yours. Most partners price well above the minimum, to the value of the meeting.' },
    { q: 'What does the success share cover?', a: 'Everything it takes to book the meeting, including the data, the multichannel outreach, the calling, the tooling, and the management. It is shared across the team rather than billed as separate line items.' },
  ],
};

// 2026-06-26 (Loom 61): "which clients does this work well for" overlay for the
// Cost Per Meeting Success Share, modelled on done-for-you outbound positioning
// and pitched a notch lower because our retainers are lower. GoGorilla voice only.
const CPM_FIT_OVERLAY = {
  title: 'Which clients does this service work well for?',
  intro: 'This service works best when each booked meeting can turn into real revenue. The clearer the market and the sales motion, the more meetings we can book, and the more each one is worth.',
  tabs: [
    { label: 'The best-fit signals', blocks: [
      { t: 'p', x: 'You keep a fixed 40% of every meeting you approve, so the model rewards clients whose meetings are genuinely valuable and whose market is deep enough to keep booking.' },
      { t: 'checks', items: [
        'A high average deal or contract value, so one booked meeting is worth far more than it costs',
        'A clearly defined ICP, so we know exactly who to reach and what to say',
        'A large enough addressable market, so there are always more of the right buyers to book',
        'A proven, repeatable sales process and product-market fit, so the meetings we book convert',
        'Buyers who are hard to reach through inbound alone, where targeted outbound and calling open doors',
      ] },
      { t: 'p', x: 'Some existing brand awareness helps too, since it lifts reply rates and the quality of the conversations we book.' },
    ] },
    { label: 'Why deal value matters', blocks: [
      { t: 'p', x: 'The higher the value of what the client sells, the more you can charge per meeting and the faster the cost per meeting pays for itself.' },
      { t: 'table', head: ['What we look for', 'Why it fits'], rows: [
        ['A high average contract value', 'A single closed deal covers many meetings, so the maths works from day one'],
        ['A considered, multi-step sale', 'Booking a meeting with the right buyer is the hard part, which is exactly what we deliver'],
        ['Focused, reachable buyers', 'A defined ICP means our outreach and calling land with the people who matter'],
      ] },
      { t: 'p', x: 'Because our retainers sit well below a typical done-for-you outbound agency, the bar is lower than most. Leaner ICPs and mid-market deal sizes can still work well here.' },
    ] },
  ],
  faqs: [
    { q: 'What if my client is early stage?', a: 'It can still work once they have a clear ICP and a few signs of what is converting. The more we know about who buys and why, the better we target.' },
    { q: 'Does it suit low-value or high-volume sales?', a: 'It works best where each deal is worth a lot, since the value of a booked meeting is what makes the cost per meeting pay off. For very low-value, high-volume sales, a monthly retainer service usually fits better.' },
    { q: 'How many meetings can you book?', a: 'Our aim is to book as many qualified meetings as the market and ICP allow. As a general rule, we target at least ten a month, and for a well-defined ICP with a proven sales process, we can book many more.' },
  ],
};

function MarginRow({ wholesale, rrp, serviceId, tierName, commitId, commitOpts, onSetCommit, service, selectedAddons, mult, overseasCountries, title, unitWord, flat, autoFill, cpm, cpmRange, items, oneoffItems, oneoffWholesale, oneoffRrp, monthlyTip, noTier, cpmNoTier, noTierOneoff }) {
  const storageKey = `gg.margin.${serviceId}`;
  const [bdOpen, setBdOpen] = React.useState(false);
  // 2026-06-05: pricing-model tabs - classic wholesale markup vs the 60/40
  // revenue-share partnership (GoGorilla.com takes 60%, the partner keeps 40%).
  const [calcMode, setCalcMode] = React.useState('markup');
  // The revenue-share tab shares the markup tab's monthly `retail` state, so
  // the typed price (and quick-fill picks) stay in sync across both views.
  // 2026-06-01: autoFill mode (used by the add-on margin calc) defaults the
  // retail price to 2x the wholesale and re-applies it whenever the wholesale
  // changes (i.e. a new region is picked). Non-autoFill rows keep their saved
  // or empty default.
  const _twoX = (w) => String(Math.round((Number(w) || 0) * 2 / 5) * 5);
  const [retail, setRetail] = React.useState(() => {
    // 2026-06-24 (Nicole): the retail box always starts BLANK with a 2x-of-
    // wholesale placeholder; the agency types their own price. Matches the main
    // service calculator. Previously autoFill rows (add-ons, channels, talent
    // add-ons) pre-filled the 2x figure as a real value, which read as us setting
    // their price. Any value the agency typed before still restores.
    try { const saved = localStorage.getItem(storageKey); if (saved) return saved; } catch (e) {}
    return '';
  });
  React.useEffect(() => {
    try { localStorage.setItem(storageKey, retail); } catch (e) {}
    // 2026-06-02: tell the sidebar Summary so NET PROFIT tracks the agency's
    // chosen retail prices live.
    try { window.dispatchEvent(new Event('gg:margin-retail')); } catch (e) {}
  }, [retail, storageKey]);
  // 2026-06-24 (Nicole): removed the autoFill 2x pre-fill effect. The retail box
  // stays blank with a 2x placeholder so we never type a price into it; the
  // placeholder already tracks the wholesale (incl. region changes) on its own.
  // 2026-06-04 (Loom 23): editable one-off retail so the one-off "you keep" is live.
  const ooStorageKey = `gg.margin.oneoff.${serviceId}`;
  const [ooRetail, setOoRetail] = React.useState(() => {
    try { const saved = localStorage.getItem(ooStorageKey); if (saved) return saved; } catch (e) {}
    return '';
  });
  React.useEffect(() => { try { localStorage.setItem(ooStorageKey, ooRetail); } catch (e) {} try { window.dispatchEvent(new Event('gg:margin-retail')); } catch (e) {} }, [ooRetail, ooStorageKey]);
  // 2026-06-04 (Loom 26): VAT-registration toggle. Default on (registered).
  // When off, the agency cannot reclaim the VAT we charge on the wholesale
  // rate, so its true cost is wholesale + 20%. The setting is a global property
  // of the agency, so it is stored once and synced across every service block.
  const [vatReg, setVatReg] = React.useState(() => {
    try { const v = window.localStorage.getItem('gg.vatRegistered'); if (v === null) window.localStorage.setItem('gg.vatRegistered', 'true'); return v !== 'false'; } catch (e) { return true; }
  });
  React.useEffect(() => {
    const sync = () => { try { setVatReg(window.localStorage.getItem('gg.vatRegistered') !== 'false'); } catch (e) {} };
    window.addEventListener('gg:vat-registered', sync);
    return () => window.removeEventListener('gg:vat-registered', sync);
  }, []);
  const setVat = (on) => {
    try { window.localStorage.setItem('gg.vatRegistered', on ? 'true' : 'false'); } catch (e) {}
    setVatReg(on);
    try { window.dispatchEvent(new Event('gg:vat-registered')); } catch (e) {}
  };
  // 2026-06-08: "located outside the UK" toggle. We only charge VAT to UK
  // businesses, so an international agency has no UK VAT at all (cost shown
  // net, regardless of the VAT-registered toggle). Global, synced like vatReg.
  const [outsideUk, setOutsideUkState] = React.useState(() => {
    try { return window.localStorage.getItem('gg.outsideUk') === 'true'; } catch (e) { return false; }
  });
  React.useEffect(() => {
    const sync = () => { try { setOutsideUkState(window.localStorage.getItem('gg.outsideUk') === 'true'); } catch (e) {} };
    window.addEventListener('gg:outside-uk', sync);
    return () => window.removeEventListener('gg:outside-uk', sync);
  }, []);
  const setOutsideUk = (on) => {
    try { window.localStorage.setItem('gg.outsideUk', on ? 'true' : 'false'); } catch (e) {}
    setOutsideUkState(on);
    try { window.dispatchEvent(new Event('gg:outside-uk')); } catch (e) {}
  };
  // UK VAT applies only to UK businesses that are not VAT-registered.
  const _vatApplies = !outsideUk && !vatReg;
  const vatMult = _vatApplies ? 1.2 : 1;
  // 2026-06-05 (Loom 23): capture interest in the revenue-share partnership so
  // sales can raise it on the call. Global flag, like the VAT one. Does not
  // change any pricing. (Quote/Airtable wiring is a later step.)
  const [revInterest, setRevInterest] = React.useState(() => {
    try { return window.localStorage.getItem('gg.revShareInterest') === 'true'; } catch (e) { return false; }
  });
  React.useEffect(() => {
    const sync = () => { try { setRevInterest(window.localStorage.getItem('gg.revShareInterest') === 'true'); } catch (e) {} };
    window.addEventListener('gg:revshare-interest', sync);
    return () => window.removeEventListener('gg:revshare-interest', sync);
  }, []);
  const setRevInt = (on) => {
    try { window.localStorage.setItem('gg.revShareInterest', on ? 'true' : 'false'); } catch (e) {}
    setRevInterest(on);
    try { window.dispatchEvent(new Event('gg:revshare-interest')); } catch (e) {}
  };

  // #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 => {
    // 2026-06-12 (decision confirmed): overseas calling sums its selected
    // regions' bands for both wholesale and the reference retail.
    if (a.id === 'overseas-calling' && window.callingWholesaleSum) {
      const _ws = window.callingWholesaleSum(_derivedRegion.regions || [], commitId);
      if (_ws.hasSelection) {
        const _v = (typeof _ws.value === 'number') ? _ws.value : null;
        return { id: a.id, name: a.name, rrp: (_v != null ? _v : 0), wholesale: _v, byQuote: _ws.byQuoteOnly, markets: _ws.regions };
      }
    }
    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 _unitWord = unitWord || 'month';
  const _unitLabel = (_unitWord === 'month') ? '/month' : _unitWord;
  // Loom 28 follow-up: split priced add-ons out of the base plan so each add-on is
  // priced in its own box. Base plan keeps the tier, leads/channels, and region add-ons.
  const _monthlyAddons = (Array.isArray(items) ? items : []).filter(it => it && it.aid);
  const _oneoffAddons  = (Array.isArray(oneoffItems) ? oneoffItems : []).filter(it => it && it.aid);
  const _monthlyAddonWhole = _monthlyAddons.reduce((s, it) => s + (Number(it.wholesale) || 0), 0);
  const _monthlyAddonRrp   = _monthlyAddons.reduce((s, it) => s + (Number(it.rrp) || 0), 0);
  const _oneoffAddonWhole  = _oneoffAddons.reduce((s, it) => s + (Number(it.wholesale) || 0), 0);
  const _oneoffAddonRrp    = _oneoffAddons.reduce((s, it) => s + (Number(it.rrp) || 0), 0);
  const effWholesale = (wholesale - _monthlyAddonWhole) + _addonWholesale;
  const effRrp = ((typeof rrp === 'number' ? rrp : 0) - _monthlyAddonRrp) + _addonRrp;
  // VAT-adjusted true cost (Loom 26): grossed up by 20% only for UK businesses
  // that are not VAT-registered. International agencies pay no UK VAT.
  const costWholesale = _vatApplies ? Math.round(effWholesale*1.2) : effWholesale;
  const _ooBaseWhole = Math.max(0, (Number(oneoffWholesale) || 0) - _oneoffAddonWhole);
  const _ooBaseRrp = Math.max(0, (Number(oneoffRrp) || 0) - _oneoffAddonRrp);
  const ooCost = _vatApplies ? Math.round(_ooBaseWhole*1.2) : _ooBaseWhole;

  const retailVal = parseFloat(retail);
  const hasRetail = !isNaN(retailVal) && retailVal > 0;
  const margin = hasRetail ? retailVal - costWholesale : 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, 4x, 5x of the package wholesale.
  const fillSuggested = (m) => { if (noTier) return; const _t = Math.round(costWholesale * m / 5) * 5; setRetail(retailVal === _t ? '' : String(_t)); };
  // 2026-06-04: one-off quick fill mirrors the monthly one, based on the
  // one-off cost (VAT-adjusted) so the suggested price tracks the true cost.
  const fillOoSuggested = (m) => { if (noTierOneoff) return; const _t = Math.round(ooCost * m / 5) * 5; setOoRetail((parseFloat(ooRetail) || 0) === _t ? '' : String(_t)); };
  const _qfSel = (m) => !noTier && hasRetail && retailVal === Math.round(costWholesale * m / 5) * 5;
  const _ooQfSel = (m) => { const _ov = parseFloat(ooRetail) || 0; return _ov > 0 && _ov === Math.round(ooCost * m / 5) * 5; };
  // 2026-06-10 (Loom 33 3:10): cost per meeting is now a full calc section
  // (quick fill -> wholesale -> your retail -> you keep), like the other two.
  // 2026-06-26 (Loom 61): persist the per-meeting price and meetings/month so the
  // sidebar Net profit (monthly) can fold in meetings x the agency's 40% share. Only
  // the sales row (cpm > 0) writes these keys, guarded in the effects below.
  const [cpmRetail, setCpmRetail] = React.useState(() => { try { return localStorage.getItem('gg.margin.cpm.retail') || ''; } catch (e) { return ''; } });
  const [cpmMeetings, setCpmMeetings] = React.useState(() => { try { return localStorage.getItem('gg.margin.cpm.meetings') || '10'; } catch (e) { return '10'; } });
  const [cpmOv, setCpmOv] = React.useState(false); // 2026-06-25: success-share overlay open state
  const [cpmFitOv, setCpmFitOv] = React.useState(false); // 2026-06-26: which-clients overlay open state
  React.useEffect(() => { if (!(cpm > 0 || cpmNoTier)) return undefined; try { localStorage.setItem('gg.margin.cpm.retail', cpmRetail); } catch (e) {} try { window.dispatchEvent(new Event('gg:margin-retail')); } catch (e) {} return undefined; }, [cpmRetail, cpm]);
  React.useEffect(() => { if (!(cpm > 0 || cpmNoTier)) return undefined; try { localStorage.setItem('gg.margin.cpm.meetings', cpmMeetings); } catch (e) {} try { window.dispatchEvent(new Event('gg:margin-retail')); } catch (e) {} return undefined; }, [cpmMeetings, cpm]);
  const _cpmRetailN = parseFloat(cpmRetail) || 0;
  const _cpmMeetingsN = parseFloat(cpmMeetings) || 0;
  // Loom 60: Cost Per Meeting Success Share. Agency sets the per-meeting price (min £200);
  // we take a fixed 60% (our price), they keep 40%. Below the minimum snaps up on blur.
  const CPM_MIN = 200;
  const _cpmOurPrice = Math.round(_cpmRetailN * 0.6);
  const _cpmMargin = _cpmRetailN - _cpmOurPrice;
  const _cpmBelowMin = _cpmRetailN > 0 && _cpmRetailN < CPM_MIN;
  const _commitCpmMin = () => { const n = parseFloat(cpmRetail) || 0; if (n > 0 && n < CPM_MIN) setCpmRetail(String(CPM_MIN)); };

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

  return (
    <div className={`svc-margin ${flat ? 'svc-margin--flat' : 'svc-margin--gold svc-margin--metal'} ${stateClass}`} role="region" aria-label="White-label margin calculator">
      <div className="svc-margin__head">
        <div className="svc-margin__head-icon svc-margin__head-icon--img" aria-hidden="true">
          {/* 2026-06-19: swapped the inline £ glyph for the economies-of-scale webp illustration (per request) */}
          <img src="assets/icons/economies%20of%20scale.webp" alt="" className="svc-margin__head-img" loading="lazy" />
        </div>
        <div className="svc-margin__head-text">
          <div className="svc-margin__title-row">
            <div className="svc-margin__title">{title || 'White-label margin'}</div>
            {commitId && !flat && !autoFill && (
              <div className="svc-margin__commit">
                <span className="svc-margin__commit-label">{commitId}-month minimum commitment<HoverPortalTip interactive wrapClassName="svc-margin__commit-tip-wrap" tipClassName="dis-tip dis-tip--above" placement="above" tip={<><span className="dis-tip__body" style={{display:'block'}}>We can draft the contract between you and your client based on this commitment period. We have different pricing for each minimum commitment, with lower pricing on longer terms, up to 40% off for a 12-month minimum.</span><span className="dis-tip__body" style={{display:'block',marginTop:'0.55em'}}>A longer term benefits both of you, locking in recurring profit for longer. Six months is our most popular, and we recommend encouraging your client toward a longer commitment.</span></>}><window.InfoIcon className="svc-margin__commit-info" /></HoverPortalTip></span>
                <button type="button" className="btn btn--primary svc-margin__commit-change" onClick={(e) => { e.stopPropagation(); try { const card = e.currentTarget.closest('[data-svc-id]'); const bar = card && card.querySelector('.svc__commit-bar'); if (bar && bar.scrollIntoView) bar.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (err) {} }}>Change</button>
              </div>
            )}
          </div>
          <div className="svc-margin__hint"><span className="svc-margin__hint-text">{noTier ? 'Select a plan above to see your wholesale cost and what you keep.' : 'You get wholesale pricing and charge your client whatever you like.'}</span>{(cpm > 0 || cpmNoTier) && (<button type="button" className="svc__talent-wl-link svc-margin__fit-link" onClick={(e) => { e.stopPropagation(); setCpmFitOv(true); }}><span className="svc__talent-wl-link__text">Which clients does this service work well for?</span> <span className="svc__talent-wl-link__arrow" aria-hidden="true">{'\u203a'}</span></button>)}</div>
        </div>
        {cpmFitOv && <GMOverlayModal data={CPM_FIT_OVERLAY} onClose={() => setCpmFitOv(false)} />}
      </div>

      {!flat && (
        <div className="svc-margin__vat" onClick={(e) => e.stopPropagation()}>
          <div className="svc-margin__vat-row">
            <label className="svc-margin__vat-toggle">
              <input type="checkbox" checked={vatReg && !outsideUk} onChange={(e) => { setVat(e.target.checked); if (e.target.checked) setOutsideUk(false); }} />
              {/* 2026-06-04: custom box styled like the tier-card checkbox
                  (orange gradient + neumorphic bevel) instead of the native input. */}
              <span className={`svc-margin__vat-box ${vatReg && !outsideUk ? 'is-on' : ''}`} aria-hidden="true">
                {vatReg && !outsideUk && (
                  <svg viewBox="0 0 16 16" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
                    <path d="M3.5 8.5l3 3L12.5 5" />
                  </svg>
                )}
              </span>
              <span className="svc-margin__vat-lbl">I am VAT-registered</span>
            </label>
            <HoverPortalTip as="span" wrapClassName="svc-margin__section-info-wrap" tipClassName="dis-tip dis-tip--above" placement="above" tip={<><span className="dis-tip__body">This applies to UK businesses. If you are VAT-registered, you can reclaim the VAT we charge, so it stays cost neutral for you. If you are not registered yet, we include the 20% VAT here so your margin reflects what you really pay. You can switch this for every service at once.</span></>}>
              <window.InfoIcon className="svc-margin__cell-lbl-info" />
            </HoverPortalTip>
            {/* 2026-06-08: international toggle - we only charge VAT to UK
                businesses, so this removes UK VAT entirely. */}
            <label className="svc-margin__vat-toggle svc-margin__vat-toggle--intl">
              <input type="checkbox" checked={outsideUk} onChange={(e) => { setOutsideUk(e.target.checked); if (e.target.checked) setVat(false); }} />
              <span className={`svc-margin__vat-box ${outsideUk ? 'is-on' : ''}`} aria-hidden="true">
                {outsideUk && (
                  <svg viewBox="0 0 16 16" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
                    <path d="M3.5 8.5l3 3L12.5 5" />
                  </svg>
                )}
              </span>
              <span className="svc-margin__vat-lbl">I am located outside the UK</span>
            </label>
            <HoverPortalTip as="span" wrapClassName="svc-margin__section-info-wrap" tipClassName="dis-tip dis-tip--above" placement="above" tip={<><span className="dis-tip__body">We only charge VAT to UK businesses, so if your agency is based outside the UK, we remove it entirely and your wholesale cost is shown net. We work with reseller agencies across over 20 countries, in multiple languages, and in your time zone. You can switch this for every service at once.</span></>}>
              <window.InfoIcon className="svc-margin__cell-lbl-info" />
            </HoverPortalTip>
          </div>
          {/* 2026-06-12 (Loom 45 0:30): the amber VAT note is gone, the two
              checkbox tooltips already tell the same story and the box only
              added vertical space. */}
        </div>
      )}
      {/* 2026-06-06: compact (flat) variant moves the quick-fill chips into
          the cells row below (left column) to free vertical space at the top;
          full-size calculators keep the standalone row here. */}
      {/* 2026-06-08 (Loom 29): standalone Quick fill row removed; chips now live in the left column for the main calculator too, matching the per-add-on boxes. */}
      {/* 2026-06-08 (Loom 29 3:52): Estimated cost per meeting moved below, between Monthly recurring and One-off. */}
      {(!flat || _unitWord === 'day') && (
      <div className="svc-margin__section-head">
        {_unitWord === 'day' ? 'Day rate' : (_unitWord === 'one-off' ? 'One-off' : 'Monthly recurring')}
        <HoverPortalTip wrapClassName="svc-margin__section-info-wrap" tipClassName="dis-tip dis-tip--above" placement="above" tip={monthlyTip ? <><span className="dis-tip__body">{monthlyTip}</span></> : <><span className="dis-tip__body">Your ongoing monthly costs and pricing. One-off setup fees are billed once at the start and shown in the full breakdown.</span></>}>
          <window.InfoIcon className="svc-margin__cell-lbl-info" />
        </HoverPortalTip>
      </div>
      )}

      <div className="svc-margin__calc svc-margin__calc--hasquick">
        {/* 2026-06-06: flat variant only - quick-fill chips live as the first
            column of the cells row (per Alexander's layout sketch). */}
        {(
          <div className="svc-margin__cell svc-margin__cell--quickcol thin-glass-frame" onClick={(e) => e.stopPropagation()}>
            <div className="svc-margin__cell-lbl">
                <span className="svc-margin__cell-lbl-text">Quick fill<HoverPortalTip wrapClassName="svc-margin__cell-lbl-tip-wrap" tipClassName="dis-tip dis-tip--above" placement="above" tip={<><span className="dis-tip__body">Set your retail price to a multiple of your wholesale cost, then fine-tune it. For example, 2x doubles the wholesale for a 50% margin.</span></>}><window.InfoIcon className="svc-margin__cell-lbl-info" /></HoverPortalTip></span>
            </div>
            <div className="svc-margin__quickcol-btns">
              <button type="button" disabled={noTier} className={`svc-margin__quick-btn ${noTier ? 'is-disabled' : ''} ${_qfSel(1.5) ? 'is-selected' : ''}`} onClick={(e) => { e.stopPropagation(); fillSuggested(1.5); }}>1.5×</button>
              <button type="button" disabled={noTier} className={`svc-margin__quick-btn svc-margin__quick-btn--pop ${noTier ? 'is-disabled' : ''} ${_qfSel(2) ? 'is-selected' : ''}`} onClick={(e) => { e.stopPropagation(); fillSuggested(2); }}>2×</button>
              <button type="button" disabled={noTier} className={`svc-margin__quick-btn ${noTier ? 'is-disabled' : ''} ${_qfSel(3) ? 'is-selected' : ''}`} onClick={(e) => { e.stopPropagation(); fillSuggested(3); }}>3×</button>
            </div>
          </div>
        )}
        <div className="svc-margin__cell svc-margin__cell--cost thin-glass-frame">
          <div className="svc-margin__cell-lbl">
            
              <span className="svc-margin__cell-lbl-text">
                Your wholesale cost
                <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.com charges you, quoted excluding VAT. You bill your client separately at whatever price you choose.</span>
              </>}
            ><window.InfoIcon className="svc-margin__cell-lbl-info" /></HoverPortalTip>
              </span>
            
          </div>
          <div className="svc-margin__cell-num">{noTier ? <span style={{fontSize:'0.8rem', fontWeight:600, lineHeight:1.25, color:'var(--gg-muted,#5a647d)', display:'inline-block'}}>Select a plan above</span> : window.fmt(costWholesale)}</div>
          {!noTier && <div className="svc-margin__cell-unit">{_unitLabel}{_vatApplies ? ' (includes 20% VAT)' : ''}</div>}
          {(Array.isArray(items) ? items.filter(it => !it.aid).length : 0) > 1 && (
            <div className="svc-margin__cell-rrp" style={{fontSize:'0.68rem', color:'var(--gg-muted, #5a647d)', marginTop:'2px'}}>Includes your plan and channels</div>
          )}
          {/* 2026-06-08 (Loom 29 3:32): "Our direct client price" line removed; RRP now shown inline on the tier card. */}
          {_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 thin-glass-frame">
          <div className="svc-margin__cell-lbl">
            
              <span className="svc-margin__cell-lbl-text">
                Your retail price
                <HoverPortalTip
              wrapClassName="svc-margin__cell-lbl-tip-wrap"
              tipClassName="dis-tip dis-tip--above"
              placement="above"
              tip={<>
                <span className="dis-tip__body" style={{display:'block'}}>You set this price and keep the margin. Your client pays you directly and never sees GoGorilla.com. Any VAT you add for your client sits on top.</span><span className="dis-tip__body" style={{display:'block',marginTop:'0.55em'}}>This box is optional, only here to help you work out your margin.</span>
              </>}
            ><window.InfoIcon className="svc-margin__cell-lbl-info" /></HoverPortalTip>
                {effRrp > 0 && <span style={{ marginLeft: '4px', fontWeight: 400, fontStyle: 'italic', fontSize: '0.82em', color: 'var(--gg-blue, #002abf)', whiteSpace: 'nowrap' }}>(RRP £{Math.round(effRrp).toLocaleString('en-GB')})</span>}
              </span>
            
          </div>
          <div className="svc-margin__cell-input">
            <span className="svc-margin__currency">£</span>
            <input
              id={`retail-${serviceId}`}
              type="text"
              inputMode="numeric"
              placeholder=""
              value={retail ? Number(retail).toLocaleString('en-GB') : ''}
              onChange={(e) => setRetail(e.target.value.replace(/[^0-9]/g, ''))}
              onClick={(e) => e.stopPropagation()}
              onFocus={(e) => e.target.select()}
            />
          </div>
          <div className="svc-margin__cell-unit">{_unitLabel}</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 thin-glass-frame">
          <div className="svc-margin__cell-lbl">
            
              <span className="svc-margin__cell-lbl-text">
                You keep
                <HoverPortalTip
              wrapClassName="svc-margin__cell-lbl-tip-wrap"
              tipClassName="dis-tip dis-tip--above"
              placement="above"
              tip={<><span className="dis-tip__body">This is what you keep, your client price minus our wholesale cost. You pay us wholesale and charge your client whatever you like.</span></>}
            ><window.InfoIcon className="svc-margin__cell-lbl-info" /></HoverPortalTip>
              </span>
            
          </div>
          <div className="svc-margin__cell-num">
            {/* 2026-06-12 (Loom 45 0:48): the /month unit shows even before a
                retail price is entered, so the empty state reads as a monthly
                figure waiting to happen. */}
            {noTier ? <span style={{fontSize:'0.8rem', fontWeight:600, lineHeight:1.25, color:'var(--gg-muted,#5a647d)', display:'inline-block'}}>Select a plan above</span> : (hasRetail ? <>{window.fmt(Math.round(margin))}<span className="svc-margin__cell-unit svc-margin__cell-unit--inline">{_unitLabel}</span></> : <><span className="svc-margin__cell-num--mute">£,</span><span className="svc-margin__cell-unit svc-margin__cell-unit--inline">{_unitLabel}</span></>)}
          </div>
          {hasRetail && !noTier && (
            <span className={`svc-margin__pct ${isLoss ? 'svc-margin__pct--loss' : 'svc-margin__pct--good'}`}>
              {margin >= 0 ? '+' : ''}{Math.round(marginPct * 100)}% margin
              <HoverPortalTip
                as="span"
                wrapClassName="svc-margin__pct-tip-wrap"
                tipClassName="dis-tip dis-tip--above"
                placement="above"
                tip={<><span className="dis-tip__body">Your margin is the share of your client price you keep after our wholesale cost. Charging double the wholesale gives a 50% margin.</span></>}
              >
                <window.InfoIcon className="svc-margin__pct-info" />
              </HoverPortalTip>
            </span>
          )}
          {!hasRetail && !noTier && <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>
      )}

      {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)}{_unitWord === 'month' ? '/mo' : ''} to break even.</span>
        </div>
      )}
      {(cpm > 0 || cpmNoTier) && (
        <div className="svc-margin__cpm-section">
          <div className="svc-margin__section-head">
            Cost Per Meeting Success Share
            <GMTipRich label="Cost Per Meeting Success Share" data={CPM_SHARE_TIP} hasOverlay onOpenOverlay={() => setCpmOv(true)} />
          </div>
          {cpmOv && <GMOverlayModal data={CPM_SHARE_OVERLAY} onClose={() => setCpmOv(false)} />}
          <div className="svc-margin__calc">
            <div className="svc-margin__cell svc-margin__cell--meetings thin-glass-frame">
              <div className="svc-margin__cell-lbl"><span className="svc-margin__cell-lbl-text">Meetings / mo<HoverPortalTip interactive wrapClassName="svc-margin__cell-lbl-tip-wrap" tipClassName="dis-tip dis-tip--above" placement="above" tip={<><span className="dis-tip__body">Our North Star is the number of meetings we book each month, and our success-sharing model means we work to book as many as we can. As a general rule, we aim for at least ten a month, and for a well-defined ICP with a proven sales process, product-market fit, and good brand awareness we can book many more.</span></>}><window.InfoIcon className="svc-margin__cell-lbl-info" /></HoverPortalTip></span></div>
              <div className="svc-margin__cell-input"><input type="text" inputMode="numeric" placeholder="" value={cpmMeetings ? Number(cpmMeetings).toLocaleString('en-GB') : ''} onChange={(e) => setCpmMeetings(e.target.value.replace(/[^0-9]/g, ''))} onClick={(e) => e.stopPropagation()} onFocus={(e) => e.target.select()} aria-label="Expected meetings per month" /></div>
            </div>
            <div className="svc-margin__cell svc-margin__cell--cost thin-glass-frame">
              <div className="svc-margin__cell-lbl"><span className="svc-margin__cell-lbl-text">Our price<HoverPortalTip interactive wrapClassName="svc-margin__cell-lbl-tip-wrap" tipClassName="dis-tip dis-tip--above" placement="above" tip={<><span className="dis-tip__body" style={{display:'block'}}>What we charge you, a fixed 60% of your per-meeting price. We book and run the meetings, so this share is spread across our SDR pod, calling, and tooling.</span><span className="dis-tip__body" style={{display:'block',marginTop:'0.55em'}}>You can charge your client as much as you like above the £200 minimum, and you always keep the other 40%.</span></>}><window.InfoIcon className="svc-margin__cell-lbl-info" /></HoverPortalTip></span></div>
              <div className="svc-margin__cell-num">{_cpmRetailN > 0 ? window.fmt(_cpmOurPrice) : <span className="svc-margin__cell-num--mute">£,</span>}</div>
              <div className="svc-margin__cell-unit">/meeting · 60%</div>
              {_cpmRetailN > 0 && _cpmMeetingsN > 0 && <span className="svc-margin__cell-monthly">{'\u00d7'} {_cpmMeetingsN.toLocaleString('en-GB')} = {window.fmt(_cpmOurPrice * _cpmMeetingsN)}/mo</span>}
            </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 9h14" /><path d="M5 15h14" /></svg>
            </div>
            <div className="svc-margin__cell svc-margin__cell--retail thin-glass-frame">
              <div className="svc-margin__cell-lbl"><span className="svc-margin__cell-lbl-text">Wholesale rate<HoverPortalTip interactive wrapClassName="svc-margin__cell-lbl-tip-wrap" tipClassName="dis-tip dis-tip--above" placement="above" tip={<><span className="dis-tip__body">Your wholesale rate per approved meeting, with a £200 minimum. You can set it higher to match the value of what your client sells.</span></>}><window.InfoIcon className="svc-margin__cell-lbl-info" /></HoverPortalTip></span></div>
              <div className="svc-margin__cell-input">
                <span className="svc-margin__currency">£</span>
                <input type="text" inputMode="numeric" placeholder="200" value={cpmRetail ? Number(cpmRetail).toLocaleString('en-GB') : ''} onChange={(e) => setCpmRetail(e.target.value.replace(/[^0-9]/g, ''))} onBlur={_commitCpmMin} onClick={(e) => e.stopPropagation()} onFocus={(e) => e.target.select()} aria-label="Wholesale rate per meeting" />
              </div>
              <div className="svc-margin__cell-unit">/meeting</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="M12 5v14" /><path d="M5 12h14" /></svg>
            </div>
            <div className="svc-margin__cell svc-margin__cell--result thin-glass-frame">
              <div className="svc-margin__cell-lbl"><span className="svc-margin__cell-lbl-text">You keep<HoverPortalTip interactive wrapClassName="svc-margin__cell-lbl-tip-wrap" tipClassName="dis-tip dis-tip--above" placement="above" tip={<><span className="dis-tip__body">What you keep on every meeting, a fixed 40% of your per-meeting price. The split is always 60/40, so your margin stays the same whatever you charge.</span></>}><window.InfoIcon className="svc-margin__cell-lbl-info" /></HoverPortalTip></span></div>
              <div className="svc-margin__cell-num">{_cpmRetailN > 0 ? <>{window.fmt(_cpmMargin)}<span className="svc-margin__cell-unit svc-margin__cell-unit--inline">/meeting</span></> : <span className="svc-margin__cell-num--mute">£,</span>}</div>
              {_cpmRetailN > 0 && (_cpmBelowMin ? <span className="svc-margin__pct svc-margin__pct--loss">Below £200 minimum</span> : <span className="svc-margin__pct svc-margin__pct--good">Fixed +40% margin</span>)}
              {_cpmRetailN > 0 && _cpmMeetingsN > 0 && <span className="svc-margin__cell-monthly">{'\u00d7'} {_cpmMeetingsN.toLocaleString('en-GB')} = {window.fmt(_cpmMargin * _cpmMeetingsN)}/mo</span>}
              {!(_cpmRetailN > 0) && <div className="svc-margin__cell-unit">enter rate</div>}
            </div>
          </div>
        </div>
      )}
      {!autoFill && (noTierOneoff || (Array.isArray(oneoffItems) && oneoffItems.length > 0 && oneoffWholesale > 0)) && (
        <div className="svc-margin__oneoff">
          <div className="svc-margin__section-head svc-margin__section-head--oneoff">
            One-off fees
            <HoverPortalTip as="span" wrapClassName="svc-margin__section-info-wrap" tipClassName="dis-tip dis-tip--above" placement="above" tip={<><span className="dis-tip__body">One-time charges such as setup and any one-off add-ons, billed once at the start and separate from your monthly price.</span></>}>
              <window.InfoIcon className="svc-margin__cell-lbl-info" />
            </HoverPortalTip>
          </div>
          <div className="svc-margin__calc svc-margin__calc--hasquick">
            <div className="svc-margin__cell svc-margin__cell--quickcol thin-glass-frame" onClick={(e) => e.stopPropagation()}>
              <div className="svc-margin__cell-lbl">
                <span className="svc-margin__cell-lbl-text">Quick fill<HoverPortalTip wrapClassName="svc-margin__cell-lbl-tip-wrap" tipClassName="dis-tip dis-tip--above" placement="above" tip={<><span className="dis-tip__body">Set your retail price to a multiple of your wholesale cost, then fine-tune it. For example, 2x doubles the wholesale for a 50% margin.</span></>}><window.InfoIcon className="svc-margin__cell-lbl-info" /></HoverPortalTip></span>
              </div>
              <div className="svc-margin__quickcol-btns">
                <button type="button" disabled={noTierOneoff} className={`svc-margin__quick-btn ${noTierOneoff ? 'is-disabled' : ''} ${_ooQfSel(1.5) ? 'is-selected' : ''}`} onClick={(e) => { e.stopPropagation(); fillOoSuggested(1.5); }}>1.5×</button>
                <button type="button" disabled={noTierOneoff} className={`svc-margin__quick-btn svc-margin__quick-btn--pop ${noTierOneoff ? 'is-disabled' : ''} ${_ooQfSel(2) ? 'is-selected' : ''}`} onClick={(e) => { e.stopPropagation(); fillOoSuggested(2); }}>2×</button>
                <button type="button" disabled={noTierOneoff} className={`svc-margin__quick-btn ${noTierOneoff ? 'is-disabled' : ''} ${_ooQfSel(3) ? 'is-selected' : ''}`} onClick={(e) => { e.stopPropagation(); fillOoSuggested(3); }}>3×</button>
              </div>
            </div>
            <div className="svc-margin__cell svc-margin__cell--cost thin-glass-frame">
              <div className="svc-margin__cell-lbl"><span className="svc-margin__cell-lbl-text">Your wholesale cost<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 for one-off fees such as setup, the rate GoGorilla.com charges you. You bill your client separately at whatever price you choose.</span></>}><window.InfoIcon className="svc-margin__cell-lbl-info" /></HoverPortalTip></span></div>
              <div className="svc-margin__cell-num">{noTierOneoff ? <span style={{fontSize:'0.8rem', fontWeight:600, lineHeight:1.25, color:'var(--gg-muted,#5a647d)', display:'inline-block'}}>Select a plan above</span> : window.fmt(ooCost)}</div>
              {!noTierOneoff && <div className="svc-margin__cell-unit">one-off{_vatApplies ? ' (includes 20% VAT)' : ''}</div>}
              {/* 2026-06-08 (Loom 29 3:32): "Our direct client price" line removed; RRP now shown on the tier card. */}
            </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 thin-glass-frame">
              <div className="svc-margin__cell-lbl"><span className="svc-margin__cell-lbl-text">Your retail price<HoverPortalTip wrapClassName="svc-margin__cell-lbl-tip-wrap" tipClassName="dis-tip dis-tip--above" placement="above" tip={<><span className="dis-tip__body" style={{display:'block'}}>You bill your client directly for the one-off work, so you set this price and keep the margin.</span><span className="dis-tip__body" style={{display:'block',marginTop:'0.55em'}}>This box is optional, only here to help you work out your margin, so you can leave it blank.</span></>}><window.InfoIcon className="svc-margin__cell-lbl-info" /></HoverPortalTip>{_ooBaseRrp > 0 && <span style={{ marginLeft: '4px', fontWeight: 400, fontStyle: 'italic', fontSize: '0.82em', color: 'var(--gg-blue, #002abf)', whiteSpace: 'nowrap' }}>(RRP £{Math.round(_ooBaseRrp).toLocaleString('en-GB')})</span>}</span></div>
              <div className="svc-margin__cell-input">
                <span className="svc-margin__currency">£</span>
                <input type="text" inputMode="numeric" placeholder="" value={ooRetail ? Number(ooRetail).toLocaleString('en-GB') : ''} onChange={(e) => setOoRetail(e.target.value.replace(/[^0-9]/g, ''))} onClick={(e) => e.stopPropagation()} onFocus={(e) => e.target.select()} disabled={noTierOneoff} aria-label="Your one-off retail price" />
              </div>
              <div className="svc-margin__cell-unit">one-off</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 thin-glass-frame">
              <div className="svc-margin__cell-lbl"><span className="svc-margin__cell-lbl-text">You keep<HoverPortalTip wrapClassName="svc-margin__cell-lbl-tip-wrap" tipClassName="dis-tip dis-tip--above" placement="above" tip={<><span className="dis-tip__body">This is what you keep on the one-off fees, your client price minus our wholesale cost.</span></>}><window.InfoIcon className="svc-margin__cell-lbl-info" /></HoverPortalTip></span></div>
              <div className="svc-margin__cell-num">{(!noTierOneoff && (parseFloat(ooRetail) || 0) > 0) ? <>{window.fmt(Math.round((parseFloat(ooRetail) || 0) - ooCost))}<span className="svc-margin__cell-unit svc-margin__cell-unit--inline">one-off</span></> : <span className="svc-margin__cell-num--mute">£,</span>}</div>
              {(!noTierOneoff && (parseFloat(ooRetail) || 0) > 0) && (
                <span className={`svc-margin__pct ${((parseFloat(ooRetail) || 0) - ooCost) < 0 ? 'svc-margin__pct--loss' : 'svc-margin__pct--good'}`}>{((parseFloat(ooRetail) || 0) - ooCost) >= 0 ? '+' : ''}{Math.round((((parseFloat(ooRetail) || 0) - ooCost) / ((parseFloat(ooRetail) || 0) || 1)) * 100)}% margin<HoverPortalTip as="span" wrapClassName="svc-margin__pct-tip-wrap" tipClassName="dis-tip dis-tip--above" placement="above" tip={<><span className="dis-tip__body">Your margin is the share of your client price you keep after our wholesale cost. Charging double the wholesale gives a 50% margin.</span></>}><window.InfoIcon className="svc-margin__pct-info" /></HoverPortalTip></span>
              )}
              {!noTierOneoff && !((parseFloat(ooRetail) || 0) > 0) && <div className="svc-margin__cell-unit">enter retail</div>}
            </div>
          </div>
        </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, selected = true }) {
  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 (!selected) {
    // Not selected yet: show the calculator at the lowest available regional rate
    // as a starting point, matching how the other add-ons preview before selection.
    const _map = (_W.addons && _W.addons[addon.id]) || {};
    const _vals = Object.values(_map).filter(v => typeof v === 'number' && v > 0);
    const _floor = _vals.length ? Math.min.apply(null, _vals) : (typeof addon.price === 'number' ? addon.price : 0);
    if (_floor > 0) {
      return (
        <div className="addon__margin" onClick={(e) => e.stopPropagation()} style={{ marginTop: '10px' }}>
          <window.MarginRow wholesale={_floor} rrp={_floor} serviceId={`addon-${addon.id}`} tierName={addon.name} title={`${addon.name} margin`} flat autoFill />
        </div>
      );
    }
    return (
      <div className="addon__margin" onClick={(e) => e.stopPropagation()} style={{ marginTop: '10px', fontSize: '0.8rem', color: 'var(--gg-muted, #6B7280)' }}>
        Select this add-on to choose your calling markets and see your wholesale cost and margin.
      </div>
    );
  }
  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>
    );
  }
  // 2026-06-12 (decision confirmed): wholesale and the reference retail both
  // sum the selected regions' bands. Rest of World alone reads by quote.
  const _ws = window.callingWholesaleSum ? window.callingWholesaleSum(regions, addon && addon._ctxCommitId) : null;
  const byQuote = !!(_ws && _ws.byQuoteOnly);
  const wholesale = (_ws && typeof _ws.value === 'number') ? _ws.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={(typeof wholesale === 'number' ? wholesale : addon.price)}
          serviceId={`addon-${addon.id}`}
          tierName={addon.name}
          title={`${addon.name} margin`}
          flat
          autoFill
        />
      )}
    </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.com 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.com 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(() => {
    if (window.ggCloseAllTips) window.ggCloseAllTips();
    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>
        <div className="cmp-modal__title-row" style={{ paddingRight: '2.5rem' }}>
          <h2 id="cmp-modal-title" className="cmp-modal__title" style={{ margin: 0, paddingRight: 0, display: 'inline' }}>{service.name} tiers compared</h2>
          {service.pricingUrl && (
            <a className="svc__title-cmp-btn" href={service.pricingUrl} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>
              <span className="svc__title-cmp-btn-text">See full pricing</span>
              <span className="svc__title-cmp-btn-arrow" aria-hidden="true">↗</span>
            </a>
          )}
        </div>
        {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 _pr = window.priceFor(service, t.id, 12);
              const _isCustom = t.isEnterprise || _pr.custom;
              const _isFree = !_isCustom && _pr.value === 0;
              const priceLabel = _isCustom ? 'Custom' : (_isFree ? 'Free' : window.fmt(_pr.value));
              const _subText = _pr.oneTime ? 'one-off' : '/mo';
              const _showSub = !_isCustom && !_isFree;
              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}
                      {_showSub && <span className="cmp-card__tier-sub">{_subText}</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';
                          // Channels section: hide channels not in this plan so there are no greyed-out rows.
                          const inChannels = String(sec.label || '').toLowerCase() === 'channels';
                          if (v === 'no' && inChannels) return null;
                          // 'addon' (e.g. LinkedIn) reads in the normal colour with a tick; the (add-on) label makes it clear.
                          const avail = (v === 'yes' || v === 'addon');
                          const cls = avail ? 'cmp-card__row--yes' : '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">{avail ? '✓' : '−'}</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.
// 2026-06-08 (Loom 31 01:49-02:37): reusable quantity input. Lets the user
// type and delete freely (local draft state), and clamps to the minimum on
// blur instead of fighting every keystroke. Used by all add-on counters so the
// "can't delete / can't go below minimum" bug is fixed everywhere.
function QtyInput({ id, className = '', value, min = 1, max = 99999, onCommit, ariaLabel, placeholder }) {
  const [draft, setDraft] = React.useState(String(value == null ? '' : value));
  React.useEffect(() => { setDraft(String(value == null ? '' : value)); }, [value]);
  const commit = () => {
    const v = parseInt(draft, 10);
    const clamped = Number.isFinite(v) ? Math.max(min, Math.min(max, v)) : min;
    if (onCommit) onCommit(clamped);
    setDraft(String(clamped));
  };
  return React.createElement('input', {
    id, className, type: 'number', inputMode: 'numeric', min, max, step: 1,
    value: draft, placeholder,
    onClick: (e) => e.stopPropagation(),
    onKeyDown: (e) => { e.stopPropagation(); if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } },
    onChange: (e) => setDraft(e.target.value),
    onBlur: commit,
    'aria-label': ariaLabel,
  });
}
window.QtyInput = QtyInput;

function InfoIcon({ className = '', title, onClick, size = 13, tabIndex = -1, as = 'button' }) {
  /* 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(
      as,
      Object.assign({
        className: `info-icon ${className}`.trim(),
        'aria-label': title || 'More info',
        onClick,
        tabIndex,
      }, as === 'button' ? { type: 'button' } : {}),
      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;

  // Loom 58: the moment the calculator loads in post-call mode (the Cal.com
  // redirect after a booking), fire the lead notification once — Slack alert +
  // resume link + the Airtable / platform lead row — so a booked call is captured
  // even if the visitor clicks nothing further. Deduped per booking; waits briefly
  // for the quote to compute so the alert carries the totals.
  React.useEffect(() => {
    if (!isPostCallMode()) return;
    // The lead arrived from the Cal booking with their email in the redirect but
    // never typed it into the calculator. Treat it as the known lead email so the
    // autosave fires as they keep configuring before the call — each save upserts
    // their LATEST state onto the same row the resume link reads. Set before the
    // dedupe return so a reload still arms autosave.
    try { var _pcE = postCallEmail(); if (_pcE && !window.__leadEmail) window.__leadEmail = _pcE; } catch (e) {}
    var key;
    try {
      var _p = new URLSearchParams(window.location.search || '');
      key = 'gg.booked-alert.' + (_p.get('uid') || _p.get('bookingUid') || state.quote_uuid || 'booked');
      if (window.localStorage.getItem(key)) return;
    } catch (e) { return; }
    var done = false, tries = 0;
    var fire = function () {
      if (done) return; done = true;
      try { window.localStorage.setItem(key, String(Date.now())); } catch (e) {}
      try {
        var _email = postCallEmail() || (state.referral && state.referral.email) || '';
        trackCheckoutAction('book_call', state, '', _email, { firstName: postCallName(), phone: postCallPhone() });
      } catch (e) {}
    };
    var iv = setInterval(function () {
      tries++;
      var live = window.__currentQuote || {};
      if ((Number(live.monthlyTotal) || 0) > 0 || (Number(live.oneTimeTotal) || 0) > 0 || tries >= 6) {
        clearInterval(iv); fire();
      }
    }, 500);
    return function () { clearInterval(iv); };
  }, []);
  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 [bannerSavingsOv, setBannerSavingsOv] = React.useState(false); // 2026-06-26 (Loom 64): white-label banner -> available savings overlay
  const [bannerReferHelpOv, setBannerReferHelpOv] = React.useState(false); // 2026-07-02: How-do-I-refer link under the switch-back toggle
  const [referConfirm, setReferConfirm] = React.useState(false); // Loom 69: confirm before switching to referring
  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;
  // Freelancer engine hero: the CEO greeting + video replaces the generic intro
  // header on the client step (but not in post-call mode). Optional ?name= personalises it.
  const _flHero = !!(typeof window !== 'undefined' && window.isFreelancerMode && window.isFreelancerMode()) && !isPostCallMode();
  const _flName = (() => { try { const n = new URLSearchParams(window.location.search || '').get('name'); return n ? n.trim().replace(/[<>]/g, '').slice(0, 40) : ''; } catch (e) { return ''; } })();
  // 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 plan', titleC: '.', 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: 'Bring your ',               titleB: 'brand to life', titleC: '.', sub: 'Build and grow your community across social and content, plus AI-first 3D animation for your ad campaigns.' },
    talent:      { eyebrow: 'Talent solutions',   titleA: 'Plug into our ',            titleB: 'global talent infrastructure', titleC: '.', sub: 'Access better talent from our three global offices, with recruitment, HR, payroll, and compliance handled by us.' },
    fundraising: { eyebrow: 'Fundraising',        titleA: 'Access our investor ecosystem ', titleB: 'to secure capital sooner', titleC: '.', sub: 'Unlock the Founders Portal and our curated investor database, then add research and pitch materials aligned with your raise.' },
  };
  // Per-path heading overrides so each journey reads in its own frame. Eyebrow
  // (step label) stays the same; founder uses the defaults above.
  const PATH_HEADERS = {
    growth: {
      'agency-own':         { titleA: 'Outbound that ', titleB: 'grows your agency', titleC: '.', sub: 'Sales and demand generation built to win your agency new clients.' },
      'agency-whitelabel':  { titleA: "Build your client's ", titleB: 'growth stack', titleC: '.', sub: 'Pick the services you will resell. Prices shown are your wholesale cost.' },
      'investor-portfolio': { titleA: 'Pick the growth engines for ', titleB: 'your portfolio company', titleC: '.', sub: 'Sales, paid advertising, and email marketing for the company you back.' },
      'investor-considering': { titleA: 'Scope the growth for ', titleB: 'the company you are assessing', titleC: '.', sub: 'Sales, paid advertising, and email marketing. Pick what is relevant so we can frame your free assessment.' },
      'investor-general':      { titleA: 'Explore the engines for ', titleB: 'demand', titleC: '.', sub: 'Sales, paid advertising, and email marketing. Add anything you would like to talk through on your call.' },
    },
    creative: {
      'agency-whitelabel':  { titleA: "Bring your client's ", titleB: 'brand to life', titleC: '.', sub: 'Social, content, and 3D animation you can resell to your client.' },
      'investor-portfolio': { titleA: "Bring your portfolio company's ", titleB: 'brand to life', titleC: '.', sub: 'Social, content, and 3D animation for the company you back.' },
      'investor-considering': { titleA: 'Scope the creative for ', titleB: 'the company you are assessing', titleC: '.', sub: 'Social, content, and 3D animation. Include whatever is relevant to your assessment.' },
      'investor-general':      { titleA: 'Explore creative that ', titleB: 'performs', titleC: '.', sub: 'Social, content, and 3D animation. Add anything you are curious about.' },
    },
    talent: {
      'agency-own':         { titleA: 'Plug into our ', titleB: 'global talent infrastructure', titleC: '.', sub: 'Access better talent from our three global offices, with recruitment, HR, payroll, and compliance handled by us.' },
      'agency-whitelabel':  { titleA: 'Resell our ', titleB: 'global talent infrastructure', titleC: '.', sub: 'Offer dedicated specialists to your clients and earn 10% recurring commission for the duration of the minimum commitment. The price shown is the standard rate your client pays.' },
      'investor-portfolio': { titleA: 'Plug into our ', titleB: 'global talent infrastructure', titleC: '.', sub: 'Access better talent from our three global offices, with recruitment, HR, payroll, and compliance handled by us.' },
      'investor-considering': { titleA: 'Scope the specialists for ', titleB: 'the company you are assessing', titleC: '.', sub: 'Dedicated specialists across marketing, creative, ops, and engineering. Include any the target would need.' },
      'investor-general':      { titleA: 'Explore our ', titleB: 'global talent infrastructure', titleC: '.', sub: 'Dedicated specialists across marketing, creative, ops, and engineering. Add any you would like to discuss.' },
    },
    fundraising: {
      'investor-portfolio': { titleA: 'Unlock your ', titleB: 'Investor Portal benefits', titleC: '.', sub: 'Network access, deal flow, and portfolio benefits for the companies you back.' },
      'investor-partnership': { titleA: 'Shape your ', titleB: 'investor partnership', titleC: '.', sub: 'Network access, portfolio deal flow, and optional commission. Choose how you would like to partner.' },
      'investor-considering': { titleA: 'Reserve your ', titleB: 'Investor Portal access', titleC: '.', sub: 'Network access, deal flow, and partner benefits. It is currently a waiting list.' },
      'investor-general':      { titleA: 'Reserve your ', titleB: 'Investor Portal access', titleC: '.', sub: 'Network access, deal flow, and partner benefits. It is currently a waiting list.' },
    },
  };
  const _journeyKey = intentId === 'agency-own' ? 'agency-own'
    : intentId === 'agency-whitelabel' ? 'agency-whitelabel'
    : (clientTypeId === 'investor' && intentId === 'investor-portfolio') ? 'investor-portfolio'
    : (clientTypeId === 'investor' && intentId === 'investor-partnership') ? 'investor-partnership'
    : (clientTypeId === 'investor' && intentId === 'investor-considering') ? 'investor-considering'
    : (clientTypeId === 'investor' && intentId === 'investor-general') ? 'investor-general'
    : null;
  const _baseHeader = (stepDef.id === 'whitelabel' && isAgency && !isWhitelabelAgency)
    ? { eyebrow: 'Agency plan', titleA: 'Choose your ', titleB: 'agency plan', titleC: '.', sub: 'Select your agency plan. This sets your discount across all GoGorilla services.' }
    : headers[stepDef.id];
  const _hov = (_journeyKey && PATH_HEADERS[stepDef.id]) ? PATH_HEADERS[stepDef.id][_journeyKey] : null;
  let header = _hov ? { ..._baseHeader, ..._hov } : _baseHeader;
  // Freelancer skin: the agency-own growth heading speaks to a solo freelancer,
  // not "your agency". Other steps read fine as-is.
  const _flCopy = !!(typeof window !== 'undefined' && window.isFreelancerMode && window.isFreelancerMode());
  if (_flCopy && stepDef.id === 'growth' && intentId === 'agency-own') {
    header = { ...header, titleA: 'Outbound that ', titleB: 'grows your business', titleC: '.', sub: 'Sales and demand generation you can run for yourself or deliver to your clients.' };
  }
  // Item 4:13 (Loom): post-call, the FIRST service step welcomes the attendee by
  // name (from the booking redirect) now that they have answered the Step 0
  // questions, and nudges them to pick services. The nudge is tailored per path:
  // founders pick services they want, white-label agencies pick services to
  // resell to their client, own-agencies pick services to grow with, portfolio
  // investors pick for their portfolio companies, and partnership investors are
  // pointed at the Investor Portal. Later service steps keep their own subs.
  const _firstServiceStepIdx = activeFlow.findIndex(st => st.id !== 'client' && st.id !== 'checkout');
  const _isFirstServiceStep = _firstServiceStepIdx > 0 && step === _firstServiceStepIdx;
  const _postCallNudge = (() => {
    if (isAgency && intentId === 'agency-whitelabel') return 'Now pick the services you would like to resell to your client so we can make the most of your call.';
    if (isAgency) return (window.isFreelancerMode && window.isFreelancerMode()) ? 'Now tell us which services you would like to grow your business with so we can make the most of your call.' : 'Now tell us which services you would like to grow your agency with so we can make the most of your call.';
    if (clientTypeId === 'investor' && intentId === 'investor-portfolio') return 'Now tell us which services your portfolio company needs so we can make the most of your call.';
    if (clientTypeId === 'investor' && intentId === 'investor-partnership') return 'Now tell us how you would like to work with us through the Investor Portal so we can make the most of your call.';
    return 'Now tell us which services you are interested in so we can make the most of your call.';
  })();
  const _headerSub = (isPostCallMode() && _isFirstServiceStep)
    ? ((postCallName() ? ('Thanks for sharing those, ' + postCallName() + '. ') : 'Thanks for sharing those. ') + _postCallNudge)
    : header?.sub;
  // 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.com 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 (unmet prerequisite)
    return true;                           // 2026-06-26: allow forward tab nav to any unlocked step (skip pages directly)
  };
  // 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) &&
    !(isClientStep && clientTypeId === 'investor' && !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.com above to continue'
        : (isClientStep && clientTypeId === 'investor' && !intentId)
          ? 'Choose how you would like to work with GoGorilla.com above to continue'
          : (isQualifierStep && !qualifierIsComplete)
            ? 'Answer all questions to continue'
            : 'Choose a whitelabel option to continue'
    : null;

  // Batch 4 (Loom): post-call Step 0 leads with the qualifier and every question
  // is required (each reveals only after the previous is answered). Continue
  // therefore appears only once the WHOLE qualifier is complete, the same bar as
  // the final step. Normal mode and every other step are unaffected.
  const _postCallStep0Qualifier = isPostCallMode() && isClientStep && (clientTypeId === 'founder' || clientTypeId === 'agency');
  const _qualifierComplete = !_postCallStep0Qualifier
    || (clientTypeId === 'agency'
        ? (window.agencyQualifierComplete ? window.agencyQualifierComplete(state.qualifier, intentId) : true)
        : (window.qualifierComplete ? window.qualifierComplete(state.qualifier, clientTypeId) : true));
  const _showFootNext = !(_postCallStep0Qualifier && !_qualifierComplete);

  // ── 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-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 countFor={(s) => window.countStepSelections ? window.countStepSelections(state, s) : null} step={step} flow={activeFlow} onJump={onJumpStep} onNudge={() => {
          /* 2026-06-26: forward tab nav now jumps directly (canJumpTo returns true
             for unlocked steps), so the old scroll-to-Next nudge is removed -
             clicking a step never scrolls the page. */
        }} canJumpTo={canJumpTo} isStepLocked={isStepLocked} lockReasonFor={lockReasonFor} clientTypeId={clientTypeId} intentId={intentId} startFresh={(savedCount > 0 || clientTypeId) ? (
          <window.HoverPortalTip wrapClassName="step-utility-tipwrap" tipClassName="dis-tip dis-tip--below dis-tip--compact" placement="below" tip={"Start fresh, clear all selections and reload"}>
            <button
              type="button"
              className="step-utility-btn start-fresh-link"
              onClick={() => {
                // 2026-06-24 (Nicole): mark that we are starting fresh so the
                // autosave's page-leave flush does not re-save the discarded quote
                // as the reload unloads the page (which would refill the row).
                try { window.__ggStartingFresh = true; } catch (e) {}
                // 2026-06-24 (Nicole): a fresh start also clears the lead's saved
                // quote on the backend, so the rep's resume link resets to an empty
                // calculator instead of the picks the lead just discarded. Best-
                // effort, only when we know the lead's email (post-call) + a quote id.
                try {
                  var _c0 = JSON.parse(window.localStorage.getItem('gg.pricing-cart.v1') || '{}');
                  var _qid0 = _c0 && _c0.quote_uuid;
                  var _em0 = String((window.__leadEmail) || '').trim();
                  if (_qid0 && _em0 && navigator.sendBeacon) {
                    var _clr = JSON.stringify({ action: 'autosave', email: _em0, quote_uuid: _qid0, clientType: '', intentId: '', selections: {}, q0: {}, qualifier: {}, lines: [], monthlyTotal: 0, oneTimeTotal: 0 });
                    navigator.sendBeacon('/api/send-quote', new Blob([_clr], { type: 'application/json' }));
                  }
                } catch (e) {}
                try { window.localStorage.removeItem('gg.pricing-cart.v1'); } catch (e) {}
                try { Object.keys(window.localStorage).filter(k => k.indexOf('gg.margin') === 0).forEach(k => window.localStorage.removeItem(k)); } catch (e) {}
                // 2026-06-12 (review): a fresh start resets the VAT toggles too,
                // a stale gg.vatRegistered=false otherwise survives and the
                // I-am-VAT-registered box loses its checked-by-default state.
                try { window.localStorage.removeItem('gg.vatRegistered'); window.localStorage.removeItem('gg.outsideUk'); } catch (e) {}
                // Re-show the referral modal on a fresh start (the reload wipes
                // window.__ggReferral, so the discount can never be applied twice).
                try { window.sessionStorage.removeItem('gg_ref_modal_seen'); } catch (e) {}
                try { window.localStorage.removeItem('gg.flMode'); } catch (e) {}
                try { window.location.reload(); } catch (e) {}
              }}
              aria-label="Start fresh, clear all selections and reload"
            >
              <span aria-hidden="true">↻</span>
            </button>
          </window.HoverPortalTip>
        ) : null} />

        {/* 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' })} />
        )}


        {/* 2026-06-12 (Loom 45 1:22-1:50): the welcome-back banner moved into
            the grid's main column below, it used to sit full width here and
            push the whole grid, sidebar included, down the page. */}

        {/* Single grid, calculator sidebar persists across both sections */}
        <div className={"calc__grid" + ((_flHero && isClientStep) ? ' calc__grid--fl-landing' : '')}>
          {/* 2026-07-01 (Option A): in referring mode there is no reselling, so the
              white-label margin calculators (all share the .svc-margin class) are
              hidden. The referral commission shows in the sidebar instead. */}
          {state.referMode && <style>{'.svc-margin{display:none !important}'}</style>}
          <div className="calc__main">
            {savedCount > 0 && isClientStep && (
              <div className="welcome-back alert--metal gg-frame-card">
                <span className="gg-frame gg-frame--metal" style={{ '--gg-frame-slice': '18px' }} aria-hidden="true" />
                <div className="welcome-back__icon">↻</div>
                <div className="welcome-back__body">
                  <strong>{repResumeName() ? `Welcome back, ${repResumeName()}.` : 'Welcome back.'}</strong> You have <strong>{savedCount} service{savedCount === 1 ? '' : 's'}</strong> saved in your plan from last time.
                </div>
                <div className="welcome-back__actions">
                  <button
                    type="button"
                    className="btn btn--ghost btn--sm"
                    onClick={() => window.open('https://www.gogorilla.com/', '_blank', 'noopener,noreferrer')}
                    aria-label="Back to the GoGorilla website, opens in a new tab"
                  >
                    Back to website <span aria-hidden="true" className="btn__arrow btn__arrow--diag">↗</span>
                  </button>
                  <button
                    className="btn btn--primary btn--sm"
                    onClick={() => onJumpStep && onJumpStep(1)}
                  >
                    Resume <span className="btn__arrow">›</span>
                  </button>
                </div>
              </div>
            )}
            {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">{_flHero ? 'GoGorilla Partners' : <>GorillaMatch&trade; Pricing Engine</>}</div>
                    <h2 className="section-head__title">
                      {isPostCallMode()
                        ? (postCallName() ? <>Thanks for booking your call, <em>{postCallName()}.</em></> : <>Thanks for <em>scheduling.</em></>)
                        : _flHero
                          ? (_flName ? <>Hi {_flName}, meet our CEO and <em>learn how we might work together.</em></> : <>Meet our CEO and <em>learn how we might work together.</em></>)
                          : <>Choose the services to <em>fuel your revenue engine.</em></>}
                    </h2>
                    <p className="section-head__sub">
                      {isPostCallMode()
                        ? 'Tell us a little bit about your business so we can make the most of your call.'
                        : _flHero
                          ? 'A two-sided partnership. Watch the quick intro, then explore your options below.'
                          : 'Choose your services and watch your quote build in real time, so you can plug into our revenue engine sooner.'}
                    </p>
                    {_flHero && (
                      <div className="fl-hero-row" style={{ display: 'flex', gap: '14px', alignItems: 'stretch', flexWrap: 'wrap', margin: '20px auto 0', width: '100%', maxWidth: 'none', textAlign: 'left' }}>
                      <div className="fl-video-hero" aria-label="Founder video placeholder" style={{ flex: '1.27 1 440px', minWidth: '300px', aspectRatio: '16 / 9', borderRadius: '16px', background: '#0f1c35', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '10px' }}>
                        <span style={{ width: '60px', height: '60px', borderRadius: '50%', background: 'rgba(255,255,255,0.14)', display: 'flex', alignItems: 'center', justifyContent: 'center' }} aria-hidden="true">
                          <svg width="28" height="28" viewBox="0 0 24 24" fill="#ffffff"><path d="M8 5v14l11-7z" /></svg>
                        </span>
                        <span style={{ color: 'rgba(255,255,255,0.86)', fontSize: '0.82rem', fontWeight: 500 }}>Founder intro. Video coming soon.</span>
                      </div>
                      <div className="fl-careers-block thin-glass-frame" style={{ flex: '1 1 380px', minWidth: '300px', textAlign: 'left', background: 'rgba(255, 255, 255, 0.55)', border: '1px solid #e3e7f1', borderRadius: '14px', padding: '18px 20px' }}>
                        {/* 2026-07-02: careers-page job posting on the freelancer landing (Flywheel v11, Implementation Brief Sprint 1, copy ratified by Nicole). A matching careers-uk listing routes here. */}
                        <div style={{ fontWeight: 800, fontSize: '1.02rem', color: '#0f1c35', marginBottom: '2px' }}>Freelance Partner (UK), remote</div>
                        <div style={{ fontSize: '0.85rem', color: '#33415e', fontStyle: 'italic', marginBottom: '10px' }}>We are expanding our freelance bench.</div>
                        <div style={{ fontSize: '0.85rem', color: '#33415e', lineHeight: 1.55, marginBottom: '8px' }}>GoGorilla.com partners with UK freelancers and micro-agencies who want dependable delivery capacity behind them. You keep your clients and your brand. We provide the talent, the tooling, and the commercial upside.</div>
                        <div style={{ fontSize: '0.85rem', color: '#33415e', lineHeight: 1.55, marginBottom: '8px' }}><strong>What you get.</strong> Direct access to our Philippines talent for your own delivery, a white-label suite at partner rates, recurring commission on every business you refer, and a London (Chelsea) desk once your first client is active.</div>
                        <div style={{ fontSize: '0.85rem', color: '#33415e', lineHeight: 1.55, marginBottom: '8px' }}><strong>Who we look for.</strong> UK-based freelancers and micro-agencies in social media, paid advertising, content, video, or email, with a live client or a portfolio that shows your work.</div>
                        <div style={{ fontSize: '0.85rem', color: '#33415e', lineHeight: 1.55, marginBottom: '8px' }}><strong>Deliver for us as well.</strong> Active delivery partners earn a retainer plus a share of the upside they help create, up to 50% more than standard freelance market rates. Tell us on your call and we will walk you through how it works.</div>
                        <div style={{ fontSize: '0.85rem', color: '#33415e', lineHeight: 1.55 }}><strong>How it starts.</strong> Answer a few questions and build your plan in the pricing calculator. We review every application for fit and come back to you within two working days.</div>
                      </div>
                      </div>
                    )}
                  </div>
                </div>

                <ClientTypeSection
                  referredDeclared={!!(state.referral && state.referral.status === 'declared')}
                  referralStatus={state.referral ? state.referral.status : null}
                  onToggleReferred={() => { const _rs = state.referral && state.referral.status; if (_rs === 'applied' || _rs === 'self') return; dispatch({ type: 'SET_REFERRAL', referral: (_rs === 'declared') ? null : { status: 'declared', pct: 10 } }); }}
                  onEnterReferMode={() => { dispatch({ type: 'SET_INTENT', id: 'agency-whitelabel' }); dispatch({ type: 'SET_REFER_MODE', value: true }); }}
                  referMode={state.referMode}
                  onToggleReferMode={(on) => { dispatch({ type: 'SET_REFER_MODE', value: on }); if (on) { try { setTimeout(() => { const _el = document.querySelector('.client-grid'); if (_el && _el.scrollIntoView) _el.scrollIntoView({ behavior: 'smooth', block: 'center' }); }, 90); } catch (e) {} } }}
                  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 (state.referMode) {
                      // 2026-07-02: in referring mode the cards choose WHO you are referring.
                      // Skip the intent picker and forecast that business's plan straight away
                      // (referMode is preserved through SET_CLIENT/SET_INTENT).
                      dispatch({ type: 'SET_INTENT', id: id === 'agency' ? 'agency-whitelabel' : (id === 'investor' ? 'investor-portfolio' : 'proposal') });
                      if (!isPostCallMode()) dispatch({ type: 'SET_STEP', step: 1 });
                      return;
                    }
                    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' });
                    // Item 3 (Loom 0:31): post-call mode leads with the qualifier on
                    // Step 0, so do not auto-advance here. The user continues via the
                    // Step 0 Continue button once they have answered (or skipped) it.
                    if (!isPostCallMode()) dispatch({ type: 'SET_STEP', step: 1 });
                  }}
                  intentId={intentId}
                  setIntent={(id) => {
                    dispatch({ type: 'SET_INTENT', id });
                    setClientReadyChoice(null); // reset when intent changes
                    // 2026-06-08 (Loom 30): every investor intent, including
                    // "considering an investment", advances straight to the next
                    // step on click, the same as the others. (The old early return
                    // that held investor-considering on Step 0 was removed.)
                    // Item 3 (Loom 0:31): in post-call mode the agency qualifier leads
                    // on Step 0, so hold here until the user clicks Continue. Investors
                    // have no Step 0 qualifier, so they still advance immediately.
                    if (!(isPostCallMode() && state.clientTypeId === 'agency')) 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 })}
                  onDdAreasChange={(ddIds, ddLabels) => {
                    // Batch 7 (Loom): keep the chosen due-diligence areas on the quote
                    // as they toggle the pills, so they ride along to the team on the
                    // final step. Advancing is handled by the main Step 0 Continue,
                    // and the investor-considering flow now skips the service steps.
                    dispatch({ type: 'SET_SERVICES_INQUIRY', value: {
                      categories: ddIds,
                      notes: (ddIds && ddIds.length) ? ('Pre-investment marketing due diligence. Areas to assess: ' + ddLabels.join(', ') + '.') : '',
                    } });
                  }}
                />

{/* 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. */}

                {/* Item 3 (Loom 0:31): in post-call mode we LEAD with the qualifier on
                    Step 0 so the team captures the key business info before the call.
                    Shown only after a client type is picked. Founders and agencies have
                    a qualifier; investors have little to configure and skip straight on.
                    Hidden here in normal mode (the qualifier lives on the final step).
                    The matching block on the final step is suppressed in post-call mode
                    so the questions are never asked twice. */}
                {isPostCallMode() && clientTypeId === 'founder' && (
                  <div className="build-section build-section--qualifier youreset-qualifier qualifier-step0">
                    <QualifierSection
                      qualifier={state.qualifier || {}}
                      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>
                )}
                {isPostCallMode() && clientTypeId === 'agency' && intentId && (
                  <div className="build-section build-section--qualifier youreset-qualifier qualifier-step0">
                    <QualifierSection
                      qualifier={(() => {
                        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>
                )}
              </>
            ) : (
              /* ── STEPS 1-4: services for this group (also referring mode, Option A) ── */
              <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">{_headerSub}</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 alert--metal gg-frame-card" style={{ alignItems: 'center' }}>
                    {/* 2026-06-05: metal frame (original bronze finish). */}
                    <span className="gg-frame gg-frame--metal" style={{ '--gg-frame-slice': '18px' }} aria-hidden="true" />
                    <span className="alert__icon"><img src="assets/icons/check.webp" alt="" /></span>
                    {state.referMode ? (<div><strong>You are now referring.</strong> Use this to forecast your potential referral commission. Pick the services you'd refer and see it on the right. You don't need to know exactly what they'll buy, so submit the client's details from your admin portal once you're ready.</div>) : (<div>
                      <strong>
                        {agencyIntent.id === 'agency-whitelabel'
                          ? 'White-label discount applied.'
                          : ((window.isFreelancerMode && window.isFreelancerMode()) ? 'Partner discount applied.' : 'Agency partner discount applied.')}
                      </strong>{' '}
                      <span className="alert__hl">{Math.round((1 - agyMult) * 100)}% off</span> every managed-service tier{agencyIntent.id === 'agency-whitelabel' ? ', automatically' : ', available with one active referral'}{agencyIntent.id === 'agency-whitelabel' ? (
                        <HoverPortalTip as="span" wrapClassName="alert__savings-info" wrapStyle={{ display: 'inline-flex', verticalAlign: 'middle', marginLeft: '3px' }} tipClassName="dis-tip dis-tip--above" placement="above" tip={<>
                          <span className="dis-tip__body" style={{ display: 'block' }}>Your white-label discount is not the only saving. Bring a client onto more than one managed service and the accountability discount applies, with 5% off your retainers for two core services and 10% off for three or more. It stacks on top of your 40%, so consolidating more client work with us widens your margin.</span>
                          <button type="button" className="tip-overlay-link" onClick={(e) => { e.preventDefault(); e.stopPropagation(); setBannerSavingsOv(true); }}>View available savings</button>
                        </>}><window.InfoIcon /></HoverPortalTip>
                      ) : <>. Talent is at GorillaPerks partner rates.</>}
                    </div>)}
                    {state.referMode ? (
                      <div style={{ marginLeft: 'auto', flex: '0 0 auto', display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '5px' }}>
                      <button type="button" className="svc__upfront-toggle is-on fl-refer-toggle" role="switch" aria-checked={true} aria-label={agencyIntent.id === 'agency-own' ? 'Switch back to your own plan' : 'Switch back to white-labelling'} onClick={() => dispatch({ type: 'SET_REFER_MODE', value: false })} style={{ marginLeft: 'auto', flex: '0 0 auto' }}>
                        <span className="svc__upfront-knob" aria-hidden="true" />
                        <span className="svc__upfront-text">{agencyIntent.id === 'agency-own' ? 'Switch to your own plan' : 'Switch to white-labelling'}</span>
                        <span className="svc__upfront-save">Save {Math.round((1 - window.getAgencyMultiplier({ ...state, referMode: false })) * 100)}%</span>
                      </button>
                        <button type="button" className="summary__savings-viewall svc__talent-wl-link" style={{ order: 3 }} onClick={() => setBannerReferHelpOv(true)}><span className="svc__talent-wl-link__text">How do I refer?</span> <span className="svc__talent-wl-link__arrow" aria-hidden="true">{'\u203a'}</span></button>
                      </div>
                    ) : (((agencyIntent.id === 'agency-whitelabel' || agencyIntent.id === 'agency-own')) && (
                      <button type="button" className="svc__upfront-toggle fl-refer-toggle" role="switch" aria-checked={false} aria-label="Switch to referring and earn 10% recurring commission" onClick={() => dispatch({ type: 'SET_REFER_MODE', value: true })} style={{ marginLeft: 'auto', flex: '0 0 auto' }}>
                        <span className="svc__upfront-knob" aria-hidden="true" />
                        <span className="svc__upfront-text">Switch to referring</span>
                        <span className="svc__upfront-save">10% recurring commission</span>
                      </button>
                    ))}
                  </div>
                )}
                {(clientTypeId === 'founder' || clientTypeId === 'investor') && (
                  <div className="alert alert--info alert--metal gg-frame-card" style={{ alignItems: 'center' }}>
                    {/* 2026-07-02: referral-forecast banner for founders & investors (mirrors the agency white-label banner). */}
                    <span className="gg-frame gg-frame--metal" style={{ '--gg-frame-slice': '18px' }} aria-hidden="true" />
                    <span className="alert__icon"><img src="assets/icons/check.webp" alt="" /></span>
                    {state.referMode ? (<div><strong>You are now referring.</strong> Use this to forecast your potential referral commission. Pick the services you'd refer and see it on the right. You don't need to know exactly what they'll buy, so submit the client's details from your admin portal once you're ready.</div>) : (<div><strong>Earn by referring.</strong> Switch to referring to forecast how much commission you could earn from each referral. You earn <strong>10% recurring</strong> for the length of each client's minimum commitment.</div>)}
                    {state.referMode ? (
                      <div style={{ marginLeft: 'auto', flex: '0 0 auto', display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '5px' }}>
                      <button type="button" className="svc__upfront-toggle is-on fl-refer-toggle" role="switch" aria-checked={true} aria-label="Switch back to my plan" onClick={() => dispatch({ type: 'SET_REFER_MODE', value: false })} style={{ marginLeft: 'auto', flex: '0 0 auto' }}>
                        <span className="svc__upfront-knob" aria-hidden="true" />
                        <span className="svc__upfront-text">Switch back to my plan</span>
                      </button>
                        <button type="button" className="summary__savings-viewall svc__talent-wl-link" style={{ order: 3 }} onClick={() => setBannerReferHelpOv(true)}><span className="svc__talent-wl-link__text">How do I refer?</span> <span className="svc__talent-wl-link__arrow" aria-hidden="true">{'\u203a'}</span></button>
                      </div>
                    ) : (
                      <button type="button" className="svc__upfront-toggle fl-refer-toggle" role="switch" aria-checked={false} aria-label="Switch to referring and earn 10% recurring commission" onClick={() => dispatch({ type: 'SET_REFER_MODE', value: true })} style={{ marginLeft: 'auto', flex: '0 0 auto' }}>
                        <span className="svc__upfront-knob" aria-hidden="true" />
                        <span className="svc__upfront-text">Switch to referring</span>
                        <span className="svc__upfront-save">10% recurring commission</span>
                      </button>
                    )}
                  </div>
                )}
                {isAgency && !agencyIntent && !isWhitelabelStep && (
                  <div className="alert alert--info alert--soft">
                    <span className="alert__icon">↑</span>
                    <div>
                      <strong>Pick how you'll use GoGorilla.com 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 });
                      }}
                      onSetMailOpt={(key, value) => {
                        if (!selections[s.id]) dispatch({ type: 'SET_SERVICE', id: s.id, on: true });
                        dispatch({ type: 'SET_MAIL_OPT', id: s.id, key, value });
                      }}
                      onSetAddonQty={(aid, value) => dispatch({ type: 'SET_ADDON_QTY', id: s.id, addonId: aid, value })}
                      onToggleAddonRecurring={(aid) => dispatch({ type: 'TOGGLE_ADDON_RECURRING', id: s.id, addonId: aid })}
                      onSetStartWindow={(value) => dispatch({ type: 'SET_START_WINDOW', id: s.id, value })}
                      onSetPtBilling={(value) => dispatch({ type: 'SET_PT_BILLING', id: s.id, 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 (hidden on step 0) + Next.
                Item 4 (Loom 1:00 / 2:31): Step 0 also renders a manual Next
                button as a safety net. Card-click still auto-advances, but if
                that ever fails (for example a returning visitor who already has
                a client type selected) the user can always proceed manually. */}
            <div className="page__foot page__foot--steps">
              {!isClientStep && (
              <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>
              )}
              
              {_showFootNext && (
              <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;
                    // 2026-06-13 (review): a selected custom role must carry a
                    // name, and the name and description must pass the
                    // professional-language check, before the user leaves the
                    // talent step. The gate re-runs ggTextOk on the live values
                    // (not the warn flags) so a type-then-click race can never
                    // slip flagged text through. Blocked clicks scroll back to
                    // the offending field (window scroll, the smooth/ancestor
                    // lessons from the add-on jumper apply).
                    if (stepDef?.id === 'talent') {
                      const _gSels = ((window.__lastBuildPageState || {}).selections) || {};
                      const _ok = window.ggTextOk || (() => true);
                      const _bad = ['dedicated-ft', 'dedicated-pt'].map((sid) => {
                        const _s = _gSels[sid];
                        const _cid = sid === 'dedicated-ft' ? 'ft-custom' : 'pt-custom';
                        if (!(_s && Array.isArray(_s.roles) && _s.roles.includes(_cid))) return null;
                        const _cfg = (_s.roleConfigs || {})[_cid] || {};
                        const _nm = String(_cfg.customName || '').trim();
                        if (!_nm || !_ok(_nm)) return { sid, field: 'name' };
                        if (_cfg.customBrief && !_ok(_cfg.customBrief)) return { sid, field: 'brief' };
                        return null;
                      }).find(Boolean);
                      if (_bad) {
                        const _el = document.getElementById(`gg-custom-${_bad.field}-${_bad.sid === 'dedicated-ft' ? 'ft' : 'pt'}`)
                          || document.querySelector('.df-role-card--custom');
                        if (_el) {
                          try { _el.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (e) { _el.scrollIntoView(); }
                          setTimeout(() => { try { _el.focus({ preventScroll: true }); } catch (e) {} }, 450);
                          _el.classList.add('df-custom-text--attn');
                          setTimeout(() => { _el.classList.remove('df-custom-text--attn'); }, 1900);
                        }
                        return;
                      }
                    }
                    onNext();
                  }}
                  disabled={!isQualifierStep && !currentStepSatisfied}
                >
                  {isClientStep && isPostCallMode() ? 'Continue' : (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>

          {bannerSavingsOv && <GMOverlayModal data={getSavingsOverlay(clientTypeId, intentId)} onClose={() => setBannerSavingsOv(false)} clientTypeId={clientTypeId} intentId={intentId} onSwitchToReferring={(!state.referMode && clientTypeId === 'agency' && intentId === 'agency-whitelabel') ? (() => { setBannerSavingsOv(false); dispatch({ type: 'SET_REFER_MODE', value: true }); }) : undefined} />}
          {bannerReferHelpOv && <GMOverlayModal data={getReferHelpOverlay()} onClose={() => setBannerReferHelpOv(false)} clientTypeId={clientTypeId} intentId={intentId} />}
          <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
    ? true
    : (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={isFT ? (line.label + '. Estimated hourly. The monthly fee is confirmed after shortlisting and billed from placement, so it is not in the running total. A refundable \u00a3250 deposit is due today.') : line.label}
          >
            <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>
        {/* 2026-06-12 (Nicole): the compact waiting-list pill sits beside the
            cost on the same line, and the role contributes £0. */}
        <span className="summary__line-val summary__line-val--role">{line.waitlist && (
          <WaitlistTip
            body={'This role is currently at capacity. It stays on your quote as a waiting-list item at no charge, and we email you as soon as a spot opens, typically within 2 to 4 weeks.'}
            meta={'No charge applies whilst you are on the list.'}
          />
        )}{line.custom ? String(line.customLabel || 'Custom') : <AnimatedGBP value={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">3 months minimum</span>
              </div>
              <div className="summary__role-detail summary__role-detail--multi">
                <span className="summary__role-detail-k">Billing</span>
                <span className="summary__role-detail-v">Estimated at the hourly rate shown for about 173 hours a month. We confirm the exact monthly fee after shortlisting to your brief and bill it from placement, so it is not added to the running monthly total above. A refundable £250 deposit is due today.</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' },
  // 2026-06-15 (Alexander): the generic AGENCY55 code was retired in favour of
  // unique, expiring, trackable per-agency white-label codes held in Airtable
  // (Partner Discount Codes table) and loaded at runtime via /api/partner-codes,
  // which index.html merges into this object before a code is applied.
};
// Expose so app.v2.jsx's loadState() can re-validate a persisted code.
window.PROMO_CODES = PROMO_CODES;
// 2026-06-15: merge any per-agency codes already fetched from /api/partner-codes
// (index.html). If that fetch resolves later, its own handler performs the merge.
if (typeof window !== 'undefined' && window.__PARTNER_CODES) { for (var __pc in window.__PARTNER_CODES) PROMO_CODES[__pc] = window.__PARTNER_CODES[__pc]; }

// 2026-06-12 (Nicole): the quote-line builder, extracted from Summary so the
// step counters and the sidebar Services-and-Add-ons count read the SAME
// list through the same rule. Pure function of state.
function buildQuoteLines(state) {
  const selections = state.selections || {};
  const agyMult = window.getAgencyMultiplier(state);
    const list = [];

    Object.entries(selections).forEach(([sid, sel]) => {
      const svc = window.SERVICES.find(x => x.id === sid);
      const tier = window.findTier(svc, sel.tier);
      // 2026-06-11: standalone add-ons can sit in the cart with no plan, so
      // a tier-less selection still renders its add-on lines. Only fully
      // empty selections are skipped.
      if (!svc) return;
      if (!tier && !(Array.isArray(sel.addons) && sel.addons.length)) 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 || svc.fixedDuration) ? '' : ((sid === 'dedicated-pt' && sel.ptBilling === 'oneoff') ? ' · one-off booking' : ((sid === 'dedicated-pt' || sid === 'dedicated-ft') ? ' · 3mo min' : ` · ${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 = !!tier && Array.isArray(svc.waitlistTiers) && svc.waitlistTiers.includes(tier.id);
      const isWaitlist = tier ? ((cap?.status === 'full' || isTierWaitlist) && !tier.isEnterprise) : (cap?.status === 'full');
      if (tier && tier.isEnterprise) {
        // 2026-06-12 (review): Enterprise is a waiting-list state, so the line
        // carries the amber pill with bespoke wording whilst staying custom
        // for the quote snapshot. £0 to the total until the proposal lands.
        list.push({ id: sid, sid, label: `${svc.name} · Enterprise${commitSuffix}`, value: 0, custom: true, waitlist: true, entWaitlist: true });
      } else if (tier && isWaitlist) {
        list.push({ id: sid, sid, label: `${svc.name} · ${tier.name}${commitSuffix}`, value: 0, waitlist: true });
      } else if (tier) {
        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 = (window.ggRound5 || (x => x))(p.value * _svcMult);
          const suffix = p.oneTime ? ' (one-off)' : '';
          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: £395/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 = 1;
            // 2026-06-11 (Loom 38), Enterprise collects the seat count as a
            // proposal input, no £395 charge while pricing is bespoke.
            const _entLi = !!(tier && tier.isEnterprise);
            list.push({
              id: `${sid}:linkedin-profiles`,
              sid,
              label: `↳ LinkedIn outbound · ${_liProfiles} profile${_liProfiles === 1 ? '' : 's'}`,
              value: _entLi ? 0 : _chanWl(395, commitId, _svcMultCh) * _liProfiles,
              rrp: _entLi ? 0 : _chanCommitRrp(395, commitId) * _liProfiles,
              breakdown: _entLi ? 'Scoped in your proposal' : `${_liProfiles} × £${_chanCommitRrp(395, commitId).toLocaleString('en-GB')}/profile/mo`,
              addon: true,
              ...(_entLi ? { free: true } : {}),
            });
          }
          // 2026-06-12 (Loom 44 1:26): Instagram outreach, one managed profile
          // at a flat £295/mo (Enterprise scopes it in the proposal instead).
          if (sel.channels.includes('instagram')) {
            const _entIg = !!(tier && tier.isEnterprise);
            list.push({
              id: `${sid}:instagram-outreach`,
              sid,
              label: '↳ Instagram growth · 1 profile',
              value: _entIg ? 0 : _chanWl(495, commitId, _svcMultCh),
              rrp: _entIg ? 0 : _chanCommitRrp(495, commitId),
              breakdown: _entIg ? 'Scoped in your proposal' : `Manual follows and DMs · 1 managed profile × £${_chanCommitRrp(495, commitId).toLocaleString('en-GB')}/mo`,
              addon: true,
              ...(_entIg ? { free: true } : {}),
            });
          }
          // 2026-06-05 (Loom 27): WhatsApp is included as standard and already
          // appears inside the Channels line above, so no separate line here.
        }
        // 2026-06-11 (Loom 38 items 2-3): Paid Advertising extra channels.
        // Scale includes 2, each additional adds £495/£395/£295 a month by
        // commitment. Enterprise picks carry no charge, they shape the proposal.
        if (sid === 'paid-ads' && tier && !tier.isEnterprise) {
          const _chCfgPa = window.SERVICE_CHANNELS['paid-ads'];
          const _inclPa = _chCfgPa && _chCfgPa.included ? _chCfgPa.included[sel.tier] : undefined;
          if (typeof _inclPa === 'number' && _chans.length > _inclPa) {
            const _extraN = _chans.length - _inclPa;
            const _cidPa = String(sel.commitId || '12');
            const _perPa = _chCfgPa.extraPrice ? (_chCfgPa.extraPrice[_cidPa] != null ? _chCfgPa.extraPrice[_cidPa] : _chCfgPa.extraPrice['12']) : 0;
            const _svcMultPa = window.getAgencyMultiplier ? window.getAgencyMultiplier(state, sid) : agyMult;
            if (_perPa > 0) list.push({
              id: `${sid}:extra-channels`,
              sid,
              label: `↳ Additional ad channel${_extraN === 1 ? '' : 's'} · ${_extraN}`,
              value: Math.round(_extraN * _perPa * _svcMultPa),
              breakdown: `${_extraN} × £${_perPa}/channel/mo on a ${_cidPa}-month commitment`,
              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 && tier) {
        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: `↳ ${svc.name} 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: `↳ ${svc.name} setup fee (one-off)`,
            value: (window.ggRound5 || (x => x))(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', sel.channels, sel.monthlyLeads)
        : svc.addons;
      // 2026-06-10: tier-included add-ons list automatically (the card is
      // greyed and unselectable), so it's clear they come with the plan.
      ctxAddons.filter(a => a.included && !(sel.addons || []).includes(a.id)).forEach(a => {
        list.push({ id: `${sid}:inc:${a.id}`, sid, label: `↳ ${a.name}`, value: 0, custom: true, customLabel: a.includedLabel || 'Included', addon: true, includedAuto: true, ...(a.oneTime ? { oneTime: true } : {}) });
      });
      (sel.addons || []).forEach(aid => {
        let a = ctxAddons.find(x => x.id === aid);
        if (!a) return;
        // 2026-06-12 (Loom 40): a one-off switched to recurring bills monthly
        // at the discounted rate, the line moves from setup to monthly.
        if (a.recurringOption && sel.addonRecurring && sel.addonRecurring[aid]) {
          const _raw = (a.price || 0) * (1 - (a.recurringOption.savePct || 11) / 100);
          const _rp = a.recurringOption.price != null ? a.recurringOption.price : (_raw >= 10 ? Math.round(_raw) : Math.round(_raw * 100) / 100);
          a = { ...a, price: _rp, oneTime: false, priceLabel: null, _recurringOn: true };
        }
        // CLB data-detail level sets the per-prospect price (Loom 47, 0:59), so
        // the quote line matches the level chosen on the add-on card.
        if (aid === 'premium-sourcing' && !a._recurringOn && window.CLB_LEVEL_PRICE) {
          const _clvl = (sel.mailOpts && sel.mailOpts.clbLevel) || 'company-only';
          const _cprc = window.CLB_LEVEL_PRICE[_clvl];
          if (typeof _cprc === 'number') a = { ...a, price: _cprc };
        }
        // 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.perUnit || (/^\/[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}` : '';
        // 2026-06-12 (review): under an Enterprise plan every selected add-on
        // joins the same waiting list as the plan, priced in the proposal, so
        // the line reads the amber pill and contributes £0. Standalone
        // add-ons (Available separately) stay purchasable at their price.
        // The flags derive from the live tier each render, so switching to an
        // available plan restores normal pricing automatically.
        if (tier && tier.isEnterprise && !a.standalone) {
          list.push({ id: `${sid}:${aid}`, sid, aid, label: `↳ ${a.name}${qtyLabel}`, value: 0, waitlist: true, entWaitlist: true, addon: true, breakdown: 'Scoped in your proposal' });
          return;
        }
        // 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: `${tier ? "↳ " : svc.name + " · "}${a.name}${qtyLabel}`, value: 0, custom: true, addon: true });
          return;
        }
        if (a.free) {
          list.push({ id: `${sid}:${aid}`, sid, aid, label: `${tier ? "↳ " : svc.name + " · "}${a.name}${qtyLabel}`, value: 0, free: true, addon: true });
          return;
        }
        if (isAddonWaitlist) {
          list.push({ id: `${sid}:${aid}`, sid, aid, label: `${tier ? "↳ " : svc.name + " · "}${a.name}${qtyLabel}`, value: 0, waitlist: true, addon: true });
          return;
        }
        // 2026-06-12 (review): add-ons can carry a one-off setup fee, billed
        // once alongside the recurring line and summed into the setup total.
        if (a.setupFee && !isWaitlist) {
          const _svcMultSf = window.getAgencyMultiplier ? window.getAgencyMultiplier(state, sid) : agyMult;
          list.push({
            id: `${sid}:${aid}:setup`,
            sid,
            label: `↳ ${a.name} setup fee (one-off)`,
            value: (window.ggRound5 || (x => x))(Math.round(a.setupFee * _svcMultSf)),
            rrp: Math.round(a.setupFee),
            addon: true,
            oneTime: true,
            setupFee: true,
          });
        }
        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 = _svcMultA;
        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 : (window.ggRound5 || (x => x))((a.price || 0) * _addonMult) * q;
        let _aRrp = Math.round((a.price || 0) * q);
        // CLB white-label uses explicit wholesale prices (not retail x mult).
        if (aid === 'premium-sourcing' && !a._recurringOn && state.intentId === 'agency-whitelabel' && window.CLB_LEVEL_WHOLESALE) {
          const _clvlW = (sel.mailOpts && sel.mailOpts.clbLevel) || 'company-only';
          const _whl = window.CLB_LEVEL_WHOLESALE[_clvlW];
          if (typeof _whl === 'number') aVal = _whl * q;
        }
        // 2026-05-22: enrich addon line with breakdown text where applicable.
        let _addonBreakdown = null;
        let _ovCustom = false;
        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) {
            const _rateCard = (_Wc && _Wc.addons && _Wc.addons['overseas-calling']) || {};
            const _numRegs = _cr.regions.filter(rc => typeof _rateCard[rc] === 'number');
            const _quoteRegs = _cr.regions.filter(rc => !(typeof _rateCard[rc] === 'number'));
            if (_numRegs.length === 0 && _cr.regions.length) { aVal = 0; _aRrp = 0; _ovCustom = true; }
            // 2026-06-12 (Nicole): at retail, each selected region adds its
            // own monthly rate (Rest of World stays by quote). White-label
            // keeps the costliest-region wholesale model below.
            if (!isWaitlist && state.intentId !== 'agency-whitelabel' && _numRegs.length) {
              const _sum = (window.ggRound5 || (x => x))(_numRegs.reduce((s2, rc) => s2 + _rateCard[rc], 0) * (window.commitScaleFactor ? window.commitScaleFactor(sel.commitId) : 1));
              aVal = (window.ggRound5 || (x => x))(_sum * _addonMult) * q;
              _aRrp = Math.round(_sum * q);
              _addonBreakdown = _numRegs.map(rc => `${_labels[rc] || rc}`).join(' + ');
              if (_quoteRegs.length) _addonBreakdown += ` · ${_quoteRegs.map(rc => _labels[rc] || rc).join(', ')} quoted at onboarding`;
            } else {
              _addonBreakdown = `Region: ${_cr.regions.map(rc => _labels[rc] || rc).join(', ')}`;
              if (_quoteRegs.length && state.intentId !== 'agency-whitelabel') _addonBreakdown += ' · quoted at onboarding';
            }
            if (!isWaitlist && state.intentId === 'agency-whitelabel' && window.callingWholesaleSum) {
              // 2026-06-12 (decision confirmed): the wholesale SUMS the
              // selected regions' bands, mirroring the per-region retail.
              const _ws = window.callingWholesaleSum(_cr.regions, sel.commitId);
              if (_ws.byQuoteOnly) {
                aVal = 0; _aRrp = 0;
                _addonBreakdown += ' · by quote at onboarding';
              } else if (typeof _ws.value === 'number') {
                // 2026-06-17 (Nicole): overseas carries a white-label margin, so
                // the agency pays the region sum at the agency multiplier; the
                // RRP stays the full retail region sum.
                aVal = (window.ggRound5 || (x => x))(_ws.value * _addonMult);
                _aRrp = _ws.value;
                if (_ws.anyQuote) _addonBreakdown += ' · Rest of World by quote at onboarding';
              }
            }
          }
        } 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: `${tier ? "↳ " : svc.name + " · "}${a.name}${qtyLabel}`,
          value: aVal,
          rrp: _aRrp,
          breakdown: _addonBreakdown,
          negative: a.negative,
          addon: true,
          oneTime: !!a.oneTime,
          waitlist: !!isWaitlist,
          fromPriced: /^\s*from\b/i.test(a.priceLabel || ''),
          ...(_ovCustom ? { custom: true, customLabel: 'Custom' } : {}),
          ...((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 => {
          let role = tierRoles.find(r => r.id === rid);
          // 2026-06-09: synthesize the user-defined custom role (FT only).
          if (!role && rid === 'ft-custom' && sel.tier === 'fulltime') {
            const _cc = sel.roleConfigs?.['ft-custom'] || {};
            role = { id: 'ft-custom', name: (String(_cc.customName || '').trim() || 'Custom role'), isCustom: true, hourlyFrom: parseFloat(_cc.customRate) || 0 };
          }
          // 2026-06-12 (Loom 41 0:30): synthesize the PT custom role, priced
          // through the standard day-rate path from its rough ladder.
          if (!role && rid === 'pt-custom' && sel.tier === 'parttime') {
            // 2026-06-12 (review): the PT custom role prices from the typed
            // day rate, flat across blocks and locations, like FT's budget.
            const _cc = sel.roleConfigs?.['pt-custom'] || {};
            const _trRaw = parseFloat(_cc.customRate) || 0;
            const _tr = _trRaw > 0 ? Math.max(150, Math.round(_trRaw)) : 0;
            role = { id: 'pt-custom', name: (String(_cc.customName || '').trim() || 'Custom role'), isCustom: true, price: _tr, unit: '/day', priceLabel: _tr > 0 ? ('£' + _tr + '/day') : 'Custom', dayRates: _tr > 0 ? { d1: _tr, d5: _tr, d10: _tr, custom: _tr } : null, _flatLoc: true };
          }
          if (!role) return;
          const cfg = sel.roleConfigs?.[rid] || null;
          const isPT = sel.tier === 'parttime';
          // Custom role: bespoke line (name + rate + brief), Philippines, no commitment.
          if (role.isCustom && !isPT) {
            // 2026-06-12 (Nicole): £6/hour floor, and the chosen office
            // location replaces the Philippines-only assumption.
            const _crRaw = parseFloat(cfg && cfg.customRate) || 0;
            const _cr = _crRaw > 0 ? Math.max(6, _crRaw) : 0;
            const _locLbl = (((window.DEDICATED_FT_LOCATIONS || []).find(l => l.id === (cfg && cfg.location)) || {}).label) || 'Philippines';
            const _brief = cfg && cfg.customBrief ? String(cfg.customBrief).trim() : '';
            list.push({
              id: `${sid}:role:${rid}`,
              sid,
              rid,
              label: `↳ ${role.name} (custom · 173 hours/month)`,
              value: 0,
              custom: true,
              customLabel: _cr > 0 ? `From £${_cr}/hour` : 'Custom',
              breakdown: `Custom role · ${_locLbl}${_cr > 0 ? ` · from £${_cr}/hour` : ''}${_brief ? ` · ${_brief.slice(0, 80)}${_brief.length > 80 ? '…' : ''}` : ''}`,
              addon: true,
              role: true,
              roleConfig: cfg,
              roleTier: sel.tier,
            });
            return;
          }
          // 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));
            }
            // 2026-06-10: scale PT day rate by the selected office location.
            const _ptLocMultC = role._flatLoc ? 1 : (((window.DEDICATED_FT_LOCATIONS || []).find(l => l.id === ((cfg && cfg.location) || 'philippines')) || {}).multLo || 1);
            dayRate = Math.round(dayRate * _ptLocMultC);
            const _svcMultD = window.getAgencyMultiplier ? window.getAgencyMultiplier(state, sid) : agyMult;
            // 2026-06-03: apply the per-role commitment discount (3mo 0% /
            // 6mo -20% / 12mo -40%) to the PT day rate, matching the commit
            // toggle in the role config. Defaults to 12mo (the toggle default).
            // 2026-06-12 (Nicole): one-off bills once at the full day rate,
            // recurring carries a flat 20% saving on a 3-month minimum. The
            // silent 12-month ladder default is retired.
            const _ptOneOff = sel.ptBilling === 'oneoff';
            const _ptSave = _ptOneOff ? 0 : 20;
            computed = Math.round(dayRate * (1 - _ptSave / 100) * days * _svcMultD);
            // 2026-06-12 (Nicole): waitlisted roles join the quote at £0, the
            // demand is captured and nothing is charged whilst they wait.
            if (role.waitlist) computed = 0;
          }
          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 _ptLocMultB = role._flatLoc ? 1 : (((window.DEDICATED_FT_LOCATIONS || []).find(l => l.id === ((cfg && cfg.location) || 'philippines')) || {}).multLo || 1);
              dayRate = Math.round(dayRate * _ptLocMultB);
              if (sel.ptBilling === 'oneoff') {
                return `${days} day${days === 1 ? '' : 's'} × £${dayRate.toLocaleString('en-GB')}/day · one-off block, billed once`;
              }
              const _ptEffRate = Math.round(dayRate * 0.8);
              return `${days} day${days === 1 ? '' : 's'} × £${_ptEffRate.toLocaleString('en-GB')}/day · recurring (\u221220%), 3-month minimum`;
            })();
            list.push({
              id: `${sid}:role:${rid}`,
              sid,
              rid,
              label: `↳ ${role.name}${cfg && typeof cfg.days === 'number' ? (sel.ptBilling === 'oneoff' ? ` (${cfg.days} day${cfg.days === 1 ? '' : 's'}, one-off)` : ` (${cfg.days} day${cfg.days === 1 ? '' : 's'}/month)`) : ''}`,

              value: computed,
              breakdown: role.waitlist ? 'Waiting list, no charge until a spot opens' : _ptBreakdown,
              addon: true,
              role: true,
              ...(role.waitlist ? { waitlist: true } : {}),
              roleConfig: cfg,
              roleTier: sel.tier,
              ...(sel.ptBilling === 'oneoff' ? { oneTime: true } : { commitMo: '3' }),
            });
          } 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) {
                // 2026-06-10: flat 3-month minimum, no commit discounts (the
                // stale 0.8/0.6 multipliers survived here); range now follows
                // the fixed seniority ladder via dedicated-flow's helper.
                const _rng = window.dfHourlyRange ? window.dfHourlyRange(role, cfg.location) : null;
                const _fmtR = window.dfRateFmt || ((v) => v);
                const lo = _rng ? _rng.lo : Math.round(role.hourlyFrom * loc.multLo);
                const hi = _rng ? _rng.hi : Math.round(role.hourlyFrom * loc.multHi);
                customLabel = `From £${_fmtR(lo)}-£${_fmtR(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;
                const _ptTail = sel.ptBilling === 'oneoff' ? 'one-off booking' : 'recurring (\u221220%), 3-month minimum';
                return base ? `${base} · ${_ptTail}` : _ptTail;
              }
              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 ? ' (173 hours/month)' : ''}${(!isPT && cfg && (cfg.qty || 1) > 1) ? ` × ${Math.max(1, Math.min(99, cfg.qty))} hires` : ''}`,

              value: 0,
              custom: true,
              customLabel,
              breakdown: _ftBreakdown,
              addon: true,
              role: true,
              ...(role.waitlist ? { waitlist: true } : {}),
              roleConfig: cfg,
              roleTier: sel.tier,
              commitMo: _ftCommitMo,
            });
          }
        });
      }
      // 2026-06-12 (Loom 41): the start window reads as a free proposal-input
      // line so it lands in the quote snapshot.
      if (svc && (svc.id === 'dedicated-ft' || svc.id === 'dedicated-pt') && sel.startWindow) {
        const _swLabels = (window.DF_START_WINDOW_LABELS || {});
        list.push({
          id: `${sid}:start-window`,
          sid,
          label: `↳ Start window · ${_swLabels[sel.startWindow] || sel.startWindow}`,
          value: 0,
          free: true,
          addon: true,
        });
      }
      // 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' || svc.id === 'dedicated-pt') && Array.isArray(sel.roles) && sel.roles.length > 0) {
        const _isPtDep = svc.id === 'dedicated-pt';
        const ftRoleDefs = svc.roles?.[_isPtDep ? 'parttime' : 'fulltime'] || [];
        sel.roles.forEach(rid => {
          const roleDef = ftRoleDefs.find(r => r.id === rid);
          const _rc = (sel.roleConfigs && sel.roleConfigs[rid]) || {};
          const roleName = roleDef ? roleDef.name : ((rid === 'ft-custom' || rid === 'pt-custom') ? (String(_rc.customName || '').trim() || 'Custom role') : rid);
          // 2026-06-08: deposit scales with the hire quantity for this role.
          const _depQty = _isPtDep ? 1 : Math.max(1, Math.min(99, (sel.roleConfigs && sel.roleConfigs[rid] && sel.roleConfigs[rid].qty) || 1));
          const _depEach = window.DEDICATED_DEPOSIT || 250;
          const _depUnit = (state.intentId === 'agency-whitelabel') ? Math.round(_depEach * 0.6) : _depEach;
          list.push({
            id: `${sid}:deposit:${rid}`,
            sid,
            label: `↳ ${roleName}, deposit (refundable)${_depQty > 1 ? ` × ${_depQty} hires` : ''}`,
            value: _depUnit * _depQty,
            rrp: _depEach * _depQty,
            breakdown: `Refundable, held until placement. Returned if no hire is made.${_depQty > 1 ? ` £${_depEach} × ${_depQty} hires.` : ''}`,
            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' || sel.leadSourceMode === 'none') 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;
      // 2026-06-11 (Loom 38), Enterprise collects the volume as a proposal
      // input. No per-lead charge, the chosen volume reads as a free info line.
      if (_tier === 'enterprise') {
        if (sel.monthlyLeads) list.push({
          id: `${sid}:extra-leads`,
          sid,
          label: `↳ Monthly lead volume · ${Number(sel.monthlyLeads).toLocaleString('en-GB')}`,
          value: 0,
          free: true,
          addon: true,
          breakdown: 'Scoped in your proposal',
        });
        return;
      }
      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' ? 3 : 4}/lead, billed monthly`,
        addon: true,
      });
    });
    return list;
}
window.buildQuoteLines = buildQuoteLines;
// The sidebar counting rule, one per core service and one per paid add-on
// (paid channel extras included). Setup fees, deposits, auto-included items,
// the channels info line, free zero-value lines, and the dedicated service
// header lines never count.
function countableQuoteLines(lines) {
  return lines.filter(l => !l.setupFee && !l.includedAuto && !l.deposit && !String(l.id || '').endsWith(':channels') && !(l.free && !(l.value > 0)) && !(String(l.sid || '').startsWith('dedicated') && !l.role && !l.addon));
}
window.countableQuoteLines = countableQuoteLines;

// 2026-06-19 (Loom 52): "View available savings" overlay. Makes the ways to
// save discoverable from the Applied Savings box, since the explanatory line
// disappears once a discount is applied. Referral is educational for now, the
// live "share your link" flow hooks into the affiliate portal once it exists.
// 2026-06-19 (Loom 52): the savings overlay is built per ICP. Founders see the
// Founders Pro free tab, agencies the white-label margin tab, investors the
// investor-discount tab, and everyone gets recurring billing and bring-your-own-
// list alongside refer, longer commitment, pay upfront, and bundle services.
// 2026-07-01 (Nicole): a small copy-to-clipboard block for the referral intro
// templates the partner sends to introduce a prospect.
function GMCopyBlock({ title, body, note, badge }) {
  const [copied, setCopied] = uS(false);
  const _copy = () => { try { navigator.clipboard.writeText(body); setCopied(true); setTimeout(() => setCopied(false), 2500); } catch (e) {} };
  return (
    <div style={{ border: '1px solid #dfe4f3', borderRadius: '10px', margin: '0.5rem 0 0.9rem', overflow: 'hidden' }}>
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '0.6rem', padding: '0.5rem 0.7rem', background: 'rgba(0,42,191,0.05)', borderBottom: '1px solid #dfe4f3' }}>
        <span style={{ fontSize: '0.78rem', fontWeight: 700, color: '#0F1C35', display: 'inline-flex', alignItems: 'center' }}>{title}{badge ? <span className="channel-tile__rec-tag" style={{ marginBottom: 0, marginLeft: '6px', verticalAlign: 'middle' }}>{badge}</span> : null}</span>
        <button type="button" onClick={_copy} style={{ fontSize: '0.75rem', fontWeight: 600, color: '#fff', background: '#002ABF', border: 'none', borderRadius: '6px', padding: '0.3rem 0.75rem', cursor: 'pointer', flex: '0 0 auto' }}>{copied ? 'Copied' : 'Copy'}</button>
      </div>
      {note ? <div style={{ padding: '0.6rem 0.75rem 0', fontSize: '0.72rem', color: '#8a93a8', whiteSpace: 'pre-wrap', lineHeight: 1.5 }}>{note}</div> : null}
      <pre style={{ margin: 0, padding: '0.75rem', fontSize: '0.8rem', lineHeight: 1.55, color: '#374151', whiteSpace: 'pre-wrap', fontFamily: 'inherit' }}>{body}</pre>
    </div>
  );
}
// 2026-07-01 (Nicole): shared referral copy, used by both the "How do I refer?"
// overlay and the "Refer a business" tab of the savings overlay so they never
// drift. WhatsApp is our recommended intro route, so it comes first with a badge.
const GG_REFER_EMAIL_NOTE = 'To: alexander.onslow@gogorilla.com\nCc: [Prospect email]\nSubject: Intro: [Prospect first name] at [Prospect company]';
const GG_REFER_EMAIL_BODY = 'Hi Alexander,\nI would like to introduce you to [Prospect first name] at [Prospect company]. I think GoGorilla.com could be a great fit for what they are working on.\n[Prospect first name], meet Alexander, the founder of GoGorilla.com. I will leave you both to it.\nBest,\n[Partner name]';
const GG_REFER_WA_BODY = 'Hi Alexander, meet [Prospect first name] from [Prospect company]. I think GoGorilla.com could really help them. [Prospect first name], this is Alexander, our founder. I will let you two take it from here.';
const GG_REFER_HOWITWORKS = [
  { t: 'p', x: 'Referring takes a couple of minutes, and you can start before you are a client yourself. There are three ways to do it, and every route earns you the same commission.' },
  { t: 'hr' },
  { t: 'subtabs', items: [
    { label: 'WhatsApp intro', blocks: [
      { t: 'p', x: 'The highest-touch route, and the one most likely to close. Start a WhatsApp group with our founder, Alexander, on +44 7467 473522, add the business you are referring, and post the message below.' },
      { t: 'copytpl', title: 'WhatsApp introduction', badge: 'Recommended', body: GG_REFER_WA_BODY },
      { t: 'link', url: 'https://wa.me/447467473522', text: 'Message Alexander on WhatsApp', cls: 'gm-ov__link--anim' },
    ] },
    { label: 'Email intro', blocks: [
      { t: 'p', x: 'Copy the message, fill in the placeholders, and send it to Alexander with the business copied in. The grey lines show what to put in the To, Cc, and Subject fields.' },
      { t: 'copytpl', title: 'Email introduction', note: GG_REFER_EMAIL_NOTE, body: GG_REFER_EMAIL_BODY },
    ] },
    { label: 'Share your link', blocks: [
      { t: 'p', x: 'Share your referral code and the calculator link with the business you want to refer. Your code does two things. It attributes the referral to you, and it gives them 10% off for the length of their minimum commitment period.' },
      { t: 'checks', items: [
        'They click your link or type your code, and the 10% discount applies automatically.',
        'If they forget the code, they can still tick \u2018I\u2019ve been referred\u2019 on the first page, and we confirm the referral after they sign up.',
      ] },
    ] },
  ] },
];
const GG_REFER_COMMISSION = [
  { t: 'p', x: 'You earn recurring commission on what each business you refer pays us, based on their sign-up amount, for the length of their minimum commitment period. Your rate starts at 10% and rises as you refer more, and there is no cap on what you can earn.' },
  { t: 'checks', items: [
    '10% off the plan for the business you refer.',
    '10% recurring commission for you, paid for the length of their minimum commitment period.',
    'Your rate rises as you refer more, 15% once you have two active referrals, and 20% once you have five.',
    'Take your commission as cash, or as credit towards your own services.',
    'Claim your earnings once your balance reaches \u00a3100.',
  ] },
  { t: 'sub', x: 'A quick example' },
  { t: 'p', x: 'Say you refer a business that signs up at \u00a32,000 a month on a six-month commitment. They save \u00a3200 a month, and you receive \u00a3200 a month for those six months. Refer three businesses at that level, and your rate rises to 15%, which is \u00a3900 a month in recurring commission.' },
  { t: 'p', x: 'Once your balance reaches \u00a3100, you can claim it at any time, and we pay by bank transfer, PayPal, or Wise.' },
];
const GG_REFER_FAQS = [
  { q: 'Do I have to be a client to refer?', a: 'No. You can refer before you buy anything yourself. Share your link or introduce a business while you build your own plan, and we open a referral account for you straight away, so your earnings start accruing the moment your first referral becomes active.' },
  { q: 'How do I track my referrals and commission?', a: 'You get a referral dashboard in our portal as soon as you sign up as a referrer. It shows every link you have shared, which businesses have signed up, whether each one is still active, and your running commission balance, so you always know what you have earned and what is still pending.' },
  { q: 'When and how do I get paid?', a: 'You earn recurring commission on each referred business, starting at 10% of their sign-up amount and paid for the length of their minimum commitment period. Once your balance reaches \u00a3100 you can claim it at any time, and we pay by bank transfer, PayPal, or Wise.' },
  { q: 'How many businesses can I refer?', a: 'As many as you like. Your rate rises with your active referrals, 10% to start, 15% once you have two, and 20% once you have five, and your current rate applies to every active referral you hold.' },
  { q: 'What if a business I referred cancels?', a: 'Commission for a business runs until they cancel or complete their minimum commitment period, whichever comes first. Either way your other referrals are unaffected, and you keep earning on every one that is still active.' },
  { q: 'Do I earn the same for referring an agency or an investor?', a: 'Referring a direct client earns the recurring ladder. Referring an agency into our white-label programme earns a fixed \u00a3250 when they land their first active client, and introducing an investor earns \u00a3100 when their first portfolio company becomes active, then the ladder on each portfolio company. The View available commissions link shows every rate.' },
];
function getReferHelpOverlay() {
  return {
    title: 'How do I refer?',
    intro: 'Refer another business to GoGorilla.com. They get 10% off their plan, and you earn 10% recurring commission on what they pay us throughout their minimum commitment period, rising to 20% as you refer more businesses.',
    tabs: [
      { label: 'How it works', blocks: GG_REFER_HOWITWORKS },
      { label: 'Your commission', blocks: GG_REFER_COMMISSION },
    ],
    faqs: GG_REFER_FAQS,
  };
}
if (typeof window !== 'undefined') window.getReferHelpOverlay = getReferHelpOverlay;

// 2026-07-02 (Loom 72): "Available commissions" overlay. One tab per referral
// type on the ratified structure (RATIFIED v3 commission doc, 2 Jul 2026).
// Creators come via the affiliate programme and capital solutions is deferred
// pending compliance, so neither gets a tab.
function getAvailableCommissionsOverlay() {
  return {
    title: 'Available commissions',
    intro: 'Every referral earns you something. Commission is based on each client\'s sign-up amount and runs for the length of their minimum commitment period.',
    tabs: [
      { label: 'Direct clients', badge: 'Most common', blocks: [
        { t: 'p', x: 'Refer a business that becomes a client and you earn recurring commission on their initial monthly sign-up amount, for the length of their minimum commitment period.' },
        { t: 'table', head: ['Active referrals', 'Your rate'], rows: [['1', '10%'], ['2 to 4', '15%'], ['5 or more', '20%']] },
        { t: 'p', x: 'Your current rate applies to every active referral you hold, so your fifth referral lifts all of them to 20%.' },
        { t: 'checks', items: [
          'The business you refer gets 10% off their plan.',
          'Commission is based on their sign-up amount, so your earnings are predictable from day one.',
          'Claim from a \u00a3100 balance as cash, or take it as credit towards your own services.',
        ] },
      ] },
      { label: 'Agencies', blocks: [
        { t: 'p', x: 'Refer an agency that joins our white-label programme and you earn a fixed \u00a3250, paid when they land their first active client.' },
        { t: 'checks', items: [
          'One fixed payment per agency you refer.',
          'Paid as soon as they land their first active client.',
          'Their white-label margin and their own referrals stay theirs.',
        ] },
      ] },
      { label: 'Investors', blocks: [
        { t: 'p', x: 'Introduce us to your portfolio and you earn \u00a3100 when your first portfolio company becomes active, then the direct-client rates on every portfolio company you refer.' },
        { t: 'checks', items: [
          '\u00a3100 one-off when your first portfolio company goes active.',
          'The direct-client rate ladder applies to each portfolio company after that.',
          'Take your commission as cash, or as credit towards your own services.',
        ] },
      ] },
      { label: 'Strategic partners', blocks: [
        { t: 'p', x: 'Web developers, videographers, lawyers, and accountants refer clients on the direct-client rates, once approved as partners.' },
        { t: 'checks', items: [
          'The rate ladder applies, 10% rising to 20% as you refer more.',
          'Commission can be taken as cash, or as credit towards our services.',
          'Approval is once per partner, not per referral.',
        ] },
      ] },
    ],
    faqs: GG_REFER_FAQS,
  };
}
if (typeof window !== 'undefined') window.getAvailableCommissionsOverlay = getAvailableCommissionsOverlay;

function getSavingsOverlay(clientTypeId, intentId) {
  const isAgency = clientTypeId === 'agency';
  const isFounder = clientTypeId === 'founder';
  const isInvestor = clientTypeId === 'investor';

  const referTab = {
    label: 'Refer a business',
    badge: 'Popular',
    blocks: [ ...GG_REFER_HOWITWORKS, { t: 'sub', x: 'Your commission' }, ...GG_REFER_COMMISSION ],
    faqs: GG_REFER_FAQS,
  };

  const commitTab = {
    label: 'Longer commitment',
    blocks: [
      { t: 'p', x: 'Commit for longer and pay less. The saving is built into the prices, so it updates as you change the term on each service.' },
      { t: 'checks', items: ['Twelve-month term, save up to 40%', 'Six-month term, save up to 20%', 'Three-month term, the standard rate'] },
      { t: 'sub', x: 'A quick example' },
      { t: 'p', x: 'A service at £1,000 a month on a three-month term comes down to around £600 a month on a twelve-month term. You choose the term per service, so you can take the longer saving where you are confident and keep flexibility elsewhere.' },
    ],
    faqs: [
      { q: 'How do the commitment discounts work?', a: 'A longer minimum term earns a bigger discount, rising to 40% at twelve months. The saving is built straight into the prices, so you see it the moment you change the term rather than waiting for checkout.' },
      { q: 'Is the term set per service or for the whole plan?', a: 'Per service. You can put your core service on a twelve-month term for the deepest saving and keep a newer service on a shorter term whilst you test it, and each one is priced on its own term.' },
      { q: 'What happens at the end of the term?', a: 'Your plan carries on at the same rate unless you change it. The minimum term is about how long you commit for the discount, not a hard stop, so nothing resets against you when it passes.' },
    ],
  };

  const upfrontTab = {
    label: 'Pay upfront',
    blocks: [
      { t: 'p', x: 'Pay for a service a year at a time instead of monthly and take 10% off that service, on top of whatever else you have applied.' },
      { t: 'checks', items: ['Save 10% on any service you pay upfront', 'Chosen per service, so you can mix upfront and monthly', 'Stacks with your commitment and bundle savings'] },
    ],
    faqs: [
      { q: 'How much does paying upfront save?', a: 'Paying a service a year at a time instead of monthly takes a further 10% off that service. It is applied on top of your commitment and bundle savings, not instead of them.' },
      { q: 'Can I pay some services upfront and others monthly?', a: 'Yes. Pay upfront is chosen per service, so you can prepay your core service for the extra saving and keep a newer one monthly whilst you settle in.' },
    ],
  };

  const bundleBlocks = [
    { t: 'p', x: 'The more we run for you, the more accountable we are for your growth, so we share the saving. This is the accountability discount.' },
    { t: 'checks', items: ['Bundle 2 core services, save 5% off retainers', 'Bundle 3 or more, save 10% off retainers', 'Applies to your managed-service retainers and add-ons'] },
  ];
  if (isAgency) bundleBlocks.push({ t: 'p', x: 'As a white-label partner your 40% discount and the accountability discount stack on top of each other, so consolidating more client work with us widens your margin.' });
  const bundleFaqs = [
    { q: 'What counts towards the bundle?', a: 'Your managed-service retainers and their add-ons count towards it. One-off charges and refundable deposits do not, since the discount is about the ongoing work we are accountable for.' },
    { q: 'How much can I save by bundling?', a: 'Two core services save 5% off your retainers, and three or more save 10%. The more of your growth we run, the more we share in the outcome, which is why the rate steps up.' },
  ];
  if (isAgency) bundleFaqs.push({ q: 'Does it stack with my white-label discount?', a: 'Yes. For white-label agencies the 40% white-label discount and the accountability discount stack additively, so you keep the full 40% and add the bundle saving on top.' });
  const bundleTab = { label: 'Bundle services', blocks: bundleBlocks, faqs: bundleFaqs };

  const recurringExample = isAgency
    ? 'A part-time dedicated resource you would resell at a one-off rate drops 20% when you switch it to recurring, and that goes straight into your margin. Custom List Building moves from £4.50 to £4 a prospect on recurring, an 11% saving on every list you order.'
    : 'A part-time dedicated resource drops 20% when you switch it from a one-off booking to a recurring one. Custom List Building moves from £4.50 to £4 a prospect, an 11% saving on every list you order.';
  const recurringFaqs = [
    { q: 'Which items can be set to recurring?', a: 'Part-time dedicated resources and most add-ons can run as a recurring subscription. Each one shows a recurring toggle as you build it, so you can see the lower price before you commit.' },
    { q: 'How much does recurring save?', a: 'Part-time dedicated resources save 20% on the recurring rate, and add-ons save in the region of 10% to 11% depending on the item. The exact saving is shown on each item when you switch it across.' },
  ];
  if (isAgency) recurringFaqs.push({ q: 'Does the recurring saving affect my margin?', a: 'It improves it. Because you buy the recurring rate at your white-label price, the 20% on part-time resources and the saving on add-ons come off your cost, so the gap to your resale price widens.' });
  const recurringTab = {
    label: 'Recurring billing',
    blocks: [
      { t: 'p', x: 'Some resources and add-ons can run as a recurring subscription rather than a one-off purchase. Switching them across brings the price down, because we can plan the work around an ongoing relationship.' },
      { t: 'checks', items: ['Part-time dedicated resources, save 20% on the recurring rate', 'Add-ons, save around 10% to 11% when set to recurring', 'Toggle it per item as you build your plan'] },
      { t: 'sub', x: 'A quick example' },
      { t: 'p', x: recurringExample },
    ],
    faqs: recurringFaqs,
  };

  const byolTab = {
    label: 'Bring your own list',
    blocks: [
      { t: 'p', x: 'If you already have a prospect list you want us to work, bring it and take 15% off. It applies to sales and demand generation, where the list is the starting point for the outreach we run.' },
      { t: 'checks', items: ['15% off when you supply your own prospect list', 'Applies to sales and demand generation', 'Use it when your list is ready to go'] },
    ],
    faqs: [
      { q: 'When does bringing my own list save 15%?', a: 'When you supply a prospect list for us to work rather than having us build one, we take 15% off the relevant sales and demand generation service, because part of the sourcing is already done.' },
      { q: 'Does my list need to meet any standard?', a: 'It should be a genuine, permissioned B2B list with the basic fields we need to run outreach. If anything is missing we will tell you before we start, and you can top it up or have us enrich it for you.' },
    ],
  };

  const foundersProTab = {
    label: 'Founders Pro free',
    blocks: [
      { t: 'p', x: 'Founders Pro, the middle Founders Portal tier, becomes free on your monthly total the moment you add at least one other GoGorilla.com service to your plan. You keep the full toolkit at no extra monthly cost.' },
      { t: 'checks', items: ['Founders Pro is free when paired with any other service', 'It stays on your plan, only the monthly cost is removed', 'Add a growth, creative, or talent service to unlock it'] },
      { t: 'sub', x: 'A quick example' },
      { t: 'p', x: 'Add Sales and Demand Generation to your plan and your Founders Pro tier drops to £0 a month. You keep the deck tools, the financial model, and the investor access alongside your growth service, and you only pay for the growth service.' },
    ],
    faqs: [
      { q: 'How does Founders Pro become free?', a: 'As soon as your plan includes one other GoGorilla.com service alongside Founders Pro, we remove the monthly cost of Founders Pro from your total. It is applied automatically, so you will see it land in Applied Savings as you build your plan.' },
      { q: 'Does the higher tier become free too?', a: 'No. The free offer is for Founders Pro, the middle tier. Founders Pro Plus keeps its own price because of the extra modelling, investor volume, and support it includes, although you can still pair it with other services for the bundle saving.' },
      { q: 'What counts as another service?', a: 'Any paid GoGorilla.com service outside the Founders Portal counts, for example Sales and Demand Generation, a creative service, or a dedicated hire. Once one of those is on your plan, Founders Pro is free.' },
    ],
  };

  const whiteLabelTab = {
    label: 'White-label margin',
    blocks: [
      { t: 'p', x: 'As a white-label partner you buy everything at 40% below the public price and resell at whatever you choose, so the 40% is your built-in margin before you add your own markup.' },
      { t: 'checks', items: ['40% off the public price on services and part-time dedicated resources', 'Full-time dedicated resources work differently, you earn 10% recurring commission on those', 'Stack the accountability discount on top when you bundle', 'Resell at your own price, the margin is yours'] },
      { t: 'p', x: 'Full-time dedicated hires sit outside the 40% because they are placed at cost. Rather than a wholesale discount you take a 10% recurring commission on the placement for as long as it runs, which keeps your margin without inflating the salary your client sees.' },
    ],
    faqs: [
      { q: 'What does the 40% apply to?', a: 'It applies to your managed-service retainers, add-ons, and part-time dedicated resources. You buy at 40% below our public price and set your own resale price, so the gap between the two is your margin.' },
      { q: 'Why are full-time dedicated resources different?', a: 'Full-time hires are placed at the true cost of the person, so there is no wholesale margin to discount. Instead you earn a 10% recurring commission on the placement for as long as it runs, which protects your margin whilst keeping the price transparent for your client.' },
      { q: 'Can I stack other savings on the 40%?', a: 'Yes. Your 40% white-label discount and the accountability discount stack additively, and you can pay upfront or commit for longer on top, so your margin grows as you consolidate more of your client work with us.' },
    ],
  };

  const investorTab = {
    label: 'Investor discount',
    blocks: [
      { t: 'p', x: 'As an investor you take 15% off our services as standard, whether you are buying for your own firm or for a business in your portfolio. It applies automatically once you choose an investor path.' },
      { t: 'checks', items: ['15% off our services for investors', 'Applies to your own firm and to portfolio companies', 'Stacks with longer commitment and paying upfront'] },
    ],
    faqs: [
      { q: 'How does the investor discount work?', a: 'Once you select an investor path, 15% comes off our services automatically and shows in Applied Savings. You do not need a code, it is built into the prices you see.' },
      { q: 'Can a portfolio company use it?', a: 'Yes. The 15% applies whether the work is for your own firm or for a business in your portfolio, so you can extend the same rate to the companies you back.' },
      { q: 'Does it stack with other savings?', a: 'Yes. The 15% sits alongside the commitment discount and paying upfront, so a portfolio company on a twelve-month term paid annually saves well beyond the 15% alone.' },
    ],
  };

  const tabs = [referTab];
  if (isFounder) tabs.push(foundersProTab);
  else if (isAgency) tabs.push(whiteLabelTab);
  else if (isInvestor) tabs.push(investorTab);
  tabs.push(commitTab, upfrontTab, bundleTab, recurringTab, byolTab);

  let intro = 'Here are the ways to bring your price down. Some apply automatically as you build your plan, and others, like referring a business or bringing your own list, are yours to unlock.';
  if (isAgency) intro = 'Here are the ways to bring your price down and widen your margin. Your white-label discount applies automatically, and the savings below stack on top of it.';
  else if (isInvestor) intro = 'Here are the ways to bring your price down. Your 15% investor discount applies automatically, and the savings below stack on top of it.';

  return {
    title: 'More ways to save',
    intro: intro,
    tabs: tabs,
    faqs: [
      { q: 'Can I combine these savings?', a: 'Most of them stack. Your commitment discount, paying upfront, recurring billing, and bundling all apply together, and a referral or a valid discount code can sit on top at checkout.' },
      { q: 'When are the savings applied?', a: 'Commitment, pay upfront, recurring, and bundling apply automatically as you build your plan and show in the Applied Savings box. A referral or discount code is applied at checkout.' },
      { q: 'Will I see each saving before I pay?', a: 'Yes. Every applied saving appears in the Applied Savings box with the amount off, and the full breakdown lists each one line by line, so nothing is hidden until checkout.' },
    ],
  };
}

function Summary({ state, dispatch, onNext, step, flow }) {
  const { clientTypeId, intentId, selections } = state;
  const ct = window.CLIENT_TYPES.find(c => c.id === clientTypeId);
  // 2026-06-09: VAT toggles mirrored into the breakdown overlay (agency white-label).
  // Synced with the margin-box toggles via the shared localStorage keys + events.
  const [vatReg, setVatRegS] = React.useState(() => { try { const v = window.localStorage.getItem('gg.vatRegistered'); if (v === null) window.localStorage.setItem('gg.vatRegistered', 'true'); return v !== 'false'; } catch (e) { return true; } });
  const [outsideUk, setOutsideUkS] = React.useState(() => { try { return window.localStorage.getItem('gg.outsideUk') === 'true'; } catch (e) { return false; } });
  React.useEffect(() => {
    const sv = () => { try { setVatRegS(window.localStorage.getItem('gg.vatRegistered') !== 'false'); } catch (e) {} };
    const so = () => { try { setOutsideUkS(window.localStorage.getItem('gg.outsideUk') === 'true'); } catch (e) {} };
    window.addEventListener('gg:vat-registered', sv); window.addEventListener('gg:outside-uk', so);
    return () => { window.removeEventListener('gg:vat-registered', sv); window.removeEventListener('gg:outside-uk', so); };
  }, []);
  // 2026-07-01: live referral commission figure. Mirrors the referral estimator's
  // "average client value per month" via the shared bridge so the sidebar shows a
  // live commission number in referring mode.
  const [referAvg, setReferAvg] = React.useState(() => { try { return (window.__flReferAvg != null) ? window.__flReferAvg : 1500; } catch (e) { return 1500; } });
  React.useEffect(() => {
    const onA = (e) => { try { setReferAvg((e && e.detail != null) ? e.detail : (window.__flReferAvg || 0)); } catch (_) {} };
    window.addEventListener('gg:refer-avg', onA);
    return () => window.removeEventListener('gg:refer-avg', onA);
  }, []);
  const setVat = (on) => { try { window.localStorage.setItem('gg.vatRegistered', on ? 'true' : 'false'); } catch (e) {} setVatRegS(on); try { window.dispatchEvent(new Event('gg:vat-registered')); } catch (e) {} };
  const setOutsideUk = (on) => { try { window.localStorage.setItem('gg.outsideUk', on ? 'true' : 'false'); } catch (e) {} setOutsideUkS(on); try { window.dispatchEvent(new Event('gg:outside-uk')); } catch (e) {} };
  // 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);
  // 2026-06-02: NET PROFIT follows the per-service retail prices the agency sets
  // in the margin calculators (stored in localStorage). Re-render when any of
  // them changes so the sidebar profit updates live.
  const [, _bumpRetail] = React.useReducer(x => x + 1, 0);
  React.useEffect(() => {
    const onR = () => _bumpRetail();
    window.addEventListener('gg:margin-retail', onR);
    window.addEventListener('gg:vat-registered', onR);
    return () => { window.removeEventListener('gg:margin-retail', onR); window.removeEventListener('gg:vat-registered', onR); };
  }, []);
  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; }
    if (match.expires && Date.now() > new Date(match.expires).getTime()) { setPromoError('This code has expired. Please contact us on WhatsApp, or request a new code.'); return; }
    if (match.topUpAgencyTo && clientTypeId !== 'agency') { setPromoError('This code applies to white-label agency orders.'); return; }
    // 2026-07-02 (R-01 fix): a typed referral partner code applies as a referral
    // discount ONCE — it sets state.referral (which folds 10% into the total and
    // shows a "Referral discount" row) + window.__ggReferral attribution, but must
    // NOT also set promoApplied, which fed promoDiscount and double-counted the 10%
    // (-19% total). Non-referral promo codes set promoApplied in the else branch.
    if (match.referral) {
      const _rpct = (typeof match.pct === 'number' && match.pct > 0) ? match.pct : 10;
      try {
        window.__ggReferral = {
          code: code,
          valid: true,
          pct: _rpct,
          label: match.label || 'Referral discount',
          stripePromotionCodeId: null,
          portalAccountId: match.portalAccountId || null,
          partnerEmail: match.partnerEmail || null,
          partnerName: match.partnerName || null,
          source: 'typed-code',
        };
        window.__ggReferralKey = code + '|typed';
        window.__ggReferralUi = { status: 'applied' };
      } catch (e) {}
      if (typeof dispatch === 'function') dispatch({ type: 'SET_REFERRAL', referral: { status: 'applied', code: code, pct: _rpct, label: match.label || null } });
    } else {
      setPromoApplied({ code, ...match });
    }
    setPromoError('');
    setPromoInput('');
    setPromoOpen(false);
  };
  const removePromo = () => {
    // If the applied code was a typed referral code, clear the attribution too
    // (leave a portal ?ref referral untouched — only typed-code attribution is
    // cleared here).
    try {
      if (window.__ggReferral && window.__ggReferral.source === 'typed-code') {
        window.__ggReferral = null; window.__ggReferralKey = null; window.__ggReferralUi = null;
        if (typeof dispatch === 'function') dispatch({ type: 'SET_REFERRAL', referral: null });
      }
    } catch (e) {}
    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(() => buildQuoteLines(state), [selections, isAgency, intentId, state.referMode]);

  // 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.
  // 2026-06-12 (review): trailing billing parentheticals on breakdown labels
  // render as small badges so the lines scan cleanly. Only billing terms are
  // badged, product names like (Lite) and (Full) stay as written.
  const _bdLabel = (label) => {
    if (typeof label !== 'string') return label;
    const m = label.match(/^(.*?)\s*\((one-?off|recurring|ex\. VAT|\d+%)\)\s*$/i);
    if (!m) return label;
    return (<>{m[1]}<span className="bdwn-badge">{m[2]}</span></>);
  };
  const monthlyLines = lines.filter(l => !l.oneTime && !l.waitlist);
  const oneTimeLines = lines.filter(l => l.oneTime && !l.waitlist);
  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);
  // 2026-06-02: per line, the agency's "charge" is the retail price they typed
  // in that margin calculator (localStorage), if any; otherwise our public RRP.
  // Tier retail maps to the main service line (id === sid); add-on retail maps
  // to add-on lines (aid). Channel/setup sub-lines have no input -> RRP.
  const _lineCharge = (l) => {
    // 2026-06-03 (Loom 19): the SDG cost sub-lines (extra leads, LinkedIn
    // profiles, WhatsApp follow-up) are pure costs folded into the SDG service
    // retail, so they carry no separate charge and subtract their wholesale
    // from the agency's net profit.
    if (l.id && /:(extra-leads|whatsapp-followup)$/.test(l.id)) return 0;
    let key = null;
    if (l.aid) key = `gg.margin.addon-${l.aid}`;
    else if (l.id && l.id.endsWith(':linkedin-profiles')) key = 'gg.margin.channel-linkedin';
    else if (l.id && l.id.endsWith(':instagram-outreach')) key = 'gg.margin.channel-instagram';
    else if (l.id === l.sid) key = `gg.margin.${l.sid}`;
    if (key) {
      try { const v = parseFloat(localStorage.getItem(key)); if (isFinite(v) && v > 0) return v; } catch (e) {}
    }
    return _lineRrp(l);
  };
  // 2026-06-04 (Loom 26): if the agency is not VAT registered it cannot reclaim
  // the VAT we charge on the wholesale rate, so its true cost is wholesale + 20%.
  const _vatMult = (() => { try { const _outside = localStorage.getItem('gg.outsideUk') === 'true'; const _notReg = localStorage.getItem('gg.vatRegistered') === 'false'; return (!_outside && _notReg) ? 1.2 : 1; } catch (e) { return 1; } })();
  // 2026-06-08: white-label agencies based outside the UK are not charged UK
  // VAT, so the breakdown omits the 20% VAT lines entirely for them.
  const _outsideUk = (() => { try { return localStorage.getItem('gg.outsideUk') === 'true'; } catch (e) { return false; } })();
  const _noUkVat = _isWlAgency && _outsideUk;
  const _hasSales = monthlyLines.some(l => l && l.sid === 'sales');
  // 2026-06-26 (Loom 61): fold the cost-per-meeting success share into the monthly
  // profit. Meetings/month x the agency's fixed 40% per-meeting margin, only when the
  // sales service is selected and both the price and the volume are set.
  const _cpmMonthlyProfit = (() => {
    if (!_isWlAgency || !_hasSales) return 0;
    try {
      const r = parseFloat(localStorage.getItem('gg.margin.cpm.retail')) || 0;
      const m = parseFloat(localStorage.getItem('gg.margin.cpm.meetings')) || 0;
      if (r > 0 && m > 0) return Math.max(0, r - Math.round(r * 0.6)) * m;
    } catch (e) {}
    return 0;
  })();
  const agencyMonthlyMargin = (_isWlAgency ? monthlyLines.filter(l => !l.custom).reduce((s, l) => s + (_lineCharge(l) - l.value * _vatMult), 0) : 0) + _cpmMonthlyProfit;
  // 2026-06-26 (Loom 61): has the agency entered any real retail price yet? Until they
  // have, the sidebar shows a 'Set your prices' prompt instead of an RRP-based figure.
  const _hasAnyRetail = (() => {
    try {
      // Only tier / add-on / channel / one-off retail flips the figure on. The cost per
      // meeting alone does not, so entering it never surfaces an RRP-based figure for a
      // tier the agency has not priced (it still folds into the figure once a tier is set).
      return Object.keys(localStorage).some((k) => k.indexOf('gg.margin.') === 0 && k.indexOf('gg.margin.cpm') !== 0 && (parseFloat(localStorage.getItem(k)) || 0) > 0);
    } catch (e) { return false; }
  })();
  const agencySetupMargin = _isWlAgency ? (() => {
    // 2026-06-26: group one-off setup-fee lines by service, then use the agency's
    // typed one-off retail (gg.margin.oneoff.<sid>) when set, falling back to our
    // public RRP until they price it (mirrors the monthly net-profit behaviour).
    const _g = {};
    oneTimeLines.filter(l => l.setupFee && !l.custom && !l.waitlist).forEach(l => {
      const _sid = l.sid || '_';
      if (!_g[_sid]) _g[_sid] = { whole: 0, rrp: 0 };
      _g[_sid].whole += l.value * _vatMult;
      _g[_sid].rrp += _lineRrp(l);
    });
    let _tot = Object.keys(_g).reduce((tot, _sid) => {
      const _grp = _g[_sid];
      let _retail = _grp.rrp;
      try { const _v = parseFloat(localStorage.getItem(`gg.margin.oneoff.${_sid}`)); if (isFinite(_v) && _v > 0) _retail = _v; } catch (e) {}
      return tot + (_retail - _grp.whole);
    }, 0);
    // 2026-06-27: one-off ADD-ONS (e.g. Custom List Building) carry their own retail
    // in gg.margin.addon-<aid> via _lineCharge, so fold their one-off margin in too.
    oneTimeLines.filter(l => l.aid && !l.setupFee && !l.custom && !l.waitlist).forEach(l => { _tot += (_lineCharge(l) - l.value * _vatMult); });
    return _tot;
  })() : 0;
  // 2026-06-02: per-service retail prices the agency set (for Airtable). Reads
  // the same localStorage keys the margin calculators write; tier retail -> main
  // service line, add-on retail -> add-on line. Only lists where a price is set.
  const _retailByServiceStr = _isWlAgency ? (() => {
    const out = [];
    monthlyLines.forEach(l => {
      let key = null, label = null;
      if (l.aid) { key = `gg.margin.addon-${l.aid}`; label = l.aid; }
      else if (l.id && l.id.endsWith(':linkedin-profiles')) { key = 'gg.margin.channel-linkedin'; label = 'channel-linkedin'; }
      else if (l.id && l.id.endsWith(':instagram-outreach')) { key = 'gg.margin.channel-instagram'; label = 'channel-instagram'; }
      else if (l.id === l.sid) { key = `gg.margin.${l.sid}`; label = l.sid; }
      if (!key) return;
      try { const v = parseFloat(localStorage.getItem(key)); if (isFinite(v) && v > 0) out.push(`${label}:${Math.round(v)}`); } catch (e) {}
    });
    return out.join(', ');
  })() : '';
  // 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;
    // 2026-06-03 (CALC-06): Enterprise "Contact for pricing" tiers (custom,
    // non-role, £0) no longer count toward the multi-service bundle.
    if (l.custom && !l.role) return;
    // 2026-06-03 (Loom 18 7:06): one-off services (e.g. Fundraising) do not
    // count toward the accountability bundle, since one-off charges are not
    // discounted.
    if (l.oneTime) return;
    // 2026-06-18 (C-01): a £0 monthly line (e.g. Founders Portal, free when
    // bundled) must not bump the bundle tier (2->3 services) and deepen the
    // discount on the paid services. Only count services that actually charge.
    // Custom/bespoke services (value 0 but quoted bespoke, e.g. FT Dedicated)
    // are kept by the l.custom guard above, so they still count.
    if (!l.custom && !(l.value > 0)) return;
    _msCountedSids.add(l.sid);
  });
  const monthlyServiceCount = _msCountedSids.size;
  // Total selected services for display only. Includes Dedicated Resources and
  // one-off services, which do not qualify for the discount but are still
  // things the user has chosen. Drives the "X services selected" subtitle.
  const _allSelectedSids = new Set();
  lines.forEach(l => {
    if (l.addon || l.setupFee || l.waitlist) return;
    if (!l.sid) return;
    _allSelectedSids.add(l.sid);
  });
  const selectedServiceCount = _allSelectedSids.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-06-03 (Loom 18 7:06, "exclude all one-off"): one-off charges no longer
  // receive the multi-service bundle discount, matching the policy that one-off
  // charges are excluded. This previously discounted one-off creative work
  // (Content / Motion) and would also have caught one-off services such as
  // Fundraising once a second service was added.
  const oneTimeDiscountableSubtotal = 0;
  // Dedicated Resources staffing is excluded from the multi-service bundle so the
  // discount never lands on pass-through wages. We strip both discountExcluded
  // lines and the per-role lines (which carry a real monthly value but no
  // discountExcluded flag) from the discountable base before computing
  // bundleDiscountMonthly, so only managed-service retainers and regular add-ons
  // are reduced. (2026-06-03 Loom 18 7:06: extended to role lines, Option A.)
  const _excludedMonthlyVal = monthlyLines
    .filter(l => l.discountExcluded || l.role)
    .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);
  // 2026-06-03 (Loom 18 7:06): a discount code applies to the same base as the
  // accountability discount, managed-service retainers and regular add-ons,
  // after the bundle discount. Dedicated Resources staffing is already stripped
  // from _discountableBase above, so codes never touch dedicated wages either.
  // Codes still stack and remain one per quote.
  const _codeBaseAfterBundle = Math.max(0, _discountableBase - bundleDiscountMonthly);
  // 2026-06-15: a topUpAgencyTo code (AGENCY55) lifts an agency quote to a
  // target rate for this order. The extra is the gap between the agency rate
  // already baked into the line prices and the target, off the same code base,
  // so the quote lands at the target % off retail rather than stacking.
  const promoDiscount = !promoApplied ? 0
    : ((promoApplied.topUpAgencyTo && isAgency)
        ? (promoApplied.topUpAgencyTo > agencyDiscountPct
            ? _codeBaseAfterBundle * ((promoApplied.topUpAgencyTo - agencyDiscountPct) / (100 - agencyDiscountPct))
            : 0)
        : _codeBaseAfterBundle * (promoApplied.pct / 100));
  // 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 waitlistLines = lines.filter(l => l.waitlist);
  // 2026-06-02 (#6 BYOL): genuine flat 15% off each BYOL service's retainer.
  // Previously BYOL was display-only (£0 effect). Stacks additively after bundle.
  // 2026-06-03: reverted CALC-01 tiering back to a genuine flat 15% per
  // Alexander's Tuesday decision (the list-quality selector is removed entirely).
  const BYOL_FLAT_PCT = 0.15;
  const byolSavingsMonthly = monthlyLines
    .filter(l => !l.addon && l.id === l.sid && selections[l.sid] && selections[l.sid].leadSourceMode === 'byol')
    .reduce((s, l) => s + (l.value * ((selections[l.sid] && selections[l.sid].payUpfront) ? 0.90 : 1) * BYOL_FLAT_PCT), 0);
  // 2026-06-03 (CALC-04): enforce combinedDiscountCap as a TRUE ceiling on the
  // total monthly discount (agency rate + pay-upfront + bundle + promo + BYOL),
  // not just the multi-service %. For agency intents the combined discount off
  // standard wholesale (sum of rrp) is capped; clamp the monthly total up to
  // that floor so stacked discounts can't exceed the cap. capAdjustment is the
  // add-back, shown as an explicit line in the breakdown so the modal reconciles.
  let total = Math.max(0, afterBundle - promoDiscount - byolSavingsMonthly);
  const _capFrac = window.combinedDiscountCap ? window.combinedDiscountCap(state) : 1;
  let capAdjustment = 0;
  if (_capFrac < 1) {
    const _rrpMonthly = monthlyLines.reduce((s, l) => s + (typeof l.rrp === 'number' ? l.rrp : 0), 0);
    const _capFloor = _rrpMonthly * (1 - _capFrac);
    if (_capFloor > 0 && total < _capFloor) { capAdjustment = _capFloor - total; total = _capFloor; }
  }
  // 2026-07-02: referral discount ("I've been referred" toggle = declared, or an
  // applied ?ref code) takes 10% off the monthly total. Applied after the cap so it
  // is a genuine reduction the customer sees; shown in Applied Savings + the full
  // breakdown. Skipped in referring mode (there the user is the referrer, not referred).
  const _referralActive = !state.referMode && !!(state.referral && (state.referral.status === 'declared' || state.referral.status === 'applied'));
  const referralPct = _referralActive ? (((state.referral && typeof state.referral.pct === 'number') ? state.referral.pct : 10)) : 0;
  const referralDiscountMonthly = _referralActive ? total * (referralPct / 100) : 0;
  total = Math.max(0, total - referralDiscountMonthly);
  const annual = total * 12 + oneTimeAfterBundle;
  const animTotal = window.useAnimatedValue(Math.round(total));
  // 2026-06-11: setup & one-off figures animate like every other price.
  const animOneTime = window.useAnimatedValue(Math.round(oneTimeAfterBundle));
  const animSetupMargin = window.useAnimatedValue(Math.round(agencySetupMargin));

  // 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(() => {
    // Effective minimum commitment for the quote: the longest commitment term
    // among the recurring services (one-off / fixed-duration services and PT
    // one-off carry none; dedicated PT/FT are a 3-month minimum). Sent to the
    // portal so it can set the subscription commitMonths and match the referee
    // discount duration to the commitment. null when nothing recurring is picked.
    const _effCommitMonths = (() => {
      let mx = 0;
      try {
        Object.keys(selections || {}).forEach((sid) => {
          const sel = selections[sid];
          if (!sel || !sel.tier) return;
          const svc = (window.SERVICES || []).find(x => x.id === sid);
          if (!svc || svc.oneTime || svc.fixedDuration) return;
          if (sid === 'dedicated-pt' && sel.ptBilling === 'oneoff') return;
          let m;
          if (sid === 'dedicated-pt' || sid === 'dedicated-ft') {
            m = 3;
          } else {
            const opts = window.commitsFor ? window.commitsFor(svc) : null;
            const cid = (opts && opts.some(o => o.id === sel.commitId)) ? sel.commitId : '12';
            m = (opts && (opts.find(o => o.id === cid) || {}).months) || 12;
          }
          if (m > mx) mx = m;
        });
      } catch (e) { /* ignore */ }
      return mx || null;
    })();
    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: ((typeof window !== 'undefined' && window.isFreelancerMode && window.isFreelancerMode()) ? 'For Freelancers' : (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 || '', brandColour: q0.brandColour || '', brandAssetsUrl: q0.brandAssetsUrl || '', brandNotes: q0.brandNotes || '', brandAssetUploads: Array.isArray(q0.brandAssetUploads) ? q0.brandAssetUploads : [] },
      // 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,
      // Effective minimum commitment (max of the recurring services' terms),
      // for the portal to set subscription commitMonths + referee discount duration.
      commitMonths: _effCommitMonths,
      // 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,
        // 2026-06-18 (Batch 11): carry includedAuto/setupFee/customLabel into the
        // published snapshot. The checkout reads window.__currentQuote.lines, and
        // its custom-detection excludes includedAuto lines, but this map was
        // dropping the flag, so a free "Included" add-on (e.g. Investor Scale's
        // Paid Advertising Lite) was still counted as custom-priced.
        includedAuto: !!l.includedAuto, setupFee: !!l.setupFee, customLabel: l.customLabel || null,
      })),
      monthlyTotal: Math.round(total),
      oneTimeTotal: Math.round(oneTimeSubtotal),
      netProfit: _isWlAgency ? Math.round(agencyMonthlyMargin) : 0,
      netProfitSetup: _isWlAgency ? Math.round(agencySetupMargin) : 0,
      retailByService: _isWlAgency ? _retailByServiceStr : '',
      promoCode: promoApplied?.code || '',
      promoPct: promoApplied?.pct || 0,
      // 2026-07-02: the referral discount is folded into monthlyTotal above.
      // Publish the pieces so the platform payload applies it exactly ONCE
      // (source of truth = this discounted total; referral code = attribution).
      referralStatus: _referralActive ? state.referral.status : null,
      referralPct: referralPct,
      referralDiscountMonthly: Math.round(referralDiscountMonthly),
      monthlyTotalBeforeReferral: Math.round(total + referralDiscountMonthly),
    };
  }, [
    clientTypeId, intentId, lines, total, oneTimeSubtotal, promoApplied, ct, intent,
    qualifier, selections,
    q0.clientName, q0.industry, q0.brandColour, q0.brandAssetsUrl, q0.brandNotes, quote_uuid, ae_ref, winback_ref, warmth, cohort,
    agencyMonthlyMargin, agencySetupMargin, _retailByServiceStr,
  ]);

  // 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 [savingsOvOpen, setSavingsOvOpen] = React.useState(false);
  const [referHelpOv, setReferHelpOv] = React.useState(false);
  const [commOv, setCommOv] = React.useState(false); // 2026-07-02: Available Commissions overlay
  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-card">
      <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">{state.referMode ? 'Your referral' : 'Your Plan'}</div>
            <div className="summary__plan-name">
              {ct ? (
                <>GoGorilla<em>{state.referMode ? 'Referrals' : (((window.isFreelancerMode && window.isFreelancerMode()) && ct.id === 'agency') ? 'Partners' : ct.subBrand)}</em><sup>®</sup></>
              ) : (
                <span className="summary__plan-name-empty">Pick your client type ↑</span>
              )}
            </div>
            {(!ct || agencyDiscountPct > 0 || state.referMode) && (
              <div className="summary__plan-meta">
                {ct ? (
                  state.referMode
                    ? <span className="summary__plan-chip">10% recurring</span>
                    : (agencyDiscountPct > 0 && <span className="summary__plan-chip">−{agencyDiscountPct}% {((window.isFreelancerMode && window.isFreelancerMode())) ? 'partner' : 'agency'}</span>)
                ) : (
                  'Tailors pricing, tiers, and add-ons'
                )}
              </div>
            )}
          </div>
        </div>
        {state.referMode && (
          <div className="summary__refer-note" style={{ margin: '4px 0 6px', fontSize: '0.8rem', color: '#475A86', lineHeight: 1.5 }}>This is what the business you refer would pay. You earn <strong>10% recurring commission</strong> on it, shown below.</div>
        )}
        {(<div className="summary__list-title">
          {/* 2026-06-10 (Loom 32 4:28): heading opens the full breakdown, a second way in. */}
          <span
            className="summary__heading-link"
            role="button"
            tabIndex={0}
            title="See full breakdown"
            onClick={() => setShowFullBreakdown(true)}
            onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setShowFullBreakdown(true); } }}
          >Services &amp; Add-ons</span>
          {/* 2026-06-11 (Loom 37): the counter counts items the visitor chose,
              one per core service and one per paid add-on (paid channel extras
              like LinkedIn included). Setup fees, deposits, auto-included
              items, and the channels info line never count. */}
          <span>{countableQuoteLines(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 title={l.label}>{l.label}</span>
            </span>
            <span className="summary__line-val">
              {l.waitlist ? (
                <WaitlistTip
                  body={l.entWaitlist ? 'Enterprise places are limited, so adding this reserves your spot whilst your proposal is prepared. Your plan is saved and no charge applies in the meantime.' : undefined}
                  meta={l.entWaitlist ? 'Pricing is confirmed in your proposal.' : undefined}
                />
              ) : l.custom ? String(l.customLabel || 'Custom') : <AnimatedGBP value={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. */}

        {/* 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>
      </div>

      {/* QA: Applied Savings moved OUT of the scrolling list so it stays
          visible (standalone, pinned), styled like the accountability box. */}
        {!state.referMode && (() => {
          const savingLines = [];
          // 1. Commitment savings — ONE consolidated line for all
          // recurring services (the commit discount is baked into each
          // line's price). Effective commit = the user's pick, else the
          // 12-month default priceFor falls back to, so this mirrors the
          // displayed prices. Per-service detail lives in the hover tooltip.
          const _commitSavings = [];
          for (const [sid, sel] of Object.entries(selections || {})) {
            if (!sel) continue;
            const svc = (window.SERVICES || []).find(s => s.id === sid);
            if (!svc) continue;
            const opts = window.commitsFor ? window.commitsFor(svc) : null;
            if (!opts || !opts.length) continue;
            const cid = (sel.commitId != null && opts.some(o => o.id === String(sel.commitId))) ? String(sel.commitId) : '12';
            const opt = opts.find(o => o.id === cid);
            if (!opt || !opt.save) continue;
            _commitSavings.push({ name: svc.name || sid, months: opt.months, save: opt.save });
          }
          if (_commitSavings.length) {
            const _cs = _commitSavings.map(c => c.save);
            const _cmin = Math.min(..._cs), _cmax = Math.max(..._cs);
            savingLines.push({
              key: 'commit-all',
              label: 'Commitment discount',
              icon: 'clock',
              pctLabel: _cmin === _cmax ? ('−' + _cmax + '%') : ('−' + _cmin + '–' + _cmax + '%'),
              tip: (
                <>
                  <span className="applied-tip__head">Commitment discount applies to:</span>
                  {_commitSavings.map((c, i) => (
                    <span key={i} className="applied-tip__row">{c.name} <strong>{c.months}-month (−{c.save}%)</strong></span>
                  ))}
                </>
              ),
            });
          }
          // 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),
                icon: 'banknote',
                pct: 10,
                tip: <span>Paying for {svc.name || sid} upfront for the full term takes 10% off its retainer.</span>,
              });
            }
          }
          // 3. BYOL flat 15% discount per service (list-quality selector removed per Alexander)
          for (const [sid, sel] of Object.entries(selections || {})) {
            if (sel?.leadSourceMode === 'byol') {
              const svc = (window.SERVICES || []).find(s => s.id === sid);
              if (!svc) continue;
              savingLines.push({
                key: 'byolq-' + sid,
                label: 'Bring Your Own List on ' + (svc.name || sid),
                icon: 'clipboard',
                pct: 15,
                tip: <span>Supplying your own verified list for {svc.name || sid} takes 15% off its retainer. We still enrich the data, write the copy, and run the full cadence.</span>,
              });
            }
          }
          // 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';
            // 2026-06-12 (Loom 44 10:48, Nicole): investors see their own label.
            const _isInv = state.clientTypeId === 'investor';
            savingLines.push({
              key: 'agency',
              label: (isWl ? 'White-label' : (_isInv ? 'Investor' : ((window.isFreelancerMode && window.isFreelancerMode()) ? 'Partner' : 'Agency partner'))) + ' discount on all tiers',
              icon: 'building',
              pct: pct,
              tip: <span>Your {isWl ? 'white-label' : (_isInv ? 'investor' : ((window.isFreelancerMode && window.isFreelancerMode()) ? 'partner' : 'agency partner'))} rate of {pct}% applies to every service tier in this quote{isWl ? ', except full-time dedicated resources, where you earn a 10% recurring commission instead' : ''}.</span>,
            });
          }
          // Referral discount (declared "I've been referred" toggle, or an applied
          // ?ref code): 10% off, folded into the monthly total above and listed here.
          if (_referralActive) {
            const _refDeclared = state.referral.status === 'declared';
            savingLines.push({
              key: 'referral',
              label: 'Referral discount',
              icon: 'tag',
              pct: referralPct,
              note: _refDeclared ? 'Add who referred you on the last step to confirm it.' : null,
              tip: <span>Your {referralPct}% referral discount is included in your monthly total{_refDeclared ? '. Tell us who referred you on the last page so we can confirm it' : ''}.</span>,
            });
          }
          // Always render: the discount-code input lives here, and the
          // savings list grows as discounts get picked up.
          return (
            <div className="summary__applied-savings" aria-label="Applied savings">
              <div className="summary__applied-savings-head">
                {/* 2026-06-10 (Loom 32 4:38): heading opens the full breakdown too. */}
                <span
                  className="summary__heading-link"
                  role="button"
                  tabIndex={0}
                  title="See full breakdown"
                  onClick={() => setShowFullBreakdown(true)}
                  onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setShowFullBreakdown(true); } }}
                >Applied savings</span>
                <HoverPortalTip
                  wrapClassName="summary__applied-savings-info-wrap"
                  tipClassName="summary__total-tip"
                  placement="above"
                  tip={<>
                    <span className="summary__total-tip-head">Applied savings</span>
                    <span className="summary__total-tip-body">Applied Savings shows every discount working on your quote right now, such as your commitment length, paying upfront, bundling services, bringing your own list, and any discount code. They are already included in your monthly total.</span>
                  </>}
                >
                  <window.InfoIcon className="summary__applied-savings-info" title="About applied savings" />
                </HoverPortalTip>
                <button type="button" className="summary__savings-viewall svc__talent-wl-link" onClick={() => setSavingsOvOpen(true)} style={{ marginLeft: 'auto' }}><span className="svc__talent-wl-link__text">View available savings</span> <span className="svc__talent-wl-link__arrow" aria-hidden="true">›</span></button>
              </div>
              {savingLines.map(s => (
                <React.Fragment key={s.key}>
                  <div className="summary__applied-saving">
                    <span className="summary__applied-saving-label">
                      <SavingIcon name={s.icon} />
                      <span className="summary__applied-saving-text">{s.label}</span>
                      {s.tip && (
                        <HoverPortalTip as="span" placement="above" tipClassName="applied-tip" wrapClassName="summary__applied-savings-info-wrap" tip={s.tip}>
                          <window.InfoIcon className="summary__applied-savings-info" title="About this saving" />
                        </HoverPortalTip>
                      )}
                    </span>
                    <span className="summary__applied-saving-pct">{s.pctLabel || ('−' + s.pct + '%')}</span>
                  </div>
                  {s.note && (
                    <div className="summary__applied-saving-note" style={{ width: '100%', fontSize: '0.72rem', color: '#5a647d', lineHeight: 1.4, margin: '1px 0 5px' }}>{s.note}</div>
                  )}
                </React.Fragment>
              ))}
              {savingLines.length === 0 && (
                <div className="summary__applied-saving summary__applied-saving--empty">No savings yet. Add a discount code or bundle services to start saving.</div>
              )}
              <div className={`summary__promo summary__promo--in-savings ${promoOpen ? 'summary__promo--open' : ''} ${promoApplied ? 'summary__promo--applied' : ''}`}>
                {promoApplied ? (
                  <>
                  <div className="summary__promo-applied">
                    <span className="summary__promo-applied-label">
                      <SavingIcon name="tag" />
                      <span>{promoApplied.label} <code className="summary__promo-tag">{promoApplied.code}</code> ({promoApplied.topUpAgencyTo ? promoApplied.topUpAgencyTo + '% partner rate' : '−' + promoApplied.pct + '%'}){promoApplied.topUpAgencyTo && window.HoverPortalTip ? (<window.HoverPortalTip wrapClassName="df-loc-tip-wrap" wrapStyle={{display:'inline-flex',verticalAlign:'middle',marginLeft:'0.3rem'}} tipClassName="dis-tip dis-tip--above" placement="above" tip={"A one-off white-label partner offer that lifts your rate to 55% off for this order. Given for a first order only, arranged with your GoGorilla.com contact."}><window.InfoIcon title="About this offer" onClick={(e) => e.stopPropagation()} /></window.HoverPortalTip>) : null}</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>
                  {/* 2026-07-01: referral partner codes (e.g. Johnny10) give the referred
                      business a discount for the length of their minimum commitment. Show
                      that caveat when a referral code is applied, not for the white-label
                      partner-rate (topUpAgencyTo) codes. */}
                  {(!promoApplied.topUpAgencyTo && (promoApplied.minCommitment || /referr/i.test(promoApplied.label || ''))) && (
                    <div className="summary__promo-refnote" style={{ fontSize: '0.72rem', color: '#5a647d', margin: '5px 0 0', lineHeight: 1.4 }}>This saving applies for the length of your minimum commitment period.</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 className="summary__promo-hint" style={{ gridColumn: '1 / -1', width: '100%', boxSizing: 'border-box', fontSize: '0.72rem', color: '#8a93a8', marginTop: '6px', lineHeight: 1.4 }}>Have a code from a partner or our team? Enter it here to apply your discount.</div>
                    </div>
                  )
                )}
              </div>
            </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. */}
      {!state.referMode && (() => {
        // Use the count of actually-priced services, not raw selection keys.
        // Stale selections from prior personas would otherwise inflate this.
        // 2026-06-03 (Loom 18 7:06): the subtitle counts every selected service
        // (including Dedicated Resources and one-off services), while the % and
        // the highlighted rung reflect only the qualifying services. A short
        // note explains the gap when non-qualifying services are selected.
        const qualifyingCount = monthlyServiceCount;
        const selectedCount = selectedServiceCount;
        const hasNonQualifying = selectedCount > qualifyingCount;
        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 = qualifyingCount >= 3 ? 3 : Math.max(1, qualifyingCount);
        const subtitle = selectedCount === 0
          ? 'No services selected yet'
          : `${selectedCount} service${selectedCount === 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="Accountability discount">
            <button
              type="button"
              className="ms-stack__head"
              aria-expanded={msOpen}
              aria-controls="ms-stack-body"
              onClick={() => setMsOpen(o => !o)}
            >
              <span className="ms-stack__head-main">
              <span className="ms-stack__lbl">
                ACCOUNTABILITY DISCOUNT
                <HoverPortalTip
                  wrapClassName="ms-stack__info-wrap"
                  tipClassName="channel-tile__tip ms-stack__tip"
                  placement="above"
                  tip={<span>The more we run for you, the more accountable we are for your growth, so we share the saving.<br/><br/>Bundle 2 core services and save <strong>5% off retainers</strong>, or 3 or more and save <strong>10% off retainers</strong>. It applies to your managed-service retainers and add-ons. Commit-length discounts and bundle savings apply automatically. Promotional codes are validated with your Account Executive.{isWhitelabel && <><br/><br/>For <strong>white-label agencies</strong>, your 40% white-label discount and the accountability discount stack additively.</>}</span>}
                >
                  <span
                    className="ms-stack__info"
                    role="button"
                    tabIndex={-1}
                    aria-label="About the accountability 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-row2">
                <span className="ms-stack__head-pct-num">{currentPct}% off</span>
              <span className="ms-stack__head-amount">{bundleDiscount > 0 ? '−' + window.fmt(bundleDiscount) : ''}</span>
              </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>
                {hasNonQualifying && (
                  <div className="ms-stack__note" style={{ marginTop: '0.6rem', fontSize: '0.72rem', lineHeight: 1.4, color: 'var(--gg-muted, #6b7280)' }}>
                    Dedicated Resources and one-off charges are not included in this discount.
                  </div>
                )}
              </div>
            )}
          </div>
        );
      })()}

      {(<div className="summary__total" style={state.referMode ? { display: 'flex', flexDirection: 'column' } : undefined}>
        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px', margin: '0 0 6px', order: -2 }}>
          <span style={{ fontSize: '0.72rem', fontWeight: 800, letterSpacing: '0.06em', textTransform: 'uppercase', color: '#002ABF' }}>{state.referMode ? 'Your forecast' : 'Your totals'}</span>
          {state.referMode && (
            <button type="button" className="summary__savings-viewall svc__talent-wl-link" onClick={() => setCommOv(true)}><span className="svc__talent-wl-link__text">View available commissions</span> <span className="svc__talent-wl-link__arrow" aria-hidden="true">{'\u203a'}</span></button>
          )}
        </div>
        {/* 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;
                  if (state.referMode) {
                    return (
                      <>
                        <span className="summary__total-tip-head">About this total</span>
                        <span className="summary__total-tip-body">This is the monthly total the business you refer would pay us, before VAT and one-off setup fees. You do not pay it. Your recurring commission is worked out from this amount.</span>
                      </>
                    );
                  }
                  return (
                    <>
                      <span className="summary__total-tip-head">About this total</span>
                      <span className="summary__total-tip-body">
                        Your monthly total {_noUkVat ? <><strong>has no UK VAT</strong> (your business is located outside the UK) and <strong>does not include one-off setup fees</strong></> : <><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' : ((window.isFreelancerMode && window.isFreelancerMode()) ? 'partner' : 'agency partner')} discount</strong>.</>
                        )}
                        {_hasMsDiscount && (
                          <> A <strong>{_msPct}% accountability 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" style={state.referMode ? { fontSize: '1.15rem', fontWeight: 700, color: 'var(--gg-muted, #6b7280)' } : undefined}>{hasEnterprise && total === 0 ? 'Custom' : window.fmt(animTotal)}</div>
        </div>
        {_isWlAgency && !state.referMode && (
          <div className="summary__total-row" style={{ marginTop: '6px' }}>
            <div className="summary__total-label">
              <span>Net profit (monthly)</span>
              <HoverPortalTip wrapClassName="summary__total-info-wrap" tipClassName="summary__total-tip" placement="above"
                tip={<><span className="summary__total-tip-head">Your net profit</span><span className="summary__total-tip-body">Your monthly profit, what you charge your client minus your wholesale cost, across services and add-ons. Set your prices in the margin boxes above to update it (we use our public RRP until you do). If you have set the sales cost per meeting and your meetings per month, this also includes the share you keep on those meetings.{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">{_hasAnyRetail ? <span style={{ color: '#1E9E5C', fontSize: '1.15rem' }}>{window.fmt(Math.round(agencyMonthlyMargin))}/mo</span> : <HoverPortalTip as="span" wrapClassName="summary__setprices-tip-wrap" wrapStyle={{ display: 'inline-flex' }} tipClassName="summary__total-tip" placement="above" tip={<><span className="summary__total-tip-head">Set your prices</span><span className="summary__total-tip-body">Type what you charge your clients for each service in the margin boxes above, and your monthly profit appears here.</span></>}><button type="button" className="summary__set-prices" style={{ color: '#1E9E5C', fontWeight: 600 }} onClick={() => { try { const el = document.querySelector('.svc-margin'); if (el && el.scrollIntoView) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); return; } if (typeof dispatch === 'function') { dispatch({ type: 'SET_SERVICE', id: 'sales', on: true }); dispatch({ type: 'SET_TIER', id: 'sales', tier: 'grow' }); } setTimeout(() => { try { const m = document.querySelector('.svc-margin') || document.querySelector('[data-svc-id=\"sales\"]'); if (m && m.scrollIntoView) m.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (e) {} }, 250); } catch (e) {} }}>Set your prices</button></HoverPortalTip>}</div>
          </div>
        )}
        {state.referMode && (
          <div className="summary__total-row" style={{ order: -1, marginBottom: '8px' }}>
            <div className="summary__total-label">
              <span style={{ color: '#0E8A50', fontWeight: 700 }}>Recurring commission</span>
              <HoverPortalTip wrapClassName="summary__total-info-wrap" tipClassName="summary__total-tip" placement="above"
                tip={<><span className="summary__total-tip-head">Your recurring commission</span><span className="summary__total-tip-body">You earn 10% recurring commission on the monthly total the business you refer pays us, based on their sign-up amount. It runs to the end of their minimum commitment period. Reactivations do not earn recurring commission. Your rate rises to 15% from your second active referral, and 20% from your fifth.</span></>}>
                <button type="button" className="summary__total-info" aria-label="About your commission" 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"><span style={{ color: '#0E8A50' }}>{window.fmt(Math.round(0.10 * (total || 0)))}/mo</span></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(animOneTime)}</div>
        </div>
        {_isWlAgency && !state.referMode && (
          <div className="summary__total-row" style={{ marginTop: '4px' }}>
            <div className="summary__total-label summary__total-label--setup-fees">
              <span>Net profit (one-off)</span>
              <HoverPortalTip wrapClassName="summary__total-info-wrap" tipClassName="summary__total-tip" placement="above"
                tip={<><span className="summary__total-tip-head">Your one-off profit</span><span className="summary__total-tip-body">What you charge your client for setup and one-off fees, minus our wholesale setup cost. This is a one-time figure, billed once at the start.</span></>}>
                <button type="button" className="summary__total-info" aria-label="About your one-off 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 summary__total-val--setup-fees">{_hasAnyRetail ? <span style={{ color: '#1E9E5C', fontSize: '0.82rem' }}>{window.fmt(Math.round(agencySetupMargin))}</span> : <HoverPortalTip as="span" wrapClassName="summary__setprices-tip-wrap" wrapStyle={{ display: 'inline-flex' }} tipClassName="summary__total-tip" placement="above" tip={<><span className="summary__total-tip-head">Set your prices</span><span className="summary__total-tip-body">Type what you charge your clients for setup and one-off fees in the margin boxes above, and your one-off profit appears here.</span></>}><button type="button" className="summary__set-prices summary__set-prices--sm" style={{ color: '#1E9E5C', fontWeight: 600 }} onClick={() => { try { const el = document.querySelector('.svc-margin'); if (el && el.scrollIntoView) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); return; } if (typeof dispatch === 'function') { dispatch({ type: 'SET_SERVICE', id: 'sales', on: true }); dispatch({ type: 'SET_TIER', id: 'sales', tier: 'grow' }); } setTimeout(() => { try { const m = document.querySelector('.svc-margin') || document.querySelector('[data-svc-id=\"sales\"]'); if (m && m.scrollIntoView) m.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (e) {} }, 250); } catch (e) {} }}>Set your prices</button></HoverPortalTip>}</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 thin-glass-frame"
          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. */}
      {savingsOvOpen && <GMOverlayModal data={getSavingsOverlay(state.clientTypeId, state.intentId)} onClose={() => setSavingsOvOpen(false)} clientTypeId={state.clientTypeId} intentId={state.intentId} onSwitchToReferring={(!state.referMode && state.clientTypeId === 'agency' && state.intentId === 'agency-whitelabel') ? (() => { setSavingsOvOpen(false); dispatch({ type: 'SET_REFER_MODE', value: true }); }) : undefined} />}
      {referHelpOv && <GMOverlayModal data={getReferHelpOverlay()} onClose={() => setReferHelpOv(false)} clientTypeId={state.clientTypeId} intentId={state.intentId} />}
      {commOv && <GMOverlayModal data={getAvailableCommissionsOverlay()} onClose={() => setCommOv(false)} clientTypeId={state.clientTypeId} intentId={state.intentId} />}
      {showFullBreakdown && ReactDOM.createPortal(
        (() => {
          const hasAnyLines = monthlyLines.length > 0 || oneTimeLines.length > 0 || hasWaitlist;
          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' : (l.fromPriced && l.value > 0 ? 'From ' + window.fmt(l.value) : window.fmt(l.value));
                  lines.push('  ' + (l.addon ? '+ ' : '') + l.label + ': ' + val);
                });
                lines.push('  Subtotal: ' + window.fmt(monthlySubtotal));
                if (bundleDiscount > 0) lines.push('  Accountability discount (-' + Math.round(_msPctFinal * 100) + '%): -' + window.fmt(bundleDiscount));
                if (promoDiscount > 0 && promoApplied) lines.push('  ' + (promoApplied.label || 'Promo') + ' ' + promoApplied.code + ' (' + (promoApplied.topUpAgencyTo ? promoApplied.topUpAgencyTo + '% partner rate' : '-' + promoApplied.pct + '%') + '): -' + window.fmt(promoDiscount));
                if (referralDiscountMonthly > 0) lines.push('  Referral discount (-' + referralPct + '%): -' + window.fmt(referralDiscountMonthly));
                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' : (l.fromPriced && l.value > 0 ? 'From ' + window.fmt(l.value) : window.fmt(l.value));
                  lines.push('  ' + l.label + ': ' + val);
                });
                lines.push('  Setup total: ' + window.fmt(oneTimeSubtotal));
                lines.push('');
              }
              if (hasWaitlist) {
                lines.push('Waiting list (reserved, not charged):');
                waitlistLines.forEach(l => {
                  lines.push('  ' + (l.addon ? '+ ' : '') + l.label + ': Reserved');
                });
                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. Once you complete the calculator, we hold the onboarding capacity for these services for up to 30 days.
                </p>

                {isAgency && (
                  <div className="svc-margin__vat bdwn-modal__vat-toggles" onClick={(e) => e.stopPropagation()}>
                    <div className="svc-margin__vat-row">
                      <label className="svc-margin__vat-toggle">
                        <input type="checkbox" checked={vatReg && !outsideUk} onChange={(e) => { setVat(e.target.checked); if (e.target.checked) setOutsideUk(false); }} />
                        <span className={`svc-margin__vat-box ${vatReg && !outsideUk ? 'is-on' : ''}`} aria-hidden="true">{vatReg && !outsideUk && (<svg viewBox="0 0 16 16" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M3.5 8.5l3 3 6-7" strokeLinecap="round" strokeLinejoin="round"/></svg>)}</span>
                        <span className="svc-margin__vat-lbl">I am VAT-registered</span>
                      </label>
                      {/* 2026-06-12 (Loom 45 1:22): same tooltips as the margin
                          calculator's checkboxes, reused verbatim. */}
                      <HoverPortalTip as="span" wrapClassName="svc-margin__section-info-wrap" tipClassName="dis-tip dis-tip--below" placement="below" tip={<><span className="dis-tip__body">This applies to UK businesses. If you are VAT-registered, you can reclaim the VAT we charge, so it stays cost neutral for you. If you are not registered yet, we include the 20% VAT here so your margin reflects what you really pay. You can switch this for every service at once.</span></>}>
                        <window.InfoIcon className="svc-margin__cell-lbl-info" />
                      </HoverPortalTip>
                      <label className="svc-margin__vat-toggle svc-margin__vat-toggle--intl">
                        <input type="checkbox" checked={outsideUk} onChange={(e) => { setOutsideUk(e.target.checked); if (e.target.checked) setVat(false); }} />
                        <span className={`svc-margin__vat-box ${outsideUk ? 'is-on' : ''}`} aria-hidden="true">{outsideUk && (<svg viewBox="0 0 16 16" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M3.5 8.5l3 3 6-7" strokeLinecap="round" strokeLinejoin="round"/></svg>)}</span>
                        <span className="svc-margin__vat-lbl">I am located outside the UK</span>
                      </label>
                      <HoverPortalTip as="span" wrapClassName="svc-margin__section-info-wrap" tipClassName="dis-tip dis-tip--below" placement="below" tip={<><span className="dis-tip__body">We only charge VAT to UK businesses, so if your agency is based outside the UK, we remove it entirely and your wholesale cost is shown net. We work with reseller agencies across over 20 countries, in multiple languages, and in your time zone. You can switch this for every service at once.</span></>}>
                        <window.InfoIcon className="svc-margin__cell-lbl-info" />
                      </HoverPortalTip>
                    </div>
                  </div>
                )}

                <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 ? '+ ' : ''}{_bdLabel(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' :
                                  (<>{l.fromPriced && l.value > 0 && 'From '}{typeof l.rrp === 'number' && l.rrp > l.value && (<span className="bdwn-modal__line-was">{window.fmt(l.rrp)}</span>)}{window.fmt(l.value)}</>)
                                }</span>
                              </li>
                            ))}
                          </ul>
                          <div className="bdwn-modal__subtotal">
                            <span>Subtotal</span>
                            <span>{(() => { const _rrpSub = monthlyLines.reduce((s, l) => s + (typeof l.rrp === 'number' ? l.rrp : l.value), 0); return _rrpSub > monthlySubtotal ? (<><span className="bdwn-modal__line-was">{window.fmt(_rrpSub)}</span>{window.fmt(monthlySubtotal)}</>) : window.fmt(monthlySubtotal); })()}</span>
                          </div>
                          {/* 2026-06-02: discounts already baked into the line
                              prices above (commitment length, BYOL list quality,
                              agency / white-label rate). Pay-upfront / bundle /
                              promo are itemised as £ deductions under Recurring
                              monthly; this surfaces the % deals reflected in each
                              line price so the breakdown is complete. Mirrors the
                              sidebar "Applied savings". */}
                          {(() => {
                            const baked = [];
                            const SVC = window.SERVICES || [];
                            const _commitInfo = [];
                            for (const [sid, sel] of Object.entries(selections || {})) {
                              if (!sel) continue;
                              const svc = SVC.find(s => s.id === sid);
                              if (!svc) continue;
                              const opts = window.commitsFor ? window.commitsFor(svc) : null;
                              if (!opts || !opts.length) continue;
                              const cid = (sel.commitId != null && opts.some(o => o.id === String(sel.commitId))) ? String(sel.commitId) : '12';
                              const opt = opts.find(o => o.id === cid);
                              if (!opt) continue;
                              _commitInfo.push({ sid, name: svc.name || sid, save: opt.save || 0, opts, cid });
                            }
                            // 2026-06-10 (Loom 32 5:04): commitment rows are interactive.
                            // The months render as a pill with a dropdown so the term can
                            // be switched right here; rows now show even at 0% save so
                            // every multi-commit service is switchable from the breakdown.
                            _commitInfo.forEach((c, i) => {
                              baked.push({
                                key: 'commit-' + i,
                                label: (<>
                                  <span>Commitment · {c.name}</span>
                                  <BdCommitPill opts={c.opts} cid={c.cid} onPick={(newCid) => dispatch({ type: 'SET_SERVICE_COMMIT', id: c.sid, commitId: newCid })} />
                                </>),
                                icon: 'clock',
                                pct: c.save,
                                pctLabel: c.save > 0 ? undefined : '',
                              });
                            });
                            const QPCT = { 'fully-enriched': 15, 'companies-contacts': 10, 'company-only': 5 };
                            const QLBL = { 'fully-enriched': 'Companies + emails + phone numbers', 'companies-contacts': 'Companies + emails + LinkedIn URLs', 'company-only': 'Companies + website' };
                            // 2026-06-03 (CALC-03): BYOL now rendered as a £ deduction line below (was a baked %).
                            if (typeof agyMult === 'number' && agyMult < 1) {
                              baked.push({ key: 'agency', icon: 'building', label: (state.intentId === 'agency-whitelabel' ? 'White-label' : ((window.isFreelancerMode && window.isFreelancerMode()) ? 'Partner' : 'Agency partner')) + ' rate on all tiers', pct: Math.round((1 - agyMult) * 100) });
                            }
                            if (!baked.length && !(promoDiscount > 0 && promoApplied) && !(payUpfrontSavings > 0) && !(bundleDiscount > 0) && !(byolSavingsMonthly > 0) && !(capAdjustment > 0) && !(referralDiscountMonthly > 0)) return null;
                            return (
                              <div className="bdwn-modal__section bdwn-modal__section--baked">
                                <div className="bdwn-modal__section-title">Discounts</div>
                                {payUpfrontSavings > 0 && (
                                  <div className="bdwn-modal__discount">
                                    <span className="bdwn-modal__baked-label"><SavingIcon name="banknote" /><span>Pay upfront (&minus;10% per opted-in service)</span></span>
                                    <span>&minus;{window.fmt(payUpfrontSavings)}</span>
                                  </div>
                                )}
                                {bundleDiscount > 0 && (
                                  <div className="bdwn-modal__discount">
                                    <span className="bdwn-modal__baked-label"><SavingIcon name="layers" /><span>Multi-service bundle ({monthlyServiceCount} services &middot; &minus;{Math.round(_msPctFinal * 100)}%)</span></span>
                                    <span>&minus;{window.fmt(bundleDiscount)}</span>
                                  </div>
                                )}
                                {Object.entries(selections || {}).map(([sid, sel]) => {
                                  if (!sel || sel.leadSourceMode !== 'byol') return null;
                                  const _bp = 15;
                                  const _ln = monthlyLines.find(l => !l.addon && l.id === l.sid && l.sid === sid);
                                  if (!_ln) return null;
                                  const _amt = _ln.value * (sel.payUpfront ? 0.90 : 1) * (_bp / 100);
                                  const _svc = SVC.find(s => s.id === sid);
                                  return (
                                    <div key={'byol-' + sid} className="bdwn-modal__discount">
                                      <span className="bdwn-modal__baked-label"><SavingIcon name="clipboard" /><span>Bring Your Own List on {(_svc && _svc.name) || sid} (&minus;{_bp}%)</span></span>
                                      <span>&minus;{window.fmt(_amt)}</span>
                                    </div>
                                  );
                                })}
                                {promoDiscount > 0 && promoApplied && (
                                  <div className="bdwn-modal__discount">
                                    <span className="bdwn-modal__baked-label">
                                      <SavingIcon name="tag" />
                                      <span>{promoApplied.label || 'Promo'} <code className="bdwn-modal__code">{promoApplied.code}</code> ({promoApplied.topUpAgencyTo ? (promoApplied.topUpAgencyTo + '% partner rate') : ('\u2212' + promoApplied.pct + '%')})</span>
                                    </span>
                                    <span>&minus;{window.fmt(promoDiscount)}</span>
                                  </div>
                                )}
                                {capAdjustment > 0 && (
                                  <div className="bdwn-modal__discount">
                                    <span className="bdwn-modal__baked-label"><SavingIcon name="building" /><span>Combined discount cap (max {Math.round((1 - _capFrac) * 100)}% off)</span></span>
                                    <span>+{window.fmt(capAdjustment)}</span>
                                  </div>
                                )}
                                {referralDiscountMonthly > 0 && (
                                  <div className="bdwn-modal__discount">
                                    <span className="bdwn-modal__baked-label"><SavingIcon name="tag" /><span>Referral discount (&minus;{referralPct}%)</span></span>
                                    <span>&minus;{window.fmt(referralDiscountMonthly)}</span>
                                  </div>
                                )}
                                {baked.map(b => (
                                  <div key={b.key} className="bdwn-modal__discount bdwn-modal__discount--baked">
                                    {b.tip ? (
                                      <HoverPortalTip as="span" placement="above" tipClassName="applied-tip" wrapClassName="bdwn-modal__baked-label bdwn-modal__baked-label--tip" tip={b.tip}>
                                        <SavingIcon name={b.icon} />
                                        <span>{b.label}</span>
                                        <span className="bdwn-modal__baked-info" aria-hidden="true">ⓘ</span>
                                      </HoverPortalTip>
                                    ) : (
                                      <span className="bdwn-modal__baked-label">
                                        <SavingIcon name={b.icon} />
                                        <span>{b.label}</span>
                                      </span>
                                    )}
                                    <span>{b.pctLabel || ('−' + b.pct + '%')}</span>
                                  </div>
                                ))}
                              </div>
                            );
                          })()}
                          <div className="bdwn-modal__total">
                            <span>Monthly total {!_noUkVat && <span className="bdwn-modal__vat">(ex. VAT)</span>}</span>
                            <span>{window.fmt(total)}</span>
                          </div>
                          {!_noUkVat && <div className="bdwn-modal__vat-row"><span>VAT<span className="bdwn-badge">20%</span></span><span>{window.fmt(total * 0.2)}</span></div>}
                          {!_noUkVat && <div className="bdwn-modal__vat-row bdwn-modal__vat-row--incl"><span>Monthly total inc. VAT</span><span>{window.fmt(total * 1.2)}</span></div>}
                          <div className="bdwn-modal__total-note">This is an estimate. We confirm your final total on your call and in your written proposal.</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">{_bdLabel(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' :
                                  (l.fromPriced && l.value > 0 ? `From ${window.fmt(l.value)}` : 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>
                          {!_noUkVat && <div className="bdwn-modal__vat-row"><span>VAT<span className="bdwn-badge">20%</span></span><span>{window.fmt(oneTimeAfterBundle * 0.2)}</span></div>}
                          {!_noUkVat && <div className="bdwn-modal__vat-row bdwn-modal__vat-row--incl"><span>Setup total inc. VAT</span><span>{window.fmt(oneTimeAfterBundle * 1.2)}</span></div>}
                        </div>
                      )}

                      {hasWaitlist && (
                        <div className="bdwn-modal__section bdwn-modal__section--waitlist">
                          <div className="bdwn-modal__section-title">Waiting list</div>
                          <ul className="bdwn-modal__list">
                            {waitlistLines.map(l => (
                              <li key={'wl-' + 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 ? '+ ' : ''}{_bdLabel(l.label)}</span>
                                </span>
                                <span className="bdwn-modal__line-val bdwn-modal__line-val--waitlist">Reserved</span>
                              </li>
                            ))}
                          </ul>
                          <div className="bdwn-modal__waitlist-note">These are at capacity, so your spots are reserved and no charge applies. We will notify you by email when capacity opens. They are not part of your plan totals.</div>
                        </div>
                      )}

                      {/* 2026-06-02: white-label margin. Per chargeable line,
                          show what the agency pays us (wholesale) vs what they
                          charge their client (the retail they set in the margin
                          calculator, else our RRP) and the resulting margin.
                          Footer gives the headline monthly + setup net profit. */}
                      {_isWlAgency && (Math.round(agencyMonthlyMargin) !== 0 || Math.round(agencySetupMargin) !== 0) && (
                        <div className="bdwn-modal__section bdwn-modal__section--margin">
                          <div className="bdwn-modal__section-title">Your margin (white-label)</div>
                          <ul className="bdwn-modal__list">
                            {monthlyLines.filter(l => !l.custom && (_lineCharge(l) - l.value) !== 0).map(l => {
                              const pay = l.value, charge = _lineCharge(l), margin = charge - pay;
                              return (
                                <li key={'mgn-' + 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 ? '+ ' : ''}{_bdLabel(l.label)}</span>
                                    <span className="bdwn-modal__line-breakdown">You pay {window.fmt(pay)} &middot; you charge {window.fmt(charge)}</span>
                                  </span>
                                  <span className="bdwn-modal__line-val bdwn-modal__line-val--profit">+{window.fmt(margin)}</span>
                                </li>
                              );
                            })}
                          </ul>
                          <div className="bdwn-modal__profit">
                            <span>Your monthly profit</span>
                            <span>{window.fmt(Math.round(agencyMonthlyMargin))}</span>
                          </div>
                          {Math.round(agencySetupMargin) > 0 && (
                            <div className="bdwn-modal__profit bdwn-modal__profit--setup">
                              <span>Setup profit (one-off)</span>
                              <span>{window.fmt(Math.round(agencySetupMargin))}</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 thin-glass-frame">
                    <div className="bdwn-modal__summary-row">
                      <span>Setup &amp; kick-off<span className="bdwn-badge">one-off</span></span>
                      <span>{window.fmt(oneTimeAfterBundle)}</span>
                    </div>
                    <div className="bdwn-modal__summary-row">
                      <span>Monthly retainer</span>
                      <span>{window.fmt(total)}</span>
                    </div>
                    {_isWlAgency && Math.round(agencyMonthlyMargin) !== 0 && (
                      <div className="bdwn-modal__summary-row bdwn-modal__summary-row--profit">
                        <span>Your monthly profit</span>
                        <span>{window.fmt(Math.round(agencyMonthlyMargin))}</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>

                {/* 2026-06-12 (review): the VAT sentence retired in every variant, the
                    VAT checkboxes carry tooltips that explain it. */}

                <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). 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, extra) {
  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,
      // 2026-07-02 (R-06): referral + referring-mode markers so Airtable/AE can
      // explain a referred total and tell referring-mode from purchase intent.
      referralStatus: live.referralStatus || null,
      referralPct:    live.referralPct || 0,
      referralDiscountMonthly: live.referralDiscountMonthly || 0,
      monthlyTotalBeforeReferral: live.monthlyTotalBeforeReferral || (live.monthlyTotal || 0),
      referMode:     !!(state && state.referMode),
      monthlyTotal:  live.monthlyTotal || 0,
      oneTimeTotal:  live.oneTimeTotal || 0,
      netProfit:     live.netProfit || 0,
      retailByService: live.retailByService || '',
      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 || '',
      // 2026-06-02: forward per-service config so Airtable per-service columns
      // populate on this CTA too (not only the final CheckoutForm submit).
      selections:    state?.selections || {},
      // 2026-06-06 (QA audit): ServicesInquiryModal answers were stored in
      // state but never forwarded, so 'Services Inquiry Categories/Notes'
      // always landed blank in Airtable. Forward them on every CTA.
      servicesInquiry: state?.servicesInquiry || {},
      ...(extra && typeof extra === 'object' && !Array.isArray(extra) ? extra : {}),
    };
    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.
    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 { window.__leadEmail = trimmed; } catch (e) {}
    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,
          netProfit:     live.netProfit || 0,
          retailByService: live.retailByService || '',
          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 || '',
          selections:    state?.selections || {},
          // 2026-06-06 (QA audit): ServicesInquiryModal answers were stored in
          // state but never forwarded, so 'Services Inquiry Categories/Notes'
          // always landed blank in Airtable. Forward them on every CTA.
          servicesInquiry: state?.servicesInquiry || {},
        }),
        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,
        netProfit:     live.netProfit || 0,
        retailByService: live.retailByService || '',
        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 || '',
        selections:    state?.selections || {},
        // 2026-06-06 (QA audit): ServicesInquiryModal answers were stored in
        // state but never forwarded, so 'Services Inquiry Categories/Notes'
        // always landed blank in Airtable. Forward them on every CTA.
        servicesInquiry: state?.servicesInquiry || {},
      };
      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);
      try { window.__leadEmail = trimmed; } catch (e) {}
      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>
  );
}

// ── 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">{(typeof window !== 'undefined' && window.isFreelancerMode && window.isFreelancerMode() && window.flAgencyStr) ? window.flAgencyStr(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;

// 2026-06-16 (Nicole): build the post-checkout onboarding Typeform link with the
// client's details prefilled via Typeform hidden fields (firstname/lastname/email/
// company), so they don't re-enter what we already captured at checkout. White-label
// agencies (resellers) onboard via the partner form instead. See the change log.
function buildOnboardingUrl(state, form) {
  var f = form || {};
  if (state && state.intentId === 'agency-whitelabel') {
    return 'https://form.typeform.com/to/z2KHsHZS';
  }
  var qp = [];
  var add = function(k, v) { v = (v || '').trim(); if (v) qp.push(k + '=' + encodeURIComponent(v)); };
  add('firstname', f.firstName); add('lastname', f.lastName);
  add('email', f.email); add('company', f.company);
  return 'https://form.typeform.com/to/qrtt48bC' + (qp.length ? '?' + qp.join('&') : '');
}
window.buildOnboardingUrl = buildOnboardingUrl;

// ── Referral modal (portal ?ref) ──────────────────────────────────────
// Shown on landing when ?ref is a valid referral-code format. Captures the
// email, validates against the portal (window.ggResolveReferral), applies the
// referral, fires a toast, and records it in state so the sidebar reflects it.
// Soft nudge on dismiss; the checkout step stays as a backstop.
window.ReferralModal = function ReferralModal({ state, dispatch }) {
  const { useState, useEffect } = React;
  const _code = (() => { try { return (new URLSearchParams(window.location.search).get('ref') || '').trim(); } catch (_) { return ''; } })();
  const _eligible = !!_code
    // 2026-07-03: never pop the referee discount modal on the freelancer/partner
    // path, where ?ref= is a marketing tracking parameter, not a GoGorilla code.
    && !(typeof window !== 'undefined' && window.isFreelancerMode && window.isFreelancerMode())
    && !/^(naz_call_|client_|agency_|investor_)/i.test(_code)
    // 2026-07-01: accept both the legacy hyphen format (e.g. NICO-H5T5) and the
    // new clean partner-code format (e.g. JOHNNY10, NICO10). The trailing hyphen
    // group is optional, and a second hyphen (the AGENCY-RUNWAY-55 wholesale
    // codes typed in the promo box) is excluded so those never pop this modal.
    && /^[A-Za-z0-9]{2,12}(-[A-Za-z0-9]{2,6})?$/.test(_code);
  const [open, setOpen]   = useState(false);
  const [email, setEmail] = useState('');
  const [phase, setPhase] = useState('ask'); // ask | checking | invalid | self | bademail
  const [nudge, setNudge] = useState(false);
  const [laterHover, setLaterHover] = useState(false);
  const _EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

  useEffect(() => {
    if (!_eligible) return;
    let seen = false;
    try { seen = sessionStorage.getItem('gg_ref_modal_seen') === '1'; } catch (_) {}
    const applied = !!(state.referral && state.referral.status === 'applied');
    if (!seen && !applied) setOpen(true);
  }, []);

  if (!_eligible || !open) return null;

  const _markSeen = () => { try { sessionStorage.setItem('gg_ref_modal_seen', '1'); } catch (_) {} };
  const _close = () => { _markSeen(); setOpen(false); };
  const _attemptClose = () => { if (!nudge) setNudge(true); else _close(); };

  const _apply = async () => {
    const em = (email || '').trim();
    if (!_EMAIL_RE.test(em)) { setPhase('bademail'); return; }
    setPhase('checking');
    let ui = { status: 'none' };
    try { ui = await window.ggResolveReferral(em); } catch (_) {}
    if (ui && ui.status === 'applied') {
      const _rb = window.__ggReferral || {};
      const _pct = (typeof _rb.pct === 'number') ? _rb.pct : 10;
      if (typeof dispatch === 'function') dispatch({ type: 'SET_REFERRAL', referral: { status: 'applied', code: _code, email: em, pct: _pct, label: _rb.label || null } });
      try { window.dispatchEvent(new CustomEvent('gg:referral-applied', { detail: { title: 'Referral applied', body: 'Your ' + _pct + '% discount will be applied at checkout.' } })); } catch (_) {}
      _markSeen();
      setOpen(false);
    } else if (ui && ui.status === 'self') {
      if (typeof dispatch === 'function') dispatch({ type: 'SET_REFERRAL', referral: { status: 'self', code: _code, email: em } });
      setPhase('self');
    } else {
      if (typeof dispatch === 'function') dispatch({ type: 'SET_REFERRAL', referral: { status: 'invalid', code: _code, email: em } });
      setPhase('invalid');
    }
  };

  const _sBackdrop = { position: 'fixed', inset: 0, background: 'rgba(7,17,43,0.55)', zIndex: 100000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '1.25rem' };
  const _sCard = { position: 'relative', background: '#fff', borderRadius: '16px', maxWidth: '430px', width: '100%', padding: '1.7rem 1.5rem 1.5rem', boxShadow: '0 24px 70px rgba(7,17,43,0.38)' };
  const _sInput = { width: '100%', boxSizing: 'border-box', padding: '0.7rem 0.85rem', borderRadius: '10px', border: '1.5px solid #D6DEEE', fontSize: '0.95rem', marginTop: '0.25rem' };

  return (
    <div style={_sBackdrop} role="dialog" aria-modal="true" aria-label="Apply your referral" onClick={_attemptClose}>
      <div style={_sCard} onClick={(e) => e.stopPropagation()}>
        <button type="button" className="cmp-modal__close" aria-label="Close" onClick={_attemptClose}>
          <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>
        <div style={{ fontSize: '0.72rem', fontWeight: 700, letterSpacing: '0.06em', textTransform: 'uppercase', color: '#002ABF' }}>You have been referred</div>
        <div style={{ fontSize: '1.3rem', fontWeight: 800, color: '#0F1C35', margin: '0.35rem 0 0.45rem' }}>Claim your referral discount</div>
        <p style={{ fontSize: '0.92rem', color: '#475A86', margin: '0 0 1rem', lineHeight: 1.45 }}>Enter your email to apply your referral. Your discount will be waiting for you at checkout.</p>
        <label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#0F1C35' }}>
          Email
          <input type="email" value={email} placeholder="you@company.com" autoFocus style={_sInput}
            onChange={(e) => { setEmail(e.target.value); if (phase !== 'ask' && phase !== 'checking') setPhase('ask'); }}
            onKeyDown={(e) => { if (e.key === 'Enter') _apply(); }} />
        </label>
        {phase === 'bademail' && <div style={{ color: '#B42318', fontSize: '0.82rem', marginTop: '0.45rem' }}>Please enter a valid email address.</div>}
        {phase === 'invalid' && <div style={{ color: '#475A86', fontSize: '0.82rem', marginTop: '0.45rem' }}>We could not apply that referral code, but you can carry on. You can also add it at the last step.</div>}
        {phase === 'self' && <div style={{ color: '#475A86', fontSize: '0.82rem', marginTop: '0.45rem' }}>You cannot use your own referral link, but you are all set to continue.</div>}
        <button type="button" className="btn btn--primary btn--lg" onClick={_apply} disabled={phase === 'checking'} style={{ width: '100%', justifyContent: 'center', marginTop: '1rem' }}>
          {phase === 'checking' ? 'Checking your code' : 'Apply my referral'} <span className="btn__arrow">›</span>
        </button>
        {nudge && <div style={{ fontSize: '0.82rem', color: '#475A86', marginTop: '0.75rem', background: '#EEF1F8', borderRadius: '8px', padding: '0.55rem 0.7rem' }}>Your referral will only be applied if you enter your email. You can also add it at the last step.</div>}
        <button type="button" onClick={_close} onMouseEnter={() => setLaterHover(true)} onMouseLeave={() => setLaterHover(false)} style={{ display: 'block', margin: '0.8rem auto 0', border: 'none', background: 'transparent', color: laterHover ? '#002ABF' : '#6B7894', fontSize: '0.85rem', textDecoration: 'underline', cursor: 'pointer', transition: 'transform 0.15s ease, color 0.15s ease', transform: laterHover ? 'translateY(-2px)' : 'translateY(0)' }}>I will add it later</button>
      </div>
    </div>
  );
};

function CheckoutForm({ state, dispatch, commitText }) {
  const [form, setForm] = React.useState({
    firstName: postCallName() || flPrefillName() || '', lastName: '', company: '', website: '', email: postCallEmail() || flPrefillEmail() || (state.referral && state.referral.email) || '', phone: postCallPhone() || flPrefillPhone() || '', phoneCountry: 'GB', budget: '', clientsManaged: '', portcoName: '',
  });
  const [errors, setErrors] = React.useState({});
  const [captchaToken, setCaptchaToken] = React.useState('');
  const [status, setStatus] = React.useState('idle'); // idle | sending | sent
  // 2026-07-01: the in-calc Schedule embed uses a dedicated Cal event type
  // (book-a-call-calc) with no booking redirect, so it stays on the page. When
  // Cal fires bookingSuccessful we flip this and collapse the embed into an
  // inline confirmation, rather than navigating to a standalone booked page.
  const [calBooked, setCalBooked] = React.useState(false);
  // Referral (portal ?ref): validated on email blur/submit. referralUi drives
  // the inline note under Email; the validated code + Stripe promo id are
  // forwarded to the portal by platform-api.js via window.__ggReferral.
  const [referralUi, setReferralUi] = React.useState(() => (state.referral && state.referral.status) ? { status: state.referral.status } : { status: 'none' }); // none|checking|applied|invalid|self
  const _refParam = (() => { try { return (new URLSearchParams(window.location.search).get('ref') || '').trim(); } catch (_) { return ''; } })();
  const _refEligible = !!_refParam && !/^(naz_call_|client_|agency_|investor_)/i.test(_refParam) && /^[A-Za-z0-9]{1,8}-[A-Za-z0-9]{2,6}$/.test(_refParam);
  async function _checkReferral() {
    const _em = (form.email || '').trim();
    // 2026-07-01: a typed referral partner code (from the promo box) carries the
    // partner attribution but no Stripe promo id (the Airtable row holds none).
    // Now that we have the referee's email, resolve the real Stripe promotion
    // code from the portal so the 10% actually applies at Stripe, exactly as the
    // ?ref= path does. If the portal says invalid or returns no promo id, we fall
    // back to no Stripe discount rather than blocking checkout, keeping the
    // attribution intact.
    try {
      const _rb = window.__ggReferral;
      if (_rb && _rb.source === 'typed-code' && _rb.code && !_rb.stripePromotionCodeId && EMAIL_RE.test(_em) && typeof window.validateReferral === 'function') {
        const _d = await window.validateReferral(_rb.code, _em);
        if (_d && _d.valid && _d.stripePromotionCodeId) {
          window.__ggReferral.stripePromotionCodeId = String(_d.stripePromotionCodeId);
          if (_d.discountPct != null && !isNaN(Number(_d.discountPct))) window.__ggReferral.pct = Number(_d.discountPct);
        }
      }
    } catch (_) { /* keep attribution, no Stripe discount */ }
    if (!_refEligible || typeof window.ggResolveReferral !== 'function') return;
    if (!EMAIL_RE.test(_em)) return;
    setReferralUi({ status: 'checking' });
    try {
      const ui = await window.ggResolveReferral(_em);
      if (ui && (ui.status === 'applied' || ui.status === 'invalid' || ui.status === 'self')) {
        setReferralUi(ui);
        if (typeof dispatch === 'function') dispatch({ type: 'SET_REFERRAL', referral: { status: ui.status, code: _refParam, email: _em, pct: (window.__ggReferral && typeof window.__ggReferral.pct === 'number') ? window.__ggReferral.pct : 10, label: (window.__ggReferral && window.__ggReferral.label) || null } });
      } else setReferralUi({ status: 'none' });
    } catch (_) { setReferralUi({ status: 'none' }); }
  }
  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';
  // 2026-06-18 (Nicole): classify the live quote so the final step adapts to it.
  // window.__currentQuote is rebuilt from state on every render, so this stays
  // in sync as selections change. A custom/bespoke line means the price is not
  // final (no up-front payment); a quote that is ONLY waitlisted items has
  // nothing to pay or quote yet.
  const _liveQuote = (typeof window !== 'undefined' && window.__currentQuote) || {};
  const _qLines = Array.isArray(_liveQuote.lines) ? _liveQuote.lines : [];
  const _quoteHasCustom = _qLines.some(l => l && l.custom && !l.includedAuto);
  const _quoteHasWaitlist = _qLines.some(l => l && l.waitlist);
  const _quotePayTotal = (Number(_liveQuote.monthlyTotal) || 0) + (Number(_liveQuote.oneTimeTotal) || 0);
  const _quoteHasPayable = _quotePayTotal > 0;
  const _quoteWaitlistOnly = _quoteHasWaitlist && !_quoteHasPayable && !_quoteHasCustom;
  // Custom/bespoke present (and there is something to engage on, i.e. not a
  // pure-waitlist quote): pricing is finalised on a call, so the payment-link
  // option is disabled and we nudge the visitor to Schedule a call.
  const _forceScheduleCustom = _quoteHasCustom && !_quoteWaitlistOnly;
  // Did the visitor toggle "pay upfront" (10% off) on any service? When the last
  // step blocks immediate payment (custom pricing, or a waitlist), reassure them
  // the upfront saving still applies once we confirm the price.
  const _anyPayUpfront = !!(state.selections && Object.values(state.selections).some(s => s && s.payUpfront === true));
  React.useEffect(() => {
    if (_isPartnerProPlus && (state.callPreference || 'schedule') !== 'schedule' && dispatch) {
      dispatch({ type: 'SET_CALL_PREFERENCE', value: 'schedule' });
    }
  }, [_isPartnerProPlus]);
  // A custom-priced quote can't be paid up-front; if the visitor had chosen
  // "Request a payment link" and then added a custom item, fall back to
  // scheduling a call.
  React.useEffect(() => {
    if (_forceScheduleCustom && (state.callPreference || 'schedule') === 'contract-payment' && dispatch) {
      dispatch({ type: 'SET_CALL_PREFERENCE', value: 'schedule' });
    }
  }, [_forceScheduleCustom]);

  // 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') return;
    if (_quoteWaitlistOnly) return;
    if (typeof window.Cal !== 'function' || !window.Cal.ns || !window.Cal.ns['book-a-call']) return;
    // 2026-07-01 (Batch 6): the in-calculator inline embed shares the same Cal
    // event type as the website "Book a call" nav, whose booking redirect appends
    // ?booked=1 and drops the visitor into post-call mode. For an in-calc booking
    // that is a never-ending loop. We used to rely on Cal's bookingSuccessful
    // event, but with a redirect configured on the event type it does not fire
    // before the navigation, and it is namespace-global (it would also flag the
    // nav modal booking). Instead we stamp the flag when THIS inline embed mounts
    // (only the in-calc Schedule flow mounts #cal-inline-success), see below.
    // 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',
          // Freelancer visitors book on the dedicated freelancer calendar; every other ICP keeps book-a-call-calc.
          calLink: (window.isFreelancerMode && window.isFreelancerMode()) ? 'team/gogorilla/book-a-call-freelancer' : 'team/gogorilla/book-a-call-calc',
          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,
            metadata: (function () {
              var _fl = !!(window.isFreelancerMode && window.isFreelancerMode());
              var _lq = window.__currentQuote || {};
              var m = {
                gg_referring: (_fl && state.referMode) ? 'referring' : 'ownuse',
                gg_icp: _fl ? 'freelancer' : String(state.clientTypeId || ''),
                gg_quote_id: String(_lq.quote_uuid || state.quote_uuid || '')
              };
              var _mt = Number(_lq.monthlyTotal) || 0;
              if (_mt > 0) m.gg_monthly_total_pence = String(Math.round(_mt * 100));
              return m;
            })(),
          },
        });
        // 2026-07-01: listen for the Cal booking-success event on the in-calc
        // embed (the book-a-call-calc event type has no redirect, so it fires
        // here and the page stays put). Collapse the embed into the inline
        // confirmation below instead of the old redirect + standalone page.
        try {
          window.Cal.ns['book-a-call']('on', { action: 'bookingSuccessful', callback: function () { try { setCalBooked(true); } catch (e3) {} } });
        } catch (e3) {}
        // 2026-07-01 (Batch 6): stamp the in-calc booking flag the moment this
        // inline embed mounts. Only the in-calculator Schedule flow mounts
        // #cal-inline-success (the website-nav Book-a-call uses a Cal modal), so
        // when Cal redirects to ?booked=1 after the user books here,
        // inCalcBookingConfirm() shows the call-booked confirmation endpoint
        // instead of post-call mode. Cleared on Back, and self-expires in 30 min.
        try { window.localStorage.setItem('gg.inCalcBook', JSON.stringify({ t: Date.now() })); } catch (e2) {}
      } catch (e) { /* swallow, fall back to nothing */ }
    }, 40);
    return () => { clearTimeout(t); try { var _cc = document.getElementById('cal-inline-success'); if (_cc) _cc.innerHTML = ''; } catch (e) {} };
  }, [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 }));
    try { if (k === 'email' && typeof v === 'string' && v.indexOf('@') > 0) window.__leadEmail = v.trim(); } catch (e) {}
    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.';
    // 2026-05-31: Last name + Company are hidden on the "already had a call"
    // path (team already has them), so only validate them otherwise.
    if ((state?.callPreference || 'schedule') !== 'already-had') {
      // 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}).`;
      }
    }
    // 2026-06-24 (Nicole): attribution ("How did you hear about us?") is REQUIRED
    // on the Founders + Agencies paths. Block the CTA and nudge to the question if
    // it has not been answered. The other qualifier questions stay optional.
    if (state && !state.referMode && (state.clientTypeId === 'founder' || state.clientTypeId === 'agency')) {
      const _hv = state.qualifier && state.qualifier.heardVia;
      if (!(Array.isArray(_hv) && _hv.length > 0)) {
        e.heardVia = 'Before you continue, please let us know how you heard about us.';
        try {
          // Only nudge to the question if it is actually on screen. If the lead
          // has not worked down to it yet it is not rendered, and scrolling to a
          // random earlier panel is worse than staying put with the notice.
          const _t = document.querySelector('[data-q-id="heardVia"]');
          if (_t) _t.scrollIntoView({ behavior: 'smooth', block: 'center' });
        } catch (_) {}
      }
    }
    // 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');
    // 2026-07-01 (Batch 7): stamp the in-calc booking flag the moment the user
    // commits to the Schedule / email-first flow, synchronously and before Cal
    // mounts or redirects. The earlier mount-time stamp did not reliably survive
    // to the ?booked=1 redirect, so this is the bulletproof point. It means the
    // post-booking redirect always lands on the call-booked confirmation instead
    // of looping into post-call mode. Cleared on Back, and self-expires in 30 min.
    try {
      var _bkPref = (state && (state.callPreference || 'schedule')) || 'schedule';
      if (_bkPref === 'schedule') {
        window.localStorage.setItem('gg.inCalcBook', JSON.stringify({ t: Date.now() }));
      }
    } catch (e2) {}
    // Resolve the referral (if any) before building the platform payload so
    // window.__ggReferral is populated for ensurePlatformQuoteAsync below.
    try { await _checkReferral(); } catch (_) { /* best-effort */ }
    // 2026-07-01: on "Start referring now", create the partner's affiliate
    // account so the portal issues their referral link, code, and dashboard
    // access (it emails the secure sign-in itself), and we include the link and
    // code in our own email. Best-effort, a failure still sends the quote email.
    let _referralShareUrl = '';
    let _referralCode = '';
    const _referSubmit = !!(state && state.referMode && (state.callPreference === 'refer-start'));
    // Any freelancer who books a call (referring or own-use) has the portal booking webhook own the follow-up email
    // (referrer invite when gg_referring is true, quote-plus-dashboard when false), so suppress the calculator's own quote email.
    const _referBook = !!(state && window.isFreelancerMode && window.isFreelancerMode() && ((state.callPreference || 'schedule') === 'schedule') && !isPostCallMode());
    if (_referSubmit) {
      // 2026-07-03: route referring submissions to the portal /api/v1/quotes route, which
      // creates or reuses the affiliate, mints the FIRSTNAME10 code, and sends the referral
      // FORECAST email plus the dashboard magic link. The calculator suppresses its own
      // email in this mode (suppressEmail on the payload below). We await this and surface
      // any failure rather than falling back to the generic quote email.
      try {
        if (typeof window.buildPlatformQuotePayload !== 'function' || typeof window.createPlatformQuote !== 'function') {
          throw new Error('Referral service unavailable');
        }
        const _rp = window.buildPlatformQuotePayload(state, form.email.trim(), null, { referring: true });
        _rp.referring = true;
        if (_rp.snapshot && typeof _rp.snapshot === 'object') { _rp.snapshot.referring = true; }
        const _rr = await window.createPlatformQuote(_rp);
        if (_rr && typeof _rr === 'object') {
          _referralShareUrl = String(_rr.share_url || _rr.shareUrl || _rr.referral_link || _rr.referralShareUrl || '');
          _referralCode = String(_rr.code || _rr.referral_code || _rr.referralCode || '');
        }
      } catch (_err) {
        setError('We could not set up your referral just now. Please try again in a moment.');
        setStatus('error');
        return;
      }
    }
    // 2026-06-18 (Batch C): on the "Request a payment link" path with a fully-
    // priced quote, generate the non-expiring Stripe Payment Link first so it
    // can go in the email. Best-effort: any failure falls back to the normal
    // flow (no link; the quote is ensured below as usual). _pqEnsured stops the
    // post-send ensurePlatformQuoteAsync from creating a duplicate.
    let _paymentLinkUrl = '';
    let _pqEnsured = false;
    try {
      const _lq = window.__currentQuote || {};
      const _lns = Array.isArray(_lq.lines) ? _lq.lines : [];
      const _fullyPriced = ((Number(_lq.monthlyTotal) || 0) + (Number(_lq.oneTimeTotal) || 0)) > 0 && !_lns.some(l => l && ((l.custom && !l.includedAuto) || l.waitlist));
      // 2026-06-26: an agency does not need a contract, so a fully-upfront agency quote
      // auto-generates the upfront Stripe link (a single full-term charge with the 10%
      // upfront discount baked in). 'Fully upfront' means every monthly-priced service
      // has Pay upfront on. Mixed quotes (some upfront, some monthly) and founders or
      // investors stay on the manual handoff, because the upfront link charges the whole
      // quote as one payment, so it only makes sense when the whole quote is upfront.
      const _sels = (state && state.selections) || {};
      const _anyUpfront = Object.values(_sels).some((sx) => sx && sx.payUpfront === true);
      const _pricedSids = new Set(_lns.filter((l) => l && !l.oneTime && !l.free && !(l.custom && l.includedAuto) && (Number(l.value) || 0) > 0).map((l) => l.sid));
      const _fullyUpfront = _pricedSids.size > 0 && Array.from(_pricedSids).every((sid) => _sels[sid] && _sels[sid].payUpfront === true);
      const _agencyUpfront = (state && state.clientTypeId === 'agency') && _fullyUpfront;
      const _pref = (state?.callPreference || 'schedule');
      const _hasEnsure = typeof window.ensurePlatformQuoteAsync === 'function';
      const _hasCreate = typeof window.createPaymentLink === 'function';
      // 2026-06-26 (payment-link diagnostics): log which sub-condition gates the
      // Stripe Payment Link so a missing link in the email is traceable from the console.
      console.warn('[pay-link] gate', { callPreference: _pref, isContractPayment: _pref === 'contract-payment', fullyPriced: _fullyPriced, anyUpfront: _anyUpfront, fullyUpfront: _fullyUpfront, agencyUpfront: _agencyUpfront, hasEnsure: _hasEnsure, hasCreate: _hasCreate, monthlyTotal: Number(_lq.monthlyTotal) || 0, oneTimeTotal: Number(_lq.oneTimeTotal) || 0, customOrWaitlistLine: _lns.some(l => l && ((l.custom && !l.includedAuto) || l.waitlist)) });
      if (_pref === 'contract-payment' && _fullyPriced && (!_anyUpfront || _agencyUpfront) && _hasEnsure && _hasCreate) {
        const _contact = {
          firstName: form.firstName.trim(), lastName: form.lastName.trim(), company: form.company.trim(),
          website: form.website.trim(), phone: form.phone.trim(), phoneCountry: form.phoneCountry,
          callPreference: _pref,
        };
        // 2026-06-26: a single auto-retry on each call so a one-off transient
        // portal failure never sends the email with the wrong (call) CTA.
        let _pq = null;
        try { _pq = await window.ensurePlatformQuoteAsync('email_quote', state, form.email.trim(), _contact); }
        catch (_e1) { console.warn('[pay-link] ensurePlatformQuoteAsync threw:', (_e1 && _e1.message) || _e1); }
        if (!(_pq && _pq.id)) {
          console.warn('[pay-link] ensurePlatformQuoteAsync failed, retrying once');
          await new Promise((r) => setTimeout(r, 600));
          try { _pq = await window.ensurePlatformQuoteAsync('email_quote', state, form.email.trim(), _contact); }
          catch (_e1b) { console.warn('[pay-link] ensurePlatformQuoteAsync retry threw:', (_e1b && _e1b.message) || _e1b); }
        }
        console.warn('[pay-link] ensurePlatformQuoteAsync ->', _pq && _pq.id ? ('quoteId=' + _pq.id) : _pq);
        if (_pq && _pq.id) {
          _pqEnsured = true;
          // Agency fully-upfront -> one-off full-term charge (upfront: true); every
          // other allowed case stays on the standard monthly subscription link.
          const _mkLink = () => (_agencyUpfront ? window.createPaymentLink(_pq.id, undefined, { upfront: true }) : window.createPaymentLink(_pq.id));
          let _pl = null;
          try { _pl = await _mkLink(); }
          catch (_e2) { console.warn('[pay-link] createPaymentLink threw:', (_e2 && _e2.message) || _e2); }
          if (!(_pl && _pl.url)) {
            console.warn('[pay-link] createPaymentLink failed, retrying once');
            await new Promise((r) => setTimeout(r, 600));
            try { _pl = await _mkLink(); }
            catch (_e2b) { console.warn('[pay-link] createPaymentLink retry threw:', (_e2b && _e2b.message) || _e2b); }
          }
          console.warn('[pay-link] createPaymentLink ->', _pl);
          if (_pl && _pl.url) _paymentLinkUrl = String(_pl.url);
          else console.warn('[pay-link] createPaymentLink returned no url after retry');
        } else {
          console.warn('[pay-link] no platform quote id after retry; cannot create payment link');
        }
      } else {
        console.warn('[pay-link] SKIPPED (gate not met, see flags above)');
      }
    } catch (_e0) { console.warn('[pay-link] block error (falling back to no link):', (_e0 && _e0.message) || _e0); }
    try {
      const live = window.__currentQuote || {};
      await fetch('/api/send-quote', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          action:        'email_quote',
          suppressEmail: _referSubmit || _referBook,
          booked:        isPostCallMode(),
          // 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 || '',
          portcoName:    form.portcoName || '',
          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,
          netProfit:     live.netProfit || 0,
          retailByService: live.retailByService || '',
          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 || {},
          // 2026-06-06 (QA audit): ServicesInquiryModal answers were stored in
          // state but never forwarded, so 'Services Inquiry Categories/Notes'
          // always landed blank in Airtable. Forward them on every CTA.
          servicesInquiry: state?.servicesInquiry || {},
          // 2026-06-18 (Batch C): generated payment link (empty unless on the
          // payment-link path with a fully-priced quote); send-quote emails it.
          payment_link_url: _paymentLinkUrl,
          referralShareUrl: _referralShareUrl,
          referralCode: _referralCode,
        }),
      });
    } 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 (!_referSubmit && !_pqEnsured && 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 _ONB_CTA = (
      <div className="checkout-form__portal-note" style={{ marginTop: '0.85rem', display: 'flex', flexDirection: 'column', gap: '0.4rem', alignItems: 'center' }}>
        <p style={{ margin: 0, fontSize: '0.82rem', color: '#3a4a63', textAlign: 'center', lineHeight: 1.5, maxWidth: '34rem' }}>
          Once your payment is complete, we'll email <strong>{form.email}</strong> a secure link to set up and access your GoGorilla portal.
        </p>
      </div>
    );
    // #72 Post-call mode: the user already booked via Cal.com, so the success
    // state must NOT re-prompt a booking or re-embed the Cal widget. Confirm
    // the selections are in and that we will prep for the existing call.
    if (isPostCallMode()) {
      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 || postCallName()}.</div>
              <div className="checkout-form__success-body">Your selections are on their way to our team, and a copy of your quote is going to <strong>{form.email}</strong>. We will go through everything before your call so we can make the most of your time.</div>
            </div>
          </div>
          {_ONB_CTA}
        </div>
      );
    }
    // 2026-07-01: booking complete (Cal bookingSuccessful fired). Collapse the
    // embed and show the confirmation inline, on the same last step, keeping the
    // quote sidebar in view. No standalone page, no redirect.
    if (calBooked) {
      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">Your call is booked, {form.firstName}.</div>
              <div className="checkout-form__success-body">We have emailed the details to <strong>{form.email}</strong>, including the calendar invite and the joining link. Please check your inbox, and your spam folder just in case. There is nothing more you need to do now, so we look forward to speaking with you.</div>
            </div>
          </div>
          {!_quoteWaitlistOnly && !state.referMode && _ONB_CTA}
        </div>
      );
    }
    const _pref = state.callPreference || 'schedule';
    const _isAgency = state.clientTypeId === 'agency';
    const _showCal = _pref === 'schedule' && !_quoteWaitlistOnly;
    const _email = <strong>{form.email}</strong>;
    let _body;
    if (_quoteWaitlistOnly) {
      _body = <>You are on the waiting list. We have reserved your spot and will email {_email} the moment a place opens. There is nothing to pay in the meantime.</>;
    } else if (_pref === 'email-first') {
      _body = <>We will email {_email} the details of your quote and follow up from there. There is nothing else you need to do now, and you can book a call any time 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. We already have the context from our call, so there is nothing further you need to do.</>;
    } 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 if (_pref === 'refer-start') {
      _body = <>Your referral link and code are on their way to {_email}. Check your inbox to access your dashboard and start referring straight away.</>;
    } else if (state.referMode) {
      _body = <>Pick a slot below to talk through how referring works with our team. We email your referral link, code, and dashboard access once your call is booked.</>;
    } 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>}
        {!_quoteWaitlistOnly && !state.referMode && _ONB_CTA}
        {_showCal && (
          <div style={{ marginTop: '1rem', display: 'flex', justifyContent: 'flex-start' }}>
            <button type="button" className="btn btn--ghost btn--lg" onClick={() => { try { var _ce = document.getElementById('cal-inline-success'); if (_ce) _ce.innerHTML = ''; } catch (e) {} try { window.localStorage.removeItem('gg.inCalcBook'); } catch (e) {} setStatus('idle'); }} aria-label="Go back to your details and selections">‹ Back</button>
          </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 (_quoteWaitlistOnly) return 'Reserve your spot.';
            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 over.';
            if (p === 'contract-payment') return 'We will send your payment link shortly.';
            if (p === 'refer-start') return 'We will email your referral link and code shortly.';
            return 'Choose how you would like to proceed.';
          })()}</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 === 'already-had' ? 'schedule' : (state.callPreference || 'schedule'));
        // 2026-06-08 (Loom 30): investor intents other than "grow my portfolio
        // company" have no priced, payable plan yet (a free assessment, a general
        // enquiry, or the waitlisted Investor Portal), so the "Request contract
        // and payment link" route does not apply, only scheduling a call.
        const _investorNoPay = state.clientTypeId === 'investor' && state.intentId !== 'investor-portfolio';
        // 2026-06-08 (Loom 30): the schedule option copy reflects the investor
        // intent, since "considering" books a free assessment (not a proposal),
        // "general enquiry" is a no-commitment chat, and partnership is the
        // waitlisted Investor Portal.
        const _invIntent = state.clientTypeId === 'investor' ? state.intentId : null;
        const _scheduleDesc =
            _invIntent === 'investor-considering' ? 'Recommended. We email a summary of what you have scoped, then you choose a time for your free assessment call.'
          : _invIntent === 'investor-general'      ? 'Recommended. We email a summary of what you picked, then you choose a time to talk it through.'
          : _invIntent === 'investor-partnership'  ? 'Recommended. We email the details, then you choose a time to talk through the partnership.'
          : 'Recommended. We email your proposal, then you choose a time to review it with our team.';
        // 2026-07-01 (Batch 4): in referring mode there is nothing to pay, so the
        // "Request a payment link" route is removed. The visitor either schedules
        // a call or starts referring right away, in which case we email their
        // referral link and dashboard access. The magic link is auto-emailed and
        // we cannot redirect into the portal, so the confirmation tells them to
        // check their email.
        const PREFS = state.referMode ? [
          { id: 'schedule',    title: 'Schedule a call to talk it through', desc: 'Recommended. Book a quick call to talk it through, and we email your referral link, code, and dashboard access once it is booked.' },
          { id: 'refer-start', title: 'Start referring now',                desc: 'We email your referral link, code, and dashboard access straight away, so you can begin right now.' },
        ] : [
          { id: 'schedule',         title: 'Schedule a call to review it',  desc: _scheduleDesc },
          ...((_forceScheduleCustom && !_isPartnerProPlus) ? [
          { id: 'email-first',      title: 'Email me the details',          desc: 'We email your proposal and hold your place, with no call needed now. You can still book a call later if you would like to talk it through.' },
          ] : []),
          ...(_investorNoPay ? [] : [
          { 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.' },
          ]),
        ];
        if (_quoteWaitlistOnly) {
          return (
            <div className="call-pref">
              <div className="call-pref__reserve" role="note">
                <span className="call-pref__reserve-dot" aria-hidden="true" />
                <div className="call-pref__reserve-text">
                  <div className="call-pref__reserve-title">Reserve your spot on the waiting list</div>
                  <div className="call-pref__reserve-body">The services you have chosen are at full capacity right now. Add your details below and we will hold your place, then email you the moment a spot opens. There is nothing to pay in the meantime.</div>
                  {_anyPayUpfront && <div className="call-pref__reserve-upfront">You chose to pay upfront for 10% off. That saving still applies, we will add it once we confirm your price.</div>}
                </div>
              </div>
            </div>
          );
        }
        return (
          <div className="call-pref" role="radiogroup" aria-label="How would you like to proceed?">
            <div className="call-pref__grid">
              {PREFS.map(p => {
                const on = callPref === p.id;
                const _disabled = (_isPartnerProPlus && p.id !== 'schedule') || (_forceScheduleCustom && p.id === 'contract-payment');
                return (
                  <button
                    key={p.id}
                    type="button"
                    role="radio"
                    aria-checked={on}
                    aria-disabled={_disabled}
                    disabled={_disabled}
                    title={_disabled ? (_isPartnerProPlus ? 'A confidential intake call is required for Partner Pro+' : 'Your quote includes custom-priced items, so we finalise pricing on a quick call first') : 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>
            {!state.referMode && (_forceScheduleCustom || _isPartnerProPlus || (_quoteHasWaitlist && !_quoteWaitlistOnly)) && (
              <p className="call-pref__custom-note" role="note">
                <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="6.5"/><path d="M8 7.3v3.4"/><path d="M8 5.1h.01"/></svg>
                <span>{(_forceScheduleCustom || _isPartnerProPlus)
                  ? <>{_isPartnerProPlus
                      ? <>Partner Pro+ includes a <strong>required confidential intake call</strong> so we can understand your acquisition thesis first.</>
                      : <>Your quote includes <strong>custom-priced items</strong>, so we finalise the exact price on a quick call before any payment.</>}{(_quoteHasWaitlist && !_quoteWaitlistOnly) ? <> Some items are also on the <strong>waiting list</strong>, which we will hold and email you about when a place opens.</> : null} Choose <strong>Schedule a call</strong> above{(_forceScheduleCustom && !_isPartnerProPlus) ? <>, or <strong>Email me the details</strong> if you would rather we followed up by email</> : <> and we'll take care of the rest</>}.{_anyPayUpfront ? <> You chose to pay upfront, so the 10% saving still applies once we confirm your final price.</> : null}</>
                  : <>Some items in your quote are on the <strong>waiting list</strong>. You can proceed with the rest now, and we will hold those and email you when a place opens.</>}</span>
              </p>
            )}
            <p style={{ fontSize: '0.78rem', color: 'var(--gg-muted, #5a647d)', lineHeight: 1.45, marginTop: '0.85rem' }}>
              {callPref === 'already-had'
                ? 'We already have your details from our call, so we just need to know where to send your proposal.'
                : '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">{(state.callPreference || 'schedule') === 'already-had' ? 'Name' : '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>

        {/* 2026-05-31: on the "already had a call" path the team already holds the
            contact, so we collect only First name + Email (enough to deliver the
            proposal and match the lead). All personas. */}
        {(state.callPreference || 'schedule') !== 'already-had' && (
        <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 ${(state.callPreference || 'schedule') === 'already-had' ? '' : '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)}
            onBlur={_checkReferral}
            autoComplete="email"
            required
          />
          {errors.email && <span className="checkout-form__err">{errors.email}</span>}
        </label>

        {_refEligible && (referralUi.status === 'applied' || referralUi.status === 'checking' || referralUi.status === 'invalid' || referralUi.status === 'self') && (
          <div className="checkout-form__field checkout-form__field--full">
            <span style={{
              display: 'inline-flex', alignItems: 'center', gap: '0.4rem',
              fontSize: '0.85rem', fontWeight: 600, lineHeight: 1.3,
              padding: '0.45rem 0.7rem', borderRadius: '8px',
              background: referralUi.status === 'applied' ? '#E7F6EE' : '#EEF1F8',
              color:      referralUi.status === 'applied' ? '#0B7A3B' : '#475A86',
              border:     '1px solid ' + (referralUi.status === 'applied' ? '#BFE6CD' : '#D6DEEE'),
            }}>
              {referralUi.status === 'applied'  && (<span>{'\u2713 Referral applied. Your discount will be waiting at checkout.'}</span>)}
              {referralUi.status === 'checking' && (<span>{'Checking your referral\u2026'}</span>)}
              {referralUi.status === 'invalid'  && (<span>{'We could not apply that referral code, but you can carry on.'}</span>)}
              {referralUi.status === 'self'     && (<span>{'You cannot use your own referral link, but you are all set to continue.'}</span>)}
            </span>
          </div>
        )}

        {(state.callPreference || 'schedule') !== 'already-had' && (<>
        <label className="checkout-form__field">
          <span className="checkout-form__label">{state.clientTypeId === 'agency' ? ((window.isFreelancerMode && window.isFreelancerMode()) ? 'Your name or business name' : 'Agency or business 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' ? ((window.isFreelancerMode && window.isFreelancerMode()) ? 'Your business website' : 'Agency or business 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>

        {/* 2026-06-02 #28: agencies are asked "Clients you manage" instead of budget,
            so the monthly-budget field is hidden for the agency client type. */}
        {state.clientTypeId !== 'agency' && (
        <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 === 'investor' && state.intentId === 'investor-portfolio' && (
          <label className="checkout-form__field">
            <span className="checkout-form__label">Which portfolio company is this for? <span className="checkout-form__optional">(optional)</span></span>
            <input
              type="text"
              className="checkout-form__input"
              placeholder="e.g. Acme Robotics"
              value={form.portcoName}
              onChange={(e) => setField('portcoName', e.target.value)}
            />
          </label>
        )}
        {/* 2026-06-08 (Loom 29): "Clients you manage" field removed from the final step per Alexander. */}

        <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>
        )}

        {errors.heardVia && (
          <div className="checkout-form__err checkout-form__err--block" role="alert" style={{ gridColumn: '1 / -1', width: '100%', boxSizing: 'border-box' }}>
            {errors.heardVia}
          </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…'
            : _quoteWaitlistOnly ? 'Reserve my spot' : isPostCallMode() ? 'Send my selections to the team' : state.referMode ? ((state.callPreference === 'refer-start') ? 'Email me my link and code' : 'Choose a time to talk') : (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} /> {_quoteWaitlistOnly ? 'No payment now · We email you the moment a place opens' : isPostCallMode() ? 'Your selections will be sent to our team so we can make the most of your call.' : state.referMode ? 'No payment now · We email your link and code' : (window.getStep6TrustLine ? window.getStep6TrustLine(state.callPreference || 'schedule', state.clientTypeId, state.intentId) : '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 || svc.fixedDuration) 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-off 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
            countFor={(s) => window.countStepSelections ? window.countStepSelections(state, s) : null}
            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}
            startFresh={
              <window.HoverPortalTip wrapClassName="step-utility-tipwrap" tipClassName="dis-tip dis-tip--below dis-tip--compact" placement="below" tip={"Start fresh, clear all selections and reload"}>
                <button type="button" className="step-utility-btn start-fresh-link" onClick={() => {
                  try { window.localStorage.removeItem('gg.pricing-cart.v1'); } catch (e) {}
                  try { Object.keys(window.localStorage).filter(k => k.indexOf('gg.margin') === 0).forEach(k => window.localStorage.removeItem(k)); } catch (e) {}
                  try { window.localStorage.removeItem('gg.vatRegistered'); window.localStorage.removeItem('gg.outsideUk'); } catch (e) {}
                  try { window.sessionStorage.removeItem('gg_ref_modal_seen'); } catch (e) {}
                  try { window.localStorage.removeItem('gg.flMode'); } catch (e) {}
                  try { window.location.reload(); } catch (e) {}
                }} aria-label="Start fresh, clear all selections and reload">
                  <span aria-hidden="true">↻</span>
                </button>
              </window.HoverPortalTip>
            }
          />
        )}
        <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 && !state.referMode && state.intentId !== 'investor-considering' && (
              <div className="youreset__edit-bar">
                <button
                  type="button"
                  className="youreset__edit-btn"
                  onClick={() => dispatch({ type: 'SET_STEP', step: 1 })}
                >
                  ‹ 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.referMode ? 'Your referral'
                : state.intentId === 'agency-whitelabel' ? "Your client's quote"
                : (state.clientTypeId === 'investor' && state.intentId === 'investor-portfolio') ? 'Your portfolio plan'
                : (state.clientTypeId === 'investor' && state.intentId === 'investor-considering') ? 'Your assessment'
                : (state.clientTypeId === 'investor' && state.intentId === 'investor-general') ? 'Your enquiry'
                : (state.clientTypeId === 'investor' && state.intentId === 'investor-partnership') ? 'Your partnership'
                : 'Your plan'}</div>
              <h1 className="section-head__title">{(() => {
                if (isPostCallMode()) return <>Ready to <em>launch.</em></>;
                if (state.clientTypeId === 'investor') {
                  if (state.intentId === 'investor-considering') return <>Book your free <em>assessment.</em></>;
                  if (state.intentId === 'investor-general')     return <>Let's <em>talk.</em></>;
                  if (state.intentId === 'investor-partnership') return <>Join the <em>waiting list.</em></>;
                  if (state.intentId === 'investor-portfolio')   return <>Let's check we're the <em>right engine for your portfolio.</em></>;
                }
                if (state.referMode) return <>You're set to <em>start referring.</em></>;
                if (state.clientTypeId === 'agency') return <>Let's check we're the <em>right engine for your clients.</em></>;
                return <>Let's check we're the <em>right engine for your unique goals.</em></>;
              })()}</h1>
              <p className="section-head__sub section-head__sub--full">
                {isPostCallMode()
                  ? 'Your call is booked. Review what you have put together, then send it to the team so we can come prepared.'
                  : state.referMode
                    ? 'Your referral link and code are ready. If you would like to talk it through, book a quick call below. Otherwise, you can start sharing your link and code straight away.'
                  : (state.intentId === 'agency-whitelabel'
                    ? "A few quick questions help us check we are the right engine for your clients and prepare for the call. Review your client's quote, then book a scoping call or request a payment link when you are ready."
                    : (state.clientTypeId === 'investor' && state.intentId === 'investor-portfolio')
                      ? "Review the plan for your portfolio company, then book a call or request a payment link when you're ready."
                    : (state.clientTypeId === 'investor' && state.intentId === 'investor-considering')
                      ? "Review what you have scoped, then book a free, no-obligation call. We assess the target's marketing and share diligence-ready notes."
                    : (state.clientTypeId === 'investor' && state.intentId === 'investor-general')
                      ? "Review what you have picked, then book a no-commitment call to talk it through. There is nothing to sign."
                    : (state.clientTypeId === 'investor' && state.intentId === 'investor-partnership')
                      ? "The Investor Portal is opening soon. Reserve your place, then book a call to talk through the partnership."
                      : "A few quick questions help us check we are the right engine for your goals and prepare for the call. When you are ready, book your kick-off call or request your contract and payment link.")}
              </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 && !isPostCallMode() && (
              <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, fundComplete: 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.referMode && state.clientTypeId === 'agency' && !state.quoteSubmitted && !isPostCallMode() && (() => {
              const _q = state.qualifier || {};
              const _vol = _q.aq_refer_volume || null;
              const _needs = Array.isArray(_q.aq_refer_needs) ? _q.aq_refer_needs : [];
              const _scrollTo = (sel) => { setTimeout(() => { try { const el = document.querySelector(sel); if (el && el.scrollIntoView) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (e) {} }, 130); };
              const _opt = (label, on, onClick) => (
                <button key={label} type="button" className={`qualifier-opt qualifier-opt--label-only ${on ? 'qualifier-opt--on' : ''}`} onClick={onClick}>
                  <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">{label}</div></div>
                </button>
              );
              return null;
            })()}
            {state.clientTypeId === 'agency' && !state.referMode && !state.quoteSubmitted && !isPostCallMode() && (
              <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"
                  brandingNode={state.intentId === 'agency-whitelabel' && !state.quoteSubmitted && state.qualifier?.aq_proposal_a === 'proposal' ? (
                    <Q0Section
                      q0={state.q0 || { clientName: '', industry: '' }}
                      onChange={(field, value) => dispatch({ type: 'SET_Q0_FIELD', field, value })}
                    />
                  ) : null}
                />
              </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 _flMode = !!(window.isFreelancerMode && window.isFreelancerMode());
              const referDone = state.referMode && !!(state.qualifier && state.qualifier.aq_refer_volume) && Array.isArray(state.qualifier && state.qualifier.aq_refer_needs) && state.qualifier.aq_refer_needs.length > 0;
              // 2026-07-02: in refer mode the qualifier is optional enrichment
              // (submit-time validation exempts referMode from the attribution
              // requirement), so never gate this box on it. Previously a
              // founder-type referral rendered the FULL founder qualifier and
              // hid the form until every question incl. attribution was
              // answered (referDone only ever fires for agency referrals, as
              // aq_refer_* questions render solely in agency refer mode) --
              // referrers got stranded with no form. The form now always
              // renders in refer mode; the questions above stay optional.
              const qDone = !needsQualifier
                || state.quoteSubmitted
                || state.referMode
                || founderDone
                || agencyDone
                || referDone;
              // Item 3 (Loom 0:31): in post-call mode the qualifier is answered on
              // Step 0, so the plan box is no longer gated on qualifier completion here.
              if (!qDone && !isPostCallMode()) return null;
              return (
                <div className="plan-summary-card glass-frame">
                  <div className="plan-summary-card__head">
                    <div className="plan-summary-card__eyebrow">
                      {state.referMode
                        ? <>Your <strong>referral</strong></>
                        : state.intentId === 'agency-whitelabel'
                        ? <>Built for your <strong>client</strong></>
                        : state.clientTypeId === 'agency'
                          ? ((window.isFreelancerMode && window.isFreelancerMode())
                              ? <>Built for your <strong>business</strong></>
                              : <>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">{state.referMode ? 'Referral programme' : (((window.isFreelancerMode && window.isFreelancerMode()) && state.clientTypeId === 'agency') ? 'Partner plan' : ((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}>{state.intentId === 'investor-considering' ? '‹ Back' : '‹ 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);
  };
})();

