/* Bahá'ís of Dublin — admin SPA, mounted at /admin/*.
 *
 * Issues #11 (Supabase Auth + Google OAuth + contributor allowlist) and #14
 * (admin shell + dashboard). This is a *separate* React root from the public
 * site (see index.html): when the path starts with /admin the bootstrap renders
 * <AdminApp/> instead of the public <App/>. No public-site chrome leaks in here,
 * and the (heavier) Supabase Auth client is loaded lazily so public pages stay
 * light.
 *
 * Auth model (see supabase/migrations/20260609120000_contributors.sql):
 *   1. Google sign-in via Supabase Auth authenticates the *human*.
 *   2. The `contributors` table is the explicit allowlist. A signed-in Google
 *      account with no active row there is "an authenticated nobody" — RLS hands
 *      it nothing, and we show the not-authorized page. The gate is the
 *      contributors SELECT: an active contributor can read their own row
 *      (is_active_contributor()); everyone else gets zero rows.
 *
 * The first admin can't be self-served (insert needs is_admin()) — they're
 * seeded directly in the DB after their first sign-in. See docs/HANDOFF.md.
 */

/* ── Routing ──────────────────────────────────────────────────────────────
   Admin has its own little route table, independent of the public ROUTES in
   site-shell.jsx. Deep links (/admin/inbox) work because Cloudflare Pages' SPA
   fallback serves the root index.html for any unknown path, and the bootstrap
   re-dispatches to AdminApp on /admin/*. */
const ADMIN_SIGN_IN_PATH = '/admin/sign-in';

/* RBAC tiers (#63): global_admin (Matthew) / admin (LSA members) / member.
   isAdminRole covers both admin tiers — mirrors the DB's is_admin(). */
const ROLE_LABELS = { global_admin: 'Global admin', admin: 'Admin', member: 'Member' };
const isAdminRole = (role) => role === 'admin' || role === 'global_admin';
const ADMIN_NAV = [
  { id: 'dashboard', path: '/admin', label: 'Dashboard' },
  { id: 'inbox', path: '/admin/inbox', label: 'Inbox' },
  { id: 'posts', path: '/admin/posts', label: 'Posts' },
  { id: 'events', path: '/admin/events', label: 'Events' },
  { id: 'settings', path: '/admin/settings', label: 'Settings' },
];

const normalizeAdminPath = (pathname) => {
  let p = (pathname || '/').replace(/\/index\.html$/, '/');
  while (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1);
  return p || '/';
};
const adminPageForPath = (pathname) => {
  const p = normalizeAdminPath(pathname);
  if (p === ADMIN_SIGN_IN_PATH) return 'sign-in';
  const hit = ADMIN_NAV.find((r) => r.path === p);
  return hit ? hit.id : 'dashboard'; // /admin, /admin/, and unknown /admin/* land on the dashboard
};
const adminPathForPage = (id) => (ADMIN_NAV.find((r) => r.id === id) || ADMIN_NAV[0]).path;

/* ── Supabase client (lazy, singleton) ────────────────────────────────────
   Loaded only once the admin section actually mounts. Mirrors the public
   site's loadTurnstileScript pattern: inject the CDN <script>, await onload,
   then construct the client from /api/config (supabaseUrl + the public,
   RLS-guarded anon key). PKCE flow + detectSessionInUrl so the OAuth redirect
   back to /admin is exchanged for a session automatically. */
const SUPABASE_UMD =
  'https://unpkg.com/@supabase/supabase-js@2.48.1/dist/umd/supabase.js';

const loadScriptOnce = (src) =>
  new Promise((resolve, reject) => {
    if (document.querySelector(`script[src="${src}"]`)) return resolve();
    const s = document.createElement('script');
    s.src = src;
    s.crossOrigin = 'anonymous';
    s.onload = () => resolve();
    s.onerror = () => reject(new Error(`Failed to load ${src}`));
    document.head.appendChild(s);
  });

let _supabasePromise = null;
const getSupabase = () => {
  if (_supabasePromise) return _supabasePromise;
  _supabasePromise = (async () => {
    const cfg = await fetch('/api/config').then((r) => r.json());
    if (!cfg.supabaseUrl || !cfg.supabaseAnonKey) {
      throw new Error('Supabase config missing from /api/config');
    }
    if (!window.supabase) await loadScriptOnce(SUPABASE_UMD);
    return window.supabase.createClient(cfg.supabaseUrl, cfg.supabaseAnonKey, {
      auth: {
        flowType: 'pkce',
        detectSessionInUrl: true,
        persistSession: true,
        autoRefreshToken: true,
      },
    });
  })();
  return _supabasePromise;
};

/* ── Small presentational helpers ─────────────────────────────────────────*/
const AdminGoogleMark = () => (
  // Google "G" — official four-colour mark, inline so no extra request.
  <svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true">
    <path fill="#4285F4" d="M17.64 9.2c0-.64-.06-1.25-.16-1.84H9v3.48h4.84a4.14 4.14 0 0 1-1.8 2.72v2.26h2.92c1.7-1.57 2.68-3.88 2.68-6.62z" />
    <path fill="#34A853" d="M9 18c2.43 0 4.47-.8 5.96-2.18l-2.92-2.26c-.8.54-1.84.86-3.04.86-2.34 0-4.32-1.58-5.03-3.7H.96v2.33A9 9 0 0 0 9 18z" />
    <path fill="#FBBC05" d="M3.97 10.72a5.4 5.4 0 0 1 0-3.44V4.95H.96a9 9 0 0 0 0 8.1l3.01-2.33z" />
    <path fill="#EA4335" d="M9 3.58c1.32 0 2.5.45 3.44 1.35l2.58-2.58C13.46.89 11.43 0 9 0A9 9 0 0 0 .96 4.95l3.01 2.33C4.68 5.16 6.66 3.58 9 3.58z" />
  </svg>
);

const AdminBrand = () => (
  <div className="bd-admin__brand">
    <NineStar size={22} stroke="var(--bd-gold)" strokeWidth={1} />
    <span>
      <strong>Bahá'ís of Dublin</strong>
      <span className="bd-admin__brand-sub">Community admin</span>
    </span>
  </div>
);

const AdminCentered = ({ children }) => (
  <div className="bd-admin bd-admin--centered">
    <div className="bd-admin__card">{children}</div>
  </div>
);

/* ── Auth screens ─────────────────────────────────────────────────────────*/
const SignIn = ({ onSignIn, busy, error }) => (
  <AdminCentered>
    <AdminBrand />
    <h1 className="bd-admin__title">Sign in</h1>
    <p className="bd-admin__lead">
      Community members only. Sign in with the Google account on the community
      allowlist.
    </p>
    <button type="button" className="bd-admin__google-btn" onClick={onSignIn} disabled={busy}>
      <AdminGoogleMark />
      {busy ? 'Redirecting…' : 'Sign in with Google'}
    </button>
    {error && <p className="bd-admin__error">{error}</p>}
  </AdminCentered>
);

const NotAuthorized = ({ email, onSignOut }) => (
  <AdminCentered>
    <AdminBrand />
    <h1 className="bd-admin__title">Not authorized</h1>
    <p className="bd-admin__lead">
      {email ? <>You're signed in as <strong>{email}</strong>, but that</> : 'That'}{' '}
      account isn't on the community allowlist, so there's nothing here for it
      yet.
    </p>
    <p className="bd-admin__muted">
      If you should have access, ask an admin to add you, then sign in again.
    </p>
    <button type="button" className="bd-admin__btn bd-admin__btn--ghost" onClick={onSignOut}>
      Sign out
    </button>
  </AdminCentered>
);

const AdminLoading = ({ label = 'Loading…' }) => (
  <AdminCentered>
    <div className="bd-admin__spinner" aria-hidden="true" />
    <p className="bd-admin__muted">{label}</p>
  </AdminCentered>
);

/* ── Shell (sidebar + topbar) ─────────────────────────────────────────────*/
const AdminSidebar = ({ page, onNavigate, me, onSignOut }) => (
  <nav className="bd-admin__sidebar" aria-label="Admin">
    <AdminBrand />
    <ul className="bd-admin__navlist">
      {ADMIN_NAV.map((item) => (
        <li key={item.id}>
          <a
            href={item.path}
            onClick={(e) => { e.preventDefault(); onNavigate(item.id); }}
            className={`bd-admin__navlink ${page === item.id ? 'is-active' : ''}`}
            aria-current={page === item.id ? 'page' : undefined}
          >
            {item.label}
          </a>
        </li>
      ))}
    </ul>
    <div className="bd-admin__sidebar-foot">
      <div className="bd-admin__me">
        <span className="bd-admin__me-name">{me.display_name}</span>
        <span className="bd-admin__me-role">{ROLE_LABELS[me.role] || me.role}</span>
      </div>
      <button type="button" className="bd-admin__btn bd-admin__btn--ghost bd-admin__signout" onClick={onSignOut}>
        Sign out
      </button>
    </div>
  </nav>
);

const AdminShell = ({ page, onNavigate, me, onSignOut, mobile, children }) => {
  const [menuOpen, setMenuOpen] = React.useState(false);
  const title = ADMIN_NAV.find((r) => r.id === page)?.label || 'Dashboard';
  const navigate = (id) => { onNavigate(id); setMenuOpen(false); };
  return (
    <div className={`bd-admin bd-admin--shell ${mobile ? 'is-mobile' : ''} ${menuOpen ? 'is-menu-open' : ''}`}>
      {mobile && (
        <header className="bd-admin__topbar">
          <button
            type="button"
            className="bd-admin__menu-btn"
            onClick={() => setMenuOpen((v) => !v)}
            aria-label="Menu"
            aria-expanded={menuOpen}
          >
            <span /><span /><span />
          </button>
          <span className="bd-admin__topbar-title">{title}</span>
        </header>
      )}
      {(!mobile || menuOpen) && (
        <AdminSidebar page={page} onNavigate={navigate} me={me} onSignOut={onSignOut} />
      )}
      {mobile && menuOpen && (
        <div className="bd-admin__scrim" onClick={() => setMenuOpen(false)} aria-hidden="true" />
      )}
      <main className="bd-admin__main">{children}</main>
    </div>
  );
};

/* ── Dashboard ────────────────────────────────────────────────────────────
   Surfaces the four things issue #14 asks for, each read straight from Supabase
   under the contributor's JWT (RLS does the gating). audit_log is admin-only by
   RLS, so non-admins simply see an empty-but-explained activity panel. */
const fmtWhen = (iso) => {
  if (!iso) return '';
  try {
    return new Date(iso).toLocaleString('en-US', {
      month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',
    });
  } catch { return iso; }
};
const fmtDay = (iso) => {
  if (!iso) return '';
  try {
    return new Date(iso).toLocaleDateString('en-US', {
      weekday: 'short', month: 'short', day: 'numeric',
    });
  } catch { return iso; }
};

const StatCard = ({ label, value, hint, onClick }) => {
  const Tag = onClick ? 'button' : 'div';
  return (
    <Tag type={onClick ? 'button' : undefined} className={`bd-admin__stat ${onClick ? 'is-clickable' : ''}`} onClick={onClick}>
      <span className="bd-admin__stat-value">{value}</span>
      <span className="bd-admin__stat-label">{label}</span>
      {hint && <span className="bd-admin__stat-hint">{hint}</span>}
    </Tag>
  );
};

const Panel = ({ title, action, children }) => (
  <section className="bd-admin__panel">
    <div className="bd-admin__panel-head">
      <h2 className="bd-admin__panel-title">{title}</h2>
      {action}
    </div>
    {children}
  </section>
);

const EVENTS_SHOWN = 8; // list display cap — counts always come from the full window

const Dashboard = ({ sb, me, onNavigate }) => {
  const [state, setState] = React.useState({ loading: true, error: null });
  const [data, setData] = React.useState({
    newInbox: 0, drafts: [], draftCount: 0, events: [], activity: [],
    failed: { inbox: false, drafts: false, events: false, activity: false },
  });

  React.useEffect(() => {
    let alive = true;
    (async () => {
      const nowIso = new Date().toISOString();
      const horizon = new Date(Date.now() + 60 * 864e5).toISOString(); // 60 days out
      // Supabase queries resolve to { data, error } rather than throwing, so
      // each section fails independently — one broken query must not blank the
      // successful ones (a zeroed stat would read as "all clear").
      const [inbox, drafts, events, activity] = await Promise.all([
        sb.from('contact_submissions').select('id', { count: 'exact', head: true }).eq('status', 'new'),
        sb.from('posts').select('id, title, updated_at, slug', { count: 'exact' })
          .eq('is_draft', true).order('updated_at', { ascending: false }).limit(5),
        // The whole 60-day window, unlimited: the attention count must see every
        // event (a limit would undercount), and calendar-sync soft-deletes must
        // stay hidden. The panel renders only the first EVENTS_SHOWN.
        sb.from('events').select('id, title, start_at, welcome_text, host_name')
          .is('deleted_at', null)
          .gte('start_at', nowIso).lte('start_at', horizon).order('start_at', { ascending: true }),
        // audit_log is admin-only by RLS; non-admins get an empty set (no error).
        isAdminRole(me.role)
          ? sb.from('audit_log').select('id, action, target_type, created_at, metadata')
              .order('created_at', { ascending: false }).limit(8)
          : Promise.resolve({ data: [], error: null }),
      ]);
      if (!alive) return;
      const failed = {
        inbox: !!inbox.error, drafts: !!drafts.error,
        events: !!events.error, activity: !!activity.error,
      };
      setData({
        newInbox: inbox.count || 0,
        drafts: drafts.data || [],
        draftCount: drafts.count ?? (drafts.data ? drafts.data.length : 0),
        events: events.data || [],
        activity: activity.data || [],
        failed,
      });
      const failedNames = Object.keys(failed).filter((k) => failed[k]);
      setState({
        loading: false,
        error: failedNames.length ? `Couldn't load: ${failedNames.join(', ')}.` : null,
      });
    })();
    return () => { alive = false; };
  }, [sb, me.role]);

  if (state.loading) return <AdminLoading label="Loading dashboard…" />;

  const needsAttention = data.events.filter((e) => !e.welcome_text || !e.host_name);
  const statHint = (fail, count, someLabel, noneLabel) => {
    if (fail) return 'unavailable';
    return count ? someLabel : noneLabel;
  };

  return (
    <div className="bd-admin__page">
      <h1 className="bd-admin__h1">Welcome, {me.display_name.split(' ')[0]}</h1>
      {state.error && (
        <p className="bd-admin__error">{state.error} The other sections below are live.</p>
      )}

      <div className="bd-admin__stats">
        <StatCard label="New inbox messages" value={data.failed.inbox ? '—' : data.newInbox}
          hint={statHint(data.failed.inbox, data.newInbox, 'awaiting triage', 'all clear')}
          onClick={() => onNavigate('inbox')} />
        <StatCard label="Drafts in progress" value={data.failed.drafts ? '—' : data.draftCount}
          hint={statHint(data.failed.drafts, data.draftCount, 'unpublished', 'none')}
          onClick={() => onNavigate('posts')} />
        <StatCard label="Events needing attention" value={data.failed.events ? '—' : needsAttention.length}
          hint={statHint(data.failed.events, needsAttention.length, 'missing host/welcome', 'all enriched')}
          onClick={() => onNavigate('events')} />
      </div>

      <div className="bd-admin__cols">
        <Panel title="Upcoming events"
          action={<a href="/admin/events" className="bd-admin__link"
            onClick={(e) => { e.preventDefault(); onNavigate('events'); }}>All events →</a>}>
          {renderEventsPanel(data)}
        </Panel>

        <Panel title="Drafts in progress"
          action={<a href="/admin/posts" className="bd-admin__link"
            onClick={(e) => { e.preventDefault(); onNavigate('posts'); }}>All posts →</a>}>
          {renderDraftsPanel(data)}
        </Panel>
      </div>

      <Panel title="Recent activity">
        {renderActivityPanel(data, me)}
      </Panel>
    </div>
  );
};

const renderEventsPanel = (data) => {
  if (data.failed.events) return <p className="bd-admin__muted">Couldn't load events.</p>;
  if (data.events.length === 0) return <p className="bd-admin__muted">No events in the next 60 days.</p>;
  const shown = data.events.slice(0, EVENTS_SHOWN);
  const more = data.events.length - shown.length;
  return (
    <ul className="bd-admin__list">
      {shown.map((ev) => {
        const attn = !ev.welcome_text || !ev.host_name;
        return (
          <li key={ev.id} className="bd-admin__list-row">
            <span className="bd-admin__list-main">{ev.title}</span>
            <span className="bd-admin__list-meta">{fmtDay(ev.start_at)}</span>
            {attn && <span className="bd-admin__badge">needs attention</span>}
          </li>
        );
      })}
      {more > 0 && (
        <li className="bd-admin__list-row">
          <span className="bd-admin__list-meta">+{more} more in the next 60 days</span>
        </li>
      )}
    </ul>
  );
};

const renderDraftsPanel = (data) => {
  if (data.failed.drafts) return <p className="bd-admin__muted">Couldn't load drafts.</p>;
  if (data.drafts.length === 0) return <p className="bd-admin__muted">No drafts right now.</p>;
  return (
    <ul className="bd-admin__list">
      {data.drafts.map((p) => (
        <li key={p.id} className="bd-admin__list-row">
          <span className="bd-admin__list-main">{p.title || '(untitled)'}</span>
          <span className="bd-admin__list-meta">edited {fmtWhen(p.updated_at)}</span>
        </li>
      ))}
    </ul>
  );
};

const renderActivityPanel = (data, me) => {
  if (!isAdminRole(me.role)) return <p className="bd-admin__muted">The activity log is visible to admins.</p>;
  if (data.failed.activity) return <p className="bd-admin__muted">Couldn't load the activity log.</p>;
  if (data.activity.length === 0) return <p className="bd-admin__muted">No recorded activity yet.</p>;
  return (
    <ul className="bd-admin__list">
      {data.activity.map((a) => (
        <li key={a.id} className="bd-admin__list-row">
          <span className="bd-admin__list-main">{a.action}</span>
          {a.target_type && <span className="bd-admin__list-tag">{a.target_type}</span>}
          <span className="bd-admin__list-meta">{fmtWhen(a.created_at)}</span>
        </li>
      ))}
    </ul>
  );
};

/* ── Shared list machinery ────────────────────────────────────────────────
   Every capability-gated admin list (inbox #15, posts #18, special periods
   #68) has the same skeleton: probe the capability via RPC (so a member
   without the grant sees a clear no-access note instead of a misleading
   empty list — RLS returns zero rows either way), load the first keyset
   page, and append older pages on demand.

   Keyset pagination: each page fetches strictly past the last loaded row's
   (sort-key, id) tuple. Offsets would drift when rows arrive or change
   filter membership mid-traversal (skipping rows), and a single growing
   limit would silently stop at PostgREST's 1000-row response cap. The id
   tiebreak keeps ties deterministic.

   The generation counter is bumped whenever the list is (re)loaded from
   scratch (filter change, return from a detail view), so an in-flight
   load-more from the previous incarnation can't overwrite the fresh rows
   when it finally resolves. */
const useCapabilityList = ({ sb, cap, fetchPage, pageSize, active }) => {
  const [access, setAccess] = React.useState(null); // null = probing
  const [rows, setRows] = React.useState([]);
  const [lastPageFull, setLastPageFull] = React.useState(false);
  const [loading, setLoading] = React.useState(true);
  const [loadingMore, setLoadingMore] = React.useState(false);
  const [error, setError] = React.useState(null);
  const genRef = React.useRef(0);

  React.useEffect(() => {
    let alive = true;
    sb.rpc('has_capability', { cap }).then((res) => {
      if (alive) setAccess(res.error ? false : !!res.data);
    });
    return () => { alive = false; };
  }, [sb, cap]);

  React.useEffect(() => {
    if (access !== true || !active) return undefined;
    let alive = true;
    genRef.current += 1;
    (async () => {
      setLoading(true);
      const res = await fetchPage(null);
      if (!alive) return;
      if (res.error) { setError(res.error.message); }
      else {
        const page = res.data || [];
        setRows(page);
        setLastPageFull(page.length === pageSize);
        setError(null);
      }
      setLoading(false);
    })();
    return () => { alive = false; };
  }, [access, active, fetchPage, pageSize]);

  const loadMore = async () => {
    if (rows.length === 0) return;
    const gen = genRef.current;
    setLoadingMore(true);
    const res = await fetchPage(rows[rows.length - 1]);
    if (gen !== genRef.current) {
      // The list reloaded mid-flight — discard the stale page, but still
      // clear the loading flag so the control doesn't wedge.
      setLoadingMore(false);
      return;
    }
    if (res.error) { setError(res.error.message); }
    else {
      const page = res.data || [];
      // Belt-and-braces: the keyset filter shouldn't produce duplicates, but
      // a row whose sort key was edited between requests could reappear.
      const seen = new Set(rows.map((r) => r.id));
      setRows([...rows, ...page.filter((r) => !seen.has(r.id))]);
      setLastPageFull(page.length === pageSize);
      setError(null);
    }
    setLoadingMore(false);
  };

  return { access, rows, setRows, lastPageFull, loading, loadingMore, error, loadMore };
};

const AdminFilterTabs = ({ filters, filter, onChange }) => (
  <div className="bd-admin__filters" role="tablist" aria-label="Filter by status">
    {filters.map((f) => (
      <button
        key={f.id}
        type="button"
        role="tab"
        aria-selected={filter === f.id}
        className={`bd-admin__filter ${filter === f.id ? 'is-active' : ''}`}
        onClick={() => onChange(f.id)}
      >
        {f.label}
      </button>
    ))}
  </div>
);

const AdminLoadMore = ({ visible, busy, label, onClick }) => {
  if (!visible) return null;
  return (
    <div className="bd-admin__load-more">
      <button type="button" className="bd-admin__btn bd-admin__btn--ghost" disabled={busy} onClick={onClick}>
        {busy ? 'Loading…' : label}
      </button>
    </div>
  );
};

/* ── Inbox (#15) ──────────────────────────────────────────────────────────
   The primary triage workspace for contact-form messages. Access is the
   'inbox' capability (#63): admins implicitly, members via a grant. */
const STATUS_META = {
  new: { label: 'New', cls: 'is-new' },
  read: { label: 'Read', cls: 'is-read' },
  responded: { label: 'Responded', cls: 'is-responded' },
  needs_careful_response: { label: 'Needs care', cls: 'is-care' },
  archived: { label: 'Archived', cls: 'is-archived' },
  spam: { label: 'Spam', cls: 'is-spam' },
};
const INBOX_FILTERS = [
  { id: 'active', label: 'Active' }, // everything except spam + archived
  { id: 'new', label: 'New' },
  { id: 'needs_careful_response', label: 'Needs care' },
  { id: 'responded', label: 'Responded' },
  { id: 'archived', label: 'Archived' },
  { id: 'spam', label: 'Spam' },
];
const firstLine = (message) => {
  const line = (message || '').split('\n')[0];
  return line.length > 96 ? `${line.slice(0, 96)}…` : line;
};
const RESPONSE_SUBJECT = "Re: your message to the Bahá'ís of Dublin";

const StatusBadge = ({ status }) => {
  const meta = STATUS_META[status] || { label: status, cls: '' };
  return <span className={`bd-admin__badge bd-admin__badge--status ${meta.cls}`}>{meta.label}</span>;
};

const READ_TRANSITION_NOTICE = "Couldn't mark this as read — the list may still show it as new.";

/* First open moves 'new' to 'read'. The update is conditional on status still
   being 'new' and returns the updated row, so a concurrent triage by someone
   else (zero rows matched, no error) can't be mistaken for success — in that
   case the persisted status is fetched instead. Returns { status } to adopt,
   { notice } to surface, or {} when isStale() says the caller is gone (the
   fallback fetch is skipped rather than wasted on an unmounted view). */
const resolveReadTransition = async (sb, id, isStale) => {
  const upd = await sb.from('contact_submissions')
    .update({ status: 'read' })
    .eq('id', id).eq('status', 'new')
    .select('status')
    .maybeSingle();
  if (upd.error) return { notice: READ_TRANSITION_NOTICE };
  if (upd.data) return { status: upd.data.status };
  if (isStale?.()) return {};
  const cur = await sb.from('contact_submissions').select('status').eq('id', id).maybeSingle();
  if (cur.error || !cur.data) return { notice: READ_TRANSITION_NOTICE };
  return { status: cur.data.status };
};

const InboxDetail = ({ sb, me, id, onBack, onChanged }) => {
  const [row, setRow] = React.useState(null);
  const [error, setError] = React.useState(null);
  const [notes, setNotes] = React.useState('');
  const [notesSaved, setNotesSaved] = React.useState(true);
  const [busy, setBusy] = React.useState(false);

  React.useEffect(() => {
    let alive = true;
    (async () => {
      const res = await sb.from('contact_submissions').select('*').eq('id', id).maybeSingle();
      if (!alive) return;
      if (res.error || !res.data) { setError(res.error?.message || 'Message not found.'); return; }
      setRow(res.data);
      setNotes(res.data.notes || '');
      if (res.data.status === 'new') {
        const t = await resolveReadTransition(sb, id, () => !alive);
        if (!alive) return;
        if (t.notice) {
          setError(t.notice);
        } else if (t.status) {
          setRow((r) => (r ? { ...r, status: t.status } : r));
          onChanged?.(id, { status: t.status });
        }
      }
    })();
    return () => { alive = false; };
  }, [sb, id, onChanged]);

  const setStatus = async (status) => {
    setBusy(true);
    const patch = { status };
    if (status === 'responded') {
      patch.responded_at = new Date().toISOString();
      patch.responded_by = me.id;
    }
    const res = await sb.from('contact_submissions').update(patch).eq('id', id);
    setBusy(false);
    if (res.error) { setError(res.error.message); return; }
    setRow((r) => (r ? { ...r, ...patch } : r));
    setError(null);
    onChanged?.(id, patch);
  };

  const saveNotes = async () => {
    setBusy(true);
    const res = await sb.from('contact_submissions').update({ notes }).eq('id', id);
    setBusy(false);
    if (res.error) { setError(res.error.message); return; }
    setNotesSaved(true);
    setError(null);
  };

  if (error && !row) {
    return (
      <div className="bd-admin__page">
        <button type="button" className="bd-admin__link bd-admin__back" onClick={onBack}>← Inbox</button>
        <p className="bd-admin__error">{error}</p>
      </div>
    );
  }
  if (!row) return <AdminLoading label="Opening message…" />;

  const mailto = `mailto:${encodeURIComponent(row.email)}?subject=${encodeURIComponent(RESPONSE_SUBJECT)}`;
  const statusActions = [
    { status: 'responded', label: 'Mark responded' },
    { status: 'needs_careful_response', label: 'Needs careful response' },
    { status: 'archived', label: 'Archive' },
    { status: 'spam', label: 'Mark spam' },
  ];

  return (
    <div className="bd-admin__page">
      <button type="button" className="bd-admin__link bd-admin__back" onClick={onBack}>← Inbox</button>
      {error && <p className="bd-admin__error">{error}</p>}

      <div className="bd-admin__msg-head">
        <div>
          <h1 className="bd-admin__h1">{row.name}</h1>
          <p className="bd-admin__msg-meta">
            <a className="bd-admin__link" href={mailto}>{row.email}</a>
            {row.phone && <span> · {row.phone}</span>}
            <span> · {fmtWhen(row.submitted_at)}</span>
          </p>
          {row.interests?.length > 0 && (
            <p className="bd-admin__chips">
              {row.interests.map((it) => <span key={it} className="bd-admin__chip">{it}</span>)}
            </p>
          )}
          {row.source && <p className="bd-admin__msg-source">Heard about us: {row.source}</p>}
        </div>
        <StatusBadge status={row.status} />
      </div>

      <div className="bd-admin__msg-body">{row.message}</div>

      <div className="bd-admin__msg-actions">
        <a className="bd-admin__btn bd-admin__btn--primary" href={mailto}>Reply by email</a>
        {statusActions.map((a) => (
          <button
            key={a.status}
            type="button"
            className="bd-admin__btn bd-admin__btn--ghost"
            disabled={busy || row.status === a.status}
            onClick={() => setStatus(a.status)}
          >
            {a.label}
          </button>
        ))}
        {(row.status === 'archived' || row.status === 'spam') && (
          <button type="button" className="bd-admin__btn bd-admin__btn--ghost" disabled={busy} onClick={() => setStatus('read')}>
            Move back to inbox
          </button>
        )}
      </div>

      <div className="bd-admin__notes">
        <h2 className="bd-admin__panel-title">Internal notes</h2>
        <p className="bd-admin__muted">Private to the team — never shown to the sender.</p>
        <textarea
          className="bd-admin__notes-input"
          rows={4}
          value={notes}
          onChange={(e) => { setNotes(e.target.value); setNotesSaved(false); }}
        />
        <div className="bd-admin__notes-foot">
          <button type="button" className="bd-admin__btn bd-admin__btn--ghost" disabled={busy || notesSaved} onClick={saveNotes}>
            {notesSaved ? 'Saved' : 'Save notes'}
          </button>
        </div>
      </div>
    </div>
  );
};

const INBOX_PAGE_SIZE = 200;

// Does a row still belong under the current filter after a status change?
const rowMatchesFilter = (status, filter) => {
  if (filter === 'active') return status !== 'spam' && status !== 'archived';
  return status === filter;
};

const Inbox = ({ sb, me }) => {
  const [filter, setFilter] = React.useState('active');
  const [selectedId, setSelectedId] = React.useState(null);

  // Keyset page on (submitted_at, id) descending; timestamps are quoted for
  // PostgREST's or= syntax.
  const fetchPage = React.useCallback((cursor) => {
    let q = sb.from('contact_submissions')
      .select('id, submitted_at, name, message, interests, status')
      .order('submitted_at', { ascending: false })
      .order('id', { ascending: false })
      .limit(INBOX_PAGE_SIZE);
    if (filter === 'active') q = q.not('status', 'in', '(spam,archived)');
    else q = q.eq('status', filter);
    if (cursor) {
      q = q.or(
        `submitted_at.lt."${cursor.submitted_at}",and(submitted_at.eq."${cursor.submitted_at}",id.lt.${cursor.id})`
      );
    }
    return q;
  }, [sb, filter]);

  // The list stays mounted behind the detail view (active always true):
  // detail changes patch rows in place rather than refetching.
  const { access, rows, setRows, lastPageFull, loading, loadingMore, error, loadMore } =
    useCapabilityList({ sb, cap: 'inbox', fetchPage, pageSize: INBOX_PAGE_SIZE, active: true });

  // Detail-view changes update the loaded rows in place (dropping rows that no
  // longer match the filter) instead of refetching — a refetch would snap a
  // deep-scrolled list back to the first page.
  const applyRowPatch = React.useCallback((id, patch) => {
    setRows((rs) => {
      if (patch.status && !rowMatchesFilter(patch.status, filter)) {
        return rs.filter((r) => r.id !== id);
      }
      return rs.map((r) => (r.id === id ? { ...r, ...patch } : r));
    });
  }, [filter]);

  if (access === null) return <AdminLoading label="Opening the inbox…" />;
  if (access === false) {
    return (
      <div className="bd-admin__page">
        <h1 className="bd-admin__h1">Inbox</h1>
        <p className="bd-admin__muted">
          The inbox holds personal messages, so it needs the inbox permission —
          ask the global admin for access.
        </p>
      </div>
    );
  }
  if (selectedId) {
    return <InboxDetail sb={sb} me={me} id={selectedId} onBack={() => setSelectedId(null)} onChanged={applyRowPatch} />;
  }

  let body;
  if (loading) {
    body = <p className="bd-admin__muted">Loading messages…</p>;
  } else if (rows.length === 0) {
    body = <p className="bd-admin__muted">Nothing here — all clear.</p>;
  } else {
    body = (
      <ul className="bd-admin__inbox-list">
        {rows.map((r) => {
          const care = r.status === 'needs_careful_response';
          const fresh = r.status === 'new';
          return (
            <li key={r.id}>
              <button
                type="button"
                className={`bd-admin__inbox-row ${fresh ? 'is-new' : ''} ${care ? 'is-care' : ''}`}
                onClick={() => setSelectedId(r.id)}
              >
                <span className="bd-admin__inbox-when">{fmtWhen(r.submitted_at)}</span>
                <span className="bd-admin__inbox-name">{r.name}</span>
                <span className="bd-admin__inbox-snippet">{firstLine(r.message)}</span>
                <span className="bd-admin__inbox-tags">
                  {r.interests?.slice(0, 3).map((it) => <span key={it} className="bd-admin__chip">{it}</span>)}
                  <StatusBadge status={r.status} />
                </span>
              </button>
            </li>
          );
        })}
      </ul>
    );
  }

  return (
    <div className="bd-admin__page">
      <h1 className="bd-admin__h1">Inbox</h1>
      {error && <p className="bd-admin__error">{error}</p>}
      <AdminFilterTabs filters={INBOX_FILTERS} filter={filter} onChange={setFilter} />
      {body}
      <AdminLoadMore visible={!loading && lastPageFull} busy={loadingMore}
        label="Load older messages" onClick={loadMore} />
    </div>
  );
};

/* ── Posts (#18) ──────────────────────────────────────────────────────────
   The "Life Together" post editor. Writing is the 'posts' capability (#63):
   admins implicitly, members via a grant — probed with an RPC like the inbox
   so a member without the grant sees a clear no-access note instead of a
   silent RLS write failure. The body is plain text for now (paragraph breaks
   preserved); markdown rendering is a follow-up issue. */
const POST_FILTERS = [
  { id: 'draft', label: 'Drafts' },
  { id: 'published', label: 'Published' },
  { id: 'all', label: 'All' },
];
const POSTS_PAGE_SIZE = 100;
const POST_FIELDS =
  'id, slug, title, body_markdown, published_at, author_id, is_draft, tags, created_at, updated_at';

const PG_UNIQUE_VIOLATION = '23505';
const SLUG_TAKEN_NOTICE = 'That slug is already in use by another post — pick a different one.';
const POST_RACE_PUBLISHED_NOTICE =
  'This post was published by someone else while you were editing — your changes were ' +
  'not saved. The controls below now match; use "Save changes" to keep your edits.';
const POST_RACE_DRAFT_NOTICE =
  'This post was returned to draft by someone else while you were editing — your changes ' +
  'were not saved. The controls below now match; use "Save draft" to keep your edits.';
const POST_GONE_NOTICE = 'This post no longer exists — someone may have deleted it.';
const POST_SAVE_NOTICES = {
  draft: 'Draft saved.',
  save: 'Changes saved.',
  publish: 'Published.',
  unpublish: "Unpublished — it's a draft again.",
};

/* Slug from a title: fold accents away (Bahá'í → bahai), then hyphenate every
   run of non-alphanumerics. Runs collapse to a single "-", so at most one
   leading and one trailing hyphen remain — trimmed without regex quantifiers. */
const trimHyphens = (s) => {
  let out = s;
  if (out.startsWith('-')) out = out.slice(1);
  if (out.endsWith('-')) out = out.slice(0, -1);
  return out;
};
const slugifyTitle = (title) =>
  trimHyphens(
    (title || '')
      .normalize('NFKD')
      .replace(/[\u0300-\u036f]/g, '')
      .toLowerCase()
      .replace(/[^a-z0-9]+/g, '-')
  );

const postStatusMeta = (row) => {
  if (row.is_draft) return { label: 'Draft', cls: 'is-draft' };
  return { label: 'Published', cls: 'is-published' };
};
const PostStatusBadge = ({ row }) => {
  const meta = postStatusMeta(row);
  return <span className={`bd-admin__badge bd-admin__badge--status ${meta.cls}`}>{meta.label}</span>;
};

const postFieldsFromRow = (row) => ({
  title: row.title || '',
  slug: row.slug || '',
  body: row.body_markdown || '',
  tagsText: (row.tags || []).join(', '),
});

const parsePostTags = (tagsText) => {
  const seen = new Set();
  const tags = [];
  for (const raw of (tagsText || '').split(',')) {
    const tag = raw.trim();
    const key = tag.toLowerCase();
    if (tag && !seen.has(key)) { seen.add(key); tags.push(tag); }
  }
  return tags;
};

const postWriteError = (err) => {
  if (err.code === PG_UNIQUE_VIOLATION) return SLUG_TAKEN_NOTICE;
  return err.message;
};

/* Every write is conditional on the publication state the editor loaded:
   'draft' and 'publish' require the row to still be a draft, 'save' and
   'unpublish' require it to still be published. Without the guard, a stale
   "Save draft" could silently drag a concurrently-published post back to
   draft. Returns the is_draft value the row must still have. */
const postTransitionGuard = (kind) => kind === 'draft' || kind === 'publish';
const applyPostTransition = (patch, kind, row, me) => {
  if (kind === 'publish') {
    patch.is_draft = false;
    patch.published_at = new Date().toISOString();
    // Author linkage on publish (schema: posts.author_id → contributors).
    patch.author_id = row?.author_id || me.id;
  } else if (kind === 'unpublish') {
    patch.is_draft = true;
    patch.published_at = null;
  } else if (kind === 'draft') {
    patch.is_draft = true; // explicit for inserts; a no-op under the update guard
  }
  return patch;
};

/* Insert a brand-new post; 'publish' goes out immediately, 'draft' stays in. */
const insertPost = async (sb, me, patch, kind) => {
  const toInsert = applyPostTransition({ ...patch, author_id: me.id }, kind, null, me);
  const res = await sb.from('posts').insert(toInsert).select(POST_FIELDS).single();
  if (res.error) return { error: postWriteError(res.error) };
  return { row: res.data, transitioned: kind === 'publish' };
};

/* Update an existing post, conditional on the row still being in the state
   the editor believes (same honesty as the inbox's resolveReadTransition):
   zero rows matched means someone else transitioned it first — nothing was
   written, so fetch what actually persisted instead of pretending the click
   won, and say which way the race went. */
const updatePost = async (sb, me, row, patch, kind) => {
  const res = await sb.from('posts')
    .update(applyPostTransition(patch, kind, row, me))
    .eq('id', row.id)
    .eq('is_draft', postTransitionGuard(kind))
    .select(POST_FIELDS)
    .maybeSingle();
  if (res.error) return { error: postWriteError(res.error) };
  if (res.data) {
    return { row: res.data, transitioned: kind === 'publish' || kind === 'unpublish' };
  }
  const cur = await sb.from('posts').select(POST_FIELDS).eq('id', row.id).maybeSingle();
  if (cur.error) return { error: cur.error.message };
  if (!cur.data) return { error: POST_GONE_NOTICE };
  return {
    row: cur.data,
    notice: cur.data.is_draft ? POST_RACE_DRAFT_NOTICE : POST_RACE_PUBLISHED_NOTICE,
  };
};

/* Best-effort accountability trail (audit_log_self_insert policy lets a
   community member write rows attributed to themselves). The post change has
   already persisted by the time this runs, so a failed audit write is
   deliberately not surfaced — it must never wedge a publish. */
const writePostAudit = async (sb, me, action, post) => {
  await sb.from('audit_log').insert({
    actor_id: me.id,
    action,
    target_type: 'post',
    target_id: post.id,
    metadata: { slug: post.slug },
  });
};

const PostEditor = ({ sb, me, id, onBack }) => {
  const [row, setRow] = React.useState(null); // latest persisted row (null for a new post)
  const [fields, setFields] = React.useState({ title: '', slug: '', body: '', tagsText: '' });
  const [slugTouched, setSlugTouched] = React.useState(id !== null);
  const [loading, setLoading] = React.useState(id !== null);
  const [busy, setBusy] = React.useState(false);
  const [error, setError] = React.useState(null);
  const [notice, setNotice] = React.useState(null);

  React.useEffect(() => {
    if (id === null) return undefined;
    let alive = true;
    (async () => {
      const res = await sb.from('posts').select(POST_FIELDS).eq('id', id).maybeSingle();
      if (!alive) return;
      if (res.error || !res.data) {
        setError(res.error?.message || POST_GONE_NOTICE);
      } else {
        setRow(res.data);
        setFields(postFieldsFromRow(res.data));
      }
      setLoading(false);
    })();
    return () => { alive = false; };
  }, [sb, id]);

  const setTitle = (title) => {
    setFields((f) => {
      const next = { ...f, title };
      if (!slugTouched) next.slug = slugifyTitle(title);
      return next;
    });
  };
  const setField = (name) => (e) => {
    const value = e.target.value;
    if (name === 'slug') setSlugTouched(true);
    setFields((f) => ({ ...f, [name]: value }));
  };
  const normalizeSlug = () => {
    setFields((f) => ({ ...f, slug: slugifyTitle(f.slug) }));
  };

  const persist = async (kind) => {
    const title = fields.title.trim();
    const slug = slugifyTitle(fields.slug) || slugifyTitle(title);
    if (!title) { setError('A title is needed before saving.'); return; }
    if (!slug) { setError('A slug is needed — a few letters or numbers.'); return; }
    const patch = { title, slug, body_markdown: fields.body, tags: parsePostTags(fields.tagsText) };
    setBusy(true);
    setError(null);
    setNotice(null);
    const result = row
      ? await updatePost(sb, me, row, patch, kind)
      : await insertPost(sb, me, patch, kind);
    if (result.error) {
      setBusy(false);
      setError(result.error);
      return;
    }
    if (result.transitioned) {
      const action = kind === 'publish' ? 'post_publish' : 'post_unpublish';
      await writePostAudit(sb, me, action, result.row);
    }
    setBusy(false);
    setRow(result.row);
    // On a detected race nothing was written: adopt the persisted row's state
    // (badge + controls) but keep the editor's typed fields, so the person can
    // re-save their work with the now-correct control. On success, sync the
    // fields to what the database returned.
    if (!result.notice) setFields(postFieldsFromRow(result.row));
    setNotice(result.notice || POST_SAVE_NOTICES[kind]);
  };

  const removePost = async () => {
    if (!row) return;
    if (!window.confirm('Delete this post permanently? This can’t be undone.')) return;
    setBusy(true);
    const res = await sb.from('posts').delete().eq('id', row.id);
    setBusy(false);
    if (res.error) { setError(res.error.message); return; }
    onBack();
  };

  if (loading) return <AdminLoading label="Opening post…" />;
  if (id !== null && !row) {
    return (
      <div className="bd-admin__page">
        <button type="button" className="bd-admin__link bd-admin__back" onClick={onBack}>← Posts</button>
        <p className="bd-admin__error">{error || POST_GONE_NOTICE}</p>
      </div>
    );
  }

  let heading = 'New post';
  let meta = null;
  if (row?.is_draft) {
    heading = 'Edit draft';
    meta = `Draft · last edited ${fmtWhen(row.updated_at)}`;
  } else if (row) {
    heading = 'Edit post';
    meta = `Published ${fmtWhen(row.published_at)}`;
  }
  let actions;
  if (!row || row.is_draft) {
    actions = [
      { kind: 'draft', label: 'Save draft', primary: !row },
      { kind: 'publish', label: 'Publish', primary: !!row },
    ];
  } else {
    actions = [
      { kind: 'save', label: 'Save changes', primary: true },
      { kind: 'unpublish', label: 'Unpublish', primary: false },
    ];
  }

  return (
    <div className="bd-admin__page">
      <button type="button" className="bd-admin__link bd-admin__back" onClick={onBack}>← Posts</button>
      <div className="bd-admin__msg-head">
        <h1 className="bd-admin__h1">{heading}</h1>
        {row && <PostStatusBadge row={row} />}
      </div>
      {meta && <p className="bd-admin__editor-meta">{meta}</p>}
      {error && <p className="bd-admin__error">{error}</p>}
      {notice && !error && <p className="bd-admin__notice">{notice}</p>}

      <div className="bd-admin__field">
        <label className="bd-admin__field-label" htmlFor="bd-post-title">Title</label>
        <input
          id="bd-post-title"
          className="bd-admin__input"
          type="text"
          value={fields.title}
          onChange={(e) => setTitle(e.target.value)}
        />
      </div>
      <div className="bd-admin__field">
        <label className="bd-admin__field-label" htmlFor="bd-post-slug">Slug</label>
        <input
          id="bd-post-slug"
          className="bd-admin__input"
          type="text"
          value={fields.slug}
          onChange={setField('slug')}
          onBlur={normalizeSlug}
        />
        <p className="bd-admin__field-hint">
          The post's web address: /life-together/{fields.slug || '…'} — filled in from the
          title until you edit it yourself.
        </p>
      </div>
      <div className="bd-admin__field">
        <label className="bd-admin__field-label" htmlFor="bd-post-body">Body</label>
        <textarea
          id="bd-post-body"
          className="bd-admin__input bd-admin__body-input"
          rows={14}
          value={fields.body}
          onChange={setField('body')}
        />
        <p className="bd-admin__field-hint">
          Plain text for now — paragraph breaks are preserved. Markdown formatting is a
          planned follow-up.
        </p>
      </div>
      <div className="bd-admin__field">
        <label className="bd-admin__field-label" htmlFor="bd-post-tags">Tags</label>
        <input
          id="bd-post-tags"
          className="bd-admin__input"
          type="text"
          value={fields.tagsText}
          onChange={setField('tagsText')}
        />
        <p className="bd-admin__field-hint">
          Comma-separated, e.g. "devotional, prayers". Tags also route posts into
          Prayers &amp; Quotes.
        </p>
      </div>

      <div className="bd-admin__editor-actions">
        {actions.map((a) => (
          <button
            key={a.kind}
            type="button"
            className={`bd-admin__btn ${a.primary ? 'bd-admin__btn--primary' : 'bd-admin__btn--ghost'}`}
            disabled={busy}
            onClick={() => persist(a.kind)}
          >
            {a.label}
          </button>
        ))}
        {row && (
          <button
            type="button"
            className="bd-admin__btn bd-admin__btn--danger"
            disabled={busy}
            onClick={removePost}
          >
            Delete…
          </button>
        )}
      </div>
    </div>
  );
};

const POSTS_EMPTY_COPY = {
  draft: 'No drafts right now.',
  published: 'Nothing published yet.',
  all: 'No posts yet — write the first one.',
};

const Posts = ({ sb, me }) => {
  const [filter, setFilter] = React.useState('all');
  const [selected, setSelected] = React.useState(null); // 'new' | post id | null

  // Keyset page on (updated_at, id) descending.
  const fetchPage = React.useCallback((cursor) => {
    let q = sb.from('posts')
      .select('id, title, is_draft, published_at, updated_at')
      .order('updated_at', { ascending: false })
      .order('id', { ascending: false })
      .limit(POSTS_PAGE_SIZE);
    if (filter === 'draft') q = q.eq('is_draft', true);
    else if (filter === 'published') q = q.eq('is_draft', false);
    if (cursor) {
      q = q.or(
        `updated_at.lt."${cursor.updated_at}",and(updated_at.eq."${cursor.updated_at}",id.lt.${cursor.id})`
      );
    }
    return q;
  }, [sb, filter]);

  // Reload whenever the list is in view (selected === null): coming back
  // from the editor picks up whatever was created/renamed/deleted there.
  const { access, rows, lastPageFull, loading, loadingMore, error, loadMore } =
    useCapabilityList({
      sb, cap: 'posts', fetchPage, pageSize: POSTS_PAGE_SIZE, active: selected === null,
    });

  if (access === null) return <AdminLoading label="Opening posts…" />;
  if (access === false) {
    return (
      <div className="bd-admin__page">
        <h1 className="bd-admin__h1">Posts</h1>
        <p className="bd-admin__muted">
          Posts publish in the community's name, so writing needs the posts
          permission — ask the global admin for access.
        </p>
      </div>
    );
  }
  if (selected !== null) {
    return (
      <PostEditor
        sb={sb}
        me={me}
        id={selected === 'new' ? null : selected}
        onBack={() => setSelected(null)}
      />
    );
  }

  let body;
  if (loading) {
    body = <p className="bd-admin__muted">Loading posts…</p>;
  } else if (rows.length === 0) {
    body = <p className="bd-admin__muted">{POSTS_EMPTY_COPY[filter]}</p>;
  } else {
    body = (
      <ul className="bd-admin__posts-list">
        {rows.map((r) => (
          <li key={r.id}>
            <button type="button" className="bd-admin__post-row" onClick={() => setSelected(r.id)}>
              <span className="bd-admin__post-title">{r.title || '(untitled)'}</span>
              <span className="bd-admin__list-meta">edited {fmtWhen(r.updated_at)}</span>
              <PostStatusBadge row={r} />
            </button>
          </li>
        ))}
      </ul>
    );
  }

  return (
    <div className="bd-admin__page">
      <div className="bd-admin__page-head">
        <h1 className="bd-admin__h1">Posts</h1>
        <button
          type="button"
          className="bd-admin__btn bd-admin__btn--primary"
          onClick={() => setSelected('new')}
        >
          New post
        </button>
      </div>
      {error && <p className="bd-admin__error">{error}</p>}
      <AdminFilterTabs filters={POST_FILTERS} filter={filter} onChange={setFilter} />
      {body}
      <AdminLoadMore visible={!loading && lastPageFull} busy={loadingMore}
        label="Load older posts" onClick={loadMore} />
    </div>
  );
};

/* ── Special periods (#68) ────────────────────────────────────────────────
   The LSA's calendar of holy days and celebrations. The public site's holy
   day arc (functions/api/period.js) is driven entirely by these rows: the
   light warms through anticipation, dresses for celebration, and fades
   through afterglow. Managing the calendar is the 'calendar' capability
   (#63): admins implicitly, members via a grant — probed like the inbox
   and posts. */
const PERIODS_PAGE_SIZE = 50;
const PERIOD_FIELDS =
  'id, name, kind, significance, film_url, film_label, starts_at, ends_at, ' +
  'events_starts_at, events_ends_at, lead_days, fade_days, created_at, updated_at';
const PERIOD_KINDS = [
  { id: 'holy_day', label: 'Holy day' },
  { id: 'festival', label: 'Festival' },
  { id: 'community', label: 'Community' },
];
const PERIOD_NEAR_DAYS = 7; // must match NEAR_DAYS in functions/api/period.js
const PERIOD_DAY_MS = 24 * 60 * 60 * 1000;
const PERIOD_LEAD_MAX = 60; // the schema's lead_days/fade_days check bounds
const PERIOD_FADE_MAX = 30;

const PERIOD_GONE_NOTICE = 'This period no longer exists — someone may have deleted it.';
const PERIOD_RACE_NOTICE =
  'Someone else changed this period while you were editing — your changes were not ' +
  'saved. The list and phase above now match what persisted; the form still holds ' +
  'your edits, so save again to keep them.';
/* #83: create/save/delete land back on the list with a clear confirmation
   naming the period (only a detected race keeps the editor open, with the
   honest notice above). */
const periodCreatedConfirmation = (name) =>
  `Created “${name}” — the site's light will follow it.`;
const periodSavedConfirmation = (name) => `Saved “${name}”.`;
const periodDeletedConfirmation = (name) =>
  `Deleted “${name}” — the site's light returns to ordinary time.`;
const PERIOD_GATHERINGS_HINT =
  'Gatherings shown during the celebration come from the community Google ' +
  'Calendar — public events starting inside this window appear automatically; ' +
  'the calendar syncs every 15 minutes.';

/* The same phase math as functions/api/period.js, reimplemented rather than
   shared (the public site and the admin are separate bundles): the
   celebration window is half-open [starts, ends); afterglow runs fade_days
   past the end; near is the final PERIOD_NEAR_DAYS of the lead-in; far is
   the rest of it. */
const periodPhase = (row, now) => {
  const starts = new Date(row.starts_at).getTime();
  const ends = new Date(row.ends_at).getTime();
  const t = now.getTime();
  if (t >= starts && t < ends) return 'celebration';
  if (t >= ends && t < ends + row.fade_days * PERIOD_DAY_MS) return 'afterglow';
  if (t < starts && t >= starts - PERIOD_NEAR_DAYS * PERIOD_DAY_MS) return 'near';
  if (t < starts && t >= starts - row.lead_days * PERIOD_DAY_MS) return 'far';
  return null;
};

const PERIOD_PHASE_META = {
  celebration: { label: 'Celebrating now', cls: 'is-celebration' },
  afterglow: { label: 'Afterglow', cls: 'is-afterglow' },
  near: { label: 'Drawing near', cls: 'is-near' },
  far: { label: 'Anticipation', cls: 'is-far' },
};

/* Mirror of pickActive in functions/api/period.js: when windows overlap, the
   site follows ONE period — celebration wins, then near, far, afterglow, and
   ties go to the earliest starts_at. The list shows the same selection so
   the badges say what visitors actually see: only the leading period wears
   its phase; another in-phase period is marked as waiting behind it. */
const PERIOD_PHASE_RANK = { celebration: 0, near: 1, far: 2, afterglow: 3 };
const pickActivePeriodId = (rows, now) => {
  let best = null;
  for (const row of rows) {
    const phase = periodPhase(row, now);
    if (!phase) continue;
    const better =
      !best ||
      PERIOD_PHASE_RANK[phase] < PERIOD_PHASE_RANK[best.phase] ||
      (PERIOD_PHASE_RANK[phase] === PERIOD_PHASE_RANK[best.phase] &&
        new Date(row.starts_at).getTime() < new Date(best.row.starts_at).getTime());
    if (better) best = { phase, row };
  }
  return best ? best.row.id : null;
};

/* The leader is computed over the same bounded candidate set as the API's
   fetchCandidatePeriods — NOT over the paginated list, which sorts newest
   first and could omit a currently-active older period behind a page of
   future ones. Resolves to the leading period's id, null when nothing is in
   phase, or undefined when the fetch failed (no overlap context — rows then
   wear their own phase rather than a wrong "another period leads"). */
const fetchLeadingPeriodId = async (sb, now) => {
  const earliestEnd = new Date(now.getTime() - PERIOD_FADE_MAX * PERIOD_DAY_MS).toISOString();
  const latestStart = new Date(now.getTime() + PERIOD_LEAD_MAX * PERIOD_DAY_MS).toISOString();
  const res = await sb.from('special_periods')
    .select('id, starts_at, ends_at, lead_days, fade_days')
    .gte('ends_at', earliestEnd)
    .lte('starts_at', latestStart)
    .order('starts_at', { ascending: true })
    .limit(20);
  if (res.error) return undefined;
  return pickActivePeriodId(res.data || [], now);
};

/* activeId: the period the site is following right now (null when nothing is
   in phase; undefined when the caller has no overlap context — the editor's
   single-row view — in which case the row's own phase is shown). */
const periodPhaseMeta = (row, now, activeId) => {
  const phase = periodPhase(row, now);
  if (phase) {
    if (activeId !== undefined && row.id !== activeId) {
      return { label: 'Another period leads', cls: 'is-overlapped' };
    }
    return PERIOD_PHASE_META[phase];
  }
  if (now.getTime() < new Date(row.starts_at).getTime()) {
    return { label: 'Upcoming', cls: 'is-upcoming' };
  }
  return { label: 'Past', cls: 'is-past' };
};
const PeriodPhaseBadge = ({ row, now, activeId }) => {
  const meta = periodPhaseMeta(row, now, activeId);
  return <span className={`bd-admin__badge bd-admin__badge--phase ${meta.cls}`}>{meta.label}</span>;
};

const periodKindLabel = (kind) =>
  PERIOD_KINDS.find((k) => k.id === kind)?.label || kind;
/* An existing row may carry a kind outside today's vocabulary (the column is
   free text) — keep it selectable rather than silently rewriting it. */
const periodKindOptions = (kind) => {
  if (PERIOD_KINDS.some((k) => k.id === kind)) return PERIOD_KINDS;
  return [...PERIOD_KINDS, { id: kind, label: kind }];
};

/* datetime-local ⇄ ISO. The inputs read and write the browser's local zone;
   the database stores UTC instants. */
const pad2 = (n) => String(n).padStart(2, '0');
const isoToLocalInput = (iso) => {
  if (!iso) return '';
  const d = new Date(iso);
  if (Number.isNaN(d.getTime())) return '';
  return (
    `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}` +
    `T${pad2(d.getHours())}:${pad2(d.getMinutes())}`
  );
};
const localInputToIso = (value) => {
  if (!value) return null;
  const d = new Date(value);
  return Number.isNaN(d.getTime()) ? null : d.toISOString();
};

/* Client-side mirror of the API's safeUrl rule (functions/api/period.js):
   only site-relative paths or full http(s) URLs may be stored — anything
   else would be dropped at render time anyway. */
const isSafeFilmUrl = (s) => {
  if (!s) return true;
  if (s.startsWith('/') && !s.startsWith('//')) return true;
  try {
    const url = new URL(s);
    return url.protocol === 'http:' || url.protocol === 'https:';
  } catch {
    return false;
  }
};

const periodFieldsFromRow = (row) => ({
  name: row.name || '',
  kind: row.kind || 'holy_day',
  startsLocal: isoToLocalInput(row.starts_at),
  endsLocal: isoToLocalInput(row.ends_at),
  eventsStartsLocal: isoToLocalInput(row.events_starts_at),
  eventsEndsLocal: isoToLocalInput(row.events_ends_at),
  leadDays: String(row.lead_days ?? 19),
  fadeDays: String(row.fade_days ?? 5),
  significance: row.significance || '',
  filmUrl: row.film_url || '',
  filmLabel: row.film_label || '',
});
const NEW_PERIOD_FIELDS = {
  name: '', kind: 'holy_day', startsLocal: '', endsLocal: '',
  eventsStartsLocal: '', eventsEndsLocal: '',
  leadDays: '19', fadeDays: '5', significance: '', filmUrl: '', filmLabel: '',
};

const parsePeriodDays = (value, max) => {
  const n = Number(value);
  if (!Number.isInteger(n) || n < 0 || n > max) return null;
  return n;
};

/* #87: the optional gatherings window. Empty inputs mean null — the site
   falls back to the celebration window — and clearing a previously set
   window writes null, not a stale timestamp. Validation guards the
   EFFECTIVE window (each empty side coalesced with its celebration
   boundary, exactly as the API queries it), so a partial window can never
   combine into a reversed range that silently shows no gatherings —
   mirrors the DB check. */
const periodEventsWindowFromFields = (fields, celebrationStart, celebrationEnd) => {
  const startsAt = fields.eventsStartsLocal ? localInputToIso(fields.eventsStartsLocal) : null;
  if (fields.eventsStartsLocal && !startsAt) {
    return { error: 'The gatherings window needs a valid beginning date and time (or leave it empty).' };
  }
  const endsAt = fields.eventsEndsLocal ? localInputToIso(fields.eventsEndsLocal) : null;
  if (fields.eventsEndsLocal && !endsAt) {
    return { error: 'The gatherings window needs a valid end date and time (or leave it empty).' };
  }
  // ISO-8601 UTC strings from localInputToIso compare chronologically.
  const effectiveEnd = endsAt || celebrationEnd;
  const effectiveStart = startsAt || celebrationStart;
  if (effectiveEnd <= effectiveStart) {
    return {
      error:
        'The gatherings window must end after it begins — an empty side falls back to the celebration window.',
    };
  }
  return { startsAt, endsAt };
};

/* Turn the editor's fields into a row patch, or explain what's wrong. */
const periodPatchFromFields = (fields) => {
  const name = fields.name.trim();
  if (!name) return { error: 'A name is needed — what is being celebrated?' };
  const startsAt = localInputToIso(fields.startsLocal);
  if (!startsAt) return { error: 'A beginning date and time are needed.' };
  const endsAt = localInputToIso(fields.endsLocal);
  if (!endsAt) return { error: 'An end date and time are needed.' };
  if (endsAt <= startsAt) return { error: 'The end must come after the beginning.' };
  const leadDays = parsePeriodDays(fields.leadDays, PERIOD_LEAD_MAX);
  if (leadDays === null) {
    return { error: `Anticipation days must be a whole number from 0 to ${PERIOD_LEAD_MAX}.` };
  }
  const fadeDays = parsePeriodDays(fields.fadeDays, PERIOD_FADE_MAX);
  if (fadeDays === null) {
    return { error: `Afterglow days must be a whole number from 0 to ${PERIOD_FADE_MAX}.` };
  }
  const eventsWindow = periodEventsWindowFromFields(fields, startsAt, endsAt);
  if (eventsWindow.error) return { error: eventsWindow.error };
  const filmUrl = fields.filmUrl.trim();
  if (!isSafeFilmUrl(filmUrl)) {
    return { error: 'The film link needs to be a page on this site (like /films#dawn-of-the-light) or a full http(s) web address.' };
  }
  return {
    patch: {
      name,
      kind: fields.kind,
      starts_at: startsAt,
      ends_at: endsAt,
      events_starts_at: eventsWindow.startsAt,
      events_ends_at: eventsWindow.endsAt,
      lead_days: leadDays,
      fade_days: fadeDays,
      significance: fields.significance.trim() || null,
      film_url: filmUrl || null,
      film_label: fields.filmLabel.trim() || null,
    },
  };
};

const insertPeriod = async (sb, me, patch) => {
  const res = await sb.from('special_periods')
    .insert({ ...patch, created_by: me.id })
    .select(PERIOD_FIELDS)
    .single();
  if (res.error) return { error: res.error.message };
  return { row: res.data };
};

/* Update conditional on the updated_at the editor loaded (the touch trigger
   stamps every write, so it's a reliable optimistic lock): zero rows matched
   means someone else wrote first — nothing persisted from this click, so
   fetch what actually did and say so, the same honesty as the inbox's
   resolveReadTransition and the post editor's transitions. */
const updatePeriod = async (sb, row, patch) => {
  const res = await sb.from('special_periods')
    .update(patch)
    .eq('id', row.id)
    .eq('updated_at', row.updated_at)
    .select(PERIOD_FIELDS)
    .maybeSingle();
  if (res.error) return { error: res.error.message };
  if (res.data) return { row: res.data };
  const cur = await sb.from('special_periods').select(PERIOD_FIELDS).eq('id', row.id).maybeSingle();
  if (cur.error) return { error: cur.error.message };
  if (!cur.data) return { error: PERIOD_GONE_NOTICE };
  return { row: cur.data, notice: PERIOD_RACE_NOTICE };
};

const PeriodField = ({ id, label, hint, children }) => (
  <div className="bd-admin__field">
    <label className="bd-admin__field-label" htmlFor={id}>{label}</label>
    {children}
    {hint && <p className="bd-admin__field-hint">{hint}</p>}
  </div>
);

/* onBack returns to the list quietly (the ← control); onDone returns with a
   confirmation naming the period (#83 — successful create/save/delete). Only
   a detected race keeps the editor open, with the honest notice. */
const PeriodEditor = ({ sb, me, id, onBack, onDone }) => {
  const [row, setRow] = React.useState(null); // latest persisted row (null for a new period)
  const [fields, setFields] = React.useState(NEW_PERIOD_FIELDS);
  const [loading, setLoading] = React.useState(id !== null);
  const [busy, setBusy] = React.useState(false);
  const [error, setError] = React.useState(null);
  const [notice, setNotice] = React.useState(null);

  React.useEffect(() => {
    if (id === null) return undefined;
    let alive = true;
    (async () => {
      const res = await sb.from('special_periods').select(PERIOD_FIELDS).eq('id', id).maybeSingle();
      if (!alive) return;
      if (res.error || !res.data) {
        setError(res.error?.message || PERIOD_GONE_NOTICE);
      } else {
        setRow(res.data);
        setFields(periodFieldsFromRow(res.data));
      }
      setLoading(false);
    })();
    return () => { alive = false; };
  }, [sb, id]);

  const setField = (name) => (e) => {
    const value = e.target.value;
    setFields((f) => ({ ...f, [name]: value }));
  };

  const persist = async () => {
    const checked = periodPatchFromFields(fields);
    if (checked.error) { setError(checked.error); return; }
    setBusy(true);
    setError(null);
    setNotice(null);
    const creating = row === null;
    const result = row
      ? await updatePeriod(sb, row, checked.patch)
      : await insertPeriod(sb, me, checked.patch);
    setBusy(false);
    if (result.error) { setError(result.error); return; }
    if (result.notice) {
      // A detected race: nothing was written. Adopt the persisted row (so
      // the next save guards against the fresh updated_at) but keep the
      // typed fields so the edits can be re-saved deliberately.
      setRow(result.row);
      setNotice(result.notice);
      return;
    }
    // Success lands back on the list with a confirmation naming the period.
    const confirm = creating ? periodCreatedConfirmation : periodSavedConfirmation;
    onDone(confirm(result.row.name));
  };

  const removePeriod = async () => {
    if (!row) return;
    const ok = window.confirm(
      `Delete "${row.name}"? The site's light returns to ordinary time. This can't be undone.`
    );
    if (!ok) return;
    setBusy(true);
    const res = await sb.from('special_periods').delete().eq('id', row.id);
    setBusy(false);
    if (res.error) { setError(res.error.message); return; }
    onDone(periodDeletedConfirmation(row.name));
  };

  if (loading) return <AdminLoading label="Opening period…" />;
  if (id !== null && !row) {
    return (
      <div className="bd-admin__page">
        <button type="button" className="bd-admin__link bd-admin__back" onClick={onBack}>← Events</button>
        <p className="bd-admin__error">{error || PERIOD_GONE_NOTICE}</p>
      </div>
    );
  }

  return (
    <div className="bd-admin__page">
      <button type="button" className="bd-admin__link bd-admin__back" onClick={onBack}>← Events</button>
      <div className="bd-admin__msg-head">
        <h1 className="bd-admin__h1">{row ? 'Edit period' : 'New period'}</h1>
        {row && <PeriodPhaseBadge row={row} now={new Date()} />}
      </div>
      {row && <p className="bd-admin__editor-meta">Last edited {fmtWhen(row.updated_at)}</p>}
      {error && <p className="bd-admin__error">{error}</p>}
      {notice && !error && <p className="bd-admin__notice">{notice}</p>}

      <PeriodField id="bd-period-name" label="Name">
        <input id="bd-period-name" className="bd-admin__input" type="text"
          value={fields.name} onChange={setField('name')} />
      </PeriodField>
      <PeriodField id="bd-period-kind" label="Kind">
        <select id="bd-period-kind" className="bd-admin__input" value={fields.kind} onChange={setField('kind')}>
          {periodKindOptions(fields.kind).map((k) => (
            <option key={k.id} value={k.id}>{k.label}</option>
          ))}
        </select>
      </PeriodField>
      <div className="bd-admin__field-row">
        <PeriodField id="bd-period-starts" label="Celebration begins"
          hint="Bahá'í days begin at sunset — times are local.">
          <input id="bd-period-starts" className="bd-admin__input" type="datetime-local"
            value={fields.startsLocal} onChange={setField('startsLocal')} />
        </PeriodField>
        <PeriodField id="bd-period-ends" label="Celebration ends"
          hint="The site dresses for celebration between these two moments.">
          <input id="bd-period-ends" className="bd-admin__input" type="datetime-local"
            value={fields.endsLocal} onChange={setField('endsLocal')} />
        </PeriodField>
      </div>
      <div className="bd-admin__field-row">
        <PeriodField id="bd-period-ev-starts" label="Gatherings window begins"
          hint="Leave empty to show only events during the celebration itself; set a wider range when gatherings spread across the surrounding week.">
          <input id="bd-period-ev-starts" className="bd-admin__input" type="datetime-local"
            value={fields.eventsStartsLocal} onChange={setField('eventsStartsLocal')} />
        </PeriodField>
        <PeriodField id="bd-period-ev-ends" label="Gatherings window ends">
          <input id="bd-period-ev-ends" className="bd-admin__input" type="datetime-local"
            value={fields.eventsEndsLocal} onChange={setField('eventsEndsLocal')} />
        </PeriodField>
      </div>
      <p className="bd-admin__field-hint bd-admin__gatherings-note">{PERIOD_GATHERINGS_HINT}</p>
      <div className="bd-admin__field-row">
        <PeriodField id="bd-period-lead" label="Anticipation days"
          hint="How many days before the celebration the light begins to warm — 19 is a Bahá'í month.">
          <input id="bd-period-lead" className="bd-admin__input" type="number"
            min="0" max={PERIOD_LEAD_MAX} value={fields.leadDays} onChange={setField('leadDays')} />
        </PeriodField>
        <PeriodField id="bd-period-fade" label="Afterglow days"
          hint="How many days the light lingers after the celebration ends.">
          <input id="bd-period-fade" className="bd-admin__input" type="number"
            min="0" max={PERIOD_FADE_MAX} value={fields.fadeDays} onChange={setField('fadeDays')} />
        </PeriodField>
      </div>
      <PeriodField id="bd-period-significance" label="Significance"
        hint="Shown to visitors during the celebration — always LSA-reviewed before the period begins. Paragraphs split on blank lines.">
        <textarea id="bd-period-significance" className="bd-admin__input bd-admin__significance-input"
          rows={8} value={fields.significance} onChange={setField('significance')} />
      </PeriodField>
      <div className="bd-admin__field-row">
        <PeriodField id="bd-period-film" label="Film link"
          hint="Optional — best as a film anchor on this site's Films page (each film card has one, like /films#dawn-of-the-light), or a full web address.">
          <input id="bd-period-film" className="bd-admin__input" type="text"
            value={fields.filmUrl} onChange={setField('filmUrl')} />
        </PeriodField>
        <PeriodField id="bd-period-film-label" label="Film title"
          hint="Optional — names the film in the link, e.g. “Dawn of the Light”.">
          <input id="bd-period-film-label" className="bd-admin__input" type="text"
            value={fields.filmLabel} onChange={setField('filmLabel')} />
        </PeriodField>
      </div>

      <div className="bd-admin__editor-actions">
        <button type="button" className="bd-admin__btn bd-admin__btn--primary" disabled={busy} onClick={persist}>
          {row ? 'Save changes' : 'Create period'}
        </button>
        {row && (
          <button type="button" className="bd-admin__btn bd-admin__btn--danger" disabled={busy} onClick={removePeriod}>
            Delete…
          </button>
        )}
      </div>
    </div>
  );
};

const EventsAdmin = ({ sb, me }) => {
  const [selected, setSelected] = React.useState(null); // 'new' | period id | null
  // #83: the confirmation shown atop the list after a create/save/delete —
  // dismissible, and cleared when a new editor opens.
  const [confirmation, setConfirmation] = React.useState(null);

  // Keyset page on (starts_at, id) descending: newest windows first, so
  // what's coming up sits at the top and history trails below.
  const fetchPage = React.useCallback((cursor) => {
    let q = sb.from('special_periods')
      .select('id, name, kind, starts_at, ends_at, lead_days, fade_days')
      .order('starts_at', { ascending: false })
      .order('id', { ascending: false })
      .limit(PERIODS_PAGE_SIZE);
    if (cursor) {
      q = q.or(
        `starts_at.lt."${cursor.starts_at}",and(starts_at.eq."${cursor.starts_at}",id.lt.${cursor.id})`
      );
    }
    return q;
  }, [sb]);

  // Reload whenever the list is in view (selected === null): coming back
  // from the editor picks up whatever was created/changed/deleted there.
  const { access, rows, lastPageFull, loading, loadingMore, error, loadMore } =
    useCapabilityList({
      sb, cap: 'calendar', fetchPage, pageSize: PERIODS_PAGE_SIZE, active: selected === null,
    });

  // The site's current leader, from the API's candidate set (see
  // fetchLeadingPeriodId) — refreshed whenever the list comes into view.
  const [activeId, setActiveId] = React.useState(undefined);
  React.useEffect(() => {
    if (access !== true || selected !== null) return undefined;
    let alive = true;
    fetchLeadingPeriodId(sb, new Date()).then((id) => {
      if (alive) setActiveId(id);
    });
    return () => { alive = false; };
  }, [sb, access, selected]);

  if (access === null) return <AdminLoading label="Opening events…" />;
  if (access === false) {
    return (
      <div className="bd-admin__page">
        <h1 className="bd-admin__h1">Events</h1>
        <p className="bd-admin__muted">
          Special periods shape what every visitor sees, so managing them needs
          the calendar permission — ask the global admin for access.
        </p>
        <p className="bd-admin__muted">
          Event enrichment — welcome text, host info, RSVP, cover images —
          joins this page later (issue #25).
        </p>
      </div>
    );
  }
  if (selected !== null) {
    return (
      <PeriodEditor
        sb={sb}
        me={me}
        id={selected === 'new' ? null : selected}
        onBack={() => setSelected(null)}
        onDone={(message) => {
          setConfirmation(message);
          setSelected(null);
        }}
      />
    );
  }

  const openEditor = (which) => {
    setConfirmation(null);
    setSelected(which);
  };

  const now = new Date();
  let body;
  if (loading) {
    body = <p className="bd-admin__muted">Loading special periods…</p>;
  } else if (rows.length === 0) {
    body = (
      <p className="bd-admin__muted">
        No special periods yet — add the next holy day and the site will begin
        to look forward to it.
      </p>
    );
  } else {
    body = (
      <ul className="bd-admin__posts-list">
        {rows.map((r) => (
          <li key={r.id} className="bd-admin__period-item">
            <button type="button" className="bd-admin__period-row" onClick={() => openEditor(r.id)}>
              <span className="bd-admin__period-name">{r.name}</span>
              <span className="bd-admin__list-tag">{periodKindLabel(r.kind)}</span>
              <span className="bd-admin__list-meta">{fmtWhen(r.starts_at)} → {fmtWhen(r.ends_at)}</span>
              <span className="bd-admin__list-meta">lead {r.lead_days}d · fade {r.fade_days}d</span>
              <PeriodPhaseBadge row={r} now={now} activeId={activeId} />
            </button>
            {/* #84: see the celebration takeover before it's live — the
                public homepage in preview dress, in a new tab. */}
            <a
              className="bd-admin__link bd-admin__period-preview"
              href={`/?arc-preview=${r.id}`}
              target="_blank"
              rel="noopener"
            >
              Preview
            </a>
          </li>
        ))}
      </ul>
    );
  }

  return (
    <div className="bd-admin__page">
      <h1 className="bd-admin__h1">Events</h1>
      <p className="bd-admin__muted">
        Event enrichment — welcome text, host info, RSVP, cover images — joins
        this page later (issue #25).
      </p>
      <div className="bd-admin__page-head">
        <h2 className="bd-admin__h2">Special periods</h2>
        <button
          type="button"
          className="bd-admin__btn bd-admin__btn--primary"
          onClick={() => openEditor('new')}
        >
          New period
        </button>
      </div>
      {confirmation && (
        <output className="bd-admin__confirmation">
          <span>{confirmation}</span>
          <button
            type="button"
            className="bd-admin__confirmation-dismiss"
            aria-label="Dismiss"
            onClick={() => setConfirmation(null)}
          >
            ×
          </button>
        </output>
      )}
      <p className="bd-admin__intro">
        The site's light follows these periods — anticipation as a holy day
        draws near, celebration while it's here, afterglow as it passes.
        Define the community's calendar here and the homepage does the rest.
        When windows overlap, the site follows one period at a time — the
        badges show which one leads.
      </p>
      {error && <p className="bd-admin__error">{error}</p>}
      {body}
      <AdminLoadMore visible={!loading && lastPageFull} busy={loadingMore}
        label="Load earlier periods" onClick={loadMore} />
    </div>
  );
};

/* ── Placeholder sections (their own issues: #25 events
   enrichment, settings). The shell + nav are #14; these are stubs so the nav is
   navigable and the scope of what's next is visible. ──────────────────────*/
const ComingSoon = ({ title, issue, children }) => (
  <div className="bd-admin__page">
    <h1 className="bd-admin__h1">{title}</h1>
    <div className="bd-admin__placeholder">
      <p>{children}</p>
      {issue && <p className="bd-admin__muted">Tracked in issue #{issue}.</p>}
    </div>
  </div>
);

/* ── AdminApp (the root) ──────────────────────────────────────────────────*/
const AdminApp = () => {
  // auth: 'loading' | 'signin' | 'unauthorized' | 'ready'
  const [auth, setAuth] = React.useState('loading');
  const [me, setMe] = React.useState(null);
  const [email, setEmail] = React.useState(null);
  const [sb, setSb] = React.useState(null);
  const [fatal, setFatal] = React.useState(null);
  const [signinBusy, setSigninBusy] = React.useState(false);
  const [page, setPage] = React.useState(() => adminPageForPath(window.location.pathname));
  const [mobile, setMobile] = React.useState(() => window.matchMedia('(max-width: 820px)').matches);

  // Resolve a session into one of the auth states. The contributors SELECT is
  // the allowlist gate: an active contributor reads their own row; anyone else
  // gets zero rows (RLS) → not authorized.
  const applySession = React.useCallback(async (client, session) => {
    if (!session?.user) {
      setMe(null); setEmail(null); setAuth('signin');
      return;
    }
    setEmail(session.user.email || null);
    const { data, error } = await client
      .from('contributors')
      .select('id, display_name, role, is_active')
      .eq('id', session.user.id)
      .maybeSingle();
    if (error) {
      // A genuine error (not "no rows") — surface it rather than silently locking out.
      setFatal(error.message || String(error));
      setAuth('signin');
      return;
    }
    if (data?.is_active) { setMe(data); setAuth('ready'); }
    else { setMe(null); setAuth('unauthorized'); }
  }, []);

  React.useEffect(() => {
    let subscription = null;
    (async () => {
      let client;
      try {
        client = await getSupabase();
      } catch (e) {
        setFatal(e.message || String(e));
        setAuth('signin');
        return;
      }
      setSb(client);
      const { data: { session } } = await client.auth.getSession();
      await applySession(client, session);
      const res = client.auth.onAuthStateChange((_event, s) => { applySession(client, s); });
      subscription = res.data.subscription;
    })();
    return () => { if (subscription) subscription.unsubscribe(); };
  }, [applySession]);

  // Track viewport for the responsive shell.
  React.useEffect(() => {
    const mq = window.matchMedia('(max-width: 820px)');
    const handler = (e) => setMobile(e.matches);
    mq.addEventListener('change', handler);
    return () => mq.removeEventListener('change', handler);
  }, []);

  // Back/forward within /admin.
  React.useEffect(() => {
    const onPop = () => {
      const p = normalizeAdminPath(window.location.pathname);
      if (!p.startsWith('/admin')) { window.location.reload(); return; } // left the admin SPA
      setPage(adminPageForPath(window.location.pathname));
      window.scrollTo({ top: 0 });
    };
    window.addEventListener('popstate', onPop);
    return () => window.removeEventListener('popstate', onPop);
  }, []);

  const navigate = (id) => {
    const path = adminPathForPage(id);
    if (normalizeAdminPath(window.location.pathname) !== path) {
      window.history.pushState({ page: id }, '', path);
    }
    setPage(id);
    window.scrollTo({ top: 0 });
  };

  const signIn = async () => {
    if (!sb) return;
    setSigninBusy(true);
    setFatal(null);
    const { error } = await sb.auth.signInWithOAuth({
      provider: 'google',
      options: { redirectTo: window.location.origin + '/admin' },
    });
    if (error) { setFatal(error.message); setSigninBusy(false); }
    // On success the browser redirects to Google; no further work here.
  };

  const signOut = async () => {
    if (sb) await sb.auth.signOut(); // onAuthStateChange → applySession(null) → 'signin'
    if (normalizeAdminPath(window.location.pathname) !== ADMIN_SIGN_IN_PATH) {
      window.history.pushState({}, '', ADMIN_SIGN_IN_PATH);
    }
    setPage('sign-in');
  };

  if (auth === 'loading') return <AdminLoading label="Checking your session…" />;
  if (auth === 'signin') return <SignIn onSignIn={signIn} busy={signinBusy} error={fatal} />;
  if (auth === 'unauthorized') return <NotAuthorized email={email} onSignOut={signOut} />;

  // auth === 'ready'
  let content;
  if (page === 'dashboard') content = <Dashboard sb={sb} me={me} onNavigate={navigate} />;
  else if (page === 'inbox') content = <Inbox sb={sb} me={me} />;
  else if (page === 'posts') content = <Posts sb={sb} me={me} />;
  else if (page === 'events') content = <EventsAdmin sb={sb} me={me} />;
  else if (page === 'settings') content = <ComingSoon title="Settings">Notification address, community-member management, and site settings land here.</ComingSoon>;
  else content = <Dashboard sb={sb} me={me} onNavigate={navigate} />;

  return (
    <AdminShell page={page} onNavigate={navigate} me={me} onSignOut={signOut} mobile={mobile}>
      {content}
    </AdminShell>
  );
};

window.AdminApp = AdminApp;
window.adminPageForPath = adminPageForPath;
