// VELOUR — internal admin dashboard. Six tabs, all reading live data and showing
// nothing rather than something plausible when a request fails:
//
//   Members        the cohort from api/admin/members — statuses, MRR, churn,
//                  onboarding answers. Also where a member is added on Premium
//                  at no charge (a pilot), and where one is deleted.
//   Insights       what the audience selects at onboarding: goals, tone, first
//                  ritual, and the write-your-own rituals as real quotes.
//   Blog           Jennifer's monthly letter. PAID, read on the site. Written
//                  once here and served to web and mobile from public.posts.
//   Newsletter     the FREE email — events and news. Composed here, handed to
//                  Kit as a draft broadcast. Not on the site at all; the two are
//                  different products and live in different tables.
//   Held together  the monthly opt-in shared ritual. No leaderboard, no ranking,
//                  no countdown. Authored here, read by both clients'
//                  HeldTogetherCard.
//   Emails         a preview of the transactional receipt, rendered against a
//                  real member, with the quote rotation it ships with.
//
// Reached at ?view=admin — NOT #admin, which a fragment never sends to a server
// and which Vercel's SSO bounce drops on the way through. No public nav link.
//
// GATED TWICE, SERVER-SIDE, on every request: a valid Supabase session AND an
// email on the ADMIN_EMAILS allowlist, re-checked in api/_auth.js. The sign-in
// screen here is the door; the lock is in the API.
const { Button, Avatar } = window.VelourDesignSystem_380ed4;

/* ---------- no sample members — see the note below ---------- */
// EMPTY ON PURPOSE. Fourteen invented members lived here — names, real-looking
// email addresses, plans, signup dates, cancellation reasons and invoice numbers
// — and useMembers() falls back to this array whenever /api/admin/members has not
// answered. That meant a failed request rendered a populated, plausible business:
// members, MRR, churn and signup bars, all indistinguishable from live data.
//
// Everything downstream already handles a real empty cohort — adStats returns
// zeros and "nobody, this month", the receipt preview says there is nothing to
// preview, SignupBars floors its scale at 1. So the honest fallback is nothing at
// all: an empty dashboard is a true statement about a product with no members.
const ADMIN_MEMBERS = [];

// The quote library — one line rides along on every receipt, rotated by the
// member's invoice count so no one gets the same line twice in a row.
const VELOUR_QUOTES = [
  "Small on purpose. The rest can come later.",
  "Nothing here is dramatic. That's the point.",
  "Day 14 feels like nothing. Day 180 feels like a different life.",
  "What you tend to, grows.",
  "A ritual is something you return to, not something you owe.",
  "The quiet days count too.",
  "One kept morning is a whole architecture, begun.",
  "Begin again quietly. That's still beginning.",
  "The coat worn one more time. The hour kept. It adds up.",
  "You don't need a new life. You need the one you have, held.",
  "Consistency isn't loud.",
  "Soft systems. Sharp life.",
];
// COUNTS invoiceCount, NOT the invoices array. api/admin/members.js returns
// `invoices: []` for everyone — the list lives in Stripe and has never been fetched —
// so counting the array meant every member showed quote 0 forever, including one who
// had been billed. invoiceCount is the real figure and is exactly what the receipt
// pipeline uses (`invoice_count % 12` in _email.js), so the preview now predicts the
// quote the member will actually get instead of a fixed one.
const quoteFor = (member) => VELOUR_QUOTES[(Number(member.invoiceCount) || 0) % VELOUR_QUOTES.length];

const STATUS_META = {
  active: { label: "Active", color: "var(--sage)", fill: "var(--sage-fill)", text: "var(--sage-text)" },
  trial: { label: "Trial", color: "var(--teal)", fill: "var(--teal-fill)", text: "var(--teal-text)" },
  past_due: { label: "Past due", color: "var(--amber)", fill: "var(--amber-fill)", text: "var(--amber-text)" },
  paused: { label: "Paused", color: "var(--terracotta)", fill: "var(--terracotta-fill)", text: "var(--terracotta-text)" },
  cancelled: { label: "Cancelled", color: "var(--stone)", fill: "var(--surface-sunken)", text: "var(--text-secondary)" },
};
// THESE NUMBERS MUST MATCH THE STRIPE PRICES, and nothing enforces it — the admin
// dashboard is a browser file and the prices live in Stripe, so a price change in
// one place is silent in the other. Annual read $8.50/mo until 2026-08-21, left
// over from a plan that no longer exists: Stripe's annual price is $84/yr and the
// pricing page has always said $7. Every annual member was inflating MRR by $1.50
// a month on the only screen that reports revenue — the last place a wrong number
// should live, because it is the one nobody cross-checks.
//
// LIVE since 3 Sep 2026, on acct_1U5xajR9hCNSFliw (VELOUR LLC):
//   monthly price_1UBlkcR9hCNSFliwzFNnafr0 $10/mo,
//   annual  price_1UBllmR9hCNSFliwdbri5EXz $84/yr = $7/mo.
// The previous pair ended in JYCBzpCjf1, which is the SANDBOX account, not this
// one. Stripe object ids embed their account, so that suffix is how you tell at a
// glance which account a price belongs to — and finding one here again would mean
// production is pointed at the sandbox.
const ANNUAL_MONTHLY = 7, MONTHLY = 10;
const PLAN_LABEL = { free: "Free", annual: `Annual · $${ANNUAL_MONTHLY}/mo`, monthly: `Monthly · $${MONTHLY}` };
// A comped member is on a real Premium plan and pays nothing, so the plan column
// must not print a price beside them. It says what is true instead — and the
// pilot's end date, because an open-ended comp and one that stops in November are
// different facts about the same membership.
const planLabel = (m) => {
  if (!m.comped) return PLAN_LABEL[m.plan] || "—";
  return m.pilotEndsOn ? `Pilot · no fee, to ${adFmtDate(m.pilotEndsOn)}` : "Pilot · no fee";
};
const FEEL_LABEL = { full: "Full, but scattered", quiet: "Quiet, but drifting", heavy: "Structured, but heavy", varies: "Honestly, it varies" };
const GOAL_META = {
  rituals: { name: "Rituals", color: "var(--sage)" }, wellness: { name: "Wellness", color: "var(--teal)" },
  wardrobe: { name: "Wardrobe", color: "var(--amber)" }, efficiency: { name: "Efficiency", color: "var(--terracotta)" },
};

// Null-safe: real rows carry fields the sample array always had, so a missing
// date must render as an honest dash. Without the guard, `null + "T00:00:00"`
// parses as "Invalid Date" and prints that to the screen.
const adFmtDate = (iso) => {
  if (!iso) return "—";
  const d = new Date(iso + "T00:00:00");
  return isNaN(d) ? "—" : d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
};
const adMoney = (n) => "$" + n.toFixed(2);

// <input type="datetime-local"> speaks LOCAL wall-clock with no zone, and the
// column is a timestamptz. These two convert between them without going through
// toISOString().slice(), which yields the UTC day and would shift the moment an
// author picked by up to a day either side of midnight.
function toLocalInput(iso) {
  if (!iso) return "";
  const d = new Date(iso);
  if (isNaN(d)) return "";
  const p = (n) => String(n).padStart(2, "0");
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
}
function fromLocalInput(v) {
  if (!v) return null;
  const d = new Date(v); // parsed as local time, which is what the author typed
  return isNaN(d) ? null : d.toISOString();
}

// Real members from api/admin/members. A context rather than prop-drilling: the
// tabs are siblings, and every one of them reads the same list.
//
// The fallback is the EMPTY array above, deliberately. In flight or on failure the
// dashboard shows nothing rather than something coherent — because "coherent" here
// used to mean fourteen fictional members, and a business that looks fine is the
// worst possible way to render a request that did not answer. Read loadError for
// why it is empty; empty alone never means the fetch succeeded.
const MembersContext = React.createContext(null);
function useMembers() {
  return React.useContext(MembersContext) || ADMIN_MEMBERS;
}

function adStats(members) {
  const active = members.filter((m) => m.status === "active");
  const trials = members.filter((m) => m.status === "trial");
  // Only paid plans contribute. The ternary this replaces treated every non-annual
  // plan as $10/mo, so an active FREE member — which every real signup now is —
  // silently added $10 of MRR that nobody is paying.
  // Comped members are excluded. They hold plan 'monthly' so that every
  // entitlement check grants them Premium through the ordinary path — which means
  // that without this test each pilot would add $10 of revenue nobody is paying,
  // the third time this one line has been able to invent money. See migration 013.
  const mrr = active.reduce((s, m) => s + (m.comped ? 0 : m.plan === "annual" ? ANNUAL_MONTHLY : m.plan === "monthly" ? MONTHLY : 0), 0);
  // Current calendar month, not the month the sample data happened to sit in.
  const now = new Date();
  const thisMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
  const cancelledThisMonth = members.filter((m) => m.cancelledAt && m.cancelledAt.startsWith(thisMonth)).length;
  return [
    { label: "Members", value: String(members.length), sub: `${active.length} active · ${trials.length} in trial` },
    { label: "MRR", value: adMoney(mrr), sub: "monthly recurring", serif: true },
    // `filter: "churned"` makes this tile a control rather than a readout. A count
    // with no way to reach the people it counts is the shape of most admin
    // dashboards and it is useless: "1 churned" and then a list of seven, every one
    // of them reading Active, with the answer only visible by opening each in turn.
    // The number IS the question; clicking it should answer it.
    {
      label: `Churned, ${now.toLocaleDateString("en-US", { month: "long" })}`,
      value: String(cancelledThisMonth),
      sub: cancelledThisMonth ? "click to see who" : "nobody, this month",
      filter: cancelledThisMonth ? "churned" : null,
    },
    // Genuinely not computable yet: conversion needs trial-to-active history that
    // nothing records. A dash beside real zeros is honest; an invented 78% that
    // renders identically to the live stats is not.
    { label: "Trial → paid", value: "—", sub: "needs conversion history" },
  ];
}

// Signups per week, last 8 weeks — same inline-SVG bar language as the product dashboards.
// Eight weekly buckets ending with the current week, counted from real signup
// dates. Derived rather than fixed: hardcoded May–June bars sitting beside live
// member numbers were the one thing on this page that could quietly mislead.
function SignupBars({ members }) {
  const weeks = React.useMemo(() => {
    const weekStart = new Date();
    weekStart.setHours(0, 0, 0, 0);
    weekStart.setDate(weekStart.getDate() - weekStart.getDay()); // Sunday of this week
    const buckets = [];
    for (let i = 7; i >= 0; i--) {
      const from = new Date(weekStart);
      from.setDate(from.getDate() - i * 7);
      const to = new Date(from);
      to.setDate(to.getDate() + 7);
      buckets.push({ from, to, label: from.toLocaleDateString("en-US", { month: "short", day: "numeric" }), count: 0 });
    }
    (members || []).forEach((m) => {
      if (!m.signedUp) return;
      const d = new Date(m.signedUp + "T00:00:00"); // local, matching the app's day keys
      if (isNaN(d)) return;
      const b = buckets.find((x) => d >= x.from && d < x.to);
      if (b) b.count += 1;
    });
    return buckets;
  }, [members]);

  const max = Math.max(...weeks.map((w) => w.count), 1);
  return (
    <div style={{ display: "flex", gap: 8, alignItems: "flex-end", height: 64 }} aria-label="Signups per week">
      {weeks.map((w, i) => (
        <div key={i} style={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center", gap: 6, height: "100%", justifyContent: "flex-end" }} title={`Week of ${w.label}: ${w.count}`}>
          <i style={{ width: "100%", maxWidth: 26, height: `${(w.count / max) * 100}%`, minHeight: w.count ? 4 : 2, borderRadius: "5px 5px 2px 2px", background: w.count ? "var(--sage)" : "var(--border)", display: "block" }}></i>
          <span style={{ fontSize: 9, color: "var(--text-tertiary)", letterSpacing: "0.04em", whiteSpace: "nowrap" }}>{w.label.split(" ")[1]}</span>
        </div>
      ))}
    </div>
  );
}

function StatusChip({ status }) {
  // A member whose status the API could not determine renders as "—" rather than
  // crashing this component or borrowing another status's colour. Reaching for
  // STATUS_META[null] threw a TypeError on s.fill, which is how one bad embed
  // would have taken the whole members table down instead of one cell.
  const s = STATUS_META[status] || { label: "—", color: "var(--border-strong)", fill: "var(--surface-sunken)", text: "var(--text-tertiary)" };
  return (
    <span style={{ display: "inline-flex", alignItems: "center", gap: 6, fontSize: 11.5, fontWeight: 500, letterSpacing: "0.04em", background: s.fill, color: s.text, borderRadius: 999, padding: "4px 11px", whiteSpace: "nowrap" }}>
      <i style={{ width: 6, height: 6, borderRadius: "50%", background: s.color }}></i>{s.label}
    </span>
  );
}

/* ---------- member detail panel ---------- */
function MemberDetail({ m, onClose, onMemberChanged, onMemberDeleted }) {
  // Hooks before the early return, or the order changes between renders the
  // moment the panel opens on a member and then on nobody.
  const [ending, setEnding] = React.useState(false);
  const [confirmEnd, setConfirmEnd] = React.useState(false);
  const [compOpen, setCompOpen] = React.useState(false);
  const [compNote, setCompNote] = React.useState("");
  const [compEnds, setCompEnds] = React.useState("");
  const [granting, setGranting] = React.useState(false);
  const [grantError, setGrantError] = React.useState(null);
  const [endError, setEndError] = React.useState(null);
  const [deleting, setDeleting] = React.useState(false);
  const [deleteOpen, setDeleteOpen] = React.useState(false);
  const [typedEmail, setTypedEmail] = React.useState("");
  const [deleteError, setDeleteError] = React.useState(null);
  // Invoices live in Stripe and are read when this panel opens, not carried on
  // the members list — see invoicesFor() in api/admin/members.js for why.
  // undefined = not asked yet, null = the lookup failed, [] = genuinely none.
  const [invoices, setInvoices] = React.useState(undefined);
  const [invoiceNote, setInvoiceNote] = React.useState(null);
  React.useEffect(() => {
    setConfirmEnd(false); setEndError(null);
    setDeleteOpen(false); setTypedEmail(""); setDeleteError(null);
    setInvoices(undefined); setInvoiceNote(null);
    if (!m) return;
    let cancelled = false;
    window.VelourAdminAuth.fetch(`/api/admin/members?invoices=${encodeURIComponent(m.id)}`)
      .then((r) => (r.ok ? r.json() : Promise.reject(new Error("HTTP " + r.status))))
      .then((body) => {
        if (cancelled) return;
        setInvoices(body.invoices || []);
        setInvoiceNote(body.reason || null);
      })
      .catch(() => { if (!cancelled) setInvoices(null); });
    return () => { cancelled = true; };
  }, [m && m.id]);

  // Typing the address rather than clicking twice. This is the one control here
  // that destroys somebody's data outright, and two clicks in the same spot is
  // the shape of an accident — the second one lands wherever the first did.
  const removeMember = async () => {
    if (deleting) return;
    setDeleting(true); setDeleteError(null);
    try {
      const res = await window.VelourAdminAuth.fetch(
        `/api/admin/members?id=${encodeURIComponent(m.id)}&email=${encodeURIComponent(m.email)}`,
        { method: "DELETE" }
      );
      const body = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(body.error || "That did not go through.");
      onMemberDeleted(m.id, body);
    } catch (err) {
      setDeleteError(err.message);
    } finally {
      setDeleting(false);
    }
  };

  // MOVING AN EXISTING MEMBER ONTO A COMP.
  //
  // The API has always been able to do this — createAuthUser in api/admin/
  // members.js treats an address that already exists as the ordinary case, with
  // a comment saying so ("someone already on free agrees to pilot"), and the
  // POST upserts the subscription either way. Only the panel could not: it
  // offered "End the pilot" to a comped member and, to everyone else, nothing
  // but "Delete this member". Granting a comp meant creating a SECOND account at
  // a different address, which is how a free member with real data ends up
  // stranded beside a comped duplicate.
  //
  // Same endpoint and same body as "+ Add member", minus the link: somebody who
  // is already a member does not need a fresh sign-in link to keep using the
  // account they are signed into. The pilot end date is optional here for the
  // same reason it is optional there — an open-ended pilot is a real choice.
  const grantComp = async () => {
    if (granting) return;
    setGranting(true); setGrantError(null);
    try {
      const res = await window.VelourAdminAuth.fetch("/api/admin/members", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          email: m.email,
          name: m.name,
          note: compNote.trim() || null,
          endsOn: compEnds || null,
          // They are already a member; a second link would be noise.
          sendLink: false,
        }),
      });
      const body = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(body.error || "That did not go through.");
      onMemberChanged(body.member);
      setCompOpen(false); setCompNote(""); setCompEnds("");
    } catch (err) {
      setGrantError(err.message);
    } finally {
      setGranting(false);
    }
  };

  const endPilot = async () => {
    if (ending) return;
    setEnding(true); setEndError(null);
    try {
      const res = await window.VelourAdminAuth.fetch(`/api/admin/members?id=${encodeURIComponent(m.id)}`, {
        method: "PATCH",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ comped: false }),
      });
      const body = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(body.error || "That did not go through.");
      onMemberChanged(body.member);
    } catch (err) {
      setEndError(err.message);
    } finally {
      setEnding(false); setConfirmEnd(false);
    }
  };

  if (!m) return null;
  return (
    <div style={{ position: "fixed", inset: 0, zIndex: 60, display: "flex", justifyContent: "flex-end", background: "rgba(44,44,42,0.28)" }} onClick={onClose}>
      <div onClick={(e) => e.stopPropagation()} style={{ width: "min(440px, 94vw)", background: "var(--surface-card)", height: "100%", overflowY: "auto", padding: "30px 28px", boxShadow: "-24px 0 60px -30px rgba(44,44,42,.4)" }}>
        <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
          <div style={{ display: "flex", gap: 12, alignItems: "center" }}>
            <Avatar name={m.name} ring="rituals" />
            <div>
              <div style={{ fontFamily: "var(--font-serif)", fontSize: 20, color: "var(--text-primary)" }}>{m.name}</div>
              <div style={{ fontSize: 12.5, color: "var(--text-tertiary)" }}>{m.email}</div>
            </div>
          </div>
          <button onClick={onClose} style={{ border: "none", background: "none", cursor: "pointer", fontSize: 12, letterSpacing: "0.12em", textTransform: "uppercase", color: "var(--text-tertiary)", fontFamily: "var(--font-sans)" }}>Close</button>
        </div>

        <div style={{ display: "flex", gap: 8, alignItems: "center", marginTop: 18, flexWrap: "wrap" }}>
          <StatusChip status={m.status} />
          <span style={{ fontSize: 12.5, color: "var(--text-secondary)" }}>{planLabel(m)}</span>
        </div>
        <div style={{ fontSize: 12.5, color: "var(--text-tertiary)", marginTop: 8 }}>
          Signed up {adFmtDate(m.signedUp)} · last active {adFmtDate(m.lastActive)}{m.cancelledAt ? ` · cancelled ${adFmtDate(m.cancelledAt)}` : ""}
        </div>

        {/* The pilot, and the way out of it. A comp with no visible end is a
            membership someone has to remember about; this is the reminder and the
            control in the same place. */}
        {m.comped && (
          <div style={{ marginTop: 16, border: "1px solid var(--border)", borderRadius: 12, padding: "13px 15px", background: "var(--surface-sunken)" }}>
            <div style={{ fontSize: 10.5, letterSpacing: "0.14em", textTransform: "uppercase", color: "var(--text-tertiary)", marginBottom: 6 }}>Premium at no charge</div>
            <div style={{ fontSize: 13.5, color: "var(--text-secondary)", lineHeight: 1.6 }}>
              {m.pilotEndsOn ? `Runs until ${adFmtDate(m.pilotEndsOn)}, then returns to free on its own.` : "Open-ended — it runs until you end it."}
              {m.compNote ? ` ${m.compNote}` : ""}
            </div>
            {endError && <div style={{ fontSize: 12.5, color: "var(--terracotta-text)", marginTop: 8 }}>{endError}</div>}
            <button onClick={() => (confirmEnd ? endPilot() : setConfirmEnd(true))} disabled={ending}
              style={{ ...adGhostBtnStyle, marginTop: 10, color: confirmEnd ? "var(--terracotta-text)" : "var(--text-secondary)", borderColor: confirmEnd ? "var(--terracotta)" : "var(--border)" }}>
              {ending ? "Ending…" : confirmEnd ? "End it and move them to free?" : "End the pilot"}
            </button>
          </div>
        )}

        {/* The other half of the pilot control. Shown only to a member who is not
            already comped — and never to one on a live paid subscription, because
            the API refuses that case (cancel it in Stripe first) and offering a
            button that always errors is worse than offering none. */}
        {!m.comped && !(m.status === "active" && m.plan && m.plan !== "free") && (
          <div style={{ marginTop: 16, border: "1px solid var(--border)", borderRadius: 12, padding: "13px 15px", background: "var(--surface-sunken)" }}>
            <div style={{ fontSize: 10.5, letterSpacing: "0.14em", textTransform: "uppercase", color: "var(--text-tertiary)", marginBottom: 6 }}>Premium at no charge</div>
            {!compOpen ? (
              <>
                <div style={{ fontSize: 13.5, color: "var(--text-secondary)", lineHeight: 1.6 }}>
                  Give this member the full paid product at no charge. Nothing is billed, no
                  card is asked for, and they are not counted in MRR.
                </div>
                <button onClick={() => setCompOpen(true)} style={{ ...adGhostBtnStyle, marginTop: 10 }}>
                  Move them to a pilot
                </button>
              </>
            ) : (
              <>
                <label style={{ fontSize: 10.5, letterSpacing: "0.14em", textTransform: "uppercase", color: "var(--text-tertiary)", display: "block", marginBottom: 5 }}>Pilot ends</label>
                <input type="date" value={compEnds} onChange={(e) => setCompEnds(e.target.value)}
                  style={{ width: "100%", padding: "9px 11px", borderRadius: 10, border: "1px solid var(--border)", background: "var(--surface-card)", fontFamily: "var(--font-sans)", fontSize: 13.5, color: "var(--text-primary)" }} />
                <div style={{ fontSize: 12, color: "var(--text-tertiary)", marginTop: 5 }}>Leave empty and the pilot runs until you end it.</div>
                <label style={{ fontSize: 10.5, letterSpacing: "0.14em", textTransform: "uppercase", color: "var(--text-tertiary)", display: "block", margin: "12px 0 5px" }}>Note</label>
                <input type="text" value={compNote} onChange={(e) => setCompNote(e.target.value)} placeholder="Why, for whoever reads this later"
                  style={{ width: "100%", padding: "9px 11px", borderRadius: 10, border: "1px solid var(--border)", background: "var(--surface-card)", fontFamily: "var(--font-sans)", fontSize: 13.5, color: "var(--text-primary)" }} />
                {grantError && <div style={{ fontSize: 12.5, color: "var(--terracotta-text)", marginTop: 8 }}>{grantError}</div>}
                <div style={{ display: "flex", gap: 8, marginTop: 12, flexWrap: "wrap" }}>
                  <button onClick={grantComp} disabled={granting}
                    style={{ background: "var(--ink)", color: "var(--parchment)", border: "none", borderRadius: 999, padding: "9px 18px", fontFamily: "var(--font-sans)", fontSize: 13.5, fontWeight: 500, cursor: granting ? "default" : "pointer", opacity: granting ? 0.7 : 1 }}>
                    {granting ? "Setting it up…" : "Give them Premium"}
                  </button>
                  <button onClick={() => { setCompOpen(false); setGrantError(null); }} style={adGhostBtnStyle}>Cancel</button>
                </div>
              </>
            )}
          </div>
        )}

        {m.cancelReason && (
          <div style={{ marginTop: 16, border: "1px solid var(--border)", borderRadius: 12, padding: "12px 14px", background: "var(--surface-sunken)" }}>
            <div style={{ fontSize: 10.5, letterSpacing: "0.14em", textTransform: "uppercase", color: "var(--text-tertiary)", marginBottom: 5 }}>Why they left</div>
            <div style={{ fontFamily: "var(--font-serif)", fontStyle: "italic", fontSize: 14.5, color: "var(--text-primary)" }}>“{m.cancelReason}”</div>
          </div>
        )}

        <div style={{ marginTop: 26 }}>
          <div style={{ fontSize: 11, letterSpacing: "0.16em", textTransform: "uppercase", color: "var(--text-tertiary)", marginBottom: 12 }}>What they told us at onboarding</div>
          <div style={{ display: "flex", flexWrap: "wrap", gap: 7, marginBottom: 12 }}>
            {m.onboarding.goals.map((g) => (
              <span key={g} style={{ fontSize: 11, letterSpacing: "0.08em", textTransform: "uppercase", color: GOAL_META[g].color, border: `1px solid color-mix(in srgb, ${GOAL_META[g].color} 40%, transparent)`, borderRadius: 999, padding: "3px 10px" }}>{GOAL_META[g].name}</span>
            ))}
          </div>
          <div style={{ fontSize: 13.5, color: "var(--text-secondary)", lineHeight: 1.7 }}>
            <div>Days felt: <span style={{ fontFamily: "var(--font-serif)", fontStyle: "italic", color: "var(--text-primary)" }}>{FEEL_LABEL[m.onboarding.feel] || m.onboarding.feel || "—"}</span></div>
            <div>First ritual: <span style={{ fontFamily: "var(--font-serif)", fontStyle: "italic", color: "var(--text-primary)" }}>{m.onboarding.ritual || "—"}</span></div>
          </div>
        </div>

        <div style={{ marginTop: 26 }}>
          <div style={{ fontSize: 11, letterSpacing: "0.16em", textTransform: "uppercase", color: "var(--text-tertiary)", marginBottom: 12 }}>Invoices</div>
          {invoices === undefined ? (
            <div style={{ fontSize: 13.5, color: "var(--text-tertiary)" }}>Reading Stripe…</div>
          ) : invoices === null ? (
            // A failed lookup is not an empty ledger, and must not read as one.
            <div style={{ fontSize: 13.5, color: "var(--amber-text)", lineHeight: 1.6 }}>
              Stripe would not answer, so this is blank for the wrong reason.
              {(Number(m.invoiceCount) || 0) > 0 ? ` The subscription row counts ${m.invoiceCount}.` : ""}
            </div>
          ) : invoices.length === 0 ? (
            <div style={{ fontSize: 13.5, color: "var(--text-tertiary)", fontStyle: "italic", fontFamily: "var(--font-serif)" }}>
              {invoiceNote === "no-stripe-customer"
                ? "Stripe has never heard of this member — true of every free and every comped account."
                : "None yet — nothing has been billed on this account."}
            </div>
          ) : invoices.map((inv) => (
            <div key={inv.n} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "10px 0", borderBottom: "1px solid var(--divider)", fontSize: 13.5 }}>
              <span style={{ color: "var(--text-secondary)" }}>{inv.n} · {adFmtDate(inv.date)}</span>
              <span style={{ display: "flex", gap: 10, alignItems: "center" }}>
                <span style={{ fontFamily: "var(--font-serif)", color: "var(--text-primary)" }}>{adMoney(inv.amount)}</span>
                <span style={{ fontSize: 10.5, letterSpacing: "0.08em", textTransform: "uppercase", color: inv.status === "paid" ? "var(--sage)" : "var(--terracotta)" }}>{inv.status}</span>
                {/* Stripe is the record; this is a summary of it. The link goes
                    to the thing itself rather than asking anyone to trust the row. */}
                {inv.url && (
                  <a href={inv.url} target="_blank" rel="noopener noreferrer" title="Open in Stripe"
                    style={{ fontSize: 11, color: "var(--text-tertiary)", textDecoration: "none" }}>open</a>
                )}
              </span>
            </div>
          ))}
          <div style={{ marginTop: 14, fontSize: 12, color: "var(--text-tertiary)" }}>Next receipt would carry: <span style={{ fontFamily: "var(--font-serif)", fontStyle: "italic", color: "var(--text-secondary)" }}>“{quoteFor(m)}”</span></div>
        </div>

        {/* Deleting a member. Last in the panel, behind its own disclosure, and it
            asks for the address to be typed — this removes the account and every
            row belonging to it, and there is nothing behind it. */}
        <div style={{ marginTop: 30, paddingTop: 20, borderTop: "1px solid var(--divider)" }}>
          {!deleteOpen ? (
            <button onClick={() => setDeleteOpen(true)} style={{ ...adGhostBtnStyle, color: "var(--text-tertiary)" }}>Delete this member</button>
          ) : (
            <div>
              <div style={{ fontSize: 13.5, lineHeight: 1.6, color: "var(--text-secondary)" }}>
                This removes the account and everything in it — rituals, wellness, closet, stored photos. It cannot be undone.
              </div>
              <div style={{ fontSize: 12.5, color: "var(--text-tertiary)", margin: "12px 0 7px" }}>
                Type <span style={{ color: "var(--text-primary)" }}>{m.email}</span> to confirm.
              </div>
              <input value={typedEmail} onChange={(e) => setTypedEmail(e.target.value)} placeholder={m.email}
                style={{ ...adInputStyle, width: "100%", boxSizing: "border-box" }} />
              {deleteError && <div style={{ fontSize: 12.5, lineHeight: 1.5, color: "var(--terracotta-text)", marginTop: 10 }}>{deleteError}</div>}
              <div style={{ display: "flex", gap: 10, marginTop: 14 }}>
                <button onClick={removeMember}
                  disabled={deleting || typedEmail.trim().toLowerCase() !== String(m.email).toLowerCase()}
                  style={{
                    padding: "9px 18px", borderRadius: 999, border: "none", fontFamily: "var(--font-sans)", fontSize: 13, fontWeight: 500,
                    background: "var(--terracotta)", color: "#fff",
                    cursor: deleting || typedEmail.trim().toLowerCase() !== String(m.email).toLowerCase() ? "default" : "pointer",
                    opacity: deleting || typedEmail.trim().toLowerCase() !== String(m.email).toLowerCase() ? 0.4 : 1,
                  }}>{deleting ? "Deleting…" : "Delete for good"}</button>
                <button onClick={() => { setDeleteOpen(false); setTypedEmail(""); setDeleteError(null); }} style={adGhostBtnStyle}>Cancel</button>
              </div>
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

/* ---------- emails tab: live merge preview of the invoice template ---------- */
// The three emails VELOUR sends, and who sends each.
//
// Only the receipt was previewable before. The cancellation email GOES OUT FOR
// REAL on Stripe's subscription-deleted webhook and nobody could see it before a
// member did, and the sign-in link is the first email a new member ever receives.
// An email you cannot look at is one you find out about from the person who got it.
//
// Each carries its own merge map, because the placeholder sets differ and the
// merge has to match what actually renders it — api/_email.js for the first two,
// Supabase for the third. Getting this wrong would make the preview a fiction,
// which is worse than no preview.
const ADMIN_EMAIL_TEMPLATES = [
  {
    key: "invoice_receipt.html",
    label: "Receipt",
    needsMember: true,
    sender: "Resend, on Stripe's invoice.paid webhook",
    note: "The quote rotates by the member's invoice count, so no one reads the same line twice in a row.",
    merge: (m) => {
      // Nothing has ever pulled invoices from Stripe, so there is no real one to
      // render. These are named as a preview's stand-in rather than passed off as
      // this member's billing history.
      // velourDateISO, not toISOString().slice(0,10) — that yields the UTC day,
      // which is a day early for anyone behind Greenwich all evening. The rule is
      // repo-wide and this preview is not an exception to it.
      const today = window.velourDateISO ? window.velourDateISO(new Date()) : null;
      const inv = { n: "VLR-0000", amount: m.plan === "annual" ? ANNUAL_MONTHLY * 12 : MONTHLY, date: today };
      return {
        "{{member_name}}": (m.name || "").split(" ")[0] || "there",
        "{{plan_name}}": PLAN_LABEL[m.plan] || "Membership",
        "{{amount}}": adMoney(inv.amount),
        "{{invoice_number}}": inv.n,
        "{{invoice_date}}": adFmtDate(inv.date),
        "{{next_renewal}}": "—",
        "{{quote}}": quoteFor(m),
      };
    },
  },
  {
    key: "cancellation.html",
    label: "Cancellation",
    needsMember: true,
    sender: "Resend, on Stripe's customer.subscription.deleted webhook",
    note: "Sent when a member cancels. It names the day access actually stops and the refund the terms promise — the part of cancelling that used to be silence.",
    // Exactly the four renderCancellation() fills. The template's other {{…}}
    // strings live inside HTML comments and never render.
    merge: (m) => ({
      "{{member_name}}": (m.name || "").split(" ")[0] || "there",
      "{{plan_phrase}}": m.plan === "annual" ? "annual membership" : "monthly membership",
      "{{ends_on}}": m.cancelledAt ? adFmtDate(m.cancelledAt) : "the end of the period",
      "{{refund_line}}": "You keep everything until then, and nothing else will be charged.",
    }),
  },
  {
    key: "auth_magic_link.html",
    label: "Sign-in link",
    // The only one that needs nobody: it fills a URL and a greeting, and it is
    // the first email a new member ever gets. Being unable to look at it BECAUSE
    // there are no members yet is exactly backwards.
    needsMember: false,
    sender: "Supabase Auth, not Resend",
    note: "The first email a new member ever receives. Supabase renders it and fills {{ .ConfirmationURL }} — this file is the source of truth pasted into the project's auth templates, so a change here only ships once it is pasted there too.",
    merge: () => ({ "{{ .ConfirmationURL }}": "https://tryvelour.com/#example-sign-in-link" }),
  },
];

const mergeTemplate = (html, map) =>
  Object.entries(map).reduce((out, [k, v]) => out.split(k).join(v), html);

function EmailsTab() {
  // No initial id: it used to default to the sample member "m02", which does not
  // exist once members are real, so find() returned undefined and the next line
  // threw. Null means "nothing chosen yet" and the first member stands in.
  const [memberId, setMemberId] = React.useState(null);
  const [tplKey, setTplKey] = React.useState(ADMIN_EMAIL_TEMPLATES[0].key);
  const [tpl, setTpl] = React.useState(null);
  const [err, setErr] = React.useState(null);
  const chosen = ADMIN_EMAIL_TEMPLATES.find((t) => t.key === tplKey) || ADMIN_EMAIL_TEMPLATES[0];
  React.useEffect(() => {
    let cancelled = false;
    setTpl(null); setErr(null);
    // ABSOLUTE, for the same reason the hero and blog images had to be. This
    // resolves to /src/emails/… only from the prototype's own depth; the deployed
    // admin is served at the site root, where it became /emails/… and 404'd. So
    // all three previews showed "Could not load…" in production and rendered
    // perfectly on every local check. Third instance of this shape found on
    // 4 Sep 2026 — if a path in this repo starts with ../, it is a bet on which
    // URL the document was served from, and this one is served from two.
    fetch(`/src/emails/${tplKey}`, { cache: "no-store" })
      .then((r) => { if (!r.ok) throw new Error(r.status); return r.text(); })
      .then((t) => { if (!cancelled) setTpl(t); })
      .catch((e) => { if (!cancelled) setErr(String(e)); });
    return () => { cancelled = true; };
  }, [tplKey]);
  const members = useMembers();
  const m = members.find((x) => x.id === memberId) || members[0] || null;

  // A real cohort can be empty. There is nothing to preview a RECEIPT against, and
  // inventing a member to render one for would be the same mistake as the fake
  // bars. But this used to return before the template picker rendered, which took
  // the sign-in link down with it — the one email that needs no member and the one
  // you would most want to check while you still have none.
  // The picker comes with it, so the way out of this state is visible from inside
  // it — otherwise an empty cohort hides the one email that does not need one.
  const picker = (
    <div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 18 }}>
      {ADMIN_EMAIL_TEMPLATES.map((t) => (
        <button key={t.key} onClick={() => setTplKey(t.key)} style={{
          padding: "7px 13px", borderRadius: 999, fontSize: 12.5, cursor: "pointer", fontFamily: "var(--font-sans)",
          border: `1px solid ${tplKey === t.key ? "var(--ink)" : "var(--border)"}`,
          background: tplKey === t.key ? "var(--ink)" : "transparent",
          color: tplKey === t.key ? "var(--parchment)" : "var(--text-secondary)",
        }}>{t.label}</button>
      ))}
    </div>
  );

  if (!m && chosen.needsMember) {
    return (
      <div style={{ maxWidth: 560 }}>
        <div style={{ fontSize: 11, letterSpacing: "0.16em", textTransform: "uppercase", color: "var(--text-tertiary)", marginBottom: 10 }}>Email</div>
        {picker}
        <div style={{ background: "var(--surface-card)", border: "1px solid var(--border)", borderRadius: 16, padding: "34px 28px", fontSize: 14, lineHeight: 1.65, color: "var(--text-tertiary)" }}>
          The {chosen.label.toLowerCase()} email is rendered against a real member, and there are none yet. It fills in with the first signup — the sign-in link above needs nobody and can be read now.
        </div>
      </div>
    );
  }

  const merged = tpl ? mergeTemplate(tpl, chosen.merge(m)) : null;
  return (
    <div style={{ display: "grid", gridTemplateColumns: "300px 1fr", gap: 28, alignItems: "start" }}>
      <div>
        <div style={{ fontSize: 11, letterSpacing: "0.16em", textTransform: "uppercase", color: "var(--text-tertiary)", marginBottom: 10 }}>Email</div>
        {picker}
        {m && <React.Fragment>
        <div style={{ fontSize: 11, letterSpacing: "0.16em", textTransform: "uppercase", color: "var(--text-tertiary)", marginBottom: 10 }}>Preview as</div>
        <select value={m.id} onChange={(e) => setMemberId(e.target.value)} style={{ width: "100%", padding: "10px 12px", borderRadius: 10, border: "1px solid var(--border)", background: "var(--surface-card)", fontFamily: "var(--font-sans)", fontSize: 14, color: "var(--text-primary)" }}>
          {members.map((x) => <option key={x.id} value={x.id}>{x.name} — {(STATUS_META[x.status] || { label: "—" }).label}</option>)}
        </select>
        </React.Fragment>}
        <div style={{ marginTop: 18, fontSize: 12.5, color: "var(--text-tertiary)", lineHeight: 1.65 }}>
          <div style={{ color: "var(--text-secondary)", marginBottom: 4 }}>Sent by {chosen.sender}.</div>
          {chosen.note}
        </div>
        {/* The rotation only exists in the receipt, so it only shows there. */}
        {m && tplKey === "invoice_receipt.html" && (
        <React.Fragment>
        <div style={{ marginTop: 18 }}>
          <div style={{ fontSize: 11, letterSpacing: "0.16em", textTransform: "uppercase", color: "var(--text-tertiary)", marginBottom: 10 }}>This member's quote</div>
          <div style={{ fontFamily: "var(--font-serif)", fontStyle: "italic", fontSize: 15.5, lineHeight: 1.5, color: "var(--sage)" }}>“{quoteFor(m)}”</div>
        </div>
        <div style={{ marginTop: 18 }}>
          <div style={{ fontSize: 11, letterSpacing: "0.16em", textTransform: "uppercase", color: "var(--text-tertiary)", marginBottom: 10 }}>Quote library · {VELOUR_QUOTES.length}</div>
          <div style={{ display: "flex", flexDirection: "column", gap: 6, fontSize: 12.5, color: "var(--text-secondary)", fontFamily: "var(--font-serif)", fontStyle: "italic", lineHeight: 1.45 }}>
            {VELOUR_QUOTES.map((q) => <span key={q}>“{q}”</span>)}
          </div>
        </div>
        </React.Fragment>
        )}
      </div>
      <div style={{ border: "1px solid var(--border)", borderRadius: 16, overflow: "hidden", background: "#EFEBE4" }}>
        <div style={{ padding: "10px 16px", fontSize: 11.5, letterSpacing: "0.1em", textTransform: "uppercase", color: "var(--text-tertiary)", borderBottom: "1px solid var(--border)", display: "flex", justifyContent: "space-between" }}>
          <span>{chosen.key} · merged preview</span><span>{m ? `to: ${m.email}` : "no member needed"}</span>
        </div>
        {err ? (
          <div style={{ padding: 30, fontSize: 13.5, color: "var(--terracotta)" }}>Could not load src/emails/{chosen.key} ({err}).</div>
        ) : merged ? (
          <iframe title={`${chosen.label} email preview`} srcDoc={merged} style={{ width: "100%", height: 760, border: "none", display: "block", background: "transparent" }} />
        ) : (
          <div style={{ padding: 30, fontSize: 13.5, color: "var(--text-tertiary)" }}>Loading template…</div>
        )}
      </div>
    </div>
  );
}

/* ---------- insights tab: what the audience is telling us at onboarding ---------- */
// The four suggested rituals from the onboarding flow — anything else typed is a
// custom ritual, surfaced separately as real voice-of-member quotes.
const CANONICAL_RITUALS = new Set([
  "Morning pages, 10 minutes",
  "Stretch before coffee",
  "Phone stays outside the bedroom",
  "A ten-minute walk, no headphones",
]);

// A MEMBER WHO NEVER ANSWERED IS NOT AN ANSWER.
//
// This counted every member as a respondent, and it only became visible on
// 4 Sep 2026, when api/signup.js finally started writing onboarding_answers —
// until then the table was empty and these panels were uniformly blank, so the
// partial case had never once been rendered. With some members answering and
// some not, three things went wrong at once:
//
//   feelCounts[m.onboarding.feel] with feel === null keyed the bucket "null",
//   so the panel drew an unlabelled bar and the letter summary read: "null" was
//   the most common way people described their days (57%). That sentence sits
//   under a "Copy for Jennifer" button, which is the one path in this dashboard
//   that ends in something a member reads.
//
//   customRituals collected members with no ritual at all, printing “” with a
//   real person's name under it.
//
//   And every percentage divided by the member count rather than by the number
//   who actually answered, which understates every real figure.
//
// Non-answers are now excluded rather than bucketed, and the denominator is the
// people who answered. `scopedCount` stays the member count so the header can
// still say how many of them that was.
function computeInsights(members, windowKey) {
  const cutoff = new Date();
  cutoff.setDate(cutoff.getDate() - 30);
  const scoped = windowKey === "month" ? members.filter((m) => new Date(m.signedUp + "T00:00:00") >= cutoff) : members;

  const answered = scoped.filter((m) => {
    const o = m.onboarding || {};
    return !!o.feel || !!o.ritual || (o.goals || []).length > 0;
  });
  const total = answered.length || 1;

  const goalCounts = {};
  Object.keys(GOAL_META).forEach((k) => { goalCounts[k] = 0; });
  answered.forEach((m) => (m.onboarding.goals || []).forEach((g) => { if (GOAL_META[g]) goalCounts[g] = (goalCounts[g] || 0) + 1; }));

  const feelCounts = {};
  Object.keys(FEEL_LABEL).forEach((k) => { feelCounts[k] = 0; });
  // Only a value we can name. An unrecognised one is dropped rather than drawn
  // as a bar with no label beside it.
  answered.forEach((m) => {
    const f = m.onboarding.feel;
    if (f && FEEL_LABEL[f]) feelCounts[f] = (feelCounts[f] || 0) + 1;
  });

  const ritualCounts = {};
  const customRituals = [];
  answered.forEach((m) => {
    const r = m.onboarding.ritual;
    if (!r) return;                                   // never wrote one — not a quote
    if (CANONICAL_RITUALS.has(r)) ritualCounts[r] = (ritualCounts[r] || 0) + 1;
    else customRituals.push({ name: m.name, ritual: r });
  });

  return {
    total,
    scopedCount: scoped.length,
    answeredCount: answered.length,
    goalCounts, feelCounts, ritualCounts, customRituals,
  };
}

function InsightBar({ label, count, total, color }) {
  const pct = Math.round((count / total) * 100);
  return (
    <div style={{ marginBottom: 14 }}>
      <div style={{ display: "flex", justifyContent: "space-between", fontSize: 13, marginBottom: 5 }}>
        <span style={{ color: "var(--text-primary)" }}>{label}</span>
        <span style={{ color: "var(--text-tertiary)" }}>{pct}% · {count}</span>
      </div>
      <div style={{ height: 8, borderRadius: 999, background: "var(--surface-sunken)", overflow: "hidden" }}>
        <div style={{ width: `${pct}%`, height: "100%", background: color, borderRadius: 999, transition: "width .6s cubic-bezier(.16,1,.3,1)" }}></div>
      </div>
    </div>
  );
}

/* ---------- engagement: what members actually did with what we published ---------- */
//
// AGGREGATE ONLY, and that is a decision rather than a limitation of the data.
// Nothing here records who read what. Three sources, all of them things that
// already exist:
//
//   Held together  members holding the month's ritual, COUNTED from synced
//                  rituals rather than from a counter — see joinCounts() in
//                  api/admin/campaigns.js for why that is the better number.
//   Letters        replies, which are per-member only because writing one is a
//                  thing a member chose to do.
//   Newsletter     Kit's own figures for a broadcast.
//
// THE OPEN RATE IS LABELLED, NOT HIDDEN. Apple Mail Privacy Protection loads
// images on a reader's behalf, so an "open" often means a proxy rather than a
// person. VELOUR's first send reported 7 of 7 — a rate that would be remarkable
// if it were real and is simply what image prefetching looks like. Showing it
// unqualified beside honest numbers would let it be read as engagement.
const REACTION_COLORS = ["var(--sage)", "var(--amber)", "var(--teal)"];

function EngagementSection() {
  const { posts, loading: postsLoading, loadError: postsError } = usePosts();
  const { campaigns, loading: campaignsLoading } = useCampaigns();
  const { newsletters, loading: newslettersLoading } = useNewsletters();

  const published = posts.filter((p) => p.status === "published");
  const totalReplies = published.reduce((n, p) => n + ((p.replies && p.replies.length) || 0), 0);

  // Reaction totals across every letter. Ordered by the count, so the shape of
  // what people say is the first thing visible rather than the order the chips
  // happen to be declared in.
  const reactionCounts = {};
  for (const p of published) {
    for (const r of p.replies || []) {
      if (r.reaction) reactionCounts[r.reaction] = (reactionCounts[r.reaction] || 0) + 1;
    }
  }
  const reactions = Object.entries(reactionCounts).sort((a, b) => b[1] - a[1]);
  const reactionTotal = reactions.reduce((n, [, c]) => n + c, 0);

  const currentMonth = campaigns.find((c) => c.status === "published") || null;
  const sentNewsletters = newsletters.filter((n) => n.stats);

  const card = { background: "var(--surface-card)", border: "1px solid var(--border)", borderRadius: 16, padding: "18px 20px" };
  const eyebrow = { fontSize: 11, letterSpacing: "0.14em", textTransform: "uppercase", color: "var(--text-tertiary)", marginBottom: 10 };
  const quiet = { fontSize: 13, lineHeight: 1.6, color: "var(--text-tertiary)" };
  const big = { fontFamily: "var(--font-serif)", fontWeight: 500, fontSize: 32, color: "var(--text-primary)", lineHeight: 1, margin: "6px 0 4px" };

  return (
    <div style={{ marginTop: 40 }}>
      <div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", flexWrap: "wrap", gap: 10, marginBottom: 4 }}>
        <h2 style={{ fontFamily: "var(--font-serif)", fontWeight: 500, fontSize: 22, color: "var(--text-primary)", margin: 0, letterSpacing: "-0.01em" }}>Engagement</h2>
        <span style={{ fontSize: 12.5, color: "var(--text-tertiary)" }}>What members did with what we published</span>
      </div>
      <p style={{ ...quiet, margin: "0 0 18px", maxWidth: 680 }}>
        Counts only. Nothing here records who read what — replies name the member because writing one is something they chose to do.
      </p>

      <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))", gap: 16 }}>
        {/* held together */}
        <div style={card}>
          <div style={eyebrow}>Held together</div>
          {campaignsLoading ? (
            <div style={quiet}>Loading…</div>
          ) : !currentMonth ? (
            <div style={quiet}>No month is published, so there is nothing to join.</div>
          ) : (
            <React.Fragment>
              <div style={big}>{currentMonth.holding == null ? "—" : currentMonth.holding}</div>
              <div style={{ fontSize: 12.5, color: "var(--text-secondary)" }}>
                {currentMonth.holding == null
                  ? "not counted — the tally could not be read"
                  : `${currentMonth.holding === 1 ? "member is" : "members are"} holding this ritual`}
              </div>
              <div style={{ ...quiet, marginTop: 10, fontSize: 12 }}>
                “{currentMonth.ritual}” · {currentMonth.month}
              </div>
              <div style={{ ...quiet, marginTop: 8, fontSize: 11.5 }}>
                Counted from members' own synced rituals, so it includes anyone who joined before this was measured.
              </div>
            </React.Fragment>
          )}
        </div>

        {/* replies */}
        <div style={card}>
          <div style={eyebrow}>Replies to letters</div>
          {postsLoading ? (
            <div style={quiet}>Loading…</div>
          ) : postsError ? (
            <div style={quiet}>The letters could not be loaded, so this is blank for the wrong reason.</div>
          ) : (
            <React.Fragment>
              <div style={big}>{totalReplies}</div>
              <div style={{ fontSize: 12.5, color: "var(--text-secondary)" }}>
                across {published.length} published {published.length === 1 ? "letter" : "letters"}
              </div>
              {published.length > 0 && (
                <div style={{ ...quiet, marginTop: 10, fontSize: 12 }}>
                  {published.slice(0, 3).map((p) => (
                    <div key={p.id} style={{ display: "flex", justifyContent: "space-between", gap: 10, marginTop: 4 }}>
                      <span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{p.month}</span>
                      <span>{(p.replies && p.replies.length) || 0}</span>
                    </div>
                  ))}
                </div>
              )}
            </React.Fragment>
          )}
        </div>

        {/* newsletter */}
        <div style={card}>
          <div style={eyebrow}>Newsletter</div>
          {newslettersLoading ? (
            <div style={quiet}>Loading…</div>
          ) : !sentNewsletters.length ? (
            <div style={quiet}>
              {newsletters.some((n) => n.kitBroadcastId)
                ? "Kit has not reported on these yet, or could not be asked."
                : "Nothing has been sent yet."}
            </div>
          ) : (
            <React.Fragment>
              <div style={big}>{sentNewsletters[0].stats.recipients ?? "—"}</div>
              <div style={{ fontSize: 12.5, color: "var(--text-secondary)" }}>recipients · {sentNewsletters[0].subject}</div>
              <div style={{ marginTop: 12, display: "flex", flexDirection: "column", gap: 6, fontSize: 12.5, color: "var(--text-secondary)" }}>
                <div style={{ display: "flex", justifyContent: "space-between" }}>
                  <span>Clicks</span><span>{sentNewsletters[0].stats.clicks ?? "—"}</span>
                </div>
                <div style={{ display: "flex", justifyContent: "space-between" }}>
                  <span>Unsubscribes</span><span>{sentNewsletters[0].stats.unsubscribes ?? "—"}</span>
                </div>
                <div style={{ display: "flex", justifyContent: "space-between", color: "var(--text-tertiary)" }}>
                  <span>Opens, as Kit counts them</span><span>{sentNewsletters[0].stats.opened ?? "—"}</span>
                </div>
              </div>
              <div style={{ ...quiet, marginTop: 10, fontSize: 11.5 }}>
                Treat opens as unreliable: Apple Mail loads images for the reader, which counts as an open whether or not anyone looked. Clicks and unsubscribes are the honest signals.
              </div>
            </React.Fragment>
          )}
        </div>
      </div>

      {/* what the replies said */}
      {reactionTotal > 0 && (
        <div style={{ ...card, marginTop: 16 }}>
          <div style={eyebrow}>What replies said</div>
          {reactions.map(([label, count], i) => (
            <InsightBar key={label} label={label} count={count} total={reactionTotal} color={REACTION_COLORS[i % REACTION_COLORS.length]} />
          ))}
          <div style={{ ...quiet, fontSize: 11.5 }}>
            Counted from replies that picked a reaction. A member can write back without choosing one.
          </div>
        </div>
      )}
    </div>
  );
}

function InsightsTab() {
  const [windowKey, setWindowKey] = React.useState("all");
  const [copied, setCopied] = React.useState(false);
  const data = computeInsights(useMembers(), windowKey);

  // Every one of these can be absent with real data, which the sample array never
  // showed: goalCounts and feelCounts are pre-seeded with their keys, but
  // ritualCounts only gains a key when someone picks a *canonical* ritual — so a
  // cohort of write-your-own answers leaves it empty. Reading [0] off that threw
  // and took the whole tab down.
  const byCount = (o) => Object.entries(o).sort((a, b) => b[1] - a[1])[0] || null;
  const topGoal = byCount(data.goalCounts);
  const topFeel = byCount(data.feelCounts);
  const topRitual = byCount(data.ritualCounts);
  const pct = (n) => Math.round((n / data.total) * 100);
  // FEEL_LABEL may not know a value either — fall back to what was actually
  // recorded rather than printing "undefined" into copy meant for the letter.
  // NAMED VALUES ONLY. The old fallback printed the raw key when FEEL_LABEL did
  // not know it, which is how the literal string "null" reached a sentence with a
  // Copy button under it. computeInsights no longer counts unnamed values at all;
  // this is the second guard, because this particular string can end up in a
  // letter to members.
  const feelText = topFeel && topFeel[1] > 0 && FEEL_LABEL[topFeel[0]] ? FEEL_LABEL[topFeel[0]] : null;

  const summary = data.answeredCount === 0
    ? `No onboarding answers in this ${windowKey === "month" ? "month" : "period"} yet.`
    : [
        topGoal && topGoal[1] > 0 ? `${pct(topGoal[1])}% of members chose ${GOAL_META[topGoal[0]].name} as a focus this ${windowKey === "month" ? "month" : "period"}.` : null,
        feelText ? `“${feelText}” was the most common way people described their days (${pct(topFeel[1])}%).` : null,
        topRitual ? `The most-picked first ritual was “${topRitual[0]}.”` : `Every first ritual so far was written by the member rather than picked from the suggestions.`,
      ].filter(Boolean).join(" ");

  const copy = () => {
    if (!navigator.clipboard) return;
    navigator.clipboard.writeText(summary).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1800); });
  };

  const card = { background: "var(--surface-card)", border: "1px solid var(--border)", borderRadius: 16, padding: "22px 24px" };
  const cardHead = { fontSize: 11, letterSpacing: "0.14em", textTransform: "uppercase", color: "var(--text-tertiary)", marginBottom: 16 };

  return (
    <div>
      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 22 }}>
        <div style={{ display: "flex", gap: 8 }}>
          {[["all", "All time"], ["month", "Last 30 days"]].map(([k, l]) => (
            <button key={k} onClick={() => setWindowKey(k)} style={{ padding: "7px 14px", borderRadius: 999, fontSize: 12.5, cursor: "pointer", fontFamily: "var(--font-sans)", border: `1px solid ${windowKey === k ? "var(--ink)" : "var(--border)"}`, background: windowKey === k ? "var(--ink)" : "transparent", color: windowKey === k ? "var(--parchment)" : "var(--text-secondary)" }}>{l}</button>
          ))}
        </div>
        {/* Both numbers, because they differ: every percentage below is out of the
            people who answered, and the gap between the two is itself worth seeing. */}
        <span style={{ fontSize: 12.5, color: "var(--text-tertiary)" }}>
          {data.answeredCount === data.scopedCount
            ? `${data.scopedCount} onboarded`
            : `${data.answeredCount} of ${data.scopedCount} answered`}
        </span>
      </div>

      <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20 }}>
        <div style={card}>
          <div style={cardHead}>What would you like to be different</div>
          {Object.entries(data.goalCounts).map(([k, c]) => (
            <InsightBar key={k} label={GOAL_META[k].name} count={c} total={data.total} color={GOAL_META[k].color} />
          ))}
        </div>
        <div style={card}>
          <div style={cardHead}>How your days feel right now</div>
          {Object.entries(data.feelCounts).map(([k, c]) => (
            <InsightBar key={k} label={FEEL_LABEL[k]} count={c} total={data.total} color="var(--ink)" />
          ))}
        </div>
      </div>

      <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20, marginTop: 20 }}>
        <div style={card}>
          <div style={cardHead}>Most-chosen first ritual</div>
          {Object.keys(data.ritualCounts).length === 0 ? (
            // This card was simply blank whenever every member wrote their own —
            // which, so far, every member has.
            <div style={{ fontSize: 13.5, color: "var(--text-tertiary)", fontStyle: "italic", fontFamily: "var(--font-serif)" }}>
              Nobody has picked one from the suggestions yet.
            </div>
          ) : Object.entries(data.ritualCounts).sort((a, b) => b[1] - a[1]).map(([r, c]) => (
            <InsightBar key={r} label={r} count={c} total={data.total} color="var(--sage)" />
          ))}
        </div>
        <div style={card}>
          <div style={cardHead}>In their own words</div>
          <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
            {data.customRituals.length === 0 ? (
              <div style={{ fontSize: 13.5, color: "var(--text-tertiary)", fontStyle: "italic", fontFamily: "var(--font-serif)" }}>No custom rituals written in this window.</div>
            ) : data.customRituals.map((c, i) => (
              <div key={i}>
                <div style={{ fontFamily: "var(--font-serif)", fontStyle: "italic", fontSize: 14.5, color: "var(--text-primary)" }}>“{c.ritual}”</div>
                <div style={{ fontSize: 11.5, color: "var(--text-tertiary)", marginTop: 2 }}>{c.name}</div>
              </div>
            ))}
          </div>
        </div>
      </div>

      <div style={{ ...card, marginTop: 20 }}>
        <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 16 }}>
          <div>
            <div style={cardHead}>For Jennifer's letter</div>
            <div style={{ fontFamily: "var(--font-serif)", fontStyle: "italic", fontSize: 16, lineHeight: 1.6, color: "var(--text-primary)", maxWidth: 540 }}>{summary}</div>
          </div>
          <button onClick={copy} style={{ flexShrink: 0, padding: "9px 16px", borderRadius: 999, border: "1px solid var(--border)", background: copied ? "var(--sage-fill)" : "transparent", color: copied ? "var(--sage-text)" : "var(--text-secondary)", fontFamily: "var(--font-sans)", fontSize: 12.5, cursor: "pointer", whiteSpace: "nowrap" }}>{copied ? "Copied" : "Copy for Jennifer"}</button>
        </div>
      </div>

      {/* What members did with what we published. Below the onboarding answers
          because that is the order the questions come in: who joined, then what
          they did once they were here. */}
      <EngagementSection />
    </div>
  );
}

/* ---------- blog tab: Jennifer's letter, authored once, published to both surfaces ---------- */
const BLOG_STATUS_META = {
  draft: { label: "Draft", color: "var(--stone)" },
  scheduled: { label: "Scheduled", color: "var(--amber)" },
  published: { label: "Published", color: "var(--sage)" },
};

// The five seeded letters that used to live here are gone. They were the whole
// bug: BlogTab held this array in React state and wrote it nowhere, so a letter
// typed into the composer survived exactly as long as the tab did. Letters now
// come from api/admin/posts (public.posts, in migration 001 since the beginning
// and unused until now), and this file no longer carries a copy of anyone's
// writing. The four published letters still ship as the built-in fallback in web
// DashBlog.jsx and mobile BlogScreen.jsx, which is what members read until the
// table has rows of its own.

const adInputStyle = { padding: "10px 12px", borderRadius: 10, border: "1px solid var(--border)", background: "var(--surface-card)", fontFamily: "var(--font-sans)", fontSize: 13.5, color: "var(--text-primary)", outline: "none" };
const adGhostBtnStyle = { padding: "8px 14px", borderRadius: 999, border: "1px solid var(--border)", background: "transparent", color: "var(--text-secondary)", fontFamily: "var(--font-sans)", fontSize: 12.5, cursor: "pointer" };

// Real letters from api/admin/posts. Shared with the Held together tab, whose
// "linked letter" dropdown must offer rows that actually exist — it used to list
// the seed array above, so linking a campaign to a letter stored an id no table
// had ever heard of.
const PostsContext = React.createContext(null);
function usePosts() { return React.useContext(PostsContext) || { posts: [], edits: {}, loadError: null, loading: true }; }

// One dirty editor anywhere in the dashboard, published so the sign-out button
// can ask before throwing the work away. The reported symptom was exactly this:
// a letter written, a sign-out, and nothing left. A beforeunload handler does not
// help — signing out is a React state change, not a navigation, so the browser
// never gets a chance to ask.
//
// `what` is the word the confirmation uses, because two tabs now have editors and
// "this letter has unsaved changes" over an unsaved month would be wrong.
function useUnsavedGuard(dirty, what) {
  React.useEffect(() => {
    window.velourAdminUnsaved = dirty ? (what || "letter") : null;
    const warn = (e) => { if (!dirty) return; e.preventDefault(); e.returnValue = ""; };
    window.addEventListener("beforeunload", warn);
    return () => {
      window.velourAdminUnsaved = null;
      window.removeEventListener("beforeunload", warn);
    };
  }, [dirty, what]);
}

function BlogTab() {
  const members = useMembers();
  const { posts, loadError, loading, setPosts, edits, setEdits } = usePosts();
  const [selectedId, setSelectedId] = React.useState(null);
  const [previewMode, setPreviewMode] = React.useState("web");
  // Unsaved edits are keyed by id and HELD IN THE SHELL, not here. Two reasons,
  // and the second is the one that matters: switching letters in the list must not
  // discard what was typed in the one before it — and neither must switching TABS,
  // which unmounts this whole component. A half-written letter surviving a click on
  // "Held together" and not surviving a click back is the same bug in a smaller
  // window.
  const [saving, setSaving] = React.useState(false);
  const [saveError, setSaveError] = React.useState(null);
  const [savedAt, setSavedAt] = React.useState(null);
  const [confirmDelete, setConfirmDelete] = React.useState(false);

  // Select the newest letter once they arrive, and never fight the author's own
  // choice afterwards.
  React.useEffect(() => {
    if (selectedId === null && posts.length) setSelectedId(posts[0].id);
  }, [posts, selectedId]);

  const stored = posts.find((p) => p.id === selectedId) || null;
  const post = stored ? { ...stored, ...(edits[selectedId] || {}) } : null;
  const dirty = !!(selectedId && edits[selectedId]);
  useUnsavedGuard(dirty, "letter");

  const update = (patch) => {
    setSaveError(null); setSavedAt(null); setConfirmDelete(false);
    setEdits((e) => ({ ...e, [selectedId]: { ...(e[selectedId] || {}), ...patch } }));
  };

  const select = (id) => { setSelectedId(id); setSaveError(null); setSavedAt(null); setConfirmDelete(false); };

  // Same guards as InsightsTab: with no onboarding answers these are undefined,
  // and this text goes into Jennifer's letter — a thrown error or the word
  // "undefined" reaching a draft are both worse than declining to insert.
  const insertInsight = () => {
    const data = computeInsights(members, "all");
    const byCount = (o) => Object.entries(o).sort((a, b) => b[1] - a[1])[0] || null;
    const topGoal = byCount(data.goalCounts);
    const topFeel = byCount(data.feelCounts);
    if (!data.scopedCount || !topGoal || !topGoal[1] || !topFeel) return;
    const feelText = FEEL_LABEL[topFeel[0]] || topFeel[0];
    const line = `This month, ${Math.round((topGoal[1] / data.total) * 100)}% of you told us you wanted ${GOAL_META[topGoal[0]].name.toLowerCase()} to feel different. “${feelText}” was the phrase that came up most.`;
    update({ paras: [...post.paras, line] });
  };

  // A new letter is a local row until it is saved. Its id is not a uuid, which is
  // exactly how the server tells an insert from an update.
  const newDraft = () => {
    const id = "p-draft-" + Date.now().toString(36);
    setPosts((ps) => [{ id, month: "", title: "", status: "draft", gated: "paid", publishAt: null, paras: [""], unsaved: true }, ...ps]);
    setEdits((e) => ({ ...e, [id]: {} })); // dirty from birth: it exists nowhere else
    select(id);
  };

  const save = async () => {
    if (!post || saving) return;
    setSaving(true); setSaveError(null);
    try {
      const res = await window.VelourAdminAuth.fetch("/api/admin/posts", {
        method: "PUT",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          id: post.id, month: post.month, title: post.title,
          status: post.status, gated: post.gated, publishAt: post.publishAt,
          paras: post.paras,
        }),
      });
      const body = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(body.error || "That did not save.");
      const saved = body.post;
      // The server's copy replaces the local one, id included — an insert comes
      // back with the uuid the row actually has, and every later save of this
      // letter is an update rather than a second row.
      setPosts((ps) => {
        const next = ps.map((p) => (p.id === post.id ? saved : p));
        // Month drives the order members read these in, so re-sort on it rather
        // than leaving a renamed letter sitting where it was typed.
        return next.slice().sort((a, b) => String(b.monthStart || "").localeCompare(String(a.monthStart || "")));
      });
      setEdits((e) => { const next = { ...e }; delete next[post.id]; return next; });
      setSelectedId(saved.id);
      setSavedAt(Date.now());
    } catch (err) {
      setSaveError(err.message);
    } finally {
      setSaving(false);
    }
  };

  const remove = async () => {
    if (!post || saving) return;
    // Never saved: it only exists in this tab, so dropping it needs no server.
    if (post.unsaved) {
      setPosts((ps) => ps.filter((p) => p.id !== post.id));
      setEdits((e) => { const next = { ...e }; delete next[post.id]; return next; });
      setSelectedId(null);
      return;
    }
    setSaving(true); setSaveError(null);
    try {
      const res = await window.VelourAdminAuth.fetch(`/api/admin/posts?id=${encodeURIComponent(post.id)}`, { method: "DELETE" });
      const body = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(body.error || "That did not delete.");
      setPosts((ps) => ps.filter((p) => p.id !== post.id));
      setEdits((e) => { const next = { ...e }; delete next[post.id]; return next; });
      setSelectedId(null);
    } catch (err) {
      setSaveError(err.message);
    } finally {
      setSaving(false);
      setConfirmDelete(false);
    }
  };

  return (
    <div style={{ display: "grid", gridTemplateColumns: "280px 1fr", gap: 24, alignItems: "start" }}>
      {/* post list */}
      <div>
        <button onClick={newDraft} style={{ width: "100%", padding: "10px 0", borderRadius: 10, border: "1px dashed var(--border-strong)", background: "transparent", color: "var(--text-secondary)", fontFamily: "var(--font-sans)", fontSize: 13, cursor: "pointer", marginBottom: 14 }}>+ New letter</button>
        {/* Same honesty as the members list: empty because nothing is written yet,
            and empty because the request failed, must not look identical. */}
        {loadError && (
          <div style={{ fontSize: 12, lineHeight: 1.5, color: "var(--amber-text)", background: "var(--amber-fill)", borderRadius: 10, padding: "10px 12px", marginBottom: 12 }}>
            The letters could not be loaded, so this list is empty for the wrong reason. Saving now could overwrite work that is already there.
          </div>
        )}
        {!loadError && !loading && !posts.length && (
          <div style={{ fontSize: 12.5, lineHeight: 1.5, color: "var(--text-tertiary)", padding: "10px 2px 14px" }}>
            No letters yet. The first one starts above.
          </div>
        )}
        <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
          {posts.map((p) => (
            <button key={p.id} onClick={() => select(p.id)} style={{
              textAlign: "left", padding: "12px 14px", borderRadius: 12, border: `1px solid ${selectedId === p.id ? "var(--ink)" : "var(--border)"}`,
              background: selectedId === p.id ? "var(--surface-card)" : "transparent", cursor: "pointer", fontFamily: "var(--font-sans)",
            }}>
              <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 4 }}>
                <span style={{ fontSize: 11, letterSpacing: "0.08em", textTransform: "uppercase", color: BLOG_STATUS_META[p.status].color }}>{BLOG_STATUS_META[p.status].label}</span>
                <span style={{ fontSize: 10.5, color: "var(--text-tertiary)" }}>{p.gated === "free" ? "Free" : "Paid"}</span>
              </div>
              <div style={{ fontSize: 13.5, color: "var(--text-primary)", fontWeight: 500, lineHeight: 1.3 }}>{p.title || "Untitled"}</div>
              <div style={{ fontSize: 11.5, color: "var(--text-tertiary)", marginTop: 2 }}>
                {p.month || "No month set"}{edits[p.id] ? " · unsaved" : ""}
              </div>
            </button>
          ))}
        </div>
      </div>

      {/* composer */}
      <div>
        {!post ? (
          <div style={{ border: "1px solid var(--border)", borderRadius: 16, background: "var(--surface-card)", padding: "44px 32px", textAlign: "center", color: "var(--text-tertiary)", fontSize: 13.5 }}>
            {loading ? "Loading the letters…" : "Choose a letter, or start a new one."}
          </div>
        ) : (
        <React.Fragment>
        <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 10, marginBottom: 10 }}>
          <input value={post.month} onChange={(e) => update({ month: e.target.value })} placeholder="Month, e.g. July 2026" style={{ ...adInputStyle, flex: 1 }} />
          <div style={{ display: "flex", gap: 6 }}>
            {Object.entries(BLOG_STATUS_META).map(([k, s]) => (
              <button key={k} onClick={() => update({ status: k })} style={{ padding: "7px 12px", borderRadius: 999, fontSize: 12, cursor: "pointer", fontFamily: "var(--font-sans)", border: `1px solid ${post.status === k ? s.color : "var(--border)"}`, background: post.status === k ? s.color : "transparent", color: post.status === k ? "#fff" : "var(--text-secondary)" }}>{s.label}</button>
            ))}
          </div>
        </div>
        {/* The date only exists for a scheduled letter, and a scheduled letter
            cannot be saved without one — api/posts.js publishes on it, so a
            missing date means a letter that waits for ever. Shown only when the
            status makes it mean something, rather than sitting there greyed. */}
        {post.status === "scheduled" && (
          <div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 10, flexWrap: "wrap" }}>
            <label style={{ fontSize: 12.5, color: "var(--text-secondary)" }}>Appears on</label>
            <input type="datetime-local"
              value={toLocalInput(post.publishAt)}
              onChange={(e) => update({ publishAt: fromLocalInput(e.target.value) })}
              style={{ ...adInputStyle }} />
            <span style={{ fontSize: 11.5, color: "var(--text-tertiary)" }}>
              {post.publishAt && Date.parse(post.publishAt) <= Date.now()
                ? "That is in the past, so it appears as soon as it is saved."
                : "Members see it from this moment. Nothing needs to run."}
            </span>
          </div>
        )}
        <input value={post.title} onChange={(e) => update({ title: e.target.value })} placeholder="Title" style={{ ...adInputStyle, fontFamily: "var(--font-serif)", fontSize: 22, marginBottom: 10, width: "100%", boxSizing: "border-box" }} />
        <textarea value={post.paras.join("\n\n")} onChange={(e) => update({ paras: e.target.value.split(/\n\s*\n/) })} rows={10}
          placeholder="Write the letter. Separate paragraphs with a blank line."
          style={{ ...adInputStyle, width: "100%", boxSizing: "border-box", fontFamily: "var(--font-sans)", fontSize: 14, lineHeight: 1.6, resize: "vertical" }} />

        <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: 10, flexWrap: "wrap", gap: 8 }}>
          <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
            <button onClick={insertInsight} style={adGhostBtnStyle}>+ Insert insight</button>
            <button onClick={() => update({ gated: post.gated === "free" ? "paid" : "free" })} style={adGhostBtnStyle}>{post.gated === "free" ? "Make paid-only" : "Make free"}</button>
            <button onClick={() => (confirmDelete ? remove() : setConfirmDelete(true))} style={{ ...adGhostBtnStyle, color: confirmDelete ? "var(--terracotta-text)" : "var(--text-tertiary)", borderColor: confirmDelete ? "var(--terracotta)" : "var(--border)" }}>
              {confirmDelete ? "Delete for good?" : "Delete"}
            </button>
          </div>
          <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
            <span style={{ fontSize: 11.5, color: saveError ? "var(--terracotta-text)" : "var(--text-tertiary)" }}>
              {saveError ? saveError
                : saving ? "Saving…"
                : dirty ? "Unsaved changes"
                : savedAt ? (post.status === "published" ? "Published — members can read it now" : "Saved")
                : "Saved"}
            </span>
            <button onClick={save} disabled={!dirty || saving} style={{
              padding: "9px 20px", borderRadius: 999, border: "none", fontFamily: "var(--font-sans)", fontSize: 13, fontWeight: 500,
              cursor: !dirty || saving ? "default" : "pointer", opacity: !dirty || saving ? 0.45 : 1,
              background: "var(--ink)", color: "var(--parchment)",
            }}>{post.status === "published" ? "Save & publish" : "Save"}</button>
          </div>
        </div>

        <div style={{ marginTop: 16, padding: "12px 16px", borderRadius: 12, background: "var(--surface-sunken)", fontSize: 12.5, color: "var(--text-tertiary)", lineHeight: 1.6 }}>
          <b style={{ color: "var(--text-secondary)" }}>Voice, quickly:</b> sentence case, no exclamation points, no emoji. Say "ritual," never "habit." Say "hold," never "streak." Observations, not advice.
        </div>

        {/* what members wrote back */}
        <div style={{ marginTop: 24 }}>
          <div style={{ fontSize: 11, letterSpacing: "0.14em", textTransform: "uppercase", color: "var(--text-tertiary)", marginBottom: 12 }}>
            Replies{post.replies && post.replies.length ? ` · ${post.replies.length}` : ""}
          </div>
          {!post.replies || !post.replies.length ? (
            <div style={{ border: "1px solid var(--border)", borderRadius: 14, background: "var(--surface-card)", padding: "18px 20px", fontSize: 13, lineHeight: 1.6, color: "var(--text-tertiary)" }}>
              {post.status === "published"
                ? "Nobody has written back to this one yet. Replies also arrive by email at jennifer@tryvelour.com, so this is a record rather than an inbox to watch."
                : "Replies appear once the letter is published."}
            </div>
          ) : (
            <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
              {post.replies.map((r) => (
                <div key={r.id} style={{ border: "1px solid var(--border)", borderRadius: 14, background: "var(--surface-card)", padding: "14px 18px" }}>
                  <div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 12, flexWrap: "wrap", marginBottom: r.note ? 8 : 0 }}>
                    <span style={{ fontSize: 13.5, color: "var(--text-primary)", fontWeight: 500 }}>{r.from}</span>
                    <span style={{ fontSize: 11.5, color: "var(--text-tertiary)" }}>{adFmtDate(String(r.at).slice(0, 10))}</span>
                  </div>
                  {r.reaction && (
                    <span style={{ display: "inline-block", fontSize: 11, letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--sage-text)", background: "var(--sage-fill)", borderRadius: 999, padding: "3px 10px", marginBottom: r.note ? 10 : 0 }}>{r.reaction}</span>
                  )}
                  {r.note && (
                    <div style={{ fontFamily: "var(--font-serif)", fontSize: 15, lineHeight: 1.65, color: "var(--text-primary)", whiteSpace: "pre-wrap" }}>{r.note}</div>
                  )}
                  {r.email && (
                    <div style={{ marginTop: 10 }}>
                      <a href={`mailto:${r.email}`} style={{ fontSize: 12, color: "var(--text-tertiary)", textDecoration: "none" }}>{r.email}</a>
                    </div>
                  )}
                </div>
              ))}
            </div>
          )}
        </div>

        {/* preview: web vs mobile, same content */}
        <div style={{ marginTop: 24 }}>
          <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }}>
            <div style={{ fontSize: 11, letterSpacing: "0.14em", textTransform: "uppercase", color: "var(--text-tertiary)" }}>Preview</div>
            <div style={{ display: "flex", gap: 6 }}>
              {["web", "mobile"].map((k) => (
                <button key={k} onClick={() => setPreviewMode(k)} style={{ padding: "6px 14px", borderRadius: 999, fontSize: 12, cursor: "pointer", fontFamily: "var(--font-sans)", textTransform: "capitalize", border: `1px solid ${previewMode === k ? "var(--ink)" : "var(--border)"}`, background: previewMode === k ? "var(--ink)" : "transparent", color: previewMode === k ? "var(--parchment)" : "var(--text-secondary)" }}>{k}</button>
              ))}
            </div>
          </div>
          <div style={{ border: "1px solid var(--border)", borderRadius: 16, background: "#EFEBE4", padding: 24, display: "flex", justifyContent: "center" }}>
            <div style={{ width: previewMode === "web" ? 560 : 300, background: "var(--surface-card)", borderRadius: previewMode === "web" ? 16 : 28, padding: previewMode === "web" ? "32px 36px" : "22px 20px", boxShadow: "var(--shadow-md)" }}>
              <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: previewMode === "web" ? 20 : 14 }}>
                <span style={{ fontSize: previewMode === "web" ? 11.5 : 10, letterSpacing: "0.14em", textTransform: "uppercase", fontWeight: 500, color: "var(--sage-text)", background: "var(--sage-fill)", borderRadius: 999, padding: previewMode === "web" ? "6px 12px" : "4px 9px" }}>Monthly letter</span>
                <span style={{ fontSize: previewMode === "web" ? 13.5 : 11, color: "var(--text-tertiary)" }}>{post.month || "—"}</span>
              </div>
              <div style={{ fontFamily: "var(--font-serif)", fontWeight: 500, fontSize: previewMode === "web" ? 34 : 22, lineHeight: 1.14, letterSpacing: "-0.02em", color: "var(--text-primary)", marginBottom: previewMode === "web" ? 20 : 14 }}>{post.title || "Untitled letter"}</div>
              <div style={{ display: "flex", alignItems: "center", gap: previewMode === "web" ? 12 : 9, marginBottom: previewMode === "web" ? 24 : 16 }}>
                <Avatar name="Jennifer Sichenzia" ring="rituals" />
                <div>
                  <div style={{ fontSize: previewMode === "web" ? 14.5 : 12.5, color: "var(--text-primary)", fontWeight: 500 }}>Jennifer Sichenzia</div>
                  <div style={{ fontSize: previewMode === "web" ? 13 : 11, color: "var(--text-tertiary)" }}>Founder &amp; CEO, VELOUR</div>
                </div>
              </div>
              <div style={{ fontSize: previewMode === "web" ? 17 : 14, lineHeight: previewMode === "web" ? 1.75 : 1.65, color: "var(--text-secondary)" }}>
                {post.paras.map((p, i) => <p key={i} style={{ margin: "0 0 18px" }}>{p}</p>)}
              </div>
            </div>
          </div>
        </div>
        </React.Fragment>
        )}
      </div>
    </div>
  );
}
/* ---------- newsletter tab: the free email, delivered by Kit ---------- */
//
// THE OTHER HALF OF THE BLOG TAB, and the distinction is worth keeping straight
// because the two composers look almost identical:
//
//   Blog        the monthly letter. PAID, read on the site by entitled members.
//   Newsletter  events and news. FREE, and it is not on the site at all — it
//               goes out as email.
//
// Which is why this one has a subject line and a preview line rather than a
// month and a title, and why it ends at Kit rather than at a publish toggle.
//
// PUSHING CREATES A DRAFT IN KIT. IT DOES NOT SEND. Kit's API has no send at
// all — every broadcast is created as a draft and the scheduling happens in
// Kit's own interface, which is where the rendered preview and the test send
// live. The tab says so plainly rather than implying a button here reaches
// anybody's inbox.
const NEWSLETTER_STATUS_META = {
  draft: { label: "Draft", color: "var(--stone)" },
  in_kit: { label: "In Kit", color: "var(--sage)" },
};

const NewslettersContext = React.createContext(null);
function useNewsletters() {
  return React.useContext(NewslettersContext) || { newsletters: [], edits: {}, loadError: null, loading: true };
}

function NewsletterTab() {
  const { newsletters, setNewsletters, edits, setEdits, loadError, loading } = useNewsletters();
  const [selectedId, setSelectedId] = React.useState(null);
  const [saving, setSaving] = React.useState(false);
  const [saveError, setSaveError] = React.useState(null);
  const [savedAt, setSavedAt] = React.useState(null);
  const [confirmDelete, setConfirmDelete] = React.useState(false);
  const [confirmPush, setConfirmPush] = React.useState(false);
  const [kitUrl, setKitUrl] = React.useState(null);

  React.useEffect(() => {
    if (selectedId === null && newsletters.length) setSelectedId(newsletters[0].id);
  }, [newsletters, selectedId]);

  const stored = newsletters.find((n) => n.id === selectedId) || null;
  const item = stored ? { ...stored, ...(edits[selectedId] || {}) } : null;
  const dirty = !!(selectedId && edits[selectedId]);
  useUnsavedGuard(dirty, "newsletter");

  const update = (patch) => {
    setSaveError(null); setSavedAt(null); setConfirmDelete(false); setConfirmPush(false);
    setEdits((e) => ({ ...e, [selectedId]: { ...(e[selectedId] || {}), ...patch } }));
  };

  const select = (id) => {
    setSelectedId(id); setSaveError(null); setSavedAt(null);
    setConfirmDelete(false); setConfirmPush(false); setKitUrl(null);
  };

  const newDraft = () => {
    const id = "nl-draft-" + Date.now().toString(36);
    setNewsletters((ns) => [{ id, subject: "", previewText: "", paras: [""], status: "draft", kitBroadcastId: null, pushedAt: null, unsaved: true }, ...ns]);
    setEdits((e) => ({ ...e, [id]: {} }));
    select(id);
  };

  const save = async () => {
    if (!item || saving) return;
    setSaving(true); setSaveError(null);
    try {
      const res = await window.VelourAdminAuth.fetch("/api/admin/newsletters", {
        method: "PUT",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ id: item.id, subject: item.subject, previewText: item.previewText, paras: item.paras }),
      });
      const body = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(body.error || "That did not save.");
      const saved = body.newsletter;
      setNewsletters((ns) => ns.map((n) => (n.id === item.id ? saved : n)));
      setEdits((e) => { const next = { ...e }; delete next[item.id]; return next; });
      setSelectedId(saved.id);
      setSavedAt(Date.now());
    } catch (err) {
      setSaveError(err.message);
    } finally {
      setSaving(false);
    }
  };

  const push = async () => {
    if (!item || saving) return;
    setSaving(true); setSaveError(null);
    try {
      const res = await window.VelourAdminAuth.fetch(`/api/admin/newsletters?id=${encodeURIComponent(item.id)}`, { method: "POST" });
      const body = await res.json().catch(() => ({}));
      if (!res.ok) {
        // A 409 carries the link to the draft that already exists, which is more
        // use than the refusal on its own.
        if (body.kitUrl) setKitUrl(body.kitUrl);
        throw new Error(body.error || "That did not go through.");
      }
      setNewsletters((ns) => ns.map((n) => (n.id === item.id ? body.newsletter : n)));
      setKitUrl(body.kitUrl);
    } catch (err) {
      setSaveError(err.message);
    } finally {
      setSaving(false);
      setConfirmPush(false);
    }
  };

  const remove = async () => {
    if (!item || saving) return;
    if (item.unsaved) {
      setNewsletters((ns) => ns.filter((n) => n.id !== item.id));
      setEdits((e) => { const next = { ...e }; delete next[item.id]; return next; });
      setSelectedId(null);
      return;
    }
    setSaving(true); setSaveError(null);
    try {
      const res = await window.VelourAdminAuth.fetch(`/api/admin/newsletters?id=${encodeURIComponent(item.id)}`, { method: "DELETE" });
      const body = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(body.error || "That did not delete.");
      setNewsletters((ns) => ns.filter((n) => n.id !== item.id));
      setEdits((e) => { const next = { ...e }; delete next[item.id]; return next; });
      setSelectedId(null);
    } catch (err) {
      setSaveError(err.message);
    } finally {
      setSaving(false);
      setConfirmDelete(false);
    }
  };

  const inKit = !!(item && item.kitBroadcastId);

  return (
    <div style={{ display: "grid", gridTemplateColumns: "280px 1fr", gap: 24, alignItems: "start" }}>
      {/* list */}
      <div>
        <button onClick={newDraft} style={{ width: "100%", padding: "10px 0", borderRadius: 10, border: "1px dashed var(--border-strong)", background: "transparent", color: "var(--text-secondary)", fontFamily: "var(--font-sans)", fontSize: 13, cursor: "pointer", marginBottom: 14 }}>+ New newsletter</button>
        {loadError && (
          <div style={{ fontSize: 12, lineHeight: 1.5, color: "var(--amber-text)", background: "var(--amber-fill)", borderRadius: 10, padding: "10px 12px", marginBottom: 12 }}>
            The newsletters could not be loaded, so this list is empty for the wrong reason.
          </div>
        )}
        {!loadError && !loading && !newsletters.length && (
          <div style={{ fontSize: 12.5, lineHeight: 1.5, color: "var(--text-tertiary)", padding: "10px 2px 14px" }}>
            Nothing written yet. The first one starts above.
          </div>
        )}
        <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
          {newsletters.map((n) => (
            <button key={n.id} onClick={() => select(n.id)} style={{
              textAlign: "left", padding: "12px 14px", borderRadius: 12, border: `1px solid ${selectedId === n.id ? "var(--ink)" : "var(--border)"}`,
              background: selectedId === n.id ? "var(--surface-card)" : "transparent", cursor: "pointer", fontFamily: "var(--font-sans)",
            }}>
              <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 4 }}>
                <span style={{ fontSize: 11, letterSpacing: "0.08em", textTransform: "uppercase", color: NEWSLETTER_STATUS_META[n.status].color }}>{NEWSLETTER_STATUS_META[n.status].label}</span>
                <span style={{ fontSize: 10.5, color: "var(--text-tertiary)" }}>Free</span>
              </div>
              <div style={{ fontSize: 13.5, color: "var(--text-primary)", fontWeight: 500, lineHeight: 1.3 }}>{n.subject || "Untitled"}</div>
              <div style={{ fontSize: 11.5, color: "var(--text-tertiary)", marginTop: 2 }}>
                {n.pushedAt ? adFmtDate(String(n.pushedAt).slice(0, 10)) : "Not in Kit yet"}{edits[n.id] ? " · unsaved" : ""}
              </div>
            </button>
          ))}
        </div>
      </div>

      {/* composer */}
      <div>
        {!item ? (
          <div style={{ border: "1px solid var(--border)", borderRadius: 16, background: "var(--surface-card)", padding: "44px 32px", textAlign: "center", color: "var(--text-tertiary)", fontSize: 13.5 }}>
            {loading ? "Loading the newsletters…" : "Choose a newsletter, or start a new one."}
          </div>
        ) : (
        <React.Fragment>
          <input value={item.subject} onChange={(e) => update({ subject: e.target.value })} placeholder="Subject line"
            style={{ ...adInputStyle, fontFamily: "var(--font-serif)", fontSize: 22, marginBottom: 10, width: "100%", boxSizing: "border-box" }} />
          <input value={item.previewText} onChange={(e) => update({ previewText: e.target.value })} placeholder="Preview line — the sentence inboxes show after the subject"
            style={{ ...adInputStyle, marginBottom: 10, width: "100%", boxSizing: "border-box" }} />
          <textarea value={item.paras.join("\n\n")} onChange={(e) => update({ paras: e.target.value.split(/\n\s*\n/) })} rows={10}
            placeholder="What happened, what is coming. Separate paragraphs with a blank line."
            style={{ ...adInputStyle, width: "100%", boxSizing: "border-box", fontFamily: "var(--font-sans)", fontSize: 14, lineHeight: 1.6, resize: "vertical" }} />

          <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: 10, flexWrap: "wrap", gap: 8 }}>
            <button onClick={() => (confirmDelete ? remove() : setConfirmDelete(true))} style={{ ...adGhostBtnStyle, color: confirmDelete ? "var(--terracotta-text)" : "var(--text-tertiary)", borderColor: confirmDelete ? "var(--terracotta)" : "var(--border)" }}>
              {confirmDelete ? "Delete for good?" : "Delete"}
            </button>
            <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
              <span style={{ fontSize: 11.5, color: saveError ? "var(--terracotta-text)" : "var(--text-tertiary)" }}>
                {saveError ? saveError : saving ? "Working…" : dirty ? "Unsaved changes" : savedAt ? "Saved" : "Saved"}
              </span>
              <button onClick={save} disabled={!dirty || saving} style={{
                padding: "9px 20px", borderRadius: 999, border: "none", fontFamily: "var(--font-sans)", fontSize: 13, fontWeight: 500,
                cursor: !dirty || saving ? "default" : "pointer", opacity: !dirty || saving ? 0.45 : 1,
                background: "var(--ink)", color: "var(--parchment)",
              }}>Save</button>
            </div>
          </div>

          {/* the handoff to Kit */}
          <div style={{ marginTop: 18, border: "1px solid var(--border)", borderRadius: 14, background: "var(--surface-card)", padding: "16px 18px" }}>
            <div style={{ fontSize: 11, letterSpacing: "0.14em", textTransform: "uppercase", color: "var(--text-tertiary)", marginBottom: 8 }}>Sending</div>
            {inKit ? (
              <div style={{ fontSize: 13.5, lineHeight: 1.6, color: "var(--text-secondary)" }}>
                This is in Kit as a draft. Open it there to preview it, send yourself a test, and schedule it.
                <div style={{ marginTop: 10 }}>
                  <a href={kitUrl || `https://app.kit.com/campaigns/${item.kitBroadcastId}/draft`} target="_blank" rel="noopener noreferrer"
                    style={{ display: "inline-flex", alignItems: "center", gap: 7, fontSize: 13, color: "var(--sage-text)", textDecoration: "none", fontWeight: 500 }}>
                    Open in Kit <i className="ph ph-arrow-up-right" style={{ fontSize: 13 }}></i>
                  </a>
                </div>
                <div style={{ fontSize: 12, color: "var(--text-tertiary)", marginTop: 10, lineHeight: 1.55 }}>
                  Deleting it here does not remove the Kit draft — that has to be done in Kit.
                </div>
              </div>
            ) : (
              <div>
                <p style={{ fontSize: 13.5, lineHeight: 1.6, color: "var(--text-secondary)", margin: "0 0 12px" }}>
                  Hands this to Kit as a draft broadcast. It does not send: Kit's own interface is where the rendered preview, the test send and the schedule live, and the last click before an email reaches anyone belongs there.
                </p>
                <div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
                  <button onClick={() => (confirmPush ? push() : setConfirmPush(true))} disabled={saving || dirty}
                    style={{
                      padding: "9px 18px", borderRadius: 999, border: "1px solid var(--border-strong)", background: "transparent",
                      color: "var(--text-primary)", fontFamily: "var(--font-sans)", fontSize: 13, fontWeight: 500,
                      cursor: saving || dirty ? "default" : "pointer", opacity: saving || dirty ? 0.45 : 1,
                    }}>{confirmPush ? "Create the draft in Kit?" : "Push to Kit"}</button>
                  {dirty && <span style={{ fontSize: 12, color: "var(--text-tertiary)" }}>Save first — Kit gets what is saved, not what is typed.</span>}
                </div>
              </div>
            )}
          </div>

          <div style={{ marginTop: 16, padding: "12px 16px", borderRadius: 12, background: "var(--surface-sunken)", fontSize: 12.5, color: "var(--text-tertiary)", lineHeight: 1.6 }}>
            <b style={{ color: "var(--text-secondary)" }}>This one is free and goes to everybody.</b> News, dates, what shipped. The paid monthly letter is the Blog tab — reflective, members only, read on the site. Same voice in both: sentence case, no exclamation points, no emoji. Say "ritual," never "habit." Say "hold," never "streak."
          </div>

          {/* preview — an inbox line, then the body */}
          <div style={{ marginTop: 24 }}>
            <div style={{ fontSize: 11, letterSpacing: "0.14em", textTransform: "uppercase", color: "var(--text-tertiary)", marginBottom: 12 }}>What lands in the inbox</div>
            <div style={{ border: "1px solid var(--border)", borderRadius: 16, background: "#EFEBE4", padding: 24, display: "flex", justifyContent: "center" }}>
              <div style={{ width: 560, background: "var(--surface-card)", borderRadius: 16, boxShadow: "var(--shadow-md)", overflow: "hidden" }}>
                {/* the inbox row */}
                <div style={{ padding: "14px 20px", borderBottom: "1px solid var(--divider)" }}>
                  <div style={{ fontSize: 13, color: "var(--text-primary)", fontWeight: 500 }}>VELOUR</div>
                  <div style={{ fontSize: 14, color: "var(--text-primary)", marginTop: 3 }}>{item.subject || "No subject yet"}</div>
                  <div style={{ fontSize: 13, color: "var(--text-tertiary)", marginTop: 2, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
                    {item.previewText || (item.paras[0] || "").slice(0, 90) || "—"}
                  </div>
                </div>
                {/* the body */}
                <div style={{ padding: "24px 28px", fontSize: 15.5, lineHeight: 1.72, color: "var(--text-secondary)" }}>
                  {item.paras.map((p, i) => <p key={i} style={{ margin: "0 0 16px" }}>{p}</p>)}
                  <div style={{ marginTop: 22, paddingTop: 14, borderTop: "1px solid var(--divider)", fontSize: 11.5, color: "var(--text-tertiary)", lineHeight: 1.6 }}>
                    Kit adds the unsubscribe link and the postal address below this line. They are required on a marketing email and are not ours to draw here.
                  </div>
                </div>
              </div>
            </div>
          </div>
        </React.Fragment>
        )}
      </div>
    </div>
  );
}

/* ---------- held together tab: the monthly shared ritual, authored once ---------- */
// Deliberately NOT a "challenge": no leaderboard, no per-member ranking, no
// countdown pressure, no badge. Joining is just adding this ritual to your own
// sequence — the ambient count is the only community signal. This seed becomes
// the real HELD_TOGETHER record web Dashboard.jsx / mobile TodayScreen.jsx read
// (duplicated for now, unified once Phase 0's API exists).
const HT_STATUS_META = {
  draft: { label: "Draft", color: "var(--stone)" },
  scheduled: { label: "Scheduled", color: "var(--amber)" },
  published: { label: "Published", color: "var(--sage)" },
};

// The four seeded months that used to sit here are gone, for the same reason the
// letters were: HeldTogetherTab held them in React state and wrote them nowhere,
// so a month authored in this tab was lost on the next reload. Campaigns now come
// from api/admin/campaigns.js (public.held_together_campaigns, waiting in
// migration 001 since the beginning, exactly as posts was).
//
// The invented adoption rates went with them and are not coming back — 41%, 34%
// and 29% were take-up figures for campaigns nobody had joined, rendered as
// confident bars. adoption_pct is read from the table and is null until something
// can count a real join; the admin cannot type one in.

// Real campaigns from api/admin/campaigns. A context for the same reason posts
// has one: the shell fetches once, and the tab reads it.
const CampaignsContext = React.createContext(null);
function useCampaigns() { return React.useContext(CampaignsContext) || { campaigns: [], edits: {}, loadError: null, loading: true }; }

function HeldTogetherTab() {
  const { campaigns: entries, loadError, loading, setCampaigns: setEntries, edits, setEdits } = useCampaigns();
  const [selectedId, setSelectedId] = React.useState(null);
  const [saving, setSaving] = React.useState(false);
  const [saveError, setSaveError] = React.useState(null);
  const [savedAt, setSavedAt] = React.useState(null);
  const [confirmDelete, setConfirmDelete] = React.useState(false);

  React.useEffect(() => {
    if (selectedId === null && entries.length) setSelectedId(entries[0].id);
  }, [entries, selectedId]);

  const stored = entries.find((e) => e.id === selectedId) || null;
  const entry = stored ? { ...stored, ...(edits[selectedId] || {}) } : null;
  const dirty = !!(selectedId && edits[selectedId]);
  useUnsavedGuard(dirty, "month");

  const update = (patch) => {
    setSaveError(null); setSavedAt(null); setConfirmDelete(false);
    setEdits((e) => ({ ...e, [selectedId]: { ...(e[selectedId] || {}), ...patch } }));
  };

  const select = (id) => { setSelectedId(id); setSaveError(null); setSavedAt(null); setConfirmDelete(false); };

  const newEntry = () => {
    const id = "ht-draft-" + Date.now().toString(36);
    setEntries((es) => [{ id, month: "", ritual: "", pillar: "rituals", frameLine: "", status: "draft", linkedPostId: null, joinCount: 0, adoptionPct: null, unsaved: true }, ...es]);
    setEdits((e) => ({ ...e, [id]: {} })); // dirty from birth: it exists nowhere else
    select(id);
  };

  const save = async () => {
    if (!entry || saving) return;
    setSaving(true); setSaveError(null);
    try {
      const res = await window.VelourAdminAuth.fetch("/api/admin/campaigns", {
        method: "PUT",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          id: entry.id, month: entry.month, ritual: entry.ritual, pillar: entry.pillar,
          frameLine: entry.frameLine, status: entry.status, linkedPostId: entry.linkedPostId,
        }),
      });
      const body = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(body.error || "That did not save.");
      const saved = body.campaign;
      setEntries((es) => es
        .map((e) => (e.id === entry.id ? saved : e))
        .slice()
        .sort((a, b) => String(b.monthStart || "").localeCompare(String(a.monthStart || ""))));
      setEdits((e) => { const next = { ...e }; delete next[entry.id]; return next; });
      setSelectedId(saved.id);
      setSavedAt(Date.now());
    } catch (err) {
      setSaveError(err.message);
    } finally {
      setSaving(false);
    }
  };

  const remove = async () => {
    if (!entry || saving) return;
    if (entry.unsaved) {
      setEntries((es) => es.filter((e) => e.id !== entry.id));
      setEdits((e) => { const next = { ...e }; delete next[entry.id]; return next; });
      setSelectedId(null);
      return;
    }
    setSaving(true); setSaveError(null);
    try {
      const res = await window.VelourAdminAuth.fetch(`/api/admin/campaigns?id=${encodeURIComponent(entry.id)}`, { method: "DELETE" });
      const body = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(body.error || "That did not delete.");
      setEntries((es) => es.filter((e) => e.id !== entry.id));
      setEdits((e) => { const next = { ...e }; delete next[entry.id]; return next; });
      setSelectedId(null);
    } catch (err) {
      setSaveError(err.message);
    } finally {
      setSaving(false);
      setConfirmDelete(false);
    }
  };

  // Real letters, not the old seed array. This dropdown used to list five invented
  // ids, so choosing one stored a reference to a letter that has never existed in
  // any table — and the linked-letter line below it read back the invention.
  const { posts: realPosts } = usePosts();
  const linkedPost = entry ? realPosts.find((p) => p.id === entry.linkedPostId) : null;
  const pillarBtn = (key) => {
    const on = entry.pillar === key;
    return (
      <button key={key} onClick={() => update({ pillar: key })} style={{
        display: "flex", alignItems: "center", gap: 7, padding: "8px 14px", borderRadius: 999, cursor: "pointer", fontFamily: "var(--font-sans)", fontSize: 12.5,
        border: `1px solid ${on ? GOAL_META[key].color : "var(--border)"}`, background: on ? `color-mix(in srgb, ${GOAL_META[key].color} 12%, transparent)` : "transparent",
        color: on ? "var(--text-primary)" : "var(--text-secondary)",
      }}>
        <span style={{ width: 7, height: 7, borderRadius: "50%", background: GOAL_META[key].color }}></span>{GOAL_META[key].name}
      </button>
    );
  };

  return (
    <div style={{ display: "grid", gridTemplateColumns: "280px 1fr", gap: 24, alignItems: "start" }}>
      {/* history list */}
      <div>
        <button onClick={newEntry} style={{ width: "100%", padding: "10px 0", borderRadius: 10, border: "1px dashed var(--border-strong)", background: "transparent", color: "var(--text-secondary)", fontFamily: "var(--font-sans)", fontSize: 13, cursor: "pointer", marginBottom: 14 }}>+ New month</button>
        {loadError && (
          <div style={{ fontSize: 12, lineHeight: 1.5, color: "var(--amber-text)", background: "var(--amber-fill)", borderRadius: 10, padding: "10px 12px", marginBottom: 12 }}>
            The months could not be loaded, so this list is empty for the wrong reason. Saving now could overwrite work that is already there.
          </div>
        )}
        {!loadError && !loading && !entries.length && (
          <div style={{ fontSize: 12.5, lineHeight: 1.5, color: "var(--text-tertiary)", padding: "10px 2px 14px" }}>
            No months yet. The first one starts above.
          </div>
        )}
        <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
          {entries.map((e) => (
            <button key={e.id} onClick={() => select(e.id)} style={{
              textAlign: "left", padding: "12px 14px", borderRadius: 12, border: `1px solid ${selectedId === e.id ? "var(--ink)" : "var(--border)"}`,
              background: selectedId === e.id ? "var(--surface-card)" : "transparent", cursor: "pointer", fontFamily: "var(--font-sans)",
            }}>
              <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 4 }}>
                <span style={{ fontSize: 11, letterSpacing: "0.08em", textTransform: "uppercase", color: HT_STATUS_META[e.status].color }}>{HT_STATUS_META[e.status].label}</span>
                {e.adoptionPct != null && <span style={{ fontSize: 10.5, color: "var(--text-tertiary)" }}>{e.adoptionPct}% joined</span>}
              </div>
              <div style={{ fontSize: 13.5, color: "var(--text-primary)", fontWeight: 500, lineHeight: 1.3 }}>{e.ritual || "Untitled"}</div>
              <div style={{ fontSize: 11.5, color: "var(--text-tertiary)", marginTop: 2 }}>
                {e.month || "No month set"}{edits[e.id] ? " · unsaved" : ""}
              </div>
            </button>
          ))}
        </div>

        {/* adoption trend, reusing the same bar language as Insights */}
        <div style={{ marginTop: 22, background: "var(--surface-card)", border: "1px solid var(--border)", borderRadius: 16, padding: "18px 18px 6px" }}>
          <div style={{ fontSize: 10.5, letterSpacing: "0.14em", textTransform: "uppercase", color: "var(--text-tertiary)", marginBottom: 14 }}>Adoption by month</div>
          {entries.some((e) => e.adoptionPct != null)
            ? entries.filter((e) => e.adoptionPct != null).map((e) => (
                <InsightBar key={e.id} label={e.month} count={e.adoptionPct} total={100} color={GOAL_META[e.pillar].color} />
              ))
            : (
              <div style={{ fontSize: 13, color: "var(--text-tertiary)", lineHeight: 1.6, paddingBottom: 14 }}>
                Nothing to plot yet — joins are counted once the campaigns API records them.
              </div>
            )}
        </div>
      </div>

      {/* composer */}
      <div>
        {!entry ? (
          <div style={{ border: "1px solid var(--border)", borderRadius: 16, background: "var(--surface-card)", padding: "44px 32px", textAlign: "center", color: "var(--text-tertiary)", fontSize: 13.5 }}>
            {loading ? "Loading the months…" : "Choose a month, or start a new one."}
          </div>
        ) : (
        <React.Fragment>
        <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 10, marginBottom: 10 }}>
          <input value={entry.month} onChange={(e) => update({ month: e.target.value })} placeholder="Month, e.g. July 2026" style={{ ...adInputStyle, flex: 1 }} />
          <div style={{ display: "flex", gap: 6 }}>
            {Object.entries(HT_STATUS_META).map(([k, s]) => (
              <button key={k} onClick={() => update({ status: k })} style={{ padding: "7px 12px", borderRadius: 999, fontSize: 12, cursor: "pointer", fontFamily: "var(--font-sans)", border: `1px solid ${entry.status === k ? s.color : "var(--border)"}`, background: entry.status === k ? s.color : "transparent", color: entry.status === k ? "#fff" : "var(--text-secondary)" }}>{s.label}</button>
            ))}
          </div>
        </div>

        <input value={entry.ritual} onChange={(e) => update({ ritual: e.target.value })} placeholder="The shared ritual, e.g. A ten-minute walk, no headphones"
          style={{ ...adInputStyle, fontFamily: "var(--font-serif)", fontSize: 19, marginBottom: 10, width: "100%", boxSizing: "border-box" }} />

        <div style={{ display: "flex", gap: 8, marginBottom: 10, flexWrap: "wrap" }}>
          {Object.keys(GOAL_META).map(pillarBtn)}
        </div>

        {/* A month held together IS a month, so there is no separate date to set:
            api/campaigns.js shows a scheduled month from the first day of it,
            which is the month_start the field above already implies. */}
        {entry.status === "scheduled" && (
          <div style={{ fontSize: 12.5, color: "var(--text-secondary)", background: "var(--surface-sunken)", borderRadius: 10, padding: "10px 13px", marginBottom: 10, lineHeight: 1.55 }}>
            Members see this from the first of {entry.month || "the month above"}. Nothing needs to run — set the month and it appears when the month does.
          </div>
        )}

        <textarea value={entry.frameLine} onChange={(e) => update({ frameLine: e.target.value })} rows={4}
          placeholder="Frame it as an invitation, not a task — this is what members see."
          style={{ ...adInputStyle, width: "100%", boxSizing: "border-box", fontFamily: "var(--font-sans)", fontSize: 14, lineHeight: 1.6, resize: "vertical" }} />

        <div style={{ marginTop: 12 }}>
          <div style={{ fontSize: 11, letterSpacing: "0.14em", textTransform: "uppercase", color: "var(--text-tertiary)", marginBottom: 8 }}>Feature in this month's letter</div>
          <select value={entry.linkedPostId || ""} onChange={(e) => update({ linkedPostId: e.target.value || null })} style={{ ...adInputStyle, width: "100%" }}>
            <option value="">Not linked to a letter</option>
            {realPosts.map((p) => <option key={p.id} value={p.id}>{p.month} — {p.title}</option>)}
          </select>
        </div>

        <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: 12, flexWrap: "wrap", gap: 8 }}>
          <button onClick={() => (confirmDelete ? remove() : setConfirmDelete(true))} style={{ ...adGhostBtnStyle, color: confirmDelete ? "var(--terracotta-text)" : "var(--text-tertiary)", borderColor: confirmDelete ? "var(--terracotta)" : "var(--border)" }}>
            {confirmDelete ? "Delete for good?" : "Delete"}
          </button>
          <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
            <span style={{ fontSize: 11.5, color: saveError ? "var(--terracotta-text)" : "var(--text-tertiary)" }}>
              {saveError ? saveError
                : saving ? "Saving…"
                : dirty ? "Unsaved changes"
                : savedAt ? (entry.status === "published" ? "Published — members see it now" : "Saved")
                : "Saved"}
            </span>
            <button onClick={save} disabled={!dirty || saving} style={{
              padding: "9px 20px", borderRadius: 999, border: "none", fontFamily: "var(--font-sans)", fontSize: 13, fontWeight: 500,
              cursor: !dirty || saving ? "default" : "pointer", opacity: !dirty || saving ? 0.45 : 1,
              background: "var(--ink)", color: "var(--parchment)",
            }}>{entry.status === "published" ? "Save & publish" : "Save"}</button>
          </div>
        </div>

        <div style={{ marginTop: 16, padding: "12px 16px", borderRadius: 12, background: "var(--surface-sunken)", fontSize: 12.5, color: "var(--text-tertiary)", lineHeight: 1.6 }}>
          <b style={{ color: "var(--text-secondary)" }}>Guardrails:</b> no leaderboard, no visible ranking between members, no countdown, no badge, no penalty for not joining. Joining only adds this ritual to a member's own sequence — same mechanic as onboarding.
        </div>

        {/* member-facing preview — matches HeldTogetherCard exactly */}
        <div style={{ marginTop: 24 }}>
          <div style={{ fontSize: 11, letterSpacing: "0.14em", textTransform: "uppercase", color: "var(--text-tertiary)", marginBottom: 12 }}>What members see</div>
          <div style={{ border: "1px solid var(--border)", borderRadius: 16, background: "#EFEBE4", padding: 24, display: "flex", justifyContent: "center" }}>
            <div style={{ width: 480, background: "var(--surface-card)", borderRadius: 16, padding: "24px", boxShadow: "var(--shadow-md)", border: `1px solid color-mix(in srgb, ${GOAL_META[entry.pillar].color} 35%, var(--border))` }}>
              <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 14 }}>
                <span style={{ width: 8, height: 8, borderRadius: "50%", background: GOAL_META[entry.pillar].color }}></span>
                <span style={{ fontSize: 11, letterSpacing: "0.16em", textTransform: "uppercase", color: "var(--text-tertiary)", fontWeight: 500 }}>Held together · {entry.month || "—"}</span>
              </div>
              <div style={{ fontFamily: "var(--font-serif)", fontSize: 21, lineHeight: 1.35, color: "var(--text-primary)", letterSpacing: "-0.01em", marginBottom: 10 }}>{entry.ritual || "Untitled ritual"}</div>
              <p style={{ fontSize: 14, color: "var(--text-secondary)", lineHeight: 1.6, margin: "0 0 18px" }}>{entry.frameLine || "Frame line goes here."}</p>
              <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: 12 }}>
                {/* The count line, exactly as the real card decides it. This said
                    "1,842 members are holding this with you" — a number no system
                    produced, in a preview whose whole job is to show what members
                    see. The card itself stopped saying it; the preview of the card
                    had gone on saying it, which is the more misleading of the two
                    because it is what gets checked before publishing. */}
                {entry.joinCount > 0
                  ? <span style={{ fontSize: 13, color: "var(--text-tertiary)" }}>{entry.joinCount.toLocaleString()} members are holding this with you</span>
                  : <span style={{ fontSize: 12.5, color: "var(--text-tertiary)", fontStyle: "italic" }}>No count shown — nothing records joins yet</span>}
                <button disabled style={{ padding: "10px 20px", borderRadius: 999, border: "none", background: "var(--ink)", color: "var(--parchment)", fontFamily: "var(--font-sans)", fontSize: 13.5, fontWeight: 500 }}>Add to my rituals</button>
              </div>
            </div>
          </div>
          {linkedPost && <div style={{ marginTop: 10, fontSize: 12, color: "var(--text-tertiary)" }}>Linked to letter: <span style={{ color: "var(--text-secondary)" }}>{linkedPost.title}</span></div>}
        </div>
        </React.Fragment>
        )}
      </div>
    </div>
  );
}

/* ---------- add a member on Premium, at no charge ---------- */
//
// For pilots: someone runs the paid product for a while without paying for it.
// The account created here is an ordinary account on an ordinary Premium
// membership — the only difference is that nobody is billed, which api/admin/
// members.js records as `comped` and adStats() above excludes from MRR.
//
// The sign-in link is the part that makes this usable rather than merely
// correct: VELOUR has no passwords, so an account created for someone else is
// unreachable until a magic link reaches their inbox.
function AddMemberForm({ onDone, onCancel }) {
  const [email, setEmail] = React.useState("");
  const [name, setName] = React.useState("");
  const [endsOn, setEndsOn] = React.useState("");
  const [note, setNote] = React.useState("");
  const [sendLink, setSendLink] = React.useState(true);
  const [busy, setBusy] = React.useState(false);
  const [error, setError] = React.useState(null);

  const submit = async (e) => {
    if (e) e.preventDefault();
    if (busy || !email.trim()) return;
    setBusy(true); setError(null);
    try {
      const res = await window.VelourAdminAuth.fetch("/api/admin/members", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ email: email.trim(), name: name.trim(), endsOn: endsOn || null, note: note.trim(), sendLink }),
      });
      const body = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(body.error || "That did not go through.");
      onDone(body);
    } catch (err) {
      setError(err.message);
    } finally {
      setBusy(false);
    }
  };

  const label = { fontSize: 11, letterSpacing: "0.14em", textTransform: "uppercase", color: "var(--text-tertiary)", display: "block", marginBottom: 6 };

  return (
    <form onSubmit={submit} style={{ background: "var(--surface-card)", border: "1px solid var(--border)", borderRadius: 16, padding: "20px 22px", marginBottom: 18 }}>
      <div style={{ fontFamily: "var(--font-serif)", fontSize: 19, color: "var(--text-primary)", letterSpacing: "-0.01em", marginBottom: 4 }}>Add a member on Premium</div>
      <p style={{ fontSize: 12.5, lineHeight: 1.6, color: "var(--text-tertiary)", margin: "0 0 18px", maxWidth: 620 }}>
        Creates the account and gives it the full paid product at no charge. Nothing is billed and no card is asked for. They are not counted in MRR.
      </p>
      <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14, marginBottom: 14 }}>
        <div>
          <label style={label}>Email</label>
          <input value={email} onChange={(e) => setEmail(e.target.value)} type="email" required placeholder="them@example.com" style={{ ...adInputStyle, width: "100%", boxSizing: "border-box" }} />
        </div>
        <div>
          <label style={label}>Name</label>
          <input value={name} onChange={(e) => setName(e.target.value)} placeholder="Optional" style={{ ...adInputStyle, width: "100%", boxSizing: "border-box" }} />
        </div>
        <div>
          <label style={label}>Pilot ends</label>
          <input value={endsOn} onChange={(e) => setEndsOn(e.target.value)} type="date" style={{ ...adInputStyle, width: "100%", boxSizing: "border-box" }} />
          <div style={{ fontSize: 11.5, color: "var(--text-tertiary)", marginTop: 6 }}>
            {endsOn ? "Premium stops on its own that evening, and they return to free." : "Leave empty and the pilot runs until you end it."}
          </div>
        </div>
        <div>
          <label style={label}>Note</label>
          <input value={note} onChange={(e) => setNote(e.target.value)} placeholder="Why, for whoever reads this later" style={{ ...adInputStyle, width: "100%", boxSizing: "border-box" }} />
        </div>
      </div>
      <label style={{ display: "flex", alignItems: "center", gap: 9, fontSize: 13, color: "var(--text-secondary)", cursor: "pointer", marginBottom: 16 }}>
        <input type="checkbox" checked={sendLink} onChange={(e) => setSendLink(e.target.checked)} />
        Email them a sign-in link now
      </label>
      {error && <div style={{ fontSize: 12.5, lineHeight: 1.5, color: "var(--terracotta-text)", background: "var(--terracotta-fill)", borderRadius: 10, padding: "10px 12px", marginBottom: 14 }}>{error}</div>}
      <div style={{ display: "flex", gap: 10, alignItems: "center" }}>
        <button type="submit" disabled={busy || !email.trim()} style={{ padding: "10px 22px", borderRadius: 999, border: "none", background: "var(--ink)", color: "var(--parchment)", fontFamily: "var(--font-sans)", fontSize: 13.5, fontWeight: 500, cursor: busy || !email.trim() ? "default" : "pointer", opacity: busy || !email.trim() ? 0.45 : 1 }}>
          {busy ? "Setting them up…" : "Add member"}
        </button>
        <button type="button" onClick={onCancel} style={adGhostBtnStyle}>Cancel</button>
      </div>
    </form>
  );
}

/* ---------- shell ---------- */
function AdminDashboard({ onNavigate }) {
  const [tab, setTab] = React.useState("members");
  const [query, setQuery] = React.useState("");
  const [statusFilter, setStatusFilter] = React.useState("all");
  const [selected, setSelected] = React.useState(null);
  const [adding, setAdding] = React.useState(false);
  const [addedNote, setAddedNote] = React.useState(null);

  // Real data. `null` means "still loading, or unavailable" — useMembers() then
  // falls back to the sample array, so the dashboard is never blank. loadError
  // is surfaced in the header rather than swallowed, because silently showing
  // fake numbers that look real is the worst outcome here.
  const [liveMembers, setLiveMembers] = React.useState(null);
  const [adminEmail, setAdminEmail] = React.useState(null);
  const [loadError, setLoadError] = React.useState(null);

  React.useEffect(() => {
    let cancelled = false;
    const auth = window.VelourAdminAuth;
    if (!auth) { setLoadError("no session"); return; }
    auth.fetch("/api/admin/members")
      .then((r) => (r.ok ? r.json() : Promise.reject(new Error("HTTP " + r.status))))
      .then((body) => {
        if (cancelled) return;
        setLiveMembers(body.members || []);
        setAdminEmail((body.admin && body.admin.email) || null);
      })
      .catch((e) => { if (!cancelled) setLoadError(e.message || "unavailable"); });
    return () => { cancelled = true; };
  }, []);

  const members = liveMembers || ADMIN_MEMBERS;
  const usingSample = liveMembers === null;

  // Jennifer's letters, from api/admin/posts. Held at the shell rather than inside
  // BlogTab so switching tabs does not re-fetch — and so the Held together tab can
  // offer real letters to link a campaign to.
  //
  // NO SEED FALLBACK, unlike members above, and for the same reason members lost
  // theirs: a failed request that renders as four plausible letters invites someone
  // to edit one and save it, which would write a fiction into the table. Empty plus
  // postsError is the honest pair.
  const [posts, setPosts] = React.useState([]);
  const [postsError, setPostsError] = React.useState(null);
  const [postsLoading, setPostsLoading] = React.useState(true);

  React.useEffect(() => {
    let cancelled = false;
    const auth = window.VelourAdminAuth;
    if (!auth) { setPostsError("no session"); setPostsLoading(false); return; }
    auth.fetch("/api/admin/posts")
      .then((r) => (r.ok ? r.json() : Promise.reject(new Error("HTTP " + r.status))))
      .then((body) => { if (!cancelled) { setPosts(body.posts || []); setPostsLoading(false); } })
      .catch((e) => { if (!cancelled) { setPostsError(e.message || "unavailable"); setPostsLoading(false); } });
    return () => { cancelled = true; };
  }, []);

  const [postEdits, setPostEdits] = React.useState({});
  const postsValue = React.useMemo(
    () => ({ posts, setPosts, edits: postEdits, setEdits: setPostEdits, loadError: postsError, loading: postsLoading }),
    [posts, postEdits, postsError, postsLoading]
  );

  // The months of Held together, from api/admin/campaigns. Fetched here beside the
  // letters and for the same reasons: one request per session, and no seed
  // fallback — an empty list plus an error is honest where four plausible months
  // someone could edit and save are not.
  const [campaigns, setCampaigns] = React.useState([]);
  const [campaignEdits, setCampaignEdits] = React.useState({});
  const [campaignsError, setCampaignsError] = React.useState(null);
  const [campaignsLoading, setCampaignsLoading] = React.useState(true);

  React.useEffect(() => {
    let cancelled = false;
    const auth = window.VelourAdminAuth;
    if (!auth) { setCampaignsError("no session"); setCampaignsLoading(false); return; }
    auth.fetch("/api/admin/campaigns")
      .then((r) => (r.ok ? r.json() : Promise.reject(new Error("HTTP " + r.status))))
      .then((body) => { if (!cancelled) { setCampaigns(body.campaigns || []); setCampaignsLoading(false); } })
      .catch((e) => { if (!cancelled) { setCampaignsError(e.message || "unavailable"); setCampaignsLoading(false); } });
    return () => { cancelled = true; };
  }, []);

  // The free email. Fetched here beside the letters and the months, same rules:
  // one request per session, and no seed fallback.
  const [newsletters, setNewsletters] = React.useState([]);
  const [newsletterEdits, setNewsletterEdits] = React.useState({});
  const [newslettersError, setNewslettersError] = React.useState(null);
  const [newslettersLoading, setNewslettersLoading] = React.useState(true);

  React.useEffect(() => {
    let cancelled = false;
    const auth = window.VelourAdminAuth;
    if (!auth) { setNewslettersError("no session"); setNewslettersLoading(false); return; }
    auth.fetch("/api/admin/newsletters")
      .then((r) => (r.ok ? r.json() : Promise.reject(new Error("HTTP " + r.status))))
      .then((body) => { if (!cancelled) { setNewsletters(body.newsletters || []); setNewslettersLoading(false); } })
      .catch((e) => { if (!cancelled) { setNewslettersError(e.message || "unavailable"); setNewslettersLoading(false); } });
    return () => { cancelled = true; };
  }, []);

  const newslettersValue = React.useMemo(
    () => ({ newsletters, setNewsletters, edits: newsletterEdits, setEdits: setNewsletterEdits, loadError: newslettersError, loading: newslettersLoading }),
    [newsletters, newsletterEdits, newslettersError, newslettersLoading]
  );

  const campaignsValue = React.useMemo(
    () => ({ campaigns, setCampaigns, edits: campaignEdits, setEdits: setCampaignEdits, loadError: campaignsError, loading: campaignsLoading }),
    [campaigns, campaignEdits, campaignsError, campaignsLoading]
  );

  // Signing out with a letter half-written is how the draft was lost the first
  // time. The gate unmounts the whole dashboard on sign-out, so nothing else gets
  // a chance to ask.
  const signOut = () => {
    if (window.velourAdminUnsaved && !window.confirm(`This ${window.velourAdminUnsaved} has unsaved changes. Sign out and lose them?`)) return;
    if (window.VelourAdminAuth) window.VelourAdminAuth.signOut();
  };

  // "Churned" is deliberately not a status. Someone who cancels an annual membership
  // keeps every entitlement until the period ends, so their status is correctly
  // `active` for months afterwards — which is why the Cancelled chip does not find
  // them and why the list looked identical before and after a cancellation.
  // The mark of leaving is cancelledAt, not status.
  const churnMonth = (() => { const n = new Date(); return `${n.getFullYear()}-${String(n.getMonth() + 1).padStart(2, "0")}`; })();
  const isChurned = (m) => !!(m.cancelledAt && String(m.cancelledAt).startsWith(churnMonth));

  const filtered = members.filter((m) => {
    if (statusFilter === "churned") { if (!isChurned(m)) return false; }
    else if (statusFilter !== "all" && m.status !== statusFilter) return false;
    const q = query.trim().toLowerCase();
    return !q || m.name.toLowerCase().includes(q) || m.email.toLowerCase().includes(q);
  });

  const filterBtn = (key, label) => (
    <button key={key} onClick={() => setStatusFilter(key)} style={{
      padding: "7px 14px", borderRadius: 999, fontSize: 12.5, cursor: "pointer", fontFamily: "var(--font-sans)",
      border: `1px solid ${statusFilter === key ? "var(--ink)" : "var(--border)"}`,
      background: statusFilter === key ? "var(--ink)" : "transparent",
      color: statusFilter === key ? "var(--parchment)" : "var(--text-secondary)", transition: ".25s",
    }}>{label}</button>
  );

  return (
    <MembersContext.Provider value={members}>
    <PostsContext.Provider value={postsValue}>
    <CampaignsContext.Provider value={campaignsValue}>
    <NewslettersContext.Provider value={newslettersValue}>
    <div style={{ background: "var(--surface-sunken)", minHeight: "100vh", fontFamily: "var(--font-sans)" }}>
      {/* top bar */}
      <header style={{ background: "var(--surface-card)", borderBottom: "1px solid var(--border)" }}>
        <style>{`
          .vlr-admin-nav { scrollbar-width: none; -ms-overflow-style: none; }
          .vlr-admin-nav::-webkit-scrollbar { display: none; }
          @media (max-width: 1000px) { .vlr-admin-who { display: none; } }
          @media (max-width: 720px) { .vlr-admin-eyebrow { display: none; } }
        `}</style>
        <div style={{ maxWidth: 1200, margin: "0 auto", padding: "0 32px", height: 64, display: "flex", alignItems: "center", justifyContent: "space-between", gap: 20 }}>
          <div style={{ display: "flex", alignItems: "baseline", gap: 14, flexShrink: 0 }}>
            <window.Wordmark size={17} onClick={() => onNavigate && onNavigate("home")} />
            <span className="vlr-admin-eyebrow" style={{ fontSize: 12, letterSpacing: "0.18em", textTransform: "uppercase", color: "var(--text-tertiary)" }}>Admin</span>
          </div>
          <nav className="vlr-admin-nav" style={{ display: "flex", gap: 22, minWidth: 0, overflowX: "auto", whiteSpace: "nowrap" }}>
            {[["members", "Members"], ["insights", "Insights"], ["blog", "Blog"], ["newsletter", "Newsletter"], ["together", "Held together"], ["emails", "Emails"]].map(([k, l]) => (
              <button key={k} onClick={() => setTab(k)} style={{ border: "none", background: "none", cursor: "pointer", fontFamily: "var(--font-sans)", fontSize: 14, paddingBottom: 4, flexShrink: 0, whiteSpace: "nowrap", color: tab === k ? "var(--text-primary)" : "var(--text-tertiary)", fontWeight: tab === k ? 500 : 400, borderBottom: tab === k ? "1.5px solid var(--ink)" : "1.5px solid transparent" }}>{l}</button>
            ))}
          </nav>
          <div style={{ display: "flex", alignItems: "center", gap: 14, flexShrink: 0 }}>
            {/* Says plainly which data is on screen, and is never hidden. This badge
                carries more weight than it used to: the fallback is now an EMPTY
                list, so without it a failed request and a genuinely empty cohort
                render identically — zeros everywhere, and no way to tell "nobody has
                signed up" from "we could not ask". It used to read "Sample data". */}
            {usingSample && (
              <span title={loadError ? `Could not load members: ${loadError}` : "Loading members…"}
                style={{ fontSize: 11.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "var(--amber-text)", background: "var(--amber-fill)", borderRadius: 999, padding: "5px 12px" }}>
                {loadError ? "Not loaded" : "Loading"}
              </span>
            )}
            {adminEmail && (
              <span className="vlr-admin-who" style={{ fontSize: 12.5, color: "var(--text-tertiary)", whiteSpace: "nowrap" }}>{adminEmail}</span>
            )}
            <button onClick={signOut}
              style={{ border: "1px solid var(--border)", background: "transparent", borderRadius: 999, padding: "6px 14px", cursor: "pointer", fontFamily: "var(--font-sans)", fontSize: 12.5, color: "var(--text-secondary)" }}>
              Sign out
            </button>
          </div>
        </div>
      </header>

      <main style={{ maxWidth: 1200, margin: "0 auto", padding: "34px 32px 72px" }}>
        {tab === "members" ? (
          <React.Fragment>
            {/* stat cards + signup bars */}
            <div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr) 1.4fr", gap: 16 }}>
              {adStats(members).map((s) => {
                const on = s.filter && statusFilter === s.filter;
                return (
                  <div key={s.label}
                    onClick={s.filter ? () => setStatusFilter(statusFilter === s.filter ? "all" : s.filter) : undefined}
                    role={s.filter ? "button" : undefined} tabIndex={s.filter ? 0 : undefined}
                    onKeyDown={s.filter ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setStatusFilter(statusFilter === s.filter ? "all" : s.filter); } } : undefined}
                    style={{ background: "var(--surface-card)", border: `1px solid ${on ? "var(--ink)" : "var(--border)"}`, borderRadius: 16, padding: "18px 20px", cursor: s.filter ? "pointer" : "default", transition: "border-color .25s" }}>
                    <div style={{ fontSize: 11, letterSpacing: "0.14em", textTransform: "uppercase", color: "var(--text-tertiary)" }}>{s.label}</div>
                    <div style={{ fontFamily: "var(--font-serif)", fontWeight: 500, fontSize: 32, color: "var(--text-primary)", margin: "8px 0 2px", lineHeight: 1 }}>{s.value}</div>
                    <div style={{ fontSize: 11.5, color: s.filter ? "var(--text-secondary)" : "var(--text-tertiary)", textDecoration: s.filter ? "underline" : "none", textUnderlineOffset: 3 }}>{on ? "showing them · click to clear" : s.sub}</div>
                  </div>
                );
              })}
              <div style={{ background: "var(--surface-card)", border: "1px solid var(--border)", borderRadius: 16, padding: "16px 20px 12px" }}>
                <div style={{ fontSize: 11, letterSpacing: "0.14em", textTransform: "uppercase", color: "var(--text-tertiary)", marginBottom: 8 }}>Signups · 8 weeks</div>
                <SignupBars members={members} />
              </div>
            </div>

            {/* add a member */}
            <div style={{ display: "flex", justifyContent: "flex-end", margin: "26px 0 0" }}>
              {!adding && (
                <button onClick={() => { setAdding(true); setAddedNote(null); }} style={{ padding: "9px 18px", borderRadius: 999, border: "1px solid var(--border-strong)", background: "transparent", color: "var(--text-secondary)", fontFamily: "var(--font-sans)", fontSize: 13, cursor: "pointer" }}>+ Add member</button>
              )}
            </div>
            {addedNote && (
              <div style={{ fontSize: 13, lineHeight: 1.55, color: "var(--sage-text)", background: "var(--sage-fill)", borderRadius: 12, padding: "12px 16px", margin: "14px 0 0" }}>{addedNote}</div>
            )}
            {adding && (
              <div style={{ marginTop: 16 }}>
                <AddMemberForm
                  onCancel={() => setAdding(false)}
                  onDone={(body) => {
                    setAdding(false);
                    // Put them in the list immediately rather than asking for a
                    // refresh: the whole point of this form is that the member is
                    // there afterwards.
                    //
                    // ONLY IF THE LIST ACTUALLY LOADED. `null` means the members
                    // fetch failed or is still in flight, and the header badge says
                    // so. Merging into it would replace that "not loaded" warning
                    // with a one-row list that reads as the whole cohort — a
                    // failure dressed up as a fact, which is the thing this
                    // dashboard has been rebuilt twice to stop doing. The banner
                    // above already confirms the member exists.
                    setLiveMembers((ms) => (ms === null ? null : [body.member, ...ms.filter((m) => m.id !== body.member.id)]));
                    setAddedNote(
                      `${body.member.email} is on Premium at no charge` +
                      (body.created ? "" : " — they already had an account, which now carries the pilot") +
                      (body.linkSent ? ". A sign-in link is on its way to them." : ". No sign-in link was sent; they can use Sign in on the site.")
                    );
                  }}
                />
              </div>
            )}

            {/* search + filters */}
            <div style={{ display: "flex", gap: 12, alignItems: "center", margin: "22px 0 14px", flexWrap: "wrap" }}>
              <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search name or email" style={{ flex: "0 1 300px", padding: "10px 14px", borderRadius: 999, border: "1px solid var(--border)", background: "var(--surface-card)", fontFamily: "var(--font-sans)", fontSize: 13.5, color: "var(--text-primary)", outline: "none" }} />
              <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
                {filterBtn("all", "All")}
                {Object.keys(STATUS_META).map((k) => filterBtn(k, STATUS_META[k].label))}
                {/* Sits with the status chips but is not one — see isChurned. Kept
                    visible so a filter set by clicking the tile can be seen and
                    cleared from the same row as every other filter. */}
                {filterBtn("churned", "Churned")}
              </div>
              <span style={{ marginLeft: "auto", fontSize: 12.5, color: "var(--text-tertiary)" }}>{filtered.length} of {members.length}</span>
            </div>

            {/* member table */}
            <div style={{ background: "var(--surface-card)", border: "1px solid var(--border)", borderRadius: 16, overflow: "hidden" }}>
              <div style={{ display: "grid", gridTemplateColumns: "2.2fr 1.4fr 1fr 1fr 1fr", gap: 12, padding: "12px 22px", fontSize: 10.5, letterSpacing: "0.14em", textTransform: "uppercase", color: "var(--text-tertiary)", borderBottom: "1px solid var(--border)" }}>
                <span>Member</span><span>Plan</span><span>Status</span><span>Signed up</span><span>Last active</span>
              </div>
              {filtered.map((m) => (
                <button key={m.id} onClick={() => setSelected(m)} style={{ display: "grid", gridTemplateColumns: "2.2fr 1.4fr 1fr 1fr 1fr", gap: 12, alignItems: "center", width: "100%", textAlign: "left", padding: "13px 22px", border: "none", borderBottom: "1px solid var(--divider)", background: "transparent", cursor: "pointer", fontFamily: "var(--font-sans)" }}
                  onMouseEnter={(e) => { e.currentTarget.style.background = "var(--surface-raised)"; }}
                  onMouseLeave={(e) => { e.currentTarget.style.background = "transparent"; }}>
                  <span style={{ minWidth: 0 }}>
                    <span style={{ display: "block", fontSize: 14.5, color: "var(--text-primary)", fontWeight: 500 }}>{m.name}</span>
                    <span style={{ display: "block", fontSize: 12, color: "var(--text-tertiary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{m.email}</span>
                  </span>
                  <span style={{ fontSize: 13, color: "var(--text-secondary)" }}>{planLabel(m)}</span>
                  <span style={{ display: "inline-flex", alignItems: "center", gap: 7, flexWrap: "wrap" }}>
                    <StatusChip status={m.status} />
                    {/* The whole reason churn was invisible here. A member who cancels
                        an annual plan reads Active for months, so the list looked
                        identical before and after — the only tell was opening the
                        drawer. This is that tell, on the row. */}
                    {isChurned(m) && m.status === "active" && (
                      <span title={m.cancelledAt ? "Cancelled " + adFmtDate(m.cancelledAt) : "Cancelled"}
                        style={{ fontSize: 10.5, letterSpacing: "0.1em", textTransform: "uppercase", color: "var(--terracotta-text, #8A4B28)", background: "var(--terracotta-fill, #F6E7DE)", borderRadius: 999, padding: "3px 9px", whiteSpace: "nowrap" }}>Ending</span>
                    )}
                  </span>
                  <span style={{ fontSize: 13, color: "var(--text-secondary)" }}>{adFmtDate(m.signedUp)}</span>
                  <span style={{ fontSize: 13, color: "var(--text-secondary)" }}>{adFmtDate(m.lastActive)}</span>
                </button>
              ))}
              {filtered.length === 0 && (
                // Two different empty states. "Adjust the filter" is useless advice
                // when the cohort itself is empty, and actively misleading when the
                // list is empty because the request failed rather than because
                // nobody has signed up.
                <div style={{ padding: "28px 22px", fontFamily: "var(--font-serif)", fontStyle: "italic", fontSize: 14.5, color: "var(--text-tertiary)" }}>
                  {members.length > 0
                    ? "No members match. Nothing dramatic — adjust the filter."
                    : loadError
                      ? "Members could not be loaded, so this is empty for the wrong reason. It is not a count of nobody."
                      : "No members yet. This fills in with the first signup."}
                </div>
              )}
            </div>
          </React.Fragment>
        ) : tab === "insights" ? (
          <InsightsTab />
        ) : tab === "blog" ? (
          <BlogTab />
        ) : tab === "newsletter" ? (
          <NewsletterTab />
        ) : tab === "together" ? (
          <HeldTogetherTab />
        ) : (
          <EmailsTab />
        )}
      </main>

      <MemberDetail m={selected} onClose={() => setSelected(null)}
        onMemberChanged={(updated) => {
          setLiveMembers((ms) => (ms === null ? null : ms.map((x) => (x.id === updated.id ? updated : x))));
          setSelected(updated);
        }}
        onMemberDeleted={(id, body) => {
          setLiveMembers((ms) => (ms === null ? null : ms.filter((x) => x.id !== id)));
          setSelected(null);
          setAddedNote(
            `${body.email} has been deleted, along with everything in that account` +
            (body.photosRemoved ? ` and ${body.photosRemoved} stored photo${body.photosRemoved === 1 ? "" : "s"}` : "") + "."
          );
        }} />
    </div>
    </NewslettersContext.Provider>
    </CampaignsContext.Provider>
    </PostsContext.Provider>
    </MembersContext.Provider>
  );
}

window.AdminDashboard = AdminDashboard;
