/* Analytics — pick one of your published passes, see who's registered to it.
   Everything on this screen (KPIs, chart, funnel, breakdowns) is scoped to one date-range window
   and compared against the equal-length window immediately before it, driven by a single
   GET /v1/programs/:id/overview?range= call (see spoonity-passkit-integration's members.js —
   computeOverview). Cohort semantics: "this window" means the member joined in it; the
   wallet-adds/device/retention breakdowns report that cohort's CURRENT status, not a historical
   snapshot, since the backend only keeps latest-state docs. Google has zero retention/install
   signal at all (never fabricated — always shown as explicitly not measurable, matching this
   codebase's existing values elsewhere). */
const ANALYTICS_ENDPOINT = () => (window.SpoonityAuth && window.SpoonityAuth.getEndpoint
  ? window.SpoonityAuth.getEndpoint()
  : 'https://wallet-api.spoonity.com');

async function analyticsFetch(path) {
  const r = await fetch(`${ANALYTICS_ENDPOINT()}${path}`, { credentials: 'include' });
  const json = await r.json().catch(() => ({}));
  if (!r.ok) throw new Error(json.error || `Request failed (${r.status})`);
  return json;
}

async function analyticsPost(path, body) {
  const r = await fetch(`${ANALYTICS_ENDPOINT()}${path}`, {
    method: 'POST', credentials: 'include',
    headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
  });
  const json = await r.json().catch(() => ({}));
  if (!r.ok) throw new Error(json.error || `Request failed (${r.status})`);
  return json;
}

async function analyticsDelete(path) {
  const r = await fetch(`${ANALYTICS_ENDPOINT()}${path}`, { method: 'DELETE', credentials: 'include' });
  const json = await r.json().catch(() => ({}));
  if (!r.ok) throw new Error(json.error || `Request failed (${r.status})`);
  return json;
}

// A rate only earns a percentage once its denominator can actually carry one — below that, a
// raw count-of-count is more honest than a jumpy, meaningless "33%" from a sample of 3.
const MIN_N = 30;
const fmt = (n) => (n ?? 0).toLocaleString();
const smallN = (den) => den > 0 && den < MIN_N;
const rateStr = (num, den) => (den >= MIN_N ? Math.round((num / den) * 100) + '%' : den > 0 ? fmt(num) + ' of ' + fmt(den) : '—');
// Signed percentage change for a plain count vs its previous-window count — null when there's
// nothing to compare against (previous window empty, or "all time" has no prior period at all).
function pctDelta(cur, prev) {
  if (prev == null || !(prev > 0)) return null;
  const p = Math.round(((cur - prev) / prev) * 100);
  return { text: (p > 0 ? '+' : '') + p + '%', good: p >= 0 };
}
// Percentage-POINT change between two rates (each already num/den, both required to clear
// MIN_N — a point swing between two unreliable rates is itself unreliable).
function ptsDelta(curNum, curDen, prevNum, prevDen) {
  if (!(curDen >= MIN_N) || !(prevDen >= MIN_N)) return null;
  const d = Math.round(((curNum / curDen) - (prevNum / prevDen)) * 1000) / 10;
  if (d === 0) return null;
  return { text: (d > 0 ? '+' : '') + d + ' pts', good: d >= 0 };
}

/* Collapsible broadcast panel — starts closed so it doesn't compete with the KPIs/chart for
   attention on every visit; "Compose"/"Close" toggle plus a chevron make the state legible. */
function BroadcastPanel({ programId, accent, posterOnly = false }) {
  const [open, setOpen] = React.useState(false);
  const [header, setHeader] = React.useState('');
  const [body, setBody] = React.useState('');
  const [status, setStatus] = React.useState('idle'); // idle | sending | sent | error
  const [error, setError] = React.useState('');
  const [outcome, setOutcome] = React.useState(null);

  const send = async () => {
    if (!body.trim() || status === 'sending') return;
    setStatus('sending'); setError('');
    try {
      const data = await analyticsPost('/api/wallet/push', { programId, header: header.trim() || undefined, body: body.trim() });
      setOutcome(data);
      setStatus('sent');
      setBody('');
    } catch (e) {
      setError(e.message || 'Send failed.'); setStatus('error');
    }
  };

  const textarea = { width: '100%', padding: '10px 12px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'var(--surface-secondary, #f8f6f4)', color: 'var(--typography-primary)', fontFamily: 'var(--font-sans)', fontSize: 13, resize: 'vertical', boxSizing: 'border-box' };
  const sendable = body.trim().length > 0;

  return (
    <div style={{ gridColumn: 'span 12', background: 'var(--surface-base)', borderRadius: 14, border: '1px solid var(--border-primary)' }}>
      <button type="button" onClick={() => setOpen((o) => !o)}
        style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, padding: '16px 18px', background: 'transparent', border: 'none', cursor: 'pointer', textAlign: 'left', fontFamily: 'var(--font-sans)' }}>
        <span style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
          <span style={{ fontSize: 15, fontWeight: 700, color: 'var(--typography-primary)' }}>Send a message</span>
          <span style={{ fontSize: 11.5, color: 'var(--typography-tertiary)' }}>Pushes to the lock screen of every member who has this pass installed. 10 sends/hour.</span>
        </span>
        <span style={{ display: 'flex', alignItems: 'center', gap: 10, flexShrink: 0 }}>
          <span style={{ fontSize: 12, fontWeight: 600, color: 'var(--typography-secondary)' }}>{open ? 'Close' : 'Compose'}</span>
          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--typography-tertiary)" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"
            style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .18s ease' }}><path d="M6 9l6 6 6-6" /></svg>
        </span>
      </button>
      {open && (
        <div style={{ borderTop: '1px solid var(--border-primary)', padding: 18, display: 'flex', flexDirection: 'column', gap: 10, maxWidth: 560 }}>
          {posterOnly && (
            <div style={{ display: 'flex', gap: 8, alignItems: 'flex-start', padding: '8px 10px', borderRadius: 9, background: 'var(--surface-secondary)', border: '1px solid var(--border-primary)' }}>
              <span style={{ color: 'var(--warning, #cc6716)', fontWeight: 700, fontSize: 13, lineHeight: '16px' }}>!</span>
              <span style={{ fontSize: 11, color: 'var(--typography-secondary)', lineHeight: 1.45 }}>
                This pass uses Apple's Poster layout, which doesn't support lock-screen messages yet -- Apple members won't see this. Google members will still get it.
              </span>
            </div>
          )}
          <input type="text" value={header} onChange={(e) => setHeader(e.target.value)} placeholder="Header (optional, Google only)" maxLength={100}
            disabled={status === 'sending'} style={{ ...textarea, resize: 'none' }} />
          <textarea value={body} onChange={(e) => setBody(e.target.value)} placeholder="50% off today only!" maxLength={190} rows={2}
            disabled={status === 'sending'} style={textarea} />
          {error && <div style={{ color: 'var(--critical,#c3223f)', fontSize: 12 }}>{error}</div>}
          {status === 'sent' && outcome && (
            <div style={{ fontSize: 12, color: 'var(--success,#3f7d58)' }}>
              Sent: Apple {outcome.apple.delivered}/{outcome.apple.attempted} · Google {outcome.google.delivered}/{outcome.google.attempted} devices.
            </div>
          )}
          <div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
            <button type="button" onClick={send} disabled={!sendable || status === 'sending'}
              style={{ padding: '9px 18px', borderRadius: 9, border: 'none', background: sendable ? accent : 'var(--surface-secondary, #e7e2db)', color: sendable ? '#fff' : 'var(--typography-tertiary)',
                       fontFamily: 'var(--font-sans)', fontSize: 13, fontWeight: 600, cursor: sendable && status !== 'sending' ? 'pointer' : 'default' }}>
              {status === 'sending' ? 'Sending…' : 'Send to members'}
            </button>
            <span style={{ fontSize: 11.5, color: 'var(--typography-tertiary)' }}>{190 - body.length} characters left · Apple truncates hard</span>
          </div>
        </div>
      )}
    </div>
  );
}

// Path for a bar with rounded top corners, square baseline — never all four corners
// (the mark "grows from a single baseline", per the design system's bar spec).
function roundedTopBarPath(x, y, w, h) {
  if (h <= 0) return '';
  const r = Math.min(4, w / 2, h);
  return `M${x},${y + h} V${y + r} Q${x},${y} ${x + r},${y} H${x + w - r} Q${x + w},${y} ${x + w},${y + r} V${y + h} Z`;
}

function niceAxisMax(rawMax) {
  const mag = Math.pow(10, Math.floor(Math.log10(rawMax || 1)));
  for (const step of [1, 2, 5, 10]) { if (rawMax <= step * mag) return step * mag; }
  return 10 * mag;
}

// year AND all are both monthly buckets ("YYYY-MM"); week/month are daily ("YYYY-MM-DD").
function bucketLabel(dateStr, range) {
  if (range === 'year' || range === 'all') {
    const [y, m] = dateStr.split('-').map(Number);
    return new Date(Date.UTC(y, m - 1, 1)).toLocaleDateString(undefined, { month: 'short' });
  }
  const d = new Date(dateStr + 'T00:00:00Z');
  return range === 'week'
    ? d.toLocaleDateString(undefined, { weekday: 'short' })
    : String(d.getUTCDate());
}

function bucketTooltipDate(dateStr, range) {
  if (range === 'year' || range === 'all') {
    const [y, m] = dateStr.split('-').map(Number);
    return new Date(Date.UTC(y, m - 1, 1)).toLocaleDateString(undefined, { month: 'long', year: 'numeric' });
  }
  return new Date(dateStr + 'T00:00:00Z').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
}

// Fixed-order categorical hues (validated for adjacent-pair CVD separation) — assigned by
// category position, never re-cycled or reassigned when a category drops out.
const CATEGORICAL = ['#2a78d6', '#008300', '#e87ba4', '#eda100'];
const MUTED_GRAY = '#898781'; // for "unknown" — absence of data, not a real category
const GOOD = '#3f7d58', BAD = '#c3223f';

/* One KPI stat card (span 3 of 12): value, optional delta vs the previous window, and a
   sub-label that explains the number or flags a too-small sample instead of showing a
   misleading rate. */
function KpiCard({ label, value, delta, sub }) {
  return (
    <div style={{ gridColumn: 'span 3', background: 'var(--surface-base)', border: '1px solid var(--border-primary)', borderRadius: 14, padding: '18px 18px 16px', display: 'flex', flexDirection: 'column', gap: 10, minHeight: 132 }}>
      <div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '.07em', color: 'var(--typography-tertiary)' }}>{label}</div>
      <div style={{ display: 'flex', alignItems: 'baseline', gap: 10 }}>
        <div style={{ fontSize: 32, fontWeight: 800, letterSpacing: '-.02em', lineHeight: 1, color: 'var(--typography-primary)' }}>{value}</div>
        {delta && <span style={{ fontSize: 12, fontWeight: 700, color: delta.good ? GOOD : BAD }}>{delta.text}</span>}
      </div>
      <div style={{ fontSize: 11.5, color: 'var(--typography-tertiary)', lineHeight: 1.45, marginTop: 'auto' }}>{sub}</div>
    </div>
  );
}

/* Part-to-whole breakdown card (span 4 of 12) — direct-labeled bars (count + %, or just count
   under MIN_N), a small-N footnote instead of hiding the shares silently, and an optional extra
   note (e.g. Google's "not measurable" — never a fabricated number standing in for it). */
function BreakdownCard({ title, note, rows, base, footnote, extraNote }) {
  return (
    <div style={{ gridColumn: 'span 4', background: 'var(--surface-base)', border: '1px solid var(--border-primary)', borderRadius: 14, padding: 18, display: 'flex', flexDirection: 'column' }}>
      <div style={{ fontSize: 15, fontWeight: 700, color: 'var(--typography-primary)' }}>{title}</div>
      <div style={{ fontSize: 11.5, color: 'var(--typography-tertiary)', margin: '2px 0 18px', lineHeight: 1.45 }}>{note}</div>
      {base === 0 ? (
        <div style={{ border: '1.5px dashed var(--border-primary)', borderRadius: 12, padding: '24px 16px', textAlign: 'center', fontSize: 12.5, color: 'var(--typography-tertiary)' }}>
          Nothing recorded yet.
        </div>
      ) : (
        <div style={{ display: 'flex', flexDirection: 'column', gap: 13 }}>
          {rows.map((r) => {
            const pct = base ? Math.round((r.count / base) * 100) : 0;
            return (
              <div key={r.label}>
                <div style={{ display: 'flex', justifyContent: 'space-between', gap: 10, fontSize: 12.5, marginBottom: 6 }}>
                  <span style={{ fontWeight: 600, color: 'var(--typography-primary)' }}>{r.label}</span>
                  <span style={{ color: 'var(--typography-tertiary)' }}>{smallN(base) ? fmt(r.count) : `${fmt(r.count)} · ${pct}%`}</span>
                </div>
                <div style={{ height: 8, borderRadius: 4, background: 'var(--surface-secondary, #f0ede9)', overflow: 'hidden' }}>
                  <div style={{ height: '100%', width: `${pct}%`, borderRadius: 4, background: r.color }} />
                </div>
              </div>
            );
          })}
        </div>
      )}
      <div style={{ fontSize: 11, color: 'var(--typography-tertiary)', marginTop: 'auto', paddingTop: 14, lineHeight: 1.45 }}>
        {footnote}{extraNote && <React.Fragment><br />{extraNote}</React.Fragment>}
      </div>
    </div>
  );
}

// Tapering trapezoid bands, drawn entirely as SVG (shape AND the label/count text both) — no
// HTML text overlay. Isolated the actual overflow bug through a series of minimal static-HTML
// reproductions (see the PRs on this file for the full trail): a slanted (non-zero-inset) SVG
// path renders correctly on its own, but reliably corrupts as soon as it sits in a
// position:relative parent NEXT TO an absolutely-positioned sibling — reproducible with zero
// React, zero percentages, hand-coded pixels, so not a clip-path bug, not a percentage-rounding
// bug, and not specific to hitting exactly 100% width. That combination (HTML text overlaid via
// position:absolute next to the shape) is exactly what an earlier version of this component did.
// Keeping the label/count as SVG text in the SAME element as the shape has no sibling to trigger
// it, and side-steps the bug entirely.
function FunnelShape({ stages, accent }) {
  const n = stages.length;
  const values = stages.map((s) => Math.max(0, Number(s.value) || 0));
  const fMax = Math.max(1, ...values);
  const widthOf = (v) => 54 + 46 * (v / fMax); // 54% floor keeps even the smallest stage legible

  const drops = values.map((v, i) => (i === 0 ? 0 : values[i - 1] - v));
  const worstIdx = drops.indexOf(Math.max(0, ...drops.slice(1))); // ignore negative "drops" (a stage that grew) when picking the worst

  const overall = values[0] >= MIN_N ? Math.round((values[n - 1] / values[0]) * 100) + '%' : values[0] > 0 ? fmt(values[n - 1]) + ' of ' + fmt(values[0]) : '—';

  // Running-min clamp on the SHAPE's width only (never on the numbers shown) — a real funnel
  // narrows continuously, each band's top flush with the previous band's bottom. Independently
  // time-scoped stages can still legitimately jump wider (e.g. Downloaded counts first-time
  // device confirmations in this window regardless of when the member joined, so it isn't bounded
  // by Confirmed the way a strict single-cohort funnel would be) — that stays fully visible via
  // the actual count and the "+N" drop label, it's just never implied as part of the taper.
  let runningMin = Infinity;
  const displayWidths = values.map((v) => { runningMin = Math.min(widthOf(v), runningMin); return runningMin; });

  const W = 1000, bandH = 90, gap = 6, dropH = 34;
  const rowH = bandH + dropH;
  const H = rowH * n - dropH + 4;
  const cx = W / 2;

  const rows = stages.map((stage, i) => {
    const topW = displayWidths[i] / 100 * W;
    const botW = (i < n - 1 ? displayWidths[i + 1] : displayWidths[i]) / 100 * W;
    const y0 = i * rowH + gap / 2;
    const y1 = i * rowH + bandH - gap / 2;
    const midY = (y0 + y1) / 2;
    const lost = i < n - 1 ? values[i] - values[i + 1] : 0;
    const isWorst = i + 1 === worstIdx && lost > 0;
    // A stage growing instead of shrinking is real and expected (see above) — shown as a "+"
    // gain (neutral) rather than a loss, instead of the garbled double-negative a raw "−" prefix
    // on a negative number would print. Always a percentage change relative to values[i] (the
    // earlier stage) — no MIN_N fallback here, unlike the rate-style numbers elsewhere on this
    // page: this is a plain arithmetic ratio between two specific counts on the same funnel, not
    // a rate being asked to stand in for the whole population.
    const dropLabel = values[i] === 0 ? '—'
      : lost > 0 ? `−${Math.round((lost / values[i]) * 100)}%`
      : lost < 0 ? `+${Math.round((-lost / values[i]) * 100)}%`
      : '±0%';
    const padX = 26;
    return (
      <g key={stage.label}>
        <path d={`M${cx - topW / 2},${y0} L${cx + topW / 2},${y0} L${cx + botW / 2},${y1} L${cx - botW / 2},${y1} Z`} fill={accent} opacity={Math.max(0.3, 1 - i * 0.15)} />
        <text x={cx - Math.min(topW, botW) / 2 + padX} y={midY} textAnchor="start" dominantBaseline="middle" fontSize="30" fontWeight="700" fill="#fff">{stage.label}</text>
        <text x={cx + Math.min(topW, botW) / 2 - padX} y={midY} textAnchor="end" dominantBaseline="middle" fontSize="34" fontWeight="800" fill="#fff">{fmt(values[i])}</text>
        {i < n - 1 && (
          <text x={cx} y={i * rowH + bandH + dropH / 2} textAnchor="middle" dominantBaseline="middle" fontSize="26" fontWeight={isWorst ? '700' : '600'} fill={isWorst ? BAD : 'var(--typography-tertiary)'}>
            {dropLabel}
          </text>
        )}
      </g>
    );
  });

  return (
    <div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
      <svg width="100%" viewBox={`0 0 ${W} ${H}`} style={{ display: 'block', overflow: 'visible' }}>
        {rows}
      </svg>
      <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12, marginTop: 14, paddingTop: 12, borderTop: '1px solid var(--border-primary)' }}>
        <span style={{ fontSize: 11.5, color: 'var(--typography-tertiary)' }}>{stages[0].label} → {stages[n - 1].label}</span>
        <span style={{ fontSize: 18, fontWeight: 800, letterSpacing: '-.01em', color: 'var(--typography-primary)' }}>{overall}</span>
      </div>
    </div>
  );
}

/* Single-series bar chart of signups per bucket (day or month) — no legend needed for one
   series; identity comes from the chart's own title. Every value is reachable via hover. */
function SignupsChart({ buckets, range, accent }) {
  const [hover, setHover] = React.useState(null);
  const n = buckets.length;
  const max = niceAxisMax(Math.max(0, ...buckets.map((b) => b.count)));

  const W = 720, H = 200, padL = 30, padR = 6, padT = 24, padB = 24;
  const plotW = W - padL - padR, plotH = H - padT - padB;
  const slot = plotW / n;
  const barW = Math.min(24, slot * 0.6);
  const yFor = (v) => padT + plotH - (max ? (v / max) * plotH : 0);
  // Thin the x-axis labels so at most ~12 show regardless of bucket count — a fixed step-of-5
  // (right for the 30-bar month view) would crowd or under-fill any other bucket count, e.g. the
  // up-to-60 monthly bars "all time" can show.
  const labelStep = Math.max(1, Math.ceil(n / 12));
  // Always-visible per-bar counts only make sense while there's room for them — past ~12 bars
  // they'd overlap, so they fall back to hover-only (the tooltip below still works at any n).
  const showValues = n <= 12;

  return (
    <div style={{ position: 'relative' }}>
      <svg width="100%" viewBox={`0 0 ${W} ${H}`} style={{ display: 'block', overflow: 'visible' }}>
        {[0, max / 2, max].map((v, i) => (
          <g key={i}>
            <line x1={padL} x2={W - padR} y1={yFor(v)} y2={yFor(v)} stroke="var(--border-primary)" strokeWidth="1" />
            <text x={padL - 6} y={yFor(v)} textAnchor="end" dominantBaseline="middle" fontSize="11" fill="var(--typography-tertiary)">{Math.round(v)}</text>
          </g>
        ))}
        {buckets.map((b, i) => {
          const x = padL + i * slot + (slot - barW) / 2;
          const y = yFor(b.count);
          const h = padT + plotH - y;
          return (
            <g key={b.date} onMouseEnter={() => setHover(i)} onMouseLeave={() => setHover(null)} style={{ cursor: 'default' }}>
              {/* full-column hit area — easier to hover than the (possibly tiny) bar itself */}
              <rect x={padL + i * slot} y={padT - 16} width={slot} height={plotH + 16} fill="transparent" />
              {showValues && b.count > 0 && (
                <text x={x + barW / 2} y={y - 8} textAnchor="middle" fontSize="12" fontWeight="600" fill="var(--typography-secondary)">
                  {fmt(b.count)}
                </text>
              )}
              <path d={roundedTopBarPath(x, y, barW, h)} fill={accent} opacity={hover === i ? 1 : 0.82} />
              {i % labelStep === 0 && (
                <text x={x + barW / 2} y={H - 6} textAnchor="middle" fontSize="11" fill="var(--typography-tertiary)">
                  {bucketLabel(b.date, range)}
                </text>
              )}
            </g>
          );
        })}
      </svg>
      {hover != null && (
        <div style={{ position: 'absolute', left: `${((hover + 0.5) / n) * 100}%`, top: 0, transform: 'translate(-50%, -100%)',
                      background: 'var(--typography-primary)', color: 'var(--surface-base)', fontSize: 12,
                      padding: '7px 11px', borderRadius: 8, whiteSpace: 'nowrap', pointerEvents: 'none', marginTop: -6 }}>
          <div style={{ fontWeight: 700 }}>{buckets[hover].count} sign-up{buckets[hover].count === 1 ? '' : 's'}</div>
          <div style={{ opacity: 0.75, fontSize: 11 }}>{bucketTooltipDate(buckets[hover].date, range)}</div>
        </div>
      )}
    </div>
  );
}

// Shows at most ~10 rows before scrolling (maxHeight caps it; shorter lists just don't scroll),
// with a client-side search over name/email — the full list is already in memory, no refetch.
function MembersTable({ members, programId, onDeleted }) {
  const [search, setSearch] = React.useState('');
  // The member pending removal, or null. Confirmed via a modal (same pattern as BuilderForm.jsx's
  // draft-delete) rather than a plain window.confirm — this is a real, permanent GDPR-style
  // erasure (see member-delete.js), not something to risk a stray click on.
  const [confirmRemove, setConfirmRemove] = React.useState(null);
  const [removing, setRemoving] = React.useState(false);
  const [removeError, setRemoveError] = React.useState('');
  const q = search.trim().toLowerCase();
  const filtered = q
    ? members.filter((m) => (m.name || '').toLowerCase().includes(q) || (m.email || '').toLowerCase().includes(q))
    : members;

  const handleRemove = async () => {
    if (!confirmRemove) return;
    setRemoving(true); setRemoveError('');
    try {
      await analyticsDelete(`/v1/members/${encodeURIComponent(confirmRemove.externalId)}?programId=${encodeURIComponent(programId)}`);
      onDeleted(confirmRemove.memberId);
      setConfirmRemove(null);
    } catch (e) {
      setRemoveError(e.message);
    } finally {
      setRemoving(false);
    }
  };

  const th = { textAlign: 'left', fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '.05em', color: 'var(--typography-tertiary)', padding: '10px 14px', borderBottom: '1px solid var(--border-primary)', position: 'sticky', top: 0, background: 'var(--surface-base)' };
  const td = { fontSize: 13, color: 'var(--typography-primary)', padding: '12px 14px', borderBottom: '1px solid var(--border-primary)' };

  return (
    <div style={{ gridColumn: 'span 12', background: 'var(--surface-base)', borderRadius: 14, border: '1px solid var(--border-primary)', overflow: 'hidden' }}>
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, padding: '16px 18px', borderBottom: '1px solid var(--border-primary)' }}>
        <div>
          <div style={{ fontSize: 15, fontWeight: 700, color: 'var(--typography-primary)' }}>Members</div>
          <div style={{ fontSize: 11.5, color: 'var(--typography-tertiary)', marginTop: 2 }}>{fmt(members.length)} total, all time</div>
        </div>
        <input type="text" value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search name or email…"
          style={{ width: 260, padding: '8px 12px', borderRadius: 9, border: '1px solid var(--border-primary)',
                   background: 'var(--surface-secondary, #f8f6f4)', fontFamily: 'var(--font-sans)', fontSize: 13,
                   color: 'var(--typography-primary)', boxSizing: 'border-box' }} />
      </div>
      {filtered.length === 0 ? (
        <div style={{ padding: '28px 24px', textAlign: 'center', color: 'var(--typography-tertiary)', fontSize: 13 }}>
          {members.length === 0 ? 'No one has registered for this pass yet.' : 'No members match your search.'}
        </div>
      ) : (
        <div style={{ maxHeight: 440, overflowY: 'auto' }}>
          <table style={{ width: '100%', borderCollapse: 'collapse' }}>
            <thead><tr><th style={th}>Name</th><th style={th}>Email</th><th style={{ ...th, textAlign: 'right' }}>Points</th><th style={th}>Tier</th><th style={th}>Favorite location</th><th style={th}>Favorite item</th><th style={th}>Last used</th><th style={{ ...th, textAlign: 'center' }}></th></tr></thead>
            <tbody>
              {filtered.map((m) => (
                <tr key={m.memberId}>
                  <td style={{ ...td, fontWeight: 600 }}>{m.name || '-'}</td>
                  <td style={{ ...td, color: 'var(--typography-secondary)' }}>{m.email || '-'}</td>
                  <td style={{ ...td, textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>{fmt(m.points)}</td>
                  <td style={{ ...td, color: 'var(--typography-secondary)', textTransform: 'capitalize' }}>{m.tier || '-'}</td>
                  {/* Only ever set via the vendor's own backend calling POST
                      /v1/members/favorite-store with this customer's Spoonity user_id — never at
                      enrollment (a brand-new pass has no transaction history to rank a favorite
                      from). "-" here just means it hasn't been refreshed for this member yet, not
                      an error. */}
                  <td style={{ ...td, color: 'var(--typography-secondary)' }} title={m.favoriteLocation?.address || undefined}>{m.favoriteLocation?.name || '-'}</td>
                  {/* That member's most-ordered item AT their favorite location specifically (not
                      an all-time favorite) — same refresh, riding along on favoriteLocation. */}
                  <td style={{ ...td, color: 'var(--typography-secondary)' }}>{m.favoriteLocation?.favoriteProduct || '-'}</td>
                  {/* From their last real loyalty transaction, not literally "opened the wallet" —
                      neither Apple nor Google exposes that. Same as joinedAt for anyone who's
                      never transacted since signing up, which is the correct, honest value. */}
                  <td style={{ ...td, color: 'var(--typography-secondary)' }}>{m.lastUsedAt ? new Date(m.lastUsedAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '-'}</td>
                  <td style={{ ...td, textAlign: 'center' }}>
                    {/* externalId predates a couple of legacy members that never got one backfilled
                        (see member-delete.js) — nothing safe to key a deletion request to for those,
                        so the action is disabled rather than silently pointing at the wrong record. */}
                    <button
                      type="button"
                      onClick={() => setConfirmRemove(m)}
                      disabled={!m.externalId}
                      title={m.externalId ? 'Remove this member' : "Can't remove — no external ID on file"}
                      onMouseEnter={(e) => { if (m.externalId) { e.currentTarget.style.background = 'var(--critical,#c3223f)'; e.currentTarget.style.color = '#fff'; e.currentTarget.style.borderColor = 'var(--critical,#c3223f)'; } }}
                      onMouseLeave={(e) => { e.currentTarget.style.background = 'var(--surface-base)'; e.currentTarget.style.color = 'var(--typography-tertiary)'; e.currentTarget.style.borderColor = 'var(--border-primary)'; }}
                      style={{
                        width: 28, height: 28, borderRadius: 8, border: '1px solid var(--border-primary)',
                        background: 'var(--surface-base)', cursor: m.externalId ? 'pointer' : 'not-allowed',
                        opacity: m.externalId ? 1 : 0.4, display: 'inline-flex', alignItems: 'center',
                        justifyContent: 'center', color: 'var(--typography-tertiary)', transition: 'all .15s',
                      }}
                    >
                      <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
                    </button>
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      )}

      {/* Removal confirmation — a real, permanent GDPR-erasure call (member-delete.js), same
          weight as BuilderForm.jsx's draft-delete confirm. */}
      {confirmRemove && (
        <div style={{ position: 'fixed', inset: 0, zIndex: 3000, background: 'rgba(0,0,0,.45)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
          onClick={(e) => { if (e.target === e.currentTarget && !removing) { setConfirmRemove(null); setRemoveError(''); } }}>
          <div style={{ background: 'var(--surface-base)', borderRadius: 16, width: 380, maxWidth: '90vw', padding: 24, boxShadow: '0 24px 60px -12px rgba(0,0,0,.45)' }}>
            <div style={{ width: 44, height: 44, borderRadius: '50%', background: 'rgba(195,34,63,.12)', display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: 16 }}>
              <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="var(--critical,#c3223f)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M10.29 3.86L1.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"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
            </div>
            <div style={{ fontSize: 15, fontWeight: 700, color: 'var(--typography-primary)', marginBottom: 8 }}>Remove this member?</div>
            <div style={{ fontSize: 13, color: 'var(--typography-secondary)', lineHeight: 1.5, marginBottom: 20 }}>
              <strong>{confirmRemove.name || confirmRemove.email || 'This member'}</strong> and their loyalty record (points, tier, transaction history) will be permanently deleted from the database. This cannot be undone, and does not remove the pass from their phone.
            </div>
            {removeError && <div style={{ fontSize: 12.5, color: 'var(--critical,#c3223f)', marginBottom: 14 }}>{removeError}</div>}
            <div style={{ display: 'flex', gap: 8 }}>
              <button onClick={() => { setConfirmRemove(null); setRemoveError(''); }} disabled={removing} style={{ flex: 1, padding: 9, borderRadius: 9, border: '1px solid var(--border-primary)', background: 'var(--surface-secondary)', cursor: removing ? 'default' : 'pointer', fontFamily: 'var(--font-sans)', fontSize: 13, fontWeight: 600, color: 'var(--typography-secondary)' }}>Cancel</button>
              <button onClick={handleRemove} disabled={removing} style={{ flex: 1, padding: 9, borderRadius: 9, border: 'none', background: 'var(--critical,#c3223f)', cursor: removing ? 'default' : 'pointer', fontFamily: 'var(--font-sans)', fontSize: 13, fontWeight: 600, color: '#fff', opacity: removing ? 0.7 : 1 }}>
                {removing ? 'Removing…' : 'Yes, remove'}
              </button>
            </div>
          </div>
        </div>
      )}
    </div>
  );
}

function AnalyticsView({ onHome, accent = '#F47920', initialProgramId = null, initialProgramName = null }) {
  const [programs, setPrograms] = React.useState(null); // null = loading
  // Seeded directly from the caller (e.g. the Hub's per-card "view analytics" icon) when given, so
  // this view can open straight into a specific pass's dashboard instead of always starting on the
  // "pick a pass" picker screen.
  const [selected, setSelected] = React.useState(initialProgramId ? { programId: initialProgramId, name: initialProgramName || initialProgramId } : null); // { programId, name } | null
  const [members, setMembers] = React.useState(null); // null = loading (once a pass is selected)
  const [range, setRange] = React.useState('week'); // week | month | year | all
  const [signups, setSignups] = React.useState(null); // null = loading
  const [overview, setOverview] = React.useState(null); // null = loading
  const [reloading, setReloading] = React.useState(false); // manual reload keeps the last data on screen
  const [error, setError] = React.useState('');

  // The vendor's real published programs (wallet_programs), not the editor drafts collection —
  // a program published a while ago (or through any path that skipped saving a draft) is fully
  // live but was invisible here when this read from window.SpoonityDrafts.
  React.useEffect(() => {
    const endpoint = window.SpoonityAuth ? window.SpoonityAuth.getEndpoint() : '';
    fetch(endpoint + '/v1/programs', { credentials: 'include' })
      .then((r) => r.json())
      .then((data) => setPrograms((data.programs || []).map((p) => ({ programId: p.programId, name: p.name || p.company || p.programId, cardLayout: p.cardLayout }))))
      .catch(() => setPrograms([]));
  }, []);

  // Load members whenever a pass is selected. Not window-scoped — the roster is who's ever
  // registered, same as before; the window only scopes the KPIs/chart/funnel/breakdowns above it.
  React.useEffect(() => {
    if (!selected) { setMembers(null); return; }
    setMembers(null); setError('');
    analyticsFetch(`/v1/programs/${encodeURIComponent(selected.programId)}/members`)
      .then((data) => setMembers(data.members || []))
      .catch((e) => { setError(e.message); setMembers([]); });
  }, [selected]);

  // Load the signup timeline whenever a pass is selected, or the range changes.
  React.useEffect(() => {
    if (!selected) { setSignups(null); return; }
    setSignups(null);
    analyticsFetch(`/v1/programs/${encodeURIComponent(selected.programId)}/signups?range=${range}`)
      .then((data) => setSignups(data.buckets || []))
      .catch(() => setSignups([]));
  }, [selected, range]);

  // Everything else — KPIs, funnel, the three breakdowns — comes from one window-scoped call.
  // Pulled out as its own function (rather than inline in the effect) so the reload button can
  // re-fetch on demand without blanking the screen back to "Loading…" first.
  const loadOverview = (programId, r) => {
    analyticsFetch(`/v1/programs/${encodeURIComponent(programId)}/overview?range=${r}`)
      .then((data) => setOverview(data))
      .catch(() => setOverview(null))
      .finally(() => setReloading(false));
  };
  React.useEffect(() => {
    if (!selected) { setOverview(null); return; }
    setOverview(null);
    loadOverview(selected.programId, range);
  }, [selected, range]);

  const back = () => (selected ? setSelected(null) : onHome());
  // cardLayout lives on the /v1/programs list entry, not on `selected` itself (which may have been
  // seeded from just a programId+name via the Hub's deep link) -- look it up once the list has
  // loaded, matching by programId either way.
  const selectedProgram = (programs && selected) ? programs.find((p) => p.programId === selected.programId) : null;
  const isPoster = !!(selectedProgram && selectedProgram.cardLayout === 'poster');
  const selectStyle = { padding: '8px 11px', borderRadius: 9, border: '1px solid var(--border-primary)', background: 'var(--surface-base)', fontFamily: 'var(--font-sans)', fontSize: 13, color: 'var(--typography-primary)', cursor: 'pointer' };

  // ---- Derive everything the render needs from the raw overview payload ----
  const cur = overview?.current;
  const prev = overview?.previous;
  const kpis = cur ? [
    {
      label: 'Sign-ups', value: fmt(cur.signups), delta: pctDelta(cur.signups, prev?.signups),
      sub: cur.signups ? (prev ? `vs ${fmt(prev.signups)} in the previous window` : 'All time since launch') : 'No sign-ups in this window',
    },
    {
      label: 'Wallet adds', value: fmt(cur.downloadsInWindow), delta: pctDelta(cur.downloadsInWindow, prev?.downloadsInWindow),
      sub: cur.signups ? rateStr(cur.downloadsInWindow, cur.signups) + ' of new sign-ups, Apple-confirmed only' : 'No new sign-ups yet',
    },
    {
      label: 'Active members', value: rateStr(cur.usedInWindow, cur.signups),
      delta: ptsDelta(cur.usedInWindow, cur.signups, prev?.usedInWindow, prev?.signups),
      sub: smallN(cur.signups) ? `Sample too small for a rate (n=${cur.signups})` : 'Earned or redeemed at least once, of this window’s sign-ups',
    },
    {
      label: 'Uninstall rate', value: rateStr(cur.uninstalledInCohort, cur.everRegistered),
      delta: ptsDelta(cur.uninstalledInCohort, cur.everRegistered, prev?.uninstalledInCohort, prev?.everRegistered),
      sub: smallN(cur.everRegistered)
        ? `Sample too small for a rate (n=${cur.everRegistered})`
        : cur.daysKeptSampleSize > 0 ? `Apple only · avg. ${Math.round(cur.avgDaysKept)} days kept before removal` : 'Apple only · no uninstalls yet',
    },
  ] : [];

  const funnelStages = cur ? [
    { label: 'Invite sent', value: cur.sent }, { label: 'Confirmed', value: cur.signups },
    { label: 'Downloaded', value: cur.downloadsInWindow }, { label: 'Used', value: cur.usedInWindow },
  ] : [];

  const breakdowns = cur ? [
    {
      title: 'Wallet adds', note: 'Confirmed by a device registering for updates — not just offered the option.',
      base: cur.signups, rows: [
        { label: 'Confirmed on a device', count: cur.everRegistered, color: CATEGORICAL[0] },
        { label: 'Not yet confirmed', count: cur.signups - cur.everRegistered, color: MUTED_GRAY },
      ],
      footnote: smallN(cur.signups) ? `n=${cur.signups} — counts only, too few for shares.` : `Base: ${fmt(cur.signups)} sign-ups this window.`,
    },
    {
      title: 'Device type', note: 'Recorded at sign-up; blank for members who joined before tracking.',
      base: cur.signups, rows: [
        { label: 'Phone', count: cur.phone, color: CATEGORICAL[0] },
        { label: 'Tablet', count: cur.tablet, color: CATEGORICAL[1] },
        { label: 'Computer', count: cur.computer, color: CATEGORICAL[2] },
        { label: 'Unknown', count: cur.unknown, color: MUTED_GRAY },
      ],
      footnote: smallN(cur.signups) ? `n=${cur.signups} — counts only, too few for shares.` : `Base: ${fmt(cur.signups)} sign-ups this window.`,
    },
    {
      title: 'Retention (Apple)', note: 'Of this window’s sign-ups who ever confirmed on a device — still installed vs. removed.',
      base: cur.everRegistered, rows: [
        { label: 'Still installed', count: cur.stillActive, color: GOOD },
        { label: 'Uninstalled', count: cur.uninstalledInCohort, color: BAD },
      ],
      footnote: smallN(cur.everRegistered) ? `n=${cur.everRegistered} — counts only, too few for shares.` : `Base: ${fmt(cur.everRegistered)} confirmed passes.`,
      extraNote: 'Google: not measurable — Google Wallet never reports an install or a removal.',
    },
  ] : [];

  return (
    <div style={{ minHeight: '100vh', background: 'var(--surface-secondary, #f8f6f4)', fontFamily: 'var(--font-sans)', display: 'flex', flexDirection: 'column' }}>
      <header style={{ height: 64, flexShrink: 0, display: 'flex', alignItems: 'center', gap: 16, padding: '0 28px',
                       background: 'var(--surface-base)', borderBottom: '1px solid var(--border-primary)' }}>
        <button type="button" onClick={back}
          style={{ display: 'flex', alignItems: 'center', gap: 6, border: 'none', background: 'transparent',
                   cursor: 'pointer', padding: '6px 10px 6px 6px', borderRadius: 9, color: 'var(--typography-secondary)',
                   fontFamily: 'var(--font-sans)', fontSize: 13, fontWeight: 600 }}>
          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2"
               strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 5l-7 7 7 7" /></svg>
          {selected ? 'Passes' : 'Home'}
        </button>
        <span style={{ width: 1, height: 20, background: 'var(--border-primary)' }} />
        <span style={{ fontSize: 15, fontWeight: 700, color: 'var(--typography-primary)' }}>
          {selected ? selected.name : 'Analytics'}
        </span>
        {selected && members && (
          <span style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--typography-tertiary)' }}>{fmt(members.length)} members all time</span>
        )}
      </header>

      <main style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '48px 24px' }}>
        <div style={{ width: '100%', maxWidth: 1240 }}>
          {!selected ? (
            <React.Fragment>
              <h1 style={{ fontSize: 26, fontWeight: 800, color: 'var(--typography-primary)', margin: '0 0 8px', letterSpacing: '-.01em' }}>
                Pick a pass
              </h1>
              <p style={{ fontSize: 14, color: 'var(--typography-secondary)', marginTop: 0, marginBottom: 28 }}>
                See how one of your published passes is performing.
              </p>
              {programs === null ? (
                <div style={{ color: 'var(--typography-tertiary)', fontSize: 13 }}>Loading passes…</div>
              ) : programs.length === 0 ? (
                <div style={{ padding: '32px 24px', borderRadius: 14, border: '1.5px dashed var(--border-primary)', textAlign: 'center', color: 'var(--typography-tertiary)', fontSize: 13 }}>
                  No published passes yet.
                </div>
              ) : (
                <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
                  {programs.map((p) => (
                    <button key={p.programId} type="button" onClick={() => setSelected(p)}
                      style={{ textAlign: 'left', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
                               padding: '14px 18px', borderRadius: 12, border: '1px solid var(--border-primary)',
                               background: 'var(--surface-base)', cursor: 'pointer', fontFamily: 'var(--font-sans)' }}>
                      <span style={{ fontSize: 14, fontWeight: 600, color: 'var(--typography-primary)' }}>{p.name}</span>
                      <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" style={{ color: 'var(--typography-tertiary)' }}><path d="M5 12h14M12 5l7 7-7 7" /></svg>
                    </button>
                  ))}
                </div>
              )}
            </React.Fragment>
          ) : (
            <React.Fragment>
              <div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 24, marginBottom: 6 }}>
                <h1 style={{ fontSize: 26, fontWeight: 800, letterSpacing: '-.015em', margin: 0, color: 'var(--typography-primary)' }}>Overview</h1>
                <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
                  <label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, color: 'var(--typography-tertiary)' }}>
                    Date range
                    <select value={range} onChange={(e) => setRange(e.target.value)} style={selectStyle}>
                      <option value="week">Past week</option>
                      <option value="month">Past month</option>
                      <option value="year">Past year</option>
                      <option value="all">All time</option>
                    </select>
                  </label>
                  <button type="button" onClick={() => { setReloading(true); loadOverview(selected.programId, range); }}
                    disabled={reloading || overview === null} title="Reload" aria-label="Reload"
                    style={{ display: 'flex', alignItems: 'center', gap: 6, border: '1px solid var(--border-primary)', background: 'var(--surface-base)',
                             borderRadius: 8, padding: '7px 10px', fontFamily: 'var(--font-sans)', fontSize: 12, fontWeight: 600,
                             color: 'var(--typography-secondary)', cursor: reloading || overview === null ? 'default' : 'pointer',
                             opacity: reloading || overview === null ? 0.6 : 1 }}>
                    <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round"
                      style={{ animation: reloading ? 'spin 0.8s linear infinite' : 'none' }}>
                      <path d="M21 12a9 9 0 1 1-2.64-6.36" /><path d="M21 3v6h-6" />
                    </svg>
                    {reloading ? 'Reloading…' : 'Reload'}
                  </button>
                </div>
              </div>
              <p style={{ fontSize: 13.5, color: 'var(--typography-primary)', opacity: 0.72, margin: '0 0 24px', maxWidth: 640, lineHeight: 1.5 }}>
                Every figure on this page is scoped to <strong style={{ fontWeight: 600 }}>{overview ? overview.windowLabel : '…'}</strong>, compared with the window before it.
              </p>
              {error && <div style={{ color: 'var(--critical,#c3223f)', fontSize: 13, marginBottom: 16 }}>{error}</div>}

              <div style={{ display: 'grid', gridTemplateColumns: 'repeat(12, 1fr)', gap: 20 }}>

                {overview === null ? (
                  <div style={{ gridColumn: 'span 12', color: 'var(--typography-tertiary)', fontSize: 13, padding: '20px 0' }}>Loading overview…</div>
                ) : (
                  kpis.map((k) => <KpiCard key={k.label} {...k} />)
                )}

                <div style={{ gridColumn: 'span 8', background: 'var(--surface-base)', border: '1px solid var(--border-primary)', borderRadius: 14, padding: 18, display: 'flex', flexDirection: 'column' }}>
                  <div style={{ fontSize: 15, fontWeight: 700, color: 'var(--typography-primary)' }}>Sign-ups over time</div>
                  <div style={{ fontSize: 11.5, color: 'var(--typography-tertiary)', marginTop: 2, marginBottom: 8 }}>
                    {signups ? `${fmt(signups.reduce((a, b) => a + b.count, 0))} sign-ups across this timeline` : ' '}
                  </div>
                  <div style={{ marginTop: 'auto' }}>
                    {signups === null ? (
                      <div style={{ color: 'var(--typography-tertiary)', fontSize: 13, padding: '40px 0', textAlign: 'center' }}>Loading…</div>
                    ) : (
                      <SignupsChart buckets={signups} range={range} accent={accent} />
                    )}
                  </div>
                </div>

                <div style={{ gridColumn: 'span 4', background: 'var(--surface-base)', border: '1px solid var(--border-primary)', borderRadius: 14, padding: 18, display: 'flex', flexDirection: 'column' }}>
                  <div style={{ fontSize: 15, fontWeight: 700, color: 'var(--typography-primary)' }}>Enrollment funnel</div>
                  <div style={{ fontSize: 11.5, color: 'var(--typography-tertiary)', margin: '2px 0 18px', lineHeight: 1.45 }}>
                    Invite sent → pass used. Downloads are Apple-only; Google never reports a save.
                  </div>
                  {/* flex:1 so the funnel (or its loading/empty stand-in) actually fills the
                      card's full height instead of sitting at its own small intrinsic size while
                      the grid row — stretched to match the taller sign-ups chart beside it —
                      leaves a big blank gap underneath. */}
                  {overview === null ? (
                    <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--typography-tertiary)', fontSize: 13 }}>Loading…</div>
                  ) : cur.sent === 0 ? (
                    <div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', border: '1.5px dashed var(--border-primary)', borderRadius: 12, padding: '28px 18px', textAlign: 'center', fontSize: 12.5, color: 'var(--typography-tertiary)', lineHeight: 1.5 }}>
                      No invites went out in this window.<br />Widen the date range to see the funnel.
                    </div>
                  ) : (
                    <FunnelShape accent={accent} stages={funnelStages} />
                  )}
                </div>

                {overview !== null && breakdowns.map((b) => <BreakdownCard key={b.title} {...b} />)}

                {members === null ? (
                  <div style={{ gridColumn: 'span 12', color: 'var(--typography-tertiary)', fontSize: 13 }}>Loading members…</div>
                ) : (
                  <MembersTable members={members} programId={selected.programId}
                    onDeleted={(memberId) => setMembers((cur) => (cur || []).filter((m) => m.memberId !== memberId))} />
                )}

                <BroadcastPanel programId={selected.programId} accent={accent} posterOnly={isPoster} />
              </div>
            </React.Fragment>
          )}
        </div>
      </main>
    </div>
  );
}

window.AnalyticsView = AnalyticsView;
