// Prickly — interactive components
// Quiz · KitExplorer · ClosedLoop · Timeline · RotatingHeadline · RevealWords
// All components attached to window so prickly-site.jsx can use them.

// ── Helpers ────────────────────────────────────────────────────────────────
function useInView(ref, opts = {}) {
  const [seen, setSeen] = React.useState(false);
  React.useEffect(() => {
    const el = ref.current;
    if (!el || seen) return;
    const io = new IntersectionObserver((entries) => {
      entries.forEach((e) => { if (e.isIntersecting) setSeen(true); });
    }, { threshold: 0.15, ...opts });
    io.observe(el);
    return () => io.disconnect();
  }, [ref, seen]);
  return seen;
}

function useScrollProgress(ref) {
  const [p, setP] = React.useState(0);
  React.useEffect(() => {
    const el = ref.current;
    if (!el) return;
    let raf = 0;
    const onScroll = () => {
      cancelAnimationFrame(raf);
      raf = requestAnimationFrame(() => {
        const rect = el.getBoundingClientRect();
        const vh = window.innerHeight;
        const total = rect.height + vh * 0.4;
        const seen = Math.max(0, vh - rect.top) - vh * 0.3;
        const v = Math.max(0, Math.min(1, seen / total));
        setP(v);
      });
    };
    onScroll();
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onScroll);
    return () => {
      cancelAnimationFrame(raf);
      window.removeEventListener('scroll', onScroll);
      window.removeEventListener('resize', onScroll);
    };
  }, [ref]);
  return p;
}

// ── Reveal wrapper ─────────────────────────────────────────────────────────
function Reveal({ children, d = 0, as = 'div', className = '', ...rest }) {
  const ref = React.useRef(null);
  const seen = useInView(ref);
  const Tag = as;
  return (
    <Tag
      ref={ref}
      data-reveal=""
      className={(seen ? 'in ' : '') + className}
      style={{ '--d': d, ...(rest.style || {}) }}
      {...rest}
    >
      {children}
    </Tag>
  );
}

// ── Word-by-word reveal (for big headlines) ────────────────────────────────
function RevealWords({ text, className = '', startDelay = 0 }) {
  const ref = React.useRef(null);
  const seen = useInView(ref, { threshold: 0.25 });
  const words = String(text).split(/(\s+)/);
  let i = 0;
  return (
    <span ref={ref} className={'pr-revealwords ' + (seen ? 'in ' : '') + className}>
      {words.map((w, idx) => {
        if (/^\s+$/.test(w)) return <span key={idx}>{w}</span>;
        const k = i++;
        return (
          <span key={idx} className="w" style={{ '--i': k + startDelay }}>
            <span>{w}</span>
          </span>
        );
      })}
    </span>
  );
}

// ── Rotating headline word ─────────────────────────────────────────────────
function RotatingHeadline({ words, interval = 2400 }) {
  const [active, setActive] = React.useState(0);
  React.useEffect(() => {
    const t = setInterval(() => setActive((a) => (a + 1) % words.length), interval);
    return () => clearInterval(t);
  }, [words.length, interval]);

  // measure widest word for stable width
  const ref = React.useRef(null);
  const [width, setWidth] = React.useState(null);
  React.useEffect(() => {
    if (!ref.current) return;
    const el = ref.current;
    const measure = () => {
      const ghosts = el.querySelectorAll('.measure');
      let w = 0;
      ghosts.forEach((g) => { w = Math.max(w, g.offsetWidth); });
      setWidth(w);
    };
    measure();
    window.addEventListener('resize', measure);
    return () => window.removeEventListener('resize', measure);
  }, [words]);

  return (
    <span ref={ref} className="pr-hero-rotator" style={width ? { width: width + 4 } : undefined}>
      {words.map((w, i) => (
        <span
          key={w}
          className={'word ' + (i === active ? 'active' : (i === (active - 1 + words.length) % words.length ? 'out' : ''))}
        >
          {w}
        </span>
      ))}
      {/* ghost copies for width measurement, hidden */}
      {words.map((w, i) => (
        <span key={'g' + i} className="measure" style={{ position: 'absolute', visibility: 'hidden', pointerEvents: 'none', whiteSpace: 'nowrap' }}>{w}</span>
      ))}
    </span>
  );
}

// ── Eligibility Quiz ──────────────────────────────────────────────────────
const QUIZ = [
  {
    q: "Where are you located?",
    help: null,
    opts: [
      { label: "Australia",       next: 1 },
      { label: "Another country", end: 'overseas' },
    ],
  },
  {
    q: "How old are you?",
    help: "We can't safely screen anyone under 16 through this service.",
    opts: [
      { label: "Under 16",     end: 'underage' },
      { label: "16 or older",  next: 2 },
    ],
  },
  {
    q: "Have you been sexually active since your last STI screen?",
    help: null,
    opts: [
      { label: "Yes",                              next: 3 },
      { label: "No",                               end: 'not-active' },
      { label: "I've never been sexually active",  end: 'never-active' },
    ],
  },
  {
    q: "Are you currently experiencing any symptoms?",
    help: "Discharge, pain, sores, bleeding, lumps, burning when peeing.",
    opts: [
      { label: "No, I'm feeling fine",          next: 4 },
      { label: "Yes, something is going on",    end: 'symptoms' },
    ],
  },
  {
    q: "Do you require urgent sexual health care?",
    help: null,
    opts: [
      { label: "I've had a potential exposure to HIV",  end: 'pep' },
      { label: "I need emergency contraception",        end: 'emergency-contraception' },
      { label: "I've been recently sexually assaulted", end: 'assault' },
      { label: "No, I just need a routine STI screen",  next: 5 },
    ],
  },
  {
    q: "Do you have a Medicare card?",
    help: null,
    opts: [
      { label: "Yes",  end: 'go' },
      { label: "No",   next: 6 },
    ],
  },
  {
    q: "Do you have OSHC or OVHC insurance?",
    help: "Overseas Student Health Cover or Overseas Visitor Health Cover.",
    opts: [
      { label: "Yes",  end: 'oshc' },
      { label: "No",   end: 'private' },
    ],
  },
];

const QUIZ_RESULTS = {
  go: {
    tone: 'go',
    tag: 'Eligible',
    h: "You look like a great fit for prickly.",
    p: "Based on your answers, you can move straight into the full clinical questionnaire. Your kit ships within 24 hours of clinician approval.",
    cta: 'Request your Medicare STI screen',
    sec: 'Prefer not to use Medicare?',
    secEnd: 'private',
    secNote: 'Nothing claimed on Medicare or uploaded to My Health Record — your screen stays completely off the record.',
    secCta: 'Request your private STI screen',
  },
  'not-active': {
    tone: 'go',
    tag: 'Low risk',
    h: "Screening is most useful after sexual activity.",
    p: "Since you haven't been sexually active since your last screen, your risk is low. If that changes, come back and we'll get you sorted.",
    cta: null,
    sec: null,
  },
  'never-active': {
    tone: 'go',
    tag: 'No risk',
    h: "There's nothing to screen for.",
    p: "STI transmission requires sexual contact, so there's no risk to test for. If that ever changes, come back — we'll be here.",
    cta: null,
    sec: null,
  },
  symptoms: {
    tone: 'warn',
    tag: 'See a clinic',
    h: "Symptoms need a clinic, not a screen.",
    p: "Prickly is designed for asymptomatic screening. With symptoms you'll be better off with a GP or sexual health clinic — they can examine you, test on the spot, and get you treated straight away.",
    cta: 'Find a sexual health clinic',
    sec: null,
  },
  underage: {
    tone: 'warn',
    tag: "We can't help here",
    h: "Under 16 — please speak to a GP or sexual health nurse.",
    p: "Most state sexual health services are free for under-25s and confidential. We can't safely screen anyone under 16 through this service.",
    cta: 'Find youth services',
    sec: null,
  },
  'emergency-contraception': {
    tone: 'warn',
    tag: 'Time-critical',
    h: "Emergency contraception needs a clinic or pharmacy now.",
    p: "The morning-after pill is most effective within 72 hours. Head to your nearest pharmacy, GP, or sexual health clinic — no appointment needed at most pharmacies.",
    cta: 'Find a pharmacy near me',
    sec: null,
  },
  assault: {
    tone: 'warn',
    tag: 'Get support now',
    h: "Please reach out to a specialist service.",
    p: "If you've been sexually assaulted, you deserve immediate, specialist care. 1800RESPECT (1800 737 732) is available 24/7, and your nearest sexual assault service can provide medical care and support.",
    cta: 'Call 1800RESPECT',
    sec: null,
  },
  pep: {
    tone: 'warn',
    tag: 'Time-critical',
    h: "Get to a clinic or emergency department now.",
    p: "If a possible HIV exposure happened in the last 72 hours, post-exposure prophylaxis (PEP) is time-critical and not something we can dispense at home. Call 1800 MEDICARE or your nearest sexual health clinic immediately.",
    cta: 'Call 1800 MEDICARE',
    sec: null,
  },
  'no-medicare': {
    tone: 'go',
    tag: 'Likely eligible',
    h: "You can still order — just one extra step.",
    p: "Without Medicare we'll verify your identity using an IHI number you can request online. Pathology costs are the same; Telehealth follow-up is out-of-pocket, quoted upfront.",
    cta: 'Continue to identity step',
    sec: 'About IHI numbers',
  },
  oshc: {
    tone: 'go',
    tag: 'Eligible',
    h: "You can book with your OSHC/OVHC cover.",
    p: "Your overseas health cover may offset some costs. Continue to book — we'll confirm what's claimable during the clinical questionnaire.",
    cta: 'Request your OSHC/OVHC STI screen',
    sec: 'Prefer not to use OSHC/OVHC?',
    secEnd: 'private',
    secNote: 'Nothing claimed through your insurer or synced to health records — your screen stays completely private.',
    secCta: 'Request your private STI screen',
  },
  private: {
    tone: 'go',
    tag: 'Eligible',
    h: "You can book as a private patient.",
    p: "No Medicare or insurance needed. All costs are quoted upfront before you commit to anything.",
    cta: 'Request your private STI screen',
    sec: null,
  },
  overseas: {
    tone: 'warn',
    tag: 'Coming soon',
    h: "Prickly is Australia-only for now.",
    p: "We'll let you know when we ship to your country. In the meantime: in NZ, try Sexual Wellbeing Aotearoa; in the UK, NHS sexual health clinics offer at-home kits too.",
    cta: 'Notify me when we open',
    sec: null,
  },
};

function EligibilityQuiz() {
  const [step, setStep]   = React.useState(0);
  const [path, setPath]   = React.useState([]); // stack of step indices, for back
  const [result, setResult] = React.useState(null);

  const total = QUIZ.length;
  const progress = result ? 100 : (step / total) * 100;

  const pick = (opt) => {
    if (opt.end) { setResult(opt.end); return; }
    setPath((p) => [...p, step]);
    setStep(opt.next);
  };
  const back = () => {
    if (result) { setResult(null); return; }
    setPath((p) => {
      const prev = p[p.length - 1];
      if (prev === undefined) return p;
      setStep(prev);
      return p.slice(0, -1);
    });
  };
  const restart = () => { setStep(0); setPath([]); setResult(null); };

  return (
    <div className="pr-quiz">
      <div className="pr-quiz-progress" aria-hidden>
        <div className="pr-quiz-progress-bar" style={{ width: progress + '%' }} />
      </div>

      {!result && (
        <>
          <div className="pr-quiz-step-label">
            Step {String(step + 1).padStart(2, '0')} / {String(total).padStart(2, '0')} · ~ 60 seconds
          </div>
          <h3 className="pr-quiz-question">{QUIZ[step].q}</h3>
          <p className="pr-p sm" style={{ opacity: 0.65, marginBottom: 24, maxWidth: 540 }}>{QUIZ[step].help}</p>
          <div className="pr-quiz-opts" role="radiogroup">
            {QUIZ[step].opts.map((o, i) => (
              <button key={i} className="pr-quiz-opt" onClick={() => pick(o)} type="button">
                <span style={{ display: 'inline-flex', alignItems: 'center', gap: 14 }}>
                  <span className="pr-quiz-opt-key">{String.fromCharCode(65 + i)}</span>
                  <span>{o.label}</span>
                </span>
                <span aria-hidden style={{ opacity: 0.5 }}>→</span>
              </button>
            ))}
          </div>
          <button className="pr-quiz-back" onClick={back} hidden={path.length === 0}>← back</button>
        </>
      )}

      {result && (
        <button type="button" onClick={restart} className="pr-quiz-back" style={{ position: 'absolute', bottom: 'clamp(28px, 4vw, 56px)', right: 'clamp(28px, 4vw, 56px)', marginTop: 0 }}>↺ restart</button>
      )}

      {result && (() => {
        const r = QUIZ_RESULTS[result];
        return (
          <div className={'pr-quiz-result ' + r.tone}>
            <span className="pr-pill pr-quiz-result-tag">{r.tag}</span>
            <h3>{r.h}</h3>
            <p className="pr-p" style={{ opacity: 0.82, fontSize: 17, lineHeight: 1.55, maxWidth: 540 }}>{r.p}</p>
            {r.cta && (
              <div className="pr-quiz-cta-row">
                <button type="button" className={'pr-btn ' + (r.tone === 'go' ? 'sage' : '')}>
                  {r.cta} <span className="pr-arrow">→</span>
                </button>
              </div>
            )}
            {r.sec && (
              <div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 20, paddingTop: 20, borderTop: '1px solid rgba(255,255,255,0.15)', maxWidth: 480 }}>
                <p style={{ fontWeight: 600, fontSize: 15, margin: 0 }}>{r.sec}</p>
                {r.secNote && <p className="pr-p" style={{ fontSize: 14, opacity: 0.6, margin: 0, lineHeight: 1.55 }}>{r.secNote}</p>}
                {r.secCta && (
                  <div className="pr-quiz-cta-row">
                    <button type="button" className="pr-btn ghost" onClick={r.secEnd ? () => setResult(r.secEnd) : undefined}>{r.secCta} <span className="pr-arrow">→</span></button>
                  </div>
                )}
              </div>
            )}
          </div>
        );
      })()}
    </div>
  );
}

// ── Kit explorer ──────────────────────────────────────────────────────────
const KIT_PARTS = [
  {
    id: 'card',  label: 'Instructions', brief: 'Instructions & support',
    h: 'Instructions for use',
    lede: "A single printed card: how to collect each sample, how to use the self tests, and a Medicare-compliant pathology request. Designed by clinicians, not copywriters.",
    meta: [
      ['Languages',   'English'],
      ['Support line', 'Mon–Sat · 8am–8pm AEST'],
      ['QR codes',     'To video instructions'],
      ['Reading age',  'Australian Year 6 / age 11'],
    ],
  },
  {
    id: 'bag', label: 'Specimen bag', brief: 'Biohazard collection bag',
    h: 'Specimen bag',
    lede: "A sealed biohazard specimen bag for your collected swabs and urine pot. Keeps your samples secure and compliant for transport through the post.",
    meta: [
      ['Format',     'Sealed · Biohazard rated'],
      ['Liner',      'Absorbent · leak-proof'],
      ['Compliance', 'Australia Post compliant'],
      ['Included',   'With prepaid return box'],
    ],
  },
  {
    id: 'swab',  label: 'Swabs',  brief: 'Vaginal / anal / throat',
    h: 'Self-collected swabs',
    lede: "Soft-tipped polyester swabs with a snap-off shaft, ready for the lab to process. We include the right swabs for the sites you indicate in the clinical questionnaire.",
    meta: [
      ['Format',  'Sterile · Soft-tipped swab'],
      ['Sites',   'Anal · Throat · Vaginal'],
      ['Tested for', 'Chlamydia · Gonorrhoea'],
      ['Lab method',  'NAAT / PCR'],
    ],
  },
  {
    id: 'urine', label: 'Urine pot', brief: 'First-pass urine specimen',
    h: 'First-pass urine collection',
    lede: "A urine pot with a leak-proof lid. First-pass urine — the first part of your stream — picks up the same infections as a penile/vaginal swab, with none of the discomfort.",
    meta: [
      ['Format',     'Sterile · Leak-proof pot'],
      ['Volume',     '15 mL · First-pass'],
      ['Tested for', 'Chlamydia · Gonorrhoea'],
      ['Lab method', 'NAAT / PCR'],
    ],
  },
  {
    id: 'rapid', label: 'HIV/syphilis self tests', brief: 'TGA-listed fingerprick',
    h: 'Rapid fingerprick tests',
    lede: "TGA-listed self tests — HIV and Syphilis (Syphilis not yet available) — that use a single fingerprick of blood. Read in 15 minutes with 99% accuracy.",
    meta: [
      ['HIV test',     'Atomo HIV Self Test'],
      ['Syphilis test', 'Atomo Active Syphilis Self Test'],
      ['Time to read', '15 minutes'],
      ['Status',       'TGA-listed (Active Syphilis Self Test pending)'],
    ],
  },
  {
    id: 'returns', label: 'Prepaid return label',  brief: 'Reply-paid package',
    h: 'Prepaid return label',
    lede: "Repackage your sealed samples in the original box and drop it into any Australia Post Office or red Post Box. Tracked end-to-end.",
    meta: [
      ['Carrier',  'Australia Post · Prepaid shipping'],
      ['Tracking', 'End-to-end · Australia Post'],
      ['Chain of custody', 'Logged on receipt'],
      ['Packaging', 'Plain · Nothing identifiable'],
    ],
  },
];

function KitExplorer() {
  const [active, setActive] = React.useState('rapid');
  const part = KIT_PARTS.find((p) => p.id === active);

  // SVG box: 600x480 — kit components laid out as cards on a tray
  const boxes = {
    swab:    { x: 36,  y: 184, w: 120, h: 172, label: '03', text: 'SWABS' },
    urine:   { x: 172, y: 184, w: 100, h: 172, label: '04', text: 'URINE POT' },
    rapid:   { x: 288, y: 184, w: 276, h: 172, label: '05', text: 'SELF TESTS' },
    bag:     { x: 272, y: 86,  w: 292, h: 82,  label: '02', text: 'SPECIMEN BAG' },
    returns: { x: 36,  y: 372, w: 528, h: 68,  label: '06', text: 'RETURN LABEL' },
    card:    { x: 36,  y: 86,  w: 220, h: 82,  label: '01', text: 'INSTRUCTIONS' },
  };

  return (
    <div className="pr-kit">
      <div className="pr-kit-stage">
        <svg className="pr-kit-svg" viewBox="0 0 600 480" preserveAspectRatio="xMidYMid meet" aria-hidden>
          {/* Tray border */}
          <rect x="20" y="40" width="560" height="420" rx="14" fill="none" stroke="currentColor" strokeOpacity="0.12" />
          <text x="36" y="68" className="pr-kit-marker" fillOpacity="0.55">PRICKLY · STI SCREENING EQUIPMENT</text>

          {KIT_PARTS.map((p) => {
            const b = boxes[p.id];
            const isActive = p.id === active;
            return (
              <g
                key={p.id}
                className="pr-kit-hot"
                data-active={isActive}
                onClick={() => setActive(p.id)}
                onMouseEnter={() => setActive(p.id)}
              >
                <rect
                  x={b.x} y={b.y} width={b.w} height={b.h} rx="8"
                  fill={isActive ? 'currentColor' : 'none'}
                  stroke="currentColor"
                  strokeOpacity={isActive ? 0 : 0.32}
                  fillOpacity={isActive ? 0.9 : 0}
                />
                {/* striped fill */}
                {!isActive && (
                  <pattern id={'pat-' + p.id} patternUnits="userSpaceOnUse" width="10" height="10" patternTransform="rotate(135)">
                    <line x1="0" y1="0" x2="0" y2="10" stroke="currentColor" strokeWidth="1" strokeOpacity="0.08" />
                  </pattern>
                )}
                {!isActive && (
                  <rect x={b.x} y={b.y} width={b.w} height={b.h} rx="8" fill={'url(#pat-' + p.id + ')'} />
                )}
                <text x={b.x + 14} y={b.y + 22} className="pr-kit-marker" fillOpacity={isActive ? 0.55 : 0.55} fill={isActive ? 'var(--moss)' : 'currentColor'}>
                  {b.label}
                </text>
                <text x={b.x + 14} y={b.y + 44} fill={isActive ? 'var(--moss)' : 'currentColor'} className="pr-kit-marker" style={{ fontSize: 11, letterSpacing: '0.12em', fontWeight: 600 }}>
                  {b.text}
                </text>
              </g>
            );
          })}
        </svg>
      </div>

      <div className="pr-kit-info">
        <div className="pr-kit-tabs" role="tablist">
          {KIT_PARTS.map((p) => (
            <button
              key={p.id}
              role="tab"
              aria-selected={active === p.id}
              className={'pr-kit-tab ' + (active === p.id ? 'active' : '')}
              onClick={() => setActive(p.id)}
              type="button"
            >
              {p.label}
            </button>
          ))}
        </div>

        <div className="pr-kit-detail" key={part.id}>
          <h3>{part.h}</h3>
          <p className="lede">{part.lede}</p>
          <dl>
            {part.meta.map(([k, v]) => (
              <div key={k}>
                <dt>{k}</dt>
                <dd>{v}</dd>
              </div>
            ))}
          </dl>
        </div>
      </div>
    </div>
  );
}

// ── Closed-loop care diagram ──────────────────────────────────────────────
const LOOP_NODES = [
  { id: 'order',     label: 'Order',     num: '01',
    h: 'Eligibility & order',
    p: "A clinician reviews your eligibility check against the Australian STI Management Guidelines before any kit ships. Same review on every order, even repeat ones." },
  { id: 'collect',   label: 'Collect',   num: '02',
    h: 'Self-collection',
    p: "A TGA-listed swab kit lands in plain packaging. You collect at your own pace, with a clinical support line standing by Mon–Sat." },
  { id: 'lab',       label: 'Lab PCR',   num: '03',
    h: 'Lab PCR',
    p: "Samples are returned, chain-of-custody logged, and analysed by a NATA-accredited Approved Pathology Laboratory. 2–3 business days for PCR results." },
  { id: 'review',    label: 'Review',    num: '04',
    h: 'Clinician review',
    p: "Every result — negative, positive, indeterminate — is read by an Australian-registered clinician before any communication is sent. No bots, no automation in clinical decisions." },
  { id: 'followup',  label: 'Follow-up', num: '05',
    h: 'Telehealth follow-up',
    p: "Positives and indeterminates trigger a mandatory Telehealth follow-up. Electronic prescriptions, referrals, and (with consent) a summary letter to your GP." },
  { id: 'escalate',  label: 'Escalate',  num: '06',
    h: 'Manual escalation',
    p: "If a result remains uncommunicated after 14 days, the clinical team initiates manual phone and letter contact. Emergency contact data collected at intake exists for this purpose." },
];

function ClosedLoopDiagram() {
  const [active, setActive] = React.useState(0);
  const stageRef = React.useRef(null);
  React.useEffect(() => {
    const t = setInterval(() => {
      setActive((a) => (a + 1) % LOOP_NODES.length);
    }, 3200);
    return () => clearInterval(t);
  }, []);

  const cx = 200, cy = 200, r = 140;
  const positions = LOOP_NODES.map((_, i) => {
    const angle = -Math.PI / 2 + (i / LOOP_NODES.length) * Math.PI * 2;
    return {
      x: cx + Math.cos(angle) * r,
      y: cy + Math.sin(angle) * r,
    };
  });

  const node = LOOP_NODES[active];

  return (
    <div className="pr-loop">
      <div className="pr-loop-stage" ref={stageRef}>
        <svg viewBox="-40 -40 480 480" aria-hidden>
          {/* dashed orbit */}
          <circle cx={cx} cy={cy} r={r} fill="none"
            stroke="currentColor" strokeOpacity="0.18"
            strokeDasharray="2 6" />

          {/* connecting arc to next active */}
          {positions.map((p, i) => {
            const next = positions[(i + 1) % positions.length];
            const isActive = i === active;
            return (
              <line key={i}
                x1={p.x} y1={p.y} x2={next.x} y2={next.y}
                stroke="currentColor"
                strokeOpacity={isActive ? 0.6 : 0.08}
                strokeWidth={isActive ? 1.5 : 1}
                style={{ transition: 'stroke-opacity .35s, stroke-width .35s' }}
              />
            );
          })}

          {/* central marker */}
          <circle cx={cx} cy={cy} r="48"
            fill="none"
            stroke="currentColor" strokeOpacity="0.16" strokeWidth="1" />
          <text x={cx} y={cy - 4} textAnchor="middle" fill="currentColor" opacity="0.55"
            style={{ fontFamily: '"JetBrains Mono", monospace', fontSize: 9, letterSpacing: '0.16em', textTransform: 'uppercase' }}>
            Closed
          </text>
          <text x={cx} y={cy + 10} textAnchor="middle" fill="currentColor" opacity="0.55"
            style={{ fontFamily: '"JetBrains Mono", monospace', fontSize: 9, letterSpacing: '0.16em', textTransform: 'uppercase' }}>
            loop
          </text>

          {positions.map((p, i) => {
            const n = LOOP_NODES[i];
            const isActive = i === active;
            return (
              <g
                key={n.id}
                className={'pr-loop-node ' + (isActive ? 'active' : '')}
                transform={`translate(${p.x}, ${p.y})`}
                onClick={() => setActive(i)}
                onMouseEnter={() => setActive(i)}
              >
                <circle r="34" className="pr-loop-node-circle" />
                <text y="-4" className="num" textAnchor="middle">{n.num}</text>
                <text y="14" textAnchor="middle">{n.label}</text>
              </g>
            );
          })}
        </svg>
      </div>

      <div className="pr-loop-info">
        <div className="pr-loop-counter">
          Stage {node.num} of {String(LOOP_NODES.length).padStart(2, '0')}
        </div>
        <h3 key={'h-' + node.id}>{node.h}</h3>
        <p key={'p-' + node.id}>{node.p}</p>
        <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 8 }}>
          {LOOP_NODES.map((n, i) => (
            <button
              key={n.id}
              type="button"
              onClick={() => setActive(i)}
              aria-label={'Stage ' + n.label}
              style={{
                width: 32, height: 4,
                background: i === active ? 'var(--accent)' : 'rgba(241,236,222,0.18)',
                border: 0, cursor: 'pointer', padding: 0, borderRadius: 2,
                transition: 'background .25s',
              }}
            />
          ))}
        </div>
      </div>
    </div>
  );
}

// ── Scroll-progressed 5-day timeline ──────────────────────────────────────
const TIMELINE = [
  { d: 'Anytime',  h: "Complete the eligibility check and request your STI screen",
    body: "Reviewed by an Australian-registered doctor before anything is shipped. If you're not a fit, we say so and point you to the right service.",
    side: { l: 'Integrated secure payment', t: 'Secure medical record system' } },
  { d: 'Sent next business day · arrives 2–6 days',  h: "Receive your equipment in plain packaging.",
    body: "Plain, unmarked packaging — your name and address on the outside, everything you need to collect your samples on the inside.",
    side: { l: 'Tracking via email &/or SMS', t: 'Australia Post' } },
  { d: 'On your schedule', h: "Collect your samples, on your own schedule.",
    body: "Swabs, urine, fingerprick rapid tests. Twelve minutes start to finish, usually. The clinical support line is open Mon–Sat if you need it.",
    side: { l: 'Fool-proof instructions', t: 'TGA-listed testing equipment' } },
  { d: 'Send anytime · received 2–6 days', h: "Mail your package to the lab.",
    body: "Prepaid reply-paid package, tracked. Chain-of-custody logged the moment the lab receives your samples.",
    side: { l: 'Tracking by email, SMS &/or AusPost app', t: 'Australia Post' } },
  { d: 'Within 1–3 days of samples being received',  h: "Receive your results, properly handled.",
    body: "Negative: a discreet email saying so. Positive or indeterminate: an email asking you to book a Telehealth follow-up — never a result by SMS.",
    side: { l: 'No cost follow-up if required', t: 'Clinician reviewed' } },
];

function Timeline() {
  const ref = React.useRef(null);
  const p = useScrollProgress(ref);
  const litCount = Math.max(0, Math.min(TIMELINE.length, Math.ceil(p * TIMELINE.length * 1.05)));

  return (
    <div className="pr-timeline" ref={ref}>
      <div className="pr-timeline-rail">
        <div className="pr-timeline-rail-fill" style={{ height: (p * 100) + '%' }} />
      </div>
      <div className="pr-timeline-items">
        {TIMELINE.map((it, i) => (
          <div key={it.d} className={'pr-timeline-item ' + (i < litCount ? 'lit' : '')}>
            <div>
              <div className="pr-timeline-day">{it.d}</div>
              <h3 className="pr-timeline-h">{it.h}</h3>
              <p className="pr-timeline-body">{it.body}</p>
            </div>
            <div className="pr-timeline-aside">
              <b>{it.side.t}</b>
              {it.side.l}
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

// Export to window for the main site script
Object.assign(window, {
  Reveal, RevealWords, RotatingHeadline,
  EligibilityQuiz, KitExplorer, ClosedLoopDiagram, Timeline,
  useInView, useScrollProgress,
});
