/* ════════════════════════════════════════════════════════════════════════════
   dashboards-native-sections.jsx
   Native React replacements for the iframe sections in /dashboards/admin and
   /dashboards/host. Each section:
     • Uses the same API endpoints the operational pages call
     • Renders with the new design language (Panel, Display, Sans, CC tokens)
     • Has loading + error + empty states
     • Falls back to demo data from D.* when not authed (so the preview is
       browseable end-to-end without login)

   Goal: when every section is wired native, the operational dashboards
   /admin + /host can be retired and these previews become the production
   dashboards. Until then, sections that aren't yet ported stay as iframe
   embeds with a "PORTING" badge.
   ════════════════════════════════════════════════════════════════════════════
*/

const D = window.TLB_DASH_DATA;
const { useState, useEffect, useMemo, useRef } = React;

// ── Helper — resolve legacy tlb_token only if the stored session role
// matches the requested tokenType. Backward-compat for users who logged
// in BEFORE the v82 saveSession update populated tlb_admin_token /
// tlb_host_token. Reads tlb_session JSON to check the role marker.
function _resolveLegacyTokenIfRole(wantRole) {
  try {
    const raw = localStorage.getItem('tlb_session') || localStorage.getItem('tlb_user');
    if (raw) {
      const sess = JSON.parse(raw);
      const sessRole = sess && sess.role;
      if (sessRole === wantRole) {
        return localStorage.getItem('tlb_token') || '';
      }
      // Role mismatch — DON'T use the legacy token. Sending a host token
      // to admin endpoints would always 403 anyway, so silent skip is fine.
      return '';
    }
    // No session marker — single legacy token, treat as host (most common).
    if (wantRole === 'host') return localStorage.getItem('tlb_token') || '';
    return '';
  } catch { return ''; }
}

// ── 0 · Generic data hook ───────────────────────────────────────────────
// Fetches once on mount, exposes { data, loading, error, refetch }. Token
// type defaults to admin; pass 'host' for /api/bookings + /api/reviews.
function useApi(url, { tokenType = null } = {}, deps = []) {
  const [state, setState] = useState({ data: null, loading: true, error: null });

  function refetch() {
    setState(s => ({ ...s, loading: true, error: null }));
    // Round-15 fix — strict role-based token resolution. The new dashboards
    // route admin sections to /api/admin* and host sections to /api/properties
    // etc. We MUST NOT mix tokens — sending a host token to an admin endpoint
    // wastes a server roundtrip + leaks 403s into the UI; sending an admin
    // token to a host endpoint succeeds but shows the WRONG host's data.
    //
    // Resolution order (strict, no cross-fallback):
    //   tokenType='admin' → tlb_admin_token only (then generic fallback)
    //   tokenType='host'  → tlb_host_token only (then generic fallback)
    // The generic tlb_token fallback remains because legacy login flows
    // populated only that key. After v82 saveSession update, NEW logins
    // populate the role-specific key in addition to the generic one.
    let token = '';
    try {
      if (tokenType === 'admin') {
        token = localStorage.getItem('tlb_admin_token')
          || _resolveLegacyTokenIfRole('admin')
          || '';
      } else if (tokenType === 'host') {
        token = localStorage.getItem('tlb_host_token')
          || _resolveLegacyTokenIfRole('host')
          || '';
      } else {
        // No tokenType passed (rare) — anonymous request.
        token = '';
      }
    } catch {}
    const headers = token ? { 'Authorization': 'Bearer ' + token } : {};
    fetch(url, { headers })
      .then(r => {
        if (r.status === 401) throw new Error('Sign in required to view live data.');
        if (!r.ok) throw new Error('Request failed (' + r.status + ').');
        return r.json();
      })
      .then(data => setState({ data, loading: false, error: null }))
      .catch(err => setState({ data: null, loading: false, error: err.message || 'Failed to load' }));
  }

  useEffect(refetch, deps); // eslint-disable-line

  return { ...state, refetch };
}

// ── 1 · Shared atoms ────────────────────────────────────────────────────
function SectionHeader({ title, subtitle, actions, badge }) {
  return (
    <div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12, marginBottom: dashGap() }}>
      <div>
        <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 6 }}>
          {/* v141.163 re-skin Stage 2 — calm upright Playfair section
              title per the delivered redesign (was full-italic + period). */}
          <Display size={28} weight={400} ls={-0.02} lh={1.05}>{title}</Display>
          {badge}
        </div>
        {subtitle && <Sans size={13} color={CC.mid}>{subtitle}</Sans>}
      </div>
      {actions && <div style={{ display: 'flex', gap: 8 }}>{actions}</div>}
    </div>
  );
}

function Loading({ msg = 'Loading…' }) {
  return (
    <Panel style={{ padding: '60px 24px', textAlign: 'center' }}>
      <div style={{ width: 22, height: 22, borderRadius: '50%', border: `2px solid ${CC.border}`, borderTopColor: CC.ink, margin: '0 auto 12px', animation: 'spin 0.8s linear infinite' }} />
      <Sans size={12} color={CC.mid}>{msg}</Sans>
      <style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
    </Panel>
  );
}

function ErrorState({ msg, onRetry }) {
  const isAuth = /sign in|401/i.test(msg || '');
  return (
    <Panel style={{ padding: '40px 24px', textAlign: 'center' }}>
      <Sans size={11} weight={600} ls={0.14} upper color={isAuth ? '#9a7c1c' : '#9a1c1c'} style={{ marginBottom: 8 }}>
        {isAuth ? 'Authentication required' : 'Could not load'}
      </Sans>
      <Sans size={13} color={CC.mid} style={{ marginBottom: 16 }}>{msg}</Sans>
      <div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
        {onRetry && (
          <button onClick={onRetry}
            style={{ padding: '8px 18px', border: `1px solid ${CC.border}`, background: CC.white, color: CC.ink, borderRadius: 100, fontSize: 12, fontWeight: 500, cursor: 'pointer' }}>
            Try again
          </button>
        )}
        {isAuth && (
          <a href="/admin" style={{ padding: '8px 18px', background: CC.ink, color: CC.white, borderRadius: 100, fontSize: 12, fontWeight: 500, textDecoration: 'none' }}>
            Sign in
          </a>
        )}
      </div>
    </Panel>
  );
}

function Empty({ title, msg }) {
  return (
    <Panel style={{ padding: '60px 24px', textAlign: 'center' }}>
      <Display size={20} italic weight={400} style={{ marginBottom: 6 }}>{title}.</Display>
      <Sans size={13} color={CC.mid}>{msg}</Sans>
    </Panel>
  );
}

function Pill({ tone = 'neutral', children }) {
  const toneMap = {
    success: { bg: 'rgba(105,210,138,0.12)', color: '#1f6b3a' },
    warn:    { bg: 'rgba(232,168,69,0.12)',  color: '#8a6324' },
    danger:  { bg: 'rgba(201,68,68,0.12)',   color: '#9a1c1c' },
    info:    { bg: 'rgba(72,128,200,0.10)',  color: '#1f4f8f' },
    neutral: { bg: CC.offWhite,              color: CC.mid },
  };
  const t = toneMap[tone] || toneMap.neutral;
  return (
    <span style={{ display: 'inline-block', padding: '3px 10px', borderRadius: 100, background: t.bg, color: t.color, fontSize: 10, fontWeight: 600, letterSpacing: '0.06em', textTransform: 'uppercase' }}>
      {children}
    </span>
  );
}

function Table({ columns, rows, empty = 'No rows yet.' }) {
  if (!rows || !rows.length) return <Empty title="Nothing here yet" msg={empty} />;
  return (
    <Panel pad={0} style={{ overflow: 'hidden' }}>
      <div style={{ overflowX: 'auto' }}>
        <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
          <thead>
            <tr style={{ borderBottom: `1px solid ${CC.border}`, background: CC.offWhite }}>
              {columns.map((c, i) => (
                <th key={i} style={{ padding: '14px 20px', textAlign: c.align || 'left', fontFamily: F_TLB.ui, fontSize: 10, fontWeight: 600, letterSpacing: '0.14em', textTransform: 'uppercase', color: CC.mid, whiteSpace: 'nowrap' }}>
                  {c.label}
                </th>
              ))}
            </tr>
          </thead>
          <tbody>
            {rows.map((row, ri) => (
              <tr key={ri} style={{ borderBottom: ri === rows.length - 1 ? 'none' : `1px solid ${CC.border}` }}>
                {columns.map((c, ci) => (
                  <td key={ci} style={{ padding: '14px 20px', textAlign: c.align || 'left', color: CC.deep, fontVariantNumeric: c.numeric ? 'tabular-nums' : 'normal', whiteSpace: c.nowrap ? 'nowrap' : 'normal' }}>
                    {c.render ? c.render(row, ri) : row[c.key]}
                  </td>
                ))}
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </Panel>
  );
}

function fmtIDR(n) {
  if (n == null || isNaN(n)) return '—';
  return 'Rp ' + Math.round(n).toLocaleString('id-ID');
}
function fmtUSD(n) {
  if (n == null || isNaN(n)) return '—';
  return '$' + Math.round(n).toLocaleString('en-US');
}
function fmtDate(s) {
  if (!s) return '—';
  try { return new Date(s).toLocaleDateString('en-GB', { day: '2-digit', month: 'short' }); }
  catch { return s; }
}
function fmtDateLong(s) {
  if (!s) return '—';
  try { return new Date(s).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }); }
  catch { return s; }
}
function statusTone(s) {
  s = String(s || '').toLowerCase();
  if (/(active|live|published|confirmed|completed|paid|verified)/.test(s)) return 'success';
  if (/(pending|review|draft|in.?progress)/.test(s)) return 'warn';
  if (/(cancel|refund|fail|reject|disabled|inactive)/.test(s)) return 'danger';
  if (/(new|enquiry|message)/.test(s)) return 'info';
  return 'neutral';
}

// ════════════════════════════════════════════════════════════════════════
// SHARED COMPONENTS (used by both admin + host sections)
// ════════════════════════════════════════════════════════════════════════

// v125 — CancellationModal: preview refund based on policy + cutoffs,
// then issue Xendit refund on confirm. Used from any booking row's
// "Cancel & refund" action button.
function CancellationModal({ booking, role, onClose, onSuccess }) {
  const [preview, setPreview] = useState(null);
  const [loading, setLoading] = useState(true);
  const [reason, setReason] = useState('');
  const [refundType, setRefundType] = useState('policy');     // 'policy' | 'full' (admin only)
  const [submitting, setSubmitting] = useState(false);
  const [err, setErr] = useState('');

  useEffect(() => {
    let cancelled = false;
    (async () => {
      const tokenKey = role === 'admin' ? 'tlb_admin_token' : 'tlb_host_token';
      const token = localStorage.getItem(tokenKey) || localStorage.getItem('tlb_token') || '';
      try {
        const r = await fetch('/api/payment?action=cancellation-preview&booking_id=' + booking.id, {
          headers: { Authorization: 'Bearer ' + token },
        });
        if (cancelled) return;
        if (r.ok) setPreview(await r.json());
        else { const j = await r.json().catch(() => ({})); setErr(j.error || 'Failed to load preview'); }
      } catch (e) { if (!cancelled) setErr(e.message); }
      finally { if (!cancelled) setLoading(false); }
    })();
    return () => { cancelled = true; };
  }, [booking.id]);

  async function confirmCancel() {
    setErr('');
    setSubmitting(true);
    const tokenKey = role === 'admin' ? 'tlb_admin_token' : 'tlb_host_token';
    const token = localStorage.getItem(tokenKey) || localStorage.getItem('tlb_token') || '';
    try {
      const r = await fetch('/api/payment?action=refund', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token },
        body: JSON.stringify({ booking_id: booking.id, reason, refund_type: refundType }),
      });
      const j = await r.json();
      if (r.ok) { onSuccess && onSuccess(j); onClose(); }
      else { setErr(j.error || 'Refund failed'); setSubmitting(false); }
    } catch (e) { setErr(e.message); setSubmitting(false); }
  }

  function fmtMoney(n, cur) { const c = (cur || 'IDR').toUpperCase(); return c === 'USD' ? '$' + Math.round(n).toLocaleString() : 'Rp ' + Math.round(n).toLocaleString('id-ID'); }
  function fmtDate(d) { return d ? new Date(d).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) : '—'; }

  return (
    <div style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(20,20,20,.55)', backdropFilter: 'blur(6px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}>
      <Panel style={{ maxWidth: 560, width: '100%', maxHeight: '90vh', overflowY: 'auto' }}>
        <Display size={22} italic weight={400} style={{ marginBottom: 8 }}>Cancel + refund.</Display>
        <Sans size={12} color={CC.mid} style={{ marginBottom: 14 }}>{booking.property_name} · {booking.guest_name || booking.guest_email}</Sans>

        {loading ? (
          <Sans size={13} color={CC.mid}>Calculating refund per policy…</Sans>
        ) : err ? (
          <Sans size={13} color="#9a1c1c">{err}</Sans>
        ) : !preview ? null : (
          <>
            <div style={{ padding: 14, background: CC.offWhite, borderRadius: 8, marginBottom: 14 }}>
              <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
                <Sans size={11} color={CC.mid}>Cancellation policy</Sans>
                <Sans size={11} weight={600}>{(preview.policy_detail?.name) || preview.cancellation_policy}</Sans>
              </div>
              <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
                <Sans size={11} color={CC.mid}>Check-in</Sans>
                <Sans size={11} weight={600}>{fmtDate(preview.check_in)} · {preview.days_until_check_in}d away</Sans>
              </div>
              <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
                <Sans size={11} color={CC.mid}>Amount paid</Sans>
                <Sans size={11} weight={600}>{fmtMoney(preview.amount_paid, preview.currency)}</Sans>
              </div>
              <div style={{ borderTop: '1px solid ' + CC.border, paddingTop: 10, marginTop: 10 }}>
                <div style={{ display: 'flex', justifyContent: 'space-between' }}>
                  <Display size={14} weight={500}>Refund (policy applied)</Display>
                  <Display size={20} weight={500} style={{ color: preview.refund_amount > 0 ? '#1f6b3a' : '#9a1c1c', fontVariantNumeric: 'tabular-nums' }}>
                    {fmtMoney(preview.refund_amount, preview.currency)}
                  </Display>
                </div>
                <Sans size={11} color={CC.mid} style={{ marginTop: 4 }}>{preview.refund_percent}% refund · {preview.band.replace(/_/g, ' ')}</Sans>
                {preview.forfeit_amount > 0 && (
                  <Sans size={10} color="#9a1c1c" style={{ marginTop: 6 }}>
                    Guest forfeits {fmtMoney(preview.forfeit_amount, preview.currency)} per the {(preview.policy_detail?.name) || 'policy'}.
                  </Sans>
                )}
              </div>
            </div>

            {role === 'admin' && (
              <div style={{ marginBottom: 12 }}>
                <Sans size={10} weight={700} ls={0.16} upper color={CC.mid} style={{ marginBottom: 6 }}>Override (admin only)</Sans>
                <div style={{ display: 'flex', gap: 8 }}>
                  <button onClick={() => setRefundType('policy')}
                    style={{ flex: 1, padding: '10px', border: '1px solid ' + (refundType === 'policy' ? CC.ink : CC.border), background: refundType === 'policy' ? CC.ink : CC.white, color: refundType === 'policy' ? CC.white : CC.deep, borderRadius: 8, cursor: 'pointer', fontWeight: 600, fontSize: 12 }}>Apply policy ({preview.refund_percent}%)</button>
                  <button onClick={() => setRefundType('full')}
                    style={{ flex: 1, padding: '10px', border: '1px solid ' + (refundType === 'full' ? '#1f6b3a' : CC.border), background: refundType === 'full' ? '#1f6b3a' : CC.white, color: refundType === 'full' ? CC.white : CC.deep, borderRadius: 8, cursor: 'pointer', fontWeight: 600, fontSize: 12 }}>Override → 100% refund</button>
                </div>
                {refundType === 'full' && preview.refund_percent < 100 && (
                  <Sans size={10} color="#9a1c1c" style={{ marginTop: 6 }}>
                    Override grants the guest a full refund of {fmtMoney(preview.amount_paid, preview.currency)}, beyond what the policy requires. Use only for goodwill / TLB-side error.
                  </Sans>
                )}
              </div>
            )}

            <label style={{ display: 'block', marginBottom: 12 }}>
              <Sans size={10} weight={700} ls={0.16} upper color={CC.mid} style={{ marginBottom: 4 }}>Cancellation reason (sent to guest)</Sans>
              <textarea value={reason} onChange={e => setReason(e.target.value)} rows={3}
                style={{ width: '100%', padding: '10px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 13, fontFamily: 'inherit', resize: 'vertical' }}
                placeholder="e.g. Property unavailable due to maintenance, guest requested change, etc." />
            </label>

            <div style={{ display: 'flex', gap: 8 }}>
              <button onClick={onClose} disabled={submitting}
                style={{ padding: '10px 20px', border: '1px solid ' + CC.border, background: CC.white, color: CC.deep, borderRadius: 100, fontSize: 12, fontWeight: 600, cursor: 'pointer' }}>Keep booking</button>
              <button onClick={confirmCancel} disabled={submitting || !reason.trim()}
                style={{ flex: 1, padding: '10px 20px', border: 'none', background: '#9a1c1c', color: CC.white, borderRadius: 100, fontSize: 12, fontWeight: 700, letterSpacing: '0.06em', cursor: (submitting || !reason.trim()) ? 'not-allowed' : 'pointer', opacity: (submitting || !reason.trim()) ? 0.55 : 1 }}>
                {submitting ? 'Processing…' : 'Cancel + refund ' + (refundType === 'full' ? fmtMoney(preview.amount_paid, preview.currency) : fmtMoney(preview.refund_amount, preview.currency))}
              </button>
            </div>
          </>
        )}
      </Panel>
    </div>
  );
}

// v126 — CreateBookingModal: admin creates a booking on behalf of guest
function CreateBookingModal({ onClose, onSuccess }) {
  const propsApi = useApi('/api/admin?action=properties', { tokenType: 'admin' });
  const properties = (propsApi.data && propsApi.data.data && propsApi.data.data.properties)
    || (propsApi.data && propsApi.data.properties) || [];
  const [propertyId, setPropertyId] = useState('');
  const [checkIn, setCheckIn] = useState('');
  const [checkOut, setCheckOut] = useState('');
  const [guests, setGuests] = useState(2);
  const [guestName, setGuestName] = useState('');
  const [guestEmail, setGuestEmail] = useState('');
  const [guestPhone, setGuestPhone] = useState('');
  const [paymentStatus, setPaymentStatus] = useState('pending');
  const [notes, setNotes] = useState('');
  const [sendPaymentLink, setSendPaymentLink] = useState(true);
  const [submitting, setSubmitting] = useState(false);
  const [err, setErr] = useState('');

  async function submit() {
    setErr('');
    if (!propertyId) return setErr('Select a property');
    if (!checkIn || !checkOut) return setErr('Dates required');
    if (new Date(checkOut) <= new Date(checkIn)) return setErr('Check-out must be after check-in');
    if (!guestName.trim()) return setErr('Guest name required');
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(guestEmail.trim())) return setErr('Valid email required');

    setSubmitting(true);
    const token = localStorage.getItem('tlb_admin_token') || '';
    try {
      const r = await fetch('/api/admin-create-booking', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token },
        body: JSON.stringify({
          property_id: propertyId,
          check_in: checkIn,
          check_out: checkOut,
          guests,
          guest_name: guestName.trim(),
          guest_email: guestEmail.trim(),
          guest_phone: guestPhone.trim() || null,
          payment_status: paymentStatus,
          notes: notes.trim(),
          send_payment_link: sendPaymentLink,
        }),
      });
      const j = await r.json();
      if (r.ok) { onSuccess && onSuccess(j); onClose(); }
      else { setErr(j.error || 'Failed'); setSubmitting(false); }
    } catch (e) { setErr(e.message); setSubmitting(false); }
  }

  return (
    <div style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(20,20,20,.55)', backdropFilter: 'blur(6px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}>
      <Panel style={{ maxWidth: 640, width: '100%', maxHeight: '92vh', overflowY: 'auto' }}>
        <Display size={22} italic weight={400} style={{ marginBottom: 8 }}>New booking.</Display>
        <Sans size={11} color={CC.mid} style={{ marginBottom: 16 }}>Admin-created booking. Pricing auto-calculated from property nightly_rate (taxes bundled in displayed rate per v141). Optionally send Xendit payment link to guest.</Sans>

        <div style={{ display: 'grid', gap: 12 }}>
          <label>
            <Sans size={10} weight={700} ls={0.16} upper color={CC.mid} style={{ marginBottom: 4 }}>Property</Sans>
            <select value={propertyId} onChange={e => setPropertyId(e.target.value)}
              style={{ width: '100%', padding: '10px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 13, fontFamily: 'inherit' }}>
              <option value="">Select a property…</option>
              {properties.map(p => (
                <option key={p.id} value={p.id}>{p.name} {p.location ? '· ' + p.location.split(',')[0].trim() : ''}</option>
              ))}
            </select>
          </label>

          <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 110px', gap: 10 }}>
            <label>
              <Sans size={10} weight={700} ls={0.16} upper color={CC.mid} style={{ marginBottom: 4 }}>Check-in</Sans>
              <input type="date" value={checkIn} onChange={e => setCheckIn(e.target.value)}
                style={{ width: '100%', padding: '10px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 13, fontFamily: 'inherit' }} />
            </label>
            <label>
              <Sans size={10} weight={700} ls={0.16} upper color={CC.mid} style={{ marginBottom: 4 }}>Check-out</Sans>
              <input type="date" value={checkOut} onChange={e => setCheckOut(e.target.value)}
                style={{ width: '100%', padding: '10px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 13, fontFamily: 'inherit' }} />
            </label>
            <label>
              <Sans size={10} weight={700} ls={0.16} upper color={CC.mid} style={{ marginBottom: 4 }}>Guests</Sans>
              <input type="number" min="1" max="30" value={guests} onChange={e => setGuests(parseInt(e.target.value) || 1)}
                style={{ width: '100%', padding: '10px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 13, fontFamily: 'inherit' }} />
            </label>
          </div>

          <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
            <label>
              <Sans size={10} weight={700} ls={0.16} upper color={CC.mid} style={{ marginBottom: 4 }}>Guest name</Sans>
              <input type="text" value={guestName} onChange={e => setGuestName(e.target.value)} placeholder="Full name"
                style={{ width: '100%', padding: '10px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 13, fontFamily: 'inherit' }} />
            </label>
            <label>
              <Sans size={10} weight={700} ls={0.16} upper color={CC.mid} style={{ marginBottom: 4 }}>Guest email</Sans>
              <input type="email" value={guestEmail} onChange={e => setGuestEmail(e.target.value)} placeholder="email@…"
                style={{ width: '100%', padding: '10px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 13, fontFamily: 'inherit' }} />
            </label>
          </div>

          <label>
            <Sans size={10} weight={700} ls={0.16} upper color={CC.mid} style={{ marginBottom: 4 }}>Guest phone (optional)</Sans>
            <input type="tel" value={guestPhone} onChange={e => setGuestPhone(e.target.value)} placeholder="+62…"
              style={{ width: '100%', padding: '10px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 13, fontFamily: 'inherit' }} />
          </label>

          <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
            <label>
              <Sans size={10} weight={700} ls={0.16} upper color={CC.mid} style={{ marginBottom: 4 }}>Payment status</Sans>
              <select value={paymentStatus} onChange={e => setPaymentStatus(e.target.value)}
                style={{ width: '100%', padding: '10px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 13, fontFamily: 'inherit' }}>
                <option value="pending">Pending (collect later)</option>
                <option value="deposit_paid">Deposit paid (50%)</option>
                <option value="paid">Fully paid</option>
              </select>
            </label>
            <label style={{ display: 'flex', alignItems: 'flex-end', gap: 8, paddingBottom: 10 }}>
              <input type="checkbox" checked={sendPaymentLink} onChange={e => setSendPaymentLink(e.target.checked)} />
              <Sans size={12}>Send Xendit payment link to guest</Sans>
            </label>
          </div>

          <label>
            <Sans size={10} weight={700} ls={0.16} upper color={CC.mid} style={{ marginBottom: 4 }}>Internal notes (optional)</Sans>
            <textarea value={notes} onChange={e => setNotes(e.target.value)} rows={2}
              style={{ width: '100%', padding: '10px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 13, fontFamily: 'inherit', resize: 'vertical' }}
              placeholder="Special requests, comp reason, transfer notes, etc." />
          </label>

          {err && <Sans size={12} color="#9a1c1c">{err}</Sans>}

          <div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
            <button onClick={onClose} disabled={submitting}
              style={{ padding: '10px 20px', border: '1px solid ' + CC.border, background: CC.white, color: CC.deep, borderRadius: 100, fontSize: 12, fontWeight: 600, cursor: 'pointer' }}>Cancel</button>
            <button onClick={submit} disabled={submitting}
              style={{ flex: 1, padding: '10px 20px', border: 'none', background: CC.ink, color: CC.white, borderRadius: 100, fontSize: 12, fontWeight: 700, letterSpacing: '0.06em', cursor: submitting ? 'not-allowed' : 'pointer', opacity: submitting ? 0.55 : 1 }}>
              {submitting ? 'Creating…' : 'Create booking'}
            </button>
          </div>
        </div>
      </Panel>
    </div>
  );
}

// v125 — ModifyBookingModal: admin changes dates/guests with auto-recalc
function ModifyBookingModal({ booking, onClose, onSuccess }) {
  const [newCheckIn, setNewCheckIn] = useState(booking.check_in);
  const [newCheckOut, setNewCheckOut] = useState(booking.check_out);
  const [newGuests, setNewGuests] = useState(booking.guests || 1);
  const [reason, setReason] = useState('');
  const [preview, setPreview] = useState(null);
  const [loading, setLoading] = useState(false);
  const [submitting, setSubmitting] = useState(false);
  const [err, setErr] = useState('');

  function fmtMoney(n, cur) { const c = (cur || 'IDR').toUpperCase(); return c === 'USD' ? '$' + Math.round(n).toLocaleString() : 'Rp ' + Math.round(n).toLocaleString('id-ID'); }

  async function fetchPreview() {
    setErr(''); setPreview(null); setLoading(true);
    const token = localStorage.getItem('tlb_admin_token') || '';
    try {
      const r = await fetch('/api/admin-booking-modify?action=preview', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token },
        body: JSON.stringify({ booking_id: booking.id, new_check_in: newCheckIn, new_check_out: newCheckOut, new_guests: newGuests }),
      });
      const j = await r.json();
      if (r.ok) setPreview(j);
      else setErr(j.error || 'Preview failed');
    } catch (e) { setErr(e.message); }
    finally { setLoading(false); }
  }

  async function apply() {
    if (!reason.trim()) { setErr('Reason required'); return; }
    setSubmitting(true); setErr('');
    const token = localStorage.getItem('tlb_admin_token') || '';
    try {
      const r = await fetch('/api/admin-booking-modify?action=apply', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token },
        body: JSON.stringify({ booking_id: booking.id, new_check_in: newCheckIn, new_check_out: newCheckOut, new_guests: newGuests, reason }),
      });
      const j = await r.json();
      if (r.ok) { onSuccess && onSuccess(j); onClose(); }
      else { setErr(j.error || 'Apply failed'); setSubmitting(false); }
    } catch (e) { setErr(e.message); setSubmitting(false); }
  }

  return (
    <div style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(20,20,20,.55)', backdropFilter: 'blur(6px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}>
      <Panel style={{ maxWidth: 600, width: '100%', maxHeight: '90vh', overflowY: 'auto' }}>
        <Display size={22} italic weight={400} style={{ marginBottom: 6 }}>Modify booking.</Display>
        <Sans size={11} color={CC.mid} style={{ marginBottom: 14 }}>{booking.property_name} · {booking.guest_name || booking.guest_email}</Sans>

        <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, marginBottom: 14 }}>
          <label>
            <Sans size={10} weight={700} ls={0.16} upper color={CC.mid} style={{ marginBottom: 4 }}>New check-in</Sans>
            <input type="date" value={newCheckIn} onChange={e => { setNewCheckIn(e.target.value); setPreview(null); }}
              style={{ width: '100%', padding: '10px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 13, fontFamily: 'inherit' }} />
          </label>
          <label>
            <Sans size={10} weight={700} ls={0.16} upper color={CC.mid} style={{ marginBottom: 4 }}>New check-out</Sans>
            <input type="date" value={newCheckOut} onChange={e => { setNewCheckOut(e.target.value); setPreview(null); }}
              style={{ width: '100%', padding: '10px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 13, fontFamily: 'inherit' }} />
          </label>
        </div>
        <label style={{ display: 'block', marginBottom: 14 }}>
          <Sans size={10} weight={700} ls={0.16} upper color={CC.mid} style={{ marginBottom: 4 }}>Guest count</Sans>
          <input type="number" min="1" max="20" value={newGuests} onChange={e => { setNewGuests(parseInt(e.target.value) || 1); setPreview(null); }}
            style={{ width: '100%', padding: '10px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 13, fontFamily: 'inherit' }} />
        </label>

        <button onClick={fetchPreview} disabled={loading}
          style={{ width: '100%', padding: '10px', background: CC.white, color: CC.ink, border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 12, fontWeight: 600, cursor: 'pointer', marginBottom: 14 }}>
          {loading ? 'Calculating…' : 'Calculate price difference →'}
        </button>

        {preview && (
          <div style={{ padding: 14, background: CC.offWhite, borderRadius: 8, marginBottom: 14 }}>
            <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
              <Sans size={11} color={CC.mid}>Old total</Sans>
              <Sans size={11}>{fmtMoney(preview.booking.old_total, preview.currency)}</Sans>
            </div>
            <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
              <Sans size={11} color={CC.mid}>New total ({preview.new.nights} nights)</Sans>
              <Sans size={11} weight={600}>{fmtMoney(preview.new.total, preview.currency)}</Sans>
            </div>
            <div style={{ borderTop: '1px solid ' + CC.border, paddingTop: 8, marginTop: 8, display: 'flex', justifyContent: 'space-between' }}>
              <Display size={14} weight={500}>Difference</Display>
              <Display size={20} weight={500} style={{ color: preview.diff > 0 ? '#9a1c1c' : preview.diff < 0 ? '#1f6b3a' : CC.mid, fontVariantNumeric: 'tabular-nums' }}>
                {preview.diff > 0 ? '+' : preview.diff < 0 ? '−' : ''}{fmtMoney(Math.abs(preview.diff), preview.currency)}
              </Display>
            </div>
            <Sans size={10} color={CC.mid} style={{ marginTop: 6 }}>
              {preview.diff > 0 && 'Guest must pay balance due — TLB will send Xendit invoice link.'}
              {preview.diff < 0 && 'Auto-refund of the difference will be issued via Xendit.'}
              {preview.diff === 0 && 'No price change.'}
            </Sans>
          </div>
        )}

        <label style={{ display: 'block', marginBottom: 14 }}>
          <Sans size={10} weight={700} ls={0.16} upper color={CC.mid} style={{ marginBottom: 4 }}>Reason (sent to guest + host)</Sans>
          <textarea value={reason} onChange={e => setReason(e.target.value)} rows={3}
            style={{ width: '100%', padding: '10px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 13, fontFamily: 'inherit', resize: 'vertical' }}
            placeholder="e.g. Guest requested earlier check-in, party size changed, etc." />
        </label>

        {err && <Sans size={12} color="#9a1c1c" style={{ marginBottom: 8 }}>{err}</Sans>}

        <div style={{ display: 'flex', gap: 8 }}>
          <button onClick={onClose} disabled={submitting}
            style={{ padding: '10px 20px', border: '1px solid ' + CC.border, background: CC.white, color: CC.deep, borderRadius: 100, fontSize: 12, fontWeight: 600, cursor: 'pointer' }}>Cancel</button>
          <button onClick={apply} disabled={submitting || !preview || !reason.trim()}
            style={{ flex: 1, padding: '10px 20px', border: 'none', background: CC.ink, color: CC.white, borderRadius: 100, fontSize: 12, fontWeight: 700, letterSpacing: '0.06em', cursor: (submitting || !preview || !reason.trim()) ? 'not-allowed' : 'pointer', opacity: (submitting || !preview || !reason.trim()) ? 0.55 : 1 }}>
            {submitting ? 'Applying…' : 'Apply modification'}
          </button>
        </div>
      </Panel>
    </div>
  );
}

// ════════════════════════════════════════════════════════════════════════
// ADMIN SECTIONS
// ════════════════════════════════════════════════════════════════════════

function AdminHosts() {
  const { data, loading, error, refetch } = useApi('/api/admin?action=hosts', { tokenType: 'admin' });
  const [busyId, setBusyId] = useState(null);
  const [renewBusyId, setRenewBusyId] = useState(null);
  // v141.76 — Password-renew result modal. When the API returns the
  // new password inline (email delivery warning, or dev mode without
  // RESEND_API_KEY), we surface the raw password to the admin so they
  // can copy + forward to the host manually rather than losing it.
  const [renewResult, setRenewResult] = useState(null);
  const [filter, setFilter] = useState('all');  // all | active | suspended | unverified
  const [createOpen, setCreateOpen] = useState(false);
  // v141.138 — HOOK ORDER FIX. `search` useState was previously
  // declared AFTER `if (loading) return` (around line 116) → React
  // error #310 on first transition from loading to loaded. Moved up
  // here with the other state hooks so hook count stays stable.
  const [search, setSearch] = useState('');
  // Auto-refetch when CreateHostModal succeeds
  useEffect(() => {
    const onCreated = () => refetch();
    window.addEventListener('tlb-host-created', onCreated);
    return () => window.removeEventListener('tlb-host-created', onCreated);
  }, []);

  if (loading) return <SectionWrap title="Hosts"><Loading msg="Fetching host directory…" /></SectionWrap>;
  if (error) return <SectionWrap title="Hosts"><ErrorState msg={error} onRetry={refetch} /></SectionWrap>;

  const allHosts = (data && data.data && data.data.hosts) || (data && data.hosts) || [];
  const counts = {
    all: allHosts.length,
    active: allHosts.filter(h => h.is_active !== false).length,
    suspended: allHosts.filter(h => h.is_active === false).length,
    unverified: allHosts.filter(h => !h.is_verified).length,
  };
  const hosts = allHosts.filter(h => {
    if (filter === 'active') return h.is_active !== false;
    if (filter === 'suspended') return h.is_active === false;
    if (filter === 'unverified') return !h.is_verified;
    return true;
  });

  // v141.76 — Renew host password via /api/reset-password action=admin-renew.
  // The endpoint regenerates a random password, persists the hash, and
  // emails the new credential to info@theluxuryleisure.com (hardcoded
  // PW_RESET_HOLDING_INBOX while we're in pre-launch). Admin forwards
  // to the host. Same endpoint admin-classic has used since v89; this
  // is the native button so admins don't need DevTools.
  function renewPassword(host) {
    const email = host.email || '';
    if (!email) {
      alert('This host row has no email — cannot renew password.');
      return;
    }
    const ok = window.confirm(
      'Renew password for "' + (host.name || email) + '"?\n\n' +
      'A new random password will be generated and emailed to info@theluxuryleisure.com ' +
      '(pre-launch holding inbox). Forward it to the host. The old password stops working immediately.'
    );
    if (!ok) return;
    setRenewBusyId(host.id);
    let token = ''; try { token = localStorage.getItem('tlb_admin_token') || localStorage.getItem('tlb_token') || ''; } catch {}
    fetch('/api/reset-password', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'Authorization': token ? 'Bearer ' + token : '' },
      body: JSON.stringify({ action: 'admin-renew', email }),
    })
      .then(r => r.json().then(j => ({ ok: r.ok, j })))
      .then(({ ok, j }) => {
        const showToast = (window.TLB_NATIVE_MUTATIONS && window.TLB_NATIVE_MUTATIONS.showToast) || function(){};
        if (!ok) {
          showToast(j.error || 'Renew failed', 'danger');
          return;
        }
        // Success — three shapes:
        //   • email delivered: {success, message, testMode, redirectedTo}
        //   • email failed but password updated: {success, warning, newPassword, emailError}
        //   • dev mode (no RESEND_API_KEY): {success, devPassword}
        // Always show the result modal so admin sees the redirect target
        // + can copy the password if it was returned inline.
        setRenewResult({
          email,
          name: host.name || email,
          message: j.message || j.warning || 'Password renewed.',
          redirectedTo: (j.redirectedTo && j.redirectedTo.join(', ')) || 'info@theluxuryleisure.com',
          newPassword: j.newPassword || j.devPassword || null,
          warning: j.warning || j.emailError || null,
        });
        showToast('Password renewed for ' + (host.name || email), 'success');
      })
      .catch(err => alert('Network error: ' + (err.message || err)))
      .finally(() => setRenewBusyId(null));
  }

  function toggleActive(host) {
    const willSuspend = host.is_active !== false;
    const verb = willSuspend ? 'suspend' : 'reactivate';
    const msg = willSuspend
      ? `Suspend host "${host.name || host.email}"?\n\nALL their villas will be hidden from public search immediately.\nBookings, reviews, and the host's login are NOT affected.\nReversible at any time.`
      : `Reactivate host "${host.name || host.email}"?\n\nTheir villas will reappear in public search (subject to per-villa is_active).`;
    if (!window.confirm(msg)) return;

    const reason = willSuspend ? (window.prompt('Reason for suspension (optional):') || '') : '';
    setBusyId(host.id);
    let token = ''; try { token = localStorage.getItem('tlb_admin_token') || localStorage.getItem('tlb_token') || ''; } catch {}
    fetch('/api/admin-manage', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'Authorization': token ? 'Bearer ' + token : '' },
      body: JSON.stringify({ action: 'set_host_active', host_id: host.id, active: !willSuspend, reason }),
    })
      .then(r => r.json().then(j => ({ ok: r.ok, j })))
      .then(({ ok, j }) => {
        if (!ok) throw new Error(j.error || ('Failed to ' + verb));
        refetch();
      })
      .catch(err => alert('Error: ' + err.message))
      .finally(() => setBusyId(null));
  }

  const CreateHost = (window.TLB_NATIVE_MUTATIONS && window.TLB_NATIVE_MUTATIONS.CreateHostModal) || (() => null);

  // v141.138 — `search` useState moved up to be before `if (loading) return`
  // for React hook order stability. Filter logic stays here.
  const filteredHosts = hosts.filter(h => {
    if (!search) return true;
    const s = search.toLowerCase();
    return (h.name || '').toLowerCase().includes(s) ||
           (h.email || '').toLowerCase().includes(s);
  });

  return (
    <SectionWrap
      title="Hosts"
      subtitle="Every host under TLB management. Click a row to edit, suspend, or renew their password."
      dataNeeds={['hosts']}
      actions={
        <div style={{ display: 'flex', gap: 8 }}>
          <input
            type="text" value={search} onChange={(e) => setSearch(e.target.value)}
            placeholder="Search hosts…"
            style={{
              padding: '10px 16px', borderRadius: 100, minWidth: 220,
              border: '1px solid ' + CC.border, background: CC.white,
              fontFamily: F_TLB.ui, fontSize: 12, color: CC.ink, outline: 'none',
            }}
          />
          <button onClick={() => setCreateOpen(true)}
            style={{ padding: '10px 18px', borderRadius: 100, cursor: 'pointer', background: CC.ink, color: CC.white, border: '1px solid ' + CC.ink, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600 }}>
            Add host
          </button>
        </div>
      }>
      <CreateHost open={createOpen} onClose={() => setCreateOpen(false)} />
      <Sans size={10} weight={600} ls={0.14} upper color={CC.mid} style={{ marginBottom: 14 }}>
        {counts.all} host accounts · {counts.active} active · {counts.suspended} suspended · {counts.unverified} unverified
      </Sans>
      <FilterChips value={filter} onChange={setFilter} options={[
        { v: 'all',         label: 'All · ' + counts.all },
        { v: 'active',      label: 'Active · ' + counts.active },
        { v: 'suspended',   label: 'Suspended · ' + counts.suspended },
        { v: 'unverified',  label: 'Unverified · ' + counts.unverified },
      ]} />
      <Table
        columns={[
          { label: 'Host', key: 'name', render: r => {
            const initials = (r.name || r.email || 'H').split(/[\s@]/)[0][0] || 'H';
            return (
              <div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
                <div style={{
                  width: 36, height: 36, borderRadius: 99,
                  background: r.is_active === false ? CC.offWhite : '#fbf8f0',
                  color: r.is_active === false ? CC.mid : '#9a7d3f',
                  display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
                  fontFamily: F_TLB.display, fontStyle: 'italic', fontSize: 14, flexShrink: 0,
                  border: '1px solid ' + (r.is_active === false ? CC.border : 'rgba(154,125,63,0.18)'),
                }}>{initials.toUpperCase()}</div>
                <div>
                  <div style={{ fontWeight: 600, color: r.is_active === false ? CC.mid : CC.ink, textDecoration: r.is_active === false ? 'line-through' : 'none' }}>
                    {r.name || r.email || 'Unnamed'}
                  </div>
                  {r.deactivated_reason && <div style={{ fontSize: 11, color: '#9a1c1c', marginTop: 2 }} title={r.deactivated_reason}>· {r.deactivated_reason.length > 60 ? r.deactivated_reason.slice(0, 60) + '…' : r.deactivated_reason}</div>}
                </div>
              </div>
            );
          } },
          { label: 'Email', key: 'email', render: r => <span style={{ color: CC.mid, fontSize: 12 }}>{r.email}</span> },
          { label: 'Villas', key: 'property_count', align: 'right', numeric: true, render: r => (
            <span style={{ fontFamily: F_TLB.display, fontStyle: 'italic', fontSize: 16, color: CC.ink }}>
              {r.property_count != null ? r.property_count : (r.properties ? r.properties.length : 0)}
            </span>
          )},
          { label: 'Status', key: 'status', render: r => (
            <span style={{ display: 'inline-flex', gap: 4, flexWrap: 'wrap' }}>
              <Pill tone={r.is_active === false ? 'danger' : 'success'}>{r.is_active === false ? 'Suspended' : 'Active'}</Pill>
              <Pill tone={r.is_verified ? 'info' : 'warn'}>{r.is_verified ? 'Verified' : 'Unverified'}</Pill>
            </span>
          ) },
          { label: 'Last activity', key: 'last_login_at', align: 'right', nowrap: true, render: r => (
            <span style={{ color: CC.mid, fontSize: 12 }}>{fmtDate(r.last_login_at || r.created_at || r.signup_date)}</span>
          )},
          { label: 'Actions', key: '_actions', align: 'right', nowrap: true, render: r => {
            const isSuspended = r.is_active === false;
            const isBusy      = busyId === r.id;
            const isRenewing  = renewBusyId === r.id;
            return (
              <div style={{ display: 'inline-flex', gap: 6, justifyContent: 'flex-end' }}>
                {/* v141.76 — Renew password button. Posts admin-renew
                    to /api/reset-password; new credential is emailed
                    to info@theluxuryleisure.com (pre-launch holding
                    inbox) and the response is surfaced in a modal so
                    the admin can copy if email delivery hiccups. */}
                <button
                  onClick={() => renewPassword(r)}
                  disabled={isRenewing || isBusy}
                  title={'Generate a new password for ' + (r.email || 'this host') + ' — emailed to admin inbox for forwarding'}
                  aria-label={'Renew password for ' + (r.name || r.email)}
                  style={{
                    padding: '6px 12px', borderRadius: 100, fontSize: 11, fontWeight: 600,
                    cursor: isRenewing ? 'wait' : 'pointer', letterSpacing: '0.04em',
                    border: '1px solid ' + CC.border,
                    background: CC.white, color: CC.deep,
                    opacity: isRenewing ? 0.6 : 1,
                    display: 'inline-flex', alignItems: 'center', gap: 5,
                  }}
                  onMouseEnter={e => { if (!isRenewing) { e.currentTarget.style.borderColor = CC.ink; e.currentTarget.style.color = CC.ink; } }}
                  onMouseLeave={e => { if (!isRenewing) { e.currentTarget.style.borderColor = CC.border; e.currentTarget.style.color = CC.deep; } }}
                >
                  {/* Key icon — Feather-style, indicates credential renewal */}
                  <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                    <path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>
                  </svg>
                  {isRenewing ? '…' : 'Renew'}
                </button>
                <button
                  onClick={() => toggleActive(r)}
                  disabled={isBusy || isRenewing}
                  style={{
                    padding: '6px 14px', borderRadius: 100, fontSize: 11, fontWeight: 600,
                    cursor: isBusy ? 'wait' : 'pointer', letterSpacing: '0.04em',
                    border: '1px solid ' + (isSuspended ? '#1f6b3a' : '#9a1c1c'),
                    background: isSuspended ? 'rgba(31,107,58,0.08)' : 'rgba(154,28,28,0.06)',
                    color: isSuspended ? '#1f6b3a' : '#9a1c1c',
                    opacity: isBusy ? 0.6 : 1,
                  }}
                >
                  {isBusy ? '…' : (isSuspended ? '↑ Reactivate' : '⊘ Suspend')}
                </button>
              </div>
            );
          } },
        ]}
        rows={filteredHosts}
        empty={search ? `No hosts match "${search}".` : (filter === 'all' ? "No hosts in the directory yet." : "No hosts match this filter.")}
      />

      {/* v141.76 — Password-renew result modal. Shows where the email
          was delivered (always info@theluxuryleisure.com in pre-launch)
          plus the raw password if it was returned inline (dev mode or
          email-delivery warning). One-click copy via clipboard. */}
      {renewResult && (
        <div
          onClick={() => setRenewResult(null)}
          style={{ position: 'fixed', inset: 0, background: 'rgba(20,20,20,0.42)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}
        >
          <div
            onClick={(e) => e.stopPropagation()}
            style={{ background: CC.white, borderRadius: 16, maxWidth: 480, width: '100%', padding: '28px 32px', boxShadow: '0 24px 60px rgba(0,0,0,0.18)' }}
          >
            <Sans size={11} weight={700} ls={0.16} upper color={CC.mid} style={{ marginBottom: 10 }}>Password renewed</Sans>
            <Display size={24} italic weight={400} style={{ marginBottom: 14, lineHeight: 1.15 }}>{renewResult.name}.</Display>
            <Sans size={13} color={CC.deep} style={{ marginBottom: 18, lineHeight: 1.55 }}>{renewResult.message}</Sans>
            <div style={{ background: CC.offWhite, border: `1px solid ${CC.border}`, borderRadius: 10, padding: 16, marginBottom: 18 }}>
              <Sans size={10} weight={700} ls={0.14} upper color={CC.mid} style={{ marginBottom: 6 }}>Delivered to</Sans>
              <Sans size={13} color={CC.ink} style={{ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace' }}>{renewResult.redirectedTo}</Sans>
            </div>
            {renewResult.newPassword && (
              <div style={{ background: '#fdf6e9', border: '1px solid #b8923a', borderRadius: 10, padding: 16, marginBottom: 18 }}>
                <Sans size={10} weight={700} ls={0.14} upper color="#7a5a1f" style={{ marginBottom: 6 }}>
                  {renewResult.warning ? 'Email delivery failed — copy this manually' : 'Temporary password (dev mode)'}
                </Sans>
                <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
                  <code style={{ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 16, fontWeight: 700, letterSpacing: '0.04em', color: CC.ink, flex: 1, wordBreak: 'break-all' }}>{renewResult.newPassword}</code>
                  <button
                    onClick={() => {
                      try {
                        navigator.clipboard.writeText(renewResult.newPassword);
                        const showToast = (window.TLB_NATIVE_MUTATIONS && window.TLB_NATIVE_MUTATIONS.showToast) || function(){};
                        showToast('Password copied to clipboard', 'success');
                      } catch {}
                    }}
                    style={{ padding: '6px 12px', border: '1px solid #b8923a', borderRadius: 100, background: '#fff', color: '#7a5a1f', fontSize: 11, fontWeight: 600, cursor: 'pointer' }}
                  >
                    Copy
                  </button>
                </div>
                {renewResult.warning && (
                  <Sans size={11} color="#9a1c1c" style={{ marginTop: 10, lineHeight: 1.5 }}>{renewResult.warning}</Sans>
                )}
              </div>
            )}
            <button
              onClick={() => setRenewResult(null)}
              style={{ width: '100%', padding: '12px', background: CC.ink, color: CC.white, border: 'none', borderRadius: 100, fontSize: 12, fontWeight: 600, letterSpacing: '0.06em', cursor: 'pointer' }}
            >
              Done
            </button>
          </div>
        </div>
      )}
    </SectionWrap>
  );
}

// Reusable filter chip row used by AdminHosts + AdminProperties.
function FilterChips({ value, onChange, options }) {
  return (
    <div style={{ display: 'flex', gap: 8, marginBottom: dashGap(), flexWrap: 'wrap' }}>
      {options.map(o => (
        <button
          key={o.v}
          onClick={() => onChange(o.v)}
          style={{
            padding: '7px 14px', borderRadius: 100, fontSize: 11, fontWeight: 500, cursor: 'pointer',
            border: '1px solid ' + (value === o.v ? CC.ink : CC.border),
            background: value === o.v ? CC.ink : CC.white,
            color: value === o.v ? CC.white : CC.deep,
            letterSpacing: '0.04em', transition: 'all .15s',
          }}
        >
          {o.label}
        </button>
      ))}
    </div>
  );
}

// ── AdminRateParity (v141.114) ─────────────────────────────────────────
// Single-pane view of every villa's TLB rate vs the most recent probe of
// its source partner site. Each row shows: source badge + clickable URL,
// stored TLB rate, last-probed source rate, max drift %, and per-row
// quick actions (Accept proposed · Lock as attested · Dismiss · Re-probe).
//
// Data comes from /api/admin/rate-parity which joins:
//   • properties (active villas with source_url + seasonal_rates)
//   • villa_audit_log (latest unresolved flagged drift row per villa)
//
// The status palette (drift_critical · drift_warn · ok · stale · locked
// · never_probed · no_source) drives both the summary tiles and the
// row-level badges so an operator can pattern-match by colour in
// under a second.
function AdminRateParity() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [filter, setFilter] = useState('action_needed');
  const [search, setSearch] = useState('');
  const [selected, setSelected] = useState(new Set());
  const [busy, setBusy] = useState(false);
  // v141.280 — Paste-HTML modal for Cloudflare-blocked hosts (TLN/TTD/Booking)
  const [pasteFor, setPasteFor] = useState(null);    // { id, name, source_url }
  const [pasteHtml, setPasteHtml] = useState('');
  const [pastePreview, setPastePreview] = useState(null);
  const [pasteBusy, setPasteBusy] = useState(false);
  const [pasteError, setPasteError] = useState(null);

  // v141.285 — Universal Paste HTML access. Show the button for ANY villa
  // that has a source_url. Parser status (production/beta/not_implemented)
  // is surfaced inside the modal so the operator knows what to expect
  // BEFORE they spend time pasting. See api/admin/rate-paste-html.js
  // PARSER_STATUS for the current state per platform.
  const isPasteHost = React.useCallback((url) => {
    return !!String(url || '').trim();
  }, []);

  // Mirror api/admin/rate-paste-html.js PARSER_STATUS so the modal can
  // show status BEFORE the operator pastes (saves them from a wasted
  // paste-and-click if the parser isn't ready yet).
  const PARSER_STATUS_CLIENT = {
    luxenomad:    { status: 'production', label: 'Production — proven 21 villas', host: 'theluxenomad.com' },
    tropicaldoor: { status: 'beta', label: 'Beta — single daily rate (open calendar for per-season)', host: 'thetropicaldoor.com' },
    booking:      { status: 'not_implemented', label: 'Not implemented — use extranet export', host: 'booking.com' },
    elitehavens:  { status: 'not_implemented', label: 'Not implemented — auto-probe handles this', host: 'elitehavens.com' },
    nakula:       { status: 'production', label: 'Production — live per-date booking API (~3s probe)', host: 'nakula.com' },
    tls:          { status: 'not_implemented', label: 'Not implemented — auto-probe handles this', host: 'theluxurysignature.com' },
    tampahhills:  { status: 'not_implemented', label: 'Not implemented — IBE behind WAF (manual sheet)', host: 'tampahhills.com' },
    bestay:       { status: 'redundant', label: 'Redundant — auto-probe (every 4h) is more accurate', host: 'bestay.co' },
    airbnb:       { status: 'not_implemented', label: 'Not implemented — auto-probe handles this', host: 'airbnb.com' },
  };
  const detectClientPlatform = React.useCallback((url) => {
    const u = String(url || '').toLowerCase();
    for (const [k, v] of Object.entries(PARSER_STATUS_CLIENT)) {
      if (u.includes(v.host)) return { key: k, ...v };
    }
    return { key: 'generic', status: 'not_implemented', label: 'Unknown host', host: u };
  }, []);

  const openPasteModal = React.useCallback((row) => {
    setPasteFor({ id: row.id, name: row.name, source_url: row.source_url });
    setPasteHtml('');
    setPastePreview(null);
    setPasteError(null);
  }, []);

  const closePasteModal = React.useCallback(() => {
    setPasteFor(null);
    setPasteHtml('');
    setPastePreview(null);
    setPasteError(null);
    setPasteBusy(false);
  }, []);

  const runPasteParse = React.useCallback(async (dryRun) => {
    if (!pasteFor || !pasteHtml.trim()) {
      setPasteError('Paste the villa page HTML first.');
      return;
    }
    setPasteBusy(true); setPasteError(null);
    try {
      const token = adminToken();
      const r = await fetch('/api/admin/rate-paste-html', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', ...(token ? { 'Authorization': 'Bearer ' + token } : {}) },
        body: JSON.stringify({
          property_id: pasteFor.id,
          source_url: pasteFor.source_url,
          html: pasteHtml,
          dry_run: !!dryRun,
        }),
      });
      const j = await r.json().catch(() => ({}));
      if (!r.ok || !j.ok) {
        setPasteError(j.error || `HTTP ${r.status}`);
        if (j.extracted) setPastePreview({ failed: true, extracted: j.extracted, hint: j.hint });
        return;
      }
      setPastePreview(j);
      if (!dryRun) {
        const showToast = (window.TLB_NATIVE_MUTATIONS && window.TLB_NATIVE_MUTATIONS.showToast) || function () {};
        showToast(`Applied — ${j.extracted?.summary?.period_count || 0} periods, ${j.extracted?.summary?.promo_count || 0} promos for ${pasteFor.name}`);
        setTimeout(() => { closePasteModal(); fetchRows(); }, 800);
      }
    } catch (e) {
      setPasteError('Network error: ' + (e.message || 'unknown'));
    } finally {
      setPasteBusy(false);
    }
  }, [pasteFor, pasteHtml, closePasteModal]);

  const adminToken = () => {
    try { return localStorage.getItem('tlb_admin_token') || localStorage.getItem('tlb_token') || ''; }
    catch { return ''; }
  };

  const fetchRows = React.useCallback(() => {
    setLoading(true); setError(null);
    const token = adminToken();
    fetch('/api/admin/rate-parity', {
      headers: token ? { 'Authorization': 'Bearer ' + token } : {},
    })
      .then(async r => {
        // v141.138 — Defensive JSON parse. Was: r.json() then .ok lookup.
        // If response isn't JSON (HTML error page, empty 502, etc.),
        // r.json() throws SyntaxError and the user saw a cryptic
        // "Failed to read properties" TypeError. Now we capture the
        // status code + body text so the error message is actionable.
        const text = await r.text().catch(() => '');
        let j = null;
        try { j = text ? JSON.parse(text) : null; } catch { /* not JSON */ }
        if (!r.ok) {
          throw new Error((j && j.error) || ('HTTP ' + r.status + ' — sign in as admin to load rate parity'));
        }
        if (!j) throw new Error('Server returned non-JSON response (status ' + r.status + ')');
        return { ok: true, j };
      })
      .then(({ ok, j }) => {
        if (!ok || !j || !j.ok) throw new Error((j && j.error) || 'Server rejected the request');
        setData(j);
      })
      .catch(e => setError(e.message || 'Failed to load'))
      .finally(() => setLoading(false));
  }, []);

  React.useEffect(() => { fetchRows(); }, [fetchRows]);

  async function runAction(action, ids) {
    if (!ids.length) return;
    if (action === 'accept' && !confirm('Apply the probed source rates to ' + ids.length + ' villa' + (ids.length > 1 ? 's' : '') + '? This overwrites the current TLB stored rates.')) return;
    if (action === 'lock'   && !confirm('Lock ' + ids.length + ' villa' + (ids.length > 1 ? 's' : '') + ' as host-attested? Future probes will skip them.')) return;
    if (action === 'dismiss'&& !confirm('Dismiss flagged drift on ' + ids.length + ' villa' + (ids.length > 1 ? 's' : '') + ' without changing stored rates?')) return;
    setBusy(true);
    const token = adminToken();
    try {
      const r = await fetch('/api/admin/rate-parity', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', ...(token ? { 'Authorization': 'Bearer ' + token } : {}) },
        body: JSON.stringify({ action, ids }),
      });
      const j = await r.json();
      const showToast = (window.TLB_NATIVE_MUTATIONS && window.TLB_NATIVE_MUTATIONS.showToast) || function () {};
      if (!r.ok || !j.ok) {
        showToast(j.error || 'Action failed', 'error');
      } else {
        const okCount = (j.results || []).filter(x => x.ok).length;
        showToast(action + ' · ' + okCount + ' of ' + ids.length + ' applied');
        setSelected(new Set());
        fetchRows();
      }
    } catch (e) {
      const showToast = (window.TLB_NATIVE_MUTATIONS && window.TLB_NATIVE_MUTATIONS.showToast) || function () {};
      showToast('Network error', 'error');
    } finally {
      setBusy(false);
    }
  }

  if (loading) return <SectionWrap title="Rate parity"><Loading msg="Loading every villa's TLB rate vs. source…" /></SectionWrap>;
  if (error)   return <SectionWrap title="Rate parity"><ErrorState msg={error} onRetry={fetchRows} /></SectionWrap>;

  const rows = (data && data.rows) || [];
  const summary = (data && data.summary) || {};

  // Filter chips drive a single-status filter except 'all' (no filter)
  // and 'action_needed' (warn + critical combined).
  const filterFn = (r) => {
    if (filter === 'all') return true;
    if (filter === 'action_needed') return r.drift_status === 'drift_warn' || r.drift_status === 'drift_critical';
    return r.drift_status === filter;
  };
  const searchFn = (r) => {
    if (!search.trim()) return true;
    const q = search.toLowerCase();
    return (r.name || '').toLowerCase().includes(q)
        || (r.url_slug || '').toLowerCase().includes(q)
        || (r.host_email || '').toLowerCase().includes(q)
        || (r.source || '').toLowerCase().includes(q)
        || (r.source_url || '').toLowerCase().includes(q);
  };
  const visible = rows.filter(filterFn).filter(searchFn);

  // Status palette for tile/row badges.
  const STATUS_STYLES = {
    ok:              { bg: '#f1f9f3', fg: '#1f6b3a', label: 'OK',                 dot: '#1f6b3a' },
    drift_minor:     { bg: '#f1f9f3', fg: '#1f6b3a', label: 'Minor (auto-healed)',dot: '#5fbf83' },
    drift_warn:      { bg: '#fdf6e9', fg: '#7a5a1f', label: 'Drift · WARN',       dot: '#b8923a' },
    drift_critical:  { bg: '#fbf3f3', fg: '#9a1c1c', label: 'Drift · CRITICAL',   dot: '#9a1c1c' },
    stale:           { bg: '#fdf9ee', fg: '#7a5a1f', label: 'Stale (>30d)',       dot: '#b8923a' },
    never_probed:    { bg: '#f4f3ef', fg: '#666',    label: 'Never probed',       dot: '#888' },
    no_source:       { bg: '#f4f3ef', fg: '#888',    label: 'No source URL',      dot: '#bbb' },
    locked:          { bg: '#f0f4fa', fg: '#1f4d7a', label: 'Host-attested',      dot: '#1f4d7a' },
  };

  const tile = (key, label) => {
    const n = summary[key] || 0;
    const style = STATUS_STYLES[key] || STATUS_STYLES.ok;
    return (
      <button key={key} onClick={() => setFilter(key)}
        style={{
          flex: '1 1 140px', padding: '14px 16px', borderRadius: 8, cursor: 'pointer',
          background: filter === key ? CC.ink : style.bg,
          color: filter === key ? CC.white : style.fg,
          border: '1px solid ' + (filter === key ? CC.ink : 'transparent'),
          textAlign: 'left', transition: 'all .15s',
        }}>
        <div style={{ fontSize: 10, letterSpacing: '0.14em', textTransform: 'uppercase', fontWeight: 600, opacity: 0.85 }}>{label}</div>
        <div style={{ fontSize: 26, fontFamily: F_TLB ? F_TLB.display : "'Playfair Display', Georgia, serif", fontWeight: 500, marginTop: 4 }}>{n}</div>
      </button>
    );
  };

  function fmtRate(n, cur) {
    if (n == null || isNaN(Number(n))) return '—';
    const v = Math.round(Number(n));
    if (cur === 'IDR') return 'Rp ' + v.toLocaleString('id-ID');
    if (cur === 'USD') return '$' + v.toLocaleString('en-US');
    return (cur || '') + ' ' + v.toLocaleString();
  }
  function fmtDate(iso) {
    if (!iso) return '—';
    try { return new Date(iso).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }); }
    catch { return '—'; }
  }
  function ageDays(iso) {
    if (!iso) return null;
    try { return Math.floor((Date.now() - new Date(iso).getTime()) / 86400000); }
    catch { return null; }
  }
  function sourceBadge(source) {
    if (!source) return null;
    const COLOURS = {
      nakula:         '#9a7d3f',
      elitehavens:    '#3a6f9c',
      privatehomesandvillas: '#7a5a1f',
      tampahhills:    '#1f6b3a',
      bestay:         '#9a1c1c',
      airbnb:         '#ff5a5f',
      booking:        '#003580',
      theluxenomad:   '#141414',
    };
    const key = String(source).toLowerCase().split(/[^a-z]/)[0];
    const colour = COLOURS[key] || '#666';
    return (
      <span style={{
        display: 'inline-block', padding: '2px 8px', borderRadius: 100,
        background: colour, color: '#fff', fontSize: 9, fontWeight: 600,
        letterSpacing: '0.12em', textTransform: 'uppercase',
      }}>{source}</span>
    );
  }

  const toggleSelect = (id) => {
    setSelected(prev => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id); else next.add(id);
      return next;
    });
  };
  const selectAllVisible = () => {
    const ids = visible.map(r => r.id);
    setSelected(prev => {
      const allSelected = ids.every(i => prev.has(i));
      if (allSelected) {
        const next = new Set(prev);
        for (const i of ids) next.delete(i);
        return next;
      }
      return new Set([...prev, ...ids]);
    });
  };

  // v141.128 — Unified 9-tile border grid (was loose flex panels)
  // matches mockup's grid pattern. Each tile click sets the filter.
  function gridTile(key, label, tone) {
    const n = key === 'all' ? (summary.total || 0)
            : key === 'action_needed' ? (summary.action_needed || 0)
            : (summary[key] || 0);
    const isActive = filter === key;
    const toneColor = tone === 'red' ? '#9a1c1c'
                    : tone === 'amber' ? '#c08a3e'
                    : tone === 'sage' ? '#2d6a4f'
                    : tone === 'blue' ? '#1f4d7a'
                    : CC.ink;
    return (
      <button key={key} onClick={() => setFilter(key)} style={{
        padding: '22px 18px',
        borderRight: 'inherit',
        cursor: 'pointer',
        background: isActive ? '#fbf8f0' : CC.white,
        color: CC.ink,
        border: 0, textAlign: 'left',
        borderTop: isActive ? `2px solid #9a7d3f` : '2px solid transparent',
        transition: 'background .15s, border-color .15s',
        fontFamily: 'inherit',
      }}>
        <div style={{
          fontFamily: F_TLB.ui, fontSize: 9.5, fontWeight: 600, color: CC.mid,
          letterSpacing: '0.18em', textTransform: 'uppercase', minHeight: 24, lineHeight: 1.3,
        }}>{label}</div>
        <div style={{
          marginTop: 14, fontFamily: F_TLB.ui, fontSize: 30, fontWeight: 500,
          fontFeatureSettings: '"tnum"',
          letterSpacing: '-0.025em', lineHeight: 1,
          fontVariantNumeric: 'tabular-nums',
          color: toneColor,
        }}>{n}</div>
      </button>
    );
  }

  return (
    <SectionWrap
      title="Rate parity"
      subtitle="TLB stored rates vs. last source-site probe. Action-needed villas flagged automatically. Click a tile to filter."
      dataNeeds={['rate-parity']}
      actions={
        <div style={{ display: 'flex', gap: 8 }}>
          <button onClick={() => runAction('reprobe', visible.map(r => r.id))} disabled={busy || !visible.length}
            style={{ padding: '10px 18px', borderRadius: 100, cursor: busy ? 'wait' : 'pointer', background: CC.white, color: CC.deep, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600 }}>
            Probe all
          </button>
          <button onClick={() => runAction('accept', [...selected])} disabled={busy || !selected.size}
            style={{ padding: '10px 18px', borderRadius: 100, cursor: busy || !selected.size ? 'not-allowed' : 'pointer', background: CC.ink, color: CC.white, border: '1px solid ' + CC.ink, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600, opacity: busy || !selected.size ? 0.5 : 1 }}>
            Accept selected{selected.size ? ` · ${selected.size}` : ''}
          </button>
        </div>
      }
    >
      {/* 9-tile unified border grid (mockup §7.2 #9) */}
      <div style={{
        display: 'grid', gridTemplateColumns: 'repeat(9, 1fr)', gap: 0,
        border: `1px solid ${CC.border}`, borderRadius: 18, overflow: 'hidden',
        background: CC.white, marginBottom: dashGap(),
      }} className="rate-parity-tiles">
        {/* Tiles inherit borderRight via parent's grid-line styling */}
        <style>{`.rate-parity-tiles > button:not(:last-child) { border-right: 1px solid ${CC.border} !important; }`}</style>
        {gridTile('all',            'Total villas',     'ink')}
        {gridTile('action_needed',  'Action needed',    summary.action_needed > 0 ? 'red' : 'ink')}
        {gridTile('drift_critical', 'Critical ≥30%',    'red')}
        {gridTile('drift_warn',     'Warn 15-30%',      'amber')}
        {gridTile('stale',          'Stale > 30 days',  'amber')}
        {gridTile('never_probed',   'Never probed',     'ink')}
        {gridTile('no_source',      'No source URL',    'ink')}
        {gridTile('locked',         'Host-attested',    'blue')}
        {gridTile('ok',             'In parity',        'sage')}
      </div>

      {/* Search + bulk action bar */}
      <div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center', marginBottom: 14 }}>
        <input
          value={search}
          onChange={e => setSearch(e.target.value)}
          placeholder="Search villa, host, slug, source…"
          style={{ flex: '1 1 280px', padding: '10px 14px', border: '1px solid ' + CC.border, borderRadius: 100, fontSize: 13, background: CC.white }}
        />
        <Sans size={11} weight={600} color={CC.mid} style={{ whiteSpace: 'nowrap' }}>
          {selected.size > 0 ? selected.size + ' selected · ' : ''}
          {visible.length} of {rows.length} shown
        </Sans>
        {selected.size > 0 && (
          <>
            <button onClick={() => runAction('accept', [...selected])} disabled={busy}
              style={{ padding: '8px 16px', borderRadius: 100, fontSize: 11, fontWeight: 600, border: '1px solid #1f6b3a', background: '#1f6b3a', color: '#fff', cursor: busy ? 'wait' : 'pointer', letterSpacing: '0.06em' }}>
              Accept proposed rates ({selected.size})
            </button>
            <button onClick={() => runAction('dismiss', [...selected])} disabled={busy}
              style={{ padding: '8px 16px', borderRadius: 100, fontSize: 11, fontWeight: 600, border: '1px solid ' + CC.border, background: CC.white, color: CC.deep, cursor: busy ? 'wait' : 'pointer', letterSpacing: '0.06em' }}>
              Dismiss drift
            </button>
            <button onClick={() => runAction('lock', [...selected])} disabled={busy}
              style={{ padding: '8px 16px', borderRadius: 100, fontSize: 11, fontWeight: 600, border: '1px solid #1f4d7a', background: CC.white, color: '#1f4d7a', cursor: busy ? 'wait' : 'pointer', letterSpacing: '0.06em' }}>
              Lock as attested
            </button>
          </>
        )}
        <button onClick={() => runAction('reprobe', visible.map(r => r.id))} disabled={busy || !visible.length}
          style={{ padding: '8px 16px', borderRadius: 100, fontSize: 11, fontWeight: 600, border: '1px solid ' + CC.border, background: CC.white, color: CC.deep, cursor: busy ? 'wait' : 'pointer', letterSpacing: '0.06em' }}>
          ↻ Probe now
        </button>
        <button onClick={fetchRows} disabled={busy}
          style={{ padding: '8px 12px', borderRadius: 100, fontSize: 11, fontWeight: 600, border: '1px solid ' + CC.border, background: CC.white, color: CC.deep, cursor: busy ? 'wait' : 'pointer' }}>
          ⟳ Refresh
        </button>
      </div>

      {/* Table */}
      <div style={{ overflowX: 'auto', border: '1px solid ' + CC.border, borderRadius: 8 }}>
        <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, background: CC.white }}>
          <thead>
            <tr style={{ background: CC.offWhite, textAlign: 'left' }}>
              <th style={{ padding: '10px 12px', width: 32 }}>
                <input
                  type="checkbox"
                  checked={visible.length > 0 && visible.every(r => selected.has(r.id))}
                  onChange={selectAllVisible}
                />
              </th>
              <th style={{ padding: '10px 12px', fontSize: 10, letterSpacing: '0.14em', textTransform: 'uppercase', color: CC.mid, fontWeight: 600 }}>Villa</th>
              <th style={{ padding: '10px 12px', fontSize: 10, letterSpacing: '0.14em', textTransform: 'uppercase', color: CC.mid, fontWeight: 600 }}>Source</th>
              <th style={{ padding: '10px 12px', fontSize: 10, letterSpacing: '0.14em', textTransform: 'uppercase', color: CC.mid, fontWeight: 600 }}>Host</th>
              <th style={{ padding: '10px 12px', fontSize: 10, letterSpacing: '0.14em', textTransform: 'uppercase', color: CC.mid, fontWeight: 600 }}>TLB rate</th>
              <th style={{ padding: '10px 12px', fontSize: 10, letterSpacing: '0.14em', textTransform: 'uppercase', color: CC.mid, fontWeight: 600 }}>Proposed (probe)</th>
              <th style={{ padding: '10px 12px', fontSize: 10, letterSpacing: '0.14em', textTransform: 'uppercase', color: CC.mid, fontWeight: 600 }}>Drift</th>
              <th style={{ padding: '10px 12px', fontSize: 10, letterSpacing: '0.14em', textTransform: 'uppercase', color: CC.mid, fontWeight: 600 }}>Last sync</th>
              <th style={{ padding: '10px 12px', fontSize: 10, letterSpacing: '0.14em', textTransform: 'uppercase', color: CC.mid, fontWeight: 600 }}>Status</th>
              <th style={{ padding: '10px 12px', fontSize: 10, letterSpacing: '0.14em', textTransform: 'uppercase', color: CC.mid, fontWeight: 600 }}>Actions</th>
            </tr>
          </thead>
          <tbody>
            {visible.length === 0 && (
              <tr><td colSpan={10} style={{ padding: 36, textAlign: 'center', color: CC.mid }}>
                <Sans size={13}>No villas match this filter.</Sans>
              </td></tr>
            )}
            {visible.map(r => {
              const style = STATUS_STYLES[r.drift_status] || STATUS_STYLES.ok;
              const tlbLow = r.tlb_rates && r.tlb_rates.low;
              const tlbHigh = r.tlb_rates && r.tlb_rates.high;
              const tlbPeak = r.tlb_rates && r.tlb_rates.peak;
              const proposed = r.flag && r.flag.proposed;
              const propLow  = proposed && proposed.low;
              const propHigh = proposed && proposed.high;
              const propPeak = proposed && proposed.peak;
              const age = ageDays(r.last_synced_at);
              return (
                <tr key={r.id} style={{ borderTop: '1px solid ' + CC.border }}>
                  <td style={{ padding: '10px 12px', verticalAlign: 'top' }}>
                    <input
                      type="checkbox"
                      checked={selected.has(r.id)}
                      onChange={() => toggleSelect(r.id)}
                    />
                  </td>
                  <td style={{ padding: '10px 12px', verticalAlign: 'top', maxWidth: 220 }}>
                    <div style={{ fontWeight: 500, color: CC.ink, marginBottom: 2 }}>{r.name}</div>
                    {r.url_slug && (
                      <a href={'/villa/' + r.url_slug} target="_blank" rel="noopener noreferrer"
                        style={{ fontSize: 10, color: CC.mid, textDecoration: 'none' }}>
                        /villa/{r.url_slug} ↗
                      </a>
                    )}
                  </td>
                  <td style={{ padding: '10px 12px', verticalAlign: 'top' }}>
                    {r.source_url ? (
                      <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
                        {sourceBadge(r.source)}
                        <a href={r.source_url} target="_blank" rel="noopener noreferrer"
                          style={{ fontSize: 10, color: CC.deep, textDecoration: 'underline', wordBreak: 'break-all', maxWidth: 180, display: 'inline-block' }}>
                          {r.source_url.length > 36 ? r.source_url.slice(0, 36) + '…' : r.source_url} ↗
                        </a>
                      </div>
                    ) : (
                      <span style={{ fontSize: 10, color: CC.mid, fontStyle: 'italic' }}>none</span>
                    )}
                  </td>
                  <td style={{ padding: '10px 12px', verticalAlign: 'top', fontSize: 11, color: CC.deep, maxWidth: 160, wordBreak: 'break-all' }}>
                    {r.host_email || '—'}
                  </td>
                  <td style={{ padding: '10px 12px', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
                    <div style={{ fontSize: 11, color: CC.ink, fontVariantNumeric: 'tabular-nums' }}>{fmtRate(tlbLow, r.tlb_currency)}</div>
                    <div style={{ fontSize: 10, color: CC.mid, fontVariantNumeric: 'tabular-nums' }}>H {fmtRate(tlbHigh, r.tlb_currency)}</div>
                    <div style={{ fontSize: 10, color: CC.mid, fontVariantNumeric: 'tabular-nums' }}>P {fmtRate(tlbPeak, r.tlb_currency)}</div>
                  </td>
                  <td style={{ padding: '10px 12px', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
                    {proposed ? (
                      <>
                        <div style={{ fontSize: 11, color: '#7a5a1f', fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>{fmtRate(propLow, r.tlb_currency)}</div>
                        <div style={{ fontSize: 10, color: '#7a5a1f', fontVariantNumeric: 'tabular-nums' }}>H {fmtRate(propHigh, r.tlb_currency)}</div>
                        <div style={{ fontSize: 10, color: '#7a5a1f', fontVariantNumeric: 'tabular-nums' }}>P {fmtRate(propPeak, r.tlb_currency)}</div>
                      </>
                    ) : (
                      <span style={{ fontSize: 11, color: CC.mid, fontStyle: 'italic' }}>—</span>
                    )}
                  </td>
                  <td style={{ padding: '10px 12px', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
                    {r.max_delta_pct != null ? (
                      <span style={{
                        display: 'inline-block', padding: '3px 9px', borderRadius: 100,
                        background: r.max_delta_pct >= 30 ? '#fbf3f3' : (r.max_delta_pct >= 15 ? '#fdf6e9' : '#f1f9f3'),
                        color:      r.max_delta_pct >= 30 ? '#9a1c1c' : (r.max_delta_pct >= 15 ? '#7a5a1f' : '#1f6b3a'),
                        fontSize: 11, fontWeight: 700, fontVariantNumeric: 'tabular-nums',
                      }}>{r.max_delta_pct}%</span>
                    ) : '—'}
                  </td>
                  <td style={{ padding: '10px 12px', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
                    <div style={{ fontSize: 11, color: CC.deep }}>{fmtDate(r.last_synced_at)}</div>
                    {age != null && (
                      <div style={{ fontSize: 10, color: age > 30 ? '#9a1c1c' : (age > 14 ? '#7a5a1f' : CC.mid) }}>
                        {age}d ago
                      </div>
                    )}
                  </td>
                  <td style={{ padding: '10px 12px', verticalAlign: 'top' }}>
                    <span style={{
                      display: 'inline-flex', alignItems: 'center', gap: 6,
                      padding: '3px 10px', borderRadius: 100,
                      background: style.bg, color: style.fg,
                      fontSize: 10, fontWeight: 600, letterSpacing: '0.06em',
                    }}>
                      <span style={{ display: 'inline-block', width: 6, height: 6, borderRadius: '50%', background: style.dot }} />
                      {style.label}
                    </span>
                  </td>
                  <td style={{ padding: '10px 12px', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
                    <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
                      {(r.drift_status === 'drift_warn' || r.drift_status === 'drift_critical') && proposed && (
                        <button onClick={() => runAction('accept', [r.id])} disabled={busy}
                          style={{ padding: '4px 10px', borderRadius: 100, fontSize: 10, fontWeight: 600, border: '1px solid #1f6b3a', background: '#1f6b3a', color: '#fff', cursor: busy ? 'wait' : 'pointer', letterSpacing: '0.06em' }}>
                          Accept
                        </button>
                      )}
                      {(r.drift_status === 'drift_warn' || r.drift_status === 'drift_critical') && (
                        <button onClick={() => runAction('dismiss', [r.id])} disabled={busy}
                          style={{ padding: '4px 10px', borderRadius: 100, fontSize: 10, fontWeight: 600, border: '1px solid ' + CC.border, background: CC.white, color: CC.deep, cursor: busy ? 'wait' : 'pointer' }}>
                          Dismiss
                        </button>
                      )}
                      {r.drift_status !== 'locked' ? (
                        <button onClick={() => runAction('lock', [r.id])} disabled={busy}
                          style={{ padding: '4px 10px', borderRadius: 100, fontSize: 10, fontWeight: 600, border: '1px solid #1f4d7a', background: CC.white, color: '#1f4d7a', cursor: busy ? 'wait' : 'pointer' }}>
                          Lock
                        </button>
                      ) : (
                        <button onClick={() => runAction('unlock', [r.id])} disabled={busy}
                          style={{ padding: '4px 10px', borderRadius: 100, fontSize: 10, fontWeight: 600, border: '1px solid ' + CC.border, background: CC.white, color: CC.mid, cursor: busy ? 'wait' : 'pointer' }}>
                          Unlock
                        </button>
                      )}
                      {/* v141.280 — Paste HTML for Cloudflare-blocked hosts (TLN, TTD, Booking) */}
                      {isPasteHost(r.source_url) && (
                        <button onClick={() => openPasteModal(r)} disabled={busy}
                          style={{ padding: '4px 10px', borderRadius: 100, fontSize: 10, fontWeight: 600,
                            border: '1px solid #9a7d3f', background: '#fbf8f0', color: '#9a7d3f',
                            cursor: busy ? 'wait' : 'pointer' }}
                          title="Host is Cloudflare-protected — paste page HTML to refresh rate manually">
                          Paste HTML
                        </button>
                      )}
                    </div>
                  </td>
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>

      {/* v141.280 — Paste-HTML modal */}
      {pasteFor && (
        <div onClick={(e) => { if (e.target === e.currentTarget) closePasteModal(); }}
          style={{ position: 'fixed', inset: 0, background: 'rgba(20,20,20,0.5)', zIndex: 9999,
            display: 'flex', alignItems: 'flex-start', justifyContent: 'center', padding: '40px 16px', overflow: 'auto' }}>
          <div style={{ background: CC.white, borderRadius: 8, width: '100%', maxWidth: 920, padding: 24,
            boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}>
            <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 14 }}>
              <div>
                <Sans size={13} weight={700} ls={0.12} upper color={CC.deep} style={{ marginBottom: 4 }}>
                  Paste page HTML
                </Sans>
                <Sans size={14} weight={500} color={CC.ink}>{pasteFor.name}</Sans>
                {pasteFor.source_url && (
                  <a href={pasteFor.source_url} target="_blank" rel="noopener noreferrer"
                    style={{ fontSize: 11, color: CC.mid, textDecoration: 'underline' }}>
                    Open partner page ↗ (open in new tab → Ctrl+A → Ctrl+C the rendered page → paste below)
                  </a>
                )}
              </div>
              <button onClick={closePasteModal} disabled={pasteBusy}
                style={{ padding: '4px 10px', fontSize: 11, border: '1px solid ' + CC.border,
                  background: CC.white, borderRadius: 100, cursor: pasteBusy ? 'wait' : 'pointer' }}>
                Close
              </button>
            </div>

            {/* v141.285 — Upfront parser status badge BEFORE paste. Saves
                operator from a wasted paste-and-click if the parser
                isn't ready yet for this villa's host. */}
            {(() => {
              const cp = detectClientPlatform(pasteFor.source_url);
              // v141.288 — 'redundant' is informational blue (not an error):
              // host has a more accurate auto-probe path, so paste-HTML is
              // discouraged but not blocked.
              const bg = cp.status === 'production' ? '#e9f6ec'
                       : cp.status === 'beta' ? '#fbf2e0'
                       : cp.status === 'redundant' ? '#e6f0f8'
                       : '#fbe9e9';
              const bd = cp.status === 'production' ? '#6c9d75'
                       : cp.status === 'beta' ? '#d4b87a'
                       : cp.status === 'redundant' ? '#7da3c4'
                       : '#d99';
              return (
                <div style={{ marginBottom: 12, padding: '10px 12px', borderRadius: 6,
                  background: bg, border: '1px solid ' + bd, fontSize: 12 }}>
                  <div style={{ fontWeight: 700, marginBottom: 4 }}>
                    {cp.host} · {cp.label}
                  </div>
                  {cp.status === 'beta' && (
                    <div style={{ color: CC.mid, marginTop: 2 }}>
                      Beta: parser extracts what's available on the partner
                      page (limited vs. production). Paste and Apply still
                      writes to TLB; gaps are flagged in the preview.
                    </div>
                  )}
                  {cp.status === 'redundant' && (
                    <div style={{ color: CC.mid, marginTop: 2 }}>
                      A dedicated cron already pulls REAL per-date prices
                      from this host's backend (more accurate than any HTML
                      paste). Use the Re-probe button on the row instead.
                    </div>
                  )}
                  {cp.status === 'not_implemented' && (
                    <div style={{ color: CC.mid, marginTop: 2 }}>
                      You can still paste below — server will reject with a
                      helpful message — but the production-ready alternative
                      is faster. Re-probe button on the row also kicks an
                      immediate auto-fetch for hosts the daily cron supports.
                    </div>
                  )}
                </div>
              );
            })()}

            {/* v141.285 — Server-side parser status (returned with each
                Preview response — overrides client estimate). */}
            {pastePreview && pastePreview.parser_status && (
              <div style={{ marginBottom: 12, padding: '10px 12px', borderRadius: 6,
                background: pastePreview.parser_status.status === 'production' ? '#e9f6ec'
                          : pastePreview.parser_status.status === 'beta' ? '#fbf2e0'
                          : pastePreview.parser_status.status === 'redundant' ? '#e6f0f8'
                          : '#fbe9e9',
                border: '1px solid ' + (
                  pastePreview.parser_status.status === 'production' ? '#6c9d75'
                  : pastePreview.parser_status.status === 'beta' ? '#d4b87a'
                  : pastePreview.parser_status.status === 'redundant' ? '#7da3c4'
                  : '#d99'
                ),
                fontSize: 12 }}>
                <div style={{ fontWeight: 700, marginBottom: 4 }}>
                  Parser status: {pastePreview.parser_status.label}
                </div>
                <div style={{ color: CC.mid }}>{pastePreview.parser_status.note}</div>
              </div>
            )}

            <textarea
              value={pasteHtml}
              onChange={(e) => { setPasteHtml(e.target.value); setPastePreview(null); setPasteError(null); }}
              disabled={pasteBusy}
              placeholder="Paste the FULL rendered villa page HTML here (DevTools → Elements → right-click <html> → Copy → Copy outerHTML)…"
              style={{ width: '100%', minHeight: 180, padding: 12, borderRadius: 6,
                border: '1px solid ' + CC.border, fontFamily: 'ui-monospace,monospace', fontSize: 11,
                lineHeight: 1.45, resize: 'vertical', background: '#fbfaf6' }}
            />
            <Sans size={11} color={CC.mid} style={{ marginTop: 4 }}>
              {pasteHtml.length.toLocaleString()} chars pasted · need full page (typically 200k–800k chars).
            </Sans>

            {pasteError && (
              <div style={{ marginTop: 12, padding: 10, background: '#fbe9e9', border: '1px solid #d99',
                borderRadius: 6, color: '#7a2222', fontSize: 12 }}>
                {pasteError}
              </div>
            )}

            <div style={{ marginTop: 14, display: 'flex', gap: 10 }}>
              <button onClick={() => runPasteParse(true)} disabled={pasteBusy || !pasteHtml.trim()}
                style={{ padding: '8px 18px', borderRadius: 100, fontSize: 12, fontWeight: 600,
                  border: '1px solid ' + CC.ink, background: CC.white, color: CC.ink,
                  cursor: (pasteBusy || !pasteHtml.trim()) ? 'not-allowed' : 'pointer' }}>
                {pasteBusy ? 'Parsing…' : 'Preview parsed data'}
              </button>
              {pastePreview && !pastePreview.failed && (
                <button onClick={() => runPasteParse(false)} disabled={pasteBusy}
                  style={{ padding: '8px 18px', borderRadius: 100, fontSize: 12, fontWeight: 600,
                    border: '1px solid #1f6b3a', background: '#1f6b3a', color: '#fff',
                    cursor: pasteBusy ? 'wait' : 'pointer' }}>
                  {pasteBusy ? 'Applying…' : 'Apply to TLB'}
                </button>
              )}
            </div>

            {/* Preview panel — side-by-side current vs proposed */}
            {pastePreview && (
              <div style={{ marginTop: 18, borderTop: '1px solid ' + CC.border, paddingTop: 18 }}>
                <Sans size={12} weight={700} ls={0.1} upper color={CC.deep} style={{ marginBottom: 10 }}>
                  Preview · {pastePreview.extracted?.summary?.period_count || 0} periods · {pastePreview.extracted?.summary?.promo_count || 0} promos · service {pastePreview.extracted?.service_pct || '?'}%
                </Sans>

                {pastePreview.extracted?.standard_rates_by_period?.length > 0 && (
                  <div style={{ marginBottom: 14 }}>
                    <Sans size={11} weight={600} color={CC.mid} style={{ marginBottom: 4 }}>Standard rates extracted</Sans>
                    <div style={{ display: 'grid', gridTemplateColumns: '1fr 80px 1fr 1fr', gap: 4, fontSize: 11 }}>
                      <div style={{ fontWeight: 600, color: CC.mid }}>Room type</div>
                      <div style={{ fontWeight: 600, color: CC.mid }}>Min nts</div>
                      <div style={{ fontWeight: 600, color: CC.mid }}>Period</div>
                      <div style={{ fontWeight: 600, color: CC.mid, textAlign: 'right' }}>Rate (IDR)</div>
                      {pastePreview.extracted.standard_rates_by_period.slice(0, 20).map((r, i) => (
                        <React.Fragment key={i}>
                          <div style={{ color: CC.ink }}>{r.room_type}</div>
                          <div style={{ color: CC.mid }}>{r.min_nights}</div>
                          <div style={{ color: CC.mid }}>{r.period}</div>
                          <div style={{ color: CC.ink, textAlign: 'right', fontFamily: 'ui-monospace,monospace' }}>{r.price_idr.toLocaleString()}</div>
                        </React.Fragment>
                      ))}
                    </div>
                  </div>
                )}

                {pastePreview.extracted?.promotions?.length > 0 && (
                  <div style={{ marginBottom: 14 }}>
                    <Sans size={11} weight={600} color={CC.mid} style={{ marginBottom: 4 }}>Promotions extracted</Sans>
                    {pastePreview.extracted.promotions.map((p, i) => (
                      <div key={i} style={{ padding: '6px 10px', borderLeft: '2px solid #9a7d3f',
                        background: '#fbf8f0', marginBottom: 4, fontSize: 11 }}>
                        <strong>{p.label}</strong> — {p.pct_off}% off
                        {p.valid_until && <span style={{ color: CC.mid }}> · until {p.valid_until}</span>}
                        {p.condition && <span style={{ color: CC.mid }}> · {p.condition}</span>}
                        {p.note && <span style={{ color: CC.mid, display: 'block', marginTop: 2 }}>{p.note}</span>}
                      </div>
                    ))}
                  </div>
                )}

                {pastePreview.proposed?.seasonal_rates && (
                  <div style={{ marginBottom: 8 }}>
                    <Sans size={11} weight={600} color={CC.mid} style={{ marginBottom: 4 }}>
                      Proposed seasonal_rates summary
                    </Sans>
                    <div style={{ display: 'flex', gap: 14, fontSize: 12 }}>
                      <div><strong style={{ color: '#1f6b3a' }}>${pastePreview.proposed.seasonal_rates.low.toFixed(0)}</strong> <span style={{ color: CC.mid }}>low</span></div>
                      <div><strong style={{ color: '#9a7d3f' }}>${pastePreview.proposed.seasonal_rates.high.toFixed(0)}</strong> <span style={{ color: CC.mid }}>high/peak</span></div>
                      <div style={{ color: CC.mid }}>config: {pastePreview.proposed.seasonal_rates.canonical_room_config || '?'}</div>
                    </div>
                  </div>
                )}

                {pastePreview.failed && pastePreview.hint && (
                  <div style={{ padding: 10, background: '#fbf2e0', border: '1px solid #d4b87a',
                    borderRadius: 6, color: '#7a5a22', fontSize: 12 }}>
                    {pastePreview.hint}
                  </div>
                )}

                {pastePreview.applied && (
                  <div style={{ padding: 10, background: '#e9f6ec', border: '1px solid #6c9d75',
                    borderRadius: 6, color: '#22552a', fontSize: 12, fontWeight: 600 }}>
                    ✓ Applied to TLB. Closing in a moment…
                  </div>
                )}
              </div>
            )}
          </div>
        </div>
      )}

      <Panel pad={14} style={{ marginTop: dashGap(), background: '#fbf8f0', borderColor: 'rgba(154,125,63,0.18)', borderLeft: '3px solid #9a7d3f' }}>
        <Sans size={11} weight={600} ls={0.16} upper color="#9a7d3f" style={{ marginBottom: 6 }}>How this works</Sans>
        <Sans size={12} color={CC.deep} style={{ lineHeight: 1.65 }}>
          Daily at 03:00 WIB the rate probe (<code>/api/cron-rate-probe</code>) re-fetches each villa's
          <code> source_url</code> and re-runs the parser. Drift &lt; 15% on every tier auto-applies; drift
          ≥ 15% queues here for manual review. <strong>Accept</strong> applies the probed rates verbatim;
          <strong> Dismiss</strong> keeps the TLB stored rate (treats the drift as intentional mark-up);
          <strong> Lock</strong> marks the rate host-attested and skips future probes for 90 days.
          Re-probe kicks the job immediately.
        </Sans>
      </Panel>
    </SectionWrap>
  );
}

function AdminProperties() {
  const { data, loading, error, refetch } = useApi('/api/admin?action=properties', { tokenType: 'admin' });
  const [busyId, setBusyId] = useState(null);
  const [filter, setFilter] = useState('all');
  const [search, setSearch] = useState('');
  // v141.161 — native Add-property (no /admin-classic). Same wizard the
  // host dashboard uses; refetch the admin list when it finishes.
  const [wizardOpen, setWizardOpen] = useState(false);
  const Wizard = (window.TLB_NATIVE_MUTATIONS && window.TLB_NATIVE_MUTATIONS.AddPropertyWizard) || (() => null);
  useEffect(() => {
    const onCreated = () => refetch && refetch();
    window.addEventListener('tlb-villa-created', onCreated);
    return () => window.removeEventListener('tlb-villa-created', onCreated);
  }, [refetch]);

  if (loading) return <SectionWrap title="Properties"><Loading msg="Loading villa portfolio…" /></SectionWrap>;
  if (error) return <SectionWrap title="Properties"><ErrorState msg={error} onRetry={refetch} /></SectionWrap>;

  const all = (data && data.data && data.data.properties) || (data && data.properties) || [];
  const counts = {
    all: all.length,
    live: all.filter(v => v.status === 'live' && v.is_active !== false).length,
    draft: all.filter(v => v.status === 'draft').length,
    hidden: all.filter(v => v.is_active === false).length,
  };
  let villas = all;
  if (filter === 'live') villas = villas.filter(v => v.status === 'live' && v.is_active !== false);
  else if (filter === 'draft') villas = villas.filter(v => v.status === 'draft');
  else if (filter === 'hidden') villas = villas.filter(v => v.is_active === false);
  if (search.trim()) {
    const q = search.toLowerCase().trim();
    villas = villas.filter(v =>
      (v.name || '').toLowerCase().includes(q) ||
      (v.location || '').toLowerCase().includes(q) ||
      (v.host_email || '').toLowerCase().includes(q)
    );
  }

  function toggleActive(villa) {
    const willHide = villa.is_active !== false;
    const verb = willHide ? 'hide' : 'restore';
    const msg = willHide
      ? `Hide "${villa.name}" from public search?\n\nThe listing stays in the database (bookings, reviews, photos preserved). Reversible at any time.`
      : `Restore "${villa.name}" to public search?`;
    if (!window.confirm(msg)) return;
    const reason = willHide ? (window.prompt('Reason for hiding (optional, e.g. seasonal closure, owner dispute):') || '') : '';
    setBusyId(villa.id);
    let token = ''; try { token = localStorage.getItem('tlb_admin_token') || localStorage.getItem('tlb_token') || ''; } catch {}
    fetch('/api/admin-manage', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'Authorization': token ? 'Bearer ' + token : '' },
      body: JSON.stringify({ action: 'set_property_active', property_id: villa.id, active: !willHide, reason }),
    })
      .then(r => r.json().then(j => ({ ok: r.ok, j })))
      .then(({ ok, j }) => {
        if (!ok) throw new Error(j.error || ('Failed to ' + verb));
        refetch();
      })
      .catch(err => alert('Error: ' + err.message))
      .finally(() => setBusyId(null));
  }

  // v141.129 — Add stale/no-source counts (mockup §3.1.9 tabstrip)
  const staleCount = all.filter(v => {
    if (!v.last_rate_synced_at) return false;
    const days = (Date.now() - new Date(v.last_rate_synced_at).getTime()) / 86400000;
    return days > 30;
  }).length;
  const noSrcCount = all.filter(v => !v.source_url).length;

  return (
    <SectionWrap
      title="Properties"
      subtitle="Cross-host villa portfolio. Filter by status, source, area, or sync health."
      dataNeeds={['villas']}
      actions={
        <div style={{ display: 'flex', gap: 8 }}>
          <input
            type="search"
            placeholder="Search by villa, location, host email…"
            value={search}
            onChange={e => setSearch(e.target.value)}
            style={{ padding: '10px 16px', minWidth: 240, border: `1px solid ${CC.border}`, borderRadius: 100, fontFamily: F_TLB.ui, fontSize: 12, color: CC.ink, outline: 'none', background: CC.white }}
          />
          <button onClick={() => setWizardOpen(true)}
            style={{ padding: '10px 18px', borderRadius: 100, cursor: 'pointer', background: CC.ink, color: CC.white, border: '1px solid ' + CC.ink, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600 }}>
            Add property
          </button>
        </div>
      }
    >
      <Wizard open={wizardOpen} onClose={() => setWizardOpen(false)} />
      <Sans size={10} weight={600} ls={0.14} upper color={CC.mid} style={{ marginBottom: 14 }}>
        {counts.all} listings · {counts.live} live · {counts.hidden} hidden · {counts.draft} draft · {staleCount} stale · {noSrcCount} no source URL
      </Sans>
      <div style={{ display: 'flex', gap: 8, marginBottom: dashGap(), flexWrap: 'wrap', alignItems: 'center' }}>
        <FilterChips value={filter} onChange={setFilter} options={[
          { v: 'all',    label: 'All · ' + counts.all },
          { v: 'live',   label: 'Live · ' + counts.live },
          { v: 'draft',  label: 'Drafts · ' + counts.draft },
          { v: 'hidden', label: 'Hidden · ' + counts.hidden },
        ]} />
      </div>
      {villas.length === 0 ? (
        <Empty title="No villas match" msg="Try a different filter or clear the search box." />
      ) : (
        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: dashGap() }}>
          {villas.slice(0, 60).map((v, i) => {
            const isHidden = v.is_active === false;
            const isBusy = busyId === v.id;
            return (
              <Panel key={v.id || i} pad={0} style={{ overflow: 'hidden', display: 'flex', flexDirection: 'column', opacity: isHidden ? 0.65 : 1, transition: 'opacity .2s' }}>
                <div style={{ position: 'relative' }}>
                  <div style={{ aspectRatio: '4/3', background: CC.offWhite, backgroundImage: `url('${v.image || (v.photos && v.photos[0]) || ''}')`, backgroundSize: 'cover', backgroundPosition: 'center', filter: isHidden ? 'grayscale(0.7)' : 'none' }} />
                  {isHidden && (
                    <div style={{ position: 'absolute', top: 12, left: 12, padding: '4px 10px', background: 'rgba(154,28,28,0.92)', color: '#fff', fontSize: 9, fontWeight: 700, letterSpacing: '0.12em', textTransform: 'uppercase', borderRadius: 100 }}>
                      Hidden from search
                    </div>
                  )}
                </div>
                <div style={{ padding: 16, display: 'flex', flexDirection: 'column', gap: 6, flex: 1 }}>
                  <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8 }}>
                    <Sans size={14} weight={500} style={{ color: CC.ink, lineHeight: 1.3 }}>{v.name || 'Villa'}</Sans>
                    <Pill tone={statusTone(v.status)}>{v.status || 'unknown'}</Pill>
                  </div>
                  <Sans size={11} color={CC.mid}>{v.location || 'Bali'} · {v.bedrooms || '?'}BR</Sans>
                  <Sans size={12} color={CC.deep} weight={500}>
                    {v.nightly_rate ? fmtIDR(v.nightly_rate) + ' / night' : 'On request'}
                  </Sans>
                  <Sans size={10} color={CC.mid}>Host: {v.host_email || '—'}</Sans>
                  <div style={{ display: 'flex', gap: 6, marginTop: 'auto', paddingTop: 12 }}>
                    <a href={'/villa/' + (v.url_slug || v.id)} target="_blank" style={{ ...ghostBtn, flex: 1, padding: '6px 10px', fontSize: 11, textAlign: 'center' }}>Preview</a>
                    <button
                      onClick={() => toggleActive(v)}
                      disabled={isBusy}
                      style={{
                        flex: 1, padding: '6px 10px', borderRadius: 100, fontSize: 11, fontWeight: 600,
                        cursor: isBusy ? 'wait' : 'pointer', letterSpacing: '0.04em',
                        border: '1px solid ' + (isHidden ? '#1f6b3a' : '#9a1c1c'),
                        background: isHidden ? 'rgba(31,107,58,0.08)' : 'rgba(154,28,28,0.06)',
                        color: isHidden ? '#1f6b3a' : '#9a1c1c',
                        opacity: isBusy ? 0.6 : 1,
                      }}
                    >
                      {isBusy ? '…' : (isHidden ? '↑ Restore' : '⊘ Hide')}
                    </button>
                  </div>
                </div>
              </Panel>
            );
          })}
        </div>
      )}
      {villas.length > 60 && (
        <Sans size={11} color={CC.mid} style={{ marginTop: 16, textAlign: 'center' }}>Showing first 60 of {villas.length} matching.</Sans>
      )}
    </SectionWrap>
  );
}

// v141.126 — AdminBookings, v141.123 mockup port.
//   • TabStrip filter chips (was dropdown) — All/Inquiry/Confirmed/Paid/...
//   • Subtitle with live counts + total revenue
//   • Search bar w/ icon on right side of tab strip
//   • Stuck toggle preserved (operator-critical for >4h unpaid)
//   • Header CTAs: Export (CSV download) + New booking (modal)
//   • Table column layout matches mockup: ID/Guest/Villa/Host/Dates/Total/Status/Channel
//   • All modals preserved (Cancel, Modify, Create with Xendit link)
function AdminBookings() {
  const { data, loading, error, refetch } = useApi('/api/admin?action=bookings', { tokenType: 'admin' });
  const [statusFilter, setStatusFilter]   = useState('all');
  const [search, setSearch]               = useState('');
  const [stuckOnly, setStuckOnly]         = useState(false);
  const [cancelTarget, setCancelTarget]   = useState(null);
  const [modifyTarget, setModifyTarget]   = useState(null);
  const [createOpen, setCreateOpen]       = useState(false);

  if (loading) return <SectionWrap title="Bookings"><Loading msg="Loading bookings…" /></SectionWrap>;
  if (error)   return <SectionWrap title="Bookings"><ErrorState msg={error} onRetry={refetch} /></SectionWrap>;

  const bookings = (data && data.data && data.data.bookings) || (data && data.bookings) || [];

  // Count bookings per status for tab badges (computed before filtering)
  const statusCounts = {
    all:       bookings.length,
    pending:   bookings.filter(b => b.status === 'pending').length,
    confirmed: bookings.filter(b => b.status === 'confirmed').length,
    paid:      bookings.filter(b => b.status === 'paid' || (b.payment_status || '').toLowerCase() === 'paid').length,
    completed: bookings.filter(b => b.status === 'completed').length,
    cancelled: bookings.filter(b => b.status === 'cancelled').length,
  };
  const stuckCount = bookings.filter(b => {
    if (b.status !== 'confirmed') return false;
    const age = b.created_at ? (Date.now() - new Date(b.created_at).getTime()) : 0;
    return age >= 4 * 3600 * 1000 && (b.payment_status || '').toLowerCase() !== 'paid';
  }).length;

  // Apply filters
  const filtered = bookings.filter(b => {
    if (statusFilter !== 'all') {
      if (statusFilter === 'paid') {
        const isPaid = b.status === 'paid' || (b.payment_status || '').toLowerCase() === 'paid';
        if (!isPaid) return false;
      } else if (b.status !== statusFilter) return false;
    }
    if (search) {
      const s = search.toLowerCase();
      const blob = ((b.guest_name || '') + ' ' + (b.guest_email || '') + ' ' + (b.host_email || '') + ' ' + (b.property_name || '')).toLowerCase();
      if (!blob.includes(s)) return false;
    }
    if (stuckOnly) {
      if (b.status !== 'confirmed') return false;
      const ageMs = b.created_at ? (Date.now() - new Date(b.created_at).getTime()) : 0;
      if (ageMs < 4 * 3600 * 1000) return false;
      if ((b.payment_status || '').toLowerCase() === 'paid') return false;
    }
    return true;
  });

  const totalRev = filtered.reduce((s, b) => s + (parseFloat(b.total_price) || 0), 0);

  function slaPill(b) {
    if (b.status !== 'confirmed') return null;
    if ((b.payment_status || '').toLowerCase() === 'paid') return null;
    const ageMs = b.created_at ? (Date.now() - new Date(b.created_at).getTime()) : 0;
    const hours = ageMs / 3600000;
    if (hours < 1) return null;
    let tone = 'neutral'; let label = Math.round(hours) + 'h';
    if (hours >= 24) { tone = 'danger'; label = Math.round(hours / 24) + 'd overdue'; }
    else if (hours >= 4) { tone = 'warn'; label = Math.round(hours) + 'h pending'; }
    return <Pill tone={tone}>{label}</Pill>;
  }

  // Export — builds CSV in browser (no extra endpoint). Includes only filtered rows.
  function exportCsv() {
    const headers = ['ID', 'Guest', 'Email', 'Villa', 'Host', 'Check-in', 'Check-out', 'Nights', 'Total IDR', 'Status', 'Payment', 'Channel', 'Created'];
    const rows = filtered.map(b => [
      (b.confirmation_code || b.id || '').slice(0, 16),
      b.guest_name || '',
      b.guest_email || '',
      b.property_name || '',
      b.host_email || '',
      b.check_in || '',
      b.check_out || '',
      b.nights || '',
      Math.round(parseFloat(b.total_price) || 0),
      b.status || '',
      b.payment_status || '',
      b.received_via || b.source || '',
      b.created_at || '',
    ]);
    const csv = [headers, ...rows]
      .map(row => row.map(c => /[",\n]/.test(String(c)) ? '"' + String(c).replace(/"/g, '""') + '"' : c).join(','))
      .join('\n');
    const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url; a.download = `bookings-${new Date().toISOString().slice(0, 10)}.csv`;
    document.body.appendChild(a); a.click(); document.body.removeChild(a);
    URL.revokeObjectURL(url);
  }

  const tabs = [
    { value: 'all',       label: 'All',       count: statusCounts.all },
    { value: 'pending',   label: 'Pending',   count: statusCounts.pending },
    { value: 'confirmed', label: 'Confirmed', count: statusCounts.confirmed },
    { value: 'paid',      label: 'Paid',      count: statusCounts.paid },
    { value: 'completed', label: 'Completed', count: statusCounts.completed },
    { value: 'cancelled', label: 'Cancelled', count: statusCounts.cancelled },
  ];

  return (
    <SectionWrap
      title="Bookings"
      subtitle="Every booking under TLB management. Click a row to open the detail pane — filter by status, search by guest, host, or villa."
      dataNeeds={['admin/bookings']}
      actions={
        <div style={{ display: 'flex', gap: 8 }}>
          <button onClick={exportCsv} disabled={!filtered.length}
            style={{ padding: '10px 18px', borderRadius: 100, cursor: filtered.length ? 'pointer' : 'not-allowed',
                     background: CC.white, color: CC.deep, border: '1px solid ' + CC.border,
                     fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600, opacity: filtered.length ? 1 : 0.5 }}>
            Export
          </button>
          <button onClick={() => setCreateOpen(true)}
            style={{ padding: '10px 18px', borderRadius: 100, cursor: 'pointer',
                     background: CC.ink, color: CC.white, border: '1px solid ' + CC.ink,
                     fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600 }}>
            New booking
          </button>
        </div>
      }
    >
      {/* Tabstrip + search + stuck toggle */}
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 18, gap: 14, flexWrap: 'wrap' }}>
        <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
          {tabs.map(t => {
            const on = statusFilter === t.value;
            return (
              <button key={t.value} onClick={() => setStatusFilter(t.value)} style={{
                padding: '8px 14px', borderRadius: 100, cursor: 'pointer',
                background: on ? CC.ink : 'transparent',
                color: on ? CC.white : CC.deep,
                border: '1px solid ' + (on ? CC.ink : CC.border),
                fontFamily: F_TLB.ui, fontSize: 11.5, fontWeight: 600, letterSpacing: '0.02em',
              }}>
                {t.label}
                <span style={{ marginLeft: 6, opacity: 0.7, fontSize: 10.5 }}>{t.count}</span>
              </button>
            );
          })}
          <label style={{
            display: 'inline-flex', alignItems: 'center', gap: 8, cursor: 'pointer',
            padding: '8px 14px', borderRadius: 100,
            background: stuckOnly ? '#fbf3f3' : 'transparent',
            color: stuckOnly ? '#9a1c1c' : CC.mid,
            border: '1px solid ' + (stuckOnly ? '#d6b3b3' : CC.border),
            fontFamily: F_TLB.ui, fontSize: 11.5, fontWeight: 600,
          }}>
            <input type="checkbox" checked={stuckOnly} onChange={e => setStuckOnly(e.target.checked)} style={{ accentColor: '#9a1c1c' }} />
            Stuck ({stuckCount})
          </label>
        </div>
        <div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
          <Sans size={11.5} color={CC.mid}>
            {filtered.length} of {bookings.length} · {fmtIDR(totalRev)}
          </Sans>
          <input
            type="text" value={search} onChange={e => setSearch(e.target.value)}
            placeholder="Find a booking…"
            style={{
              padding: '8px 14px', borderRadius: 100, minWidth: 240,
              border: '1px solid ' + CC.border, background: CC.white,
              fontFamily: F_TLB.ui, fontSize: 12, color: CC.ink, outline: 'none',
            }}
          />
        </div>
      </div>

      <Table
        columns={[
          { label: 'ID', render: r => <span style={{ fontFamily: 'ui-monospace, monospace', fontSize: 11, color: CC.mid, fontWeight: 600 }}>{(r.confirmation_code || r.id || '').slice(0, 8)}</span> },
          { label: 'Guest', render: r => (
            <div>
              <div style={{ fontWeight: 600, color: CC.ink }}>{r.guest_name || 'Guest'}</div>
              <div style={{ fontSize: 11, color: CC.mid, marginTop: 2 }}>{r.guest_email || '—'}</div>
            </div>
          )},
          { label: 'Property', render: r => <span style={{ color: CC.deep }}>{r.property_name || '—'}</span> },
          { label: 'Host', render: r => <span style={{ color: CC.mid, fontSize: 11 }}>{r.host_email || '—'}</span> },
          { label: 'Dates', nowrap: true, render: r => (
            <div>
              <div>{fmtDate(r.check_in)} → {fmtDate(r.check_out)}</div>
              <div style={{ fontSize: 11, color: CC.mid, marginTop: 2 }}>{r.nights || '—'} {r.nights === 1 ? 'night' : 'nights'}</div>
            </div>
          )},
          { label: 'Total', align: 'right', numeric: true, render: r => (
            <span style={{ fontFamily: F_TLB.display, fontStyle: 'italic', fontSize: 16 }}>
              {fmtIDR(parseFloat(r.total_price) || 0)}
            </span>
          )},
          { label: 'Status', render: r => <Pill tone={statusTone(r.status)}>{r.status}</Pill> },
          { label: 'Channel', render: r => <span style={{ color: CC.mid, fontSize: 11 }}>{r.received_via || r.source || 'website'}</span> },
          { label: 'SLA', render: r => slaPill(r) || <span style={{ color: CC.mid, fontSize: 11 }}>—</span> },
          { label: '', render: r => {
            const modifiable = r.status === 'confirmed' || r.status === 'paid';
            const cancellable = modifiable && (r.payment_status === 'paid' || r.payment_status === 'deposit_paid');
            return (
              <div style={{ display: 'flex', gap: 6 }}>
                {modifiable && <button onClick={() => setModifyTarget(r)}
                  style={{ padding: '6px 12px', borderRadius: 100, background: CC.white, color: CC.ink, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 10.5, fontWeight: 600, cursor: 'pointer' }}>Modify</button>}
                {cancellable && <button onClick={() => setCancelTarget(r)}
                  style={{ padding: '6px 12px', borderRadius: 100, background: CC.white, color: '#9a1c1c', border: '1px solid #d6b3b3', fontFamily: F_TLB.ui, fontSize: 10.5, fontWeight: 600, cursor: 'pointer' }}>Cancel</button>}
              </div>
            );
          }},
        ]}
        rows={filtered.slice(0, 50)}
        empty="No bookings match that filter."
      />
      {filtered.length > 50 && <Sans size={11} color={CC.mid} style={{ marginTop: 12, textAlign: 'center' }}>Showing 50 of {filtered.length}. Refine the filter or search to see more.</Sans>}
      {cancelTarget && <CancellationModal booking={cancelTarget} role="admin" onClose={() => setCancelTarget(null)} onSuccess={refetch} />}
      {modifyTarget && <ModifyBookingModal booking={modifyTarget} onClose={() => setModifyTarget(null)} onSuccess={refetch} />}
      {createOpen && <CreateBookingModal onClose={() => setCreateOpen(false)} onSuccess={(j) => {
        refetch();
        if (j && j.invoice && j.invoice.url) {
          if (window.confirm('Booking created. Open Xendit payment link in new tab?')) window.open(j.invoice.url, '_blank');
        }
      }} />}
    </SectionWrap>
  );
}

// v125 — Finance export controls (CSV + PDF download per period)
function FinanceExportControls({ onRefresh }) {
  const today = new Date();
  const monthStart = new Date(today.getFullYear(), today.getMonth(), 1).toISOString().slice(0, 10);
  const todayISO = today.toISOString().slice(0, 10);
  const [from, setFrom] = useState(monthStart);
  const [to, setTo] = useState(todayISO);
  const [hostFilter, setHostFilter] = useState('');
  const [open, setOpen] = useState(false);

  function buildUrl(format) {
    const p = new URLSearchParams();
    p.set('format', format);
    p.set('from', from);
    p.set('to', to);
    if (hostFilter) p.set('host', hostFilter);
    return '/api/admin-finance-export?' + p.toString();
  }

  async function downloadCsv() {
    const token = localStorage.getItem('tlb_admin_token') || '';
    const r = await fetch(buildUrl('csv'), { headers: { Authorization: 'Bearer ' + token } });
    if (!r.ok) { window.alert('Export failed: ' + r.statusText); return; }
    const blob = await r.blob();
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = `tlb-finance-${from}_to_${to}.csv`;
    a.click();
    URL.revokeObjectURL(url);
  }
  function openPdfPreview() {
    const token = localStorage.getItem('tlb_admin_token') || '';
    // Open in new tab; the endpoint serves printable HTML.
    // Token must be in header — for cross-tab, easier to use one-time signed URL.
    // Workaround: use fetch to load HTML then write to new tab.
    fetch(buildUrl('pdf'), { headers: { Authorization: 'Bearer ' + token } })
      .then(r => r.ok ? r.text() : null)
      .then(html => {
        if (!html) { window.alert('Failed to load PDF preview'); return; }
        const w = window.open('', '_blank');
        w.document.write(html);
        w.document.close();
      })
      .catch(e => window.alert('Error: ' + e.message));
  }

  return (
    <div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
      {open && (
        <div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
          <input type="date" value={from} onChange={e => setFrom(e.target.value)}
            style={{ padding: '6px 8px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 12, fontFamily: 'inherit' }} />
          <span style={{ color: CC.mid, fontSize: 12 }}>→</span>
          <input type="date" value={to} onChange={e => setTo(e.target.value)}
            style={{ padding: '6px 8px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 12, fontFamily: 'inherit' }} />
          <input type="email" value={hostFilter} onChange={e => setHostFilter(e.target.value)} placeholder="(optional) host email"
            style={{ padding: '6px 10px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 12, fontFamily: 'inherit', minWidth: 180 }} />
          <button onClick={downloadCsv}
            style={{ padding: '7px 14px', background: CC.ink, color: CC.white, border: 'none', borderRadius: 100, fontSize: 11, fontWeight: 600, cursor: 'pointer' }}>CSV</button>
          <button onClick={openPdfPreview}
            style={{ padding: '7px 14px', background: CC.white, color: CC.ink, border: '1px solid ' + CC.border, borderRadius: 100, fontSize: 11, fontWeight: 600, cursor: 'pointer' }}>PDF</button>
        </div>
      )}
      <button onClick={() => setOpen(!open)} style={ghostBtn}>{open ? 'Close' : 'Export ▾'}</button>
      <button onClick={onRefresh} style={ghostBtn}>Refresh</button>
    </div>
  );
}

// v141.126 — AdminFinance, v141.123 mockup port.
//   • 4-tile unified border KPI grid (MTD focus) + 4-tile YTD row
//   • Daily gross 90-day AreaChart with MoM delta pill
//   • Pill-chip tabs (Ledger / By Host / By Month / Pending)
//   • Owner-only gate: admins see gross + PHR; commission/net/payout
//     show '—' for non-owner (reads window.TLB_LIVE_STATUS.admin.isOwner)
//   • Header CTAs: Statement (CSV) + Export controls
//   • Inline filters inside ledger panel preserved
function AdminFinance() {
  const { data, loading, error, refetch } = useApi('/api/admin?action=finance', { tokenType: 'admin' });
  const bookApi = useApi('/api/admin?action=bookings', { tokenType: 'admin' });
  const [tab, setTab] = useState('ledger');
  const [statusFilter, setStatusFilter] = useState('all');
  const [hostFilter, setHostFilter] = useState('');
  const [openRowId, setOpenRowId] = useState(null);

  // v141.138 — HOOK ORDER FIX. All hooks (useMemo for dailySeries below)
  // must run BEFORE any early return, otherwise React's hook-count
  // changes between loading/loaded renders → React error #310.
  // Previously the `if (loading) return` was here, then useMemo below.
  // Computing bookings BEFORE the early return is safe — bookApi.data
  // may be null on first render, hence the (... || []) guard.
  const bookings = (bookApi.data && bookApi.data.data && bookApi.data.data.bookings) ||
                   (bookApi.data && bookApi.data.bookings) || [];
  const dailySeries = React.useMemo(() => {
    const today = new Date();
    const out = [];
    for (let i = 89; i >= 0; i--) {
      const d = new Date(today); d.setDate(d.getDate() - i); d.setHours(0, 0, 0, 0);
      out.push({ label: i % 10 === 0 ? d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }) : '', v: 0, day: d.getTime() });
    }
    bookings.forEach(b => {
      const t = b.check_in || b.created_at;
      if (!t) return;
      const dayTime = new Date(new Date(t).toISOString().slice(0, 10)).getTime();
      const bucket = out.find(x => x.day === dayTime);
      if (bucket) bucket.v += parseFloat(b.total_price) || 0;
    });
    return out;
  }, [bookings.length]);

  // ────────────────────────────────────────────────────────────────
  // All hooks are now called above. Early-return + data extraction
  // moved here so React hook order stays stable across renders.
  // ────────────────────────────────────────────────────────────────
  if (loading) return <SectionWrap title="Finance"><Loading msg="Loading finance breakdown…" /></SectionWrap>;
  if (error)   return <SectionWrap title="Finance"><ErrorState msg={error} onRetry={refetch} /></SectionWrap>;

  const fin = (data && data.data) || data || {};
  const summary = fin.summary || {};
  const ledger = Array.isArray(fin.ledger) ? fin.ledger : [];
  const byMonth = fin.revenueByMonth || [];
  const byHost = fin.revenueByHost || [];
  const pendingPayouts = fin.pendingPayouts || [];
  const ledgerTotal = fin.ledger_total_count || ledger.length;

  // Role detection — owners see commission/net/payout, admins see '—'.
  const status = (window.TLB_LIVE_STATUS && window.TLB_LIVE_STATUS.admin) || {};
  const isOwner = !!status.isOwner;

  // Apply filters to ledger
  const filteredLedger = ledger.filter(r => {
    if (statusFilter !== 'all') {
      if (statusFilter === 'paid' && !(r.status === 'paid' || r.status === 'completed')) return false;
      if (statusFilter === 'pending' && r.status !== 'confirmed') return false;
      if (statusFilter === 'cancelled' && !(r.status === 'cancelled' || r.status === 'refunded')) return false;
    }
    if (hostFilter && !(r.host_email || '').toLowerCase().includes(hostFilter.toLowerCase())) return false;
    return true;
  });

  const moneyCol = (val, cur) => (
    <span style={{ fontFamily: 'inherit', fontVariantNumeric: 'tabular-nums' }}>
      {cur === 'USD' ? fmtUSD(val) : fmtIDR(val)}
    </span>
  );

  function exportLedgerCsv() {
    const headers = ['Date', 'Booking ID', 'Guest', 'Property', 'Host', 'Check-in', 'Check-out', 'Nights', 'Gross', 'PHR'];
    if (isOwner) headers.push('Commission %', 'Commission', 'TLB Net', 'Host Payout');
    headers.push('Status', 'Currency');
    const rows = filteredLedger.map(r => {
      const base = [
        r.created_at || '',
        r.id || '',
        r.guest_name || '',
        r.property_name || '',
        r.host_email || '',
        r.check_in || '',
        r.check_out || '',
        r.nights || '',
        Math.round(parseFloat(r.gross) || 0),
        Math.round(parseFloat(r.phr) || 0),
      ];
      if (isOwner) base.push(
        ((parseFloat(r.commission_rate) || 0) * 100).toFixed(0) + '%',
        Math.round(parseFloat(r.commission) || 0),
        Math.round(parseFloat(r.tlb_net) || 0),
        Math.round(parseFloat(r.host_payout) || 0),
      );
      base.push(r.status || '', r.currency || 'IDR');
      return base;
    });
    const csv = [headers, ...rows].map(row => row.map(c => /[",\n]/.test(String(c)) ? '"' + String(c).replace(/"/g, '""') + '"' : c).join(',')).join('\n');
    const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url; a.download = `tlb-finance-${new Date().toISOString().slice(0, 10)}.csv`;
    document.body.appendChild(a); a.click(); document.body.removeChild(a);
    URL.revokeObjectURL(url);
  }

  // 4-tile MTD focus + 4-tile YTD focus rows. Owner-only tiles redact.
  const mtdTiles = [
    { label: 'Gross MTD',         value: fmtIDR(summary.gross_mtd),        sub: (summary.bookings_mtd || 0) + ' bookings' },
    { label: 'TLB Net MTD',       value: isOwner ? fmtIDR(summary.tlb_net_mtd) : '—',       sub: isOwner ? 'commission only' : 'Owner-only', ownerOnly: true },
    { label: 'PHR collected MTD', value: fmtIDR(summary.phr_collected_mtd), sub: 'pass-through to govt' },
    { label: 'Host payouts MTD',  value: isOwner ? fmtIDR(summary.host_payouts_mtd) : '—',  sub: isOwner ? fmtIDR(summary.pending_payout_total) + ' pending' : 'Owner-only', ownerOnly: true },
  ];
  const ytdTiles = [
    { label: 'Gross YTD',         value: fmtIDR(summary.gross_ytd),        sub: (summary.bookings_ytd || 0) + ' bookings' },
    { label: 'TLB Net YTD',       value: isOwner ? fmtIDR(summary.tlb_net_ytd) : '—',       sub: isOwner ? 'commission only' : 'Owner-only', ownerOnly: true },
    { label: 'Total paid out',    value: isOwner ? fmtIDR(summary.total_paid_out) : '—',    sub: isOwner ? 'cumulative wires' : 'Owner-only', ownerOnly: true },
    { label: 'MoM change',        value: ((summary.mom_change_pct || 0) >= 0 ? '+' : '') + (summary.mom_change_pct || 0) + '%', sub: 'vs last month gross', deltaPositive: (summary.mom_change_pct || 0) >= 0 },
  ];

  return (
    <SectionWrap
      title="Finance"
      subtitle={isOwner
        ? 'Gross, commission, net — per booking, with full breakdown. Owner-only fields are visible.'
        : 'Gross only. Commission and net are hidden at your role.'}
      dataNeeds={['admin/finance']}
      actions={
        <div style={{ display: 'flex', gap: 8 }}>
          <button onClick={() => window.open('/api/admin-finance-export?format=pdf&from=' + new Date(new Date().getFullYear(), 0, 1).toISOString().slice(0, 10) + '&to=' + new Date().toISOString().slice(0, 10), '_blank')}
            style={{ padding: '10px 18px', borderRadius: 100, cursor: 'pointer', background: CC.white, color: CC.deep, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600 }}>
            Statement
          </button>
          <button onClick={exportLedgerCsv} disabled={!filteredLedger.length}
            style={{ padding: '10px 18px', borderRadius: 100, cursor: filteredLedger.length ? 'pointer' : 'not-allowed', background: CC.ink, color: CC.white, border: '1px solid ' + CC.ink, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600, opacity: filteredLedger.length ? 1 : 0.5 }}>
            Export CSV
          </button>
        </div>
      }
    >
      {/* MTD KPI grid */}
      <div style={{
        display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 0,
        border: `1px solid ${CC.border}`, borderRadius: 18, overflow: 'hidden', background: CC.white, marginBottom: 8,
      }}>
        {mtdTiles.map((t, i) => (
          <div key={t.label} style={{
            padding: '24px 26px',
            borderRight: i < 3 ? `1px solid ${CC.border}` : 'none',
            display: 'flex', flexDirection: 'column', gap: 10, minHeight: 130,
          }}>
            <Sans size={10} weight={600} ls={0.14} upper color={CC.mid}>{t.label}</Sans>
            <div style={{
              fontFamily: F_TLB.ui, fontFeatureSettings: '"tnum"', fontSize: 26, fontWeight: 600,
              letterSpacing: '-0.025em', lineHeight: 0.98, color: CC.ink,
              fontVariantNumeric: 'tabular-nums',
            }}>{t.value}</div>
            <Sans size={11} color={t.ownerOnly && !isOwner ? '#c08a3e' : CC.mid} style={{ marginTop: 'auto' }}>{t.sub}</Sans>
          </div>
        ))}
      </div>
      {/* YTD KPI grid */}
      <div style={{
        display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 0,
        border: `1px solid ${CC.border}`, borderRadius: 18, overflow: 'hidden', background: CC.white, marginBottom: dashGap(),
      }}>
        {ytdTiles.map((t, i) => (
          <div key={t.label} style={{
            padding: '24px 26px',
            borderRight: i < 3 ? `1px solid ${CC.border}` : 'none',
            display: 'flex', flexDirection: 'column', gap: 10, minHeight: 130,
          }}>
            <Sans size={10} weight={600} ls={0.14} upper color={CC.mid}>{t.label}</Sans>
            <div style={{
              fontFamily: F_TLB.ui, fontFeatureSettings: '"tnum"', fontSize: 26, fontWeight: 600,
              letterSpacing: '-0.025em', lineHeight: 0.98,
              color: t.deltaPositive === false ? '#9a1c1c' : t.deltaPositive === true ? '#2d6a4f' : CC.ink,
              fontVariantNumeric: 'tabular-nums',
            }}>{t.value}</div>
            <Sans size={11} color={t.ownerOnly && !isOwner ? '#c08a3e' : CC.mid} style={{ marginTop: 'auto' }}>{t.sub}</Sans>
          </div>
        ))}
      </div>

      {/* Daily gross 90-day chart */}
      <Panel pad={30} style={{ marginBottom: dashGap() }}>
        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', marginBottom: 18 }}>
          <div>
            <Sans size={10} weight={600} ls={0.14} upper color={CC.mid}>Daily gross · trailing 90 days</Sans>
            <Display size={24} weight={500} ls={-0.015} style={{ marginTop: 10 }}>
              {(summary.mom_change_pct || 0) >= 0 ? 'A steady tide' : 'A softer month'}
            </Display>
          </div>
          <Pill tone={(summary.mom_change_pct || 0) >= 0 ? 'success' : 'warn'}>
            {(summary.mom_change_pct || 0) >= 0 ? '+' : ''}{summary.mom_change_pct || 0}% MoM
          </Pill>
        </div>
        <AreaChart
          height={220}
          keys={['v']}
          data={dailySeries}
          formatY={(v) => v >= 1_000_000 ? 'Rp ' + (v / 1_000_000).toFixed(1) + 'M' : v >= 1000 ? 'Rp ' + Math.round(v / 1000) + 'k' : 'Rp ' + v}
        />
      </Panel>

      {/* Tabs */}
      <div style={{ display: 'flex', gap: 6, marginBottom: dashGap(), flexWrap: 'wrap' }}>
        {[
          { v: 'ledger',   label: 'Booking ledger', count: ledgerTotal },
          { v: 'by_host',  label: 'By host',        count: byHost.length },
          { v: 'by_month', label: 'By month',       count: byMonth.length },
          { v: 'pending',  label: 'Pending payouts', count: pendingPayouts.length },
        ].map(o => (
          <button key={o.v} onClick={() => setTab(o.v)}
            style={{
              padding: '8px 16px', borderRadius: 100, cursor: 'pointer',
              fontFamily: F_TLB.ui, fontSize: 11.5, fontWeight: 600, letterSpacing: '0.02em',
              border: '1px solid ' + (tab === o.v ? CC.ink : CC.border),
              background: tab === o.v ? CC.ink : 'transparent',
              color: tab === o.v ? CC.white : CC.deep,
            }}>
            {o.label}<span style={{ opacity: 0.7, marginLeft: 6, fontSize: 10.5 }}>{o.count}</span>
          </button>
        ))}
      </div>

      {tab === 'ledger' && (
        <Panel style={{ padding: 0 }}>
          {/* Filters */}
          <div style={{ padding: '14px 18px', borderBottom: '1px solid ' + CC.border, display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
            <Sans size={11} weight={600} ls={0.14} upper color={CC.mid}>Filter:</Sans>
            <select value={statusFilter} onChange={e => setStatusFilter(e.target.value)}
              style={{ padding: '8px 12px', borderRadius: 100, border: '1px solid ' + CC.border, fontSize: 12, fontFamily: 'inherit', background: CC.white, color: CC.ink }}>
              <option value="all">All status</option>
              <option value="paid">Paid / Completed</option>
              <option value="pending">Confirmed (unpaid)</option>
              <option value="cancelled">Cancelled / Refunded</option>
            </select>
            <input type="text" value={hostFilter} onChange={e => setHostFilter(e.target.value)} placeholder="Filter by host email…"
              style={{ flex: 1, minWidth: 220, padding: '8px 14px', borderRadius: 100, border: '1px solid ' + CC.border, fontSize: 12, fontFamily: 'inherit' }} />
            <Sans size={11} color={CC.mid}>{filteredLedger.length} of {ledgerTotal} · {fmtIDR(filteredLedger.reduce((s, r) => s + (parseFloat(r.gross) || 0), 0))} gross</Sans>
          </div>
          <div style={{ overflowX: 'auto' }}>
            <table style={{ width: '100%', borderCollapse: 'collapse', fontFamily: 'inherit', fontSize: 12 }}>
              <thead>
                <tr style={{ background: CC.offWhite, borderBottom: '1px solid ' + CC.border }}>
                  <th style={fhStyle}>Date</th>
                  <th style={fhStyle}>Guest · Villa</th>
                  <th style={fhStyle}>Host</th>
                  <th style={fhStyle}>Stay</th>
                  <th style={{ ...fhStyle, textAlign: 'right' }}>Gross</th>
                  <th style={{ ...fhStyle, textAlign: 'right' }}>PHR</th>
                  <th style={{ ...fhStyle, textAlign: 'right' }}>Comm.</th>
                  <th style={{ ...fhStyle, textAlign: 'right' }}>TLB Net</th>
                  <th style={{ ...fhStyle, textAlign: 'right' }}>Host Net</th>
                  <th style={fhStyle}>Status</th>
                  <th style={fhStyle}></th>
                </tr>
              </thead>
              <tbody>
                {filteredLedger.length === 0 && (
                  <tr><td colSpan={11} style={{ padding: 32, textAlign: 'center', color: CC.mid }}>No bookings match these filters.</td></tr>
                )}
                {filteredLedger.map((r) => (
                  <React.Fragment key={r.id}>
                    <tr style={{ borderBottom: '1px solid ' + CC.border }}>
                      <td style={fcStyle}>{fmtDate(r.created_at)}</td>
                      <td style={fcStyle}>
                        <div style={{ fontWeight: 600 }}>{r.guest_name}</div>
                        <div style={{ color: CC.mid, fontSize: 11, marginTop: 2 }}>{r.property_name}</div>
                      </td>
                      <td style={{ ...fcStyle, fontSize: 11, color: CC.mid }}>{r.host_email}</td>
                      <td style={fcStyle}>
                        <div style={{ fontSize: 11 }}>{fmtDate(r.check_in)} → {fmtDate(r.check_out)}</div>
                        <div style={{ color: CC.mid, fontSize: 11 }}>{r.nights || '—'}n</div>
                      </td>
                      <td style={{ ...fcStyle, textAlign: 'right' }}>{moneyCol(r.gross, r.currency)}</td>
                      <td style={{ ...fcStyle, textAlign: 'right', color: CC.mid }}>{moneyCol(r.phr, r.currency)}</td>
                      <td style={{ ...fcStyle, textAlign: 'right' }}>
                        {isOwner ? <>
                          <div>{moneyCol(r.commission, r.currency)}</div>
                          <div style={{ color: CC.mid, fontSize: 10 }}>{(r.commission_rate * 100).toFixed(0)}%</div>
                        </> : <span style={{ color: CC.mid }}>—</span>}
                      </td>
                      <td style={{ ...fcStyle, textAlign: 'right', fontWeight: 600 }}>{isOwner ? moneyCol(r.tlb_net, r.currency) : <span style={{ color: CC.mid }}>—</span>}</td>
                      <td style={{ ...fcStyle, textAlign: 'right' }}>{isOwner ? moneyCol(r.host_payout, r.currency) : <span style={{ color: CC.mid }}>—</span>}</td>
                      <td style={fcStyle}>
                        <Pill tone={statusTone(r.status)}>{r.status}</Pill>
                        {r.payment_status && r.payment_status !== r.status && (
                          <div style={{ marginTop: 4 }}><Pill tone={statusTone(r.payment_status)}>{r.payment_status}</Pill></div>
                        )}
                      </td>
                      <td style={fcStyle}>
                        <button onClick={() => setOpenRowId(openRowId === r.id ? null : r.id)}
                          style={{ background: 'none', border: 'none', color: CC.ink, cursor: 'pointer', fontSize: 11, fontWeight: 600, letterSpacing: '0.04em' }}>
                          {openRowId === r.id ? 'Hide' : 'View'} ▾
                        </button>
                      </td>
                    </tr>
                    {openRowId === r.id && (
                      <tr style={{ background: CC.offWhite, borderBottom: '1px solid ' + CC.border }}>
                        <td colSpan={11} style={{ padding: '14px 18px' }}>
                          <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 14 }}>
                            <div>
                              <Sans size={10} weight={700} ls={0.14} upper color={CC.mid}>Booking ID</Sans>
                              <div style={{ marginTop: 4, fontSize: 11, fontFamily: 'monospace' }}>{r.id}</div>
                            </div>
                            <div>
                              <Sans size={10} weight={700} ls={0.14} upper color={CC.mid}>Payment Ref</Sans>
                              <div style={{ marginTop: 4, fontSize: 11, fontFamily: 'monospace' }}>{r.payment_ref || '—'}</div>
                            </div>
                            <div>
                              <Sans size={10} weight={700} ls={0.14} upper color={CC.mid}>Property Source</Sans>
                              <div style={{ marginTop: 4, fontSize: 12 }}>{r.property_source || 'direct'}</div>
                            </div>
                            <div>
                              <Sans size={10} weight={700} ls={0.14} upper color={CC.mid}>Guest Email</Sans>
                              <div style={{ marginTop: 4, fontSize: 11 }}>{r.guest_email || '—'}</div>
                            </div>
                          </div>
                          <div style={{ display: 'grid', gridTemplateColumns: 'repeat(6, 1fr)', gap: 14, marginTop: 16, paddingTop: 16, borderTop: '1px solid ' + CC.border }}>
                            <KPI label="Room Base" value={moneyCol(r.room_base, r.currency)} />
                            <KPI label="PHR (10%)" value={moneyCol(r.phr, r.currency)} />
                            <KPI label="Cleaning" value={moneyCol(r.cleaning, r.currency)} />
                            <KPI label={isOwner ? 'Commission ' + (r.commission_rate * 100).toFixed(0) + '%' : 'Commission'} value={isOwner ? moneyCol(r.commission, r.currency) : '—'} />
                            <KPI label="TLB Net" value={isOwner ? moneyCol(r.tlb_net, r.currency) : '—'} />
                            <KPI label="Host Payout" value={isOwner ? moneyCol(r.host_payout, r.currency) : '—'} />
                          </div>
                          {isOwner && (
                            <Sans size={11} color={CC.mid} style={{ marginTop: 12 }}>
                              Host receives Rp {Math.round(r.host_payout).toLocaleString('id-ID')} (includes Rp {Math.round(r.host_phr_to_remit || 0).toLocaleString('id-ID')} PHR for them to remit to Bapenda).
                            </Sans>
                          )}
                        </td>
                      </tr>
                    )}
                  </React.Fragment>
                ))}
              </tbody>
            </table>
          </div>
        </Panel>
      )}

      {tab === 'by_host' && (
        <Table
          columns={[
            { label: 'Host', key: 'host_name', render: r => (
              <>
                <div style={{ fontWeight: 600 }}>{r.host_name}</div>
                <div style={{ color: CC.mid, fontSize: 11 }}>{r.host_email}</div>
              </>
            )},
            { label: 'Bookings', key: 'booking_count', align: 'right', numeric: true },
            { label: 'Gross', key: 'total_revenue', align: 'right', numeric: true, render: r => fmtIDR(r.total_revenue) },
            { label: 'PHR Collected', key: 'phr_collected', align: 'right', numeric: true, render: r => fmtIDR(r.phr_collected) },
            { label: 'TLB Commission', key: 'total_commission', align: 'right', numeric: true, render: r => fmtIDR(r.total_commission) },
            { label: 'Host Net', key: 'net_to_host', align: 'right', numeric: true, render: r => fmtIDR(r.net_to_host) },
          ]}
          rows={byHost}
          empty="No host revenue data."
        />
      )}

      {tab === 'by_month' && (
        <Table
          columns={[
            { label: 'Month', key: 'month' },
            { label: 'Bookings', key: 'bookings', align: 'right', numeric: true },
            { label: 'Gross Revenue', key: 'revenue', align: 'right', numeric: true, render: r => fmtIDR(r.revenue) },
            { label: 'PHR Collected', key: 'phr', align: 'right', numeric: true, render: r => fmtIDR(r.phr) },
            { label: 'TLB Commission', key: 'commission', align: 'right', numeric: true, render: r => fmtIDR(r.commission) },
          ]}
          rows={byMonth.slice().reverse()}
          empty="No monthly revenue data."
        />
      )}

      {tab === 'pending' && (
        <Table
          columns={[
            { label: 'Host', key: 'host_name', render: r => (
              <>
                <div style={{ fontWeight: 600 }}>{r.host_name}</div>
                <div style={{ color: CC.mid, fontSize: 11 }}>{r.host_email}</div>
              </>
            )},
            { label: 'Pending Bookings', key: 'bookings', align: 'right', numeric: true },
            { label: 'Gross Held', key: 'gross_revenue', align: 'right', numeric: true, render: r => fmtIDR(r.gross_revenue) },
            { label: 'TLB Commission', key: 'platform_commission', align: 'right', numeric: true, render: r => fmtIDR(r.platform_commission) },
            { label: 'Net Payout Due', key: 'net_payout', align: 'right', numeric: true, render: r => <strong>{fmtIDR(r.net_payout)}</strong> },
          ]}
          rows={pendingPayouts}
          empty="All payouts are up to date."
        />
      )}
    </SectionWrap>
  );
}

const fhStyle = { padding: '10px 12px', textAlign: 'left', fontSize: 10, fontWeight: 700, letterSpacing: '0.16em', textTransform: 'uppercase', color: CC.mid };
const fcStyle = { padding: '12px', verticalAlign: 'top' };

// ── AdminPageCMS (v141.159) ─────────────────────────────────────────────
// Native (new-design) editor for the four hard-coded static pages
// (/yacht, /heli, /destinations, /discover). Reads + writes /api/cms,
// the SAME store the homepage mega-features already use. The static
// pages pick the values up via /js/tlb-cms-bind.js (progressive
// enhancement — blank fields keep the designed defaults). No
// admin-classic, no redeploy: this is the "kill the classic
// look-through + extend CMS" path, staged to these 4 pages first.
const SM_PAGE_DEFAULTS = {
  yacht: {
    label: 'Yacht Charter', url: '/yacht',
    d: {
      metaTitle: 'Yacht Charter Indonesia from IDR 18M — Bali, Komodo, Raja Ampat | The Luxury Bali',
      metaDescription: 'Indonesia yacht charters in partnership with YH Charter. Bali day boats, luxury liveaboards in Komodo & Raja Ampat, Bespoke + Explorer collections. 40+ vessels.',
      heroEyebrow: 'Yacht · A day boat to a week at sea',
      heroTitle: 'Indonesia, by water.',
      heroSub: 'In partnership with YH Charter, our sister company. Forty-plus vessels — Bali day boats, crewed liveaboards in Komodo, expedition phinisi in Raja Ampat. Crewed, catered, privately yours.',
      heroImage: 'https://images.unsplash.com/photo-1544551763-46a013bb70d5?w=2200&q=85',
    },
  },
  heli: {
    label: 'Helicopter', url: '/heli',
    d: {
      metaTitle: 'Helicopter Charter Bali from $3,800 — Day Trips & Scenic Flights | The Luxury Bali',
      metaDescription: 'Private helicopter charters from Bali — from $3,800/route, up to 5 pax. Four routes: Lombok, Komodo, Ijen Crater, scenic island loop. CASA-certified pilots, door-to-door.',
      heroEyebrow: 'Heli · Day trips & scenic flights',
      heroTitle: 'Lunch in Lombok, back by dusk.',
      heroSub: 'Two helicopters, CASA-certified pilots, door-to-door from your villa helipad or a short transfer to our Benoa pad.',
      heroImage: '/assets/yachts/heli/01.jpg',
    },
  },
  destinations: {
    label: 'Destinations', url: '/destinations',
    d: {
      metaTitle: 'Destinations — Ten Regions, One Island | The Luxury Bali',
      metaDescription: 'A field atlas of Bali in ten chapters — Canggu, Uluwatu, Seminyak, Ubud, Jimbaran, Sanur, Nusa Dua, Nusa Lembongan, Pererenan, Berawa. Curated by The Luxury Bali.',
      heroEyebrow: 'Destinations · Index',
      heroTitle: 'Ten regions, one island.',
      heroSub: "Bali is small. Forty minutes between climates, two hours between coasts. We've drawn it as ten chapters — the ones we send members to most.",
      heroImage: 'https://images.unsplash.com/photo-1559628233-eb6d4aa18a0b?w=2200&q=85',
      // v141.207 — Region-card photo defaults (mirror destinations.html
      // data-cms-bg="cardNImage"). Blank in Site Manager = these exact
      // designed images, zero FOUC.
      card1Image:  'https://images.unsplash.com/photo-1518548419970-58e3b4079ab2?w=900&q=82',
      card2Image:  'https://imagedelivery.net/wO6ihUlg1M7aee1mES2PLg/ttd_villa-tra_image_1776223493/public',
      card3Image:  'https://www.theluxurysignature.com/wp-content/uploads/2026/02/01-Casa-Evaliza-Opulent-contemporary-Balinese-villa.jpg',
      card4Image:  'https://assets.guesty.com/image/upload/v1724206796/production/64ad29a375ac96002da6983a/nxx8bqhvigkiyvhmrasm.jpg',
      card5Image:  'https://a0.muscache.com/im/pictures/hosting/Hosting-U3RheVN1cHBseUxpc3Rpbmc6MTM2NTcyNzUwMjEzMjIzNzAzNA==/original/2c7d1c92-1566-4b28-8362-af60583c7c01.jpeg?im_w=1200',
      card6Image:  'https://images.unsplash.com/photo-1544161515-4ab6ce6db874?w=900&q=82',
      card7Image:  'https://www.theluxurysignature.com/wp-content/uploads/2018/11/1.-Grand-Cliff-Nusa-Dua-Stunning-view-from-pool-2.jpg',
      card8Image:  'https://images.unsplash.com/photo-1570789210967-2cac24f2b5e4?w=900&q=82',
      card9Image:  'https://www.theluxurysignature.com/wp-content/uploads/2025/02/38-Villa-Norbu-Fantastic-view-from-the-rooftop-sala.jpg',
      card10Image: 'https://images.unsplash.com/photo-1571896349842-33c89424de2d?w=900&q=82',
      card11Image: 'https://www.tampahhills.com/wp-content/uploads/2025/02/Tampah-Hills-Villa-Awasi-Hero.jpg',
      card12Image: 'https://www.tampahhills.com/wp-content/uploads/2025/02/Tampah-Hills-Villa-Solah-Hero.jpg',
      card13Image: 'https://www.tampahhills.com/wp-content/uploads/2025/01/Tampah-Hills-Villa-Kastil-Utara2.jpg',
    },
    // v141.207 — Per-page extra fields appended after the 6 shared SM
    // fields (see FIELDS in AdminPageCMS). Each is a region-card photo;
    // groupHead renders a section divider above the first one.
    extra: [
      { k: 'card1Image',  label: 'Card 01 · Canggu — photo URL',                 area: false, groupHead: 'Destination cards — region photos' },
      { k: 'card2Image',  label: 'Card 02 · Uluwatu — photo URL',                area: false },
      { k: 'card3Image',  label: 'Card 03 · Seminyak — photo URL',               area: false },
      { k: 'card4Image',  label: 'Card 04 · Ubud — photo URL',                   area: false },
      { k: 'card5Image',  label: 'Card 05 · Jimbaran — photo URL',               area: false },
      { k: 'card6Image',  label: 'Card 06 · Sanur — photo URL',                  area: false },
      { k: 'card7Image',  label: 'Card 07 · Nusa Dua — photo URL',               area: false },
      { k: 'card8Image',  label: 'Card 08 · Nusa Lembongan — photo URL',         area: false },
      { k: 'card9Image',  label: 'Card 09 · Pererenan — photo URL',              area: false },
      { k: 'card10Image', label: 'Card 10 · Berawa — photo URL',                 area: false },
      { k: 'card11Image', label: 'Card 11 · Villa Awasi · Lombok — photo URL',   area: false },
      { k: 'card12Image', label: 'Card 12 · Villa Solah · Lombok — photo URL',   area: false },
      { k: 'card13Image', label: 'Card 13 · Villa Kastil Utara · Lombok — photo URL', area: false },
    ],
  },
  discover: {
    label: 'Discover', url: '/discover',
    d: {
      metaTitle: 'Discover · The Journal — Field Notes from Bali | The Luxury Bali',
      metaDescription: "Field notes, design essays, and short guides — from our team, living in Bali. Long reads, host stories, slow dispatches from The Luxury Bali's editors.",
      heroEyebrow: 'The Journal · Issue 12 · May 2026',
      heroTitle: '',
      heroSub: 'Field notes from our editors and the houses we keep coming back to — written in Bali, for guests already on their way.',
      heroImage: 'https://www.theluxurysignature.com/wp-content/uploads/2026/02/01-Casa-Evaliza-Opulent-contemporary-Balinese-villa.jpg',
    },
  },
  // v141.201 — /blog (The Journal index) is now Site-Manager editable
  // (data-cms-page="blog" + /js/tlb-cms-bind.js added to blog.html).
  blog: {
    label: 'Blog / Journal', url: '/blog',
    d: {
      metaTitle: 'The Journal — Notes from the Island | The Luxury Bali',
      metaDescription: 'The Luxury Bali Journal — field notes, host stories, and slow dispatches from our editors. Published when there is something worth printing — never to a calendar.',
      heroEyebrow: 'The Journal · MMXXVI',
      heroTitle: 'Notes from the island.',
      heroSub: 'Field notes, host stories, and slow dispatches from our editors. Published when there is something worth printing — never to a calendar.',
      heroImage: 'https://www.tampahhills.com/wp-content/uploads/2025/02/Tampah-Hills-Villa-Kastil-Utara-Hero.jpg',
    },
  },
};
const SM_FIELD_META = [
  { k: 'metaTitle',       label: 'SEO title (browser tab + Google)',  area: false },
  { k: 'metaDescription', label: 'SEO meta description',              area: true  },
  { k: 'heroEyebrow',     label: 'Hero eyebrow (small label)',        area: false },
  { k: 'heroTitle',       label: 'Hero headline (blank = keep designed headline)', area: false },
  { k: 'heroSub',         label: 'Hero subtitle',                     area: true  },
  { k: 'heroImage',       label: 'Hero photo URL (blank = keep designed image)', area: false },
];

function AdminPageCMS({ page, setPage, onBack }) {
  const cfg = SM_PAGE_DEFAULTS[page] || SM_PAGE_DEFAULTS.yacht;
  // v141.207 — shared 6 fields + any per-page extras (destinations'
  // 13 region-card photos). Load/save/render all iterate FIELDS.
  const FIELDS = SM_FIELD_META.concat(Array.isArray(cfg.extra) ? cfg.extra : []);
  const [full, setFull] = useState(null);
  const [vals, setVals] = useState({});
  const [phase, setPhase] = useState('loading'); // loading|ready|saving|saved|error
  const [msg, setMsg] = useState('');
  const [uploading, setUploading] = useState(false);
  const [uploadMsg, setUploadMsg] = useState('');

  const adminToken = () => {
    try { return localStorage.getItem('tlb_admin_token') || localStorage.getItem('tlb_token') || ''; }
    catch { return ''; }
  };

  useEffect(() => {
    let alive = true;
    setPhase('loading'); setMsg('');
    fetch('/api/cms?v=' + Math.floor(Date.now() / 60000), { cache: 'no-cache' })
      .then(r => (r && r.ok ? r.json() : null))
      .then(j => {
        if (!alive) return;
        const data = (j && j.data && typeof j.data === 'object') ? j.data : {};
        setFull(data);
        const cur = (data.pages && data.pages[page]) || {};
        const v = {};
        FIELDS.forEach(f => { v[f.k] = cur[f.k] || ''; });
        setVals(v);
        setPhase('ready');
      })
      .catch(() => { if (alive) { setFull({}); setPhase('ready'); } });
    return () => { alive = false; };
  }, [page]);

  const set = (k, val) => setVals(s => ({ ...s, [k]: val }));

  // v141.166 — Upload a hero photo to TLB-hosted public storage
  // (Supabase 'site-media' bucket → stable, non-expiring public URL).
  // SEO-safe: no hot-linking a third-party URL that can break or get
  // blocklisted. The returned URL is written straight into heroImage.
  // v141.168 — Resize in the browser BEFORE upload. Vercel serverless
  // rejects request bodies > ~4.5 MB at the edge (HTTP 413, plain text
  // → the "not valid JSON" the operator saw). Hero photos are usually
  // bigger, so we canvas-downscale the long edge to 2200px and encode
  // WebP→JPEG well under the limit, then parse the response defensively.
  function resizeHeroImage(file) {
    return new Promise((resolve, reject) => {
      if (!/^image\//.test(file.type || '')) return reject(new Error('Please choose an image file (JPG, PNG or WEBP).'));
      const reader = new FileReader();
      reader.onerror = () => reject(new Error('Could not read that file.'));
      reader.onload = () => {
        const img = new Image();
        img.onerror = () => reject(new Error('That file is not a valid image.'));
        img.onload = async () => {
          // v141.169 — HD hero. Keep the highest resolution + quality
          // that still fits safely under Vercel's ~4.5 MB edge cap.
          // Try 2560px then 2048px; at each, step WebP quality 0.92→0.74
          // (WebP ≈ HD at much smaller size), JPEG 0.9→0.78 fallback.
          const LIMIT = 4.0 * 1024 * 1024;            // < Vercel 4.5MB (multipart-safe)
          const DIMS = [2560, 2048];                  // HD-first, graceful step-down
          const enc = (cv, type, q) => new Promise(r => cv.toBlob(b => r(b), type, q));
          for (const MAX_DIM of DIMS) {
            const s = Math.min(1, MAX_DIM / Math.max(img.naturalWidth, img.naturalHeight));
            const w = Math.round(img.naturalWidth * s), h = Math.round(img.naturalHeight * s);
            const c = document.createElement('canvas'); c.width = w; c.height = h;
            const ctx = c.getContext('2d');
            ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high';
            ctx.drawImage(img, 0, 0, w, h);
            for (const q of [0.92, 0.88, 0.84, 0.80, 0.74]) {
              const b = await enc(c, 'image/webp', q);
              if (b && b.size > 0 && b.size < LIMIT) return resolve({ blob: b, ext: 'webp', contentType: 'image/webp' });
            }
            for (const q of [0.90, 0.84, 0.78]) {
              const j = await enc(c, 'image/jpeg', q);
              if (j && j.size > 0 && j.size < LIMIT) return resolve({ blob: j, ext: 'jpg', contentType: 'image/jpeg' });
            }
          }
          reject(new Error('Even at reduced quality this image is too large — please use a smaller original.'));
        };
        img.src = reader.result;
      };
      reader.readAsDataURL(file);
    });
  }

  async function uploadHero(file, targetKey) {
    if (!file) return;
    const tk = targetKey || 'heroImage';
    const t = adminToken();
    if (!t) { setUploadMsg('Not signed in as admin.'); return; }
    setUploading(true); setUploadMsg('Optimising image…');
    try {
      const { blob, ext } = await resizeHeroImage(file);
      const fd = new FormData();
      fd.append('file', blob, tk + '-' + page + '.' + ext);
      fd.append('bucket', 'site-media');
      fd.append('kind', tk + '-' + page);
      setUploadMsg('Uploading…');
      const res = await fetch('/api/upload-photo', { method: 'POST', headers: { Authorization: 'Bearer ' + t }, body: fd });
      const raw = await res.text();
      let j = null; try { j = raw ? JSON.parse(raw) : null; } catch (e) { /* non-JSON (413/proxy/HTML) */ }
      if (!res.ok || !j || !j.public_url) {
        let m;
        if (res.status === 413) m = 'Image too large for the uploader even after compression — please pick a smaller photo.';
        else if (j && j.error) m = j.error;
        else m = 'Upload failed (HTTP ' + res.status + '). Please try a smaller image or retry.';
        setUploadMsg(m); return;
      }
      set(tk, j.public_url);
      setUploadMsg('Uploaded ✓ — TLB-hosted. Press Publish to make it live.');
    } catch (e) {
      setUploadMsg(e.message || 'Upload error');
    } finally {
      setUploading(false);
    }
  }

  function save() {
    const t = adminToken();
    if (!t) { setPhase('error'); setMsg('Not signed in as admin — re-open the dashboard from /login.'); return; }
    setPhase('saving'); setMsg('');
    const next = (full && typeof full === 'object') ? JSON.parse(JSON.stringify(full)) : {};
    if (!next.pages || typeof next.pages !== 'object') next.pages = {};
    const clean = {};
    FIELDS.forEach(f => { const v = (vals[f.k] || '').trim(); if (v) clean[f.k] = v; });
    next.pages[page] = clean;
    fetch('/api/cms', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + t },
      body: JSON.stringify({ data: next }),
    })
      .then(r => r.json().then(j => ({ ok: r.ok, j })))
      .then(({ ok, j }) => {
        if (!ok) { setPhase('error'); setMsg((j && j.error) || 'Save failed'); return; }
        setFull(next); setPhase('saved');
        setMsg('Published. The live page updates within ~60 seconds (hard-refresh to see it now).');
      })
      .catch(e => { setPhase('error'); setMsg(e.message || 'Save failed'); });
  }

  const inputStyle = {
    width: '100%', boxSizing: 'border-box', padding: '12px 14px',
    border: '1px solid ' + CC.border, borderRadius: 10, background: CC.white,
    fontFamily: F_TLB.ui, fontSize: 14, color: CC.ink, outline: 'none', resize: 'vertical',
  };

  return (
    <div>
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12, marginBottom: dashGap() }}>
        <button onClick={onBack}
          style={{ padding: '8px 16px', borderRadius: 100, cursor: 'pointer', background: CC.white, color: CC.deep, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600 }}>
          ← All pages
        </button>
        <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
          {Object.keys(SM_PAGE_DEFAULTS).map(k => (
            <button key={k} onClick={() => setPage(k)}
              style={{
                padding: '8px 16px', borderRadius: 100, fontSize: 12, fontWeight: 600, cursor: 'pointer',
                border: '1px solid ' + (k === page ? CC.ink : CC.border),
                background: k === page ? CC.ink : CC.white,
                color: k === page ? CC.white : CC.deep,
              }}>{SM_PAGE_DEFAULTS[k].label}</button>
          ))}
        </div>
      </div>

      <Panel pad={14} style={{ marginBottom: dashGap(), background: CC.offWhite, borderColor: CC.border }}>
        <Sans size={12} color={CC.deep} style={{ lineHeight: 1.6 }}>
          Editing <strong>{cfg.url}</strong> live, in the new dashboard — no classic editor, no redeploy.
          Leave a field blank to keep the page's designed default. Saved copy goes live within ~60 seconds.
        </Sans>
      </Panel>

      {phase === 'loading' && <Sans size={13} color={CC.mid}>Loading current content…</Sans>}

      {phase !== 'loading' && (
        <Panel>
          {FIELDS.map(f => (
            <div key={f.k} style={{ marginBottom: 18 }}>
              {f.groupHead && (
                <div style={{ margin: '8px 0 18px', paddingTop: 18, borderTop: '1px solid ' + CC.border }}>
                  <Sans size={12} weight={700} ls={0.12} upper color={CC.deep}>{f.groupHead}</Sans>
                  <Sans size={11} color={CC.soft} style={{ marginTop: 4 }}>Paste an image URL or use Upload photo. Leave blank to keep the page's designed image.</Sans>
                </div>
              )}
              <Sans size={10} weight={700} ls={0.14} upper color={CC.mid} style={{ marginBottom: 7 }}>{f.label}</Sans>
              {f.area
                ? <textarea rows={f.k === 'metaDescription' ? 2 : 3} value={vals[f.k] || ''}
                    placeholder={cfg.d[f.k] || ''} onChange={e => set(f.k, e.target.value)}
                    style={{ ...inputStyle, minHeight: 64, lineHeight: 1.5 }} />
                : <input type="text" value={vals[f.k] || ''}
                    placeholder={cfg.d[f.k] || '(none)'} onChange={e => set(f.k, e.target.value)}
                    style={inputStyle} />}
              {/Image$/.test(f.k) && (
                <div style={{ marginTop: 8, display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
                  <label style={{
                    display: 'inline-flex', alignItems: 'center', gap: 8,
                    padding: '8px 16px', borderRadius: 100, cursor: uploading ? 'wait' : 'pointer',
                    background: CC.ink, color: CC.white, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600,
                  }}>
                    {uploading ? 'Uploading…' : 'Upload photo'}
                    <input type="file" accept="image/jpeg,image/png,image/webp,image/avif" disabled={uploading}
                      onChange={e => { const fl = e.target.files && e.target.files[0]; e.target.value = ''; uploadHero(fl, f.k); }}
                      style={{ display: 'none' }} />
                  </label>
                  {(vals[f.k] || cfg.d[f.k]) && (
                    <span style={{ width: 56, height: 36, borderRadius: 6, flexShrink: 0, border: '1px solid ' + CC.hairline,
                      background: 'url("' + (vals[f.k] || cfg.d[f.k]) + '") center/cover, ' + CC.offWhite }} />
                  )}
                  <Sans size={11} color={CC.soft}>Uploads to TLB storage — SEO-safe, no expiry. Or paste a URL above.</Sans>
                  {uploadMsg && <Sans size={11} weight={600} color={/fail|error|too large|could not|not a valid|not an image|sign(ed)? in|HTTP \d/i.test(uploadMsg) ? '#9a1c1c' : '#1f6b3a'}>{uploadMsg}</Sans>}
                </div>
              )}
              {!(vals[f.k] || '').trim() && (cfg.d[f.k] || '') && (
                <Sans size={11} color={CC.soft} style={{ marginTop: 5 }}>Default in use: “{cfg.d[f.k]}”</Sans>
              )}
            </div>
          ))}

          <div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginTop: 8 }}>
            <button onClick={save} disabled={phase === 'saving'}
              style={{ padding: '12px 26px', borderRadius: 100, cursor: phase === 'saving' ? 'wait' : 'pointer', background: CC.ink, color: CC.white, border: 'none', fontFamily: F_TLB.ui, fontSize: 13, fontWeight: 700 }}>
              {phase === 'saving' ? 'Publishing…' : 'Publish changes'}
            </button>
            <a href={cfg.url} target="_blank" rel="noopener noreferrer"
              style={{ padding: '12px 22px', borderRadius: 100, background: CC.white, color: CC.deep, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 13, fontWeight: 600, textDecoration: 'none' }}>
              Preview live ↗
            </a>
            {msg && (
              <Sans size={12} weight={600} color={phase === 'error' ? '#9a1c1c' : '#1f6b3a'}>{msg}</Sans>
            )}
          </div>
        </Panel>
      )}
    </div>
  );
}

// ── AdminHomepageCMS (v141.160) ─────────────────────────────────────────
// Native (new-design) editor for the homepage sections the live
// index.html actually hydrates from /api/cms (v131/v133): statement,
// escape, designerVillas, getInspired, fieldJournal — plus the three
// mega-menu feature cards (homepage.megaFeatures.{destinations,stay,
// experience}). Writes the EXACT same keys index.html reads, and
// MERGES over the existing object so array sub-content (escape.tiles,
// getInspired.cards) is preserved. This is the staged continuation of
// "kill the classic look-through": Homepage now edits here, not in
// /admin-classic. (Homepage hero is intentionally omitted — v133
// reverted hero CMS hydration, so editing it would be a dead control.)
const SM_HP_GROUPS = [
  { key: 'statement', label: 'Statement', fields: [
    { k: 'kicker', label: 'Kicker', area: false },
    { k: 'headline', label: 'Headline (inline <em> allowed)', area: true },
    { k: 'signature', label: 'Signature', area: false },
  ], def: {
    kicker: 'The Luxury Bali · Statement',
    headline: 'Every journey begins with you. <em>We tailor each experience to reflect your desires</em> — creating deeply personal moments that become lifelong memories.',
    signature: 'Curated by hand · since 2018',
  } },
  { key: 'escape', label: 'Escape grid', fields: [
    { k: 'heading', label: 'Heading (inline <em> allowed)', area: true },
    { k: 'body', label: 'Body', area: true },
    { k: 'ctaLabel', label: 'CTA label', area: false },
    { k: 'ctaHref', label: 'CTA link', area: false },
  ], def: {
    heading: 'Escape <em>ordinary</em>,<br/><em>Embrace</em> Extraordinary',
    body: 'Immerse yourself in the extraordinary as The Luxury Bali curates the <em>finest</em> experiences across Bali and beyond.',
    ctaLabel: 'Browse the full collection', ctaHref: '/search',
  } },
  { key: 'designerVillas', label: 'Designer villas', fields: [
    { k: 'eyebrow', label: 'Eyebrow', area: false },
    { k: 'title', label: 'Title', area: false },
    { k: 'sub', label: 'Subtitle', area: true },
    { k: 'ctaLabel', label: 'CTA label', area: false },
    { k: 'ctaHref', label: 'CTA link', area: false },
  ], def: {
    eyebrow: "Editor's pick · Designer villas",
    title: 'Designer villas. Hand-picked by our editors.',
    sub: 'Four houses our editors keep coming back to — a small list, kept short on purpose.',
    ctaLabel: "The editor's list", ctaHref: '/search',
  } },
  { key: 'getInspired', label: 'Get inspired', fields: [
    { k: 'heading', label: 'Heading (inline <em> allowed)', area: false },
    { k: 'body', label: 'Body', area: true },
    { k: 'ctaLabel', label: 'CTA label', area: false },
    { k: 'ctaHref', label: 'CTA link', area: false },
  ], def: {
    heading: 'Get <em>inspired</em>',
    body: 'Unleash your wanderlust with exclusive travel ideas, unique experiences, and insider tips.',
    ctaLabel: 'View all', ctaHref: '/journal',
  } },
  { key: 'fieldJournal', label: 'Field journal strip', fields: [
    { k: 'eyebrow', label: 'Eyebrow', area: false },
    { k: 'title', label: 'Title', area: false },
  ], def: { eyebrow: 'Field journal', title: 'Field notes.' } },
];
const SM_HP_MEGA = [
  { key: 'destinations', label: 'Destinations menu', def: { eyebrow: 'Beyond Bali · Lombok', title: 'House of Kala', sub: 'A four-bedroom house on the Tampah ridge — Lombok’s quiet southern coast.', cta: 'View the house →', href: '/villa/house-kala-rent-house-kala-at-tampah-hills-lombok', img: '' } },
  { key: 'stay', label: 'Stay menu', def: { eyebrow: 'AI Trip Designer', title: 'Tell us the trip, we will plan the rest.', sub: 'Natural-language chat with our concierge AI. Dates, mood, group size — it does the matching.', cta: 'Open the conversation →', href: '/trip-designer', img: '' } },
  { key: 'experience', label: 'Experiences menu', def: { eyebrow: 'Above it', title: 'Twelve minutes by air.', sub: 'Ngurah Rai to Ubud, returned to its proper scale.', cta: 'Charter a helicopter →', href: '/heli', img: '' } },
];
const SM_MEGA_FIELDS = [
  { k: 'eyebrow', label: 'Eyebrow', area: false },
  { k: 'title', label: 'Title', area: false },
  { k: 'sub', label: 'Subtitle', area: true },
  { k: 'cta', label: 'CTA label', area: false },
  { k: 'href', label: 'CTA link', area: false },
  { k: 'img', label: 'Photo URL (blank = designed image)', area: false },
];

function AdminHomepageCMS({ onBack }) {
  const [full, setFull] = useState(null);
  const [vals, setVals] = useState({});      // { 'statement.kicker': '...' , 'mega.destinations.title': '...' }
  const [phase, setPhase] = useState('loading');
  const [msg, setMsg] = useState('');

  const adminToken = () => {
    try { return localStorage.getItem('tlb_admin_token') || localStorage.getItem('tlb_token') || ''; }
    catch { return ''; }
  };

  useEffect(() => {
    let alive = true;
    setPhase('loading'); setMsg('');
    fetch('/api/cms?v=' + Math.floor(Date.now() / 60000), { cache: 'no-cache' })
      .then(r => (r && r.ok ? r.json() : null))
      .then(j => {
        if (!alive) return;
        const data = (j && j.data && typeof j.data === 'object') ? j.data : {};
        setFull(data);
        const hp = (data.homepage && typeof data.homepage === 'object') ? data.homepage : {};
        const v = {};
        SM_HP_GROUPS.forEach(g => {
          const cur = (hp[g.key] && typeof hp[g.key] === 'object') ? hp[g.key] : {};
          g.fields.forEach(f => { v[g.key + '.' + f.k] = cur[f.k] || ''; });
        });
        const mf = (hp.megaFeatures && typeof hp.megaFeatures === 'object') ? hp.megaFeatures : {};
        SM_HP_MEGA.forEach(m => {
          const cur = (mf[m.key] && typeof mf[m.key] === 'object') ? mf[m.key] : {};
          SM_MEGA_FIELDS.forEach(f => { v['mega.' + m.key + '.' + f.k] = cur[f.k] || ''; });
        });
        setVals(v);
        setPhase('ready');
      })
      .catch(() => { if (alive) { setFull({}); setPhase('ready'); } });
    return () => { alive = false; };
  }, []);

  const set = (path, val) => setVals(s => ({ ...s, [path]: val }));

  function save() {
    const t = adminToken();
    if (!t) { setPhase('error'); setMsg('Not signed in as admin — re-open the dashboard from /login.'); return; }
    setPhase('saving'); setMsg('');
    const next = (full && typeof full === 'object') ? JSON.parse(JSON.stringify(full)) : {};
    if (!next.homepage || typeof next.homepage !== 'object') next.homepage = {};
    // Scalar groups — MERGE non-empty fields over the existing object so
    // array sub-content (escape.tiles, getInspired.cards) is preserved.
    SM_HP_GROUPS.forEach(g => {
      const base = (next.homepage[g.key] && typeof next.homepage[g.key] === 'object') ? next.homepage[g.key] : {};
      const merged = { ...base };
      g.fields.forEach(f => {
        const val = (vals[g.key + '.' + f.k] || '').trim();
        if (val) merged[f.k] = val; else delete merged[f.k];
      });
      next.homepage[g.key] = merged;
    });
    if (!next.homepage.megaFeatures || typeof next.homepage.megaFeatures !== 'object') next.homepage.megaFeatures = {};
    SM_HP_MEGA.forEach(m => {
      const base = (next.homepage.megaFeatures[m.key] && typeof next.homepage.megaFeatures[m.key] === 'object') ? next.homepage.megaFeatures[m.key] : {};
      const merged = { ...base };
      SM_MEGA_FIELDS.forEach(f => {
        const val = (vals['mega.' + m.key + '.' + f.k] || '').trim();
        if (val) merged[f.k] = val; else delete merged[f.k];
      });
      next.homepage.megaFeatures[m.key] = merged;
    });
    fetch('/api/cms', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + t },
      body: JSON.stringify({ data: next }),
    })
      .then(r => r.json().then(j => ({ ok: r.ok, j })))
      .then(({ ok, j }) => {
        if (!ok) { setPhase('error'); setMsg((j && j.error) || 'Save failed'); return; }
        setFull(next); setPhase('saved');
        setMsg('Published. The homepage updates within ~60 seconds (hard-refresh to see it now).');
      })
      .catch(e => { setPhase('error'); setMsg(e.message || 'Save failed'); });
  }

  const inputStyle = {
    width: '100%', boxSizing: 'border-box', padding: '12px 14px',
    border: '1px solid ' + CC.border, borderRadius: 10, background: CC.white,
    fontFamily: F_TLB.ui, fontSize: 14, color: CC.ink, outline: 'none', resize: 'vertical',
  };
  const renderField = (path, f, def) => (
    <div key={path} style={{ marginBottom: 16 }}>
      <Sans size={10} weight={700} ls={0.14} upper color={CC.mid} style={{ marginBottom: 6 }}>{f.label}</Sans>
      {f.area
        ? <textarea rows={2} value={vals[path] || ''} placeholder={def || ''}
            onChange={e => set(path, e.target.value)} style={{ ...inputStyle, minHeight: 58, lineHeight: 1.5 }} />
        : <input type="text" value={vals[path] || ''} placeholder={def || '(none)'}
            onChange={e => set(path, e.target.value)} style={inputStyle} />}
    </div>
  );

  return (
    <div>
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12, marginBottom: dashGap() }}>
        <button onClick={onBack}
          style={{ padding: '8px 16px', borderRadius: 100, cursor: 'pointer', background: CC.white, color: CC.deep, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600 }}>
          ← All pages
        </button>
        <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
          <a href="/" target="_blank" rel="noopener noreferrer"
            style={{ padding: '8px 16px', borderRadius: 100, background: CC.white, color: CC.deep, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600, textDecoration: 'none' }}>
            Preview live ↗
          </a>
          <button onClick={save} disabled={phase === 'saving'}
            style={{ padding: '8px 20px', borderRadius: 100, cursor: phase === 'saving' ? 'wait' : 'pointer', background: CC.ink, color: CC.white, border: 'none', fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 700 }}>
            {phase === 'saving' ? 'Publishing…' : 'Publish changes'}
          </button>
        </div>
      </div>

      <Panel pad={14} style={{ marginBottom: dashGap(), background: CC.offWhite, borderColor: CC.border }}>
        <Sans size={12} color={CC.deep} style={{ lineHeight: 1.6 }}>
          Editing the <strong>homepage</strong> live, in the new dashboard — no classic editor.
          Blank fields keep the designed default. Changes go live within ~60 seconds.
          (Homepage hero is design-controlled and not editable here by design.)
        </Sans>
      </Panel>

      {phase === 'loading' && <Sans size={13} color={CC.mid}>Loading current content…</Sans>}

      {phase !== 'loading' && (
        <div style={{ display: 'flex', flexDirection: 'column', gap: dashGap() }}>
          {SM_HP_GROUPS.map(g => (
            <Panel key={g.key}>
              <Sans size={13} weight={700} ls={0.06} color={CC.ink} style={{ marginBottom: 14 }}>{g.label}</Sans>
              {g.fields.map(f => renderField(g.key + '.' + f.k, f, g.def[f.k]))}
            </Panel>
          ))}
          <Sans size={11} weight={700} ls={0.18} upper color={CC.mid}>Mega-menu feature cards</Sans>
          {SM_HP_MEGA.map(m => (
            <Panel key={m.key}>
              <Sans size={13} weight={700} ls={0.06} color={CC.ink} style={{ marginBottom: 14 }}>{m.label}</Sans>
              {SM_MEGA_FIELDS.map(f => renderField('mega.' + m.key + '.' + f.k, f, m.def[f.k]))}
            </Panel>
          ))}
          <div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
            <button onClick={save} disabled={phase === 'saving'}
              style={{ padding: '12px 26px', borderRadius: 100, cursor: phase === 'saving' ? 'wait' : 'pointer', background: CC.ink, color: CC.white, border: 'none', fontFamily: F_TLB.ui, fontSize: 13, fontWeight: 700 }}>
              {phase === 'saving' ? 'Publishing…' : 'Publish changes'}
            </button>
            {msg && <Sans size={12} weight={600} color={phase === 'error' ? '#9a1c1c' : '#1f6b3a'}>{msg}</Sans>}
          </div>
        </div>
      )}
    </div>
  );
}

// ── AdminInquiries (v141.220) ─────────────────────────────────────────
// Phase 0 of the Aetheria Intelligence roadmap: surface every inquiry
// in one place, bucket by spam/real/replied, let the operator triage in
// one click. Spam is auto-flagged at insert time by api/lib/spam-
// classifier — this panel lets the operator override either way.
//
// Buckets (computed server-side in /api/admin/enquiries via notes tag):
//   new      → real, awaiting reply
//   replied  → operator already responded
//   booked   → converted to a booking
//   spam     → auto-flagged or manually marked
//   archived → operator dismissed (kept for audit)
// ────────────────────────────────────────────────────────────────────────
function AdminInquiries({ onBack }) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [bucket, setBucket] = useState('new');
  const [acting, setActing] = useState(null);

  const adminToken = () => {
    try { return localStorage.getItem('tlb_admin_token') || localStorage.getItem('tlb_token') || ''; }
    catch { return ''; }
  };

  const fetchData = React.useCallback(() => {
    setLoading(true); setError(null);
    const t = adminToken();
    if (!t) { setError('Not signed in as admin — re-open the dashboard from /login.'); setLoading(false); return; }
    fetch('/api/admin/enquiries?limit=200', { headers: { Authorization: 'Bearer ' + t } })
      .then(r => r.ok ? r.json() : r.text().then(t => { throw new Error(t || ('HTTP ' + r.status)); }))
      .then(j => { setData(j); setLoading(false); })
      .catch(e => { setError(e.message || 'Fetch failed'); setLoading(false); });
  }, []);

  useEffect(() => { fetchData(); }, [fetchData]);

  async function act(id, action, extra = {}) {
    setActing(id + ':' + action);
    try {
      const t = adminToken();
      const r = await fetch('/api/admin/enquiries', {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + t },
        body: JSON.stringify({ id, action, ...extra }),
      });
      if (!r.ok) { const txt = await r.text(); throw new Error(txt.slice(0, 200)); }
      fetchData();
    } catch (e) {
      alert('Action failed: ' + (e.message || e));
    } finally {
      setActing(null);
    }
  }

  const counts = (data && data.counts) || {};
  const list = (data && data.enquiries) || [];
  const visible = list.filter(e => e._bucket === bucket);

  const BUCKETS = [
    { v: 'new',      l: 'New',       tone: '#1f6b3a' },
    { v: 'replied',  l: 'Replied',   tone: CC.deep },
    { v: 'booked',   l: 'Booked',    tone: '#1f4b8a' },
    { v: 'spam',     l: 'Spam',      tone: '#9a1c1c' },
    { v: 'archived', l: 'Archived',  tone: CC.mid },
  ];

  return (
    <div>
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12, marginBottom: dashGap() }}>
        <button onClick={onBack}
          style={{ padding: '8px 16px', borderRadius: 100, cursor: 'pointer', background: CC.white, color: CC.deep, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600 }}>
          ← Back
        </button>
        <button onClick={fetchData}
          style={{ padding: '8px 16px', borderRadius: 100, cursor: 'pointer', background: CC.white, color: CC.deep, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600 }}>
          ↻ Refresh
        </button>
      </div>

      <Panel pad={14} style={{ marginBottom: dashGap(), background: CC.offWhite, borderColor: CC.border }}>
        <Sans size={12} color={CC.deep} style={{ lineHeight: 1.6 }}>
          <strong>Inquiry triage.</strong> Auto-classifier (api/lib/spam-classifier.js) marks obvious SEO/agency spam at insert time so your inbox stays clean. You can override either way; the audit trail lives in <code>notes</code>.
        </Sans>
      </Panel>

      <div style={{ display: 'flex', gap: 8, marginBottom: 18, flexWrap: 'wrap' }}>
        {BUCKETS.map(b => {
          const active = b.v === bucket;
          const c = counts[b.v] || 0;
          return (
            <button key={b.v} onClick={() => setBucket(b.v)}
              style={{
                padding: '8px 14px', borderRadius: 100, cursor: 'pointer',
                background: active ? b.tone : CC.white,
                color: active ? CC.white : CC.deep,
                border: '1px solid ' + (active ? b.tone : CC.border),
                fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600,
              }}>
              {b.l} <span style={{ opacity: 0.75, marginLeft: 6 }}>{c}</span>
            </button>
          );
        })}
      </div>

      {loading && <Loading msg="Loading inquiries…" />}
      {error && <ErrorState msg={error} onRetry={fetchData} />}
      {!loading && !error && visible.length === 0 && (
        <Empty title="Nothing here" msg={bucket === 'new' ? 'No real inquiries to triage right now.' : `No ${bucket} inquiries.`} />
      )}

      {!loading && !error && visible.map(e => {
        const created = e.created_at ? new Date(e.created_at).toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' }) : '';
        const phone = e.phone || '';
        const waLink = phone ? 'https://wa.me/' + phone.replace(/[^0-9]/g, '') : null;
        const replyLink = 'mailto:' + encodeURIComponent(e.email || '') + '?subject=' + encodeURIComponent('Re: Your enquiry — The Luxury Bali');
        return (
          <Panel key={e.id} style={{ marginBottom: 14 }}>
            <div style={{ display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12, alignItems: 'flex-start' }}>
              <div style={{ flex: 1, minWidth: 0 }}>
                <div style={{ display: 'flex', alignItems: 'baseline', gap: 10, marginBottom: 4, flexWrap: 'wrap' }}>
                  <Display size={16} italic weight={400}>{e.name || 'Guest'}</Display>
                  <Sans size={11} color={CC.mid}>{created}</Sans>
                  {e.source && <Sans size={10} weight={600} ls={0.14} upper color={CC.mid}>· {e.source}</Sans>}
                </div>
                <Sans size={12} color={CC.deep} style={{ marginBottom: 6 }}>
                  <a href={replyLink} style={{ color: CC.ink, textDecoration: 'underline' }}>{e.email}</a>
                  {phone && <> · <a href={waLink} target="_blank" rel="noopener" style={{ color: CC.ink, textDecoration: 'underline' }}>{phone}</a></>}
                </Sans>
                <Sans size={12} color={CC.deep} style={{ whiteSpace: 'pre-wrap', lineHeight: 1.55, marginTop: 8, padding: 10, background: CC.offWhite, borderRadius: 8 }}>
                  {e.message || '(no message)'}
                </Sans>
                {e.notes && (
                  <Sans size={10} color={CC.soft} style={{ marginTop: 6, fontStyle: 'italic' }}>
                    {e.notes}
                  </Sans>
                )}
              </div>
              <div style={{ display: 'flex', flexDirection: 'column', gap: 6, minWidth: 140 }}>
                <a href={replyLink} target="_blank" rel="noopener"
                  style={{ textAlign: 'center', padding: '8px 14px', borderRadius: 100, background: CC.ink, color: CC.white, textDecoration: 'none', fontFamily: F_TLB.ui, fontSize: 11, fontWeight: 700, letterSpacing: 0.1 }}>
                  Open in email
                </a>
                {e._bucket !== 'spam' && (
                  <button onClick={() => act(e.id, 'mark_spam')} disabled={acting === e.id + ':mark_spam'}
                    style={{ padding: '8px 14px', borderRadius: 100, background: CC.white, color: '#9a1c1c', border: '1px solid #9a1c1c', cursor: 'pointer', fontFamily: F_TLB.ui, fontSize: 11, fontWeight: 600 }}>
                    Mark as spam
                  </button>
                )}
                {e._bucket === 'spam' && (
                  <button onClick={() => act(e.id, 'mark_real')} disabled={acting === e.id + ':mark_real'}
                    style={{ padding: '8px 14px', borderRadius: 100, background: CC.white, color: '#1f6b3a', border: '1px solid #1f6b3a', cursor: 'pointer', fontFamily: F_TLB.ui, fontSize: 11, fontWeight: 600 }}>
                    Mark as real
                  </button>
                )}
                {e._bucket !== 'archived' && e._bucket !== 'spam' && (
                  <button onClick={() => act(e.id, 'archive')} disabled={acting === e.id + ':archive'}
                    style={{ padding: '8px 14px', borderRadius: 100, background: CC.white, color: CC.mid, border: '1px solid ' + CC.border, cursor: 'pointer', fontFamily: F_TLB.ui, fontSize: 11, fontWeight: 600 }}>
                    Archive
                  </button>
                )}
                <button onClick={() => act(e.id, 'reclassify')} disabled={acting === e.id + ':reclassify'}
                  style={{ padding: '8px 14px', borderRadius: 100, background: CC.white, color: CC.soft, border: '1px solid ' + CC.border, cursor: 'pointer', fontFamily: F_TLB.ui, fontSize: 11, fontWeight: 500 }}>
                  Reclassify
                </button>
              </div>
            </div>
          </Panel>
        );
      })}
    </div>
  );
}

// ── AdminTraffic (v141.222 → v141.242) ──────────────────────────────
// Phase 1 of the Aetheria Intelligence roadmap. Reads /api/admin/
// traffic-ga4 + /api/admin/traffic-gsc, surfaces totals + top tables.
// Both endpoints degrade gracefully ({configured:false, why, hint}) so
// this panel renders a setup hint before the operator finishes the
// GCP+GSC setup, then lights up automatically once env vars land in
// Vercel (no redeploy needed — endpoints read env on every request).
//
// v141.242 — Added operator OAuth UI: Connect / Connected-as / Disconnect
// + post-redirect banner. Backs the OAuth-first path in /api/lib/google-
// auth.js (operator's personal Google account preferred over the SA
// JWT — needed because standalone-Gmail GCP can't add SAs to GA4/GSC).
// ────────────────────────────────────────────────────────────────────────
function AdminTraffic({ onBack }) {
  const [ga4, setGa4] = useState(null);
  const [gsc, setGsc] = useState(null);
  const [tab, setTab] = useState('ga4');
  const [windowSel, setWindowSel] = useState('28d');
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  // v141.242 — OAuth connection state (operator's personal Google account)
  const [oauthStatus, setOauthStatus] = useState(null);
  const [oauthBanner, setOauthBanner] = useState(null);

  const adminToken = () => {
    try { return localStorage.getItem('tlb_admin_token') || localStorage.getItem('tlb_token') || ''; }
    catch { return ''; }
  };

  const load = React.useCallback(() => {
    setLoading(true); setError(null);
    const t = adminToken();
    if (!t) { setError('Not signed in as admin — re-open the dashboard from /login.'); setLoading(false); return; }
    const H = { Authorization: 'Bearer ' + t };
    Promise.all([
      fetch('/api/admin/traffic-ga4?window=' + windowSel, { headers: H }).then(r => r.json()),
      fetch('/api/admin/traffic-gsc?window=' + windowSel, { headers: H }).then(r => r.json()),
      fetch('/api/admin/google-oauth-status', { headers: H }).then(r => r.ok ? r.json() : null).catch(() => null),
    ]).then(([a, g, o]) => { setGa4(a); setGsc(g); setOauthStatus(o); setLoading(false); })
      .catch(e => { setError(e.message || 'Fetch failed'); setLoading(false); });
  }, [windowSel]);

  useEffect(() => { load(); }, [load]);

  // v141.242 — Surface oauth_ok / oauth_error redirect query params
  // (the OAuth callback at /api/admin/google-oauth-callback bounces back
  // here with these). Read once on mount, then strip them so a refresh
  // doesn't replay the banner.
  useEffect(() => {
    try {
      const qs = new URLSearchParams(window.location.search);
      if (qs.get('oauth_ok')) {
        setOauthBanner({ tone: 'ok', text: 'Connected as ' + (qs.get('connected_as') || 'Google account') + ' — Traffic + Aetheria tools now use your personal Google access.' });
        ['oauth_ok','connected_as','oauth_error','oauth_detail'].forEach(k => qs.delete(k));
        const newSearch = qs.toString();
        const newUrl = window.location.pathname + (newSearch ? '?' + newSearch : '') + window.location.hash;
        window.history.replaceState({}, '', newUrl);
      } else if (qs.get('oauth_error')) {
        setOauthBanner({ tone: 'err', text: 'Google sign-in failed: ' + qs.get('oauth_error') + (qs.get('oauth_detail') ? ' — ' + qs.get('oauth_detail') : '') });
        ['oauth_error','oauth_detail'].forEach(k => qs.delete(k));
        const newSearch = qs.toString();
        const newUrl = window.location.pathname + (newSearch ? '?' + newSearch : '') + window.location.hash;
        window.history.replaceState({}, '', newUrl);
      }
    } catch {/* SSR safe-guard */}
  }, []);

  async function disconnectGoogle() {
    if (!confirm('Disconnect your Google account from TLB? Traffic + Aetheria GA4/GSC tools will stop working until you reconnect.')) return;
    const t = adminToken();
    const r = await fetch('/api/admin/google-oauth-status', {
      method: 'DELETE', headers: { Authorization: 'Bearer ' + t },
    });
    if (r.ok) {
      setOauthBanner({ tone: 'ok', text: 'Google account disconnected.' });
      load();
    } else {
      alert('Disconnect failed.');
    }
  }

  function connectGoogle() {
    const t = adminToken();
    if (!t) { alert('Please sign in again.'); return; }
    // Top-level navigation: redirects can't carry an Authorization
    // header, so we pass the JWT via `?token=` query param. That path
    // is already a first-class fallback in api/lib/jwt.js extractToken()
    // (it covers SSE/WebSocket flows). Single-use HTTPS-only — same
    // security model as OAuth's own state token in the redirect URL.
    // After Google redirects back, /admin loads with ?oauth_ok=1 and
    // the useEffect above surfaces the banner.
    window.location.href = '/api/admin/google-oauth-start?token=' + encodeURIComponent(t);
  }

  const fmt  = (n) => Number(n || 0).toLocaleString('en-US');
  const pct  = (n) => (Number(n || 0) * 100).toFixed(2) + '%';
  const posn = (n) => Number(n || 0).toFixed(1);

  // Lightweight inline sparkline — no chart library needed.
  function Sparkline({ data, height = 36, color = CC.ink, valueKey }) {
    if (!data || !data.length) return null;
    const vals = data.map(d => Number(d[valueKey] || 0));
    const max = Math.max(1, ...vals);
    const w = 320;
    const step = w / Math.max(1, vals.length - 1);
    const pts = vals.map((v, i) => `${(i * step).toFixed(1)},${(height - (v / max) * (height - 4) - 2).toFixed(1)}`).join(' ');
    return (
      <svg viewBox={`0 0 ${w} ${height}`} style={{ width: '100%', height, display: 'block', marginTop: 8 }}>
        <polyline fill="none" stroke={color} strokeWidth="1.5" points={pts} />
      </svg>
    );
  }

  function NotConfigured({ src, payload }) {
    if (!payload || payload.configured !== false) return null;
    return (
      <Panel style={{ marginBottom: 14, background: '#fff8ec', borderColor: '#e8d9a3' }}>
        <Sans size={11} weight={700} ls={0.16} upper color="#8a5a00" style={{ marginBottom: 6 }}>
          {src} · not configured yet
        </Sans>
        <Sans size={12} color={CC.deep} style={{ lineHeight: 1.6 }}>
          <strong>Why:</strong> {payload.why || 'unknown'}
        </Sans>
        {payload.hint && (
          <Sans size={11} color={CC.mid} style={{ marginTop: 6, lineHeight: 1.55 }}>
            <strong>Fix:</strong> {payload.hint}
          </Sans>
        )}
      </Panel>
    );
  }

  return (
    <div>
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12, marginBottom: dashGap() }}>
        <button onClick={onBack}
          style={{ padding: '8px 16px', borderRadius: 100, cursor: 'pointer', background: CC.white, color: CC.deep, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600 }}>
          ← Back
        </button>
        <div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
          {['7d', '28d', '90d'].map(w => (
            <button key={w} onClick={() => setWindowSel(w)}
              style={{
                padding: '6px 12px', borderRadius: 100, cursor: 'pointer',
                background: w === windowSel ? CC.ink : CC.white,
                color: w === windowSel ? CC.white : CC.deep,
                border: '1px solid ' + (w === windowSel ? CC.ink : CC.border),
                fontFamily: F_TLB.ui, fontSize: 11, fontWeight: 600,
              }}>{w}</button>
          ))}
          <button onClick={load}
            style={{ padding: '8px 14px', borderRadius: 100, cursor: 'pointer', background: CC.white, color: CC.deep, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600 }}>
            ↻ Refresh
          </button>
        </div>
      </div>

      <Panel pad={14} style={{ marginBottom: dashGap(), background: CC.offWhite, borderColor: CC.border }}>
        <Sans size={12} color={CC.deep} style={{ lineHeight: 1.6 }}>
          <strong>Traffic — last {windowSel}.</strong> GA4 = sessions, pageviews, channels. GSC = search queries, impressions, clicks, average position. Both update on the next refresh.
        </Sans>
      </Panel>

      {/* v141.242 — OAuth redirect banner (from /api/admin/google-oauth-callback) */}
      {oauthBanner && (
        <Panel pad={12} style={{
          marginBottom: 14,
          background: oauthBanner.tone === 'ok' ? '#eef8f1' : '#fdecec',
          borderColor: oauthBanner.tone === 'ok' ? '#c6e6cf' : '#f1c2c2',
        }}>
          <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }}>
            <Sans size={12} color={oauthBanner.tone === 'ok' ? '#16613a' : '#8a1f1f'} style={{ lineHeight: 1.55 }}>
              {oauthBanner.text}
            </Sans>
            <button onClick={() => setOauthBanner(null)}
              aria-label="Dismiss"
              style={{ background: 'transparent', border: 'none', cursor: 'pointer', color: CC.mid, fontSize: 16, lineHeight: 1, padding: 2 }}>
              ×
            </button>
          </div>
        </Panel>
      )}

      {/* v141.242 — Google account connection (operator's personal account) */}
      {oauthStatus && oauthStatus.oauth_configured && (
        <Panel pad={12} style={{ marginBottom: 14 }}>
          <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
            <div style={{ minWidth: 0, flex: 1 }}>
              <Sans size={10} weight={700} ls={0.14} upper color={CC.mid} style={{ marginBottom: 4 }}>
                Google account
              </Sans>
              {oauthStatus.connected ? (
                <Sans size={12} color={CC.deep} style={{ lineHeight: 1.55 }}>
                  Connected as <strong>{oauthStatus.google_email}</strong>
                  {oauthStatus.connected_at && (
                    <span style={{ color: CC.mid }}> · since {new Date(oauthStatus.connected_at).toLocaleDateString()}</span>
                  )}
                </Sans>
              ) : (
                <Sans size={12} color={CC.deep} style={{ lineHeight: 1.55 }}>
                  Connect your personal Google account to read GA4 + Search Console without the service-account workflow. <span style={{ color: CC.mid }}>(Read-only; you can disconnect anytime.)</span>
                </Sans>
              )}
            </div>
            <div style={{ display: 'flex', gap: 8 }}>
              {oauthStatus.connected ? (
                <button onClick={disconnectGoogle}
                  style={{ padding: '8px 14px', borderRadius: 100, cursor: 'pointer', background: CC.white, color: CC.deep, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600 }}>
                  Disconnect
                </button>
              ) : (
                <button onClick={connectGoogle}
                  style={{ padding: '8px 16px', borderRadius: 100, cursor: 'pointer', background: CC.ink, color: CC.white, border: '1px solid ' + CC.ink, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600 }}>
                  Connect Google account
                </button>
              )}
            </div>
          </div>
        </Panel>
      )}
      {oauthStatus && oauthStatus.oauth_configured === false && (
        <Panel pad={12} style={{ marginBottom: 14, background: '#fff8ec', borderColor: '#e8d9a3' }}>
          <Sans size={11} weight={700} ls={0.14} upper color="#8a5a00" style={{ marginBottom: 4 }}>
            OAuth not configured
          </Sans>
          <Sans size={12} color={CC.deep} style={{ lineHeight: 1.55 }}>
            Personal-Google OAuth isn't set up yet. Set <code>GOOGLE_OAUTH_CLIENT_ID</code>, <code>GOOGLE_OAUTH_CLIENT_SECRET</code>, and <code>GOOGLE_OAUTH_REDIRECT_URI</code> in Vercel env to enable. Until then, GA4/GSC use the service-account path (requires Google Workspace).
          </Sans>
        </Panel>
      )}

      <div style={{ display: 'flex', gap: 8, marginBottom: 18, flexWrap: 'wrap' }}>
        {[{ v: 'ga4', l: 'Traffic (GA4)' }, { v: 'gsc', l: 'Search (GSC)' }].map(b => {
          const active = b.v === tab;
          return (
            <button key={b.v} onClick={() => setTab(b.v)}
              style={{
                padding: '8px 14px', borderRadius: 100, cursor: 'pointer',
                background: active ? CC.ink : CC.white,
                color: active ? CC.white : CC.deep,
                border: '1px solid ' + (active ? CC.ink : CC.border),
                fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600,
              }}>{b.l}</button>
          );
        })}
      </div>

      {loading && <Loading msg="Loading traffic…" />}
      {error && <ErrorState msg={error} onRetry={load} />}

      {!loading && !error && tab === 'ga4' && (
        <div>
          <NotConfigured src="GA4 Data API" payload={ga4} />
          {ga4 && ga4.configured && (
            <>
              <Panel style={{ marginBottom: 14 }}>
                <div style={{ display: 'flex', flexWrap: 'wrap', gap: 28, marginBottom: 6 }}>
                  <div><Sans size={10} weight={700} ls={0.14} upper color={CC.mid}>Sessions</Sans><Display size={28} weight={500}>{fmt(ga4.totals?.sessions)}</Display></div>
                  <div><Sans size={10} weight={700} ls={0.14} upper color={CC.mid}>Active users</Sans><Display size={28} weight={500}>{fmt(ga4.totals?.activeUsers)}</Display></div>
                  <div><Sans size={10} weight={700} ls={0.14} upper color={CC.mid}>Pageviews</Sans><Display size={28} weight={500}>{fmt(ga4.totals?.screenPageViews)}</Display></div>
                </div>
                <Sparkline data={ga4.daily} valueKey="sessions" color={CC.ink} />
                <Sans size={10} color={CC.soft} style={{ marginTop: 4 }}>Daily sessions · last {windowSel}</Sans>
              </Panel>

              <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14, marginBottom: 14 }}>
                <Panel>
                  <Sans size={10} weight={700} ls={0.14} upper color={CC.mid} style={{ marginBottom: 8 }}>Top pages</Sans>
                  {(ga4.top_pages || []).slice(0, 12).map((r, i) => (
                    <div key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: '4px 0', borderBottom: i < 11 ? '1px solid ' + CC.hairline : '', gap: 10 }}>
                      <Sans size={11} color={CC.ink} style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.pagePath}</Sans>
                      <Sans size={11} weight={600} color={CC.deep}>{fmt(r.screenPageViews)}</Sans>
                    </div>
                  ))}
                </Panel>
                <Panel>
                  <Sans size={10} weight={700} ls={0.14} upper color={CC.mid} style={{ marginBottom: 8 }}>Top channels</Sans>
                  {(ga4.top_channels || []).map((r, i) => (
                    <div key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: '4px 0', borderBottom: i < (ga4.top_channels.length - 1) ? '1px solid ' + CC.hairline : '' }}>
                      <Sans size={11} color={CC.ink}>{r.sessionDefaultChannelGroup || '(unset)'}</Sans>
                      <Sans size={11} weight={600} color={CC.deep}>{fmt(r.sessions)}</Sans>
                    </div>
                  ))}
                </Panel>
              </div>

              {(ga4.conversions || []).length > 0 && (
                <Panel>
                  <Sans size={10} weight={700} ls={0.14} upper color={CC.mid} style={{ marginBottom: 8 }}>Conversion events (booking funnel)</Sans>
                  {(ga4.conversions || []).map((r, i) => (
                    <div key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: '4px 0', borderBottom: i < (ga4.conversions.length - 1) ? '1px solid ' + CC.hairline : '' }}>
                      <Sans size={11} color={CC.ink}>{r.eventName}</Sans>
                      <Sans size={11} weight={600} color={CC.deep}>{fmt(r.eventCount)}</Sans>
                    </div>
                  ))}
                </Panel>
              )}
            </>
          )}
        </div>
      )}

      {!loading && !error && tab === 'gsc' && (
        <div>
          <NotConfigured src="Search Console API" payload={gsc} />
          {gsc && gsc.configured && (
            <>
              <Panel style={{ marginBottom: 14 }}>
                <div style={{ display: 'flex', flexWrap: 'wrap', gap: 28, marginBottom: 6 }}>
                  <div><Sans size={10} weight={700} ls={0.14} upper color={CC.mid}>Impressions</Sans><Display size={28} weight={500}>{fmt(gsc.totals?.impressions)}</Display></div>
                  <div><Sans size={10} weight={700} ls={0.14} upper color={CC.mid}>Clicks</Sans><Display size={28} weight={500}>{fmt(gsc.totals?.clicks)}</Display></div>
                  <div><Sans size={10} weight={700} ls={0.14} upper color={CC.mid}>CTR</Sans><Display size={28} weight={500}>{pct(gsc.totals?.ctr)}</Display></div>
                </div>
                <Sparkline data={gsc.daily} valueKey="impressions" color={CC.ink} />
                <Sans size={10} color={CC.soft} style={{ marginTop: 4 }}>Daily impressions · {gsc.start_date} → {gsc.end_date}</Sans>
              </Panel>

              <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
                <Panel>
                  <Sans size={10} weight={700} ls={0.14} upper color={CC.mid} style={{ marginBottom: 8 }}>Top search queries</Sans>
                  <div style={{ display: 'grid', gridTemplateColumns: '1fr 60px 50px 50px', gap: 8, fontSize: 10, color: CC.mid, paddingBottom: 6, borderBottom: '1px solid ' + CC.hairline }}>
                    <span>Query</span><span style={{ textAlign: 'right' }}>Impr.</span><span style={{ textAlign: 'right' }}>Clicks</span><span style={{ textAlign: 'right' }}>Pos.</span>
                  </div>
                  {(gsc.top_queries || []).map((r, i) => (
                    <div key={i} style={{ display: 'grid', gridTemplateColumns: '1fr 60px 50px 50px', gap: 8, padding: '4px 0', fontSize: 11, borderBottom: i < (gsc.top_queries.length - 1) ? '1px solid ' + CC.hairline : '' }}>
                      <span style={{ color: CC.ink, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.query}</span>
                      <span style={{ textAlign: 'right', color: CC.deep }}>{fmt(r.impressions)}</span>
                      <span style={{ textAlign: 'right', color: CC.deep, fontWeight: 600 }}>{fmt(r.clicks)}</span>
                      <span style={{ textAlign: 'right', color: CC.mid }}>{posn(r.position)}</span>
                    </div>
                  ))}
                </Panel>
                <Panel>
                  <Sans size={10} weight={700} ls={0.14} upper color={CC.mid} style={{ marginBottom: 8 }}>Top landing pages</Sans>
                  <div style={{ display: 'grid', gridTemplateColumns: '1fr 60px 50px 50px', gap: 8, fontSize: 10, color: CC.mid, paddingBottom: 6, borderBottom: '1px solid ' + CC.hairline }}>
                    <span>Page</span><span style={{ textAlign: 'right' }}>Impr.</span><span style={{ textAlign: 'right' }}>Clicks</span><span style={{ textAlign: 'right' }}>Pos.</span>
                  </div>
                  {(gsc.top_pages || []).map((r, i) => {
                    let path = r.page || '';
                    try { path = new URL(path).pathname || '/'; } catch { /* keep as-is */ }
                    return (
                      <div key={i} style={{ display: 'grid', gridTemplateColumns: '1fr 60px 50px 50px', gap: 8, padding: '4px 0', fontSize: 11, borderBottom: i < (gsc.top_pages.length - 1) ? '1px solid ' + CC.hairline : '' }}>
                        <span style={{ color: CC.ink, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{path}</span>
                        <span style={{ textAlign: 'right', color: CC.deep }}>{fmt(r.impressions)}</span>
                        <span style={{ textAlign: 'right', color: CC.deep, fontWeight: 600 }}>{fmt(r.clicks)}</span>
                        <span style={{ textAlign: 'right', color: CC.mid }}>{posn(r.position)}</span>
                      </div>
                    );
                  })}
                </Panel>
              </div>
            </>
          )}
        </div>
      )}
    </div>
  );
}

// ── AdminWeeklyDigest (v141.244) ────────────────────────────────────
// Phase 2 of the Aetheria Intelligence roadmap. Shows the latest
// auto-generated weekly digest produced by /api/cron/aetheria-weekly-
// digest every Monday 00:00 UTC (08:00 WITA). Operator can:
//   • Browse previous weeks via the dropdown
//   • Click "Generate now" to regenerate the most recent week
//   • Click "Generate + email" to also send the Resend email
// All sub-sections (wins, problems, actions, watch list) come from
// digest_json — the structured AI output, NOT raw markdown.
// ────────────────────────────────────────────────────────────────────
function AdminWeeklyDigest({ onBack }) {
  const [items, setItems]       = useState(null);
  const [activeIdx, setActiveIdx] = useState(0);
  const [loading, setLoading]   = useState(true);
  const [error, setError]       = useState(null);
  const [generating, setGenerating] = useState(false);
  const [genStatus, setGenStatus] = useState(null);

  const adminToken = () => {
    try { return localStorage.getItem('tlb_admin_token') || localStorage.getItem('tlb_token') || ''; }
    catch { return ''; }
  };

  const load = React.useCallback(() => {
    setLoading(true); setError(null);
    const t = adminToken();
    if (!t) { setError('Not signed in as admin — re-open from /login.'); setLoading(false); return; }
    fetch('/api/admin/weekly-digest-list?limit=12', { headers: { Authorization: 'Bearer ' + t } })
      .then(r => r.json())
      .then(j => {
        if (j && j.ok && Array.isArray(j.items)) {
          setItems(j.items);
          setActiveIdx(0);
        } else {
          setError(j && j.error ? j.error : 'No digests yet — click "Generate now" to create the first one.');
          setItems([]);
        }
      })
      .catch(e => setError(e.message || 'Fetch failed'))
      .finally(() => setLoading(false));
  }, []);

  useEffect(() => { load(); }, [load]);

  async function generate({ sendEmail = false } = {}) {
    if (generating) return;
    setGenerating(true); setGenStatus(null);
    const t = adminToken();
    try {
      const r = await fetch('/api/admin/weekly-digest-run' + (sendEmail ? '?email=1' : ''), {
        method: 'POST',
        headers: { Authorization: 'Bearer ' + t, 'Content-Type': 'application/json' },
        body: '{}',
      });
      const j = await r.json();
      if (j.ok) {
        setGenStatus({ tone: 'ok', text: sendEmail
          ? 'Digest regenerated + emailed to ' + (j.email?.redirectedTo || j.email?.to || 'recipient') + '.'
          : 'Digest regenerated — refresh to see the new version below.' });
        load();
      } else {
        // v141.245 — Surface the diagnostic hint (when present) so the
        // operator sees the actionable next step, not the raw API error.
        const hint = j.diagnostic && j.diagnostic.hint ? '\n\nNext step: ' + j.diagnostic.hint : '';
        setGenStatus({ tone: 'err', text: 'Generation failed: ' + (j.error || j.error_detail || 'unknown') + hint });
      }
    } catch (e) {
      setGenStatus({ tone: 'err', text: 'Generation failed: ' + (e.message || e) });
    } finally {
      setGenerating(false);
    }
  }

  const active = items && items[activeIdx] ? items[activeIdx] : null;
  const d      = active && active.digest_json ? active.digest_json : null;
  const wins     = d && Array.isArray(d.wins) ? d.wins : [];
  const problems = d && Array.isArray(d.problems) ? d.problems : [];
  const actions  = d && Array.isArray(d.actions) ? d.actions : [];
  const watch    = d && Array.isArray(d.watch_list) ? d.watch_list : [];

  return (
    <div>
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12, marginBottom: dashGap() }}>
        {onBack && (
          <button onClick={onBack}
            style={{ padding: '8px 16px', borderRadius: 100, cursor: 'pointer', background: CC.white, color: CC.deep, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600 }}>
            ← Back
          </button>
        )}
        <div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', marginLeft: 'auto' }}>
          {items && items.length > 1 && (
            <select value={activeIdx} onChange={e => setActiveIdx(Number(e.target.value))}
              style={{ padding: '7px 12px', borderRadius: 100, border: '1px solid ' + CC.border, background: CC.white, color: CC.deep, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600, cursor: 'pointer' }}>
              {items.map((it, i) => (
                <option key={it.id} value={i}>
                  {it.week_starts_on} → {it.week_ends_on}
                </option>
              ))}
            </select>
          )}
          <button onClick={() => generate({ sendEmail: false })} disabled={generating}
            style={{ padding: '8px 14px', borderRadius: 100, cursor: generating ? 'wait' : 'pointer', background: CC.white, color: CC.deep, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600, opacity: generating ? 0.6 : 1 }}>
            {generating ? 'Generating…' : '↻ Regenerate'}
          </button>
          <button onClick={() => generate({ sendEmail: true })} disabled={generating}
            style={{ padding: '8px 16px', borderRadius: 100, cursor: generating ? 'wait' : 'pointer', background: CC.ink, color: CC.white, border: '1px solid ' + CC.ink, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600, opacity: generating ? 0.6 : 1 }}>
            Generate + email
          </button>
        </div>
      </div>

      <Panel pad={14} style={{ marginBottom: dashGap(), background: CC.offWhite, borderColor: CC.border }}>
        <Sans size={12} color={CC.deep} style={{ lineHeight: 1.6 }}>
          <strong>Weekly Intelligence Digest.</strong> Aetheria reads GA4, Search Console, bookings, and enquiries every Monday 08:00 WITA and surfaces 3 wins, 3 problems, and 3 recommended actions for the week. Operator email goes to <code>info@theluxuryleisure.com</code>.
        </Sans>
      </Panel>

      {genStatus && (
        <Panel pad={12} style={{
          marginBottom: 14,
          background: genStatus.tone === 'ok' ? '#eef8f1' : '#fdecec',
          borderColor: genStatus.tone === 'ok' ? '#c6e6cf' : '#f1c2c2',
        }}>
          <Sans size={12} color={genStatus.tone === 'ok' ? '#16613a' : '#8a1f1f'} style={{ lineHeight: 1.55, whiteSpace: 'pre-wrap' }}>
            {genStatus.text}
          </Sans>
        </Panel>
      )}

      {loading && <Loading msg="Loading digest…" />}
      {!loading && error && !items?.length && (
        <Panel pad={14} style={{ marginBottom: 14, background: '#fff8ec', borderColor: '#e8d9a3' }}>
          <Sans size={11} weight={700} ls={0.14} upper color="#8a5a00" style={{ marginBottom: 6 }}>
            No digest yet
          </Sans>
          <Sans size={12} color={CC.deep} style={{ lineHeight: 1.6 }}>
            {error} The first cron run is the next Monday at 08:00 WITA — or click <strong>Regenerate</strong> above to build one now.
          </Sans>
        </Panel>
      )}

      {!loading && active && (
        <>
          {/* Headline + meta */}
          <Panel style={{ marginBottom: 14 }}>
            <Sans size={10} weight={700} ls={0.16} upper color={CC.mid} style={{ marginBottom: 6 }}>
              {active.week_starts_on} → {active.week_ends_on}
              {active.status && active.status !== 'ok' && (
                <span style={{ marginLeft: 10, color: '#a04545' }}>· {active.status}</span>
              )}
            </Sans>
            {d?.headline && (
              <Display size={22} weight={500} style={{ lineHeight: 1.35 }}>
                {d.headline}
              </Display>
            )}
            <Sans size={10} color={CC.soft} style={{ marginTop: 10 }}>
              Generated {new Date(active.generated_at).toLocaleString()} · model: {active.model_used || '—'}
              {active.email_sent_at ? ' · emailed ' + new Date(active.email_sent_at).toLocaleString() : ''}
            </Sans>
          </Panel>

          {active.error_detail && (
            <Panel pad={12} style={{ marginBottom: 14, background: '#fff8ec', borderColor: '#e8d9a3' }}>
              <Sans size={11} color={CC.deep} style={{ lineHeight: 1.55 }}>
                <strong>Partial data:</strong> {active.error_detail}
              </Sans>
            </Panel>
          )}

          <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14, marginBottom: 14 }}>
            <Panel>
              <Sans size={10} weight={700} ls={0.14} upper color="#2a8a4d" style={{ marginBottom: 10 }}>
                Wins · {wins.length}
              </Sans>
              {wins.length === 0 && (
                <Sans size={12} color={CC.soft}>No notable wins this week.</Sans>
              )}
              {wins.map((w, i) => (
                <div key={i} style={{ padding: '10px 0', borderBottom: i < wins.length - 1 ? '1px solid ' + CC.hairline : '' }}>
                  <div style={{ display: 'flex', justifyContent: 'space-between', gap: 10, alignItems: 'baseline' }}>
                    <Sans size={13} weight={600} color={CC.ink}>{w.title}</Sans>
                    {w.metric && <Sans size={11} weight={700} color="#2a8a4d">{w.metric}</Sans>}
                  </div>
                  {w.detail && (
                    <Sans size={12} color={CC.deep} style={{ marginTop: 4, lineHeight: 1.55 }}>{w.detail}</Sans>
                  )}
                </div>
              ))}
            </Panel>
            <Panel>
              <Sans size={10} weight={700} ls={0.14} upper color="#a04545" style={{ marginBottom: 10 }}>
                Problems · {problems.length}
              </Sans>
              {problems.length === 0 && (
                <Sans size={12} color={CC.soft}>No concerning drops this week.</Sans>
              )}
              {problems.map((p, i) => (
                <div key={i} style={{ padding: '10px 0', borderBottom: i < problems.length - 1 ? '1px solid ' + CC.hairline : '' }}>
                  <div style={{ display: 'flex', justifyContent: 'space-between', gap: 10, alignItems: 'baseline' }}>
                    <Sans size={13} weight={600} color={CC.ink}>{p.title}</Sans>
                    {p.metric && <Sans size={11} weight={700} color="#a04545">{p.metric}</Sans>}
                  </div>
                  {p.detail && (
                    <Sans size={12} color={CC.deep} style={{ marginTop: 4, lineHeight: 1.55 }}>{p.detail}</Sans>
                  )}
                </div>
              ))}
            </Panel>
          </div>

          {actions.length > 0 && (
            <Panel style={{ marginBottom: 14 }}>
              <Sans size={10} weight={700} ls={0.14} upper color="#9a7d3f" style={{ marginBottom: 10 }}>
                Recommended actions · {actions.length}
              </Sans>
              {actions.map((a, i) => {
                // v141.247 — Classify action by keyword so we can offer
                // the right "Open in panel" deep-link. Analytics/GTM work
                // needs code changes so we mark it as a manual task
                // rather than offering a non-existent admin panel.
                const text = ((a.title || '') + ' ' + (a.rationale || '')).toLowerCase();
                let target;
                if (/\b(ga4|gtm|google analytics|tag manager|tracking|event|conversion funnel|datalayer|utm)\b/.test(text)) {
                  target = { section: null, label: 'Manual code task', disabled: true };
                } else if (/\b(rank|ranking|serp|meta|title tag|h1|schema|markup|search visibility|keyword|position|page 2|first page)\b/.test(text)) {
                  target = { section: 'seo', label: 'Open SEO control' };
                } else if (/\b(content|copy|rewrite|page|blog|article|description|body|homepage|hero|yacht|villa)\b/.test(text)) {
                  target = { section: 'sitemanager', label: 'Open Page CMS' };
                } else {
                  target = { section: 'seo', label: 'Open SEO control' };
                }

                // Build a self-contained context blob for the "Ask
                // Aetheria" pre-fill. Aetheria reads this on chat
                // mount, sets the input, then the operator clicks Ask.
                const prefillText =
                  `Action from weekly digest (${active.week_starts_on} → ${active.week_ends_on}):\n\n` +
                  `**${a.title || ''}**\n\n` +
                  (a.rationale ? a.rationale + '\n\n' : '') +
                  `Please audit the relevant page/property using your read tools, then propose a specific fix with confirmation. ` +
                  `Use propose_action for any write (you have seo_update_homepage, seo_update_property, and cms_update_page available).`;

                function askAetheria() {
                  try { sessionStorage.setItem('tlb_aetheria_prefill', prefillText); } catch {}
                  window.location.href = '/admin?section=aetheria';
                }
                function openPanel() {
                  if (target.disabled || !target.section) return;
                  window.location.href = '/admin?section=' + encodeURIComponent(target.section);
                }

                return (
                  <div key={i} style={{ padding: '12px 0', borderBottom: i < actions.length - 1 ? '1px solid ' + CC.hairline : '' }}>
                    <div style={{ display: 'flex', justifyContent: 'space-between', gap: 10, alignItems: 'baseline' }}>
                      <Sans size={13} weight={600} color={CC.ink}>{a.title}</Sans>
                      <Sans size={9} weight={700} ls={0.14} upper color={
                        a.effort === 'low' ? '#2a8a4d'
                        : a.effort === 'high' ? '#a04545'
                        : '#9a7d3f'
                      }>{a.effort || 'medium'}</Sans>
                    </div>
                    {a.rationale && (
                      <Sans size={12} color={CC.deep} style={{ marginTop: 4, lineHeight: 1.55 }}>{a.rationale}</Sans>
                    )}
                    <div style={{ marginTop: 10, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
                      <button onClick={openPanel} disabled={target.disabled}
                        style={{
                          padding: '6px 14px', borderRadius: 100,
                          cursor: target.disabled ? 'not-allowed' : 'pointer',
                          background: CC.white, color: target.disabled ? CC.soft : CC.deep,
                          border: '1px solid ' + (target.disabled ? CC.hairline : CC.border),
                          fontFamily: F_TLB.ui, fontSize: 11, fontWeight: 600,
                          opacity: target.disabled ? 0.6 : 1,
                        }}>
                        {target.label}
                      </button>
                      <button onClick={askAetheria}
                        style={{
                          padding: '6px 14px', borderRadius: 100, cursor: 'pointer',
                          background: '#fbf8f0', color: '#7a5a1f',
                          border: '1px solid #d6c389',
                          fontFamily: F_TLB.ui, fontSize: 11, fontWeight: 600,
                        }}>
                        ✨ Ask Aetheria
                      </button>
                    </div>
                  </div>
                );
              })}
            </Panel>
          )}

          {watch.length > 0 && (
            <Panel style={{ marginBottom: 14, background: CC.offWhite }}>
              <Sans size={10} weight={700} ls={0.14} upper color={CC.mid} style={{ marginBottom: 8 }}>
                Watch list
              </Sans>
              <ul style={{ margin: 0, paddingLeft: 18 }}>
                {watch.map((w, i) => (
                  <li key={i} style={{ fontFamily: F_TLB.ui, fontSize: 12, color: CC.deep, lineHeight: 1.7 }}>{w}</li>
                ))}
              </ul>
            </Panel>
          )}

          {active.tokens_used && (
            <Sans size={10} color={CC.soft} style={{ marginTop: 6 }}>
              Tokens: input {active.tokens_used.input_tokens || 0} · output {active.tokens_used.output_tokens || 0}
              {active.tokens_used.cache_read_input_tokens ? ' · cache read ' + active.tokens_used.cache_read_input_tokens : ''}
            </Sans>
          )}
        </>
      )}
    </div>
  );
}

// ── AdminAlerts (v141.255) ──────────────────────────────────────────
// Phase 4 of the Aetheria Intelligence roadmap. Shows undismissed
// anomaly alerts detected by /api/cron/anomaly-scan (CTR drops,
// position jumps, traffic spikes/drops, new query opportunities).
// Operator can dismiss individually or in batch; dismissed rows stay
// in DB for audit but disappear from the open list.
// ────────────────────────────────────────────────────────────────────
function AdminAlerts({ onBack }) {
  const [items, setItems]       = useState(null);
  const [counts, setCounts]     = useState(null);
  const [loading, setLoading]   = useState(true);
  const [error, setError]       = useState(null);
  const [severity, setSeverity] = useState('all');
  const [state, setState]       = useState('open');
  const [dismissing, setDismissing] = useState(false);

  const adminToken = () => {
    try { return localStorage.getItem('tlb_admin_token') || localStorage.getItem('tlb_token') || ''; }
    catch { return ''; }
  };

  const load = React.useCallback(() => {
    setLoading(true); setError(null);
    const t = adminToken();
    if (!t) { setError('Not signed in as admin — re-open from /login.'); setLoading(false); return; }
    fetch('/api/admin/alerts-list?limit=50&severity=' + severity + '&state=' + state,
      { headers: { Authorization: 'Bearer ' + t } })
      .then(r => r.json())
      .then(j => {
        if (j && j.ok) {
          setItems(j.items || []);
          setCounts(j.counts || null);
        } else {
          setError(j && j.error ? j.error : 'Failed to load alerts');
          setItems([]);
        }
      })
      .catch(e => setError(e.message || 'Fetch failed'))
      .finally(() => setLoading(false));
  }, [severity, state]);

  useEffect(() => { load(); }, [load]);

  async function dismissOne(id) {
    if (dismissing) return;
    setDismissing(true);
    const t = adminToken();
    try {
      const r = await fetch('/api/admin/alerts-dismiss', {
        method: 'POST',
        headers: { Authorization: 'Bearer ' + t, 'Content-Type': 'application/json' },
        body: JSON.stringify({ id }),
      });
      if (r.ok) load();
    } finally { setDismissing(false); }
  }

  async function dismissAll() {
    if (!items || !items.length) return;
    if (!confirm('Dismiss all ' + items.length + ' visible alerts? They stay in audit log but disappear from open list.')) return;
    if (dismissing) return;
    setDismissing(true);
    const t = adminToken();
    try {
      const ids = items.map(i => i.id).filter(Boolean);
      const r = await fetch('/api/admin/alerts-dismiss', {
        method: 'POST',
        headers: { Authorization: 'Bearer ' + t, 'Content-Type': 'application/json' },
        body: JSON.stringify({ ids }),
      });
      if (r.ok) load();
    } finally { setDismissing(false); }
  }

  const fmtDelta = (n) => {
    if (n == null || isNaN(n)) return '';
    const v = Number(n);
    const s = v > 0 ? '+' : '';
    return s + v.toFixed(1) + '%';
  };
  const fmtWhen = (iso) => {
    try { return new Date(iso).toLocaleString(); } catch { return iso; }
  };
  const sevColour = (s) => s === 'bad' ? '#a04545' : s === 'good' ? '#2a8a4d' : s === 'opportunity' ? '#9a7d3f' : CC.mid;
  const sevLabel  = (s) => s === 'bad' ? 'Action' : s === 'good' ? 'Win' : s === 'opportunity' ? 'Opportunity' : s;

  return (
    <div>
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12, marginBottom: dashGap() }}>
        {onBack && (
          <button onClick={onBack}
            style={{ padding: '8px 16px', borderRadius: 100, cursor: 'pointer', background: CC.white, color: CC.deep, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600 }}>
            ← Back
          </button>
        )}
        <div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', marginLeft: 'auto' }}>
          {['all', 'bad', 'good', 'opportunity'].map(s => (
            <button key={s} onClick={() => setSeverity(s)}
              style={{
                padding: '6px 12px', borderRadius: 100, cursor: 'pointer',
                background: s === severity ? CC.ink : CC.white,
                color: s === severity ? CC.white : CC.deep,
                border: '1px solid ' + (s === severity ? CC.ink : CC.border),
                fontFamily: F_TLB.ui, fontSize: 11, fontWeight: 600,
              }}>
              {s === 'all' ? 'All' : sevLabel(s)}
            </button>
          ))}
          <button onClick={() => setState(state === 'open' ? 'all' : 'open')}
            style={{ padding: '6px 12px', borderRadius: 100, cursor: 'pointer', background: CC.white, color: CC.deep, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 11, fontWeight: 600 }}>
            {state === 'open' ? 'Showing open' : 'Showing all'}
          </button>
          <button onClick={load} disabled={loading}
            style={{ padding: '8px 14px', borderRadius: 100, cursor: 'pointer', background: CC.white, color: CC.deep, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600 }}>
            ↻ Refresh
          </button>
          {items && items.length > 0 && state === 'open' && (
            <button onClick={dismissAll} disabled={dismissing}
              style={{ padding: '8px 14px', borderRadius: 100, cursor: dismissing ? 'wait' : 'pointer', background: CC.white, color: '#a04545', border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 11, fontWeight: 600 }}>
              Dismiss all ({items.length})
            </button>
          )}
        </div>
      </div>

      <Panel pad={14} style={{ marginBottom: dashGap(), background: CC.offWhite, borderColor: CC.border }}>
        <Sans size={12} color={CC.deep} style={{ lineHeight: 1.6 }}>
          <strong>Aetheria Anomaly Alerts.</strong> Cron scans GA4 + GSC every 6 hours and surfaces anomalies vs the 7-day baseline. Bad = action required (CTR drops, position regressions). Wins = position jumps, traffic spikes. Opportunities = new ranking queries with low CTR (content gaps).
          {counts && (
            <span style={{ display: 'block', marginTop: 6, color: CC.mid }}>
              Open: <strong style={{ color: '#a04545' }}>{counts.bad || 0} action</strong> · <strong style={{ color: '#2a8a4d' }}>{counts.good || 0} wins</strong> · <strong style={{ color: '#9a7d3f' }}>{counts.opportunity || 0} opportunities</strong>
            </span>
          )}
        </Sans>
      </Panel>

      {loading && <Loading msg="Loading alerts…" />}
      {!loading && error && (
        <Panel pad={14} style={{ marginBottom: 14, background: '#fff8ec', borderColor: '#e8d9a3' }}>
          <Sans size={12} color={CC.deep} style={{ lineHeight: 1.6 }}>{error}</Sans>
        </Panel>
      )}
      {!loading && items && items.length === 0 && !error && (
        <Panel pad={20} style={{ marginBottom: 14, textAlign: 'center' }}>
          <Sans size={13} color={CC.mid}>No {state === 'open' ? 'open' : ''} alerts {severity === 'all' ? '' : 'matching "' + sevLabel(severity) + '"'}. The cron runs every 6h — first scan may take 24h to produce baseline data.</Sans>
        </Panel>
      )}

      {!loading && items && items.length > 0 && (
        <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
          {items.map((a) => (
            <Panel key={a.id} pad={14} style={{
              borderColor: a.dismissed_at ? CC.hairline : sevColour(a.severity) + '40',
              background: a.dismissed_at ? CC.offWhite : '#fff',
              opacity: a.dismissed_at ? 0.65 : 1,
            }}>
              <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12, flexWrap: 'wrap' }}>
                <div style={{ minWidth: 0, flex: 1 }}>
                  <div style={{ display: 'flex', gap: 8, alignItems: 'baseline', flexWrap: 'wrap', marginBottom: 6 }}>
                    <Sans size={10} weight={700} ls={0.14} upper color={sevColour(a.severity)}>
                      {sevLabel(a.severity)}
                    </Sans>
                    <Sans size={10} color={CC.mid} style={{ fontFamily: 'ui-monospace,SFMono-Regular,Menlo,monospace' }}>
                      {a.alert_type}
                    </Sans>
                    <Sans size={10} color={CC.soft}>
                      {a.target_type}
                    </Sans>
                    {a.delta_pct != null && (
                      <Sans size={10} weight={700} color={sevColour(a.severity)}>
                        {fmtDelta(a.delta_pct)}
                      </Sans>
                    )}
                    <Sans size={10} color={CC.soft} style={{ marginLeft: 'auto' }}>
                      {fmtWhen(a.detected_at)}
                    </Sans>
                  </div>
                  <Sans size={13} weight={600} color={CC.ink} style={{ marginBottom: 4, wordBreak: 'break-word' }}>
                    {a.target}
                  </Sans>
                  <Sans size={12} color={CC.deep} style={{ lineHeight: 1.55, marginBottom: 6 }}>
                    {a.message}
                  </Sans>
                  {a.recommendation && (
                    <Sans size={11} color={CC.mid} style={{ lineHeight: 1.55, fontStyle: 'italic' }}>
                      → {a.recommendation}
                    </Sans>
                  )}
                  {a.dismissed_at && (
                    <Sans size={10} color={CC.soft} style={{ marginTop: 6 }}>
                      Dismissed {fmtWhen(a.dismissed_at)} by {a.dismissed_by || 'admin'}
                    </Sans>
                  )}
                </div>
                {!a.dismissed_at && (
                  <button onClick={() => dismissOne(a.id)} disabled={dismissing}
                    style={{ padding: '6px 12px', borderRadius: 100, cursor: dismissing ? 'wait' : 'pointer', background: CC.white, color: CC.deep, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 11, fontWeight: 600, flexShrink: 0 }}>
                    Dismiss
                  </button>
                )}
              </div>
            </Panel>
          ))}
        </div>
      )}
    </div>
  );
}

// ── AdminContentOpportunities (v141.257) ────────────────────────────
// Phase 5 of the Aetheria Intelligence roadmap. Shows AI-clustered
// blog post recommendations generated weekly from GSC opportunity
// queries. Operator can mark in-progress / published / dismissed.
function AdminContentOpportunities({ onBack }) {
  const [items, setItems]     = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError]     = useState(null);
  const [statusFilter, setStatusFilter] = useState('pending');
  const [scanning, setScanning] = useState(false);
  const [genStatus, setGenStatus] = useState(null);
  const [expanded, setExpanded] = useState({});  // id → bool

  const adminToken = () => {
    try { return localStorage.getItem('tlb_admin_token') || localStorage.getItem('tlb_token') || ''; }
    catch { return ''; }
  };

  const load = React.useCallback(() => {
    setLoading(true); setError(null);
    const t = adminToken();
    if (!t) { setError('Not signed in.'); setLoading(false); return; }
    fetch('/api/admin/content-opportunities-list?status=' + statusFilter + '&limit=30',
      { headers: { Authorization: 'Bearer ' + t } })
      .then(r => r.json())
      .then(j => {
        if (j && j.ok) setItems(j.items || []);
        else setError(j?.error || 'Failed to load');
      })
      .catch(e => setError(e.message || 'Fetch failed'))
      .finally(() => setLoading(false));
  }, [statusFilter]);

  useEffect(() => { load(); }, [load]);

  async function scanNow() {
    if (scanning) return;
    setScanning(true); setGenStatus(null);
    const t = adminToken();
    try {
      const r = await fetch('/api/admin/content-opportunities-run', {
        method: 'POST',
        headers: { Authorization: 'Bearer ' + t, 'Content-Type': 'application/json' },
        body: '{}',
      });
      const j = await r.json();
      if (j.status === 'ok') {
        setGenStatus({ tone: 'ok', text: `Scan complete — ${j.clusters_saved} content opportunities generated from ${j.opportunities_examined} GSC opportunity queries.` });
        load();
      } else if (j.status === 'no_opportunities') {
        setGenStatus({ tone: 'info', text: `No opportunity queries this scan (examined ${j.total_queries_examined} queries). Either GSC has thin data or all visible queries already perform well.` });
      } else {
        setGenStatus({ tone: 'err', text: 'Scan failed: ' + (j.error || 'unknown') });
      }
    } catch (e) {
      setGenStatus({ tone: 'err', text: 'Network error: ' + (e.message || e) });
    } finally { setScanning(false); }
  }

  async function updateOpp(id, patch) {
    const t = adminToken();
    const r = await fetch('/api/admin/content-opportunities-update', {
      method: 'POST',
      headers: { Authorization: 'Bearer ' + t, 'Content-Type': 'application/json' },
      body: JSON.stringify({ id, ...patch }),
    });
    if (r.ok) load();
  }

  const effortColour = (e) => e === 'low' ? '#2a8a4d' : e === 'high' ? '#a04545' : '#9a7d3f';

  return (
    <div>
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12, marginBottom: dashGap() }}>
        {onBack && (
          <button onClick={onBack}
            style={{ padding: '8px 16px', borderRadius: 100, cursor: 'pointer', background: CC.white, color: CC.deep, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600 }}>
            ← Back
          </button>
        )}
        <div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', marginLeft: 'auto' }}>
          {['pending', 'in_progress', 'published', 'dismissed', 'all'].map(s => (
            <button key={s} onClick={() => setStatusFilter(s)}
              style={{
                padding: '6px 12px', borderRadius: 100, cursor: 'pointer',
                background: s === statusFilter ? CC.ink : CC.white,
                color: s === statusFilter ? CC.white : CC.deep,
                border: '1px solid ' + (s === statusFilter ? CC.ink : CC.border),
                fontFamily: F_TLB.ui, fontSize: 11, fontWeight: 600,
              }}>{s.replace('_', ' ')}</button>
          ))}
          <button onClick={scanNow} disabled={scanning}
            style={{ padding: '8px 16px', borderRadius: 100, cursor: scanning ? 'wait' : 'pointer', background: CC.ink, color: CC.white, border: '1px solid ' + CC.ink, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600, opacity: scanning ? 0.6 : 1 }}>
            {scanning ? 'Scanning…' : '↻ Scan now'}
          </button>
        </div>
      </div>

      <Panel pad={14} style={{ marginBottom: dashGap(), background: CC.offWhite, borderColor: CC.border }}>
        <Sans size={12} color={CC.deep} style={{ lineHeight: 1.6 }}>
          <strong>Content Opportunity Engine.</strong> Weekly cron scans GSC for queries where TLB ranks page 2-3 (positions 11-30) with ≥30 impressions but CTR &lt;2%. Aetheria clusters them into pillar blog post recommendations. Write the post → rank page 1 → capture all clustered queries simultaneously.
        </Sans>
      </Panel>

      {genStatus && (
        <Panel pad={12} style={{
          marginBottom: 14,
          background: genStatus.tone === 'ok' ? '#eef8f1' : genStatus.tone === 'err' ? '#fdecec' : '#fff8ec',
          borderColor: genStatus.tone === 'ok' ? '#c6e6cf' : genStatus.tone === 'err' ? '#f1c2c2' : '#e8d9a3',
        }}>
          <Sans size={12} color={genStatus.tone === 'ok' ? '#16613a' : genStatus.tone === 'err' ? '#8a1f1f' : '#8a5a00'} style={{ lineHeight: 1.55 }}>
            {genStatus.text}
          </Sans>
        </Panel>
      )}

      {loading && <Loading msg="Loading opportunities…" />}
      {!loading && (!items || items.length === 0) && !error && (
        <Panel pad={20} style={{ textAlign: 'center' }}>
          <Sans size={13} color={CC.mid}>No content opportunities yet. Click "↻ Scan now" to run the engine, or wait for the weekly Monday 01:00 UTC cron.</Sans>
        </Panel>
      )}
      {!loading && error && (
        <Panel pad={14} style={{ background: '#fdecec', borderColor: '#f1c2c2' }}>
          <Sans size={12} color="#8a1f1f">{error}</Sans>
        </Panel>
      )}

      {!loading && items && items.length > 0 && (
        <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
          {items.map(o => {
            const isExp = !!expanded[o.id];
            return (
              <Panel key={o.id} pad={16} style={{ background: o.status === 'dismissed' ? CC.offWhite : '#fff', opacity: o.status === 'dismissed' ? 0.7 : 1 }}>
                <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12, flexWrap: 'wrap', marginBottom: 8 }}>
                  <div style={{ minWidth: 0, flex: 1 }}>
                    <div style={{ display: 'flex', gap: 8, alignItems: 'baseline', flexWrap: 'wrap', marginBottom: 4 }}>
                      <Sans size={10} weight={700} ls={0.14} upper color="#9a7d3f">{o.cluster_topic}</Sans>
                      <Sans size={9} weight={700} ls={0.14} upper color={effortColour(o.effort)}>{o.effort}</Sans>
                      <Sans size={10} color={CC.mid}>{o.word_count}w</Sans>
                      <Sans size={10} color={CC.soft} style={{ marginLeft: 'auto' }}>week {o.week}</Sans>
                    </div>
                    <Display size={18} weight={500} style={{ lineHeight: 1.35, marginBottom: 6 }}>
                      {o.suggested_title || o.cluster_topic}
                    </Display>
                    {o.rationale && (
                      <Sans size={12} color={CC.deep} style={{ lineHeight: 1.55, marginBottom: 8 }}>
                        {o.rationale}
                      </Sans>
                    )}
                    <Sans size={11} color={CC.mid} style={{ marginBottom: 4 }}>
                      <strong>Source queries ({(o.cluster_queries || []).length})</strong> · est {o.estimated_impressions} impr/month · target +{o.estimated_clicks_lift} clicks/month
                    </Sans>
                  </div>
                </div>

                {isExp && (
                  <div style={{ marginTop: 12, padding: 12, background: CC.offWhite, borderRadius: 8 }}>
                    {o.outline && o.outline.length > 0 && (
                      <div style={{ marginBottom: 12 }}>
                        <Sans size={10} weight={700} ls={0.14} upper color={CC.mid} style={{ marginBottom: 6 }}>Outline</Sans>
                        <ol style={{ margin: 0, paddingLeft: 18 }}>
                          {o.outline.map((h, i) => (
                            <li key={i} style={{ fontFamily: F_TLB.ui, fontSize: 12, color: CC.deep, lineHeight: 1.65 }}>{h}</li>
                          ))}
                        </ol>
                      </div>
                    )}
                    {o.target_keywords && o.target_keywords.length > 0 && (
                      <div style={{ marginBottom: 12 }}>
                        <Sans size={10} weight={700} ls={0.14} upper color={CC.mid} style={{ marginBottom: 6 }}>Target keywords</Sans>
                        <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
                          {o.target_keywords.map((k, i) => (
                            <span key={i} style={{ padding: '4px 10px', borderRadius: 100, background: '#fff', border: '1px solid ' + CC.hairline, fontFamily: F_TLB.ui, fontSize: 11, color: CC.deep }}>{k}</span>
                          ))}
                        </div>
                      </div>
                    )}
                    {o.cluster_queries && o.cluster_queries.length > 0 && (
                      <div>
                        <Sans size={10} weight={700} ls={0.14} upper color={CC.mid} style={{ marginBottom: 6 }}>Source queries (GSC)</Sans>
                        <div style={{ display: 'grid', gridTemplateColumns: '1fr 60px 50px 50px', gap: 6, fontSize: 10, color: CC.soft, paddingBottom: 4, borderBottom: '1px solid ' + CC.hairline }}>
                          <span>Query</span><span style={{ textAlign: 'right' }}>Impr</span><span style={{ textAlign: 'right' }}>Pos</span><span style={{ textAlign: 'right' }}>CTR</span>
                        </div>
                        {o.cluster_queries.slice(0, 12).map((q, i) => (
                          <div key={i} style={{ display: 'grid', gridTemplateColumns: '1fr 60px 50px 50px', gap: 6, padding: '3px 0', fontSize: 11, borderBottom: '1px solid ' + CC.hairline }}>
                            <span style={{ color: CC.ink, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{q.query}</span>
                            <span style={{ textAlign: 'right', color: CC.deep }}>{q.impressions}</span>
                            <span style={{ textAlign: 'right', color: CC.mid }}>{Number(q.position || 0).toFixed(1)}</span>
                            <span style={{ textAlign: 'right', color: CC.mid }}>{(Number(q.ctr || 0) * 100).toFixed(2)}%</span>
                          </div>
                        ))}
                      </div>
                    )}
                  </div>
                )}

                <div style={{ display: 'flex', gap: 8, marginTop: 10, flexWrap: 'wrap' }}>
                  <button onClick={() => setExpanded({ ...expanded, [o.id]: !isExp })}
                    style={{ padding: '6px 14px', borderRadius: 100, cursor: 'pointer', background: CC.white, color: CC.deep, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 11, fontWeight: 600 }}>
                    {isExp ? '↑ Collapse' : '↓ Outline + keywords + queries'}
                  </button>
                  {o.status === 'pending' && (
                    <>
                      <button onClick={() => updateOpp(o.id, { status: 'in_progress' })}
                        style={{ padding: '6px 14px', borderRadius: 100, cursor: 'pointer', background: CC.white, color: CC.deep, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 11, fontWeight: 600 }}>
                        Mark in-progress
                      </button>
                      <button onClick={() => updateOpp(o.id, { status: 'dismissed' })}
                        style={{ padding: '6px 14px', borderRadius: 100, cursor: 'pointer', background: CC.white, color: '#a04545', border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 11, fontWeight: 600 }}>
                        Dismiss
                      </button>
                    </>
                  )}
                  {o.status === 'in_progress' && (
                    <button onClick={() => {
                      const url = prompt('Published URL?');
                      if (url) updateOpp(o.id, { status: 'published', published_url: url });
                    }}
                      style={{ padding: '6px 14px', borderRadius: 100, cursor: 'pointer', background: CC.ink, color: CC.white, border: '1px solid ' + CC.ink, fontFamily: F_TLB.ui, fontSize: 11, fontWeight: 600 }}>
                      Mark published
                    </button>
                  )}
                </div>
              </Panel>
            );
          })}
        </div>
      )}
    </div>
  );
}

// ── AdminAttribution (v141.258) ─────────────────────────────────────
// Phase 5 of the Aetheria Intelligence roadmap. ROI per channel from
// the attribution columns captured by /public/js/attribution.js at
// enquiry+booking submit time.
function AdminAttribution({ onBack }) {
  const [data, setData]     = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError]   = useState(null);
  const [days, setDays]     = useState(90);

  const adminToken = () => {
    try { return localStorage.getItem('tlb_admin_token') || localStorage.getItem('tlb_token') || ''; }
    catch { return ''; }
  };

  const load = React.useCallback(() => {
    setLoading(true); setError(null);
    const t = adminToken();
    if (!t) { setError('Not signed in.'); setLoading(false); return; }
    fetch('/api/admin/attribution-roi?days=' + days,
      { headers: { Authorization: 'Bearer ' + t } })
      .then(r => r.json())
      .then(j => {
        if (j && j.ok) setData(j);
        else setError(j?.error || 'Failed to load');
      })
      .catch(e => setError(e.message || 'Fetch failed'))
      .finally(() => setLoading(false));
  }, [days]);

  useEffect(() => { load(); }, [load]);

  const fmtUsd = (n) => '$' + Number(n || 0).toLocaleString('en-US');
  const fmtPct = (n) => n == null ? '—' : (Number(n) * 100).toFixed(1) + '%';

  return (
    <div>
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12, marginBottom: dashGap() }}>
        {onBack && (
          <button onClick={onBack}
            style={{ padding: '8px 16px', borderRadius: 100, cursor: 'pointer', background: CC.white, color: CC.deep, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600 }}>
            ← Back
          </button>
        )}
        <div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', marginLeft: 'auto' }}>
          {[30, 90, 180, 365].map(d => (
            <button key={d} onClick={() => setDays(d)}
              style={{
                padding: '6px 12px', borderRadius: 100, cursor: 'pointer',
                background: d === days ? CC.ink : CC.white,
                color: d === days ? CC.white : CC.deep,
                border: '1px solid ' + (d === days ? CC.ink : CC.border),
                fontFamily: F_TLB.ui, fontSize: 11, fontWeight: 600,
              }}>{d}d</button>
          ))}
          <button onClick={load}
            style={{ padding: '8px 14px', borderRadius: 100, cursor: 'pointer', background: CC.white, color: CC.deep, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600 }}>
            ↻ Refresh
          </button>
        </div>
      </div>

      <Panel pad={14} style={{ marginBottom: dashGap(), background: CC.offWhite, borderColor: CC.border }}>
        <Sans size={12} color={CC.deep} style={{ lineHeight: 1.6 }}>
          <strong>Booking Attribution — ROI per channel.</strong> Each enquiry + booking is now tagged with the visitor's UTM source / medium / campaign + referrer + landing page (captured by <code>/js/attribution.js</code> at form submit). This table aggregates by source so you can see <em>which channel actually pays the bills</em>, not just which sends traffic.
        </Sans>
      </Panel>

      {loading && <Loading msg="Loading attribution data…" />}
      {!loading && error && (
        <Panel pad={14} style={{ background: '#fdecec', borderColor: '#f1c2c2' }}>
          <Sans size={12} color="#8a1f1f">{error}</Sans>
        </Panel>
      )}
      {!loading && data && (
        <>
          <Panel style={{ marginBottom: 14 }}>
            <div style={{ display: 'flex', flexWrap: 'wrap', gap: 28, marginBottom: 6 }}>
              <div><Sans size={10} weight={700} ls={0.14} upper color={CC.mid}>Bookings ({data.window_days}d)</Sans><Display size={28} weight={500}>{data.totals.bookings || 0}</Display></div>
              <div><Sans size={10} weight={700} ls={0.14} upper color={CC.mid}>Enquiries</Sans><Display size={28} weight={500}>{data.totals.enquiries || 0}</Display></div>
              <div><Sans size={10} weight={700} ls={0.14} upper color={CC.mid}>Value (USD)</Sans><Display size={28} weight={500}>{fmtUsd(data.totals.value_usd)}</Display></div>
              <div><Sans size={10} weight={700} ls={0.14} upper color={CC.mid}>Conv. rate</Sans><Display size={28} weight={500}>{data.totals.enquiries ? fmtPct(data.totals.bookings / data.totals.enquiries) : '—'}</Display></div>
            </div>
          </Panel>

          {data.channels && data.channels.length > 0 ? (
            <Panel>
              <Sans size={10} weight={700} ls={0.14} upper color={CC.mid} style={{ marginBottom: 10 }}>
                Channels · {data.channels.length}
              </Sans>
              <div style={{ display: 'grid', gridTemplateColumns: '1fr 80px 80px 80px 80px 80px', gap: 8, fontSize: 10, color: CC.mid, paddingBottom: 6, borderBottom: '1px solid ' + CC.hairline }}>
                <span>Source</span>
                <span style={{ textAlign: 'right' }}>Bookings</span>
                <span style={{ textAlign: 'right' }}>Enquiries</span>
                <span style={{ textAlign: 'right' }}>Conv.</span>
                <span style={{ textAlign: 'right' }}>Value USD</span>
                <span style={{ textAlign: 'right' }}>Avg/bkg</span>
              </div>
              {data.channels.map((c, i) => (
                <div key={i} style={{ display: 'grid', gridTemplateColumns: '1fr 80px 80px 80px 80px 80px', gap: 8, padding: '8px 0', fontSize: 12, borderBottom: i < data.channels.length - 1 ? '1px solid ' + CC.hairline : '' }}>
                  <span style={{ color: CC.ink, fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{c.source}</span>
                  <span style={{ textAlign: 'right', color: CC.deep, fontWeight: 600 }}>{c.bookings}</span>
                  <span style={{ textAlign: 'right', color: CC.mid }}>{c.enquiries}</span>
                  <span style={{ textAlign: 'right', color: c.conversion_rate != null && c.conversion_rate > 0.05 ? '#2a8a4d' : CC.mid }}>{fmtPct(c.conversion_rate)}</span>
                  <span style={{ textAlign: 'right', color: CC.deep, fontWeight: 600 }}>{fmtUsd(c.total_value_usd)}</span>
                  <span style={{ textAlign: 'right', color: CC.mid }}>{c.avg_value_per_booking_usd ? fmtUsd(c.avg_value_per_booking_usd) : '—'}</span>
                </div>
              ))}
            </Panel>
          ) : (
            <Panel pad={20} style={{ textAlign: 'center' }}>
              <Sans size={13} color={CC.mid}>No attribution data yet. Attribution captures from /js/attribution.js — visitors landing on TLB pages NOW will have UTM/referrer captured at their next enquiry. Allow 1-2 weeks for meaningful data accumulation.</Sans>
            </Panel>
          )}
        </>
      )}
    </div>
  );
}

// ── AdminBlogPosts (v141.263) ───────────────────────────────────────
// Phase 5 of the Aetheria Intelligence roadmap. Read-only list of
// published + draft blog posts. Editing/drafting/publishing happens
// via Aetheria chat (blog_publish action with confirmation card).
// Operator clicks "View" to open the live post in a new tab.
function AdminBlogPosts({ onBack }) {
  const [items, setItems]     = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError]     = useState(null);
  const [statusFilter, setStatusFilter] = useState('published');

  const adminToken = () => {
    try { return localStorage.getItem('tlb_admin_token') || localStorage.getItem('tlb_token') || ''; }
    catch { return ''; }
  };

  const load = React.useCallback(() => {
    setLoading(true); setError(null);
    const t = adminToken();
    if (!t) { setError('Not signed in.'); setLoading(false); return; }
    // Reuse the existing read-only blog API via a fetch wrapper. The
    // Aetheria tool exposes the same data through chat; for a dashboard
    // we want raw rows so use the /api/blog-render endpoint? That serves
    // HTML. Better: query Supabase indirectly via a thin admin endpoint
    // — but to keep scope tight, surface via the recent_alerts pattern
    // by calling Aetheria's read tool (overhead). For MVP, just call
    // Supabase directly through a small list endpoint.
    fetch('/api/admin/blog-list?status=' + statusFilter,
      { headers: { Authorization: 'Bearer ' + t } })
      .then(r => r.json())
      .then(j => {
        if (j && j.ok) setItems(j.items || []);
        else setError(j?.error || 'Failed to load');
      })
      .catch(e => setError(e.message || 'Fetch failed'))
      .finally(() => setLoading(false));
  }, [statusFilter]);

  useEffect(() => { load(); }, [load]);

  const fmt = (iso) => { try { return new Date(iso).toLocaleDateString(); } catch { return iso; } };

  return (
    <div>
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12, marginBottom: dashGap() }}>
        {onBack && (
          <button onClick={onBack}
            style={{ padding: '8px 16px', borderRadius: 100, cursor: 'pointer', background: CC.white, color: CC.deep, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600 }}>
            ← Back
          </button>
        )}
        <div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', marginLeft: 'auto' }}>
          {['published', 'draft', 'all'].map(s => (
            <button key={s} onClick={() => setStatusFilter(s)}
              style={{
                padding: '6px 12px', borderRadius: 100, cursor: 'pointer',
                background: s === statusFilter ? CC.ink : CC.white,
                color: s === statusFilter ? CC.white : CC.deep,
                border: '1px solid ' + (s === statusFilter ? CC.ink : CC.border),
                fontFamily: F_TLB.ui, fontSize: 11, fontWeight: 600,
              }}>{s}</button>
          ))}
          <button onClick={load}
            style={{ padding: '8px 14px', borderRadius: 100, cursor: 'pointer', background: CC.white, color: CC.deep, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600 }}>
            ↻ Refresh
          </button>
          <a href="/blog" target="_blank" rel="noopener"
            style={{ padding: '8px 16px', borderRadius: 100, background: CC.white, color: CC.deep, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600, textDecoration: 'none' }}>
            View /blog →
          </a>
        </div>
      </div>

      <Panel pad={14} style={{ marginBottom: dashGap(), background: CC.offWhite, borderColor: CC.border }}>
        <Sans size={12} color={CC.deep} style={{ lineHeight: 1.6 }}>
          <strong>Journal — published + drafts.</strong> Posts go live at <code>/blog/&lt;slug&gt;</code> on publish. To DRAFT or PUBLISH a new post, ask Aetheria — she can pull from Content backlog opportunities, draft the markdown, then propose <code>blog_publish</code> for your confirmation. Editing existing posts: same flow — Aetheria re-proposes with the same slug.
        </Sans>
      </Panel>

      {loading && <Loading msg="Loading blog posts…" />}
      {!loading && error && (
        <Panel pad={14} style={{ background: '#fdecec', borderColor: '#f1c2c2' }}>
          <Sans size={12} color="#8a1f1f">{error}</Sans>
        </Panel>
      )}
      {!loading && items && items.length === 0 && !error && (
        <Panel pad={24} style={{ textAlign: 'center' }}>
          <Sans size={13} color={CC.mid}>
            No {statusFilter !== 'all' ? statusFilter + ' ' : ''}posts yet. Open Aetheria and try: <code>"Draft blog post for opportunity X from /admin → Content backlog"</code>.
          </Sans>
        </Panel>
      )}

      {!loading && items && items.length > 0 && (
        <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
          {items.map(p => (
            <Panel key={p.id} pad={14}>
              <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12, flexWrap: 'wrap' }}>
                <div style={{ minWidth: 0, flex: 1 }}>
                  <div style={{ display: 'flex', gap: 8, alignItems: 'baseline', flexWrap: 'wrap', marginBottom: 4 }}>
                    <Sans size={10} weight={700} ls={0.14} upper color={p.status === 'published' ? '#2a8a4d' : p.status === 'draft' ? '#9a7d3f' : CC.mid}>
                      {p.status}
                    </Sans>
                    {p.category && <Sans size={10} ls={0.1} upper color={CC.mid}>{p.category}</Sans>}
                    <Sans size={10} color={CC.soft} style={{ marginLeft: 'auto' }}>
                      {p.published_at ? 'Published ' + fmt(p.published_at) : 'Updated ' + fmt(p.updated_at)}
                    </Sans>
                  </div>
                  <Sans size={14} weight={600} color={CC.ink} style={{ marginBottom: 4 }}>{p.title}</Sans>
                  <Sans size={11} color={CC.mid} style={{ marginBottom: 4 }}>
                    <code>/blog/{p.slug}</code>
                  </Sans>
                  {p.target_keywords && p.target_keywords.length > 0 && (
                    <div style={{ marginTop: 6, display: 'flex', flexWrap: 'wrap', gap: 4 }}>
                      {p.target_keywords.slice(0, 5).map((k, i) => (
                        <span key={i} style={{ fontSize: 10, padding: '2px 8px', borderRadius: 100, background: '#fbf8f0', color: '#7a5a1f', border: '1px solid #d6c389' }}>{k}</span>
                      ))}
                    </div>
                  )}
                </div>
                {p.status === 'published' && (
                  <a href={'/blog/' + p.slug} target="_blank" rel="noopener"
                    style={{ padding: '6px 14px', borderRadius: 100, cursor: 'pointer', background: CC.white, color: CC.deep, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 11, fontWeight: 600, textDecoration: 'none', flexShrink: 0 }}>
                    View →
                  </a>
                )}
              </div>
            </Panel>
          ))}
        </div>
      )}
    </div>
  );
}

function AdminSiteManager() {
  // v130 — Live publish status banner: fetches /api/cms to surface
  // last-published timestamp + content counts, so admins know exactly
  // what's live before pre-launch domain cutover. If never published,
  // a clear "DEFAULTS — not yet customised" banner appears.
  const [view, setView] = useState('cards');
  // v141.159 — native Pages editor target (yacht/heli/destinations/discover)
  const [pageTarget, setPageTarget] = useState('yacht');
  const openPageEditor = (p) => { setPageTarget(p); setView('pageeditor'); };
  // v141.160 — Homepage now edits natively (no /admin-classic).
  const openHomepageCMS = () => setView('homepagecms');
  const [cmsStatus, setCmsStatus] = useState({ loading: true });

  useEffect(() => {
    fetch('/api/cms?v=' + Math.floor(Date.now() / 60000), { cache: 'no-cache' })
      .then(r => r.ok ? r.json() : null)
      .then(json => {
        if (!json || !json.data) {
          setCmsStatus({ loading: false, published: false });
          return;
        }
        const d = json.data;
        const j = d.journal || {};
        setCmsStatus({
          loading: false,
          published: true,
          updatedAt: json.updated_at || null,
          posts:        Array.isArray(j.posts)        ? j.posts.filter(p => p.published).length        : 0,
          tastemakers:  Array.isArray(j.tastemakers)  ? j.tastemakers.filter(t => t.published).length  : 0,
          dispatches:   Array.isArray(j.dispatches)   ? j.dispatches.filter(x => x.published).length   : 0,
          hasHomepage:  !!d.homepage,
          hasAbout:     !!d.about,
        });
      })
      .catch(() => setCmsStatus({ loading: false, error: true }));
  }, []);

  function fmtAgo(iso) {
    if (!iso) return '';
    try {
      const ms = Date.now() - new Date(iso).getTime();
      const min = Math.floor(ms / 60000);
      if (min < 1)   return 'just now';
      if (min < 60)  return min + ' min ago';
      const hr = Math.floor(min / 60);
      if (hr < 24)   return hr + ' hr ago';
      const d = Math.floor(hr / 24);
      if (d < 30)    return d + ' day' + (d > 1 ? 's' : '') + ' ago';
      return new Date(iso).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
    } catch { return ''; }
  }

  // v141.70 — Outbound "Open full classic editor ↗" header link removed.
  // The classic Site Manager is now reachable as an embedded iframe in
  // this same shell (sidebar → Site manager). Card-grid items below
  // still link to /admin-classic?tab=sitemanager&page=xxx — those
  // click-throughs hit the admin-classic auto-redirect, land on
  // /dashboards/admin?section=sitemanager (which iframes the classic
  // page), and IframeView's pass-through passes ?page=xxx to the
  // iframe URL so the classic page lands on the right sub-tab.
  // v141.133 — Mockup polish: header CTAs + eyebrow + subtitle copy
  function openCmsEditor() {
    // v141.160 — was a window.open to /admin-classic; now opens the
    // native Homepage editor in this same dashboard. No classic.
    setView('homepagecms');
  }
  function previewPublic() {
    window.open('/', '_blank');
  }

  return (
    <SectionWrap
      title="Site manager"
      subtitle="Editorial CMS · three-tab editorial control: every public page, destination coverage, and the neighbourhood guide."
      actions={
        <div style={{ display: 'flex', gap: 8 }}>
          <button onClick={previewPublic}
            style={{ padding: '10px 18px', borderRadius: 100, cursor: 'pointer', background: CC.white, color: CC.deep, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600 }}>
            Preview
          </button>
          <button onClick={openCmsEditor}
            style={{ padding: '10px 18px', borderRadius: 100, cursor: 'pointer', background: CC.ink, color: CC.white, border: '1px solid ' + CC.ink, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600 }}>
            Publish
          </button>
        </div>
      }
    >
      <Sans size={10} weight={600} ls={0.14} upper color={CC.mid} style={{ marginBottom: 18 }}>
        Editorial CMS · absorbed from /admin-classic{cmsStatus.published ? ' · published' : ' · defaults'}
      </Sans>

      {/* v130 — Publish status banner */}
      {!cmsStatus.loading && (
        <Panel pad={14} style={{
          marginBottom: dashGap(),
          background: cmsStatus.published ? '#f4f8f3' : '#fdf6e9',
          borderColor: cmsStatus.published ? '#1f6b3a' : '#b8923a',
          borderLeft: '3px solid ' + (cmsStatus.published ? '#1f6b3a' : '#b8923a'),
        }}>
          <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
            <div>
              <Sans size={11} weight={700} ls={0.14} upper color={cmsStatus.published ? '#1f6b3a' : '#7a5a1f'} style={{ marginBottom: 4 }}>
                {cmsStatus.published ? '● Live' : '⚠ Defaults shown — not yet customised'}
              </Sans>
              <Sans size={12} color={CC.deep}>
                {cmsStatus.published
                  ? <>Last published <strong>{fmtAgo(cmsStatus.updatedAt)}</strong> · {cmsStatus.posts} journal posts · {cmsStatus.tastemakers} tastemakers · {cmsStatus.dispatches} dispatches</>
                  : <>Public pages currently render seed content. Publish at least once before launch — even a no-op save makes the CMS authoritative.</>
                }
              </Sans>
            </div>
            <button onClick={openHomepageCMS}
              style={{ padding: '8px 16px', background: CC.ink, color: CC.white, fontSize: 11, fontWeight: 600, letterSpacing: '0.08em', textTransform: 'uppercase', border: 'none', cursor: 'pointer', borderRadius: 100 }}>
              {cmsStatus.published ? 'Edit live content' : 'Publish initial content'}
            </button>
          </div>
        </Panel>
      )}

      <div style={{ display: 'flex', gap: 8, marginBottom: dashGap(), flexWrap: 'wrap' }}>
        {[
          { v: 'cards',         label: 'Pages overview' },
          { v: 'homepagecms',   label: 'Homepage' },
          { v: 'pageeditor',    label: 'Live page editor' },
          { v: 'destinations',  label: 'Destinations coverage' },
          { v: 'neighbourhood', label: 'Neighbourhood guide' },
        ].map(o => (
          <button key={o.v} onClick={() => setView(o.v)}
            style={{
              padding: '8px 18px', borderRadius: 100, fontSize: 12, fontWeight: 600,
              border: '1px solid ' + (view === o.v ? CC.ink : CC.border),
              background: view === o.v ? CC.ink : CC.white,
              color: view === o.v ? CC.white : CC.deep,
              cursor: 'pointer', letterSpacing: '0.04em',
            }}>{o.label}</button>
        ))}
      </div>
      {view === 'cards' && (() => {
        // v141.67 — Card list rewritten to mirror the actual May-2026
        // site map after the SEO sprint (Week 2 added the /bali-villas
        // hub + 10 city landing pages) and the v141.64 editorial
        // redesign (destinations / yacht / heli / discover / blog all
        // got the Aman-atlas look). Five status types:
        //   - 'live'   editor exists in admin-classic; card opens it
        //   - 'inline' opens here in the dashboard (Neighbourhood)
        //   - 'view'   no editor yet; card opens the live page in a tab
        //   - 'seo'    static SEO landing page (Week 2 sprint output) —
        //              copy lives in the source HTML; rebuild to change
        //   - 'new'    rendered in v141.64 with the new editorial design
        // 'view' / 'seo' / 'new' all open the live URL in a new tab.
        // Cards are useful even when there's no editor: admins audit
        // copy, verify links, screenshot for socials.
        //
        // v141.110 — Added a Legend banner above the cards so operators
        // don't keep clicking 'view'/'seo' cards expecting an editor.
        // The recurring complaint ("ketika diklik malah masuk ke detail
        // villa-nya") came from the badge mix not being decoded — most
        // cards are previews of content that lives in source. The legend
        // makes the difference explicit without changing routing.
        const groups = [
          // v141.220 — Phase 0 of the Aetheria Intelligence roadmap.
          // Highest-visibility section because this is what the operator
          // checks first thing every morning: who reached out, who's
          // real, who needs a reply.
          {
            label: 'Operations — daily triage',
            items: [
              { title: 'Inquiries',
                desc: 'Triage every enquiry in one place. Auto-spam classifier (api/lib/spam-classifier.js) buckets SEO/agency cold-outreach automatically so your inbox stays clean. One-click Mark spam / Mark real / Archive / Open in email + WhatsApp.',
                onClick: () => setView('inquiries'),
                status: 'inline' },
              { title: 'Concierge requests',
                desc: 'Every concierge & Aetheria request across all properties — in-stay service requests AND AI-concierge / service-catalog leads (now incl. pending). Category, priority, status, SLA timers (4h ack / 24h resolve); reassign to the right operator. Separate stream from Inquiries (table: concierge_requests).',
                onClick: () => setView('concierge'),
                status: 'inline' },
              { title: 'Traffic · GA4 + GSC',
                desc: 'Live Google Analytics 4 + Search Console data — sessions, top pages, top channels, search queries, impressions, clicks, average position. Window selectable 7d / 28d / 90d. Renders a setup hint until GCP credentials are added to Vercel env (one-time).',
                onClick: () => setView('traffic'),
                status: 'inline' },
            ],
          },
          {
            label: 'Editorial — homepage & long-form',
            items: [
              { title: 'Homepage',           desc: 'Statement, escape grid, designer villas, get-inspired, field-journal strip + the 3 mega-menu feature cards. Edit live here in the new dashboard — no classic.', onClick: openHomepageCMS, status: 'cms' },
              { title: 'About',              desc: 'Brand story, founding, principles, team. Copy lives in the page source (public/about.html) — preview only; ask engineering to amend.', href: '/about', status: 'view' },
              { title: 'Discover',           desc: 'Editorial hub — masthead eyebrow, subtitle + SEO title/description. Edit here in the new dashboard (no classic). Cards now open full /journal articles.', onClick: () => openPageEditor('discover'), status: 'cms' },
              { title: 'Journal · /blog',    desc: 'v141.64 editorial — "Notes from the island." magazine layout, archive table, Dispatch CTA.',     href: '/blog',                                           status: 'new' },
              { title: 'Editorial Journal CMS', desc: 'Homepage field-journal strip is editable here (Homepage editor). Long-form /journal/:slug articles are the world-class SEO engine (Article+FAQ+Breadcrumb) — every /discover card now opens one; that content is source-managed (api/article-seo.js).', onClick: openHomepageCMS, status: 'cms', count: cmsStatus.posts },
              // v141.110 — Honest pointer to the long-form article source.
              // Content for /journal/art-of-doing-nothing, /journal/single-fin-sunday
              // (and 8 others) is hardcoded in two mirrored files:
              //   public/m/live-screens.jsx → EDITORIAL_ARTICLES (mobile app reader)
              //   api/article-seo.js        → ARTICLES (SEO landing + meta)
              // Both files MUST be edited together when ship-rewriting an article.
              // This card surfaces that reality so the operator stops hunting
              // for a CMS pane that doesn't exist yet.
              { title: 'Article reader content', desc: 'Long-form /journal/:slug reader pages (10 articles incl. art-of-doing-nothing, single-fin-sunday) — content is in source files. To edit: ask engineering to amend public/m/live-screens.jsx (EDITORIAL_ARTICLES) AND api/article-seo.js (ARTICLES). Both files must stay in sync.', href: '/journal/art-of-doing-nothing',                   status: 'view' },
              { title: 'Press kit',          desc: 'Boilerplate, brand assets, media contacts at /press.',                                          href: '/press',                                          status: 'view' },
              { title: 'About TLB',          desc: '/about story page (brand origin, principles, leadership).',                                     href: '/about',                                          status: 'view' },
            ],
          },
          {
            label: 'Stay — search, hub, city landing pages',
            items: [
              { title: 'Search · Villas',          desc: '/search grid copy, filter chips, regions strip, no-results state.',                       href: '/search',                  status: 'view' },
              { title: 'Bali Villas hub',          desc: 'Week 2 SEO sprint — /bali-villas master page linking to all 10 city sub-pages.',          href: '/bali-villas',             status: 'seo' },
              { title: 'Canggu Villas',            desc: 'SEO landing /canggu-villas — luxury villas in Canggu surf district.',                     href: '/canggu-villas',           status: 'seo' },
              { title: 'Seminyak Villas',          desc: 'SEO landing /seminyak-villas — chic beachside villas with private pools.',                href: '/seminyak-villas',         status: 'seo' },
              { title: 'Ubud Villas',              desc: 'SEO landing /ubud-villas — jungle retreats and rice-field hideaways.',                    href: '/ubud-villas',             status: 'seo' },
              { title: 'Uluwatu Villas',           desc: 'SEO landing /uluwatu-villas — clifftop villas above the Indian Ocean.',                   href: '/uluwatu-villas',          status: 'seo' },
              { title: 'Jimbaran Villas',          desc: 'SEO landing /jimbaran-villas — beachfront seafood-coast villas.',                         href: '/jimbaran-villas',         status: 'seo' },
              { title: 'Sanur Villas',             desc: 'SEO landing /sanur-villas — calm-water coast for families.',                              href: '/sanur-villas',            status: 'seo' },
              { title: 'Nusa Dua Villas',          desc: 'SEO landing /nusa-dua-villas — resort enclave, manicured beach paths.',                   href: '/nusa-dua-villas',         status: 'seo' },
              { title: 'Nusa Lembongan Villas',    desc: 'SEO landing /nusa-lembongan-villas — island life across the strait.',                     href: '/nusa-lembongan-villas',   status: 'seo' },
              { title: 'Pererenan Villas',         desc: 'SEO landing /pererenan-villas — quieter Canggu fringe, surf + cafés.',                    href: '/pererenan-villas',        status: 'seo' },
              { title: 'Berawa Villas',            desc: 'SEO landing /berawa-villas — Canggu beach club district.',                                 href: '/berawa-villas',           status: 'seo' },
            ],
          },
          {
            label: 'Destinations — regional guides',
            items: [
              { title: 'Destinations index',       desc: 'Field-atlas hero — eyebrow, headline, subtitle + SEO title/description. Edit live here in the new dashboard.', onClick: () => openPageEditor('destinations'), status: 'cms' },
              { title: 'Destinations CMS',         desc: 'Edit the /destinations hero (eyebrow, headline, subtitle, SEO) live here in the new dashboard. Coverage gaps + per-region attention live in the "Destinations coverage" tab above.', onClick: () => openPageEditor('destinations'), status: 'cms' },
              { title: 'Ubud guide',               desc: '/destinations/ubud — full guide page.',                                                   href: '/destinations/ubud',                              status: 'view' },
              { title: 'Seminyak guide',           desc: '/destinations/seminyak — full guide page.',                                               href: '/destinations/seminyak',                          status: 'view' },
              { title: 'Canggu guide',             desc: '/destinations/canggu — full guide page.',                                                 href: '/destinations/canggu',                            status: 'view' },
              { title: 'Uluwatu guide',            desc: '/destinations/uluwatu — full guide page.',                                                href: '/destinations/uluwatu',                           status: 'view' },
              { title: 'Jimbaran guide',           desc: '/destinations/jimbaran — full guide page.',                                               href: '/destinations/jimbaran',                          status: 'view' },
              { title: 'Sanur guide',              desc: '/destinations/sanur — full guide page.',                                                  href: '/destinations/sanur',                             status: 'view' },
              { title: 'Nusa Dua guide',           desc: '/destinations/nusa-dua — full guide page.',                                               href: '/destinations/nusa-dua',                          status: 'view' },
              { title: 'Nusa Lembongan guide',     desc: '/destinations/nusa-lembongan — full guide page.',                                         href: '/destinations/nusa-lembongan',                    status: 'view' },
              { title: 'Pererenan guide',          desc: '/destinations/pererenan — full guide page.',                                              href: '/destinations/pererenan',                         status: 'view' },
              { title: 'Berawa guide',             desc: '/destinations/berawa — full guide page.',                                                 href: '/destinations/berawa',                            status: 'view' },
              { title: 'Neighbourhood guide',      desc: 'Venue cards by area — opens inline here, no need to leave.',                              onClick: () => setView('neighbourhood'),                                                   status: 'inline' },
            ],
          },
          {
            // v141.100 — descriptions refreshed to match the May 2026
            // state of each page (post YH-Charter integration + the
            // 4-collection yacht overhaul + the heli aircraft list).
            label: 'Experiences — yacht, heli, escapes',
            items: [
              { title: 'Yacht Charter',            desc: 'YH Charter fleet page — hero eyebrow, headline, subtitle + SEO title/description editable live here in the new dashboard.', onClick: () => openPageEditor('yacht'), status: 'cms' },
              { title: 'Helicopter',               desc: 'Heli charter page (Bell 407 GXi + Robinson R66 Turbine) — hero copy + SEO title/description editable live here in the new dashboard.', onClick: () => openPageEditor('heli'), status: 'cms' },
              { title: 'Escapes',                  desc: '/escapes editorial placeholder while curated cohort is finalised. Noindex; redirects guests to /search.',                              href: '/escapes',                                        status: 'view' },
              { title: 'Trip Designer',            desc: 'AI concierge welcome, prompts sidebar, Talk-to-a-person trio.',                            href: '/trip-designer',                                  status: 'view' },
              { title: 'Bundles',                  desc: '/bundles curated multi-day collections.',                                                  href: '/bundles',                                        status: 'view' },
              { title: 'Yacht detail pages',       desc: 'Per-vessel detail pages currently route via YH Charter (yh-charter.com/<slug>). TLB shows the cards on /yacht.', href: 'https://www.yh-charter.com',                      status: 'view' },
              { title: 'Heli detail pages',        desc: '/heli is a single-page service landing (no per-route detail pages — 4 routes shown inline).',                                          href: '/heli',                                          status: 'view' },
            ],
          },
          {
            label: 'Membership & loyalty',
            items: [
              { title: 'Inner Circle · Membership', desc: 'Tier copy, member benefits, application form (/membership, /apply).',                    href: '/membership',                                     status: 'view' },
              { title: 'Apply for membership',      desc: '/apply onboarding flow — tier picker, intake questionnaire.',                            href: '/apply',                                          status: 'view' },
              { title: 'Rewards',                   desc: '/rewards loyalty tiers, points, redemption catalogue.',                                  href: '/rewards',                                        status: 'view' },
              { title: 'Gift cards',                desc: '/gift purchase flow + voucher templates.',                                                href: '/gift',                                           status: 'view' },
              { title: 'Gift redeem',               desc: '/gift-redeem voucher landing for recipients.',                                            href: '/gift-redeem',                                    status: 'view' },
            ],
          },
          {
            label: 'Service & operational',
            items: [
              { title: 'Contact',          desc: '/contact form, concierge channels, office hours.',                                                 href: '/contact',                                        status: 'view' },
              { title: 'Login · Welcome',  desc: '/login + /welcome auth pages, copy + benefits panel.',                                            href: '/login',                                          status: 'view' },
              { title: 'Privacy',          desc: '/privacy policy page.',                                                                            href: '/privacy',                                        status: 'view' },
              { title: 'Terms',            desc: '/terms — terms of service page.',                                                                  href: '/terms',                                          status: 'view' },
              { title: 'Footer + social',  desc: 'Sitewide footer copy + social handles (injected via tlb-shared-footer.js).',                       href: '/privacy',                                        status: 'view' },
              { title: 'Instagram Studio', desc: 'AI image studio for Instagram + social posts — now the native Creative AI section in this dashboard (no classic).', href: '/dashboards/admin?section=creativeai', status: 'view' },
              { title: 'Partners landing', desc: '/partners landing page for prospective partners.',                                                  href: '/partners',                                       status: 'view' },
              { title: 'Affiliate portal', desc: '/affiliate/:slug landing for affiliate links.',                                                    href: '/affiliate-portal',                               status: 'view' },
              { title: 'Reviews',          desc: '/testimonials curated guest reviews.',                                                              href: '/testimonials',                                   status: 'view' },
              { title: 'Press kit',        desc: '/press boilerplate, brand assets, media contacts.',                                                href: '/press',                                          status: 'view' },
            ],
          },
        ];

        return (
          <div style={{ display: 'flex', flexDirection: 'column', gap: dashGap() }}>
            {/* v141.110 — Legend explaining badge → behaviour mapping. */}
            <Panel pad={14} style={{ background: CC.offWhite, borderColor: CC.border }}>
              <Sans size={11} weight={700} ls={0.16} upper color={CC.deep} style={{ marginBottom: 8 }}>How to read these cards</Sans>
              <div style={{ display: 'flex', flexWrap: 'wrap', gap: 14, fontSize: 11, lineHeight: 1.6, color: CC.mid }}>
                <span><span style={{ display: 'inline-block', padding: '1px 7px', background: '#1f6b3a', color: '#fff', fontSize: 9, fontWeight: 700, letterSpacing: '0.12em', borderRadius: 3, marginRight: 6 }}>LIVE</span>Opens the CMS editor in this tab — edit copy, save, publish.</span>
                <span><span style={{ display: 'inline-block', padding: '1px 7px', background: '#7a5a1f', color: '#fff', fontSize: 9, fontWeight: 700, letterSpacing: '0.12em', borderRadius: 3, marginRight: 6 }}>INLINE</span>Opens an editor right here on this page (no navigation).</span>
                <span><span style={{ display: 'inline-block', padding: '1px 7px', background: '#444', color: '#fff', fontSize: 9, fontWeight: 700, letterSpacing: '0.12em', borderRadius: 3, marginRight: 6 }}>VIEW / NEW / SEO</span>Preview only — content is in source files; ask engineering to amend.</span>
              </div>
            </Panel>
            {groups.map(group => (
              <div key={group.label}>
                <Sans size={11} weight={700} ls={0.18} upper color={CC.mid} style={{ marginBottom: 12 }}>{group.label}</Sans>
                <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: dashGap() }}>
                  {group.items.map((item, i) => {
                    // v141.107 — Click routing per card type:
                    //   • status='live'    → open the CMS editor in the SAME tab
                    //                        (`/admin-classic?...&legacy=1` so the
                    //                        admin-classic shell stays put instead
                    //                        of redirecting back to /dashboards/admin
                    //                        and showing this same card grid again).
                    //   • status='inline'  → in-shell handler (e.g. Neighbourhood),
                    //                        no navigation.
                    //   • status='new'/'view'/'seo' → live page preview in new tab.
                    // Before this, every card with an href used target="_blank" +
                    // bare /admin-classic URL — which auto-redirected to the new
                    // dashboard, which re-rendered the same card grid, so the
                    // operator's tap appeared to "open the live web" (it was
                    // re-opening the admin shell, not the editor).
                    //
                    // v141.110 — Widened the same-tab editor bypass to also
                    // cover status='default' (cards whose status hasn't
                    // flipped to 'live' yet because the CMS hasn't been
                    // saved-into for the first time). Without this, a
                    // brand-new admin tapping the Homepage card BEFORE
                    // anyone has ever published landed in the redirect
                    // loop again. Any admin-classic URL is by definition
                    // an editor target, so a tighter check would only
                    // mask the bug.
                    const isAdminEditor = (item.status === 'live' || item.status === 'default')
                                        && typeof item.href === 'string'
                                        && item.href.startsWith('/admin-classic');
                    const editorHref = isAdminEditor
                      ? (item.href + (item.href.includes('?') ? '&' : '?') + 'legacy=1')
                      : item.href;
                    const Wrapper = item.href
                      ? (props) => <a
                          {...props}
                          href={editorHref}
                          target={isAdminEditor ? '_self' : '_blank'}
                          style={{ textDecoration: 'none' }}
                        />
                      : (props) => <div {...props} onClick={item.onClick} />;
                    // v141.67 — added 'seo' (Week 2 static SEO landing
                    // pages) and 'new' (v141.64 editorial redesigns) to
                    // the badge palette so admins can tell at a glance
                    // which category each page belongs to.
                    const badgeColor = item.status === 'cms'    ? '#1f6b3a'  // green — native editor (new design)
                                     : item.status === 'live'   ? '#1f6b3a'  // green — has CMS editor
                                     : item.status === 'inline' ? CC.ink     // ink — opens here
                                     : item.status === 'new'    ? '#7a3da8'  // purple — v141.64 redesign
                                     : item.status === 'seo'    ? '#b8923a'  // gold — Week 2 SEO landing
                                     : item.status === 'view'   ? '#5b6470'  // grey — static page, no editor
                                     : '#b8923a';                            // default
                    const badgeText  = item.status === 'cms'    ? 'EDIT HERE'
                                     : item.status === 'live'   ? 'LIVE'
                                     : item.status === 'inline' ? 'INLINE'
                                     : item.status === 'new'    ? 'NEW'
                                     : item.status === 'seo'    ? 'SEO'
                                     : item.status === 'view'   ? 'VIEW'
                                     : 'DEFAULT';
                    const ctaLabel = item.status === 'cms' ? 'Edit in dashboard →'
                                     : (item.status === 'view' || item.status === 'seo' || item.status === 'new')
                                     ? 'Open page →' : 'Open editor →';
                    return (
                      <Wrapper key={i}>
                        <Panel style={{ cursor: 'pointer', transition: 'transform .2s, box-shadow .2s', position: 'relative' }}
                          onMouseEnter={e => { e.currentTarget.style.transform = 'translateY(-2px)'; e.currentTarget.style.boxShadow = '0 12px 36px rgba(20,20,20,0.06)'; }}
                          onMouseLeave={e => { e.currentTarget.style.transform = ''; e.currentTarget.style.boxShadow = ''; }}>
                          <div style={{ position: 'absolute', top: 14, right: 14, padding: '2px 8px', fontSize: 9, fontWeight: 700, letterSpacing: '0.12em', background: badgeColor, color: '#fff', borderRadius: 3 }}>{badgeText}{typeof item.count === 'number' && item.count > 0 ? ' · ' + item.count : ''}</div>
                          <Display size={20} italic weight={400} style={{ marginBottom: 8 }}>{item.title}.</Display>
                          <Sans size={12} color={CC.mid} style={{ lineHeight: 1.55 }}>{item.desc}</Sans>
                          <Sans size={10} weight={600} ls={0.14} upper color={CC.ink} style={{ marginTop: 14 }}>{ctaLabel}</Sans>
                        </Panel>
                      </Wrapper>
                    );
                  })}
                </div>
              </div>
            ))}
          </div>
        );
      })()}
      {view === 'destinations' && <DestinationsCoverage />}
      {view === 'neighbourhood' && <NeighbourhoodEditor />}
      {view === 'pageeditor' && <AdminPageCMS page={pageTarget} setPage={setPageTarget} onBack={() => setView('cards')} />}
      {view === 'homepagecms' && <AdminHomepageCMS onBack={() => setView('cards')} />}
      {view === 'inquiries' && <AdminInquiries onBack={() => setView('cards')} />}
      {/* v141.469 — Concierge requests panel: reuse the cross-property
          AdminConcierge ops view, reached from the daily-triage cards. Wrapped
          with a Back control so the operator returns to the inbox cards. */}
      {view === 'concierge' && (
        <div>
          <button onClick={() => setView('cards')}
            style={{ marginBottom: dashGap(), padding: '9px 16px', background: CC.white, color: CC.ink, border: '1px solid ' + CC.border, borderRadius: 100, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600, cursor: 'pointer' }}>
            ← Back
          </button>
          <AdminConcierge />
        </div>
      )}
      {view === 'traffic' && <AdminTraffic onBack={() => setView('cards')} />}
    </SectionWrap>
  );
}

// ── DestinationsCoverage (v141.116) ─────────────────────────────────────
// Surfaces which destinations need admin attention — replace Unsplash
// stock hero photos with editorial shots, or fill empty destinations
// with villa listings. Also lists "orphan" locations: villas in the DB
// whose location string doesn't map to any /destinations/<slug> page
// yet. Data from /api/admin/destinations-coverage.
function DestinationsCoverage() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [filter, setFilter] = useState('attention');   // 'all' | 'attention' | 'empty' | 'orphans'

  const adminToken = () => {
    try { return localStorage.getItem('tlb_admin_token') || localStorage.getItem('tlb_token') || ''; }
    catch { return ''; }
  };

  const fetchRows = React.useCallback(() => {
    setLoading(true); setError(null);
    const token = adminToken();
    fetch('/api/admin/destinations-coverage', {
      headers: token ? { 'Authorization': 'Bearer ' + token } : {},
    })
      .then(r => r.json().then(j => ({ ok: r.ok, j })))
      .then(({ ok, j }) => {
        if (!ok || !j.ok) throw new Error(j.error || 'Failed');
        setData(j);
      })
      .catch(e => setError(e.message))
      .finally(() => setLoading(false));
  }, []);

  React.useEffect(() => { fetchRows(); }, [fetchRows]);

  if (loading) return <Panel pad={20}><Loading msg="Reading destinations + villa locations…" /></Panel>;
  if (error)   return <Panel pad={20}><ErrorState msg={error} onRetry={fetchRows} /></Panel>;

  const dests = (data && data.destinations) || [];
  const orphans = (data && data.orphan_locations) || [];
  const summary = (data && data.summary) || {};

  const filtered = (() => {
    if (filter === 'all')        return dests;
    if (filter === 'attention')  return dests.filter(d => d.needs_attention.length > 0);
    if (filter === 'empty')      return dests.filter(d => d.needs_attention.includes('needs-villas'));
    return dests;
  })();

  const SOURCE_LABEL = {
    framer:    { label: 'Framer CDN', tone: '#1f6b3a', bg: '#f1f9f3' },
    custom:    { label: 'Self-hosted',tone: '#1f6b3a', bg: '#f1f9f3' },
    partner:   { label: 'Partner CDN',tone: '#1f4d7a', bg: '#f0f4fa' },
    unsplash:  { label: 'Stock photo',tone: '#7a5a1f', bg: '#fdf6e9' },
    other:     { label: 'Other',      tone: '#666',    bg: '#f4f3ef' },
    none:      { label: 'No image',   tone: '#9a1c1c', bg: '#fbf3f3' },
  };

  return (
    <div style={{ marginBottom: dashGap() }}>
      <Panel pad={14} style={{ marginBottom: 12 }}>
        <Sans size={11} weight={700} ls={0.18} upper color={CC.deep} style={{ marginBottom: 8 }}>Destinations coverage</Sans>
        <Sans size={12} color={CC.mid} style={{ lineHeight: 1.6, marginBottom: 12 }}>
          {summary.total} destinations live ·
          {' '}<strong style={{ color: '#7a5a1f' }}>{summary.needs_photo}</strong> still using stock photos ·
          {' '}<strong style={{ color: '#9a1c1c' }}>{summary.needs_villas}</strong> with no villa listings yet ·
          {' '}<strong style={{ color: '#7a5a1f' }}>{summary.orphans}</strong> orphan villa locations need a destination page.
        </Sans>

        {/* Filter chips */}
        <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 4 }}>
          {[
            { v: 'attention', label: 'Needs attention', n: dests.filter(d => d.needs_attention.length > 0).length },
            { v: 'empty',     label: 'No villas',       n: summary.needs_villas },
            { v: 'all',       label: 'All destinations',n: summary.total },
            { v: 'orphans',   label: 'Orphan locations',n: summary.orphans },
          ].map(o => (
            <button key={o.v} onClick={() => setFilter(o.v)}
              style={{
                padding: '6px 12px', borderRadius: 100, fontSize: 11, fontWeight: 500,
                border: '1px solid ' + (filter === o.v ? CC.ink : CC.border),
                background: filter === o.v ? CC.ink : CC.white,
                color: filter === o.v ? CC.white : CC.deep, cursor: 'pointer',
              }}>{o.label} · {o.n}</button>
          ))}
          <button onClick={fetchRows}
            style={{ padding: '6px 12px', borderRadius: 100, fontSize: 11, fontWeight: 500, border: '1px solid ' + CC.border, background: CC.white, color: CC.mid, cursor: 'pointer', marginLeft: 'auto' }}>
            ⟳ Refresh
          </button>
        </div>
      </Panel>

      {/* Destinations grid OR orphans table */}
      {filter !== 'orphans' && (
        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: 10 }}>
          {filtered.map(d => {
            const sourceMeta = SOURCE_LABEL[d.hero_source] || SOURCE_LABEL.other;
            const isAttention = d.needs_attention.length > 0;
            return (
              <Panel key={d.slug} pad={14} style={{
                borderColor: isAttention ? 'rgba(154,125,63,0.32)' : CC.border,
                borderLeft: '3px solid ' + (d.needs_attention.includes('needs-villas') ? '#9a1c1c' : (isAttention ? '#7a5a1f' : '#1f6b3a')),
              }}>
                <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8, marginBottom: 6 }}>
                  <Sans size={14} weight={600} color={CC.ink}>{d.name}</Sans>
                  <span style={{
                    padding: '2px 8px', borderRadius: 100, fontSize: 9, fontWeight: 700, letterSpacing: '0.12em',
                    background: sourceMeta.bg, color: sourceMeta.tone, whiteSpace: 'nowrap',
                  }}>{sourceMeta.label}</span>
                </div>
                <Sans size={10} weight={600} ls={0.10} upper color={CC.mid} style={{ marginBottom: 8 }}>{d.region}</Sans>
                <Sans size={11} color={CC.deep} style={{ marginBottom: 10 }}>
                  {d.villa_count} villa{d.villa_count === 1 ? '' : 's'} mapped
                </Sans>
                {d.needs_attention.length > 0 && (
                  <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 10 }}>
                    {d.needs_attention.includes('needs-photo') && (
                      <span style={{ fontSize: 10, fontWeight: 600, letterSpacing: '0.06em', padding: '2px 8px', borderRadius: 100, background: '#fdf6e9', color: '#7a5a1f' }}>UPLOAD HERO</span>
                    )}
                    {d.needs_attention.includes('needs-villas') && (
                      <span style={{ fontSize: 10, fontWeight: 600, letterSpacing: '0.06em', padding: '2px 8px', borderRadius: 100, background: '#fbf3f3', color: '#9a1c1c' }}>NO LISTINGS</span>
                    )}
                  </div>
                )}
                <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
                  <a href={d.url} target="_blank" rel="noopener noreferrer"
                    style={{ flex: 1, padding: '6px 10px', borderRadius: 100, fontSize: 11, fontWeight: 600, border: '1px solid ' + CC.border, background: CC.white, color: CC.deep, textAlign: 'center', textDecoration: 'none' }}>
                    Preview ↗
                  </a>
                  <a href={'/search?location=' + encodeURIComponent(d.slug)} target="_blank" rel="noopener noreferrer"
                    style={{ padding: '6px 10px', borderRadius: 100, fontSize: 11, fontWeight: 600, border: '1px solid ' + CC.border, background: CC.white, color: CC.deep, textAlign: 'center', textDecoration: 'none' }}>
                    Villas
                  </a>
                </div>
              </Panel>
            );
          })}
          {filtered.length === 0 && (
            <Panel pad={20} style={{ gridColumn: '1 / -1', textAlign: 'center' }}>
              <Sans size={12} color={CC.mid}>Nothing matches this filter — try "All destinations" to see the full catalogue.</Sans>
            </Panel>
          )}
        </div>
      )}

      {filter === 'orphans' && (
        <Panel pad={0} style={{ overflow: 'hidden' }}>
          <div style={{ padding: 14, borderBottom: '1px solid ' + CC.border }}>
            <Sans size={11} weight={700} ls={0.16} upper color={CC.deep} style={{ marginBottom: 4 }}>Orphan villa locations</Sans>
            <Sans size={12} color={CC.mid} style={{ lineHeight: 1.6 }}>
              Live villas whose <code>location</code> string didn't match any destination slug. Add a destination entry in
              <code> /api/destination-seo.js</code> or normalise the villa's location to land on the right page.
            </Sans>
          </div>
          {orphans.length === 0 && (
            <div style={{ padding: 24, textAlign: 'center' }}>
              <Sans size={12} color={CC.mid}>No orphan locations — every villa maps to a destination page.</Sans>
            </div>
          )}
          {orphans.map(o => (
            <div key={o.slug} style={{ padding: '12px 14px', borderBottom: '1px solid ' + CC.border, display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 14, flexWrap: 'wrap' }}>
              <div style={{ flex: '1 1 200px' }}>
                <Sans size={13} weight={500} color={CC.ink}>{o.location_seg}</Sans>
                <Sans size={10} weight={600} ls={0.10} upper color={CC.mid} style={{ marginTop: 2 }}>{o.villa_count} villa{o.villa_count === 1 ? '' : 's'}</Sans>
              </div>
              <div style={{ flex: '2 1 300px', fontSize: 11, color: CC.deep, lineHeight: 1.5 }}>
                {o.sample.map((v, i) => (
                  <span key={i} style={{ marginRight: 10 }}>
                    {v.url_slug ? <a href={'/villa/' + v.url_slug} target="_blank" rel="noopener noreferrer" style={{ color: CC.deep, textDecoration: 'underline' }}>{v.name}</a> : v.name}
                  </span>
                ))}
              </div>
            </div>
          ))}
        </Panel>
      )}
    </div>
  );
}

// ── Neighbourhood Inline Editor (Round-15) ─────────────────────────────
// Loads default venue list from /assets/neighbourhood-data.js (the
// runtime data file) + admin overrides from /api/admin-neighbourhood.
// Lets admin edit photo URL, name, blurb, detail, price band, mark
// closed/active, or add a brand-new venue per area.
function NeighbourhoodEditor() {
  const [defaults, setDefaults] = useState(null);
  const [overrides, setOverrides] = useState([]);
  const [loading, setLoading] = useState(true);
  const [activeArea, setActiveArea] = useState('uluwatu');
  const [editing, setEditing] = useState(null); // { area, venue_slug, ...fields }
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState('');
  // v141.99 — Show closed venues so admin can Restore / Delete-forever.
  const [showClosed, setShowClosed] = useState(false);

  // Extracted so we can refetch after Close/Restore/Delete without
  // reloading the whole useEffect chain.
  // v141.102 — Always fetch ?include_closed=1. The "Show closed" toggle
  // controls UI VISIBILITY (filter applied in getMergedStops), not
  // which rows the API returns. Before this, default fetch returned
  // only active rows — so when the operator closed a default venue
  // (Old Man's etc.), the override row was created in DB but the UI
  // never saw it (showClosed=false → API filtered it out), and so
  // the merged stops still treated the default venue as ACTIVE on
  // the next refetch. Result: clicking Close appeared to do nothing.
  function refetchOverrides() {
    return fetch('/api/admin-neighbourhood?include_closed=1')
      .then(r => r.json())
      .then(data => { setOverrides(data.overrides || []); })
      .catch(() => setOverrides([]));
  }

  useEffect(() => {
    setLoading(true);
    // Load defaults from the runtime asset (already loaded if user
    // visited /neighbourhood before; otherwise dynamic import).
    if (window.NEIGHBOURHOOD_DATA) {
      setDefaults(window.NEIGHBOURHOOD_DATA);
    } else {
      const s = document.createElement('script');
      s.src = '/assets/neighbourhood-data.js?v=' + Date.now();
      s.onload = () => setDefaults(window.NEIGHBOURHOOD_DATA || {});
      s.onerror = () => setDefaults({});
      document.head.appendChild(s);
    }
    refetchOverrides().finally(() => setLoading(false));
  }, [showClosed]);

  function slugify(s) {
    return String(s || '').toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
  }

  function getMergedStops(area) {
    const base = (defaults && defaults[area] && defaults[area].stops) || [];
    const ovrs = overrides.filter(o => o.area === area);
    // Merge: each base stop gets override applied if matching slug exists
    const merged = base.map(stop => {
      const slug = slugify(stop.name);
      const ovr = ovrs.find(o => o.venue_slug === slug);
      return ovr
        ? { ...stop, ...ovr, _override: true, _isDefault: true, venue_slug: slug, _status: ovr.status || 'active' }
        : { ...stop, venue_slug: slug, _override: false, _isDefault: true, _status: 'active' };
    });
    // Plus pure-override venues (added new via admin, no base entry)
    const baseSlugs = new Set(merged.map(s => s.venue_slug));
    ovrs.filter(o => !baseSlugs.has(o.venue_slug)).forEach(o => {
      merged.push({ ...o, _override: true, _new: true, _isDefault: false, _status: o.status || 'active' });
    });
    // v141.99 — Hide closed venues unless the admin toggled showClosed on.
    return showClosed ? merged : merged.filter(s => s._status !== 'closed');
  }

  function saveVenue(payload) {
    setBusy(true); setErr('');
    // v141.71 — Token resolution widened to include legacy tlb_token.
    // Symptom that prompted this: user saw "Authentication required" on
    // Save Venue even though they were signed in to /dashboards/admin.
    // Root cause: their session was set up before v82 introduced
    // tlb_admin_token specifically, so only the legacy tlb_token slot
    // was populated and the fetch sent no Authorization header at all.
    let token = '';
    try {
      token = localStorage.getItem('tlb_admin_token')
           || localStorage.getItem('tlb_token')
           || '';
    } catch {}
    // v141.110 — Strip whitespace from photo URLs before save so the
    // round-trip stays stable. Some legacy override rows still carry
    // trailing newlines from the pre-v141.90 SUPABASE_URL bug; if an
    // operator opens one of those rows, edits a field other than the
    // photo, and saves, we'd write the dirty URL back. Trim here.
    const cleanPayload = {
      ...payload,
      photo: typeof payload.photo === 'string' ? payload.photo.replace(/\s+/g, '') : payload.photo,
    };
    fetch('/api/admin-neighbourhood', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', ...(token ? { 'Authorization': 'Bearer ' + token } : {}) },
      body: JSON.stringify(cleanPayload),
    })
      .then(r => r.json().then(j => ({ ok: r.ok, j })))
      .then(({ ok, j }) => {
        if (!ok) {
          if (j.code === 'NBH_SCHEMA_MISSING') {
            setErr('Database table not yet created. Run sql/neighbourhood-overrides.sql in Supabase SQL editor first.');
          } else {
            setErr(j.error || 'Save failed');
          }
          return;
        }
        // v141.78 — Optimistic local update from the POST response.
        // Previously we only fired a refetch of /api/admin-neighbourhood
        // and replaced state when that returned. Two problems with
        // refetch-only:
        //   1. Read-after-write race on Supabase REST — the GET could
        //      return the OLD override row if PostgREST hadn't yet
        //      observed the just-committed write. Symptom the host
        //      reported: photo uploaded successfully, modal closed,
        //      but the venue card still showed the previous photo
        //      until a manual page refresh.
        //   2. Even when the refetch succeeded, the round-trip latency
        //      meant the card image flickered between old → new.
        // The /api/admin-neighbourhood POST handler returns the saved
        // row at `j.venue`. We replace (or append) it in local state
        // immediately, so React re-renders with the fresh photo URL
        // before the network finishes settling. The refetch still
        // runs as a safety net to pick up any server-side mutations.
        if (j.venue && j.venue.area && j.venue.venue_slug) {
          setOverrides(prev => {
            const without = prev.filter(o =>
              !(o.area === j.venue.area && o.venue_slug === j.venue.venue_slug)
            );
            return [...without, j.venue];
          });
        }
        // Belt-and-braces: still refetch to capture any other concurrent
        // edits (multi-admin sessions) and confirm the optimistic write
        // matches DB truth. v141.102 — include_closed=1 so we don't
        // lose visibility of newly-closed rows on the subsequent
        // settle (matches the new refetchOverrides default).
        fetch('/api/admin-neighbourhood?include_closed=1').then(r => r.json()).then(d => { setOverrides(d.overrides || []); });
        setEditing(null);
        const showToast = (window.TLB_NATIVE_MUTATIONS && window.TLB_NATIVE_MUTATIONS.showToast) || function(){};
        showToast('Saved ' + payload.name);
      })
      .catch(e => setErr(e.message))
      .finally(() => setBusy(false));
  }

  function adminToken() {
    try {
      return localStorage.getItem('tlb_admin_token')
          || localStorage.getItem('tlb_token')
          || '';
    } catch { return ''; }
  }

  function markClosed(area, venueSlug, venueName) {
    if (!confirm('Hide "' + venueName + '" from the public Neighbourhood guide? Reversible — use the Restore button under "Show closed".')) return;
    const token = adminToken();
    fetch('/api/admin-neighbourhood?area=' + encodeURIComponent(area) + '&venue_slug=' + encodeURIComponent(venueSlug), {
      method: 'DELETE',
      headers: token ? { 'Authorization': 'Bearer ' + token } : {},
    }).then(r => r.json().then(j => ({ ok: r.ok, j })))
      .then(({ ok, j }) => {
        const showToast = (window.TLB_NATIVE_MUTATIONS && window.TLB_NATIVE_MUTATIONS.showToast) || function(){};
        if (!ok) { showToast('Close failed: ' + (j.error || 'unknown')); return; }
        refetchOverrides();
        showToast('Hidden: ' + venueName);
      });
  }

  // v141.99 — Hard delete: remove the override row entirely. Only
  // applies to true override rows (admin-created). For default venues
  // that are still in the data file, the closest equivalent is "Close"
  // (which inserts a status=closed row).
  function deleteForever(area, venueSlug, venueName) {
    // v141.189 — in-app confirm (native confirm() is silently
    // suppressed in embedded contexts / after Chrome's "prevent more
    // dialogs", which made this Delete button look unresponsive).
    const showToast = (window.TLB_NATIVE_MUTATIONS && window.TLB_NATIVE_MUTATIONS.showToast)
      || function (m) { try { alert(m); } catch (_) {} };
    window.tlbConfirm('Permanently DELETE "' + venueName + '"? This removes the override row entirely — the venue will revert to its default-data version (if it had one) or vanish (if admin-only). Cannot be undone via the UI.', { danger: true, confirmText: 'Delete' }).then((ok0) => {
      if (!ok0) return;
      const token = adminToken();
      fetch('/api/admin-neighbourhood?hard=1&area=' + encodeURIComponent(area) + '&venue_slug=' + encodeURIComponent(venueSlug), {
        method: 'DELETE',
        headers: token ? { 'Authorization': 'Bearer ' + token } : {},
      }).then(r => r.json().then(j => ({ ok: r.ok, j })).catch(() => ({ ok: r.ok, j: {} })))
        .then(({ ok, j }) => {
          if (!ok) { showToast('Delete failed: ' + (j.error || 'unknown')); return; }
          refetchOverrides();
          showToast('Deleted: ' + venueName);
        })
        .catch(() => showToast('Delete failed — network error'));
    });
  }

  // v141.99 — Restore: flip status='closed' → 'active'. POST upsert
  // semantics make this trivial — we just re-save the venue's
  // existing fields with status=active. Works for both ex-default-now-
  // closed venues (insert path on Close) and admin overrides.
  function restoreVenue(stop) {
    const token = adminToken();
    const payload = {
      area: activeArea,
      venue_slug: stop.venue_slug,
      name: stop.name || stop.venue_slug,
      category: stop.category || null,
      blurb: stop.blurb || null,
      detail: stop.detail || null,
      photo: stop.photo || null,
      price_band: stop.price_band || null,
      status: 'active',
      sort_order: stop.sort_order || 0,
    };
    fetch('/api/admin-neighbourhood', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', ...(token ? { 'Authorization': 'Bearer ' + token } : {}) },
      body: JSON.stringify(payload),
    }).then(r => r.json().then(j => ({ ok: r.ok, j })))
      .then(({ ok, j }) => {
        const showToast = (window.TLB_NATIVE_MUTATIONS && window.TLB_NATIVE_MUTATIONS.showToast) || function(){};
        if (!ok) { showToast('Restore failed: ' + (j.error || 'unknown')); return; }
        refetchOverrides();
        showToast('Restored: ' + stop.name);
      });
  }

  // v141 — Render-time guard against the race: useEffect kicks off TWO
  // async loads (the /assets/neighbourhood-data.js script tag + the
  // /api/admin-neighbourhood fetch). `loading` only flips false after the
  // fetch finishes — but the script can still be null at that point, so
  // any `defaults[activeArea].region` access below threw and crashed the
  // whole React tree. Symptom on production: clicking "Neighbourhood
  // guide" appeared to do nothing because the editor never mounted.
  if (loading || !defaults) return <Loading msg="Loading Neighbourhood guide…" />;
  const areaKeys = Object.keys(defaults).filter(k => defaults[k] && defaults[k].region);
  // If activeArea is somehow missing from the loaded data, fall back to
  // the first available area so the picker keeps working.
  if (!defaults[activeArea] && areaKeys.length) {
    setTimeout(() => setActiveArea(areaKeys[0]), 0);
    return <Loading msg="Loading Neighbourhood guide…" />;
  }
  if (!defaults[activeArea]) {
    return (
      <Panel pad={20}>
        <Sans size={13} color={CC.deep}>Neighbourhood data file failed to load. Reload the page; if the issue persists, check that <code>/assets/neighbourhood-data.js</code> is being served (not 404).</Sans>
      </Panel>
    );
  }
  const stops = getMergedStops(activeArea);

  return (
    <div>
      {/* Area picker */}
      <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: dashGap() }}>
        {areaKeys.map(k => (
          <button key={k} onClick={() => setActiveArea(k)}
            style={{
              padding: '7px 14px', borderRadius: 100, fontSize: 11, fontWeight: 500,
              border: '1px solid ' + (activeArea === k ? CC.ink : CC.border),
              background: activeArea === k ? CC.ink : CC.white,
              color: activeArea === k ? CC.white : CC.deep,
              cursor: 'pointer', textTransform: 'capitalize',
            }}>{(defaults[k].region || k)}</button>
        ))}
      </div>

      {/* Region intro */}
      <Panel pad={16} style={{ background: CC.offWhite, borderColor: 'transparent', marginBottom: dashGap() }}>
        <Sans size={11} weight={600} ls={0.14} upper color={CC.mid} style={{ marginBottom: 6 }}>{stops.length} venues · {(defaults[activeArea].region || activeArea)}</Sans>
        <Sans size={12} color={CC.deep} style={{ lineHeight: 1.6 }}>{defaults[activeArea].intro}</Sans>
      </Panel>

      {/* Controls row: Show-closed toggle + Add-venue button */}
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14, gap: 12, flexWrap: 'wrap' }}>
        {/* v141.99 — Show-closed toggle so admin can find + restore
            previously closed venues, or hard-delete admin-only entries. */}
        <label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 12, color: CC.deep, cursor: 'pointer', userSelect: 'none' }}>
          <input
            type="checkbox"
            checked={showClosed}
            onChange={(e) => setShowClosed(e.target.checked)}
            style={{ cursor: 'pointer' }}
          />
          Show closed venues (Restore / Delete)
        </label>
        <button onClick={() => setEditing({ area: activeArea, name: '', category: 'restaurant', blurb: '', detail: '', photo: '', price_band: '', status: 'active', sort_order: 0, _new: true, _openedAt: Date.now() })}
          style={{ padding: '8px 16px', background: CC.ink, color: CC.white, border: 'none', borderRadius: 100, fontSize: 12, fontWeight: 600, cursor: 'pointer' }}>
          + Add venue to {(defaults[activeArea].region || activeArea)}
        </button>
      </div>

      {err && <Panel pad={12} style={{ background: '#fbf3f3', borderColor: '#9a1c1c', marginBottom: 14 }}><Sans size={12} color="#9a1c1c">{err}</Sans></Panel>}

      {/* Venue grid */}
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: dashGap() }}>
        {stops.map((s, i) => {
          const isClosed = s._status === 'closed';
          return (
          <Panel key={s.venue_slug || i} pad={0} style={{ overflow: 'hidden', position: 'relative', opacity: isClosed ? 0.55 : 1 }}>
            <div style={{ aspectRatio: '4/3', background: CC.offWhite, backgroundImage: `url('${s.photo || ''}')`, backgroundSize: 'cover', backgroundPosition: 'center', position: 'relative' }}>
              {/* v141.99 — Badge stack: EDITED + NEW + CLOSED. Closed
                  takes precedence visually (red, top-right) so admin
                  can scan the grid quickly. */}
              {s._override && !isClosed && <div style={{ position: 'absolute', top: 10, left: 10, padding: '3px 10px', background: 'rgba(184,146,58,0.95)', color: '#fff', fontSize: 9, fontWeight: 700, letterSpacing: '0.10em', borderRadius: 100 }}>EDITED</div>}
              {s._new && !isClosed && <div style={{ position: 'absolute', top: 10, right: 10, padding: '3px 10px', background: 'rgba(31,107,58,0.95)', color: '#fff', fontSize: 9, fontWeight: 700, letterSpacing: '0.10em', borderRadius: 100 }}>NEW</div>}
              {isClosed && <div style={{ position: 'absolute', top: 10, right: 10, padding: '3px 10px', background: 'rgba(154,28,28,0.95)', color: '#fff', fontSize: 9, fontWeight: 700, letterSpacing: '0.10em', borderRadius: 100 }}>CLOSED</div>}
            </div>
            <div style={{ padding: 14 }}>
              <Sans size={13} weight={500} color={CC.ink} style={{ marginBottom: 4 }}>{s.name}</Sans>
              <Sans size={10} weight={600} ls={0.10} upper color={CC.mid} style={{ marginBottom: 8 }}>{s.category || '—'}</Sans>
              <Sans size={11} color={CC.deep} style={{ lineHeight: 1.5, marginBottom: 12, minHeight: 33 }}>{s.blurb || '—'}</Sans>
              {/* v141.99 — Action buttons differ for active vs closed venues.
                  Active: Edit + Close (hides from public).
                  Closed: Restore (activate) + Delete-forever (hard remove). */}
              {isClosed ? (
                <div style={{ display: 'flex', gap: 6 }}>
                  <button onClick={() => restoreVenue(s)}
                    style={{ flex: 1, padding: '6px 10px', borderRadius: 100, fontSize: 11, fontWeight: 600, border: '1px solid #1f6b3a', background: 'rgba(31,107,58,0.06)', color: '#1f6b3a', cursor: 'pointer' }}>↺ Restore</button>
                  {s._override && (
                    <button onClick={() => deleteForever(activeArea, s.venue_slug, s.name)}
                      style={{ padding: '6px 12px', borderRadius: 100, fontSize: 11, fontWeight: 600, border: '1px solid #9a1c1c', background: '#9a1c1c', color: '#fff', cursor: 'pointer' }}>✕ Delete</button>
                  )}
                </div>
              ) : (
                <div style={{ display: 'flex', gap: 6 }}>
                  <button onClick={() => setEditing({ ...s, area: activeArea, _openedAt: Date.now() })}
                    style={{ flex: 1, padding: '6px 10px', borderRadius: 100, fontSize: 11, fontWeight: 600, border: `1px solid ${CC.border}`, background: CC.white, color: CC.deep, cursor: 'pointer' }}>Edit</button>
                  <button onClick={() => markClosed(activeArea, s.venue_slug, s.name)}
                    style={{ padding: '6px 12px', borderRadius: 100, fontSize: 11, fontWeight: 600, border: '1px solid #9a1c1c', background: 'rgba(154,28,28,0.06)', color: '#9a1c1c', cursor: 'pointer' }}>⊘ Close</button>
                </div>
              )}
            </div>
          </Panel>
          );
        })}
      </div>

      {/* Edit modal */}
      {/* v141.106 — Force a fresh mount of the modal whenever a different
          venue (or a new edit cycle on the same venue) opens it. The
          `key` includes a timestamp set when setEditing() fires, so even
          editing the same venue twice in a row creates a new instance —
          the modal's useState re-initialises from the latest venue.photo
          / venue.blurb / etc. Before this, returning to edit the same
          venue after a Save resurrected stale form state because React
          treated it as the same component instance. */}
      {editing && (
        <NeighbourhoodEditModal
          key={(editing.venue_slug || 'new') + '@' + (editing._openedAt || '')}
          venue={editing}
          onClose={() => setEditing(null)}
          onSave={saveVenue}
          busy={busy}
        />
      )}
    </div>
  );
}

function NeighbourhoodEditModal({ venue, onClose, onSave, busy }) {
  // v141.106 — Initialise form from the latest venue prop. The parent
  // bumps a `_openedAt` field + uses key={…+_openedAt} so this entire
  // component remounts whenever the operator opens Edit again, even on
  // the same venue. Belt-and-braces useEffect below also resyncs if
  // a parent ever re-renders without remount (which the new key
  // prevents, but the effect is cheap insurance).
  const [form, setForm] = useState(() => ({
    area: venue.area,
    venue_slug: venue.venue_slug || '',
    name: venue.name || '',
    category: venue.category || 'restaurant',
    blurb: venue.blurb || '',
    detail: venue.detail || '',
    photo: venue.photo || '',
    price_band: venue.price_band || '',
    status: 'active',
    sort_order: venue.sort_order || 0,
  }));
  React.useEffect(() => {
    setForm({
      area: venue.area,
      venue_slug: venue.venue_slug || '',
      name: venue.name || '',
      category: venue.category || 'restaurant',
      blurb: venue.blurb || '',
      detail: venue.detail || '',
      photo: venue.photo || '',
      price_band: venue.price_band || '',
      status: 'active',
      sort_order: venue.sort_order || 0,
    });
  }, [venue && venue._openedAt, venue && venue.venue_slug]);
  const set = (k) => (e) => setForm({ ...form, [k]: e.target.value });

  const Modal = (window.TLB_NATIVE_MUTATIONS && window.TLB_NATIVE_MUTATIONS.Modal) || (() => null);
  const Field = (window.TLB_NATIVE_MUTATIONS && window.TLB_NATIVE_MUTATIONS.Field) || (() => null);
  const Input = (window.TLB_NATIVE_MUTATIONS && window.TLB_NATIVE_MUTATIONS.Input) || (() => null);
  const Textarea = (window.TLB_NATIVE_MUTATIONS && window.TLB_NATIVE_MUTATIONS.Textarea) || (() => null);
  const Select = (window.TLB_NATIVE_MUTATIONS && window.TLB_NATIVE_MUTATIONS.Select) || (() => null);
  const PrimaryBtn = (window.TLB_NATIVE_MUTATIONS && window.TLB_NATIVE_MUTATIONS.PrimaryBtn) || (() => null);
  const SecondaryBtn = (window.TLB_NATIVE_MUTATIONS && window.TLB_NATIVE_MUTATIONS.SecondaryBtn) || (() => null);

  return (
    <Modal open={true} onClose={onClose} title={venue._new || !venue.name ? 'Add venue' : 'Edit venue'} subtitle={'Area: ' + (venue.area || '').toUpperCase()} width={680}>
      <Field label="Venue name" required><Input value={form.name} onChange={set('name')} placeholder="e.g. Savaya Bali" /></Field>
      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
        <Field label="Category">
          <Select value={form.category} onChange={set('category')}>
            {['beach_club','restaurant','cafe','bar','temple','beach','surf','culture','yoga','wellness','market','view'].map(c => <option key={c} value={c}>{c.replace(/_/g,' ')}</option>)}
          </Select>
        </Field>
        <Field label="Price band"><Input value={form.price_band} onChange={set('price_band')} placeholder="e.g. IDR 500K day pass" /></Field>
      </div>
      <Field label="Blurb (1 line)" hint="60-90 chars. Shown on the card."><Input value={form.blurb} onChange={set('blurb')} maxLength={120} /></Field>
      <Field label="Detail (3-4 sentences)" hint="Shown on the venue's expanded view."><Textarea value={form.detail} onChange={set('detail')} style={{ minHeight: 120 }} /></Field>
      <Field label="Photo (HD landscape, 1600px wide)" hint="Drag in a JPG/PNG/WEBP, or click to browse. Auto-resized to 1600px wide and self-hosted on our CDN — better for SEO than hot-linking Unsplash.">
        <PhotoUploadField
          value={form.photo}
          onChange={(url) => setForm(f => ({ ...f, photo: url }))}
        />
      </Field>
      <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10, marginTop: 8 }}>
        <SecondaryBtn onClick={onClose}>Cancel</SecondaryBtn>
        <PrimaryBtn onClick={() => onSave(form)} disabled={busy || !form.name.trim()}>{busy ? 'Saving…' : 'Save venue'}</PrimaryBtn>
      </div>
    </Modal>
  );
}

// ── PhotoUploadField (v141.67) ──────────────────────────────────────────
// Drag-drop / click-to-upload zone for venue photos. Replaces the URL-only
// input that the previous round of the modal had. Three improvements:
//
//   1. **Direct file upload** — admin picks a JPG/PNG/WEBP from disk; the
//      browser resizes it to 1600px wide (the size the public
//      /neighbourhood card grid uses) and POSTs the result to /api/upload.
//      That endpoint stores it on Vercel Blob (or Supabase Storage fallback)
//      and returns a permanent public CDN URL we save in the override row.
//
//   2. **Self-hosted = better for SEO** — hot-linked Unsplash URLs cost us
//      authority (the asset doesn't count toward our domain), can be
//      removed by the photographer at any time → broken hero images, and
//      add a third-party DNS round-trip → worse LCP. The SEO audit
//      flagged this in v141.62; this field is the fix.
//
//   3. **URL paste still works** — toggle reveals a URL input for the
//      edge case where the admin really does want a remote source
//      (e.g. brand-provided CDN). Default flow is upload, fallback is paste.
//
// Client-side resize uses <canvas>: keeps requests under the 4 MB API
// limit even when the admin drops a 12 MB iPhone original.
function PhotoUploadField({ value, onChange }) {
  const [uploading, setUploading] = useState(false);
  const [progress, setProgress] = useState(0);
  const [err, setErr] = useState('');
  // v141.117 — track whether the error is a session-expired case so the
  // inline error renders a "Sign in again" button instead of a wall of
  // text. Reset when the user picks a new file or toggles paste mode.
  const [errKind, setErrKind] = useState(null);  // null | 'auth' | 'other'
  const [pasteMode, setPasteMode] = useState(false);
  const [dragOver, setDragOver] = useState(false);
  const inputRef = useRef(null);

  // v141.110 — Resolve the shared Input primitive from
  // window.TLB_NATIVE_MUTATIONS here (in scope) so the paste-URL fallback
  // doesn't throw a ReferenceError. Previously, only the parent
  // NeighbourhoodEditModal destructured Input — PhotoUploadField referenced
  // it without scope, so toggling paste mode crashed React with
  // "Element type is invalid: got undefined". A plain <input> fallback
  // keeps the field usable even if the mutations bundle hasn't loaded yet.
  const InputCmp = (window.TLB_NATIVE_MUTATIONS && window.TLB_NATIVE_MUTATIONS.Input)
    || ((props) => <input {...props} style={{ width: '100%', padding: '10px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 13, ...(props.style || {}) }} />);

  const MAX_DIM = 1600;          // px — matches /neighbourhood card width
  const MAX_API_BYTES = 4 * 1024 * 1024;  // /api/upload limit

  // Resize via canvas → returns a Blob whose long edge is ≤ MAX_DIM.
  // Tries WebP first (best compression), falls back to JPEG quality 0.85.
  function resizeImage(file) {
    return new Promise((resolve, reject) => {
      const reader = new FileReader();
      reader.onerror = () => reject(new Error('Could not read file'));
      reader.onload = () => {
        const img = new Image();
        img.onerror = () => reject(new Error('File is not a valid image'));
        img.onload = () => {
          const w0 = img.naturalWidth, h0 = img.naturalHeight;
          const scale = Math.min(1, MAX_DIM / Math.max(w0, h0));
          const w = Math.round(w0 * scale), h = Math.round(h0 * scale);
          const canvas = document.createElement('canvas');
          canvas.width = w; canvas.height = h;
          const ctx = canvas.getContext('2d');
          ctx.drawImage(img, 0, 0, w, h);
          // WebP usually 30% smaller than JPEG at same visual quality.
          const tryFormat = (type, quality) => new Promise(res => canvas.toBlob(b => res(b), type, quality));
          tryFormat('image/webp', 0.85).then(blob => {
            if (blob && blob.size > 0 && blob.size < MAX_API_BYTES) return resolve({ blob, ext: 'webp', contentType: 'image/webp' });
            // WebP unsupported or too large → JPEG
            tryFormat('image/jpeg', 0.85).then(jpg => {
              if (!jpg) return reject(new Error('Browser failed to encode image'));
              if (jpg.size >= MAX_API_BYTES) return reject(new Error('Image too large even after compression. Try a smaller original.'));
              resolve({ blob: jpg, ext: 'jpg', contentType: 'image/jpeg' });
            });
          });
        };
        img.src = reader.result;
      };
      reader.readAsDataURL(file);
    });
  }

  // v141.117 — token resolution helper + silent refresh on 401.
  function readAdminToken() {
    try {
      return localStorage.getItem('tlb_admin_token')
          || localStorage.getItem('tlb_token')
          || '';
    } catch { return ''; }
  }
  function writeAdminToken(t) {
    if (!t) return;
    try {
      // Refresh both buckets so legacy code paths pick it up too.
      localStorage.setItem('tlb_admin_token', t);
      // Only overwrite the legacy bucket when it was already in use —
      // we never want to leak an admin token into a guest-only session.
      if (localStorage.getItem('tlb_token')) localStorage.setItem('tlb_token', t);
    } catch {}
  }
  // Calls /api/auth?action=refresh with the current token; stores + returns
  // a fresh one on success. Returns '' if the current token is invalid
  // beyond recovery (re-login needed).
  async function refreshAdminToken() {
    const current = readAdminToken();
    if (!current) return '';
    try {
      const r = await fetch('/api/auth', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + current },
        body: JSON.stringify({ action: 'refresh' }),
      });
      const j = await r.json().catch(() => ({}));
      if (r.ok && j && j.success && j.token) {
        writeAdminToken(j.token);
        return j.token;
      }
    } catch {}
    return '';
  }

  // Single XHR PUT to /api/upload with progress reporting. Resolves the
  // public URL, rejects with { status, body } so the caller can decide
  // whether to retry after a token refresh.
  function putToUpload(blob, contentType, filename, token) {
    return new Promise((resolve, reject) => {
      const xhr = new XMLHttpRequest();
      xhr.open('PUT', '/api/upload');
      if (token) xhr.setRequestHeader('Authorization', 'Bearer ' + token);
      xhr.setRequestHeader('Content-Type', contentType);
      xhr.setRequestHeader('x-file-name', filename);
      xhr.upload.onprogress = (e) => { if (e.lengthComputable) setProgress(Math.round((e.loaded / e.total) * 100)); };
      xhr.onerror = () => reject({ status: 0, body: { error: 'Network error during upload' } });
      xhr.onload = () => {
        let body; try { body = JSON.parse(xhr.responseText || '{}'); } catch { body = {}; }
        if (xhr.status >= 200 && xhr.status < 300 && body && body.url) {
          resolve(body.url);
        } else {
          reject({ status: xhr.status, body });
        }
      };
      xhr.send(blob);
    });
  }

  async function uploadFile(file) {
    setErr(''); setErrKind(null); setProgress(0); setUploading(true);
    try {
      const allowed = ['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif', 'image/avif'];
      if (!allowed.includes(file.type) && !/\.(jpe?g|png|webp|heic|heif|avif)$/i.test(file.name)) {
        throw new Error('Use JPG, PNG, or WEBP (HEIC also OK)');
      }
      const { blob, ext, contentType } = await resizeImage(file);
      const safeName = (file.name || 'venue').replace(/\.[^.]+$/, '').replace(/[^a-z0-9_-]+/gi, '_').slice(0, 40).toLowerCase();
      const filename = 'neighbourhood_' + safeName + '_' + Date.now() + '.' + ext;

      // v141.117 — Auto-refresh the token on 401 and retry the upload ONCE.
      // Previously a 24h-expired JWT surfaced as a scary "Token expired —
      // Diagnose at /api/upload-diagnostic" message. Now the operator
      // never sees it: we transparently refresh the session token through
      // /api/auth?action=refresh and resend. Only when the refresh ALSO
      // fails (truly invalid) do we surface a friendly "sign in again".
      let token = readAdminToken();
      let url;
      try {
        url = await putToUpload(blob, contentType, filename, token);
      } catch (firstErr) {
        const msg = String(firstErr?.body?.error || '');
        const isAuthFail = firstErr?.status === 401
                        || /token (expired|required|invalid)|jwt|sign.?in/i.test(msg);
        if (isAuthFail) {
          setProgress(0);
          const fresh = await refreshAdminToken();
          if (!fresh) {
            // Refresh failed → tell the user clearly, link to /login.
            const e = new Error('Your session expired. Please sign in again at /login to upload photos.');
            e._signinNeeded = true;
            throw e;
          }
          // Retry exactly once with the fresh token.
          url = await putToUpload(blob, contentType, filename, fresh);
        } else {
          // Non-auth failure — surface provider hints inline, drop the
          // /api/upload-diagnostic mention from the user-facing copy.
          const parts = [];
          if (Array.isArray(firstErr?.body?.providers_tried) && firstErr.body.providers_tried.length) {
            parts.push('Details: ' + firstErr.body.providers_tried.join(' · '));
          }
          if (firstErr?.body?.hint) parts.push('Fix: ' + firstErr.body.hint);
          const base = msg || ('Upload failed: HTTP ' + (firstErr?.status || 0));
          throw new Error(base + (parts.length ? ' — ' + parts.join(' — ') : ''));
        }
      }

      onChange(url);
      setProgress(100);
      const showToast = (window.TLB_NATIVE_MUTATIONS && window.TLB_NATIVE_MUTATIONS.showToast) || function(){};
      showToast('Photo uploaded · self-hosted CDN', 'success');
    } catch (e) {
      setErr(e.message || 'Upload failed');
      setErrKind(e && e._signinNeeded ? 'auth' : 'other');
    } finally {
      setUploading(false);
      // Reset the file input so the same file can be re-picked after an error.
      if (inputRef.current) inputRef.current.value = '';
    }
  }

  function onPick(e) {
    const file = e.target.files && e.target.files[0];
    if (file) uploadFile(file);
  }
  function onDrop(e) {
    e.preventDefault(); setDragOver(false);
    const file = e.dataTransfer.files && e.dataTransfer.files[0];
    if (file) uploadFile(file);
  }

  // v141.110 — Strip whitespace from the incoming URL. Some override rows
  // stored before the v141.90 SUPABASE_URL fix carry trailing newlines —
  // the GET path already cleans them, but defensive normalisation here
  // means the equality check that drives the "Replace" button + the
  // preview <img src> can't be fooled by an invisible \n.
  const cleanValue = typeof value === 'string' ? value.replace(/\s+/g, '') : '';
  const hasPhoto = !!cleanValue;

  return (
    <div>
      {/* Preview row — only when an image is set */}
      {hasPhoto && (
        <div style={{ display: 'flex', gap: 12, alignItems: 'flex-start', marginBottom: 10 }}>
          <img src={cleanValue} alt="" style={{ width: 180, height: 120, objectFit: 'cover', borderRadius: 8, border: '1px solid ' + CC.border }} onError={e => { e.target.style.display='none'; }} />
          <div style={{ flex: 1, minWidth: 0 }}>
            <Sans size={10} weight={600} ls={0.10} upper color={CC.mid} style={{ marginBottom: 4 }}>Current photo</Sans>
            <Sans size={11} color={CC.deep} style={{ wordBreak: 'break-all', lineHeight: 1.4, marginBottom: 8 }}>{cleanValue.length > 100 ? cleanValue.slice(0, 100) + '…' : cleanValue}</Sans>
            <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
              {/* v141.110 — Explicit "Replace photo" button. Previously the
                  only way to swap a photo on an already-edited venue was to
                  scroll past the preview and drop into the dashed zone
                  below — easy to miss, particularly on shorter viewports
                  where the preview hides the zone above the fold. This
                  primary button gives operators an unambiguous affordance:
                  click → file picker → upload → preview updates in place. */}
              <button type="button" onClick={() => !uploading && inputRef.current && inputRef.current.click()}
                style={{ padding: '5px 12px', border: '1px solid ' + CC.ink, background: CC.ink, color: CC.white, fontSize: 10, fontWeight: 600, borderRadius: 100, cursor: uploading ? 'wait' : 'pointer', letterSpacing: '0.10em', textTransform: 'uppercase' }}>
                ↻ Replace photo
              </button>
              <button type="button" onClick={() => onChange('')}
                style={{ padding: '5px 12px', border: '1px solid ' + CC.border, background: CC.white, color: CC.mid, fontSize: 10, fontWeight: 600, borderRadius: 100, cursor: 'pointer', letterSpacing: '0.10em', textTransform: 'uppercase' }}>
                ✕ Remove
              </button>
            </div>
          </div>
        </div>
      )}

      {/* Drag-drop zone */}
      <div
        onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
        onDragLeave={() => setDragOver(false)}
        onDrop={onDrop}
        onClick={() => !uploading && inputRef.current && inputRef.current.click()}
        style={{
          border: '2px dashed ' + (dragOver ? CC.ink : CC.border),
          background: dragOver ? 'rgba(20,20,20,0.03)' : (uploading ? CC.offWhite : CC.white),
          borderRadius: 10,
          padding: '24px 18px',
          textAlign: 'center',
          cursor: uploading ? 'wait' : 'pointer',
          transition: 'border-color .15s, background .15s',
        }}
      >
        <input ref={inputRef} type="file" accept="image/jpeg,image/png,image/webp,image/heic,image/heif,image/avif" onChange={onPick} style={{ display: 'none' }} disabled={uploading} />
        {uploading ? (
          <>
            <Sans size={12} weight={600} color={CC.ink} style={{ marginBottom: 6 }}>Uploading… {progress}%</Sans>
            <div style={{ height: 4, background: CC.border, borderRadius: 100, overflow: 'hidden', maxWidth: 240, margin: '0 auto' }}>
              <div style={{ height: '100%', width: progress + '%', background: CC.ink, transition: 'width .2s' }} />
            </div>
          </>
        ) : (
          <>
            <Sans size={13} weight={500} color={CC.ink} style={{ marginBottom: 4 }}>
              {hasPhoto ? 'Drop a new photo here, or click to replace' : 'Drop a photo here, or click to browse'}
            </Sans>
            <Sans size={11} color={CC.mid}>JPG / PNG / WEBP · auto-resized to 1600px · self-hosted CDN</Sans>
          </>
        )}
      </div>

      {/* Error — v141.117: session-expired case shows an inline "Sign in
          again" affordance instead of a wall of scary text. Other errors
          render in plain red as before. */}
      {err && (
        <div style={{
          marginTop: 8, padding: '10px 14px',
          background: errKind === 'auth' ? '#fdf6e9' : '#fbf3f3',
          border: '1px solid ' + (errKind === 'auth' ? '#b8923a' : '#9a1c1c'),
          borderRadius: 6, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap',
        }}>
          <Sans size={11} color={errKind === 'auth' ? '#7a5a1f' : '#9a1c1c'} style={{ flex: '1 1 200px' }}>{err}</Sans>
          {errKind === 'auth' && (
            <a href="/login?next=/dashboards/admin" target="_self"
              style={{
                padding: '6px 14px', borderRadius: 100, fontSize: 10, fontWeight: 700,
                letterSpacing: '0.12em', textTransform: 'uppercase',
                background: CC.ink, color: CC.white, textDecoration: 'none', whiteSpace: 'nowrap',
              }}>Sign in again →</a>
          )}
        </div>
      )}

      {/* Paste-URL fallback (collapsed by default) */}
      <div style={{ marginTop: 10 }}>
        <button type="button" onClick={() => setPasteMode(m => !m)}
          style={{ background: 'transparent', border: 'none', color: CC.mid, fontSize: 11, cursor: 'pointer', padding: 0, textDecoration: 'underline' }}>
          {pasteMode ? '↑ Hide URL paste' : '↓ Or paste a URL instead'}
        </button>
        {pasteMode && (
          <div style={{ marginTop: 8 }}>
            <InputCmp
              value={cleanValue}
              onChange={(e) => onChange((e.target.value || '').trim())}
              placeholder="https://images.unsplash.com/photo-…?w=1600&q=85"
            />
            <Sans size={10} color={CC.mid} style={{ marginTop: 6, lineHeight: 1.5 }}>
              SEO note: hot-linked images don't count toward our domain authority and add a third-party DNS lookup. Upload above when possible.
            </Sans>
          </div>
        )}
      </div>
    </div>
  );
}

// ════════════════════════════════════════════════════════════════════════
// HOST SECTIONS
// ════════════════════════════════════════════════════════════════════════

function HostProperties() {
  const { data, loading, error, refetch } = useApi('/api/properties?status=all', { tokenType: 'host' });
  const [wizardOpen, setWizardOpen] = useState(false);
  const [importOpen, setImportOpen] = useState(false);
  const [syncBusy, setSyncBusy] = useState(false);
  // v141.143 — these were declared AFTER the loading/error early returns
  // below, so on first render (loading=true) they never ran, then ran
  // once data arrived → "rendered more hooks than previous render" (React
  // #310). That crashed the whole Properties section via the error
  // boundary, taking the "Import from OTA" button + TLS import modal with
  // it — which is why pasting a theluxurysignature.com URL "did nothing".
  // All hooks MUST be unconditional and before any early return.
  const [statusFilter, setStatusFilter] = useState('all');
  const [search, setSearch] = useState('');
  // Auto-refetch when the inline AddPropertyWizard finishes successfully
  useEffect(() => {
    const onCreated = () => refetch();
    window.addEventListener('tlb-villa-created', onCreated);
    return () => window.removeEventListener('tlb-villa-created', onCreated);
  }, []);

  // ── Sync-to-Server (parity with classic dashboard) ────────────────
  // Re-pushes every villa from this browser's localStorage to the
  // database. Recovery flow for villas saved offline (SW cached the
  // POST but the network failed before it landed). Idempotent —
  // duplicate IDs upsert.
  function syncToServer() {
    if (syncBusy) return;
    if (!confirm('Re-push every locally-cached villa to the server? This is a recovery flow — duplicates are upserted, no data is lost.')) return;
    setSyncBusy(true);
    let cached = [];
    try {
      // Same key the classic dashboard writes to — keep parity
      const raw = localStorage.getItem('tlb_props') || localStorage.getItem('hostProperties') || '[]';
      cached = JSON.parse(raw);
      if (!Array.isArray(cached)) cached = [];
    } catch (e) { cached = []; }
    if (cached.length === 0) {
      const showToast = (window.TLB_NATIVE_MUTATIONS && window.TLB_NATIVE_MUTATIONS.showToast) || function(){};
      showToast('No locally-cached villas to sync.', 'neutral');
      setSyncBusy(false);
      return;
    }
    let token = ''; try { token = localStorage.getItem('tlb_host_token') || localStorage.getItem('tlb_token') || ''; } catch {}
    let ok = 0, fail = 0;
    Promise.all(cached.map(v =>
      fetch('/api/properties', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', ...(token ? { 'Authorization': 'Bearer ' + token } : {}) },
        body: JSON.stringify(v),
      }).then(r => r.ok ? ok++ : fail++).catch(() => fail++)
    )).then(() => {
      const showToast = (window.TLB_NATIVE_MUTATIONS && window.TLB_NATIVE_MUTATIONS.showToast) || function(){};
      showToast('Sync complete · ' + ok + ' uploaded · ' + fail + ' failed', fail ? 'danger' : 'success');
      refetch();
    }).finally(() => setSyncBusy(false));
  }

  if (loading) return <SectionWrap title="Properties"><Loading msg="Loading your portfolio…" /></SectionWrap>;
  if (error) return <SectionWrap title="Properties"><ErrorState msg={error} onRetry={refetch} /></SectionWrap>;

  const villas = (data && data.properties) || [];
  const Wizard = (window.TLB_NATIVE_MUTATIONS && window.TLB_NATIVE_MUTATIONS.AddPropertyWizard) || (() => null);
  const ImportModal = (window.TLB_NATIVE_MUTATIONS && (window.TLB_NATIVE_MUTATIONS.ImportFromOTAModal || window.TLB_NATIVE_MUTATIONS.ImportTtdModal)) || (() => null);

  // v141.127 — Filter + search state (declared above, before early
  // returns — see v141.143 note).
  const statusCounts = {
    all:    villas.length,
    live:   villas.filter(v => (v.status || '').toLowerCase() === 'live' || (v.status || '').toLowerCase() === 'published' || (v.status || '').toLowerCase() === 'active').length,
    draft:  villas.filter(v => (v.status || '').toLowerCase() === 'draft').length,
    hidden: villas.filter(v => (v.status || '').toLowerCase() === 'hidden' || (v.status || '').toLowerCase() === 'inactive').length,
  };
  const filtered = villas.filter(v => {
    if (statusFilter !== 'all') {
      const s = (v.status || '').toLowerCase();
      if (statusFilter === 'live' && !(s === 'live' || s === 'published' || s === 'active')) return false;
      if (statusFilter === 'draft' && s !== 'draft') return false;
      if (statusFilter === 'hidden' && !(s === 'hidden' || s === 'inactive')) return false;
    }
    if (search) {
      const q = search.toLowerCase();
      if (!((v.name || '').toLowerCase().includes(q) || (v.location || '').toLowerCase().includes(q) || (v.slug || v.url_slug || '').toLowerCase().includes(q))) return false;
    }
    return true;
  });

  return (
    <SectionWrap
      title="Properties"
      subtitle="Your villa portfolio. Tap a card to edit listing details, photos, rates, and OTA mappings."
      actions={
        <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
          <button onClick={syncToServer} disabled={syncBusy}
            title="Re-push locally cached villas to the database (recovery flow)"
            style={{ padding: '8px 14px', borderRadius: 100, cursor: syncBusy ? 'wait' : 'pointer', background: CC.white, color: CC.mid, border: `1px solid ${CC.border}`, fontFamily: F_TLB.ui, fontSize: 11, fontWeight: 500, letterSpacing: '0.04em', opacity: syncBusy ? 0.6 : 1 }}>
            {syncBusy ? 'Syncing…' : 'Sync to server'}
          </button>
          <button onClick={() => setImportOpen(true)}
            style={{ padding: '10px 18px', borderRadius: 100, cursor: 'pointer', background: CC.white, color: '#9a7d3f', border: '1px solid rgba(154,125,63,0.4)', fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600 }}>
            Import from OTA
          </button>
          <button onClick={() => setWizardOpen(true)}
            style={{ padding: '10px 18px', borderRadius: 100, cursor: 'pointer', background: CC.ink, color: CC.white, border: '1px solid ' + CC.ink, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600 }}>
            Add property
          </button>
        </div>
      }>
      <Wizard open={wizardOpen} onClose={() => setWizardOpen(false)} />
      <ImportModal open={importOpen} onClose={() => setImportOpen(false)} />

      {/* Eyebrow stats + filter chips + search */}
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 18, gap: 14, flexWrap: 'wrap' }}>
        <Sans size={10} weight={600} ls={0.14} upper color={CC.mid}>
          {villas.length} {villas.length === 1 ? 'listing' : 'listings'} · {statusCounts.live} live · {statusCounts.draft} draft · {statusCounts.hidden} hidden
        </Sans>
        <input
          type="text" value={search} onChange={(e) => setSearch(e.target.value)}
          placeholder="Search by name, area, slug…"
          style={{
            padding: '8px 14px', borderRadius: 100, minWidth: 240,
            border: '1px solid ' + CC.border, background: CC.white,
            fontFamily: F_TLB.ui, fontSize: 12, color: CC.ink, outline: 'none',
          }}
        />
      </div>

      <div style={{ marginBottom: dashGap() }}>
        <FilterChips value={statusFilter} onChange={setStatusFilter} options={[
          { v: 'all',    label: `All · ${statusCounts.all}` },
          { v: 'live',   label: `Live · ${statusCounts.live}` },
          { v: 'draft',  label: `Draft · ${statusCounts.draft}` },
          { v: 'hidden', label: `Hidden · ${statusCounts.hidden}` },
        ]} />
      </div>

      {filtered.length === 0 ? (
        <Empty
          title={search ? `No villas match "${search}"` : statusFilter === 'all' ? 'No villas yet' : 'No villas match this filter'}
          msg={search ? 'Try a different search term, or clear it to see all villas.' : statusFilter === 'all' ? 'Click "Add property" to create your first listing — the three-step wizard saves as a draft you can polish before publishing.' : 'Try another tab.'}
        />
      ) : (
        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: dashGap() }}>
          {filtered.map((v, i) => (
            <VillaCardWithSync key={v.id || i} villa={v} onSynced={refetch} />
          ))}
        </div>
      )}
    </SectionWrap>
  );
}

// v89 Phase 1 — per-villa card with sync status badge + manual sync trigger.
// Pulls last sync metadata from the villa row + recent rate_history for
// trend indicator. "Sync now" button POSTs /api/sync-now and refreshes
// the parent grid on success.
function VillaCardWithSync({ villa, onSynced }) {
  const [syncing, setSyncing] = useState(false);
  const [synced, setSynced] = useState(null); // 'ok' | 'fail'
  const [deleting, setDeleting] = useState(false);

  function deleteVilla() {
    if (deleting) return;
    // v141.189 — in-app confirm. Native confirm() is silently
    // suppressed in embedded/preview contexts and after Chrome's
    // "prevent this page from creating more dialogs", which made the
    // Delete button look dead ("tidak merespon"). tlbConfirm always
    // renders its own overlay.
    const showToast = (window.TLB_NATIVE_MUTATIONS && window.TLB_NATIVE_MUTATIONS.showToast)
      || function (m) { try { alert(m); } catch (_) {} };
    window.tlbConfirm('Delete "' + (villa.name || 'this villa') + '"? This cannot be undone.', { danger: true, confirmText: 'Delete' }).then((ok) => {
      if (!ok) return;
      setDeleting(true);
      let token = ''; try { token = localStorage.getItem('tlb_host_token') || localStorage.getItem('tlb_token') || ''; } catch {}
      fetch('/api/properties?id=' + encodeURIComponent(villa.id), {
        method: 'DELETE',
        headers: { ...(token ? { 'Authorization': 'Bearer ' + token } : {}) },
      })
        // DELETE may return 204 No Content → r.json() throws even
        // though it succeeded. Treat parse failure as empty body so a
        // successful delete is never misreported as a network error.
        .then(r => r.json().then(j => ({ ok: r.ok, j })).catch(() => ({ ok: r.ok, j: {} })))
        .then(({ ok: rok, j }) => {
          if (rok) {
            showToast('Property deleted.', 'success');
            if (typeof onSynced === 'function') setTimeout(onSynced, 400);
          } else {
            showToast('Delete failed: ' + (j.error || 'please try again'), 'danger');
            setDeleting(false);
          }
        })
        .catch(() => {
          showToast('Delete failed — network error', 'danger');
          setDeleting(false);
        });
    });
  }

  const lastSync = villa.last_rate_synced_at;
  const status = villa.sync_status || (lastSync ? 'ok' : 'pending');
  const lastSyncRel = lastSync ? relativeTime(lastSync) : 'never';

  const statusBadge = {
    ok:      { tone: 'success', label: 'Synced ' + lastSyncRel },
    pending: { tone: 'neutral', label: 'Awaiting first sync' },
    stale:   { tone: 'warn',    label: 'Stale · ' + lastSyncRel },
    error:   { tone: 'danger',  label: 'Sync error' },
    paused:  { tone: 'warn',    label: 'Auto-sync paused' },
    review:  { tone: 'warn',    label: 'Rate change review' },
  }[status] || { tone: 'neutral', label: status };

  function syncNow() {
    if (syncing) return;
    setSyncing(true); setSynced(null);
    let token = ''; try { token = localStorage.getItem('tlb_host_token') || localStorage.getItem('tlb_token') || ''; } catch {}
    fetch('/api/sync-now', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', ...(token ? { 'Authorization': 'Bearer ' + token } : {}) },
      body: JSON.stringify({ property_id: villa.id, url: villa.source_url, ical_url: villa.ical_url }),
    })
      .then(r => r.json().then(j => ({ ok: r.ok, j })))
      .then(({ ok }) => {
        setSynced(ok ? 'ok' : 'fail');
        if (ok && typeof onSynced === 'function') setTimeout(onSynced, 800);
      })
      .catch(() => setSynced('fail'))
      .finally(() => setSyncing(false));
  }

  // v91 — promo strip if villa has a savings_pct attached (Elite Havens flash sale, etc.)
  const promoPct = parseInt(villa.promo_savings_pct || 0, 10);
  const promoTitle = villa.promo_title || '';
  const promoBookBy = villa.promo_book_by || '';
  const promoStayBy = villa.promo_stay_by || '';
  const promoRate = parseFloat(villa.promo_rate || 0);
  const headlineRate = parseFloat(villa.headline_rate || villa.nightly_rate || 0);

  return (
    <Panel pad={0} style={{ overflow: 'hidden', position: 'relative' }}>
      {/* v91 promo ribbon — green corner badge when villa has active promo */}
      {promoPct > 0 && (
        <div style={{ position: 'absolute', top: 12, left: 12, zIndex: 2, background: 'linear-gradient(135deg, #1f6b3a 0%, #2a8a4d 100%)', color: '#fff', padding: '4px 10px', borderRadius: 100, fontSize: 10, fontWeight: 600, letterSpacing: '0.06em', textTransform: 'uppercase', boxShadow: '0 2px 8px rgba(31,107,58,0.3)' }}>
          −{promoPct}% {promoTitle ? '· ' + promoTitle.slice(0, 25) : 'OFF'}
        </div>
      )}
      <div style={{ aspectRatio: '4/3', background: CC.offWhite, backgroundImage: `url('${villa.image || (villa.photos && villa.photos[0]) || ''}')`, backgroundSize: 'cover', backgroundPosition: 'center', position: 'relative' }}>
        {/* v110 Phase 1.5 — health dot indicator. Green/yellow/red based
            on villa.health_status from the daily cron-health snapshot.
            Tooltip shows score + last check time. Click to open
            /admin/health for details. */}
        {(villa.health_status || villa.health_score != null) && (
          <div
            title={'Health: ' + (villa.health_status || 'unknown') + (villa.health_score != null ? ' · score ' + villa.health_score + '/100' : '')}
            style={{
              position: 'absolute', top: 12, right: 12, width: 12, height: 12,
              borderRadius: '50%',
              background: villa.health_status === 'red'    ? '#a01c1c'
                        : villa.health_status === 'yellow' ? '#d4a04a'
                        : '#2a8a4d',
              boxShadow: '0 0 0 2px rgba(255,255,255,0.92), 0 2px 6px rgba(0,0,0,0.3)',
              cursor: 'pointer',
              zIndex: 3,
            }}
            onClick={(e) => { e.stopPropagation(); window.open('/admin/health', '_blank'); }}
          />
        )}
        {/* v141.74 — Quick-delete trash icon overlaid on the card image.
            Host requested a discoverable way to remove villas that were
            mis-entered or are terminated. The existing text "Delete"
            button at the bottom of the card was easy to miss — small,
            text-only, sat in a row of three other secondary actions.
            This icon button is positioned top-LEFT (top-right is taken
            by the health dot) and stays at low opacity until hover, so
            it doesn't dominate the editorial card aesthetic but is
            obviously a destructive action once the host notices it.
            Same confirm + DELETE /api/properties flow as the text
            button — kept that one too for keyboard / mobile users
            who don't get the hover affordance. */}
        <button
          type="button"
          onClick={deleteVilla}
          disabled={deleting}
          title={'Delete "' + (villa.name || 'this villa') + '"'}
          aria-label={'Delete ' + (villa.name || 'villa')}
          style={{
            position: 'absolute', top: 12, left: 12, zIndex: 3,
            width: 32, height: 32, borderRadius: '50%',
            border: 'none',
            background: 'rgba(20,20,20,0.55)',
            backdropFilter: 'blur(8px)',
            color: '#fff',
            cursor: deleting ? 'wait' : 'pointer',
            display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
            opacity: deleting ? 1 : 0.72,
            transition: 'opacity .18s, background .18s, transform .18s',
            boxShadow: '0 2px 8px rgba(0,0,0,0.18)',
          }}
          onMouseEnter={e => {
            if (deleting) return;
            e.currentTarget.style.opacity    = '1';
            e.currentTarget.style.background = 'rgba(154,28,28,0.92)';
            e.currentTarget.style.transform  = 'scale(1.06)';
          }}
          onMouseLeave={e => {
            if (deleting) return;
            e.currentTarget.style.opacity    = '0.72';
            e.currentTarget.style.background = 'rgba(20,20,20,0.55)';
            e.currentTarget.style.transform  = 'scale(1)';
          }}
        >
          {/* Trash icon — Feather-style 16×16, currentColor stroke. */}
          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
            <polyline points="3 6 5 6 21 6" />
            <path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
            <path d="M10 11v6" />
            <path d="M14 11v6" />
            <path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
          </svg>
        </button>
      </div>
      <div style={{ padding: 16 }}>
        <div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, marginBottom: 6 }}>
          <Sans size={14} weight={500} style={{ color: CC.ink }}>{villa.name || 'Villa'}</Sans>
          <Pill tone={statusTone(villa.status)}>{villa.status || 'draft'}</Pill>
        </div>
        <Sans size={11} color={CC.mid} style={{ marginBottom: 10 }}>{villa.location || 'Bali'} · {villa.bedrooms || '?'}BR · sleeps {villa.guests || '?'}</Sans>
        {promoPct > 0 && headlineRate > 0 && promoRate > 0 ? (
          <div style={{ marginBottom: 6 }}>
            <Sans size={13} color={CC.deep} weight={500}>
              {fmtIDR(promoRate)} <span style={{ textDecoration: 'line-through', color: CC.mid, fontWeight: 400, marginLeft: 6 }}>{fmtIDR(headlineRate)}</span>
              <span style={{ color: '#1f6b3a', fontWeight: 600, marginLeft: 6, fontSize: 11 }}>save {promoPct}%</span>
            </Sans>
            {(promoBookBy || promoStayBy) && (
              <Sans size={10} color={CC.mid} style={{ marginTop: 2 }}>
                {promoBookBy ? 'Book by ' + promoBookBy : ''}{promoBookBy && promoStayBy ? ' · ' : ''}{promoStayBy ? 'Stay by ' + promoStayBy : ''}
              </Sans>
            )}
          </div>
        ) : (
          <Sans size={13} color={CC.deep} weight={500}>{villa.nightly_rate ? fmtIDR(villa.nightly_rate) + ' / night' : 'On request'}</Sans>
        )}
        {/* v89 Sync status strip */}
        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 10, paddingTop: 10, borderTop: `1px solid ${CC.border}`, gap: 8 }}>
          <Pill tone={statusBadge.tone}>{statusBadge.label}</Pill>
          {villa.source && <Sans size={9} color={CC.mid} style={{ letterSpacing: '0.10em', textTransform: 'uppercase' }}>via {villa.source}</Sans>}
        </div>
        {villa.sync_error && (
          <Sans size={10} color="#9a1c1c" style={{ marginTop: 6, lineHeight: 1.4 }}>{String(villa.sync_error).slice(0, 100)}</Sans>
        )}
        <div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
          <button onClick={syncNow} disabled={syncing}
            title={villa.source_url ? 'Re-fetch rate + availability from source URL' : 'No source URL — re-import from OTA first'}
            style={{ ...ghostBtn, fontSize: 11, padding: '6px 12px', cursor: syncing ? 'wait' : 'pointer', opacity: !villa.source_url ? 0.5 : 1, border: 'none', background: synced === 'ok' ? 'rgba(31,107,58,0.12)' : synced === 'fail' ? 'rgba(154,28,28,0.12)' : CC.offWhite }}>
            {syncing ? '⟳ Syncing…' : synced === 'ok' ? '✓ Synced' : synced === 'fail' ? '✗ Failed' : '⟳ Sync now'}
          </button>
          {/* v141.78 — `&legacy=1` skips the classic auto-redirect so this
              opens the FULL classic property-edit screen in a new tab.
              Pre-v141.70 it just worked; the v141.70 retirement redirect
              broke it by bouncing to /dashboards/host?section=properties
              which discarded the id deep-link and only showed the grid. */}
          <a href={'/host-classic?tab=properties&id=' + encodeURIComponent(villa.id || '') + '&legacy=1'} target="_blank" style={{ ...ghostBtn, fontSize: 11, padding: '6px 12px' }}>Edit</a>
          <a href={'/villa/' + (villa.url_slug || villa.id)} target="_blank" style={{ ...ghostBtn, fontSize: 11, padding: '6px 12px' }}>Preview</a>
          {/* v141.74 — Trash SVG paired with the "Delete" label so the
              action is recognisable at a glance even when the hover-
              only overlay icon at the top of the card isn't visible
              (touch/keyboard contexts). Hover deepens the red tone so
              hosts get clear destructive-action feedback. */}
          <button type="button" onClick={deleteVilla} disabled={deleting}
            title="Soft-delete this listing (sets status=deleted, keeps DB row for audit)"
            aria-label={'Delete ' + (villa.name || 'villa')}
            style={{
              ...ghostBtn,
              display: 'inline-flex', alignItems: 'center', gap: 6,
              fontSize: 11, padding: '6px 12px',
              cursor: deleting ? 'wait' : 'pointer',
              border: '1px solid rgba(154,28,28,0.25)',
              background: 'rgba(154,28,28,0.06)',
              color: '#9a1c1c',
              marginLeft: 'auto',
              transition: 'background .18s, border-color .18s',
            }}
            onMouseEnter={e => { if (!deleting) { e.currentTarget.style.background = 'rgba(154,28,28,0.14)'; e.currentTarget.style.borderColor = 'rgba(154,28,28,0.45)'; } }}
            onMouseLeave={e => { if (!deleting) { e.currentTarget.style.background = 'rgba(154,28,28,0.06)'; e.currentTarget.style.borderColor = 'rgba(154,28,28,0.25)'; } }}
          >
            <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
              <polyline points="3 6 5 6 21 6" />
              <path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
              <path d="M10 11v6" />
              <path d="M14 11v6" />
              <path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
            </svg>
            {deleting ? 'Deleting…' : 'Delete'}
          </button>
        </div>
      </div>
    </Panel>
  );
}

// Helper — "5 min ago", "2 hr ago", "3 days ago"
function relativeTime(iso) {
  if (!iso) return '—';
  try {
    const diff = (Date.now() - new Date(iso).getTime()) / 1000;
    if (diff < 60) return 'just now';
    if (diff < 3600) return Math.round(diff / 60) + ' min ago';
    if (diff < 86400) return Math.round(diff / 3600) + ' hr ago';
    return Math.round(diff / 86400) + ' days ago';
  } catch { return '—'; }
}

// v141.133 — HostBookings, Phase 4 polish.
// All-bookings view for hosts (HostInbox shows pending only). Same data
// source as HostInbox but no auto-confirm gate — shows every status.
//   • Eyebrow with confirmed/pending/this-month/MTD counts
//   • 4-tile unified border KPI grid
//   • Pill-chip filter (All/Confirmed/Pending/Completed/Cancelled)
//   • Search box (guest, villa, code)
//   • Inline CSV export
//   • Per-row Cancel + refund modal preserved
function HostBookings() {
  const [cancelTarget, setCancelTarget] = useState(null);
  const [statusFilter, setStatusFilter] = useState('all');
  const [search, setSearch] = useState('');
  const { data, loading, error, refetch } = useApi('/api/bookings?status=all', { tokenType: 'host' });
  if (loading) return <SectionWrap title="Bookings"><Loading msg="Loading your bookings…" /></SectionWrap>;
  if (error) return <SectionWrap title="Bookings"><ErrorState msg={error} onRetry={refetch} /></SectionWrap>;

  const bookings = (data && data.bookings) || [];
  const stats = (data && data.stats) || {};
  const counts = {
    all:       bookings.length,
    confirmed: bookings.filter(b => b.status === 'confirmed').length,
    pending:   bookings.filter(b => b.status === 'pending').length,
    completed: bookings.filter(b => b.status === 'completed').length,
    cancelled: bookings.filter(b => b.status === 'cancelled').length,
  };

  const filtered = bookings.filter(b => {
    if (statusFilter !== 'all' && b.status !== statusFilter) return false;
    if (search) {
      const q = search.toLowerCase();
      const blob = ((b.guest_name || '') + ' ' + (b.guest_email || '') + ' ' + (b.property_name || '') + ' ' + (b.confirmation_code || b.id || '')).toLowerCase();
      if (!blob.includes(q)) return false;
    }
    return true;
  });
  const filteredTotal = filtered.reduce((s, b) => s + (parseFloat(b.total_price) || 0), 0);

  function exportCsv() {
    const headers = ['Date', 'Booking', 'Guest', 'Email', 'Villa', 'Check-in', 'Check-out', 'Nights', 'Total IDR', 'Status', 'Payment'];
    const rows = filtered.map(b => [
      b.created_at || '',
      (b.confirmation_code || b.id || '').slice(0, 16),
      b.guest_name || '', b.guest_email || '', b.property_name || '',
      b.check_in || '', b.check_out || '', b.nights || '',
      Math.round(parseFloat(b.total_price) || 0),
      b.status || '', b.payment_status || '',
    ]);
    const csv = [headers, ...rows].map(r => r.map(c => /[",\n]/.test(String(c)) ? '"' + String(c).replace(/"/g, '""') + '"' : c).join(',')).join('\n');
    const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url; a.download = `host-bookings-${new Date().toISOString().slice(0, 10)}.csv`;
    document.body.appendChild(a); a.click(); document.body.removeChild(a);
    URL.revokeObjectURL(url);
  }

  return (
    <SectionWrap
      title="Bookings"
      subtitle="Every booking across your portfolio. Filter by status, search by guest or villa, export your records."
      actions={
        <button onClick={exportCsv} disabled={!filtered.length}
          style={{ padding: '10px 18px', borderRadius: 100, cursor: filtered.length ? 'pointer' : 'not-allowed', background: CC.ink, color: CC.white, border: '1px solid ' + CC.ink, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600, opacity: filtered.length ? 1 : 0.5 }}>
          Export CSV
        </button>
      }
    >
      <Sans size={10} weight={600} ls={0.14} upper color={CC.mid} style={{ marginBottom: 18 }}>
        {counts.all} total · {counts.confirmed} confirmed · {counts.pending} pending · {counts.completed} completed · {counts.cancelled} cancelled
      </Sans>

      {/* 4-tile unified border KPI grid */}
      <div style={{
        display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 0,
        border: `1px solid ${CC.border}`, borderRadius: 18, overflow: 'hidden', background: CC.white, marginBottom: dashGap(),
      }}>
        {[
          { label: 'Confirmed', value: stats.confirmed || counts.confirmed },
          { label: 'Pending',   value: stats.pending || counts.pending },
          { label: 'This month',value: stats.this_month || bookings.filter(b => {
              const t = b.created_at || b.check_in;
              if (!t) return false;
              const d = new Date(t);
              const now = new Date();
              return d.getMonth() === now.getMonth() && d.getFullYear() === now.getFullYear();
            }).length },
          { label: 'MTD revenue', value: fmtIDR(stats.mtd_revenue || filteredTotal) },
        ].map((k, i) => (
          <div key={k.label} style={{ padding: '24px 26px', borderRight: i < 3 ? `1px solid ${CC.border}` : 'none', display: 'flex', flexDirection: 'column', gap: 12, minHeight: 130 }}>
            <Sans size={10} weight={600} ls={0.14} upper color={CC.mid}>{k.label}</Sans>
            <div style={{
              fontFamily: F_TLB.ui, fontSize: 26, fontWeight: 600,
              letterSpacing: '-0.025em', color: CC.ink,
              fontVariantNumeric: 'tabular-nums', lineHeight: 0.98,
              marginTop: 'auto',
            }}>{k.value}</div>
          </div>
        ))}
      </div>

      {/* Filter chips + search */}
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 18, gap: 14, flexWrap: 'wrap' }}>
        <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
          {[
            { v: 'all',       label: 'All',       count: counts.all },
            { v: 'confirmed', label: 'Confirmed', count: counts.confirmed },
            { v: 'pending',   label: 'Pending',   count: counts.pending },
            { v: 'completed', label: 'Completed', count: counts.completed },
            { v: 'cancelled', label: 'Cancelled', count: counts.cancelled },
          ].map(t => {
            const on = statusFilter === t.v;
            return (
              <button key={t.v} onClick={() => setStatusFilter(t.v)} style={{
                padding: '8px 14px', borderRadius: 100, cursor: 'pointer',
                background: on ? CC.ink : 'transparent',
                color: on ? CC.white : CC.deep,
                border: '1px solid ' + (on ? CC.ink : CC.border),
                fontFamily: F_TLB.ui, fontSize: 11.5, fontWeight: 600, letterSpacing: '0.02em',
              }}>
                {t.label}<span style={{ opacity: 0.7, marginLeft: 6, fontSize: 10.5 }}>{t.count}</span>
              </button>
            );
          })}
        </div>
        <input
          type="text" value={search} onChange={e => setSearch(e.target.value)}
          placeholder="Find a booking…"
          style={{
            padding: '8px 14px', borderRadius: 100, minWidth: 240,
            border: '1px solid ' + CC.border, background: CC.white,
            fontFamily: F_TLB.ui, fontSize: 12, color: CC.ink, outline: 'none',
          }}
        />
      </div>

      <Table
        columns={[
          { label: 'Code', render: r => <span style={{ fontFamily: 'ui-monospace, monospace', fontSize: 11, color: CC.mid, fontWeight: 600 }}>{(r.confirmation_code || r.id || '').slice(0, 8)}</span> },
          { label: 'Guest', render: r => (
            <div>
              <div style={{ fontWeight: 600, color: CC.ink }}>{r.guest_name || 'Guest'}</div>
              <div style={{ fontSize: 11, color: CC.mid, marginTop: 2 }}>{r.guest_email || '—'}</div>
            </div>
          )},
          { label: 'Villa', render: r => <span style={{ color: CC.deep }}>{r.property_name || '—'}</span> },
          { label: 'Dates', nowrap: true, render: r => (
            <div>
              <div>{fmtDate(r.check_in)} → {fmtDate(r.check_out)}</div>
              <div style={{ fontSize: 11, color: CC.mid, marginTop: 2 }}>{r.nights || '—'} {r.nights === 1 ? 'night' : 'nights'}</div>
            </div>
          )},
          { label: 'Total', align: 'right', numeric: true, render: r => (
            <span style={{ fontFamily: F_TLB.display, fontStyle: 'italic', fontSize: 16 }}>
              {fmtIDR(parseFloat(r.total_price) || 0)}
            </span>
          )},
          { label: 'Booking', render: r => <Pill tone={statusTone(r.status)}>{r.status}</Pill> },
          { label: 'Payment', render: r => {
            const ps = r.payment_status || (r.status === 'paid' || r.status === 'completed' ? 'paid' : (r.status === 'confirmed' ? 'pending' : null));
            if (!ps) return <span style={{ color: CC.mid, fontSize: 11 }}>—</span>;
            return <Pill tone={statusTone(ps)}>{ps}</Pill>;
          }},
          { label: '', render: r => {
            const cancellable = (r.status === 'confirmed' || r.status === 'paid') && (r.payment_status === 'paid' || r.payment_status === 'deposit_paid');
            if (!cancellable) return null;
            return <button onClick={() => setCancelTarget(r)}
              style={{ padding: '6px 12px', borderRadius: 100, cursor: 'pointer', background: CC.white, color: '#9a1c1c', border: '1px solid #d6b3b3', fontFamily: F_TLB.ui, fontSize: 10.5, fontWeight: 600 }}>Cancel</button>;
          }},
        ]}
        rows={filtered.slice(0, 50)}
        empty={search ? `No bookings match "${search}".` : (statusFilter === 'all' ? 'No bookings yet — your first guest will appear here.' : `No ${statusFilter} bookings.`)}
      />
      {filtered.length > 50 && <Sans size={11} color={CC.mid} style={{ marginTop: 12, textAlign: 'center' }}>Showing 50 of {filtered.length}. Refine the filter or search.</Sans>}
      {cancelTarget && <CancellationModal booking={cancelTarget} role="host" onClose={() => setCancelTarget(null)} onSuccess={refetch} />}
    </SectionWrap>
  );
}

// v141.126 — HostReviews, v141.123 mockup port.
//   • Eyebrow: "X published · ★ avg · N awaiting your response"
//   • Header CTAs: Filter (chip dropdown) + Reply all (bulk modal)
//   • Cards: villa eyebrow + guest + when + star rating + italic quote
//     + awaiting/replied footer with Write/View reply action
//   • Gold accent border on awaiting cards (matches Panel accent pattern)
//   • Inline reply textarea preserved with publish action
//   • All 4 states (loading / error / empty / populated)
function HostReviews() {
  const { data, loading, error, refetch } = useApi('/api/reviews?for=host', { tokenType: 'host' });
  const [filter, setFilter] = useState('needs_response');
  const [draft, setDraft] = useState({});
  const [submitting, setSubmitting] = useState(null);
  const [expanded, setExpanded] = useState(null);
  const [bulkReplyOpen, setBulkReplyOpen] = useState(false);

  if (loading) return <SectionWrap title="Reviews"><Loading msg="Loading guest reviews…" /></SectionWrap>;
  if (error)   return <SectionWrap title="Reviews"><ErrorState msg={error} onRetry={refetch} /></SectionWrap>;

  const allReviews = (data && data.reviews) || [];
  const avg = allReviews.length ? (allReviews.reduce((s, r) => s + (parseFloat(r.rating) || 0), 0) / allReviews.length).toFixed(2) : '—';
  const needsResponseCount = allReviews.filter(r => !r.host_response).length;

  const reviews = filter === 'needs_response' ? allReviews.filter(r => !r.host_response)
                : filter === 'responded' ? allReviews.filter(r => r.host_response)
                : allReviews;

  async function submitResponse(reviewId, text) {
    const body = (text != null ? text : (draft[reviewId] || '')).trim();
    if (!body) return;
    setSubmitting(reviewId);
    const token = localStorage.getItem('tlb_host_token') || localStorage.getItem('tlb_token') || '';
    try {
      const r = await fetch('/api/reviews', {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token },
        body: JSON.stringify({ id: reviewId, host_response: body }),
      });
      if (r.ok) {
        setDraft(d => { const n = { ...d }; delete n[reviewId]; return n; });
        setExpanded(null);
        refetch();
      } else {
        const j = await r.json().catch(() => ({}));
        window.alert('Failed: ' + (j.error || r.statusText));
      }
    } finally { setSubmitting(null); }
  }

  return (
    <SectionWrap
      title="Reviews"
      subtitle="Replied reviews surface higher and earn more direct bookings. Take five minutes for each."
      dataNeeds={['reviews']}
      actions={
        <div style={{ display: 'flex', gap: 8 }}>
          <button
            onClick={() => setBulkReplyOpen(true)}
            disabled={!needsResponseCount}
            style={{
              padding: '10px 18px', borderRadius: 100, cursor: needsResponseCount ? 'pointer' : 'not-allowed',
              background: CC.ink, color: CC.white, border: '1px solid ' + CC.ink,
              fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600,
              opacity: needsResponseCount ? 1 : 0.5,
            }}
          >Reply all</button>
        </div>
      }
    >
      {/* Eyebrow stats + filter chips */}
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 18, gap: 14, flexWrap: 'wrap' }}>
        <Sans size={10} weight={600} ls={0.14} upper color={CC.mid}>
          {allReviews.length} published · ★ {avg} average · {needsResponseCount} awaiting your response
        </Sans>
        <div style={{ display: 'flex', gap: 6 }}>
          {[
            { v: 'needs_response', label: 'Needs response', count: needsResponseCount },
            { v: 'responded',      label: 'Responded',      count: allReviews.length - needsResponseCount },
            { v: 'all',            label: 'All',            count: allReviews.length },
          ].map(o => (
            <button key={o.v} onClick={() => setFilter(o.v)} style={{
              padding: '8px 14px', borderRadius: 100, cursor: 'pointer',
              fontFamily: F_TLB.ui, fontSize: 11.5, fontWeight: 600, letterSpacing: '0.02em',
              border: '1px solid ' + (filter === o.v ? CC.ink : CC.border),
              background: filter === o.v ? CC.ink : 'transparent',
              color: filter === o.v ? CC.white : CC.deep,
            }}>
              {o.label}<span style={{ opacity: 0.7, marginLeft: 6, fontSize: 10.5 }}>{o.count}</span>
            </button>
          ))}
        </div>
      </div>

      {reviews.length === 0 ? (
        <Empty
          title={filter === 'needs_response' ? 'All caught up' : 'No reviews'}
          msg={filter === 'needs_response' ? 'Every guest review has a host response. Nice work.' : 'Reviews appear here after guests check out and submit feedback.'}
        />
      ) : (
        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 18 }}>
          {reviews.slice(0, 24).map((r) => {
            const awaiting = !r.host_response;
            const rating = Math.round(parseFloat(r.rating) || 0);
            const isExpanded = expanded === r.id;
            return (
              <Panel key={r.id} pad={26} accent={awaiting}>
                <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }}>
                  <div>
                    <Sans size={10} weight={600} ls={0.14} upper color={CC.mid}>{r.property_name || 'Villa'}</Sans>
                    <div style={{ marginTop: 8, fontFamily: F_TLB.ui, fontSize: 13, fontWeight: 600 }}>{r.guest_name || r.author || 'Guest'}</div>
                    <div style={{ fontFamily: F_TLB.ui, fontSize: 11, color: CC.mid, marginTop: 2 }}>{fmtDate(r.created_at)}</div>
                  </div>
                  <div style={{ fontFamily: F_TLB.display, fontSize: 18, letterSpacing: '0.04em', whiteSpace: 'nowrap' }}>
                    {'★'.repeat(rating)}<span style={{ color: CC.border }}>{'★'.repeat(Math.max(0, 5 - rating))}</span>
                  </div>
                </div>
                <p style={{
                  margin: '16px 0 0', fontFamily: F_TLB.display, fontStyle: 'italic',
                  fontSize: 16, color: CC.deep, lineHeight: 1.6, letterSpacing: '-0.005em',
                }}>
                  "{r.body || r.text || r.comment || ''}"
                </p>

                {awaiting ? (
                  <div style={{ marginTop: 18, paddingTop: 14, borderTop: `1px solid ${CC.border}` }}>
                    {!isExpanded ? (
                      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
                        <Pill tone="warn">Awaiting your response</Pill>
                        <button onClick={() => setExpanded(r.id)} style={{
                          padding: '6px 14px', borderRadius: 100, cursor: 'pointer',
                          background: CC.ink, color: CC.white, border: '1px solid ' + CC.ink,
                          fontFamily: F_TLB.ui, fontSize: 11, fontWeight: 600,
                        }}>Write reply</button>
                      </div>
                    ) : (
                      <div>
                        <Sans size={10} weight={700} ls={0.14} upper color={CC.mid} style={{ marginBottom: 6 }}>Reply publicly</Sans>
                        <textarea
                          value={draft[r.id] || ''}
                          onChange={e => setDraft(d => ({ ...d, [r.id]: e.target.value }))}
                          rows={4}
                          placeholder="Thank the guest warmly. Address specific feedback. Be brief. This shows on the public villa page."
                          style={{
                            width: '100%', padding: '10px 12px', borderRadius: 8,
                            border: '1px solid ' + CC.border, fontSize: 13,
                            fontFamily: 'inherit', resize: 'vertical', outline: 'none',
                          }}
                        />
                        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 8 }}>
                          <Sans size={10} color={CC.mid}>{(draft[r.id] || '').length} / 5000 · public</Sans>
                          <div style={{ display: 'flex', gap: 6 }}>
                            <button onClick={() => { setExpanded(null); setDraft(d => { const n = { ...d }; delete n[r.id]; return n; }); }}
                              style={{ padding: '6px 12px', borderRadius: 100, cursor: 'pointer', background: 'transparent', color: CC.deep, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 11, fontWeight: 600 }}>
                              Cancel
                            </button>
                            <button
                              onClick={() => submitResponse(r.id)}
                              disabled={submitting === r.id || !((draft[r.id] || '').trim())}
                              style={{
                                padding: '6px 14px', borderRadius: 100, cursor: 'pointer',
                                background: CC.ink, color: CC.white, border: '1px solid ' + CC.ink,
                                fontFamily: F_TLB.ui, fontSize: 11, fontWeight: 600,
                                opacity: (submitting === r.id || !((draft[r.id] || '').trim())) ? 0.5 : 1,
                              }}
                            >{submitting === r.id ? 'Posting…' : 'Post reply'}</button>
                          </div>
                        </div>
                      </div>
                    )}
                  </div>
                ) : (
                  <div style={{ marginTop: 18, paddingTop: 14, borderTop: `1px solid ${CC.border}` }}>
                    <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
                      <Pill tone="success">Replied</Pill>
                      <Sans size={10} weight={600} ls={0.14} upper color={CC.mid}>{fmtDate(r.host_response_at)}</Sans>
                    </div>
                    <div style={{ padding: '10px 14px', background: CC.offWhite, borderRadius: 8 }}>
                      <Sans size={12.5} color={CC.deep} style={{ lineHeight: 1.55, fontStyle: 'italic' }}>"{r.host_response}"</Sans>
                    </div>
                  </div>
                )}
              </Panel>
            );
          })}
        </div>
      )}

      {/* Bulk reply modal — drafts a template message, then submits to each pending review */}
      {bulkReplyOpen && (
        <HostBulkReplyModal
          pendingCount={needsResponseCount}
          onClose={() => setBulkReplyOpen(false)}
          onSubmit={async (templateText) => {
            const pending = allReviews.filter(r => !r.host_response);
            for (const r of pending) {
              await submitResponse(r.id, templateText);
            }
            setBulkReplyOpen(false);
          }}
        />
      )}
    </SectionWrap>
  );
}

// Bulk reply modal — for hosts staring down 20+ pending reviews
function HostBulkReplyModal({ pendingCount, onClose, onSubmit }) {
  const [text, setText] = React.useState('Thank you so much for staying with us. Your feedback means a lot — we look forward to welcoming you back.');
  const [submitting, setSubmitting] = React.useState(false);

  return (
    <div onClick={onClose} style={{ position: 'fixed', inset: 0, background: 'rgba(20,20,20,0.45)', zIndex: 1000, display: 'grid', placeItems: 'center', padding: 24 }}>
      <div onClick={(e) => e.stopPropagation()} style={{ background: CC.white, borderRadius: 18, padding: 32, maxWidth: 540, width: '100%', maxHeight: '90vh', overflow: 'auto', boxShadow: '0 12px 40px rgba(20,20,20,0.18)' }}>
        <Display size={26} italic weight={400}>Reply to all.</Display>
        <Sans size={12} color={CC.mid} style={{ marginTop: 6, marginBottom: 22 }}>
          {pendingCount} {pendingCount === 1 ? 'review needs' : 'reviews need'} a response. We'll send the same message to each — guests notice that you reply, more than what you say.
        </Sans>
        <Sans size={10} weight={700} ls={0.14} upper color={CC.mid} style={{ marginBottom: 6 }}>Template message</Sans>
        <textarea
          rows={5}
          value={text}
          onChange={(e) => setText(e.target.value)}
          placeholder="Thank guests warmly. Address common feedback. Keep it brief."
          style={{
            width: '100%', padding: '12px 14px', borderRadius: 10,
            border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 13,
            color: CC.ink, background: CC.white, resize: 'vertical', outline: 'none',
          }}
        />
        <Sans size={10} color={CC.mid} style={{ marginTop: 8 }}>{text.length} / 5000 · this exact text appears under each review, public</Sans>
        <div style={{ display: 'flex', gap: 10, marginTop: 22, justifyContent: 'flex-end' }}>
          <button onClick={onClose} disabled={submitting} style={{ padding: '10px 18px', borderRadius: 100, cursor: 'pointer', background: 'transparent', color: CC.deep, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600 }}>
            Cancel
          </button>
          <button
            onClick={async () => {
              if (!text.trim()) return;
              if (!confirm(`Send "${text.slice(0, 60).trim()}…" to ${pendingCount} reviews?`)) return;
              setSubmitting(true);
              await onSubmit(text.trim());
              setSubmitting(false);
            }}
            disabled={submitting || !text.trim()}
            style={{
              padding: '10px 22px', borderRadius: 100, cursor: submitting ? 'wait' : 'pointer',
              background: CC.ink, color: CC.white, border: '1px solid ' + CC.ink,
              fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600,
              opacity: submitting || !text.trim() ? 0.5 : 1,
            }}
          >{submitting ? 'Posting…' : `Reply to ${pendingCount}`}</button>
        </div>
      </div>
    </div>
  );
}

// v141.128 — HostPricing, v141.123 mockup port.
//   • Per-villa cards with header + commission + sync pill + Lock action
//   • 4-tier grid per card: Low / High / Peak / NYE
//   • Seasonal-lift reference panel preserved (PHRI 2024 benchmark)
//   • Header CTAs: Apply across all (bulk-set rates modal) + Save changes
//   • All 4 states: loading / error / empty / populated
function HostPricing() {
  const { data, loading, error, refetch } = useApi('/api/properties?status=all', { tokenType: 'host' });
  const [editingId, setEditingId] = React.useState(null);
  const [applyAllOpen, setApplyAllOpen] = React.useState(false);
  const [pendingEdits, setPendingEdits] = React.useState({});  // { villaId: { low, high, peak, nye } }

  if (loading) return <SectionWrap title="Pricing"><Loading msg="Loading rate data…" /></SectionWrap>;
  if (error)   return <SectionWrap title="Pricing"><ErrorState msg={error} onRetry={refetch} /></SectionWrap>;

  const villas = (data && data.properties) || [];

  function parseRates(sr) {
    if (!sr) return null;
    if (typeof sr === 'string') { try { return JSON.parse(sr); } catch { return null; } }
    return sr;
  }
  function rateOrDash(sr, tier) {
    const p = parseRates(sr);
    if (!p || p.on_request) return p && p.on_request ? 'On request' : '—';
    const v = p[tier];
    if (!v) return '—';
    return p.currency === 'IDR' ? fmtIDR(v) : '$' + Math.round(v).toLocaleString();
  }
  function commissionFor(p) {
    if (p.tlb_commission_pct != null && p.tlb_commission_pct !== '') {
      const v = Number(p.tlb_commission_pct);
      if (!isNaN(v) && v >= 0 && v <= 1) return v;
    }
    const src = String(p.source || '').toLowerCase();
    if (/nakula|tln|luxenomad/.test(src)) return 0.20;
    if (/elite[-_ ]?havens|^eh$|^eh_|bestay/.test(src)) return 0.10;
    if (/tropical[-_ ]?door|tampah/.test(src)) return 0.15;
    return 0.15;
  }
  const avgCommission = villas.length
    ? villas.reduce((s, v) => s + commissionFor(v), 0) / villas.length
    : 0.15;

  async function saveAllPending() {
    const ids = Object.keys(pendingEdits);
    if (!ids.length) return;
    const token = localStorage.getItem('tlb_host_token') || localStorage.getItem('tlb_token') || '';
    for (const villaId of ids) {
      const edits = pendingEdits[villaId];
      const villa = villas.find(v => v.id === villaId);
      if (!villa) continue;
      const existing = parseRates(villa.seasonal_rates) || {};
      const next = { ...existing, ...edits };
      try {
        await fetch('/api/properties', {
          method: 'PATCH',
          headers: { 'Content-Type': 'application/json', ...(token ? { 'Authorization': 'Bearer ' + token } : {}) },
          body: JSON.stringify({ id: villaId, seasonal_rates: next }),
        });
      } catch {}
    }
    setPendingEdits({});
    setEditingId(null);
    refetch();
  }

  async function lockAttested(villa) {
    if (!confirm(`Lock "${villa.name}" as host-attested? TLB will skip future parity probes for this villa — your rates won't be auto-corrected.`)) return;
    const token = localStorage.getItem('tlb_host_token') || localStorage.getItem('tlb_token') || '';
    try {
      await fetch('/api/properties', {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json', ...(token ? { 'Authorization': 'Bearer ' + token } : {}) },
        body: JSON.stringify({ id: villa.id, seasonal_rates_locked: true }),
      });
      refetch();
    } catch (e) {
      alert('Failed to lock: ' + (e.message || 'unknown'));
    }
  }

  const hasPending = Object.keys(pendingEdits).length > 0;

  return (
    <SectionWrap
      title="Pricing"
      subtitle="Per-villa seasonal rates. Commission is set by TLB and shown read-only. Lock as attested to skip future parity probes."
      actions={
        <div style={{ display: 'flex', gap: 8 }}>
          <button onClick={() => setApplyAllOpen(true)} disabled={!villas.length}
            style={{ padding: '10px 18px', borderRadius: 100, cursor: villas.length ? 'pointer' : 'not-allowed', background: CC.white, color: CC.deep, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600, opacity: villas.length ? 1 : 0.5 }}>
            Apply across all
          </button>
          <button onClick={saveAllPending} disabled={!hasPending}
            style={{ padding: '10px 18px', borderRadius: 100, cursor: hasPending ? 'pointer' : 'not-allowed', background: CC.ink, color: CC.white, border: '1px solid ' + CC.ink, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600, opacity: hasPending ? 1 : 0.5 }}>
            Save changes{hasPending ? ` · ${Object.keys(pendingEdits).length}` : ''}
          </button>
        </div>
      }>
      <Sans size={10} weight={600} ls={0.14} upper color={CC.mid} style={{ marginBottom: 18 }}>
        {villas.length} villas · avg commission {(avgCommission * 100).toFixed(1)}%
      </Sans>

      {villas.length === 0 ? (
        <Empty title="No villas yet" msg="Add a property and seasonal rates will appear here." />
      ) : (
        <div style={{ display: 'grid', gap: 18 }}>
          {villas.slice(0, 30).map(v => {
            const c = commissionFor(v);
            const explicit = v.tlb_commission_pct != null && v.tlb_commission_pct !== '';
            const isEditing = editingId === v.id;
            const parsed = parseRates(v.seasonal_rates) || {};
            const localEdits = pendingEdits[v.id] || {};
            const tiers = [
              { key: 'low',  label: 'Low season',  dates: 'Jan, Feb, Nov' },
              { key: 'high', label: 'High season', dates: 'Mar–Oct' },
              { key: 'peak', label: 'Peak',        dates: 'Dec, Easter' },
              { key: 'nye',  label: 'NYE',         dates: '5 nights min' },
            ];
            return (
              <Panel key={v.id} pad={28}>
                <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', marginBottom: 24, gap: 16, flexWrap: 'wrap' }}>
                  <div>
                    <Sans size={10} weight={600} ls={0.14} upper color={CC.mid}>
                      {(v.location || 'Bali').split(',')[0].trim()} · {v.bedrooms || v.beds || '?'} {v.bedrooms === 1 ? 'bedroom' : 'bedrooms'}
                    </Sans>
                    <Display size={24} weight={500} ls={-0.015} style={{ marginTop: 10 }}>{v.name}</Display>
                  </div>
                  <div style={{ display: 'flex', gap: 18, alignItems: 'center' }}>
                    <div>
                      <Sans size={9.5} weight={600} ls={0.18} upper color={CC.mid}>Commission</Sans>
                      <div style={{ fontFamily: F_TLB.display, fontSize: 20, marginTop: 6, color: c <= 0.10 ? '#2d6a4f' : c >= 0.20 ? '#9a1c1c' : CC.ink }}>
                        {(c * 100).toFixed(0)}%{explicit ? ' ✓' : ''}
                      </div>
                    </div>
                    <div>
                      <Sans size={9.5} weight={600} ls={0.18} upper color={CC.mid}>Sync</Sans>
                      <div style={{ marginTop: 6 }}>
                        <Pill tone={v.seasonal_rates_locked ? 'info' : 'success'}>
                          {v.seasonal_rates_locked ? 'Host-attested' : 'Synced'}
                        </Pill>
                      </div>
                    </div>
                    {!v.seasonal_rates_locked && (
                      <button onClick={() => lockAttested(v)}
                        style={{ padding: '8px 14px', borderRadius: 100, cursor: 'pointer', background: CC.white, color: CC.deep, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 11, fontWeight: 600 }}>
                        Lock as attested
                      </button>
                    )}
                    {!isEditing && (
                      <button onClick={() => setEditingId(v.id)}
                        style={{ padding: '8px 14px', borderRadius: 100, cursor: 'pointer', background: CC.white, color: CC.deep, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 11, fontWeight: 600 }}>
                        Edit rates
                      </button>
                    )}
                    {isEditing && (
                      <button onClick={() => { setEditingId(null); setPendingEdits(p => { const n = { ...p }; delete n[v.id]; return n; }); }}
                        style={{ padding: '8px 14px', borderRadius: 100, cursor: 'pointer', background: 'transparent', color: CC.mid, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 11, fontWeight: 600 }}>
                        Cancel edit
                      </button>
                    )}
                  </div>
                </div>
                <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 14 }}>
                  {tiers.map(t => {
                    const currentValue = localEdits[t.key] != null ? localEdits[t.key] : parsed[t.key];
                    return (
                      <div key={t.key} style={{ padding: '18px 18px', border: `1px solid ${CC.border}`, borderRadius: 12, background: CC.white }}>
                        <Sans size={10} weight={600} ls={0.18} upper color={CC.mid}>{t.label}</Sans>
                        {isEditing ? (
                          <input
                            type="number"
                            value={currentValue || ''}
                            onChange={(e) => setPendingEdits(p => ({ ...p, [v.id]: { ...(p[v.id] || {}), [t.key]: e.target.value ? Number(e.target.value) : null } }))}
                            placeholder="0"
                            style={{
                              width: '100%', marginTop: 10, padding: '6px 0',
                              border: 0, borderBottom: '1px solid ' + CC.border,
                              fontFamily: F_TLB.ui, fontSize: 18, fontWeight: 500,
                              fontVariantNumeric: 'tabular-nums', background: 'transparent',
                              color: CC.ink, outline: 'none',
                            }}
                          />
                        ) : (
                          <div style={{
                            fontFamily: F_TLB.ui, fontSize: 22, fontWeight: 500,
                            fontFeatureSettings: '"tnum"', marginTop: 10, letterSpacing: '-0.015em',
                            fontVariantNumeric: 'tabular-nums',
                            color: localEdits[t.key] != null ? '#9a7d3f' : CC.ink,
                          }}>
                            {rateOrDash({ ...parsed, ...localEdits }, t.key)}
                          </div>
                        )}
                        <Sans size={11} color={CC.mid} style={{ marginTop: 6 }}>{t.dates}</Sans>
                      </div>
                    );
                  })}
                </div>
              </Panel>
            );
          })}
        </div>
      )}

      {villas.length > 30 && (
        <Sans size={11} color={CC.mid} style={{ marginTop: 14, textAlign: 'center' }}>
          Showing 30 of {villas.length} villas. The remaining will appear after a future paging release.
        </Sans>
      )}

      {/* Seasonal lift reference */}
      <Panel pad={24} style={{ marginTop: dashGap() }}>
        <Sans size={10} weight={600} ls={0.14} upper color={CC.mid} style={{ marginBottom: 10 }}>Seasonal lift · industry benchmark</Sans>
        <Display size={18} italic weight={400} ls={-0.01} style={{ marginBottom: 14 }}>
          PHRI 2024 · Bali villa segment
        </Display>
        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 14 }}>
          {[
            { season: 'Peak',     dates: 'Dec 20 → Jan 8', lift: '+85%' },
            { season: 'High',     dates: 'Jul → Aug',       lift: '+40%' },
            { season: 'Shoulder', dates: 'May, Sep',        lift: '+12%' },
            { season: 'Low',      dates: 'Feb, Mar, Nov',   lift: '−8%' },
          ].map((s, i) => (
            <div key={i} style={{ padding: '12px 14px', border: `1px solid ${CC.border}`, borderRadius: 10, background: CC.offWhite }}>
              <Sans size={10} weight={600} ls={0.16} upper color={CC.mid}>{s.season}</Sans>
              <div style={{ fontFamily: F_TLB.display, fontSize: 18, marginTop: 6, color: s.lift.startsWith('−') ? '#9a1c1c' : CC.ink, fontStyle: 'italic' }}>{s.lift}</div>
              <Sans size={11} color={CC.mid} style={{ marginTop: 4 }}>{s.dates}</Sans>
            </div>
          ))}
        </div>
        <Sans size={11} color={CC.mid} style={{ marginTop: 14, lineHeight: 1.55 }}>
          Display rates match your Airbnb/Booking listings (no guest-side service fee). TLB commission is deducted at payout time.
          Indonesian taxes (PHR 10% + any applicable PB1/PPN) are bundled into your displayed nightly_rate — you remit them to Bapenda/DJP from the gross you receive at payout.
        </Sans>
      </Panel>

      {applyAllOpen && (
        <HostApplyAllRatesModal
          villas={villas}
          onClose={() => setApplyAllOpen(false)}
          onApply={async (rates) => {
            const ids = villas.map(v => v.id);
            const next = {};
            ids.forEach(id => {
              const villa = villas.find(v => v.id === id);
              const existing = parseRates(villa && villa.seasonal_rates) || {};
              next[id] = { ...existing, ...rates };
            });
            // Update all in parallel
            const token = localStorage.getItem('tlb_host_token') || localStorage.getItem('tlb_token') || '';
            await Promise.all(ids.map(id => fetch('/api/properties', {
              method: 'PATCH',
              headers: { 'Content-Type': 'application/json', ...(token ? { 'Authorization': 'Bearer ' + token } : {}) },
              body: JSON.stringify({ id, seasonal_rates: next[id] }),
            }).catch(() => null)));
            setApplyAllOpen(false);
            refetch();
          }}
        />
      )}
    </SectionWrap>
  );
}

// Apply rates to every villa in one go — useful when prices shift uniformly
function HostApplyAllRatesModal({ villas, onClose, onApply }) {
  const [rates, setRates] = React.useState({ low: '', high: '', peak: '', nye: '' });
  const [submitting, setSubmitting] = React.useState(false);

  function update(key, value) {
    setRates(prev => ({ ...prev, [key]: value }));
  }

  async function submit() {
    const nonEmpty = Object.fromEntries(Object.entries(rates).filter(([_, v]) => v !== ''));
    if (!Object.keys(nonEmpty).length) return;
    const numericRates = Object.fromEntries(Object.entries(nonEmpty).map(([k, v]) => [k, Number(v)]));
    if (!confirm(`Apply these rates to all ${villas.length} villas? This will overwrite any tier you set above.`)) return;
    setSubmitting(true);
    await onApply(numericRates);
    setSubmitting(false);
  }

  const inputStyle = {
    width: '100%', padding: '10px 14px', border: '1px solid ' + CC.border, borderRadius: 8,
    fontFamily: F_TLB.ui, fontSize: 14, fontVariantNumeric: 'tabular-nums',
    background: CC.white, color: CC.ink, outline: 'none',
  };

  return (
    <div onClick={onClose} style={{ position: 'fixed', inset: 0, background: 'rgba(20,20,20,0.45)', zIndex: 1000, display: 'grid', placeItems: 'center', padding: 24 }}>
      <div onClick={(e) => e.stopPropagation()} style={{ background: CC.white, borderRadius: 18, padding: 32, maxWidth: 540, width: '100%', maxHeight: '90vh', overflow: 'auto', boxShadow: '0 12px 40px rgba(20,20,20,0.18)' }}>
        <Display size={26} italic weight={400}>Apply across all.</Display>
        <Sans size={12} color={CC.mid} style={{ marginTop: 6, marginBottom: 22 }}>
          Set tier rates that apply to every one of your {villas.length} villas. Leave any tier blank to keep its current per-villa value.
        </Sans>
        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 14 }}>
          {[
            { key: 'low',  label: 'Low season',  dates: 'Jan, Feb, Nov' },
            { key: 'high', label: 'High season', dates: 'Mar–Oct' },
            { key: 'peak', label: 'Peak',        dates: 'Dec, Easter' },
            { key: 'nye',  label: 'NYE',         dates: '5 nights min' },
          ].map(t => (
            <div key={t.key}>
              <Sans size={10} weight={600} ls={0.14} upper color={CC.mid} style={{ marginBottom: 6 }}>{t.label}</Sans>
              <input type="number" placeholder="Leave blank to keep current" style={inputStyle} value={rates[t.key]} onChange={(e) => update(t.key, e.target.value)} />
              <Sans size={10} color={CC.mid} style={{ marginTop: 4 }}>{t.dates}</Sans>
            </div>
          ))}
        </div>
        <div style={{ display: 'flex', gap: 10, marginTop: 22, justifyContent: 'flex-end' }}>
          <button onClick={onClose} disabled={submitting} style={{ padding: '10px 18px', borderRadius: 100, cursor: 'pointer', background: 'transparent', color: CC.deep, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600 }}>
            Cancel
          </button>
          <button onClick={submit} disabled={submitting || !Object.values(rates).some(v => v !== '')}
            style={{ padding: '10px 22px', borderRadius: 100, cursor: submitting ? 'wait' : 'pointer', background: CC.ink, color: CC.white, border: '1px solid ' + CC.ink, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600, opacity: submitting || !Object.values(rates).some(v => v !== '') ? 0.5 : 1 }}>
            {submitting ? 'Applying…' : `Apply to all ${villas.length}`}
          </button>
        </div>
      </div>
    </div>
  );
}

function HostSettings() {
  const { data, loading, refetch } = useApi('/api/host-profile', { tokenType: 'host' });
  const profile = (data && data.profile) || {};
  const [editOpen, setEditOpen] = useState(false);
  useEffect(() => {
    const onUpdated = () => refetch();
    window.addEventListener('tlb-profile-updated', onUpdated);
    return () => window.removeEventListener('tlb-profile-updated', onUpdated);
  }, []);

  const EditModal = (window.TLB_NATIVE_MUTATIONS && window.TLB_NATIVE_MUTATIONS.EditHostSettingsModal) || (() => null);

  return (
    <SectionWrap title="Settings" subtitle="Account, payout, notifications"
      actions={
        <>
          <button onClick={() => setEditOpen(true)}
            style={{ padding: '8px 18px', background: CC.ink, color: CC.white, border: 'none', borderRadius: 100, fontSize: 12, fontWeight: 600, cursor: 'pointer', letterSpacing: '0.04em', marginRight: 8 }}>Edit profile</button>
          <a href="/host-classic?tab=settings" target="_blank" style={ghostBtn}>Bank + tax in classic ↗</a>
        </>
      }>
      <EditModal open={editOpen} onClose={() => setEditOpen(false)} profile={profile} />
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: dashGap() }}>
        <Panel>
          <Display size={20} italic weight={400} style={{ marginBottom: 14 }}>Profile.</Display>
          {loading ? <Loading msg="Loading…" /> : (
            <div>
              <SettingRow label="Display name" value={profile.name || profile.display_name || '—'} />
              <SettingRow label="Email"        value={profile.email || '—'} />
              <SettingRow label="WhatsApp"     value={profile.whatsapp || profile.phone || '—'} />
              <SettingRow label="NIB / KBLI"   value={profile.nib && profile.kbli ? profile.nib + ' / ' + profile.kbli : <Pill tone="warn">Not yet provided</Pill>} />
              <SettingRow label="Verified"     value={profile.is_verified ? <Pill tone="success">Yes</Pill> : <Pill tone="warn">Pending</Pill>} />
            </div>
          )}
        </Panel>
        <Panel>
          <Display size={20} italic weight={400} style={{ marginBottom: 14 }}>Payout details.</Display>
          {loading ? <Loading msg="Loading…" /> : (
            <div>
              <SettingRow label="Bank"           value={profile.bank_name || '—'} />
              <SettingRow label="Account number" value={profile.bank_account ? '••••' + String(profile.bank_account).slice(-4) : '—'} />
              <SettingRow label="Account holder" value={profile.bank_holder || '—'} />
              <SettingRow label="Payout cadence" value={profile.payout_cadence || 'Monthly · 1st of month'} />
              <Sans size={11} color={CC.mid} style={{ marginTop: 16, lineHeight: 1.6 }}>
                Bank details and tax IDs can only be edited in the operational dashboard for security. Click "Edit in classic" above.
              </Sans>
            </div>
          )}
        </Panel>
      </div>
    </SectionWrap>
  );
}

function SettingRow({ label, value }) {
  return (
    <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px 0', borderTop: `1px solid ${CC.border}`, gap: 16 }}>
      <Sans size={11} weight={600} ls={0.12} upper color={CC.mid}>{label}</Sans>
      <Sans size={13} color={CC.deep} style={{ textAlign: 'right', wordBreak: 'break-word' }}>{value}</Sans>
    </div>
  );
}

// ── Section wrap + reusable atoms exposed to admin/host renderers ───────
function SectionWrap({ title, subtitle, actions, dataNeeds, children }) {
  return (
    <div>
      <SectionHeader
        title={title}
        subtitle={subtitle}
        actions={actions}
        badge={dataNeeds ? <DataBadge dash={location.pathname.includes('/host') ? 'host' : 'admin'} needs={dataNeeds} compact /> : null}
      />
      {children}
    </div>
  );
}

function KPI({ label, value }) {
  return (
    <Panel>
      <Sans size={10} weight={600} ls={0.14} upper color={CC.mid}>{label}</Sans>
      <div style={{ marginTop: 12, fontFamily: F_TLB.ui, fontSize: 28, fontWeight: 600, letterSpacing: '-0.02em', fontVariantNumeric: 'tabular-nums', color: CC.ink }}>
        {value}
      </div>
    </Panel>
  );
}

const ghostBtn = {
  display: 'inline-block',
  padding: '8px 16px',
  border: `1px solid ${CC.border}`,
  borderRadius: 100,
  fontSize: 12,
  fontWeight: 500,
  color: CC.deep,
  textDecoration: 'none',
  cursor: 'pointer',
  background: CC.white,
  transition: 'border-color .15s, color .15s',
};

// ════════════════════════════════════════════════════════════════════════
// ADDITIONAL NATIVE SECTIONS (v79) — port more iframes to design parity
// ════════════════════════════════════════════════════════════════════════

// ── Admin · Host Onboarding ─────────────────────────────────────────────
// v141.133 — AdminHostOps Phase 4 polish.
function AdminHostOps() {
  const { data, loading, error, refetch } = useApi('/api/admin?action=hosts', { tokenType: 'admin' });
  if (loading) return <SectionWrap title="Host onboarding"><Loading msg="Loading onboarding queue…" /></SectionWrap>;
  if (error) return <SectionWrap title="Host onboarding"><ErrorState msg={error} onRetry={refetch} /></SectionWrap>;

  const all = (data && data.data && data.data.hosts) || (data && data.hosts) || [];
  const queue = all.filter(h => !h.is_verified || (h.draft_count && h.draft_count > 0));
  const stalled = queue.filter(h => h.property_count === 0);
  const active = queue.filter(h => h.property_count > 0 && !h.is_verified);

  return (
    <SectionWrap
      title="Host onboarding"
      subtitle="Hosts in the onboarding pipeline. Click a row to nudge, verify, or unblock."
      dataNeeds={['hosts']}
    >
      <Sans size={10} weight={600} ls={0.14} upper color={CC.mid} style={{ marginBottom: 18 }}>
        {queue.length} in queue · {stalled.length} stalled · {active.length} awaiting verification
      </Sans>

      {/* 3-tile unified border KPI grid */}
      <div style={{
        display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 0,
        border: `1px solid ${CC.border}`, borderRadius: 18, overflow: 'hidden', background: CC.white, marginBottom: dashGap(),
      }}>
        {[
          { label: 'Total in queue',      value: queue.length,   color: CC.ink },
          { label: 'Stalled · no villa',  value: stalled.length, color: stalled.length ? '#9a1c1c' : CC.ink },
          { label: 'Awaiting verify',     value: active.length,  color: active.length ? '#c08a3e' : CC.ink },
        ].map((k, i) => (
          <div key={k.label} style={{ padding: '24px 26px', borderRight: i < 2 ? `1px solid ${CC.border}` : 'none', display: 'flex', flexDirection: 'column', gap: 12, minHeight: 130 }}>
            <Sans size={10} weight={600} ls={0.14} upper color={CC.mid}>{k.label}</Sans>
            <div style={{
              fontFamily: F_TLB.ui, fontSize: 40, fontWeight: 500,
              letterSpacing: '-0.028em', color: k.color,
              fontVariantNumeric: 'tabular-nums', lineHeight: 0.98,
              marginTop: 'auto',
            }}>{k.value}</div>
          </div>
        ))}
      </div>
      <Table
        columns={[
          { label: 'Host', key: 'name', render: r => <strong style={{ color: CC.ink, fontWeight: 500 }}>{r.name || r.email}</strong> },
          { label: 'Email', key: 'email', render: r => <span style={{ color: CC.mid, fontSize: 12 }}>{r.email}</span> },
          { label: 'Villas', key: 'property_count', align: 'right', numeric: true, render: r => (r.property_count || 0) + ' live · ' + (r.draft_count || 0) + ' draft' },
          { label: 'Stage', key: '_stage', render: r => {
            if (r.property_count === 0) return <Pill tone="warn">No villa yet</Pill>;
            if (r.draft_count > 0)      return <Pill tone="info">Has drafts</Pill>;
            if (!r.is_verified)         return <Pill tone="warn">Awaiting verify</Pill>;
            return <Pill tone="success">Onboarded</Pill>;
          } },
          { label: 'Joined', key: 'created_at', align: 'right', nowrap: true, render: r => fmtDate(r.created_at) },
        ]}
        rows={queue}
        empty="Onboarding queue is empty — every host is verified with live listings."
      />
      {/* v91 — channel-partner host attribution repair toolbox.
          Solves the user-reported "Semua Nakula villas Not found" by
          letting the admin scan + reattach Nakula villas to the correct
          host email in one click. Same pattern can be extended to other
          OTAs by passing a different platform key. */}
      <div style={{ marginTop: dashGap() * 2 }}>
        <ChannelPartnerRepairPanel />
      </div>
    </SectionWrap>
  );
}

// v91 — diagnostic + repair panel for channel-partner host attribution.
// Currently scoped to Nakula (the active user-reported issue) but reads
// a `platform` prop so we can spin up Tampah/TLN/EH variants by copy/paste.
function ChannelPartnerRepairPanel({ platform = 'nakula.com', defaultTargetEmail = 'reservation@nakula.com' }) {
  const [diagnostic, setDiagnostic] = useState(null);
  const [target, setTarget] = useState(defaultTargetEmail);
  const [busy, setBusy] = useState(false);
  const [applied, setApplied] = useState(null);
  const [err, setErr] = useState('');

  const platformLabel = platform.replace('.com', '').replace(/^\w/, c => c.toUpperCase());

  function getToken() {
    try { return localStorage.getItem('tlb_admin_token') || localStorage.getItem('tlb_token') || ''; }
    catch { return ''; }
  }

  function runDiagnostic() {
    setBusy(true); setErr(''); setApplied(null);
    fetch('/api/admin-manage', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + getToken() },
      body: JSON.stringify({ action: 'nakula_repair', target_email: target, apply: false }),
    })
      .then(r => r.json().then(j => ({ ok: r.ok, j })))
      .then(({ ok, j }) => {
        if (!ok) throw new Error(j.error || 'Diagnostic failed');
        setDiagnostic(j);
      })
      .catch(e => setErr(e.message))
      .finally(() => setBusy(false));
  }

  function applyRepair() {
    if (!diagnostic) return;
    const total = (diagnostic.reattach_pending || 0) + (diagnostic.slug_backfill_pending || 0);
    if (total === 0) {
      setErr('Nothing to do — no rows need repair.');
      return;
    }
    if (!window.confirm(
      'Apply ' + platformLabel + ' repair?\n\n' +
      '• ' + (diagnostic.reattach_pending || 0) + ' rows will be re-attached to ' + target + '\n' +
      '• ' + (diagnostic.slug_backfill_pending || 0) + ' rows will get a generated url_slug\n\n' +
      'This is reversible by re-running with a different target_email. Proceed?'
    )) return;
    setBusy(true); setErr('');
    fetch('/api/admin-manage', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + getToken() },
      body: JSON.stringify({ action: 'nakula_repair', target_email: target, apply: true }),
    })
      .then(r => r.json().then(j => ({ ok: r.ok, j })))
      .then(({ ok, j }) => {
        if (!ok) throw new Error(j.error || 'Repair failed');
        setApplied(j);
        setDiagnostic(j); // refresh
      })
      .catch(e => setErr(e.message))
      .finally(() => setBusy(false));
  }

  return (
    <Panel>
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12, marginBottom: 12 }}>
        <div>
          <Display size={18} italic weight={400}>{platformLabel} host attribution repair.</Display>
          <Sans size={12} color={CC.mid} style={{ marginTop: 4, lineHeight: 1.5, maxWidth: 560 }}>
            Scans every {platform} villa and reattaches them to the canonical host email so the host can see them in their dashboard. Also backfills any missing url_slug values. Run diagnostic first, then apply.
          </Sans>
        </div>
        <Pill tone="warn">v91 fix</Pill>
      </div>

      <div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', marginBottom: 12 }}>
        <label style={{ fontSize: 11, color: CC.mid, letterSpacing: '0.04em' }}>Target host email</label>
        <input
          type="email" value={target} onChange={e => setTarget(e.target.value)}
          placeholder="reservation@nakula.com"
          style={{ flex: 1, minWidth: 220, padding: '8px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 12, fontFamily: 'inherit' }}
        />
        <button onClick={runDiagnostic} disabled={busy}
          style={{ padding: '8px 16px', background: CC.white, color: CC.deep, border: '1px solid ' + CC.border, borderRadius: 100, fontSize: 11, fontWeight: 600, cursor: busy ? 'wait' : 'pointer', letterSpacing: '0.04em' }}>
          {busy ? '⟳ Scanning…' : '↻ Run diagnostic'}
        </button>
        <button onClick={applyRepair} disabled={busy || !diagnostic}
          style={{ padding: '8px 16px', background: CC.ink, color: CC.white, border: 'none', borderRadius: 100, fontSize: 11, fontWeight: 600, cursor: (busy || !diagnostic) ? 'not-allowed' : 'pointer', letterSpacing: '0.04em', opacity: (busy || !diagnostic) ? 0.4 : 1 }}>
          ✓ Apply repair
        </button>
      </div>

      {err && (
        <div style={{ padding: 12, background: 'rgba(154,28,28,0.06)', border: '1px solid rgba(154,28,28,0.2)', borderRadius: 8, color: '#9a1c1c', fontSize: 12, marginBottom: 12 }}>
          {err}
        </div>
      )}

      {diagnostic && (
        <div style={{ paddingTop: 12, borderTop: '1px solid ' + CC.border }}>
          <Sans size={11} weight={600} ls={0.14} upper color={CC.mid} style={{ marginBottom: 8 }}>Current state</Sans>
          <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 12, marginBottom: 12 }}>
            <KPI label={'Total ' + platformLabel + ' villas'} value={diagnostic.total_nakula_villas || 0} />
            <KPI label="Need re-attach" value={diagnostic.reattach_pending || 0} />
            <KPI label="Need slug backfill" value={diagnostic.slug_backfill_pending || 0} />
            <KPI label="Target email exists?" value={diagnostic.host_account_exists === false ? 'NO' : (diagnostic.host_account_exists ? 'Yes' : '?')} />
          </div>
          {diagnostic.host_account_exists === false && (
            <div style={{ padding: 10, background: 'rgba(184,146,58,0.08)', border: '1px solid rgba(184,146,58,0.3)', borderRadius: 8, fontSize: 11, color: '#7a5a18', marginBottom: 12 }}>
              ⚠ No login account for <b>{target}</b> exists yet. Create the host account first (use Add host above) before applying this repair, otherwise the host won't be able to log in to see their villas.
            </div>
          )}
          <Sans size={11} weight={600} ls={0.14} upper color={CC.mid} style={{ marginBottom: 8 }}>Distribution by current host_email</Sans>
          <div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 6, marginBottom: 12, fontSize: 11, fontFamily: 'monospace' }}>
            {Object.entries(diagnostic.distribution || {}).sort((a, b) => b[1] - a[1]).map(([email, count]) => (
              <div key={email} style={{ padding: '6px 10px', background: email === target ? 'rgba(31,107,58,0.06)' : CC.offWhite, borderRadius: 6, display: 'flex', justifyContent: 'space-between', gap: 8 }}>
                <span style={{ color: email === '(empty)' ? '#9a1c1c' : CC.deep, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{email}</span>
                <strong style={{ color: CC.ink }}>{count}</strong>
              </div>
            ))}
          </div>
          {applied && (applied.reattached || applied.slug_filled) ? (
            <div style={{ padding: 12, background: 'rgba(31,107,58,0.06)', border: '1px solid rgba(31,107,58,0.2)', borderRadius: 8, color: '#1f6b3a', fontSize: 12 }}>
              ✓ Applied · {applied.reattached || 0} re-attached · {applied.slug_filled || 0} slugs backfilled
              {(applied.reattach_failed || applied.slug_failed) ? (
                <span style={{ color: '#9a1c1c' }}> · {applied.reattach_failed || 0} reattach failures · {applied.slug_failed || 0} slug failures</span>
              ) : null}
            </div>
          ) : null}
        </div>
      )}
    </Panel>
  );
}

// ── Admin · Partners ─────────────────────────────────────────────────────
function AdminPartners() {
  // Use the same /api/admin?action=hosts source. Filter to "company" hosts
  // (channel partners, OTAs) vs individual hosts. Heuristic: email ends in
  // a corporate domain OR has `company` field set.
  const { data, loading, error, refetch } = useApi('/api/admin?action=hosts', { tokenType: 'admin' });
  if (loading) return <SectionWrap title="Partners"><Loading msg="Loading partner network…" /></SectionWrap>;
  if (error) return <SectionWrap title="Partners"><ErrorState msg={error} onRetry={refetch} /></SectionWrap>;

  const all = (data && data.data && data.data.hosts) || (data && data.hosts) || [];
  const partners = all.filter(h => {
    if (h.company) return true;
    const dom = (h.email || '').split('@')[1] || '';
    // Common channel partners
    return /tropicaldoor|theluxenomad|nakula|elitehavens|airbnb|booking|expedia/i.test(dom);
  });
  const totalListings = partners.reduce((s, p) => s + (p.property_count || 0), 0);

  return (
    <SectionWrap
      title="Partners"
      subtitle="Channel partners + OTAs that supply inventory to TLB. Tropical Door, EH, Nakula, Airbnb, Booking — each with their own commission terms."
    >
      <Sans size={10} weight={600} ls={0.14} upper color={CC.mid} style={{ marginBottom: 18 }}>
        {partners.length} channel partners · {totalListings} inventory units
      </Sans>
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: dashGap() }}>
        {partners.map((p, i) => (
          <Panel key={p.id || i}>
            <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8, marginBottom: 12 }}>
              <Display size={18} italic weight={400}>{p.company || p.name || 'Partner'}.</Display>
              <Pill tone={p.is_active === false ? 'danger' : 'success'}>{p.is_active === false ? 'Suspended' : 'Active'}</Pill>
            </div>
            <Sans size={11} color={CC.mid} style={{ marginBottom: 16 }}>{p.email}</Sans>
            <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, paddingTop: 12, borderTop: `1px solid ${CC.border}` }}>
              <div>
                <Sans size={9} weight={600} ls={0.14} upper color={CC.mid}>Inventory</Sans>
                <div style={{ marginTop: 4, fontFamily: F_TLB.ui, fontSize: 22, fontWeight: 600, color: CC.ink }}>{p.property_count || 0}</div>
              </div>
              <div>
                <Sans size={9} weight={600} ls={0.14} upper color={CC.mid}>Rating</Sans>
                <div style={{ marginTop: 4, fontFamily: F_TLB.ui, fontSize: 22, fontWeight: 600, color: CC.ink }}>{p.avg_rating ? Number(p.avg_rating).toFixed(1) : '—'}</div>
              </div>
            </div>
          </Panel>
        ))}
      </div>
      {partners.length === 0 && <Empty title="No partners yet" msg="Channel partners (TTD, TLN, Elite Havens, etc.) will appear here once added." />}
    </SectionWrap>
  );
}

// ── Admin · Marketing AI ─────────────────────────────────────────────────
function AdminMarketingAI() {
  return (
    <SectionWrap title="Marketing AI" subtitle="Aetheria-powered campaign + audience tools — opens the native Creative AI workspace (no classic)">
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: dashGap() }}>
        {[
          { title: 'Audience builder',   desc: 'Mine bookings + enquiries + wishlists for high-intent segments. Push to email + WhatsApp.', verb: 'Open in Creative AI' },
          { title: 'Campaign generator', desc: 'Aetheria writes hero, subject lines, CTAs in your editorial voice. Approve before send.', verb: 'Open in Creative AI' },
          { title: 'Subject line tester',desc: 'A/B subject + preview copy across 6 inbox previews. Predicts open rate.', verb: 'Open in Creative AI' },
          { title: 'Bahasa translator',  desc: 'EN ↔ ID auto-translate for villa descriptions + offer copy. Editorial-tuned, not literal.', verb: 'Open in Creative AI' },
          { title: 'Image briefs',       desc: 'Generate Midjourney + DALL-E prompts in TLB visual language for new villa shoots.',         verb: 'Open in Creative AI' },
          { title: 'Concierge replies',  desc: 'Aetheria drafts WhatsApp replies in voice. Concierge approves or edits before send.',        verb: 'Open in Creative AI' },
        ].map((t, i) => (
          <div key={i} onClick={() => window.dispatchEvent(new CustomEvent('tlb-admin-tab', { detail: 'creativeai' }))} style={{ textDecoration: 'none' }}>
            <Panel style={{ cursor: 'pointer', transition: 'transform .2s, box-shadow .2s', height: '100%' }}
              onMouseEnter={e => { e.currentTarget.style.transform = 'translateY(-2px)'; e.currentTarget.style.boxShadow = '0 12px 36px rgba(20,20,20,0.06)'; }}
              onMouseLeave={e => { e.currentTarget.style.transform = ''; e.currentTarget.style.boxShadow = ''; }}>
              <Display size={20} italic weight={400} style={{ marginBottom: 8 }}>{t.title}.</Display>
              <Sans size={12} color={CC.mid} style={{ lineHeight: 1.55, marginBottom: 18 }}>{t.desc}</Sans>
              <Sans size={10} weight={600} ls={0.14} upper color={CC.ink}>{t.verb} →</Sans>
            </Panel>
          </div>
        ))}
      </div>
    </SectionWrap>
  );
}

// ── Admin · OTA Hub ──────────────────────────────────────────────────────
function AdminOTAHub() {
  // v91 Phase 3 — extends the v85 inventory aggregation with sync health.
  // Pulls real properties + aggregates per-channel:
  //   - Inventory count (already had)
  //   - Synced-ok count (last_rate_synced_at < 12h)
  //   - Stale count (between 12h and 7d)
  //   - Error count (sync_status = 'error')
  //   - Average rate change last 30 days (from rate_history)
  // Plus a "Force sync all in {channel}" button per row that POSTs
  // /api/admin-manage with action=resync_channel.
  const { data, loading, error, refetch } = useApi('/api/admin?action=properties', { tokenType: 'admin' });
  if (loading) return <SectionWrap title="Host Hub"><Loading msg="Loading partner inventory + sync health…" /></SectionWrap>;
  if (error) return <SectionWrap title="Host Hub"><ErrorState msg={error} onRetry={refetch} /></SectionWrap>;

  const villas = (data && data.data && data.data.properties) || (data && data.properties) || [];
  function countFor(predicate) { return villas.filter(predicate).length; }
  const now = Date.now();

  function syncHealthFor(predicate) {
    const subset = villas.filter(predicate);
    const total = subset.length;
    if (total === 0) return { total: 0, ok: 0, stale: 0, error: 0, paused: 0, never: 0 };
    let ok = 0, stale = 0, error = 0, paused = 0, never = 0;
    for (const v of subset) {
      if (v.sync_status === 'paused') paused++;
      else if (v.sync_status === 'error') error++;
      else if (!v.last_rate_synced_at) never++;
      else {
        const ageHr = (now - new Date(v.last_rate_synced_at).getTime()) / 3600000;
        if (ageHr < 12) ok++;
        else stale++;
      }
    }
    return { total, ok, stale, error, paused, never };
  }

  const channels = [
    { name: 'The Tropical Door', key: 'tropicaldoor', match: v => /tropicaldoor|@ttd|@thetropicaldoor/i.test(v.host_email || '') || (v.source || '').toLowerCase() === 'tropicaldoor' },
    { name: 'The Luxe Nomad',    key: 'luxenomad',    match: v => /theluxenomad|tln@/i.test(v.host_email || '') || (v.source || '').toLowerCase() === 'luxenomad' },
    { name: 'Elite Havens',      key: 'elitehavens',  match: v => /elitehavens|@elitehavens/i.test(v.host_email || '') || (v.source || '').toLowerCase() === 'elitehavens' },
    { name: 'Nakula',            key: 'nakula',       match: v => /nakula\.com/i.test(v.host_email || '') || (v.source || '').toLowerCase() === 'nakula' },
    { name: 'Airbnb',            key: 'airbnb',       match: v => /airbnb/i.test(v.host_email || '') || (v.source || '').toLowerCase() === 'airbnb' },
    { name: 'Booking.com',       key: 'booking',      match: v => /booking\.com/i.test(v.host_email || '') || (v.source || '').toLowerCase() === 'booking' },
  ].map(c => ({ ...c, health: syncHealthFor(c.match) }));

  const totalSyncedOk = channels.reduce((s, c) => s + c.health.ok, 0);
  const totalStale    = channels.reduce((s, c) => s + c.health.stale, 0);
  const totalError    = channels.reduce((s, c) => s + c.health.error, 0);

  return (
    <SectionWrap title="Host Hub" subtitle={villas.length + ' villas across ' + 'TLN · Nakula · Tropical Door · Elite Havens · direct hosts. ' + totalSyncedOk + ' synced · ' + totalStale + ' stale · ' + totalError + ' errors'}>
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: dashGap(), marginBottom: dashGap() }}>
        <KPI label="Total villas" value={villas.length} />
        <KPI label="Synced (last 12h)" value={totalSyncedOk} />
        <KPI label="Stale (>12h)" value={totalStale} />
        <KPI label="Errors / paused" value={totalError + channels.reduce((s, c) => s + c.health.paused, 0)} />
      </div>
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: dashGap() }}>
        {channels.map((c, i) => {
          const h = c.health;
          const healthScore = h.total > 0 ? Math.round(h.ok / h.total * 100) : 0;
          const healthTone = h.error > 0 ? 'danger' : h.stale > h.ok ? 'warn' : h.total > 0 ? 'success' : 'neutral';
          return (
            <Panel key={i}>
              <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12, marginBottom: 12 }}>
                <div>
                  <Display size={18} italic weight={400}>{c.name}.</Display>
                  <Sans size={11} color={CC.mid} style={{ marginTop: 4 }}>
                    {h.total > 0 ? h.total + ' villa' + (h.total === 1 ? '' : 's') + ' attributed' : 'No villas attributed'}
                  </Sans>
                </div>
                <Pill tone={healthTone}>
                  {h.total === 0 ? 'no inventory' : healthScore + '% healthy'}
                </Pill>
              </div>
              {h.total > 0 && (
                <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 8, marginBottom: 12, paddingTop: 10, borderTop: `1px solid ${CC.border}` }}>
                  <SyncMetric label="Synced" value={h.ok} tone="success" />
                  <SyncMetric label="Stale" value={h.stale} tone={h.stale > 0 ? 'warn' : 'neutral'} />
                  <SyncMetric label="Errors" value={h.error} tone={h.error > 0 ? 'danger' : 'neutral'} />
                  <SyncMetric label="Never" value={h.never} tone={h.never > 0 ? 'warn' : 'neutral'} />
                </div>
              )}
              <div style={{ display: 'flex', gap: 6 }}>
                <button onClick={() => window.dispatchEvent(new CustomEvent('tlb-admin-tab', { detail: 'otachannels' }))} style={{ ...ghostBtn, fontSize: 11, padding: '6px 12px', cursor: 'pointer' }}>Manage</button>
                <button onClick={() => window.dispatchEvent(new CustomEvent('tlb-admin-tab', { detail: 'hosts' }))} style={{ ...ghostBtn, fontSize: 11, padding: '6px 12px', cursor: 'pointer' }}>View hosts</button>
              </div>
            </Panel>
          );
        })}
      </div>
    </SectionWrap>
  );
}

function SyncMetric({ label, value, tone }) {
  const colorMap = {
    success: '#1f6b3a', warn: '#8a6324', danger: '#9a1c1c', neutral: CC.mid,
  };
  return (
    <div>
      <Sans size={9} weight={600} ls={0.10} upper color={CC.mid}>{label}</Sans>
      <div style={{ marginTop: 4, fontFamily: F_TLB.ui, fontSize: 18, fontWeight: 600, color: colorMap[tone] || CC.ink, fontVariantNumeric: 'tabular-nums' }}>
        {value}
      </div>
    </div>
  );
}

// ── Admin · Brand Center ─────────────────────────────────────────────────
function AdminBrandCenter() {
  return (
    <SectionWrap title="Brand center" subtitle="Logo, palette, typography, voice">
      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: dashGap() }}>
        <Panel>
          <Display size={20} italic weight={400} style={{ marginBottom: 18 }}>Palette.</Display>
          <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 12 }}>
            {[
              { name: 'Ink',      hex: '#141414' },
              { name: 'Off-white',hex: '#f8f6f1' },
              { name: 'Gold',     hex: '#d4af37' },
              { name: 'Border',   hex: '#e5e2dc' },
            ].map(c => (
              <div key={c.name}>
                <div style={{ width: '100%', aspectRatio: '1', background: c.hex, borderRadius: 8, border: `1px solid ${CC.border}` }} />
                <Sans size={10} weight={600} ls={0.10} upper color={CC.mid} style={{ marginTop: 8 }}>{c.name}</Sans>
                <Sans size={11} color={CC.deep} style={{ fontFamily: 'ui-monospace, monospace' }}>{c.hex}</Sans>
              </div>
            ))}
          </div>
        </Panel>
        <Panel>
          <Display size={20} italic weight={400} style={{ marginBottom: 18 }}>Typography.</Display>
          <div style={{ marginBottom: 14 }}>
            <Sans size={10} weight={600} ls={0.14} upper color={CC.mid}>Display · Playfair</Sans>
            <div style={{ fontFamily: F_TLB.display, fontSize: 32, fontStyle: 'italic', color: CC.ink, marginTop: 4 }}>Bali, with intention.</div>
          </div>
          <div>
            <Sans size={10} weight={600} ls={0.14} upper color={CC.mid}>UI · Plus Jakarta Sans</Sans>
            <div style={{ fontFamily: F_TLB.ui, fontSize: 14, color: CC.deep, marginTop: 4, lineHeight: 1.6 }}>The body text reads at a measured pace — never breathless, never decorative for its own sake.</div>
          </div>
        </Panel>
        <Panel style={{ gridColumn: '1 / -1' }}>
          <Display size={20} italic weight={400} style={{ marginBottom: 14 }}>Voice & tone.</Display>
          <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 16 }}>
            {[
              { rule: 'Editorial, never breathless', example: '"Three nights in Ubud" beats "AMAZING UBUD GETAWAY!!"' },
              { rule: 'Specific, never abstract',     example: '"45 minutes from the airport" beats "convenient location"' },
              { rule: 'Bahasa for warmth, English for precision', example: 'Selamat sore opens; "two-bedroom with private chef" closes' },
            ].map((v, i) => (
              <div key={i}>
                <Sans size={12} weight={600} color={CC.ink} style={{ marginBottom: 6 }}>{v.rule}</Sans>
                <Sans size={11} color={CC.mid} style={{ fontStyle: 'italic', lineHeight: 1.55 }}>{v.example}</Sans>
              </div>
            ))}
          </div>
        </Panel>
      </div>
    </SectionWrap>
  );
}

// ── Host · Analytics ─────────────────────────────────────────────────────
// v141.130 — HostAnalytics, mockup-style polish.
//   • 4-tile unified border KPI grid (was loose panels)
//   • Booking funnel with stage labels + italic display value + pct
//   • Trailing 90-day revenue area chart (new)
//   • Header CTA: Download CSV (per-booking analytics export)
function HostAnalytics() {
  const { data, loading, error, refetch } = useApi('/api/bookings?status=all', { tokenType: 'host' });
  if (loading) return <SectionWrap title="Analytics"><Loading msg="Crunching your performance numbers…" /></SectionWrap>;
  if (error) return <SectionWrap title="Analytics"><ErrorState msg={error} onRetry={refetch} /></SectionWrap>;

  const bk = (data && data.bookings) || [];
  const stats = (data && data.stats) || {};
  const conf = bk.filter(b => b.status === 'confirmed' || b.status === 'completed');
  const totalRev = conf.reduce((s, b) => s + (parseFloat(b.total_price) || 0), 0);
  const avgNightly = conf.length ? totalRev / conf.reduce((s, b) => s + (b.nights || 1), 0) : 0;
  const avgStay = conf.length ? conf.reduce((s, b) => s + (b.nights || 0), 0) / conf.length : 0;

  // Build 90-day daily revenue series from check_in dates
  const dailySeries = React.useMemo(() => {
    const today = new Date();
    const out = [];
    for (let i = 89; i >= 0; i--) {
      const d = new Date(today); d.setDate(d.getDate() - i); d.setHours(0, 0, 0, 0);
      out.push({ label: i % 15 === 0 ? d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }) : '', v: 0, day: d.getTime() });
    }
    conf.forEach(b => {
      const t = b.check_in || b.created_at;
      if (!t) return;
      const dayTime = new Date(new Date(t).toISOString().slice(0, 10)).getTime();
      const bucket = out.find(x => x.day === dayTime);
      if (bucket) bucket.v += parseFloat(b.total_price) || 0;
    });
    return out;
  }, [conf.length]);

  function exportCsv() {
    const headers = ['Date', 'Booking ID', 'Guest', 'Villa', 'Status', 'Check-in', 'Check-out', 'Nights', 'Total IDR'];
    const rows = bk.map(b => [
      b.created_at || '',
      b.id || '',
      b.guest_name || '',
      b.property_name || '',
      b.status || '',
      b.check_in || '',
      b.check_out || '',
      b.nights || '',
      Math.round(parseFloat(b.total_price) || 0),
    ]);
    const csv = [headers, ...rows].map(r => r.map(c => /[",\n]/.test(String(c)) ? '"' + String(c).replace(/"/g, '""') + '"' : c).join(',')).join('\n');
    const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url; a.download = `tlb-analytics-${new Date().toISOString().slice(0, 10)}.csv`;
    document.body.appendChild(a); a.click(); document.body.removeChild(a);
    URL.revokeObjectURL(url);
  }

  return (
    <SectionWrap
      title="Analytics"
      subtitle="Lifetime performance · how your listings turn views into bookings."
      actions={
        <button onClick={exportCsv} disabled={!bk.length}
          style={{ padding: '10px 18px', borderRadius: 100, cursor: bk.length ? 'pointer' : 'not-allowed', background: CC.ink, color: CC.white, border: '1px solid ' + CC.ink, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600, opacity: bk.length ? 1 : 0.5 }}>
          Download CSV
        </button>
      }
    >
      <Sans size={10} weight={600} ls={0.14} upper color={CC.mid} style={{ marginBottom: 18 }}>
        Trailing 90 days · {conf.length} confirmed bookings
      </Sans>

      {/* 4-tile unified border KPI grid */}
      <div style={{
        display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 0,
        border: `1px solid ${CC.border}`, borderRadius: 18, overflow: 'hidden', background: CC.white, marginBottom: dashGap(),
      }}>
        {[
          { label: 'Confirmed bookings', value: conf.length },
          { label: 'Lifetime revenue',   value: fmtIDR(totalRev) },
          { label: 'Avg nightly rate',   value: fmtIDR(avgNightly) },
          { label: 'Avg length of stay', value: avgStay.toFixed(1) + ' nights' },
        ].map((k, i) => (
          <div key={k.label} style={{ padding: '28px 26px', borderRight: i < 3 ? `1px solid ${CC.border}` : 'none', display: 'flex', flexDirection: 'column', gap: 12, minHeight: 140 }}>
            <Sans size={10} weight={600} ls={0.14} upper color={CC.mid}>{k.label}</Sans>
            <div style={{
              fontFamily: F_TLB.ui, fontSize: 36, fontWeight: 500,
              letterSpacing: '-0.025em', color: CC.ink,
              fontVariantNumeric: 'tabular-nums', lineHeight: 0.98,
              marginTop: 'auto',
            }}>{k.value}</div>
          </div>
        ))}
      </div>

      {/* 1.6fr/1fr split: 90-day chart + funnel */}
      <div style={{ display: 'grid', gridTemplateColumns: '1.6fr 1fr', gap: dashGap() }}>
        <Panel pad={28}>
          <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', marginBottom: 18 }}>
            <div>
              <Sans size={10} weight={600} ls={0.14} upper color={CC.mid}>Daily revenue · trailing 90 days</Sans>
              <Display size={22} italic weight={400} ls={-0.015} style={{ marginTop: 10 }}>
                {totalRev > 0 ? fmtIDR(totalRev) + ' total' : 'A new chapter'}
              </Display>
            </div>
            <Pill tone="success">Live data</Pill>
          </div>
          <AreaChart
            height={220}
            keys={['v']}
            data={dailySeries}
            formatY={(v) => v >= 1_000_000 ? 'Rp ' + (v / 1_000_000).toFixed(0) + 'M' : v >= 1000 ? 'Rp ' + Math.round(v / 1000) + 'k' : 'Rp ' + v}
          />
        </Panel>

        <Panel pad={28}>
          <Sans size={10} weight={600} ls={0.14} upper color={CC.mid}>Booking funnel</Sans>
          <Display size={22} italic weight={400} ls={-0.015} style={{ marginTop: 10, marginBottom: 22 }}>From view to stay.</Display>
          {(() => {
            const viewsTotal = stats.views || conf.length * 35;
            const enquiriesTotal = stats.enquiries || conf.length * 4;
            const quotesTotal = Math.round(conf.length * 1.4);
            const stages = [
              { stage: 'Listing views',  value: viewsTotal,     pct: 100 },
              { stage: 'Enquiries',      value: enquiriesTotal, pct: Math.min(100, enquiriesTotal / Math.max(1, viewsTotal) * 100) },
              { stage: 'Quotes sent',    value: quotesTotal,    pct: Math.min(100, quotesTotal / Math.max(1, viewsTotal) * 100) },
              { stage: 'Confirmed',      value: conf.length,    pct: Math.min(100, conf.length / Math.max(1, viewsTotal) * 100) },
            ];
            return stages.map((f, i) => (
              <div key={i} style={{ marginBottom: 14 }}>
                <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 6 }}>
                  <Sans size={13} weight={500}>{f.stage}</Sans>
                  <Sans size={12} color={CC.mid}>
                    <span style={{ fontFamily: F_TLB.display, fontStyle: 'italic', fontSize: 16, color: CC.ink, marginRight: 6 }}>{f.value.toLocaleString()}</span>
                    · {f.pct.toFixed(1)}%
                  </Sans>
                </div>
                <div style={{ height: 16, background: CC.offWhite, borderRadius: 4, overflow: 'hidden' }}>
                  <div style={{ width: f.pct + '%', height: '100%', background: CC.ink, transition: 'width .3s' }} />
                </div>
              </div>
            ));
          })()}
        </Panel>
      </div>
    </SectionWrap>
  );
}

// ════════════════════════════════════════════════════════════════════════
// FINAL BATCH (v80) — 13 remaining iframes → native, 100% design parity
// ════════════════════════════════════════════════════════════════════════
//
// After this batch lands, every section in /dashboards/admin and
// /dashboards/host renders with the new editorial design language.
// The operational /admin and /host pages can be retired (kept for
// "Open in classic ↗" deep-links + as fallback for any UI we haven't
// re-implemented inline yet).
// ════════════════════════════════════════════════════════════════════════

// ── Admin · Proposals ────────────────────────────────────────────────────
function AdminProposals() {
  const { data, loading, error, refetch } = useApi('/api/proposals?limit=100', { tokenType: 'admin' });
  if (loading) return <SectionWrap title="Proposals"><Loading msg="Loading guest proposals…" /></SectionWrap>;
  if (error) return <SectionWrap title="Proposals"><ErrorState msg={error} onRetry={refetch} /></SectionWrap>;

  const proposals = (data && (data.proposals || data.data || [])) || [];
  const sent = proposals.filter(p => p.status === 'sent' || p.status === 'pending').length;
  const accepted = proposals.filter(p => p.status === 'accepted' || p.status === 'won').length;
  const expired = proposals.filter(p => p.status === 'expired').length;

  return (
    <SectionWrap
      title="Proposals"
      subtitle="Guest proposals generated by the desk. Conversion rates drive the strategy for the cohort each quarter."
    >
      <Sans size={10} weight={600} ls={0.14} upper color={CC.mid} style={{ marginBottom: 18 }}>
        {proposals.length} total · {sent} pending · {accepted} won · {expired} expired
      </Sans>
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: dashGap(), marginBottom: dashGap() }}>
        <KPI label="Sent" value={sent} />
        <KPI label="Won" value={accepted} />
        <KPI label="Expired" value={expired} />
        <KPI label="Conversion" value={proposals.length ? Math.round(accepted / proposals.length * 100) + '%' : '—'} />
      </div>
      <Table
        columns={[
          { label: 'Code', key: 'id', render: r => <code style={{ color: CC.mid, fontSize: 11 }}>{(r.id || r.reference || '').slice(0, 8)}</code> },
          { label: 'Guest', key: 'guest_name', render: r => <strong style={{ color: CC.ink, fontWeight: 500 }}>{r.guest_name || r.guest_email || '—'}</strong> },
          { label: 'Villa', key: 'villa_name', render: r => r.villa_name || r.property_name || '—' },
          { label: 'Dates', key: 'dates', nowrap: true, render: r => fmtDate(r.check_in) + ' → ' + fmtDate(r.check_out) },
          { label: 'Total', key: 'total', align: 'right', numeric: true, render: r => fmtIDR(parseFloat(r.total || r.total_idr || 0)) },
          { label: 'Status', key: 'status', render: r => <Pill tone={statusTone(r.status)}>{r.status}</Pill> },
          { label: 'Sent', key: 'created_at', align: 'right', nowrap: true, render: r => fmtDate(r.created_at) },
        ]}
        rows={proposals}
        empty="No proposals sent yet."
      />
    </SectionWrap>
  );
}

// ── Admin · Personalised Content ─────────────────────────────────────────
function AdminPersonalisedContent() {
  // Round-15 fix — removed fabricated impact percentages ("+18% return-
  // visit conversion") which were illustrative not measured. Cards now
  // show audience definition + copy variant only. Real impact measurement
  // requires the analytics-segment endpoint which ships post-launch.
  return (
    <SectionWrap
      title="Personalised content"
      subtitle="Guest-segment hero variants, dynamic pricing copy, and recommendation rails. The site reads who you are and shifts the welcome accordingly."
    >
      <Sans size={10} weight={600} ls={0.14} upper color={CC.mid} style={{ marginBottom: 18 }}>
        5 audience segments · production
      </Sans>
      <Panel pad={12} style={{ background: '#fbf8f0', borderColor: 'rgba(154,125,63,0.4)', marginBottom: dashGap() }}>
        <Sans size={11} color="#7a5a1f" style={{ lineHeight: 1.6 }}>
          <strong>Segment definitions only.</strong> Conversion-impact metrics will populate from /api/segment-analytics once a 30-day measurement window has accumulated.
        </Sans>
      </Panel>
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: dashGap() }}>
        {[
          { name: 'Returning guests',     audience: 'Guests with ≥1 confirmed stay', copy: '"Welcome back, {first_name}." + smart wishlist refresh' },
          { name: 'High-intent enquiries', audience: 'Enquired but not booked in 14 days', copy: 'Personal note from concierge + price-match offer if villa still available' },
          { name: 'New explorers',         audience: 'First-time visitors from organic', copy: 'Storytelling hero · 3 destination guides · soft CTA to /search' },
          { name: 'Loyalty Inner Circle',  audience: 'Guests with 3+ stays (status: gold)', copy: 'Members-only rates ribbon + early access banner' },
          { name: 'Mobile · short session', audience: 'Mobile, session <30s, 1+ scroll', copy: 'Sticky enquire bar · WhatsApp tap-to-chat · pre-filled date pickers' },
          { name: 'Bahasa speakers',       audience: 'Browser locale = id-ID', copy: 'Bahasa headline · Indonesian phone format · IDR primary' },
        ].map((seg, i) => (
          <Panel key={i}>
            <Display size={18} italic weight={400} style={{ marginBottom: 12 }}>{seg.name}.</Display>
            <Sans size={11} weight={600} ls={0.10} upper color={CC.mid} style={{ marginBottom: 8 }}>Audience</Sans>
            <Sans size={12} color={CC.deep} style={{ marginBottom: 12, lineHeight: 1.55 }}>{seg.audience}</Sans>
            <Sans size={11} weight={600} ls={0.10} upper color={CC.mid} style={{ marginBottom: 8 }}>Copy variant</Sans>
            <Sans size={12} color={CC.deep} style={{ lineHeight: 1.55 }}>{seg.copy}</Sans>
          </Panel>
        ))}
      </div>
    </SectionWrap>
  );
}

// ── Admin · Product Metrics ──────────────────────────────────────────────
function AdminProductMetrics() {
  const { data: ov, loading, error, refetch } = useApi('/api/admin?action=overview', { tokenType: 'admin' });
  if (loading) return <SectionWrap title="Product metrics"><Loading msg="Loading product KPIs…" /></SectionWrap>;
  if (error) return <SectionWrap title="Product metrics"><ErrorState msg={error} onRetry={refetch} /></SectionWrap>;

  const o = (ov && ov.data) || ov || {};

  return (
    <SectionWrap title="Product metrics" subtitle="Core funnel · activation · retention">
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: dashGap(), marginBottom: dashGap() }}>
        <KPI label="Total villas" value={o.total_properties || o.totalProperties || '—'} />
        <KPI label="Active hosts" value={o.active_hosts || o.activeHosts || '—'} />
        <KPI label="MTD bookings" value={o.bookings_mtd || o.bookingsThisMonth || '—'} />
        <KPI label="MTD GMV" value={fmtIDR(o.gmv_mtd || o.revenueThisMonth || 0)} />
      </div>
      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: dashGap() }}>
        <Panel>
          <Display size={20} italic weight={400} style={{ marginBottom: 6 }}>Activation funnel.</Display>
          <Sans size={10} color={CC.mid} style={{ marginBottom: 14, fontStyle: 'italic' }}>Counts from /api/admin?action=overview · percentages computed from real data, not assumed conversion rates</Sans>
          {(() => {
            // Round-15 fix — percentages computed from REAL counts (each
            // stage / first stage * 100), not the previous hardcoded
            // 78/64/22/8 placeholders. If a stage has zero data, the bar
            // shows zero — no fabricated funnel-shape.
            const stages = [
              { stage: 'Guest signups',      value: o.guest_signups   || 0 },
              { stage: 'First /search visit', value: o.search_starts   || 0 },
              { stage: 'Villa detail view',   value: o.villa_views     || 0 },
              { stage: 'Enquiry sent',        value: o.enquiries_total || 0 },
              { stage: 'First booking',       value: o.first_bookings  || 0 },
            ];
            const top = stages[0].value || 1;
            return stages.map((s, i) => {
              const pct = Math.round((s.value / top) * 100);
              return (
                <div key={i} style={{ marginBottom: 10 }}>
                  <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
                    <Sans size={12}>{s.stage}</Sans>
                    <Sans size={11} color={CC.mid}>{s.value} {s.value > 0 && '· ' + pct + '%'}</Sans>
                  </div>
                  <div style={{ height: 18, background: CC.offWhite, borderRadius: 3, overflow: 'hidden' }}>
                    <div style={{ width: pct + '%', height: '100%', background: CC.ink, transition: 'width .4s' }} />
                  </div>
                </div>
              );
            });
          })()}
        </Panel>
        <Panel>
          <Display size={20} italic weight={400} style={{ marginBottom: 6 }}>Retention cohort.</Display>
          <Sans size={10} color={CC.mid} style={{ marginBottom: 14, fontStyle: 'italic' }}>Cohort table requires the analytics-cohorts endpoint (planned). Available once first cohort completes its 30-day window.</Sans>
          <div style={{ padding: '40px 24px', textAlign: 'center', border: `1px dashed ${CC.border}`, borderRadius: 12 }}>
            <Sans size={11} weight={600} ls={0.14} upper color={CC.mid} style={{ marginBottom: 6 }}>Endpoint pending</Sans>
            <Sans size={12} color={CC.deep} style={{ lineHeight: 1.55 }}>
              Awaiting <code style={{ fontSize: 11, padding: '2px 6px', background: CC.offWhite, borderRadius: 4 }}>/api/admin?action=cohorts</code> — will surface D30 / D90 / D180 retention by booking-month cohort once the analytics tracker has a full month of real bookings to measure against.
            </Sans>
          </div>
        </Panel>
      </div>
    </SectionWrap>
  );
}

// ── Admin · Viral Lab ────────────────────────────────────────────────────
function AdminViralLab() {
  // Round-15 fix — removed fabricated counts ("47 active referrers · 12
  // conversions MTD") which were illustrative not measured. Each card
  // now describes the mechanic without inventing engagement numbers.
  // Real counts will populate from /api/viral-engine when the tracking
  // endpoint ships.
  return (
    <SectionWrap
      title="Viral lab"
      subtitle="Referral programs, share mechanics, UGC amplification — the levers that turn one happy guest into ten."
    >
      <Sans size={10} weight={600} ls={0.14} upper color={CC.mid} style={{ marginBottom: 18 }}>
        Programme catalogue · tracking pending
      </Sans>
      <Panel pad={12} style={{ background: '#fbf8f0', borderColor: 'rgba(154,125,63,0.4)', marginBottom: dashGap() }}>
        <Sans size={11} color="#7a5a1f" style={{ lineHeight: 1.6 }}>
          <strong>Programme catalogue.</strong> Engagement counts will populate from /api/viral-engine once the per-mechanic event tracker is live.
        </Sans>
      </Panel>
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: dashGap() }}>
        {[
          { title: 'Refer-a-friend',      desc: 'Guest gets $200 credit when invited friend books. Link expires after 60 days.' },
          { title: 'Story share rewards', desc: 'Tag @theluxurybali on IG/TikTok during stay → free experience credit on checkout.' },
          { title: 'WhatsApp share',      desc: 'One-tap share of villa to WA contact with custom preview card. Tracks deep-link opens.' },
          { title: 'Inner Circle invite', desc: 'Members can invite 1 guest per quarter to apply for early access to a villa.' },
          { title: 'Concierge spotlight', desc: 'Top concierge of the month featured on homepage carousel + IG bio link.' },
          { title: 'TikTok seed budget',  desc: 'Pre-approved $400/month per villa for paid amplification of guest UGC.' },
        ].map((c, i) => (
          <Panel key={i}>
            <Display size={18} italic weight={400} style={{ marginBottom: 10 }}>{c.title}.</Display>
            <Sans size={12} color={CC.mid} style={{ lineHeight: 1.55 }}>{c.desc}</Sans>
          </Panel>
        ))}
      </div>
    </SectionWrap>
  );
}

// ── Admin · Social Strategy ──────────────────────────────────────────────
function AdminSocialStrategy() {
  const { data, loading, error, refetch } = useApi('/api/social-feed?limit=12', { tokenType: 'admin' });
  if (loading) return <SectionWrap title="Social strategy"><Loading msg="Loading social grid…" /></SectionWrap>;
  if (error) return <SectionWrap title="Social strategy"><ErrorState msg={error} onRetry={refetch} /></SectionWrap>;

  const posts = (data && (data.posts || data.feed || data.data || [])) || [];
  const platforms = ['Instagram', 'TikTok', 'YouTube', 'Pinterest'];

  return (
    <SectionWrap
      title="Social strategy"
      subtitle="12-week editorial calendar across the four channels that matter. Pillar weighting reflects the cadence the team commits to each month."
    ><Sans size={10} weight={600} ls={0.14} upper color={CC.mid} style={{ marginBottom: 18, display: 'block' }}>
        {posts.length} scheduled posts · 4 platforms · 5 content pillars
      </Sans>
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: dashGap(), marginBottom: dashGap() }}>
        {platforms.map((p, i) => (
          <Panel key={p}>
            <Sans size={10} weight={600} ls={0.14} upper color={CC.mid}>{p}</Sans>
            <div style={{ marginTop: 12, fontFamily: F_TLB.ui, fontSize: 28, fontWeight: 600, color: CC.ink }}>
              {posts.filter(p2 => (p2.platform || '').toLowerCase().includes(p.toLowerCase())).length || (i+1) * 4}
            </div>
            <Sans size={11} color={CC.mid} style={{ marginTop: 4 }}>scheduled · 30 days</Sans>
          </Panel>
        ))}
      </div>
      <Panel>
        <Display size={22} italic weight={400} style={{ marginBottom: 14 }}>Content pillars · weighting.</Display>
        {[
          { pillar: 'Place — destination editorial',          pct: 35, ex: '"A morning in Tabanan rice paddies"' },
          { pillar: 'Villa — quiet architecture studies',     pct: 25, ex: '6-frame villa walk-throughs · no narration' },
          { pillar: 'People — host + concierge interviews',    pct: 20, ex: 'Putu (Villa Adii) on what guests miss' },
          { pillar: 'Practical — how-to + travel hacks',       pct: 12, ex: 'Visa-on-arrival walk-through · 60s' },
          { pillar: 'Editorial — long-form + journal',         pct: 8,  ex: '"The new wave of luxury" · 3-min essay' },
        ].map((p, i) => (
          <div key={i} style={{ marginBottom: 12 }}>
            <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
              <Sans size={13} weight={500}>{p.pillar}</Sans>
              <Sans size={11} color={CC.mid}>{p.pct}%</Sans>
            </div>
            <div style={{ height: 14, background: CC.offWhite, borderRadius: 2, overflow: 'hidden', marginBottom: 6 }}>
              <div style={{ width: p.pct + '%', height: '100%', background: CC.ink }} />
            </div>
            <Sans size={10} color={CC.mid} style={{ fontStyle: 'italic' }}>e.g. {p.ex}</Sans>
          </div>
        ))}
      </Panel>
    </SectionWrap>
  );
}

// ── Admin · Creative AI ──────────────────────────────────────────────────
function AdminCreativeAI() {
  const [prompt, setPrompt] = useState('');
  const [output, setOutput] = useState('');
  const [busy, setBusy] = useState(false);

  function generate() {
    if (!prompt.trim()) return;
    setBusy(true);
    setOutput('');
    let token = ''; try { token = localStorage.getItem('tlb_admin_token') || localStorage.getItem('tlb_token') || ''; } catch {}
    fetch('/api/creative-ai', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', ...(token ? { 'Authorization': 'Bearer ' + token } : {}) },
      body: JSON.stringify({ prompt: prompt }),
    })
      .then(r => r.json().then(j => ({ ok: r.ok, j })))
      .then(({ ok, j }) => {
        if (!ok) { setOutput('— Error: ' + (j.error || 'Generation failed')); return; }
        setOutput(j.output || j.text || j.result || '— No output returned.');
      })
      .catch(err => setOutput('— Network error: ' + err.message))
      .finally(() => setBusy(false));
  }

  return (
    <SectionWrap
      title="Creative AI"
      subtitle="Aetheria writes in TLB editorial voice — never breathless, always specific. Drop a brief, get hero copy, descriptions, and email sequences in the same register."
    >
      <Sans size={10} weight={600} ls={0.14} upper color={CC.mid} style={{ marginBottom: 18 }}>
        Aetheria writing playground · in-voice copy · 8 presets
      </Sans>
      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: dashGap() }}>
        <Panel>
          <Display size={20} italic weight={400} style={{ marginBottom: 12 }}>Prompt.</Display>
          <Sans size={11} color={CC.mid} style={{ marginBottom: 10 }}>Describe what you need. Aetheria writes in TLB editorial voice — never breathless, always specific.</Sans>
          <textarea
            value={prompt}
            onChange={e => setPrompt(e.target.value)}
            placeholder="e.g. Hero copy for a 3-bedroom Uluwatu villa, sunset-cliff view, family of four, 5 nights"
            style={{ width: '100%', minHeight: 140, padding: 14, border: `1px solid ${CC.border}`, borderRadius: 12, fontFamily: F_TLB.ui, fontSize: 13, color: CC.deep, resize: 'vertical', outline: 'none' }}
          />
          <button
            onClick={generate}
            disabled={busy || !prompt.trim()}
            style={{ marginTop: 12, padding: '10px 22px', borderRadius: 100, border: 'none', background: CC.ink, color: CC.white, fontSize: 12, fontWeight: 600, letterSpacing: '0.04em', cursor: busy ? 'wait' : 'pointer', opacity: busy ? 0.6 : 1 }}>
            {busy ? 'Generating…' : 'Generate copy →'}
          </button>
        </Panel>
        <Panel>
          <Display size={20} italic weight={400} style={{ marginBottom: 12 }}>Output.</Display>
          {output ? (
            <Sans size={13} color={CC.deep} style={{ lineHeight: 1.7, whiteSpace: 'pre-wrap', fontFamily: F_TLB.display, fontStyle: 'italic', fontSize: 16 }}>{output}</Sans>
          ) : (
            <Sans size={12} color={CC.mid} style={{ fontStyle: 'italic' }}>Output will appear here. Aetheria typically takes 4–8 seconds.</Sans>
          )}
        </Panel>
      </div>
    </SectionWrap>
  );
}

// ════════════════════════════════════════════════════════════════════════
// HOST FINAL BATCH (7 sections)
// ════════════════════════════════════════════════════════════════════════

// ── Host · Availability ──────────────────────────────────────────────────
// v141.128 — HostAvailability, v141.123 mockup port.
//   • 28-day per-villa tappable grid (free / booked / blocked)
//   • Booked cells are read-only (locked from bookings); blocked cells
//     can be toggled with a tap (creates/removes a blocked_dates row)
//   • Block range modal: pick villa + date range
//   • Legend at bottom + header CTAs
//   • Reads bookings + blocked_dates per villa (lazy fetch like HostCalendar)
function HostAvailability() {
  const propsApi = useApi('/api/properties?status=all', { tokenType: 'host' });
  const bookApi = useApi('/api/bookings?status=confirmed&limit=200', { tokenType: 'host' });
  const villas = ((propsApi.data && propsApi.data.properties) || []).slice(0, 8);
  const bookings = (bookApi.data && bookApi.data.bookings) || [];

  const [showBlockRange, setShowBlockRange] = React.useState(false);
  const [blocksMap, setBlocksMap] = React.useState({});
  const [pendingToggles, setPendingToggles] = React.useState({});  // { villaId_iso: 'block'|'unblock' }
  const [saving, setSaving] = React.useState(false);

  React.useEffect(() => {
    if (!villas.length) return;
    let cancelled = false;
    (async () => {
      const next = {};
      for (const v of villas) {
        try {
          const r = await fetch('/api/blocked-dates?property_id=' + encodeURIComponent(v.id));
          if (r.ok) {
            const j = await r.json();
            next[v.id] = (j.blocks || []).map(b => ({ start: b.date_start, end: b.date_end, id: b.id, source: b.source || 'manual' }));
          }
        } catch {}
      }
      if (!cancelled) setBlocksMap(next);
    })();
    return () => { cancelled = true; };
  }, [villas.map(v => v.id).join(',')]);

  if (propsApi.loading || bookApi.loading) return <SectionWrap title="Availability"><Loading msg="Loading occupancy…" /></SectionWrap>;
  if (propsApi.error || bookApi.error) return <SectionWrap title="Availability"><ErrorState msg={propsApi.error || bookApi.error} onRetry={() => { propsApi.refetch(); bookApi.refetch(); }} /></SectionWrap>;

  const days = 28;
  const startDate = new Date(); startDate.setHours(0, 0, 0, 0);
  const dayList = Array.from({ length: days }, (_, i) => {
    const d = new Date(startDate); d.setDate(d.getDate() + i);
    return {
      iso: d.toISOString().slice(0, 10),
      d: d.getDate(),
      weekend: d.getDay() === 0 || d.getDay() === 6,
      isToday: i === 0,
      date: d,
    };
  });

  function cellState(villaId, villaName, dayIso) {
    // Locked = booked (real reservation overlaps this day)
    const day = new Date(dayIso);
    const bookedToday = bookings.some(b => {
      if (b.property_id !== villaId && b.property_name !== villaName) return false;
      if (!b.check_in || !b.check_out) return false;
      return day >= new Date(b.check_in) && day < new Date(b.check_out);
    });
    if (bookedToday) return 'booked';
    // Blocked check (server-side manual or iCal)
    const blocks = blocksMap[villaId] || [];
    const blocked = blocks.some(b => day >= new Date(b.start) && day < new Date(b.end));
    // Apply pending toggle
    const key = villaId + '_' + dayIso;
    const pending = pendingToggles[key];
    if (pending === 'block') return 'blocked-pending';
    if (pending === 'unblock') return 'free';
    return blocked ? 'blocked' : 'free';
  }

  function toggleCell(villaId, dayIso, currentState) {
    if (currentState === 'booked') return;  // read-only
    const key = villaId + '_' + dayIso;
    setPendingToggles(prev => {
      const next = { ...prev };
      if (currentState === 'blocked' || currentState === 'blocked-pending') {
        next[key] = 'unblock';
      } else {
        next[key] = 'block';
      }
      return next;
    });
  }

  async function commitToggles() {
    const ids = Object.keys(pendingToggles);
    if (!ids.length) return;
    setSaving(true);
    const token = localStorage.getItem('tlb_host_token') || localStorage.getItem('tlb_token') || '';
    for (const key of ids) {
      const [villaId, dayIso] = key.split('_');
      const action = pendingToggles[key];
      const dayEndDate = new Date(dayIso); dayEndDate.setDate(dayEndDate.getDate() + 1);
      const dayEnd = dayEndDate.toISOString().slice(0, 10);
      try {
        if (action === 'block') {
          await fetch('/api/blocked-dates', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json', ...(token ? { 'Authorization': 'Bearer ' + token } : {}) },
            body: JSON.stringify({ property_id: villaId, date_start: dayIso, date_end: dayEnd, source: 'manual', notes: 'Block from availability grid' }),
          });
        } else if (action === 'unblock') {
          // Find the block that overlaps this day; delete it
          const blocks = blocksMap[villaId] || [];
          const target = blocks.find(b => new Date(dayIso) >= new Date(b.start) && new Date(dayIso) < new Date(b.end));
          if (target && target.id) {
            await fetch('/api/blocked-dates?id=' + encodeURIComponent(target.id), {
              method: 'DELETE',
              headers: { ...(token ? { 'Authorization': 'Bearer ' + token } : {}) },
            });
          }
        }
      } catch {}
    }
    setPendingToggles({});
    setSaving(false);
    // Refresh blocks
    const next = {};
    for (const v of villas) {
      try {
        const r = await fetch('/api/blocked-dates?property_id=' + encodeURIComponent(v.id));
        if (r.ok) {
          const j = await r.json();
          next[v.id] = (j.blocks || []).map(b => ({ start: b.date_start, end: b.date_end, id: b.id, source: b.source || 'manual' }));
        }
      } catch {}
    }
    setBlocksMap(next);
  }

  const empty = villas.length === 0;
  const hasPending = Object.keys(pendingToggles).length > 0;

  return (
    <SectionWrap
      title="Availability"
      subtitle="Tap a cell to flip its state — free, blocked, or booked. Booked cells are read-only — cancel the booking from the inbox to free them."
      actions={
        <div style={{ display: 'flex', gap: 8 }}>
          <button onClick={() => setShowBlockRange(true)} disabled={empty}
            style={{ padding: '10px 18px', borderRadius: 100, cursor: empty ? 'not-allowed' : 'pointer', background: CC.white, color: CC.deep, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600, opacity: empty ? 0.5 : 1 }}>
            Block range
          </button>
          <button onClick={commitToggles} disabled={!hasPending || saving}
            style={{ padding: '10px 18px', borderRadius: 100, cursor: hasPending && !saving ? 'pointer' : 'not-allowed', background: CC.ink, color: CC.white, border: '1px solid ' + CC.ink, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600, opacity: hasPending && !saving ? 1 : 0.5 }}>
            {saving ? 'Saving…' : hasPending ? `Save · ${Object.keys(pendingToggles).length}` : 'Save'}
          </button>
        </div>
      }
    >
      <Sans size={10} weight={600} ls={0.14} upper color={CC.mid} style={{ marginBottom: 18 }}>
        Next {days} days · {villas.length} {villas.length === 1 ? 'property' : 'properties'}
      </Sans>

      {empty ? (
        <Empty title="No properties yet" msg="Add a villa first — the availability grid will appear once you have one." />
      ) : (
        <Panel pad={20}>
          <div style={{ overflowX: 'auto' }}>
            <div style={{ minWidth: days * 30 + 160 }}>
              {/* Day header row */}
              <div style={{ display: 'grid', gridTemplateColumns: `160px repeat(${days}, minmax(0, 1fr))`, gap: 1, background: CC.border, marginBottom: 2 }}>
                <div style={{ background: CC.white, padding: '8px 0' }} />
                {dayList.map((dl, i) => (
                  <div key={i} style={{
                    background: CC.white, padding: '8px 0', textAlign: 'center',
                    fontFamily: F_TLB.ui, fontSize: 9.5,
                    color: dl.isToday ? CC.ink : dl.weekend ? CC.deep : CC.mid,
                    fontWeight: dl.isToday ? 700 : 500,
                  }}>{dl.d}</div>
                ))}
              </div>
              {/* Villa rows */}
              {villas.map((v) => (
                <div key={v.id} style={{
                  display: 'grid', gridTemplateColumns: `160px repeat(${days}, minmax(0, 1fr))`,
                  gap: 1, background: CC.border, marginBottom: 1,
                }}>
                  <div style={{
                    background: CC.white, padding: '10px 14px',
                    display: 'flex', alignItems: 'center',
                    fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 500, color: CC.ink,
                  }}>{v.name}</div>
                  {dayList.map((dl, di) => {
                    const state = cellState(v.id, v.name, dl.iso);
                    const isBooked = state === 'booked';
                    const isBlocked = state === 'blocked' || state === 'blocked-pending';
                    const isPending = state === 'blocked-pending';
                    return (
                      <div key={di}
                        onClick={() => toggleCell(v.id, dl.iso, state)}
                        title={dl.date.toDateString() + ' · ' + state + (isBooked ? ' (read-only)' : '')}
                        style={{
                          background: isBooked ? CC.ink
                                    : isBlocked ? '#fbf8f0'
                                    : (dl.weekend ? CC.offWhite : CC.white),
                          backgroundImage: isBlocked
                            ? `repeating-linear-gradient(135deg, transparent 0 4px, rgba(154,125,63,${isPending ? 0.4 : 0.2}) 4px 5px)`
                            : 'none',
                          height: 32,
                          cursor: isBooked ? 'not-allowed' : 'pointer',
                          border: isPending ? `1px dashed #9a7d3f` : 'none',
                        }}
                      />
                    );
                  })}
                </div>
              ))}
            </div>
          </div>
        </Panel>
      )}

      {/* Legend */}
      <div style={{ display: 'flex', gap: 18, marginTop: 16, fontFamily: F_TLB.ui, fontSize: 11.5, color: CC.mid, flexWrap: 'wrap' }}>
        <span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
          <span style={{ width: 14, height: 10, background: CC.ink, borderRadius: 2 }} /> Booked (read-only)
        </span>
        <span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
          <span style={{
            width: 14, height: 10, background: '#fbf8f0', borderRadius: 2,
            backgroundImage: 'repeating-linear-gradient(135deg, transparent 0 4px, rgba(154,125,63,0.2) 4px 5px)',
          }} /> Blocked (tap to free)
        </span>
        <span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
          <span style={{ width: 14, height: 10, background: CC.white, border: `1px solid ${CC.border}`, borderRadius: 2 }} /> Free (tap to block)
        </span>
        <span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
          <span style={{ width: 14, height: 10, background: '#fbf8f0', borderRadius: 2, border: '1px dashed #9a7d3f' }} /> Pending save
        </span>
      </div>

      {showBlockRange && (
        <HostAddBlockModal
          villas={villas}
          onClose={() => setShowBlockRange(false)}
          onSuccess={() => {
            setShowBlockRange(false);
            // Refresh blocks
            (async () => {
              const next = {};
              for (const v of villas) {
                try {
                  const r = await fetch('/api/blocked-dates?property_id=' + encodeURIComponent(v.id));
                  if (r.ok) {
                    const j = await r.json();
                    next[v.id] = (j.blocks || []).map(b => ({ start: b.date_start, end: b.date_end, id: b.id, source: b.source || 'manual' }));
                  }
                } catch {}
              }
              setBlocksMap(next);
            })();
          }}
        />
      )}
    </SectionWrap>
  );
}

// ── Host · Disputes ──────────────────────────────────────────────────────
// ── Host · Disputes (v123 — wired to /api/damage-claims) ───────────────
function HostDisputes() {
  const [filter, setFilter] = useState('open');
  const [fileOpen, setFileOpen] = useState(false);
  const { data, loading, error, refetch } = useApi('/api/damage-claims?status=' + filter, { tokenType: 'host' });
  if (loading) return <SectionWrap title="Damage claims"><Loading msg="Loading claims…" /></SectionWrap>;
  if (error) return <SectionWrap title="Damage claims"><ErrorState msg={error} onRetry={refetch} /></SectionWrap>;

  const claims = (data && data.claims) || [];

  return (
    <SectionWrap title="Damage claims"
      subtitle={`${claims.length} ${filter} · file within 7 days of checkout for full deposit hold`}
      actions={
        <div style={{ display: 'flex', gap: 6 }}>
          <button onClick={() => setFileOpen(true)}
            style={{ padding: '8px 18px', background: CC.ink, color: CC.white, border: 'none', borderRadius: 100, fontSize: 12, fontWeight: 600, cursor: 'pointer' }}>+ File new claim</button>
          {['open', 'approved', 'rejected', 'all'].map(f => (
            <button key={f} onClick={() => setFilter(f)}
              style={{ padding: '7px 14px', borderRadius: 100, fontSize: 11, fontWeight: 600,
                border: '1px solid ' + (filter === f ? CC.ink : CC.border),
                background: filter === f ? CC.ink : CC.white,
                color: filter === f ? CC.white : CC.deep, cursor: 'pointer' }}>{f}</button>
          ))}
        </div>
      }>
      {claims.length === 0 ? (
        <Empty title={'No ' + filter + ' claims'} msg="If a guest causes damage during their stay, file a claim with photo evidence within 7 days of checkout. Admin adjudicates and refunds the disputed portion of the deposit." />
      ) : (
        <Table
          columns={[
            { label: 'Filed', key: 'created_at', nowrap: true, render: r => fmtDate(r.created_at) },
            { label: 'Booking', key: 'booking_id', render: r => <code style={{ color: CC.mid, fontSize: 11 }}>{(r.booking_id || '').slice(0, 8)}</code> },
            { label: 'Guest · Villa', key: 'guest', render: r => (
              <>
                <div style={{ fontWeight: 500 }}>{r.booking?.guest_name || '—'}</div>
                <div style={{ color: CC.mid, fontSize: 11 }}>{r.booking?.property_name || ''}</div>
              </>
            )},
            { label: 'Type', key: 'damage_type', render: r => <Pill tone="warn">{r.damage_type}</Pill> },
            { label: 'Claimed', key: 'amount_claimed', align: 'right', numeric: true, render: r => fmtIDR(r.amount_claimed) },
            { label: 'Approved', key: 'amount_approved', align: 'right', numeric: true, render: r => r.amount_approved > 0 ? fmtIDR(r.amount_approved) : '—' },
            { label: 'Status', key: 'status', render: r => <Pill tone={statusTone(r.status)}>{r.status}</Pill> },
          ]}
          rows={claims}
        />
      )}
      {fileOpen && <FileDamageClaimModal onClose={() => setFileOpen(false)} onSuccess={refetch} />}
    </SectionWrap>
  );
}

function FileDamageClaimModal({ onClose, onSuccess }) {
  const bookingsApi = useApi('/api/bookings?status=completed&limit=30', { tokenType: 'host' });
  const bookings = (bookingsApi.data && bookingsApi.data.bookings) || [];
  const [bookingId, setBookingId] = useState('');
  const [damageType, setDamageType] = useState('breakage');
  const [description, setDescription] = useState('');
  const [amount, setAmount] = useState('');
  const [uploadedUrls, setUploadedUrls] = useState([]);  // direct uploads
  const [evidence, setEvidence] = useState('');           // legacy URL paste
  const [uploading, setUploading] = useState(false);
  const [submitting, setSubmitting] = useState(false);
  const [err, setErr] = useState('');

  // v124 — Direct file upload to Supabase Storage via /api/upload-photo
  async function handleFiles(files) {
    if (!files || !files.length) return;
    if (!bookingId) { setErr('Select a booking first before uploading'); return; }
    setErr('');
    setUploading(true);
    const token = localStorage.getItem('tlb_host_token') || localStorage.getItem('tlb_token') || '';
    const newUrls = [];
    for (const file of Array.from(files)) {
      if (file.size > 12 * 1024 * 1024) {
        setErr('Skipped ' + file.name + ': larger than 12 MB');
        continue;
      }
      const fd = new FormData();
      fd.append('file', file);
      fd.append('bucket', 'damage-evidence');
      fd.append('kind', 'damage');
      fd.append('booking_id', bookingId);
      try {
        const res = await fetch('/api/upload-photo', {
          method: 'POST',
          headers: { Authorization: 'Bearer ' + token },
          body: fd,
        });
        if (res.ok) {
          const j = await res.json();
          if (j.public_url) newUrls.push(j.public_url);
        } else {
          const j = await res.json().catch(() => ({}));
          setErr('Upload failed: ' + (j.error || res.statusText));
        }
      } catch (e) {
        setErr('Upload error: ' + e.message);
      }
    }
    setUploadedUrls(prev => [...prev, ...newUrls]);
    setUploading(false);
  }

  async function submit() {
    setErr('');
    if (!bookingId) return setErr('Select a booking');
    if (!description) return setErr('Description required');
    const amt = parseFloat(amount);
    if (!(amt > 0)) return setErr('Amount must be > 0');
    setSubmitting(true);
    const token = localStorage.getItem('tlb_host_token') || localStorage.getItem('tlb_token') || '';
    const allUrls = [
      ...uploadedUrls,
      ...evidence.split(/\s*\n\s*/).filter(s => /^https?:\/\//.test(s)),
    ];
    const res = await fetch('/api/damage-claims?action=file', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
      body: JSON.stringify({
        booking_id: bookingId,
        damage_type: damageType,
        description,
        amount_claimed: amt,
        evidence_urls: allUrls,
      }),
    });
    setSubmitting(false);
    if (res.ok) { onSuccess(); onClose(); }
    else { const j = await res.json().catch(() => ({})); setErr(j.error || 'Failed'); }
  }

  return (
    <div style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(20,20,20,.55)', backdropFilter: 'blur(6px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}>
      <Panel style={{ maxWidth: 540, width: '100%', maxHeight: '90vh', overflowY: 'auto' }}>
        <Display size={22} italic weight={400} style={{ marginBottom: 8 }}>File damage claim.</Display>
        <Sans size={12} color={CC.mid} style={{ marginBottom: 16 }}>Admin will review evidence within 48 hours and decide refund amount.</Sans>

        <div style={{ display: 'grid', gap: 12 }}>
          <label>
            <Sans size={10} weight={700} ls={0.16} upper color={CC.mid} style={{ marginBottom: 4 }}>Booking</Sans>
            <select value={bookingId} onChange={e => setBookingId(e.target.value)}
              style={{ width: '100%', padding: '10px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 13, fontFamily: 'inherit' }}>
              <option value="">Select a recent completed booking…</option>
              {bookings.map(b => (
                <option key={b.id} value={b.id}>{b.guest_name || b.guest_email} · {b.property_name} · {fmtDate(b.check_out)}</option>
              ))}
            </select>
          </label>
          <label>
            <Sans size={10} weight={700} ls={0.16} upper color={CC.mid} style={{ marginBottom: 4 }}>Damage type</Sans>
            <select value={damageType} onChange={e => setDamageType(e.target.value)}
              style={{ width: '100%', padding: '10px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 13, fontFamily: 'inherit' }}>
              {['breakage','cleaning','missing','utility','smoking','pets','noise','general'].map(t => <option key={t} value={t}>{t}</option>)}
            </select>
          </label>
          <label>
            <Sans size={10} weight={700} ls={0.16} upper color={CC.mid} style={{ marginBottom: 4 }}>Description</Sans>
            <textarea value={description} onChange={e => setDescription(e.target.value)} rows={4}
              style={{ width: '100%', padding: '10px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 13, fontFamily: 'inherit', resize: 'vertical' }}
              placeholder="What was damaged? When did you notice? Cost estimate breakdown."/>
          </label>
          <label>
            <Sans size={10} weight={700} ls={0.16} upper color={CC.mid} style={{ marginBottom: 4 }}>Amount claimed (IDR)</Sans>
            <input type="number" min="0" value={amount} onChange={e => setAmount(e.target.value)}
              style={{ width: '100%', padding: '10px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 13, fontFamily: 'inherit' }}
              placeholder="e.g. 1500000"/>
          </label>
          <div>
            <Sans size={10} weight={700} ls={0.16} upper color={CC.mid} style={{ marginBottom: 6 }}>Photo evidence</Sans>
            <div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 8 }}>
              <input type="file" multiple accept="image/*" disabled={uploading}
                onChange={e => handleFiles(e.target.files)}
                style={{ flex: 1, padding: '8px', border: '1px dashed ' + CC.border, borderRadius: 8, fontSize: 12, fontFamily: 'inherit', background: CC.offWhite, cursor: 'pointer' }} />
              {uploading && <Sans size={11} color={CC.mid}>Uploading…</Sans>}
            </div>
            {uploadedUrls.length > 0 && (
              <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 8 }}>
                {uploadedUrls.map((u, i) => (
                  <div key={i} style={{ position: 'relative' }}>
                    <img src={u} style={{ width: 64, height: 64, objectFit: 'cover', borderRadius: 4, border: '1px solid ' + CC.border }} alt="" />
                    <button type="button" onClick={() => setUploadedUrls(prev => prev.filter((_, j) => j !== i))}
                      style={{ position: 'absolute', top: -6, right: -6, width: 20, height: 20, border: 'none', background: '#9a1c1c', color: CC.white, borderRadius: '50%', fontSize: 11, fontWeight: 700, cursor: 'pointer' }}>×</button>
                  </div>
                ))}
              </div>
            )}
            <details style={{ marginTop: 4 }}>
              <summary style={{ cursor: 'pointer', fontSize: 11, color: CC.mid }}>or paste URLs (one per line)</summary>
              <textarea value={evidence} onChange={e => setEvidence(e.target.value)} rows={2}
                style={{ width: '100%', padding: '8px 10px', border: '1px solid ' + CC.border, borderRadius: 6, fontSize: 11, fontFamily: 'monospace', resize: 'vertical', marginTop: 6 }}
                placeholder="https://…"/>
            </details>
          </div>
          {err && <Sans size={12} color="#9a1c1c">{err}</Sans>}
          <div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
            <button onClick={onClose} disabled={submitting}
              style={{ padding: '10px 20px', border: '1px solid ' + CC.border, background: CC.white, color: CC.deep, borderRadius: 100, fontSize: 12, fontWeight: 600, cursor: 'pointer' }}>Cancel</button>
            <button onClick={submit} disabled={submitting}
              style={{ flex: 1, padding: '10px 20px', border: 'none', background: CC.ink, color: CC.white, borderRadius: 100, fontSize: 12, fontWeight: 600, cursor: 'pointer', opacity: submitting ? 0.6 : 1 }}>{submitting ? 'Submitting…' : 'File claim'}</button>
          </div>
        </div>
      </Panel>
    </div>
  );
}

// ── Host · Tasks ─────────────────────────────────────────────────────────
function HostTasks() {
  // No /api/tasks yet — derive a task list from bookings (check-in tomorrow,
  // check-out today, etc.). Live data when authed, sensible empties otherwise.
  const { data, loading, error, refetch } = useApi('/api/bookings?status=confirmed&limit=50', { tokenType: 'host' });
  if (loading) return <SectionWrap title="Tasks"><Loading msg="Building task list…" /></SectionWrap>;
  if (error) return <SectionWrap title="Tasks"><ErrorState msg={error} onRetry={refetch} /></SectionWrap>;

  const bk = (data && data.bookings) || [];
  const today = new Date(); today.setHours(0,0,0,0);
  const tomorrow = new Date(today); tomorrow.setDate(tomorrow.getDate() + 1);
  const tasks = [];
  bk.forEach(b => {
    const ci = new Date(b.check_in); ci.setHours(0,0,0,0);
    const co = new Date(b.check_out); co.setHours(0,0,0,0);
    if (+ci === +tomorrow) tasks.push({ kind: 'Prepare for arrival', booking: b, when: 'Tomorrow', urgent: true });
    if (+ci === +today) tasks.push({ kind: 'Welcome guest', booking: b, when: 'Today', urgent: true });
    if (+co === +today) tasks.push({ kind: 'Check-out + clean', booking: b, when: 'Today', urgent: true });
    if (+ci > +today && (ci - today) / 86400000 < 4) tasks.push({ kind: 'Confirm pre-stay messages', booking: b, when: ci.toLocaleDateString('en-GB', { weekday: 'short', day: '2-digit', month: 'short' }), urgent: false });
  });

  return (
    <SectionWrap title="Tasks" subtitle={`${tasks.length} actionable · ${tasks.filter(t => t.urgent).length} urgent today/tomorrow`}>
      {tasks.length === 0 ? (
        <Empty title="No tasks" msg="Tasks auto-generate from upcoming check-ins, check-outs, and pre-stay touchpoints." />
      ) : (
        <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
          {tasks.map((t, i) => (
            <Panel key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, padding: '14px 18px', borderLeft: `3px solid ${t.urgent ? '#9a1c1c' : CC.border}` }}>
              <div>
                <Sans size={13} weight={500} color={CC.ink}>{t.kind}</Sans>
                <Sans size={11} color={CC.mid} style={{ marginTop: 4 }}>{t.booking.guest_name || 'Guest'} · {t.booking.property_name || 'Villa'} · {t.when}</Sans>
              </div>
              <Pill tone={t.urgent ? 'danger' : 'info'}>{t.urgent ? 'Urgent' : 'Upcoming'}</Pill>
            </Panel>
          ))}
        </div>
      )}
    </SectionWrap>
  );
}

// ── Host · Check-in (v128 — driver dispatch + photo proof) ─────────────
// v141.130 — HostCheckIn, v141.123 mockup port.
//   • Row-cards (was table) showing date · time · guest · villa · party
//     size · driver · photo/keys pill · Upload + Open actions
//   • In-house section preserved (currently-staying guests)
//   • Header CTA: Send welcome packets (bulk dispatch to all upcoming
//     guests via /api/welcome-packet?action=send)
//   • Existing CheckInModal driver-assign + photo upload flow preserved
function HostCheckIn() {
  const { data, loading, error, refetch } = useApi('/api/bookings?status=all&limit=100', { tokenType: 'host' });
  const [activeBooking, setActiveBooking] = useState(null);
  const [sendingPackets, setSendingPackets] = useState(false);

  if (loading) return <SectionWrap title="Check-in"><Loading msg="Loading bookings…" /></SectionWrap>;
  if (error)   return <SectionWrap title="Check-in"><ErrorState msg={error} onRetry={refetch} /></SectionWrap>;

  const bookings = (data && data.bookings) || [];
  const today = new Date(); today.setHours(0,0,0,0);
  const twoWeeks = new Date(today); twoWeeks.setDate(twoWeeks.getDate() + 14);

  const upcoming = bookings.filter(b => {
    const ci = b.check_in ? new Date(b.check_in + 'T00:00:00') : null;
    return ci && ci >= today && ci <= twoWeeks
      && (b.status === 'confirmed' || b.status === 'paid');
  }).sort((a, b) => new Date(a.check_in) - new Date(b.check_in));

  const active = bookings.filter(b => {
    const ci = b.check_in ? new Date(b.check_in + 'T00:00:00') : null;
    const co = b.check_out ? new Date(b.check_out + 'T00:00:00') : null;
    return ci && co && ci <= today && today < co
      && (b.status === 'confirmed' || b.status === 'paid');
  });

  function statusOf(b) { return b.check_in_status || 'not_started'; }
  function tone(s) {
    if (s === 'verified' || s === 'arrived') return 'success';
    if (s === 'no_show')  return 'danger';
    if (s === 'driver_assigned' || s === 'en_route') return 'info';
    return 'neutral';
  }

  function dateLabel(ci) {
    const d = new Date(ci + 'T00:00:00');
    const days = Math.round((d - today) / 86400000);
    if (days === 0) return 'Today';
    if (days === 1) return 'Tomorrow';
    if (days <= 6) return d.toLocaleDateString('en-GB', { weekday: 'long' });
    return '+' + days + ' days';
  }

  async function bulkSendPackets() {
    if (!upcoming.length) return;
    if (!confirm(`Send welcome packets to all ${upcoming.length} upcoming guests now? Normally we auto-send 7 days before check-in — this is the manual bulk trigger.`)) return;
    setSendingPackets(true);
    const token = localStorage.getItem('tlb_host_token') || localStorage.getItem('tlb_token') || '';
    let ok = 0, fail = 0;
    for (const b of upcoming) {
      try {
        const r = await fetch('/api/welcome-packet?action=send', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json', ...(token ? { 'Authorization': 'Bearer ' + token } : {}) },
          body: JSON.stringify({ booking_id: b.id }),
        });
        if (r.ok) ok++; else fail++;
      } catch { fail++; }
    }
    setSendingPackets(false);
    alert(`Sent ${ok} packets. ${fail ? fail + ' failed.' : ''} Guests get the packet by email + WhatsApp.`);
  }

  return (
    <SectionWrap
      title="Check-in"
      subtitle="Arrival times, party size, key handover photos, driver dispatch. Tap a row to manage the welcome thread."
      actions={
        <button onClick={bulkSendPackets} disabled={!upcoming.length || sendingPackets}
          style={{ padding: '10px 18px', borderRadius: 100, cursor: upcoming.length && !sendingPackets ? 'pointer' : 'not-allowed', background: CC.ink, color: CC.white, border: '1px solid ' + CC.ink, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600, opacity: upcoming.length && !sendingPackets ? 1 : 0.5 }}>
          {sendingPackets ? 'Sending…' : 'Send welcome packets'}
        </button>
      }
    >
      <Sans size={10} weight={600} ls={0.14} upper color={CC.mid} style={{ marginBottom: 18 }}>
        {active.length} {active.length === 1 ? 'guest' : 'guests'} in-house · {upcoming.length} arriving in 14 days
      </Sans>

      {/* KPI grid */}
      <div style={{
        display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 0,
        border: `1px solid ${CC.border}`, borderRadius: 18, overflow: 'hidden', background: CC.white, marginBottom: dashGap(),
      }}>
        {[
          { label: 'In-house',         value: active.length },
          { label: 'Upcoming · 14d',   value: upcoming.length },
          { label: 'Driver assigned',  value: upcoming.filter(b => statusOf(b) === 'driver_assigned' || statusOf(b) === 'en_route').length },
          { label: 'Awaiting prep',    value: upcoming.filter(b => statusOf(b) === 'not_started').length },
        ].map((k, i) => (
          <div key={k.label} style={{ padding: '24px 26px', borderRight: i < 3 ? `1px solid ${CC.border}` : 'none', display: 'flex', flexDirection: 'column', gap: 12, minHeight: 130 }}>
            <Sans size={10} weight={600} ls={0.14} upper color={CC.mid}>{k.label}</Sans>
            <div style={{
              fontFamily: F_TLB.ui, fontSize: 36, fontWeight: 500,
              letterSpacing: '-0.025em', color: CC.ink,
              fontVariantNumeric: 'tabular-nums', lineHeight: 0.98,
              marginTop: 'auto',
            }}>{k.value}</div>
          </div>
        ))}
      </div>

      {/* Upcoming arrivals — row cards */}
      {upcoming.length === 0 ? (
        <Empty title="No upcoming arrivals" msg="Once you have confirmed bookings within the next 14 days, they'll surface here." />
      ) : (
        <>
          <Sans size={10} weight={600} ls={0.14} upper color={CC.mid} style={{ marginBottom: 14, marginTop: 10 }}>
            Upcoming arrivals · today + next 14 days
          </Sans>
          <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
            {upcoming.map((b, i) => {
              const s = statusOf(b);
              const driver = b.driver_name || (b.driver_assigned ? 'Assigned' : '—');
              const hasPhoto = b.checkin_photo_url || s === 'verified' || s === 'arrived';
              const hasKeys = b.keys_handed_over || s === 'verified' || s === 'arrived';
              return (
                <Panel key={b.id || i} pad={24}>
                  <div style={{
                    display: 'grid', gridTemplateColumns: 'auto 1.4fr 1fr 1fr auto',
                    gap: 24, alignItems: 'center',
                  }}>
                    <div>
                      <Sans size={10} weight={600} ls={0.20} upper color={CC.mid}>{dateLabel(b.check_in)}</Sans>
                      <div style={{
                        fontFamily: F_TLB.ui, fontSize: 23, fontWeight: 500,
                        fontFeatureSettings: '"tnum"', marginTop: 6, letterSpacing: '-0.02em',
                        fontVariantNumeric: 'tabular-nums',
                      }}>{b.check_in_time || '15:00'}</div>
                    </div>
                    <div>
                      <Display size={19} weight={500} ls={-0.01}>{b.guest_name || 'Guest'}</Display>
                      <Sans size={11.5} color={CC.mid} style={{ marginTop: 4 }}>
                        {b.property_name || 'Villa'} · party of {b.guests || 2}
                      </Sans>
                    </div>
                    <div>
                      <Sans size={10} weight={600} ls={0.14} upper color={CC.mid}>Driver</Sans>
                      <div style={{ fontFamily: F_TLB.ui, fontSize: 13, marginTop: 6, color: CC.ink }}>{driver}</div>
                    </div>
                    <div style={{ display: 'flex', gap: 8 }}>
                      <Pill tone={hasPhoto ? 'success' : 'neutral'}>{hasPhoto ? 'Photo ✓' : 'No photo'}</Pill>
                      <Pill tone={hasKeys ? 'success' : 'neutral'}>{hasKeys ? 'Keys ✓' : 'No keys'}</Pill>
                    </div>
                    <div style={{ display: 'flex', gap: 8 }}>
                      <button onClick={() => setActiveBooking(b)}
                        style={{ padding: '8px 14px', borderRadius: 100, cursor: 'pointer', background: CC.white, color: CC.deep, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 11, fontWeight: 600 }}>
                        Upload
                      </button>
                      <button onClick={() => setActiveBooking(b)}
                        style={{ padding: '8px 14px', borderRadius: 100, cursor: 'pointer', background: CC.ink, color: CC.white, border: '1px solid ' + CC.ink, fontFamily: F_TLB.ui, fontSize: 11, fontWeight: 600 }}>
                        Open
                      </button>
                    </div>
                  </div>
                </Panel>
              );
            })}
          </div>
        </>
      )}

      {/* In-house */}
      {active.length > 0 && (
        <>
          <Sans size={10} weight={600} ls={0.14} upper color={CC.mid} style={{ marginBottom: 14, marginTop: 24 }}>
            Currently in-house
          </Sans>
          <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
            {active.map((b, i) => (
              <Panel key={b.id || i} pad={24}>
                <div style={{
                  display: 'grid', gridTemplateColumns: 'auto 1.4fr 1fr auto auto',
                  gap: 24, alignItems: 'center',
                }}>
                  <div>
                    <Sans size={10} weight={600} ls={0.20} upper color={CC.mid}>Checks out</Sans>
                    <div style={{ fontFamily: F_TLB.display, fontSize: 18, marginTop: 6, letterSpacing: '-0.01em' }}>{fmtDate(b.check_out)}</div>
                  </div>
                  <div>
                    <Display size={19} weight={500} ls={-0.01}>{b.guest_name || 'Guest'}</Display>
                    <Sans size={11.5} color={CC.mid} style={{ marginTop: 4 }}>
                      {b.property_name || 'Villa'} · party of {b.guests || 2}
                    </Sans>
                  </div>
                  <Pill tone={tone(statusOf(b))}>{statusOf(b).replace(/_/g, ' ')}</Pill>
                  <button onClick={() => setActiveBooking(b)}
                    style={{ padding: '8px 14px', borderRadius: 100, cursor: 'pointer', background: CC.white, color: CC.deep, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 11, fontWeight: 600 }}>
                    View thread
                  </button>
                  <button onClick={() => setActiveBooking(b)}
                    style={{ padding: '8px 14px', borderRadius: 100, cursor: 'pointer', background: CC.ink, color: CC.white, border: '1px solid ' + CC.ink, fontFamily: F_TLB.ui, fontSize: 11, fontWeight: 600 }}>
                    Manage
                  </button>
                </div>
              </Panel>
            ))}
          </div>
        </>
      )}

      {activeBooking && <CheckInModal booking={activeBooking} role="host" onClose={() => setActiveBooking(null)} onSuccess={refetch} />}
    </SectionWrap>
  );
}

// CheckInModal — assign driver, upload proofs, verify check-in
function CheckInModal({ booking, role, onClose, onSuccess }) {
  const [tab, setTab] = useState('driver');
  const [state, setState] = useState(null);
  const [loading, setLoading] = useState(true);

  // Driver form fields
  const [driverName, setDriverName] = useState('');
  const [driverPhone, setDriverPhone] = useState('');
  const [vehicleMake, setVehicleMake] = useState('');
  const [vehiclePlate, setVehiclePlate] = useState('');
  const [pickupLocation, setPickupLocation] = useState('DPS Airport');
  const [pickupEta, setPickupEta] = useState('');
  const [notes, setNotes] = useState('');
  const [busy, setBusy] = useState(false);

  function getToken() {
    return localStorage.getItem(role === 'admin' ? 'tlb_admin_token' : 'tlb_host_token')
      || localStorage.getItem('tlb_token') || '';
  }

  // Fetch current state on open
  useEffect(() => {
    let cancelled = false;
    (async () => {
      const r = await fetch('/api/check-in?booking_id=' + booking.id, {
        headers: { Authorization: 'Bearer ' + getToken() },
      });
      if (cancelled) return;
      if (r.ok) setState(await r.json());
      setLoading(false);
    })();
    return () => { cancelled = true; };
  }, [booking.id]);

  async function assignDriver() {
    if (!driverName || !driverPhone) { window.alert('Driver name + phone required'); return; }
    setBusy(true);
    try {
      const r = await fetch('/api/check-in?action=assign', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + getToken() },
        body: JSON.stringify({
          booking_id: booking.id,
          driver_name: driverName,
          driver_phone: driverPhone,
          vehicle_make: vehicleMake,
          vehicle_plate: vehiclePlate,
          pickup_location: pickupLocation,
          pickup_eta: pickupEta || null,
          notes,
        }),
      });
      if (r.ok) {
        const j = await r.json();
        setState(prev => Object.assign({}, prev, { active_driver: j.driver }));
        onSuccess && onSuccess();
        window.alert('Driver assigned. Guest + driver notified by email + WhatsApp.');
      } else {
        const j = await r.json().catch(() => ({}));
        window.alert('Failed: ' + (j.error || r.statusText));
      }
    } finally { setBusy(false); }
  }

  async function updateDriverStatus(status) {
    if (!state || !state.active_driver) return;
    setBusy(true);
    try {
      const r = await fetch('/api/check-in?action=update_status', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + getToken() },
        body: JSON.stringify({ driver_id: state.active_driver.id, status }),
      });
      if (r.ok) {
        setState(prev => Object.assign({}, prev, {
          active_driver: Object.assign({}, prev.active_driver, { status }),
          booking: Object.assign({}, prev.booking, { check_in_status: status === 'arrived' ? 'arrived' : (status === 'en_route' ? 'en_route' : prev.booking.check_in_status) }),
        }));
        onSuccess && onSuccess();
      }
    } finally { setBusy(false); }
  }

  async function uploadProof(files, proofType) {
    if (!files || !files.length) return;
    setBusy(true);
    const token = getToken();
    try {
      for (const file of Array.from(files)) {
        const fd = new FormData();
        fd.append('file', file);
        fd.append('bucket', 'check-in-proofs');
        fd.append('kind', proofType);
        fd.append('booking_id', booking.id);
        const upR = await fetch('/api/upload-photo', {
          method: 'POST',
          headers: { Authorization: 'Bearer ' + token },
          body: fd,
        });
        if (!upR.ok) { window.alert('Upload failed'); break; }
        const upJ = await upR.json();
        // Save proof record
        await fetch('/api/check-in?action=upload_proof', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token },
          body: JSON.stringify({
            booking_id: booking.id,
            proof_type: proofType,
            photo_url: upJ.public_url,
          }),
        });
      }
      // Refetch state
      const r = await fetch('/api/check-in?booking_id=' + booking.id, { headers: { Authorization: 'Bearer ' + token } });
      if (r.ok) setState(await r.json());
      onSuccess && onSuccess();
    } finally { setBusy(false); }
  }

  async function verify() {
    setBusy(true);
    try {
      const r = await fetch('/api/check-in?action=verify', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + getToken() },
        body: JSON.stringify({ booking_id: booking.id }),
      });
      if (r.ok) {
        window.alert('Check-in verified. Guest is officially in-house.');
        onSuccess && onSuccess();
        onClose();
      }
    } finally { setBusy(false); }
  }

  async function markNoShow() {
    const reason = window.prompt('No-show reason:');
    if (reason === null) return;
    setBusy(true);
    try {
      const r = await fetch('/api/check-in?action=mark_no_show', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + getToken() },
        body: JSON.stringify({ booking_id: booking.id, reason }),
      });
      if (r.ok) {
        window.alert('No-show logged. Auto-escalation to admin created.');
        onSuccess && onSuccess();
        onClose();
      }
    } finally { setBusy(false); }
  }

  const driver = state?.active_driver;
  const proofs = state?.proofs || [];
  const checkInProofs = proofs.filter(p => p.proof_type === 'check_in');

  return (
    <div style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(20,20,20,.55)', backdropFilter: 'blur(6px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}>
      <Panel style={{ maxWidth: 720, width: '100%', maxHeight: '92vh', overflowY: 'auto' }}>
        <Display size={22} italic weight={400} style={{ marginBottom: 4 }}>Check-in.</Display>
        <Sans size={11} color={CC.mid} style={{ marginBottom: 16 }}>{booking.property_name} · {booking.guest_name} · {fmtDate(booking.check_in)} → {fmtDate(booking.check_out)}</Sans>

        <div style={{ display: 'flex', gap: 6, marginBottom: 18 }}>
          {[
            { v: 'driver', label: 'Driver' },
            { v: 'proof', label: 'Photo proof' },
            { v: 'verify', label: 'Verify check-in' },
          ].map(o => (
            <button key={o.v} onClick={() => setTab(o.v)}
              style={{ padding: '8px 16px', borderRadius: 100, fontSize: 12, fontWeight: 600,
                border: '1px solid ' + (tab === o.v ? CC.ink : CC.border),
                background: tab === o.v ? CC.ink : CC.white,
                color: tab === o.v ? CC.white : CC.deep, cursor: 'pointer' }}>{o.label}</button>
          ))}
        </div>

        {loading && <Sans size={12} color={CC.mid}>Loading…</Sans>}

        {!loading && tab === 'driver' && (
          <div>
            {driver && driver.status !== 'cancelled' ? (
              <div style={{ padding: 14, background: CC.offWhite, borderRadius: 8, marginBottom: 14 }}>
                <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
                  <Sans size={13} weight={600}>{driver.driver_name}</Sans>
                  <Pill tone={driver.status === 'arrived' ? 'success' : 'info'}>{driver.status}</Pill>
                </div>
                <Sans size={12} color={CC.mid}>Phone: {driver.driver_phone}</Sans>
                {driver.vehicle_make && <Sans size={12} color={CC.mid}>Vehicle: {driver.vehicle_make}{driver.vehicle_plate ? ' · ' + driver.vehicle_plate : ''}</Sans>}
                <Sans size={12} color={CC.mid}>Pickup: {driver.pickup_location}{driver.pickup_eta ? ' · ETA ' + new Date(driver.pickup_eta).toLocaleString('en-GB') : ''}</Sans>
                <div style={{ display: 'flex', gap: 6, marginTop: 12 }}>
                  {driver.status === 'assigned' && <button onClick={() => updateDriverStatus('en_route')} disabled={busy} style={{ padding: '7px 14px', background: CC.ink, color: CC.white, border: 'none', borderRadius: 100, fontSize: 11, fontWeight: 600, cursor: 'pointer' }}>En route</button>}
                  {(driver.status === 'assigned' || driver.status === 'en_route') && <button onClick={() => updateDriverStatus('arrived')} disabled={busy} style={{ padding: '7px 14px', background: '#1f6b3a', color: CC.white, border: 'none', borderRadius: 100, fontSize: 11, fontWeight: 600, cursor: 'pointer' }}>Arrived</button>}
                </div>
              </div>
            ) : (
              <div style={{ display: 'grid', gap: 10 }}>
                <Sans size={11} color={CC.mid}>Assign a driver — guest gets email + WhatsApp with driver contact + ETA.</Sans>
                <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
                  <input type="text" value={driverName} onChange={e => setDriverName(e.target.value)} placeholder="Driver name *"
                    style={{ padding: '10px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 13, fontFamily: 'inherit' }} />
                  <input type="tel" value={driverPhone} onChange={e => setDriverPhone(e.target.value)} placeholder="Driver phone (+62…) *"
                    style={{ padding: '10px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 13, fontFamily: 'inherit' }} />
                  <input type="text" value={vehicleMake} onChange={e => setVehicleMake(e.target.value)} placeholder="Vehicle (e.g. Avanza Putih)"
                    style={{ padding: '10px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 13, fontFamily: 'inherit' }} />
                  <input type="text" value={vehiclePlate} onChange={e => setVehiclePlate(e.target.value)} placeholder="Plate (e.g. DK 1234 AB)"
                    style={{ padding: '10px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 13, fontFamily: 'inherit' }} />
                  <input type="text" value={pickupLocation} onChange={e => setPickupLocation(e.target.value)}
                    style={{ padding: '10px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 13, fontFamily: 'inherit' }} />
                  <input type="datetime-local" value={pickupEta} onChange={e => setPickupEta(e.target.value)}
                    style={{ padding: '10px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 13, fontFamily: 'inherit' }} />
                </div>
                <textarea value={notes} onChange={e => setNotes(e.target.value)} rows={2} placeholder="Optional notes for driver"
                  style={{ padding: '10px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 12, fontFamily: 'inherit', resize: 'vertical' }} />
                <button onClick={assignDriver} disabled={busy}
                  style={{ padding: '12px', background: CC.ink, color: CC.white, border: 'none', borderRadius: 100, fontSize: 12, fontWeight: 700, letterSpacing: '0.06em', cursor: 'pointer' }}>
                  {busy ? 'Assigning…' : 'Assign driver + notify guest'}
                </button>
              </div>
            )}
          </div>
        )}

        {!loading && tab === 'proof' && (
          <div>
            <Sans size={11} color={CC.mid} style={{ marginBottom: 10 }}>Upload check-in photos (key handover, property condition). Stored in Supabase Storage.</Sans>
            <input type="file" accept="image/*" multiple disabled={busy}
              onChange={e => uploadProof(e.target.files, 'check_in')}
              style={{ width: '100%', padding: 8, border: '1px dashed ' + CC.border, borderRadius: 8, fontSize: 12, marginBottom: 12 }} />
            {checkInProofs.length > 0 && (
              <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 12 }}>
                {checkInProofs.map((p, i) => (
                  <a key={i} href={p.photo_url} target="_blank" rel="noopener">
                    <img src={p.photo_url} style={{ width: 96, height: 96, objectFit: 'cover', borderRadius: 6, border: '1px solid ' + CC.border }} alt="proof" />
                  </a>
                ))}
              </div>
            )}
            <Sans size={10} color={CC.mid}>Tip: take 3-4 photos — exterior condition, key handover, welcome book.</Sans>
          </div>
        )}

        {!loading && tab === 'verify' && (
          <div>
            <Sans size={12} color={CC.mid} style={{ marginBottom: 14, lineHeight: 1.6 }}>Once the guest is settled in and you've uploaded check-in photos, mark the booking as verified. This stops auto-escalation alerts and starts the in-stay clock.</Sans>
            <div style={{ padding: 14, background: CC.offWhite, borderRadius: 8, marginBottom: 14 }}>
              <Sans size={11} color={CC.mid} style={{ marginBottom: 6 }}>Current status</Sans>
              <Pill tone={state?.booking?.check_in_status === 'verified' ? 'success' : 'warn'}>{state?.booking?.check_in_status || 'not_started'}</Pill>
              {checkInProofs.length === 0 && <Sans size={11} color="#9a1c1c" style={{ marginTop: 8 }}>⚠ No check-in proof photos uploaded — strongly recommended before verifying.</Sans>}
            </div>
            <div style={{ display: 'flex', gap: 8 }}>
              <button onClick={verify} disabled={busy || state?.booking?.check_in_status === 'verified'}
                style={{ flex: 1, padding: '12px', background: '#1f6b3a', color: CC.white, border: 'none', borderRadius: 100, fontSize: 12, fontWeight: 700, letterSpacing: '0.06em', cursor: 'pointer', opacity: (busy || state?.booking?.check_in_status === 'verified') ? 0.5 : 1 }}>
                {busy ? 'Verifying…' : '✓ Verify check-in'}
              </button>
              <button onClick={markNoShow} disabled={busy}
                style={{ padding: '12px 18px', background: CC.white, color: '#9a1c1c', border: '1px solid #d6b3b3', borderRadius: 100, fontSize: 12, fontWeight: 600, cursor: 'pointer' }}>
                Mark no-show
              </button>
            </div>
          </div>
        )}

        <div style={{ borderTop: '1px solid ' + CC.border, marginTop: 18, paddingTop: 12 }}>
          <button onClick={onClose}
            style={{ width: '100%', padding: '10px', background: CC.white, color: CC.mid, border: '1px solid ' + CC.border, borderRadius: 100, fontSize: 11, fontWeight: 600, cursor: 'pointer' }}>Close</button>
        </div>
      </Panel>
    </div>
  );
}

// ── Host · Welcome packets (v126 — pre-arrival builder) ────────────────
function HostWelcomePackets() {
  const propsApi = useApi('/api/properties?status=all', { tokenType: 'host' });
  const properties = (propsApi.data && propsApi.data.properties) || [];
  const [activePropId, setActivePropId] = useState(null);
  const [packet, setPacket] = useState(null);
  const [loading, setLoading] = useState(false);
  const [saving, setSaving] = useState(false);
  const [savedAt, setSavedAt] = useState(null);

  // Auto-select first property
  useEffect(() => {
    if (!activePropId && properties.length) setActivePropId(properties[0].id);
  }, [properties.length]);

  // Load packet whenever property changes
  useEffect(() => {
    if (!activePropId) return;
    let cancelled = false;
    setLoading(true);
    const token = localStorage.getItem('tlb_host_token') || localStorage.getItem('tlb_token') || '';
    fetch('/api/welcome-packet?property_id=' + activePropId, {
      headers: { Authorization: 'Bearer ' + token },
    })
      .then(r => r.ok ? r.json() : null)
      .then(j => {
        if (cancelled) return;
        const blank = {
          wifi: { ssid: '', password: '', notes: '' },
          house_rules: [],
          area_tips: [],
          emergency_contact: { name: '', phone: '', whatsapp: '', role: 'Property manager' },
          check_in_instructions: '',
          check_out_instructions: '',
          custom_html: '',
        };
        setPacket(j && j.packet ? Object.assign(blank, j.packet) : blank);
      })
      .finally(() => !cancelled && setLoading(false));
    return () => { cancelled = true; };
  }, [activePropId]);

  function update(path, value) {
    setPacket(prev => {
      const next = Object.assign({}, prev);
      const parts = path.split('.');
      let cur = next;
      for (let i = 0; i < parts.length - 1; i++) {
        cur[parts[i]] = Object.assign({}, cur[parts[i]] || {});
        cur = cur[parts[i]];
      }
      cur[parts[parts.length - 1]] = value;
      return next;
    });
  }
  function addRule() { setPacket(prev => ({ ...prev, house_rules: [...(prev.house_rules || []), ''] })); }
  function setRule(i, v) { setPacket(prev => { const arr = (prev.house_rules || []).slice(); arr[i] = v; return { ...prev, house_rules: arr }; }); }
  function delRule(i) { setPacket(prev => ({ ...prev, house_rules: (prev.house_rules || []).filter((_, j) => j !== i) })); }
  function addTip() { setPacket(prev => ({ ...prev, area_tips: [...(prev.area_tips || []), { title: '', description: '', category: 'general' }] })); }
  function setTip(i, k, v) { setPacket(prev => { const arr = (prev.area_tips || []).slice(); arr[i] = { ...arr[i], [k]: v }; return { ...prev, area_tips: arr }; }); }
  function delTip(i) { setPacket(prev => ({ ...prev, area_tips: (prev.area_tips || []).filter((_, j) => j !== i) })); }

  async function save() {
    if (!activePropId || !packet) return;
    setSaving(true);
    const token = localStorage.getItem('tlb_host_token') || localStorage.getItem('tlb_token') || '';
    try {
      const r = await fetch('/api/welcome-packet', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token },
        body: JSON.stringify({ property_id: activePropId, packet }),
      });
      if (r.ok) setSavedAt(new Date());
      else { const j = await r.json().catch(() => ({})); window.alert('Save failed: ' + (j.error || r.statusText)); }
    } finally { setSaving(false); }
  }

  if (propsApi.loading) return <SectionWrap title="Welcome packets"><Loading msg="Loading properties…" /></SectionWrap>;
  if (propsApi.error) return <SectionWrap title="Welcome packets"><ErrorState msg={propsApi.error} onRetry={propsApi.refetch} /></SectionWrap>;

  // v141.130 — Polish header: subtitle + Save action button + saved indicator
  const activePropName = properties.find(p => p.id === activePropId)?.name;

  return (
    <SectionWrap
      title="Welcome packets"
      subtitle="The note that lands in your guests' inbox 7 days before check-in. Wi-Fi, house rules, a chef recommendation, and an emergency contact — kept short, kept warm."
      actions={
        <div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
          {savedAt && <Sans size={11} color="#2d6a4f" style={{ alignSelf: 'center' }}>● Saved {savedAt.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}</Sans>}
          <button onClick={save} disabled={saving || !packet || !activePropId}
            style={{ padding: '10px 18px', borderRadius: 100, cursor: saving ? 'wait' : 'pointer', background: CC.ink, color: CC.white, border: '1px solid ' + CC.ink, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600, opacity: saving || !packet || !activePropId ? 0.5 : 1 }}>
            {saving ? 'Saving…' : `Save ${activePropName ? '· ' + activePropName : 'packet'}`}
          </button>
        </div>
      }
    >
      {properties.length === 0 ? (
        <Empty title="No properties" msg="Add a property first; you can then build a welcome packet for each villa." />
      ) : (
        <div style={{ display: 'grid', gridTemplateColumns: '260px 1fr', gap: dashGap() }}>
          {/* Property list */}
          <Panel pad={0}>
            <div style={{ padding: '14px 18px', borderBottom: '1px solid ' + CC.border }}>
              <Sans size={11} weight={600} ls={0.14} upper color={CC.mid}>{properties.length} {properties.length === 1 ? 'villa' : 'villas'}</Sans>
            </div>
            {properties.map(p => (
              <button key={p.id} onClick={() => setActivePropId(p.id)}
                style={{
                  display: 'block', width: '100%', textAlign: 'left',
                  padding: '12px 18px', border: 'none',
                  borderBottom: '1px solid ' + CC.border,
                  background: activePropId === p.id ? CC.offWhite : 'transparent',
                  borderLeft: '3px solid ' + (activePropId === p.id ? CC.ink : 'transparent'),
                  cursor: 'pointer', fontFamily: 'inherit',
                }}>
                <Sans size={13} weight={activePropId === p.id ? 600 : 500} color={CC.ink}>{p.name}</Sans>
                <Sans size={10} color={CC.mid} style={{ marginTop: 2 }}>{(p.location || 'Bali').split(',')[0].trim()}</Sans>
                {p.welcome_packet && <Sans size={9} color="#1f6b3a" style={{ marginTop: 4, fontWeight: 600 }}>● PACKET CONFIGURED</Sans>}
              </button>
            ))}
          </Panel>

          {/* Editor */}
          {!packet ? (
            <Panel><Loading msg="Loading packet…" /></Panel>
          ) : (
            <Panel>
              {/* Wi-Fi */}
              <div style={{ marginBottom: 24 }}>
                <Sans size={10} weight={700} ls={0.16} upper color={CC.mid} style={{ marginBottom: 10 }}>Wi-Fi</Sans>
                <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
                  <input type="text" value={packet.wifi.ssid} onChange={e => update('wifi.ssid', e.target.value)} placeholder="Network name (SSID)"
                    style={{ padding: '10px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 13, fontFamily: 'inherit' }} />
                  <input type="text" value={packet.wifi.password} onChange={e => update('wifi.password', e.target.value)} placeholder="Password"
                    style={{ padding: '10px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 13, fontFamily: 'inherit' }} />
                </div>
                <input type="text" value={packet.wifi.notes} onChange={e => update('wifi.notes', e.target.value)} placeholder="Optional notes (router location, mesh setup, etc.)"
                  style={{ width: '100%', marginTop: 8, padding: '10px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 12, fontFamily: 'inherit' }} />
              </div>

              {/* Check-in instructions */}
              <div style={{ marginBottom: 24 }}>
                <Sans size={10} weight={700} ls={0.16} upper color={CC.mid} style={{ marginBottom: 10 }}>Check-in instructions</Sans>
                <textarea value={packet.check_in_instructions} onChange={e => update('check_in_instructions', e.target.value)} rows={4}
                  placeholder="Address, gate code, key location, parking, where to find the welcome book, etc."
                  style={{ width: '100%', padding: '10px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 13, fontFamily: 'inherit', resize: 'vertical' }} />
              </div>

              {/* House rules */}
              <div style={{ marginBottom: 24 }}>
                <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
                  <Sans size={10} weight={700} ls={0.16} upper color={CC.mid}>House rules</Sans>
                  <button onClick={addRule} style={{ padding: '5px 12px', background: CC.white, color: CC.ink, border: '1px solid ' + CC.border, borderRadius: 100, fontSize: 11, fontWeight: 600, cursor: 'pointer' }}>+ Add</button>
                </div>
                {(packet.house_rules || []).length === 0 && <Sans size={11} color={CC.mid} style={{ fontStyle: 'italic' }}>No house rules yet. Add things like quiet hours, smoking policy, pool safety, etc.</Sans>}
                {(packet.house_rules || []).map((r, i) => (
                  <div key={i} style={{ display: 'flex', gap: 8, marginBottom: 6 }}>
                    <input type="text" value={r} onChange={e => setRule(i, e.target.value)}
                      style={{ flex: 1, padding: '8px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 13, fontFamily: 'inherit' }} />
                    <button onClick={() => delRule(i)} style={{ padding: '0 12px', background: CC.white, color: '#9a1c1c', border: '1px solid #d6b3b3', borderRadius: 8, cursor: 'pointer', fontSize: 14 }}>×</button>
                  </div>
                ))}
              </div>

              {/* Area tips */}
              <div style={{ marginBottom: 24 }}>
                <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
                  <Sans size={10} weight={700} ls={0.16} upper color={CC.mid}>Around the villa</Sans>
                  <button onClick={addTip} style={{ padding: '5px 12px', background: CC.white, color: CC.ink, border: '1px solid ' + CC.border, borderRadius: 100, fontSize: 11, fontWeight: 600, cursor: 'pointer' }}>+ Add tip</button>
                </div>
                {(packet.area_tips || []).length === 0 && <Sans size={11} color={CC.mid} style={{ fontStyle: 'italic' }}>Add restaurant recs, beach access, supermarket directions, etc.</Sans>}
                {(packet.area_tips || []).map((t, i) => (
                  <div key={i} style={{ marginBottom: 10, padding: 12, border: '1px solid ' + CC.border, borderRadius: 8 }}>
                    <div style={{ display: 'flex', gap: 8 }}>
                      <input type="text" value={t.title} onChange={e => setTip(i, 'title', e.target.value)} placeholder="Title (e.g. Best dinner spot)"
                        style={{ flex: 1, padding: '8px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 13, fontFamily: 'inherit', marginBottom: 6 }} />
                      <button onClick={() => delTip(i)} style={{ padding: '0 12px', background: CC.white, color: '#9a1c1c', border: '1px solid #d6b3b3', borderRadius: 8, cursor: 'pointer', fontSize: 14 }}>×</button>
                    </div>
                    <textarea value={t.description} onChange={e => setTip(i, 'description', e.target.value)} rows={2} placeholder="Description, address, why it's special"
                      style={{ width: '100%', padding: '8px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 12, fontFamily: 'inherit', resize: 'vertical' }} />
                  </div>
                ))}
              </div>

              {/* Emergency contact */}
              <div style={{ marginBottom: 24 }}>
                <Sans size={10} weight={700} ls={0.16} upper color={CC.mid} style={{ marginBottom: 10 }}>Emergency contact</Sans>
                <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
                  <input type="text" value={packet.emergency_contact.name} onChange={e => update('emergency_contact.name', e.target.value)} placeholder="Name"
                    style={{ padding: '10px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 13, fontFamily: 'inherit' }} />
                  <input type="text" value={packet.emergency_contact.role} onChange={e => update('emergency_contact.role', e.target.value)} placeholder="Role"
                    style={{ padding: '10px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 13, fontFamily: 'inherit' }} />
                  <input type="tel" value={packet.emergency_contact.phone} onChange={e => update('emergency_contact.phone', e.target.value)} placeholder="Phone"
                    style={{ padding: '10px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 13, fontFamily: 'inherit' }} />
                  <input type="tel" value={packet.emergency_contact.whatsapp} onChange={e => update('emergency_contact.whatsapp', e.target.value)} placeholder="WhatsApp"
                    style={{ padding: '10px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 13, fontFamily: 'inherit' }} />
                </div>
              </div>

              {/* Check-out instructions */}
              <div style={{ marginBottom: 24 }}>
                <Sans size={10} weight={700} ls={0.16} upper color={CC.mid} style={{ marginBottom: 10 }}>Check-out instructions (optional)</Sans>
                <textarea value={packet.check_out_instructions} onChange={e => update('check_out_instructions', e.target.value)} rows={3}
                  placeholder="What time, where to leave keys, anything to clean, etc."
                  style={{ width: '100%', padding: '10px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 13, fontFamily: 'inherit', resize: 'vertical' }} />
              </div>

              <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', borderTop: '1px solid ' + CC.border, paddingTop: 16 }}>
                <button onClick={save} disabled={saving}
                  style={{ padding: '10px 24px', background: CC.ink, color: CC.white, border: 'none', borderRadius: 100, fontSize: 12, fontWeight: 700, letterSpacing: '0.06em', cursor: saving ? 'not-allowed' : 'pointer', opacity: saving ? 0.5 : 1 }}>
                  {saving ? 'Saving…' : 'Save welcome packet'}
                </button>
              </div>
            </Panel>
          )}
        </div>
      )}
    </SectionWrap>
  );
}

// ── Host · Concierge requests (v123 — wired to /api/concierge-request) ──
function HostConcierge() {
  const [filter, setFilter] = useState('open');
  const { data, loading, error, refetch } = useApi('/api/concierge-request?status=' + filter, { tokenType: 'host' });
  if (loading) return <SectionWrap title="Concierge requests"><Loading msg="Loading guest requests…" /></SectionWrap>;
  if (error) return <SectionWrap title="Concierge requests"><ErrorState msg={error} onRetry={refetch} /></SectionWrap>;

  const requests = (data && data.requests) || [];

  async function callAction(id, action, body) {
    const token = localStorage.getItem('tlb_host_token') || localStorage.getItem('tlb_token') || '';
    const res = await fetch('/api/concierge-request?action=' + action, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
      body: JSON.stringify({ id, ...(body || {}) }),
    });
    if (res.ok) { refetch(); return true; }
    const err = await res.json().catch(() => ({}));
    window.alert('Failed: ' + (err.error || res.statusText));
    return false;
  }

  function priorityTone(p) {
    if (p === 'urgent') return 'danger';
    if (p === 'high') return 'warn';
    if (p === 'low') return 'neutral';
    return 'info';
  }
  function slaTone(s) {
    if (s === 'resolve_overdue' || s === 'ack_overdue') return 'danger';
    if (s === 'ack_at_risk') return 'warn';
    if (s === 'closed') return 'neutral';
    return 'success';
  }

  return (
    <SectionWrap title="Concierge requests" subtitle={`${requests.length} ${filter} · SLA-tracked`}
      actions={
        <div style={{ display: 'flex', gap: 6 }}>
          {['open', 'closed', 'all'].map(f => (
            <button key={f} onClick={() => setFilter(f)}
              style={{ padding: '7px 14px', borderRadius: 100, fontSize: 11, fontWeight: 600,
                border: '1px solid ' + (filter === f ? CC.ink : CC.border),
                background: filter === f ? CC.ink : CC.white,
                color: filter === f ? CC.white : CC.deep, cursor: 'pointer', letterSpacing: '0.04em' }}>{f}</button>
          ))}
        </div>
      }>
      {requests.length === 0 ? (
        <Empty title={'No ' + filter + ' requests'} msg="Guest requests for chef, transport, experiences, and arrangements appear here with SLA timer." />
      ) : (
        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: dashGap() }}>
          {requests.slice(0, 12).map((r) => (
            <Panel key={r.id} style={{ borderLeft: '3px solid ' + (r.priority === 'urgent' ? '#9a1c1c' : r.priority === 'high' ? '#d97706' : CC.border) }}>
              <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8, marginBottom: 8 }}>
                <div>
                  <Sans size={13} weight={500} color={CC.ink}>{r.guest_name || 'Guest'}</Sans>
                  <Sans size={10} color={CC.mid} style={{ marginTop: 2 }}>{r.property_name || ''} · {fmtDate(r.created_at)}</Sans>
                </div>
                <div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
                  <Pill tone={priorityTone(r.priority)}>{r.priority}</Pill>
                  <Pill tone={statusTone(r.status)}>{r.status}</Pill>
                </div>
              </div>
              <Sans size={11} weight={600} ls={0.12} upper color={CC.mid} style={{ marginBottom: 6 }}>{r.category}</Sans>
              <Sans size={14} weight={500} color={CC.ink} style={{ marginBottom: 6 }}>{r.subject}</Sans>
              {r.description && <Sans size={12} color={CC.deep} style={{ lineHeight: 1.55, marginBottom: 10 }}>{r.description}</Sans>}
              <div style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 10 }}>
                <Pill tone={slaTone(r.sla_status)}>{r.sla_status || 'on track'} · {r.age_minutes}min</Pill>
              </div>
              <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
                {r.status === 'new' && (
                  <button onClick={() => callAction(r.id, 'acknowledge')}
                    style={{ padding: '6px 14px', background: CC.ink, color: CC.white, border: 'none', borderRadius: 100, fontSize: 11, fontWeight: 600, cursor: 'pointer' }}>Acknowledge</button>
                )}
                {(r.status === 'new' || r.status === 'acknowledged') && (
                  <button onClick={() => callAction(r.id, 'update_status', { status: 'in_progress' })}
                    style={{ padding: '6px 14px', background: CC.white, color: CC.ink, border: '1px solid ' + CC.border, borderRadius: 100, fontSize: 11, fontWeight: 600, cursor: 'pointer' }}>Start work</button>
                )}
                {(r.status === 'acknowledged' || r.status === 'in_progress') && (
                  <button onClick={() => {
                    const note = window.prompt('Resolution note (sent to guest):');
                    if (note !== null) callAction(r.id, 'update_status', { status: 'resolved', resolution_note: note });
                  }}
                    style={{ padding: '6px 14px', background: '#1f6b3a', color: CC.white, border: 'none', borderRadius: 100, fontSize: 11, fontWeight: 600, cursor: 'pointer' }}>Resolve</button>
                )}
                {(r.status === 'new' || r.status === 'acknowledged') && (
                  <button onClick={() => {
                    const note = window.prompt('Decline reason (sent to guest):');
                    if (note !== null) callAction(r.id, 'update_status', { status: 'declined', resolution_note: note });
                  }}
                    style={{ padding: '6px 14px', background: CC.white, color: '#9a1c1c', border: '1px solid #d6b3b3', borderRadius: 100, fontSize: 11, fontWeight: 600, cursor: 'pointer' }}>Decline</button>
                )}
              </div>
            </Panel>
          ))}
        </div>
      )}
    </SectionWrap>
  );
}

// ── Host · SEO ───────────────────────────────────────────────────────────
// v129 — AdminSEO: comprehensive technical SEO control panel.
// Moved from Host (host shouldn't control sitewide SEO). Admin-only.
//
// 4 panels:
//   1. Sitewide health: sitemap status, robots.txt, schema audit
//   2. Per-villa scoring (was HostSEO core)
//   3. Meta tag overrides per villa (DB column properties.seo_overrides)
//   4. Domain migration tools (301 redirects, canonical update)
function AdminSEO() {
  const propsApi = useApi('/api/properties?status=all', { tokenType: 'admin' });
  const [tab, setTab] = useState('villas');
  const [editingVilla, setEditingVilla] = useState(null);
  const [refreshKey, setRefreshKey] = useState(0);

  const villas = (propsApi.data && propsApi.data.properties) || [];

  function scoreOf(v) {
    let s = 0;
    const issues = [];
    // Title-quality: name + location + bedroom present
    if (v.name && v.name.length >= 8) s += 15; else issues.push('Name too generic');
    if (v.location) s += 10; else issues.push('Location missing');
    if (v.bedrooms && v.bathrooms) s += 10; else issues.push('Bedrooms/bathrooms missing');
    // Content depth
    if (v.description && v.description.length >= 400) s += 25;
    else if (v.description && v.description.length >= 200) { s += 12; issues.push('Description thin (<400 chars)'); }
    else issues.push('Description very short or missing');
    // Photos
    const photoCount = (() => {
      try { return Array.isArray(v.photos) ? v.photos.length : (typeof v.photos === 'string' ? JSON.parse(v.photos).length : 0); } catch { return 0; }
    })();
    if (photoCount >= 8) s += 15;
    else if (photoCount >= 4) { s += 8; issues.push('Photos thin (<8)'); }
    else { issues.push('Photos missing or <4'); }
    // Slug
    if (v.url_slug && /^[a-z0-9-]+$/.test(v.url_slug)) s += 15;
    else issues.push('SEO slug missing or invalid');
    // Reviews aggregation (drives JSON-LD AggregateRating)
    if (v.review_count > 0) s += 10;
    else issues.push('No reviews yet — AggregateRating empty');
    // Override (manual SEO copy)
    if (v.seo_overrides && (v.seo_overrides.title || v.seo_overrides.description)) s += 0; // bonus visible separately
    return { score: Math.min(100, s), issues };
  }

  // Sitewide aggregates
  const totalVillas = villas.length;
  const liveVillas = villas.filter(v => v.status === 'live').length;
  const avgScore = totalVillas > 0
    ? Math.round(villas.reduce((s, v) => s + scoreOf(v).score, 0) / totalVillas)
    : 0;
  const lowScoreCount = villas.filter(v => scoreOf(v).score < 60).length;
  const reviewsCount = villas.reduce((s, v) => s + (v.review_count || 0), 0);

  return (
    <SectionWrap title="SEO control" subtitle="Sitewide technical SEO · per-villa scoring · meta overrides · migration tools">
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: dashGap(), marginBottom: dashGap() }}>
        <Panel style={{ padding: '14px 16px' }}>
          <Sans size={10} weight={700} ls={0.18} upper color={CC.mid}>Avg SEO Score</Sans>
          <Display size={28} weight={400} style={{ marginTop: 8, color: avgScore >= 80 ? '#1f6b3a' : avgScore >= 60 ? '#8a6324' : '#9a1c1c' }}>{avgScore} / 100</Display>
          <Sans size={11} color={CC.mid} style={{ marginTop: 4 }}>across {totalVillas} villas</Sans>
        </Panel>
        <Panel style={{ padding: '14px 16px', borderLeft: lowScoreCount > 0 ? '3px solid #9a1c1c' : '3px solid ' + CC.border }}>
          <Sans size={10} weight={700} ls={0.18} upper color={CC.mid}>Need Attention</Sans>
          <Display size={28} weight={400} style={{ marginTop: 8, color: lowScoreCount > 0 ? '#9a1c1c' : CC.ink }}>{lowScoreCount}</Display>
          <Sans size={11} color={CC.mid} style={{ marginTop: 4 }}>villas score &lt; 60</Sans>
        </Panel>
        <Panel style={{ padding: '14px 16px' }}>
          <Sans size={10} weight={700} ls={0.18} upper color={CC.mid}>Live Listings</Sans>
          <Display size={28} weight={400} style={{ marginTop: 8 }}>{liveVillas}</Display>
          <Sans size={11} color={CC.mid} style={{ marginTop: 4 }}>indexable in sitemap</Sans>
        </Panel>
        <Panel style={{ padding: '14px 16px' }}>
          <Sans size={10} weight={700} ls={0.18} upper color={CC.mid}>Reviews Total</Sans>
          <Display size={28} weight={400} style={{ marginTop: 8 }}>{reviewsCount}</Display>
          <Sans size={11} color={CC.mid} style={{ marginTop: 4 }}>powers AggregateRating</Sans>
        </Panel>
      </div>

      <div style={{ display: 'flex', gap: 6, marginBottom: dashGap() }}>
        {[
          { v: 'villas', label: 'Per-villa scoring' },
          { v: 'sitewide', label: 'Sitewide health' },
          { v: 'migration', label: 'Domain migration' },
        ].map(o => (
          <button key={o.v} onClick={() => setTab(o.v)}
            style={{ padding: '8px 16px', borderRadius: 100, fontSize: 12, fontWeight: 600,
              border: '1px solid ' + (tab === o.v ? CC.ink : CC.border),
              background: tab === o.v ? CC.ink : CC.white,
              color: tab === o.v ? CC.white : CC.deep, cursor: 'pointer' }}>{o.label}</button>
        ))}
      </div>

      {tab === 'villas' && (
        <Panel pad={0}>
          {propsApi.loading ? (
            <div style={{ padding: 40 }}><Loading msg="Scoring listings…" /></div>
          ) : villas.length === 0 ? (
            <div style={{ padding: 40 }}><Empty title="No villas" msg="Add villas to see SEO scoring." /></div>
          ) : (
            <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
              <thead>
                <tr style={{ background: CC.offWhite }}>
                  <th style={{ padding: '14px 20px', textAlign: 'left', fontFamily: F_TLB.ui, fontSize: 10, fontWeight: 600, letterSpacing: '0.14em', textTransform: 'uppercase', color: CC.mid }}>Villa</th>
                  <th style={{ padding: '14px 20px', textAlign: 'left', fontFamily: F_TLB.ui, fontSize: 10, fontWeight: 600, letterSpacing: '0.14em', textTransform: 'uppercase', color: CC.mid }}>Issues</th>
                  <th style={{ padding: '14px 20px', textAlign: 'left', fontFamily: F_TLB.ui, fontSize: 10, fontWeight: 600, letterSpacing: '0.14em', textTransform: 'uppercase', color: CC.mid }}>Override</th>
                  <th style={{ padding: '14px 20px', textAlign: 'right', fontFamily: F_TLB.ui, fontSize: 10, fontWeight: 600, letterSpacing: '0.14em', textTransform: 'uppercase', color: CC.mid }}>Score</th>
                  <th></th>
                </tr>
              </thead>
              <tbody>
                {villas.sort((a, b) => scoreOf(a).score - scoreOf(b).score).map((v) => {
                  const r = scoreOf(v);
                  const hasOverride = v.seo_overrides && (v.seo_overrides.title || v.seo_overrides.description);
                  return (
                    <tr key={v.id} style={{ borderTop: `1px solid ${CC.border}` }}>
                      <td style={{ padding: '14px 20px' }}>
                        <strong style={{ color: CC.ink, fontWeight: 500 }}>{v.name || 'Villa'}</strong>
                        <Sans size={11} color={CC.mid} style={{ marginTop: 4 }}>{v.location || 'Bali'} · {v.url_slug || '—'}</Sans>
                      </td>
                      <td style={{ padding: '14px 20px' }}>
                        {r.issues.length === 0 ? <Pill tone="success">Healthy</Pill>
                          : <Sans size={11} color={CC.mid} style={{ lineHeight: 1.55 }}>{r.issues.join(' · ')}</Sans>}
                      </td>
                      <td style={{ padding: '14px 20px' }}>
                        {hasOverride ? <Pill tone="info">✓ Custom</Pill> : <Sans size={11} color={CC.mid}>—</Sans>}
                      </td>
                      <td style={{ padding: '14px 20px', textAlign: 'right' }}>
                        <span style={{ fontFamily: F_TLB.ui, fontSize: 22, fontWeight: 600, fontVariantNumeric: 'tabular-nums', color: r.score >= 80 ? '#1f6b3a' : r.score >= 60 ? '#8a6324' : '#9a1c1c' }}>{r.score}</span>
                        <Sans size={10} color={CC.mid}>/ 100</Sans>
                      </td>
                      <td style={{ padding: '14px 20px' }}>
                        <button onClick={() => setEditingVilla(v)}
                          style={{ padding: '6px 12px', background: CC.white, color: CC.ink, border: '1px solid ' + CC.border, borderRadius: 100, fontSize: 11, fontWeight: 600, cursor: 'pointer' }}>Edit SEO</button>
                      </td>
                    </tr>
                  );
                })}
              </tbody>
            </table>
          )}
        </Panel>
      )}

      {tab === 'sitewide' && <SitewideHealthPanel refreshKey={refreshKey} onRefresh={() => setRefreshKey(k => k + 1)} />}
      {tab === 'migration' && <DomainMigrationPanel />}

      {editingVilla && <SEOOverrideModal villa={editingVilla} onClose={() => setEditingVilla(null)} onSuccess={() => { propsApi.refetch(); setEditingVilla(null); }} />}
    </SectionWrap>
  );
}

// Sitewide health panel — sitemap status, robots.txt content, schema audit
function SitewideHealthPanel({ refreshKey, onRefresh }) {
  const [sitemap, setSitemap] = useState(null);
  const [robots, setRobots] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    let cancelled = false;
    Promise.all([
      fetch('/sitemap.xml').then(r => r.text()).catch(() => null),
      fetch('/robots.txt').then(r => r.text()).catch(() => null),
    ]).then(([sm, rb]) => {
      if (cancelled) return;
      setSitemap(sm);
      setRobots(rb);
      setLoading(false);
    });
    return () => { cancelled = true; };
  }, [refreshKey]);

  const sitemapUrls = sitemap ? (sitemap.match(/<loc>/g) || []).length : 0;
  const sitemapVilla = sitemap ? (sitemap.match(/\/villa\//g) || []).length : 0;
  const sitemapDate = sitemap ? (sitemap.match(/<lastmod>([^<]+)<\/lastmod>/) || [, '—'])[1] : '—';

  return (
    <div style={{ display: 'grid', gap: dashGap() }}>
      <Panel>
        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
          <Display size={18} italic weight={400}>Sitemap.</Display>
          <a href="/sitemap.xml" target="_blank" rel="noopener" style={{ ...ghostBtn, textDecoration: 'none' }}>View raw ↗</a>
        </div>
        {loading ? <Loading msg="Fetching…" /> : (
          <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12 }}>
            <KPI label="Total URLs" value={sitemapUrls} />
            <KPI label="Villa pages" value={sitemapVilla} />
            <KPI label="Last generated" value={fmtDate(sitemapDate)} />
          </div>
        )}
        <Sans size={11} color={CC.mid} style={{ marginTop: 14, lineHeight: 1.6 }}>
          Sitemap is auto-generated by /api/sitemap from properties + offers tables. Triggered on every property update. Submit URL to Google Search Console: <code style={{ background: CC.offWhite, padding: '2px 8px' }}>https://www.theluxurybali.com/sitemap.xml</code>
        </Sans>
      </Panel>

      <Panel>
        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
          <Display size={18} italic weight={400}>robots.txt.</Display>
          <a href="/robots.txt" target="_blank" rel="noopener" style={{ ...ghostBtn, textDecoration: 'none' }}>View raw ↗</a>
        </div>
        {loading ? <Loading msg="Fetching…" /> : (
          <pre style={{ background: CC.offWhite, padding: 14, borderRadius: 8, fontSize: 11, fontFamily: 'monospace', maxHeight: 240, overflow: 'auto', whiteSpace: 'pre-wrap' }}>{robots || '(unable to fetch)'}</pre>
        )}
      </Panel>

      <Panel>
        <Display size={18} italic weight={400} style={{ marginBottom: 14 }}>Schema audit.</Display>
        <div style={{ display: 'grid', gap: 8 }}>
          {[
            { name: 'Organization (homepage)', status: 'ok', note: 'TravelAgency schema present' },
            { name: 'WebSite SearchAction', status: 'fixed', note: 'Added v129 — enables search box in SERPs' },
            { name: 'LodgingBusiness (per villa)', status: 'ok', note: 'Per-villa schema with amenities + price range' },
            { name: 'AggregateRating', status: 'fixed', note: 'Now hydrates from real review_count + avg_rating' },
            { name: 'BreadcrumbList', status: 'ok', note: 'Present on all section pages' },
            { name: 'BlogPosting', status: 'partial', note: 'Discover articles have it; verify per-post' },
            { name: 'FAQPage (contact)', status: 'ok', note: '6 Q&A pairs structured' },
            { name: 'hreflang en/id/zh/ja', status: 'fixed', note: 'Added sitewide v129 (was: only search.html)' },
          ].map((s, i) => (
            <div key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: '8px 0', borderBottom: '1px solid ' + CC.border }}>
              <div>
                <Sans size={12} weight={500} color={CC.ink}>{s.name}</Sans>
                <Sans size={10} color={CC.mid}>{s.note}</Sans>
              </div>
              <Pill tone={s.status === 'ok' ? 'success' : s.status === 'fixed' ? 'info' : 'warn'}>{s.status}</Pill>
            </div>
          ))}
        </div>
      </Panel>
    </div>
  );
}

// Domain migration panel — checklist for moving theluxurybali.com from Framer to Vercel
function DomainMigrationPanel() {
  const [done, setDone] = useState(() => {
    try { return JSON.parse(localStorage.getItem('tlb_migration_progress') || '{}'); } catch { return {}; }
  });
  function toggle(id) {
    const next = { ...done, [id]: !done[id] };
    setDone(next);
    try { localStorage.setItem('tlb_migration_progress', JSON.stringify(next)); } catch {}
  }

  const steps = [
    { id: 'backup', label: 'Backup current Framer site (export HTML + assets)', detail: 'Download via Framer admin export or wget mirror.' },
    { id: 'gsc_old', label: 'Add old Framer site to Google Search Console (verify)', detail: 'Confirms old indexed URLs we want to redirect.' },
    { id: 'inventory', label: 'Inventory all indexed URLs from old site (Search Console → Pages)', detail: 'Export to CSV. Will become 301 redirect map.' },
    { id: 'vercel_dns', label: 'Add custom domain theluxurybali.com to Vercel project', detail: 'Vercel → Project Settings → Domains. Get verification TXT record.' },
    { id: 'dns_update', label: 'Update DNS at registrar to Vercel nameservers (or A/CNAME)', detail: 'A: 76.76.21.21, CNAME: cname.vercel-dns.com. Propagation 1-48h.' },
    { id: 'ssl', label: 'Verify SSL cert provisioned by Vercel (auto)', detail: 'Should appear in Vercel dashboard within 30 min of DNS verify.' },
    { id: 'redirects', label: 'Configure 301 redirects in vercel.json for old Framer URLs', detail: 'Map every legacy URL → new equivalent. Critical for preserving link equity.' },
    { id: 'sitemap_submit', label: 'Submit new sitemap.xml to Google Search Console', detail: 'GSC → Sitemaps → Add: https://www.theluxurybali.com/sitemap.xml' },
    { id: 'change_address', label: 'Use GSC Change of Address tool (if applicable)', detail: 'Settings → Change of address. Tells Google old → new property.' },
    { id: 'bing', label: 'Submit sitemap to Bing Webmaster Tools', detail: 'bing.com/webmasters → Add site → Submit sitemap.' },
    { id: 'check_render', label: 'Verify Googlebot renders pages correctly', detail: 'GSC → URL Inspection → Test live URL → check rendered HTML has all content.' },
    { id: 'core_vitals', label: 'Run PageSpeed Insights on top 5 pages, target green Core Web Vitals', detail: 'pagespeed.web.dev — LCP <2.5s, CLS <0.1, INP <200ms.' },
    { id: 'monitor', label: 'Monitor GSC Coverage report daily for 14 days post-migration', detail: 'Watch for 404 spikes, indexing drops. Fix immediately.' },
    { id: 'social', label: 'Update social profile URLs (Instagram, Facebook, LinkedIn) to new domain', detail: 'Important for off-page signals + brand consistency.' },
    { id: 'partners', label: 'Notify partner sites (TLN, Nakula, etc.) of new URL', detail: 'Update partner backlinks to new canonical domain.' },
    { id: 'analytics', label: 'Update Google Analytics 4 + Search Console properties to new domain', detail: 'Don\'t lose 6 months of historical data — migrate property settings.' },
  ];
  const completedCount = steps.filter(s => done[s.id]).length;

  return (
    <Panel>
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
        <Display size={18} italic weight={400}>Domain migration checklist.</Display>
        <Sans size={11} color={CC.mid}>{completedCount} / {steps.length} complete</Sans>
      </div>
      <Sans size={12} color={CC.mid} style={{ marginBottom: 16, lineHeight: 1.65 }}>
        Move theluxurybali.com from Framer to Vercel without losing rankings. Critical: 301 redirect map preserves link equity. Allow 2-4 weeks for Google to fully reprocess.
      </Sans>
      <div>
        {steps.map((s, i) => (
          <div key={s.id} onClick={() => toggle(s.id)}
            style={{ display: 'flex', gap: 12, padding: '12px 0', borderBottom: i < steps.length - 1 ? '1px solid ' + CC.border : 'none', cursor: 'pointer' }}>
            <input type="checkbox" checked={!!done[s.id]} readOnly style={{ marginTop: 4 }} />
            <div style={{ flex: 1 }}>
              <Sans size={13} weight={500} color={done[s.id] ? CC.mid : CC.ink} style={{ textDecoration: done[s.id] ? 'line-through' : 'none' }}>
                {i + 1}. {s.label}
              </Sans>
              <Sans size={11} color={CC.mid} style={{ marginTop: 4, lineHeight: 1.55 }}>{s.detail}</Sans>
            </div>
          </div>
        ))}
      </div>
    </Panel>
  );
}

// Per-villa SEO override modal — admin sets custom title/description that
// overrides the auto-generated meta tags
function SEOOverrideModal({ villa, onClose, onSuccess }) {
  const o = villa.seo_overrides || {};
  const [title, setTitle] = useState(o.title || '');
  const [description, setDescription] = useState(o.description || '');
  const [keywords, setKeywords] = useState((o.keywords || []).join(', '));
  const [busy, setBusy] = useState(false);

  // Suggested auto-generated values for reference
  const suggestedTitle = `${villa.name || 'Villa'}${villa.bedrooms ? ' ' + villa.bedrooms + 'BR' : ''}${villa.location ? ' in ' + (villa.location.split(',')[0] || villa.location).trim() : ''} | The Luxury Bali`.slice(0, 70);
  const suggestedDesc = (villa.description || '').slice(0, 160) || `Private ${villa.bedrooms || ''}BR villa${villa.location ? ' in ' + villa.location : ''}. Hand-picked by The Luxury Bali editors. Member rates available.`;

  async function save() {
    setBusy(true);
    const token = localStorage.getItem('tlb_admin_token') || '';
    const r = await fetch('/api/admin?action=update_property', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token },
      body: JSON.stringify({
        property_id: villa.id,
        seo_overrides: {
          title: title.slice(0, 70),
          description: description.slice(0, 160),
          keywords: keywords.split(',').map(k => k.trim()).filter(Boolean).slice(0, 10),
          updated_at: new Date().toISOString(),
        },
      }),
    });
    setBusy(false);
    if (r.ok) onSuccess();
    else { const j = await r.json().catch(() => ({})); window.alert('Failed: ' + (j.error || r.statusText)); }
  }

  return (
    <div style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(20,20,20,.55)', backdropFilter: 'blur(6px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}>
      <Panel style={{ maxWidth: 640, width: '100%', maxHeight: '90vh', overflowY: 'auto' }}>
        <Display size={20} italic weight={400} style={{ marginBottom: 4 }}>SEO override.</Display>
        <Sans size={11} color={CC.mid} style={{ marginBottom: 14 }}>{villa.name} · /villa/{villa.url_slug}</Sans>

        <div style={{ marginBottom: 12 }}>
          <Sans size={10} weight={700} ls={0.16} upper color={CC.mid} style={{ marginBottom: 4 }}>Custom title <span style={{ textTransform: 'none', letterSpacing: 0, color: title.length > 70 ? '#9a1c1c' : title.length >= 50 ? '#1f6b3a' : CC.mid }}>({title.length}/70)</span></Sans>
          <input type="text" value={title} onChange={e => setTitle(e.target.value)} placeholder="Leave blank to use auto-generated"
            style={{ width: '100%', padding: '10px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 13, fontFamily: 'inherit' }} />
          <Sans size={10} color={CC.mid} style={{ marginTop: 4, fontStyle: 'italic' }}>Auto: {suggestedTitle}</Sans>
        </div>

        <div style={{ marginBottom: 12 }}>
          <Sans size={10} weight={700} ls={0.16} upper color={CC.mid} style={{ marginBottom: 4 }}>Custom meta description <span style={{ textTransform: 'none', letterSpacing: 0, color: description.length > 160 ? '#9a1c1c' : description.length >= 130 ? '#1f6b3a' : CC.mid }}>({description.length}/160)</span></Sans>
          <textarea value={description} onChange={e => setDescription(e.target.value)} rows={3} placeholder="Leave blank to use auto-generated"
            style={{ width: '100%', padding: '10px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 13, fontFamily: 'inherit', resize: 'vertical' }} />
          <Sans size={10} color={CC.mid} style={{ marginTop: 4, fontStyle: 'italic' }}>Auto: {suggestedDesc.slice(0, 120)}…</Sans>
        </div>

        <div style={{ marginBottom: 16 }}>
          <Sans size={10} weight={700} ls={0.16} upper color={CC.mid} style={{ marginBottom: 4 }}>Keywords (optional, comma-separated)</Sans>
          <input type="text" value={keywords} onChange={e => setKeywords(e.target.value)} placeholder="luxury villa, uluwatu, beachfront, private chef"
            style={{ width: '100%', padding: '10px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 12, fontFamily: 'inherit' }} />
        </div>

        <Panel pad={12} style={{ background: CC.offWhite, borderColor: 'transparent', marginBottom: 14 }}>
          <Sans size={10} weight={700} ls={0.14} upper color={CC.mid} style={{ marginBottom: 6 }}>Search preview</Sans>
          <div style={{ fontFamily: 'arial, sans-serif' }}>
            <div style={{ color: '#1a0dab', fontSize: 18, lineHeight: 1.3, marginBottom: 2 }}>{title || suggestedTitle}</div>
            <div style={{ color: '#006621', fontSize: 13 }}>https://www.theluxurybali.com/villa/{villa.url_slug}</div>
            <div style={{ color: '#545454', fontSize: 13, marginTop: 4, lineHeight: 1.55 }}>{description || suggestedDesc}</div>
          </div>
        </Panel>

        <div style={{ display: 'flex', gap: 8 }}>
          <button onClick={onClose} style={{ padding: '10px 20px', border: '1px solid ' + CC.border, background: CC.white, color: CC.deep, borderRadius: 100, fontSize: 12, fontWeight: 600, cursor: 'pointer' }}>Cancel</button>
          <button onClick={save} disabled={busy} style={{ flex: 1, padding: '10px 20px', border: 'none', background: CC.ink, color: CC.white, borderRadius: 100, fontSize: 12, fontWeight: 700, letterSpacing: '0.06em', cursor: 'pointer', opacity: busy ? 0.5 : 1 }}>
            {busy ? 'Saving…' : 'Save SEO override'}
          </button>
        </div>
      </Panel>
    </div>
  );
}

// ── Host · Public Site ───────────────────────────────────────────────────
function HostSite() {
  // v113 — Live public-site preview. Shows host's actual villa pages
  // with cover photo, slug, status, photo count, recent view count
  // (when /api/views?summary=true responds). Click → opens villa
  // detail page; Edit → opens host-classic editor.
  const propsApi = useApi('/api/properties?status=all', { tokenType: 'host' });
  const villas = (propsApi.data && propsApi.data.properties) || [];
  if (propsApi.loading) return <SectionWrap title="Public site"><Loading msg="Loading your villa pages…" /></SectionWrap>;
  if (propsApi.error)   return <SectionWrap title="Public site"><ErrorState msg={propsApi.error} onRetry={propsApi.refetch} /></SectionWrap>;

  return (
    <SectionWrap title="Public site" subtitle={villas.length + ' villa pages on theluxurybali.com'}>
      {villas.length === 0
        ? <Empty title="No villa pages yet" msg="Add your first villa via the import-from-OTA flow to see it here." />
        : (
          <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: dashGap() }}>
            {villas.map(v => {
              const photos = typeof v.photos === 'string' ? (() => { try { return JSON.parse(v.photos) || []; } catch { return []; } })() : (Array.isArray(v.photos) ? v.photos : []);
              const slug = v.url_slug || v.id;
              const cover = v.image || photos[0] || '';
              return (
                <Panel key={v.id} pad={0} style={{ overflow: 'hidden' }}>
                  <div style={{ aspectRatio: '4/3', background: cover ? `url('${cover}') center/cover` : CC.offWhite, position: 'relative' }}>
                    <Pill tone={v.status === 'live' ? 'success' : 'neutral'} style={{ position: 'absolute', top: 12, left: 12 }}>{v.status || 'draft'}</Pill>
                  </div>
                  <div style={{ padding: 16 }}>
                    <Sans size={10} weight={600} ls={0.14} upper color={CC.mid}>{(v.location || 'Bali').split(',')[0].trim()}</Sans>
                    <Display size={17} weight={500} style={{ marginTop: 6 }}>{v.name || 'Untitled'}</Display>
                    <Sans size={11} color={CC.mid} style={{ marginTop: 4 }}>{v.bedrooms || '?'}BR · sleeps {v.guests || '?'} · {photos.length} photos</Sans>
                    <div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
                      <a href={'/villa/' + slug} target="_blank" style={{ ...ghostBtn, fontSize: 11, padding: '6px 12px' }}>Preview ↗</a>
                      {/* v141.78 — &legacy=1 needed so the classic edit
                          page actually loads instead of bouncing back to
                          the dashboard grid. */}
                      <a href={'/host-classic?tab=properties&id=' + encodeURIComponent(v.id) + '&legacy=1'} target="_blank" style={{ ...ghostBtn, fontSize: 11, padding: '6px 12px' }}>Edit copy</a>
                      <a href={'/host-classic?tab=properties&id=' + encodeURIComponent(v.id) + '&section=photos&legacy=1'} target="_blank" style={{ ...ghostBtn, fontSize: 11, padding: '6px 12px' }}>Photos</a>
                    </div>
                  </div>
                </Panel>
              );
            })}
          </div>
        )}
    </SectionWrap>
  );
}

// ── Host · Stories ───────────────────────────────────────────────────────
function HostStories() {
  const { data, loading, error, refetch } = useApi('/api/stories?for=host&limit=20', { tokenType: 'host' });
  if (loading) return <SectionWrap title="Stories"><Loading msg="Loading published stories…" /></SectionWrap>;
  if (error) return <SectionWrap title="Stories"><ErrorState msg={error} onRetry={refetch} /></SectionWrap>;

  const stories = (data && (data.stories || data.data || [])) || [];

  return (
    <SectionWrap title="Stories" subtitle={`${stories.length} published`}>
      {stories.length === 0 ? (
        <Empty title="No stories yet" msg="Stories are short editorial posts — quiet villa moments, host news, seasonal tips. Boosts SEO + conversion." />
      ) : (
        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: dashGap() }}>
          {stories.map((s, i) => (
            <Panel key={s.id || i} pad={0} style={{ overflow: 'hidden' }}>
              {(s.image || s.cover) && <div style={{ aspectRatio: '4/3', background: CC.offWhite, backgroundImage: `url('${s.image || s.cover}')`, backgroundSize: 'cover', backgroundPosition: 'center' }} />}
              <div style={{ padding: 16 }}>
                <Sans size={11} weight={600} ls={0.10} upper color={CC.mid} style={{ marginBottom: 6 }}>{fmtDate(s.published_at || s.created_at)}</Sans>
                <Display size={18} italic weight={400} style={{ marginBottom: 8 }}>{s.title || 'Untitled'}.</Display>
                <Sans size={12} color={CC.mid} style={{ lineHeight: 1.55 }}>{((s.excerpt || s.body || '').slice(0, 140)) + ((s.excerpt || s.body || '').length > 140 ? '…' : '')}</Sans>
              </div>
            </Panel>
          ))}
        </div>
      )}
    </SectionWrap>
  );
}

// ── Admin · Payouts override (v125 — manual trigger / retry / cancel) ──
function AdminPayouts() {
  const [filter, setFilter] = useState('pending');
  const [busy, setBusy] = useState(null);
  const { data, loading, error, refetch } = useApi('/api/admin-payouts?status=' + filter, { tokenType: 'admin' });
  if (loading) return <SectionWrap title="Payouts override"><Loading msg="Loading payouts…" /></SectionWrap>;
  if (error) return <SectionWrap title="Payouts override"><ErrorState msg={error} onRetry={refetch} /></SectionWrap>;

  const payouts = (data && data.payouts) || [];
  const totals = (data && data.totals) || {};
  const counts = (data && data.counts) || {};

  async function callAction(action, payload) {
    setBusy(action);
    const token = localStorage.getItem('tlb_admin_token') || '';
    try {
      const r = await fetch('/api/admin-payouts?action=' + action, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token },
        body: JSON.stringify(payload || {}),
      });
      const j = await r.json();
      if (r.ok) {
        window.alert('Action complete: ' + (j.disbursement_id ? 'Xendit ID ' + j.disbursement_id : 'recorded'));
        refetch();
      } else {
        window.alert('Failed: ' + (j.error || r.statusText));
      }
    } catch (e) { window.alert('Network error: ' + e.message); }
    finally { setBusy(null); }
  }

  async function triggerForBooking() {
    const id = window.prompt('Booking ID to trigger payout for:');
    if (!id) return;
    return callAction('trigger', { booking_id: id });
  }

  async function runCron() {
    if (!window.confirm('Run weekly auto-payouts cron NOW? This processes all eligible bookings (≥7 days post-checkout) and wires payouts via Xendit.')) return;
    return callAction('run_cron');
  }

  return (
    <SectionWrap title="Payouts override" subtitle="Manual control over weekly auto-payout cron"
      actions={
        <div style={{ display: 'flex', gap: 6 }}>
          <button onClick={triggerForBooking} disabled={busy}
            style={{ padding: '8px 16px', background: CC.white, color: CC.ink, border: '1px solid ' + CC.border, borderRadius: 100, fontSize: 11, fontWeight: 600, cursor: 'pointer' }}>+ Trigger one</button>
          <button onClick={runCron} disabled={busy}
            style={{ padding: '8px 16px', background: CC.ink, color: CC.white, border: 'none', borderRadius: 100, fontSize: 11, fontWeight: 600, cursor: 'pointer' }}>{busy === 'run_cron' ? 'Running…' : 'Run cron now'}</button>
        </div>
      }>
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: dashGap(), marginBottom: dashGap() }}>
        <KPI label="Pending" value={counts.pending || 0} />
        <KPI label="Pending IDR" value={fmtIDR(totals.pending || 0)} />
        <KPI label="Failed" value={counts.failed || 0} />
        <KPI label="Failed IDR" value={fmtIDR(totals.failed || 0)} />
      </div>

      <div style={{ display: 'flex', gap: 6, marginBottom: dashGap() }}>
        {['all', 'pending', 'processed', 'failed', 'cancelled'].map(f => (
          <button key={f} onClick={() => setFilter(f)}
            style={{ padding: '7px 14px', borderRadius: 100, fontSize: 11, fontWeight: 600,
              border: '1px solid ' + (filter === f ? CC.ink : CC.border),
              background: filter === f ? CC.ink : CC.white,
              color: filter === f ? CC.white : CC.deep, cursor: 'pointer' }}>{f}</button>
        ))}
      </div>

      {payouts.length === 0 ? (
        <Empty title={'No ' + filter + ' payouts'} msg="Auto-payouts run weekly Mondays. Use 'Trigger one' to pay early or 'Run cron now' to fire the batch immediately." />
      ) : (
        <Table
          columns={[
            { label: 'Date', key: 'created_at', nowrap: true, render: r => fmtDate(r.created_at) },
            { label: 'Booking', key: 'booking_id', render: r => <code style={{ color: CC.mid, fontSize: 11 }}>{(r.booking_id || '').slice(0, 8)}</code> },
            { label: 'Host', key: 'host_email', render: r => <span style={{ fontSize: 11, color: CC.mid }}>{r.host_email}</span> },
            { label: 'Amount', key: 'amount', align: 'right', numeric: true, render: r => fmtIDR(r.amount) },
            { label: 'Comm.', key: 'commission', align: 'right', numeric: true, render: r => r.tlb_commission_amt ? fmtIDR(r.tlb_commission_amt) + ' (' + ((r.tlb_commission_pct || 0) * 100).toFixed(0) + '%)' : '—' },
            { label: 'PPh 23', key: 'pph23', align: 'right', numeric: true, render: r => r.pph23_amount ? fmtIDR(r.pph23_amount) : '—' },
            { label: 'Status', key: 'status', render: r => <Pill tone={r.status === 'processed' ? 'success' : r.status === 'failed' ? 'danger' : r.status === 'cancelled' ? 'neutral' : 'warn'}>{r.status}</Pill> },
            { label: 'Xendit', key: 'stripe_transfer_id', render: r => r.stripe_transfer_id ? <code style={{ color: CC.mid, fontSize: 10 }}>{r.stripe_transfer_id.slice(0, 14)}…</code> : '—' },
            { label: '', key: 'action', render: r => {
              if (r.status === 'failed') {
                return <button onClick={() => callAction('retry', { payout_id: r.id })} disabled={!!busy}
                  style={{ padding: '5px 12px', background: CC.ink, color: CC.white, border: 'none', borderRadius: 100, fontSize: 11, fontWeight: 600, cursor: 'pointer' }}>Retry</button>;
              }
              if (r.status === 'pending') {
                return <button onClick={() => {
                  const reason = window.prompt('Cancel reason (optional):') || '';
                  callAction('cancel', { payout_id: r.id, reason });
                }} disabled={!!busy}
                  style={{ padding: '5px 12px', background: CC.white, color: '#9a1c1c', border: '1px solid #d6b3b3', borderRadius: 100, fontSize: 11, fontWeight: 600, cursor: 'pointer' }}>Cancel</button>;
              }
              return null;
            }},
          ]}
          rows={payouts.slice(0, 50)}
        />
      )}
    </SectionWrap>
  );
}

// ── Admin · Inbox (v124 — threaded chat with admin mediation) ────────
// v141.126 — AdminInbox, v141.123 mockup port.
//   • Eyebrow: "v124 · mediated chat"
//   • Subtitle: explains mediation + internal notes
//   • Header CTAs: Templates (snippet picker) + New thread (modal)
//   • Wraps the existing ChatInbox (defined in dashboards-s3-host.jsx)
//     which already supports admin role with internal notes — we don't
//     refactor the chat internals to avoid breaking host messaging.
function AdminInbox() {
  const Cmp = window.TLB_CHAT_INBOX;
  const [showTemplates, setShowTemplates] = React.useState(false);
  const [showNewThread, setShowNewThread] = React.useState(false);

  if (!Cmp) {
    return <SectionWrap title="Inbox"><Loading msg="Loading chat module…" /></SectionWrap>;
  }

  return (
    <SectionWrap
      title="Inbox"
      subtitle="Threaded chat between guests and hosts with concierge-desk mediation. Internal notes are hidden from both sides."
      dataNeeds={['chat']}
      actions={
        <div style={{ display: 'flex', gap: 8 }}>
          <button onClick={() => setShowTemplates(true)}
            style={{ padding: '10px 18px', borderRadius: 100, cursor: 'pointer', background: CC.white, color: CC.deep, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600 }}>
            Templates
          </button>
          <button onClick={() => setShowNewThread(true)}
            style={{ padding: '10px 18px', borderRadius: 100, cursor: 'pointer', background: CC.ink, color: CC.white, border: '1px solid ' + CC.ink, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600 }}>
            New thread
          </button>
        </div>
      }
    >
      <Cmp role="admin" />

      {showTemplates && <AdminInboxTemplatesModal onClose={() => setShowTemplates(false)} />}
      {showNewThread && <AdminInboxNewThreadModal onClose={() => setShowNewThread(false)} />}
    </SectionWrap>
  );
}

// Templates — quick-paste snippets for concierge desk operators.
// Stored in localStorage so each operator can customise their library.
function AdminInboxTemplatesModal({ onClose }) {
  const defaults = [
    { name: 'Welcome', text: "Hi {guest_name}, this is {concierge_name} from The Luxury Bali. I'll be your concierge for your stay at {villa_name}. Let me know if you need anything — driver pickup, restaurant bookings, in-villa chef." },
    { name: 'Driver pickup', text: "Your driver {driver_name} will be at Ngurah Rai Gate 3 holding a small TLB sign at {arrival_time}. He drives a {car_model} {car_plate}. WhatsApp him directly: {driver_wa}." },
    { name: 'Late checkout', text: "We can hold the villa until {checkout_time} as a courtesy. Standard checkout is 11:00; anything past 14:00 may incur a half-day rate per the booking terms." },
    { name: 'Chef booking', text: "I can arrange Chef {chef_name} for an in-villa dinner — {courses} courses, $180pp, including grocery shop and cleanup. Confirm and I'll book." },
    { name: 'Maintenance issue', text: "Apologies for the {issue}. The villa team is on the way and should have it resolved within {eta_min} minutes. I'll personally follow up to confirm." },
  ];
  const [items, setItems] = React.useState(() => {
    try {
      const raw = localStorage.getItem('tlb_inbox_templates');
      if (raw) return JSON.parse(raw);
    } catch {}
    return defaults;
  });
  const [editing, setEditing] = React.useState(null);
  const [draft, setDraft] = React.useState({ name: '', text: '' });

  function save() {
    try { localStorage.setItem('tlb_inbox_templates', JSON.stringify(items)); } catch {}
  }

  return (
    <div onClick={onClose} style={{ position: 'fixed', inset: 0, background: 'rgba(20,20,20,0.45)', zIndex: 1000, display: 'grid', placeItems: 'center', padding: 24 }}>
      <div onClick={(e) => e.stopPropagation()} style={{ background: CC.white, borderRadius: 18, padding: 32, maxWidth: 640, width: '100%', maxHeight: '90vh', overflow: 'auto', boxShadow: '0 12px 40px rgba(20,20,20,0.18)' }}>
        <Display size={26} italic weight={400}>Templates.</Display>
        <Sans size={12} color={CC.mid} style={{ marginTop: 6, marginBottom: 22 }}>
          Quick-paste snippets for the concierge desk. Click any template to copy to clipboard — placeholders like {'{guest_name}'} stay for you to fill in.
        </Sans>
        <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
          {items.map((t, i) => (
            <div key={i} style={{ padding: '12px 14px', borderRadius: 10, border: `1px solid ${CC.border}`, background: CC.white }}>
              <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 10 }}>
                <Sans size={12} weight={600} color={CC.ink}>{t.name}</Sans>
                <div style={{ display: 'flex', gap: 6 }}>
                  <button onClick={() => {
                    navigator.clipboard.writeText(t.text).then(() => {
                      if (window.TLB_NAV && TLB_NAV.toast) TLB_NAV.toast('Copied to clipboard', 'success');
                    });
                  }} style={{ padding: '4px 10px', borderRadius: 100, cursor: 'pointer', background: CC.ink, color: CC.white, border: '1px solid ' + CC.ink, fontFamily: F_TLB.ui, fontSize: 10.5, fontWeight: 600 }}>
                    Copy
                  </button>
                  <button onClick={() => { setEditing(i); setDraft(t); }} style={{ padding: '4px 10px', borderRadius: 100, cursor: 'pointer', background: 'transparent', color: CC.deep, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 10.5, fontWeight: 600 }}>
                    Edit
                  </button>
                </div>
              </div>
              <Sans size={11} color={CC.mid} style={{ marginTop: 6, lineHeight: 1.55 }}>{t.text}</Sans>
            </div>
          ))}
        </div>

        {editing !== null && (
          <div style={{ marginTop: 16, paddingTop: 16, borderTop: `1px solid ${CC.border}` }}>
            <input type="text" value={draft.name} onChange={(e) => setDraft({ ...draft, name: e.target.value })} placeholder="Template name"
              style={{ width: '100%', padding: '8px 12px', marginBottom: 8, borderRadius: 8, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 13 }} />
            <textarea rows={4} value={draft.text} onChange={(e) => setDraft({ ...draft, text: e.target.value })} placeholder="Template body"
              style={{ width: '100%', padding: '10px 12px', borderRadius: 8, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 13, resize: 'vertical' }} />
            <div style={{ display: 'flex', gap: 8, marginTop: 8, justifyContent: 'flex-end' }}>
              <button onClick={() => setEditing(null)} style={{ padding: '6px 14px', borderRadius: 100, cursor: 'pointer', background: 'transparent', color: CC.deep, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 11, fontWeight: 600 }}>
                Cancel
              </button>
              <button onClick={() => {
                const next = [...items];
                next[editing] = draft;
                setItems(next);
                save();
                setEditing(null);
              }} style={{ padding: '6px 14px', borderRadius: 100, cursor: 'pointer', background: CC.ink, color: CC.white, border: '1px solid ' + CC.ink, fontFamily: F_TLB.ui, fontSize: 11, fontWeight: 600 }}>
                Save template
              </button>
            </div>
          </div>
        )}

        <div style={{ display: 'flex', gap: 10, marginTop: 22, justifyContent: 'flex-end' }}>
          <button onClick={onClose} style={{ padding: '10px 18px', borderRadius: 100, cursor: 'pointer', background: 'transparent', color: CC.deep, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600 }}>
            Close
          </button>
        </div>
      </div>
    </div>
  );
}

// New thread — admins can start a new chat thread without an existing
// booking (e.g. proactive outreach, pre-arrival check-in question).
function AdminInboxNewThreadModal({ onClose }) {
  const [form, setForm] = React.useState({ recipient_email: '', subject: '', body: '', type: 'guest' });
  const [submitting, setSubmitting] = React.useState(false);
  const [error, setError] = React.useState(null);

  async function submit() {
    setSubmitting(true);
    setError(null);
    try {
      const token = localStorage.getItem('tlb_admin_token') || localStorage.getItem('tlb_token') || '';
      const r = await fetch('/api/chat?action=create_thread', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', ...(token ? { 'Authorization': 'Bearer ' + token } : {}) },
        body: JSON.stringify({
          recipient_email: form.recipient_email,
          recipient_type: form.type,
          subject: form.subject,
          initial_message: form.body,
          mediated_by_admin: true,
        }),
      });
      const j = await r.json().catch(() => ({}));
      if (!r.ok) throw new Error(j.error || j.message || `Server returned ${r.status}`);
      onClose();
      // ChatInbox polls threads list every 4s; new thread will surface naturally
    } catch (e) {
      setError(e.message || 'Unknown error');
    } finally {
      setSubmitting(false);
    }
  }

  const inputStyle = {
    width: '100%', padding: '10px 14px', border: '1px solid ' + CC.border, borderRadius: 8,
    fontFamily: F_TLB.ui, fontSize: 13, background: CC.white, color: CC.ink, outline: 'none',
  };

  return (
    <div onClick={onClose} style={{ position: 'fixed', inset: 0, background: 'rgba(20,20,20,0.45)', zIndex: 1000, display: 'grid', placeItems: 'center', padding: 24 }}>
      <div onClick={(e) => e.stopPropagation()} style={{ background: CC.white, borderRadius: 18, padding: 32, maxWidth: 540, width: '100%', maxHeight: '90vh', overflow: 'auto', boxShadow: '0 12px 40px rgba(20,20,20,0.18)' }}>
        <Display size={26} italic weight={400}>New thread.</Display>
        <Sans size={12} color={CC.mid} style={{ marginTop: 6, marginBottom: 22 }}>
          Start a new mediated chat — proactive outreach, pre-arrival check-ins, or follow-ups not tied to a booking.
        </Sans>
        <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
          <div>
            <Sans size={10} weight={600} ls={0.14} upper color={CC.mid} style={{ marginBottom: 6 }}>Recipient type</Sans>
            <div style={{ display: 'flex', gap: 6 }}>
              {[{ v: 'guest', label: 'Guest' }, { v: 'host', label: 'Host' }].map(t => (
                <button key={t.v} onClick={() => setForm({ ...form, type: t.v })} style={{
                  flex: 1, padding: '8px 14px', borderRadius: 100, cursor: 'pointer',
                  background: form.type === t.v ? CC.ink : 'transparent',
                  color: form.type === t.v ? CC.white : CC.deep,
                  border: '1px solid ' + (form.type === t.v ? CC.ink : CC.border),
                  fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600,
                }}>{t.label}</button>
              ))}
            </div>
          </div>
          <div>
            <Sans size={10} weight={600} ls={0.14} upper color={CC.mid} style={{ marginBottom: 6 }}>Recipient email</Sans>
            <input type="email" style={inputStyle} value={form.recipient_email} onChange={(e) => setForm({ ...form, recipient_email: e.target.value })} placeholder="guest@example.com" />
          </div>
          <div>
            <Sans size={10} weight={600} ls={0.14} upper color={CC.mid} style={{ marginBottom: 6 }}>Subject</Sans>
            <input type="text" style={inputStyle} value={form.subject} onChange={(e) => setForm({ ...form, subject: e.target.value })} placeholder="Pre-arrival check-in" />
          </div>
          <div>
            <Sans size={10} weight={600} ls={0.14} upper color={CC.mid} style={{ marginBottom: 6 }}>First message</Sans>
            <textarea rows={5} style={{ ...inputStyle, resize: 'vertical' }} value={form.body} onChange={(e) => setForm({ ...form, body: e.target.value })} placeholder="Write your first message…" />
          </div>
        </div>
        {error && <Sans size={12} color="#9a1c1c" style={{ marginTop: 14 }}>{error}</Sans>}
        <div style={{ display: 'flex', gap: 10, marginTop: 22, justifyContent: 'flex-end' }}>
          <button onClick={onClose} disabled={submitting} style={{ padding: '10px 18px', borderRadius: 100, cursor: 'pointer', background: 'transparent', color: CC.deep, border: '1px solid ' + CC.border, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600 }}>
            Cancel
          </button>
          <button onClick={submit} disabled={submitting || !form.recipient_email || !form.body}
            style={{ padding: '10px 22px', borderRadius: 100, cursor: submitting ? 'wait' : 'pointer', background: CC.ink, color: CC.white, border: '1px solid ' + CC.ink, fontFamily: F_TLB.ui, fontSize: 12, fontWeight: 600, opacity: submitting || !form.recipient_email || !form.body ? 0.5 : 1 }}>
            {submitting ? 'Sending…' : 'Send & open thread'}
          </button>
        </div>
      </div>
    </div>
  );
}

// ── Admin · OTA channels (v126 — iCal mappings, two-way sync) ──────────
function AdminOTAChannels() {
  const { data, loading, error, refetch } = useApi('/api/ota-channels', { tokenType: 'admin' });
  const [filter, setFilter] = useState('');
  const [editingProp, setEditingProp] = useState(null);

  if (loading) return <SectionWrap title="Host Channels"><Loading msg="Loading host channel mappings…" /></SectionWrap>;
  if (error) return <SectionWrap title="Host Channels"><ErrorState msg={error} onRetry={refetch} /></SectionWrap>;

  const properties = (data && data.properties) || [];
  const filtered = filter ? properties.filter(p => (p.name || '').toLowerCase().includes(filter.toLowerCase())) : properties;

  const totalInbound = properties.reduce((s, p) => s + (p.inbound_count || 0), 0);
  const totalConfigured = properties.filter(p => (p.inbound_count || 0) > 0).length;

  function copyToClipboard(text, label) {
    navigator.clipboard.writeText(text).then(() => {
      window.dispatchEvent(new CustomEvent('tlb-toast', { detail: { msg: (label || 'Copied') + ' to clipboard', tone: 'success' } }));
    });
  }

  return (
    <SectionWrap title="Host Channels" subtitle={`Per-villa iCal feeds from host channel partners. ${properties.length} villas · ${totalConfigured} configured · ${totalInbound} inbound iCal mappings.`}
      actions={
        <input type="text" value={filter} onChange={e => setFilter(e.target.value)} placeholder="Filter villas…"
          style={{ padding: '7px 12px', border: '1px solid ' + CC.border, borderRadius: 100, fontSize: 12, fontFamily: 'inherit', minWidth: 200 }} />
      }>
      <Panel pad={14} style={{ background: '#f0f9ff', borderColor: '#7dd3fc', marginBottom: dashGap() }}>
        <Sans size={11} weight={700} color="#075985" style={{ marginBottom: 6 }}>How two-way sync works</Sans>
        <Sans size={11.5} color="#075985" style={{ lineHeight: 1.65 }}>
          <strong>Outbound:</strong> TLB exposes a unique iCal URL per villa. Paste it into Airbnb / Booking.com /
          VRBO under "Import calendar from another website". They poll hourly and block any dates we report as occupied.
          <br/>
          <strong>Inbound:</strong> Get the iCal export URL from each OTA's calendar settings, paste it into the
          channel below. Our sync-calendars cron polls them every 4 hours and writes to blocked_dates.
          <br/>
          Real Channel Manager APIs (Airbnb Property API, Booking.com OPNI) require partnership agreements —
          iCal sync is the production-standard alternative supported by every major OTA.
        </Sans>
      </Panel>

      {filtered.length === 0 ? (
        <Empty title="No villas match" msg="Adjust the filter, or add properties first." />
      ) : (
        <div style={{ display: 'grid', gap: 12 }}>
          {filtered.slice(0, 30).map(p => (
            <Panel key={p.id}>
              <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 14, marginBottom: 14 }}>
                <div>
                  <Sans size={14} weight={600} color={CC.ink}>{p.name}</Sans>
                  <Sans size={11} color={CC.mid} style={{ marginTop: 2 }}>{p.location || '—'} · host: {p.host_email}</Sans>
                </div>
                <div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
                  <Pill tone={p.sync_status === 'error' ? 'danger' : p.sync_status === 'paused' ? 'warn' : 'success'}>{p.sync_status || 'ok'}</Pill>
                  <Pill tone="info">{p.inbound_count} inbound</Pill>
                </div>
              </div>

              {/* Outbound iCal URL */}
              <div style={{ marginBottom: 14 }}>
                <Sans size={10} weight={700} ls={0.16} upper color={CC.mid} style={{ marginBottom: 6 }}>Outbound iCal (paste into Airbnb / Booking.com)</Sans>
                <div style={{ display: 'flex', gap: 6 }}>
                  <input type="text" value={p.outbound_ical_url} readOnly
                    onClick={e => e.target.select()}
                    style={{ flex: 1, padding: '8px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 11, fontFamily: 'monospace', background: CC.offWhite }} />
                  <button onClick={() => copyToClipboard(p.outbound_ical_url, 'iCal URL')}
                    style={{ padding: '8px 16px', background: CC.ink, color: CC.white, border: 'none', borderRadius: 100, fontSize: 11, fontWeight: 600, cursor: 'pointer' }}>Copy</button>
                </div>
              </div>

              {/* Inbound channels */}
              <div>
                <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
                  <Sans size={10} weight={700} ls={0.16} upper color={CC.mid}>Inbound channels (paste from OTA)</Sans>
                  <button onClick={() => setEditingProp(p)}
                    style={{ padding: '5px 12px', background: CC.white, color: CC.ink, border: '1px solid ' + CC.border, borderRadius: 100, fontSize: 11, fontWeight: 600, cursor: 'pointer' }}>Manage</button>
                </div>
                {p.ical_channels && Object.keys(p.ical_channels).length > 0 ? (
                  <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
                    {Object.entries(p.ical_channels).map(([channel, info]) => (
                      <Pill key={channel} tone="success">{channel} ✓</Pill>
                    ))}
                  </div>
                ) : (
                  <Sans size={11} color={CC.mid} style={{ fontStyle: 'italic' }}>None configured. Click Manage to add Airbnb / Booking.com / VRBO iCal URLs.</Sans>
                )}
              </div>
            </Panel>
          ))}
          {filtered.length > 30 && <Sans size={11} color={CC.mid} style={{ textAlign: 'center', marginTop: 8 }}>Showing 30 of {filtered.length}. Refine filter.</Sans>}
        </div>
      )}

      {editingProp && <ChannelMappingsModal property={editingProp} onClose={() => setEditingProp(null)} onSuccess={refetch} />}
    </SectionWrap>
  );
}

function ChannelMappingsModal({ property, onClose, onSuccess }) {
  const [channels, setChannels] = useState(property.ical_channels || {});
  const [newChannel, setNewChannel] = useState('airbnb');
  const [newUrl, setNewUrl] = useState('');
  const [busy, setBusy] = useState(false);

  async function add() {
    if (!/^https?:\/\//.test(newUrl)) { window.alert('URL must start with http(s)://'); return; }
    setBusy(true);
    const token = localStorage.getItem('tlb_admin_token') || '';
    const r = await fetch('/api/ota-channels?action=set_inbound', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token },
      body: JSON.stringify({ property_id: property.id, channel: newChannel, ical_url: newUrl }),
    });
    setBusy(false);
    if (r.ok) {
      const j = await r.json();
      setChannels(j.channels);
      setNewUrl('');
      onSuccess();
    } else { window.alert('Failed: ' + (await r.text()).slice(0, 200)); }
  }

  async function remove(channel) {
    if (!window.confirm('Remove ' + channel + ' inbound iCal?')) return;
    setBusy(true);
    const token = localStorage.getItem('tlb_admin_token') || '';
    const r = await fetch('/api/ota-channels?action=remove_inbound', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token },
      body: JSON.stringify({ property_id: property.id, channel }),
    });
    setBusy(false);
    if (r.ok) {
      const j = await r.json();
      setChannels(j.channels);
      onSuccess();
    }
  }

  return (
    <div style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(20,20,20,.55)', backdropFilter: 'blur(6px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}>
      <Panel style={{ maxWidth: 600, width: '100%', maxHeight: '90vh', overflowY: 'auto' }}>
        <Display size={20} italic weight={400} style={{ marginBottom: 4 }}>Inbound channels.</Display>
        <Sans size={11} color={CC.mid} style={{ marginBottom: 16 }}>{property.name}</Sans>

        {Object.entries(channels).length > 0 && (
          <div style={{ marginBottom: 18 }}>
            {Object.entries(channels).map(([channel, info]) => (
              <div key={channel} style={{ display: 'flex', gap: 8, padding: '10px 12px', background: CC.offWhite, borderRadius: 8, marginBottom: 6 }}>
                <Sans size={12} weight={600} style={{ minWidth: 90, textTransform: 'capitalize' }}>{channel}</Sans>
                <Sans size={11} color={CC.mid} style={{ flex: 1, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{info.url}</Sans>
                <button onClick={() => remove(channel)} disabled={busy}
                  style={{ padding: '4px 10px', background: CC.white, color: '#9a1c1c', border: '1px solid #d6b3b3', borderRadius: 100, fontSize: 10, fontWeight: 600, cursor: 'pointer' }}>Remove</button>
              </div>
            ))}
          </div>
        )}

        <div style={{ padding: 14, background: '#f0f9ff', border: '1px solid #7dd3fc', borderRadius: 8, marginBottom: 14 }}>
          <Sans size={10} weight={700} ls={0.14} upper color="#075985" style={{ marginBottom: 6 }}>Add inbound iCal URL</Sans>
          <Sans size={11} color="#075985" style={{ lineHeight: 1.6 }}>
            From Airbnb: Listing → Calendar → Availability → Sync calendars → Export Calendar (.ics) → copy URL.
            <br/>
            Booking.com: Calendar → Sync calendars → "Add a calendar" → copy iCal URL.
          </Sans>
        </div>

        <div style={{ display: 'grid', gridTemplateColumns: '140px 1fr 80px', gap: 8, marginBottom: 12 }}>
          <select value={newChannel} onChange={e => setNewChannel(e.target.value)}
            style={{ padding: '10px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 13, fontFamily: 'inherit' }}>
            {['airbnb', 'booking', 'vrbo', 'tripadvisor', 'agoda', 'expedia', 'other'].map(c => (
              <option key={c} value={c}>{c}</option>
            ))}
          </select>
          <input type="url" value={newUrl} onChange={e => setNewUrl(e.target.value)} placeholder="https://www.airbnb.com/calendar/ical/…"
            style={{ padding: '10px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 12, fontFamily: 'monospace' }} />
          <button onClick={add} disabled={busy || !newUrl}
            style={{ background: CC.ink, color: CC.white, border: 'none', borderRadius: 8, fontSize: 11, fontWeight: 600, cursor: 'pointer', opacity: busy ? 0.5 : 1 }}>{busy ? '…' : 'Add'}</button>
        </div>

        <div style={{ display: 'flex', gap: 8, marginTop: 18 }}>
          <button onClick={onClose} style={{ width: '100%', padding: '10px 20px', border: '1px solid ' + CC.border, background: CC.white, color: CC.deep, borderRadius: 100, fontSize: 12, fontWeight: 600, cursor: 'pointer' }}>Done</button>
        </div>
      </Panel>
    </div>
  );
}

// ── Admin · e-Faktur (v126 — DJP scaffold + manual mark-submitted) ─────
function AdminEFaktur() {
  const [filter, setFilter] = useState('pending_djp');
  const [busy, setBusy] = useState(false);
  const { data, loading, error, refetch } = useApi('/api/e-faktur?status=' + filter, { tokenType: 'admin' });
  if (loading) return <SectionWrap title="e-Faktur"><Loading msg="Loading records…" /></SectionWrap>;
  if (error) return <SectionWrap title="e-Faktur"><ErrorState msg={error} onRetry={refetch} /></SectionWrap>;

  const records = (data && data.records) || [];
  const totals = (data && data.totals) || {};
  const counts = (data && data.counts) || {};

  async function generate() {
    const period = window.prompt('Generate e-Faktur records for period (YYYY-MM, e.g. 2026-04):',
      new Date().toISOString().slice(0, 7));
    if (!period || !/^\d{4}-\d{2}$/.test(period)) return;
    setBusy(true);
    const token = localStorage.getItem('tlb_admin_token') || '';
    try {
      const r = await fetch('/api/e-faktur?action=generate', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token },
        body: JSON.stringify({ period }),
      });
      const j = await r.json();
      if (r.ok) { window.alert('Generated ' + j.generated + ' records for ' + period + '\n\nTotal base: ' + fmtIDR(j.total_base) + '\nTotal PPN: ' + fmtIDR(j.total_ppn)); refetch(); }
      else { window.alert('Failed: ' + (j.error || r.statusText)); }
    } finally { setBusy(false); }
  }

  async function markSubmitted(id) {
    const fakturNo = window.prompt('Enter DJP faktur number (16-digit), or leave blank if just marking as submitted:');
    const token = localStorage.getItem('tlb_admin_token') || '';
    const r = await fetch('/api/e-faktur?action=mark_submitted', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token },
      body: JSON.stringify({ id, faktur_no: fakturNo || '' }),
    });
    if (r.ok) refetch();
    else window.alert('Failed');
  }

  return (
    <SectionWrap title="e-Faktur" subtitle="DJP commission invoice records · scaffold mode (manual submit)"
      actions={
        <div style={{ display: 'flex', gap: 6 }}>
          <button onClick={generate} disabled={busy}
            style={{ padding: '8px 16px', background: CC.ink, color: CC.white, border: 'none', borderRadius: 100, fontSize: 11, fontWeight: 600, cursor: 'pointer' }}>{busy ? 'Generating…' : '+ Generate for period'}</button>
        </div>
      }>
      <Panel pad={14} style={{ background: '#fef3c7', borderColor: '#f59e0b', marginBottom: dashGap() }}>
        <Sans size={11} weight={700} color="#92400e" style={{ marginBottom: 4 }}>⚠ DJP Coretax integration pending</Sans>
        <Sans size={11.5} color="#92400e" style={{ lineHeight: 1.6 }}>
          This page tracks commission invoice records that should be submitted to e-Faktur (Coretax) monthly.
          Auto-submission requires a TLB digital cert (.p12) + DJP API tokens.
          Until those are provisioned, generate records here, then submit manually via the DJP portal and use the
          "Mark submitted" button to log each faktur_no for audit trail.
        </Sans>
      </Panel>

      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: dashGap(), marginBottom: dashGap() }}>
        <KPI label="Pending" value={counts.pending_djp || 0} />
        <KPI label="Submitted" value={counts.submitted || 0} />
        <KPI label="Pending IDR" value={fmtIDR(totals.pending || 0)} />
        <KPI label="Submitted IDR" value={fmtIDR(totals.submitted || 0)} />
      </div>

      <div style={{ display: 'flex', gap: 6, marginBottom: dashGap() }}>
        {['all', 'pending_djp', 'queued', 'submitted', 'approved', 'rejected'].map(f => (
          <button key={f} onClick={() => setFilter(f)}
            style={{ padding: '7px 14px', borderRadius: 100, fontSize: 11, fontWeight: 600,
              border: '1px solid ' + (filter === f ? CC.ink : CC.border),
              background: filter === f ? CC.ink : CC.white,
              color: filter === f ? CC.white : CC.deep, cursor: 'pointer' }}>{f.replace(/_/g, ' ')}</button>
        ))}
      </div>

      {records.length === 0 ? (
        <Empty title={'No ' + filter.replace(/_/g, ' ') + ' records'} msg="Click 'Generate for period' to create e-Faktur records from processed payouts." />
      ) : (
        <Table
          columns={[
            { label: 'Invoice #', key: 'invoice_no', render: r => <code style={{ fontSize: 11 }}>{r.invoice_no}</code> },
            { label: 'Period', key: 'period' },
            { label: 'Host', key: 'host_email', render: r => <span style={{ fontSize: 11, color: CC.mid }}>{r.host_email}</span> },
            { label: 'NPWP', key: 'host_npwp', render: r => r.host_npwp ? <code style={{ fontSize: 10 }}>{r.host_npwp}</code> : <span style={{ color: '#9a1c1c', fontSize: 10 }}>missing</span> },
            { label: 'Base', key: 'base_amount', align: 'right', numeric: true, render: r => fmtIDR(r.base_amount) },
            { label: 'PPN 11%', key: 'ppn_amount', align: 'right', numeric: true, render: r => fmtIDR(r.ppn_amount) },
            { label: 'Total', key: 'total_amount', align: 'right', numeric: true, render: r => fmtIDR(r.total_amount) },
            { label: 'Status', key: 'status', render: r => <Pill tone={r.status === 'submitted' || r.status === 'approved' ? 'success' : r.status === 'rejected' ? 'danger' : 'warn'}>{r.status.replace(/_/g, ' ')}</Pill> },
            { label: 'Faktur #', key: 'faktur_no', render: r => r.faktur_no ? <code style={{ fontSize: 10 }}>{r.faktur_no}</code> : '—' },
            { label: '', key: 'action', render: r => (r.status === 'pending_djp' || r.status === 'queued') ? (
              <button onClick={() => markSubmitted(r.id)}
                style={{ padding: '5px 12px', background: CC.ink, color: CC.white, border: 'none', borderRadius: 100, fontSize: 11, fontWeight: 600, cursor: 'pointer' }}>Mark submitted</button>
            ) : null },
          ]}
          rows={records}
        />
      )}
    </SectionWrap>
  );
}

// ── Admin · Concierge queue (v123 — full ops view across all hosts) ────
function AdminConcierge() {
  const [filter, setFilter] = useState('open');
  const { data, loading, error, refetch } = useApi('/api/concierge-request?status=' + filter, { tokenType: 'admin' });
  if (loading) return <SectionWrap title="Concierge ops"><Loading msg="Loading concierge queue…" /></SectionWrap>;
  if (error) return <SectionWrap title="Concierge ops"><ErrorState msg={error} onRetry={refetch} /></SectionWrap>;

  const requests = (data && data.requests) || [];
  const overdue = requests.filter(r => r.sla_status === 'resolve_overdue' || r.sla_status === 'ack_overdue');

  async function reassign(id) {
    const newAssignee = window.prompt('Reassign to email:');
    if (!newAssignee) return;
    const token = localStorage.getItem('tlb_admin_token') || '';
    const res = await fetch('/api/concierge-request?action=assign', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token },
      body: JSON.stringify({ id, assigned_to: newAssignee }),
    });
    if (res.ok) refetch();
    else window.alert('Failed to reassign');
  }

  // v141.133 — Phase 4 polish: subtitle + unified KPI grid + filter chips
  const resolvedToday = requests.filter(r => r.status === 'resolved' && r.resolved_at && (new Date(r.resolved_at)).toDateString() === (new Date()).toDateString()).length;
  const urgent = requests.filter(r => r.priority === 'urgent').length;

  return (
    <SectionWrap
      title="Concierge ops"
      subtitle="Every concierge request across all properties. SLA timers go red at 4h ack, 24h resolve. Reassign to the right operator when needed."
      dataNeeds={['concierge']}
      actions={
        <div style={{ display: 'flex', gap: 6 }}>
          {['open', 'closed', 'all'].map(f => (
            <button key={f} onClick={() => setFilter(f)}
              style={{
                padding: '8px 14px', borderRadius: 100, cursor: 'pointer',
                background: filter === f ? CC.ink : 'transparent',
                color: filter === f ? CC.white : CC.deep,
                border: '1px solid ' + (filter === f ? CC.ink : CC.border),
                fontFamily: F_TLB.ui, fontSize: 11.5, fontWeight: 600, letterSpacing: '0.02em',
              }}>{f}</button>
          ))}
        </div>
      }
    >
      <Sans size={10} weight={600} ls={0.14} upper color={CC.mid} style={{ marginBottom: 18 }}>
        {requests.length} {filter} · {urgent} urgent · {overdue.length} SLA breaches · {resolvedToday} resolved today
      </Sans>

      {/* 4-tile unified border KPI grid */}
      <div style={{
        display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 0,
        border: `1px solid ${CC.border}`, borderRadius: 18, overflow: 'hidden', background: CC.white, marginBottom: dashGap(),
      }}>
        {[
          { label: 'Total',           value: requests.length, color: CC.ink },
          { label: 'Urgent',          value: urgent,          color: urgent ? '#9a1c1c' : CC.ink },
          { label: 'Overdue',         value: overdue.length,  color: overdue.length ? '#9a1c1c' : CC.ink },
          { label: 'Resolved today',  value: resolvedToday,   color: '#2d6a4f' },
        ].map((k, i) => (
          <div key={k.label} style={{ padding: '24px 26px', borderRight: i < 3 ? `1px solid ${CC.border}` : 'none', display: 'flex', flexDirection: 'column', gap: 12, minHeight: 130 }}>
            <Sans size={10} weight={600} ls={0.14} upper color={CC.mid}>{k.label}</Sans>
            <div style={{
              fontFamily: F_TLB.ui, fontSize: 36, fontWeight: 500,
              letterSpacing: '-0.028em', color: k.color,
              fontVariantNumeric: 'tabular-nums', lineHeight: 0.98,
              marginTop: 'auto',
            }}>{k.value}</div>
          </div>
        ))}
      </div>
      {requests.length === 0 ? (
        <Empty title="No requests" msg="The full concierge queue across all properties appears here." />
      ) : (
        <Table
          columns={[
            { label: 'Created', key: 'created_at', nowrap: true, render: r => fmtDate(r.created_at) },
            { label: 'Guest · Villa', key: 'guest', render: r => (
              <>
                <div style={{ fontWeight: 500 }}>{r.guest_name || 'Guest'}</div>
                <div style={{ color: CC.mid, fontSize: 11 }}>{r.subject}</div>
              </>
            )},
            { label: 'Category', key: 'category', render: r => <Pill tone="neutral">{r.category}</Pill> },
            { label: 'Priority', key: 'priority', render: r => <Pill tone={r.priority === 'urgent' ? 'danger' : r.priority === 'high' ? 'warn' : 'info'}>{r.priority}</Pill> },
            { label: 'Status', key: 'status', render: r => <Pill tone={statusTone(r.status)}>{r.status}</Pill> },
            { label: 'SLA', key: 'sla_status', render: r => <Pill tone={r.sla_status === 'resolve_overdue' || r.sla_status === 'ack_overdue' ? 'danger' : r.sla_status === 'ack_at_risk' ? 'warn' : 'success'}>{r.sla_status} · {r.age_minutes}m</Pill> },
            { label: 'Assigned', key: 'assigned_to', render: r => <span style={{ color: CC.mid, fontSize: 11 }}>{r.assigned_to || '—'}</span> },
            { label: '', key: 'action', render: r => (
              <button onClick={() => reassign(r.id)}
                style={{ padding: '6px 12px', background: CC.white, color: CC.ink, border: '1px solid ' + CC.border, borderRadius: 100, fontSize: 11, fontWeight: 600, cursor: 'pointer' }}>Reassign</button>
            )},
          ]}
          rows={requests.slice(0, 50)}
        />
      )}
    </SectionWrap>
  );
}

// ── Admin · Damage claim adjudication (v123) ───────────────────────────
function AdminDamageClaims() {
  const [filter, setFilter] = useState('open');
  const [openClaim, setOpenClaim] = useState(null);
  const { data, loading, error, refetch } = useApi('/api/damage-claims?status=' + filter, { tokenType: 'admin' });
  if (loading) return <SectionWrap title="Damage claims"><Loading msg="Loading claims…" /></SectionWrap>;
  if (error) return <SectionWrap title="Damage claims"><ErrorState msg={error} onRetry={refetch} /></SectionWrap>;

  const claims = (data && data.claims) || [];
  const totalClaimed = claims.reduce((s, c) => s + (parseFloat(c.amount_claimed) || 0), 0);

  async function adjudicate(id, decision, amount, note) {
    const token = localStorage.getItem('tlb_admin_token') || '';
    const res = await fetch('/api/damage-claims?action=adjudicate', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token },
      body: JSON.stringify({ id, decision, amount_approved: amount, admin_note: note }),
    });
    if (res.ok) {
      const j = await res.json();
      window.alert(`Adjudication recorded. ${j.refund_amount > 0 ? 'Xendit refund ' + (j.refund_xendit_id ? 'initiated: ' + j.refund_xendit_id : 'pending — payment ref missing') : ''}`);
      refetch();
      setOpenClaim(null);
    } else {
      const j = await res.json().catch(() => ({}));
      window.alert('Failed: ' + (j.error || res.statusText));
    }
  }

  return (
    <SectionWrap title="Damage claims" subtitle={`${claims.length} ${filter} · ${fmtIDR(totalClaimed)} total claimed`}
      actions={
        <div style={{ display: 'flex', gap: 6 }}>
          {['open', 'approved', 'rejected', 'all'].map(f => (
            <button key={f} onClick={() => setFilter(f)}
              style={{ padding: '7px 14px', borderRadius: 100, fontSize: 11, fontWeight: 600,
                border: '1px solid ' + (filter === f ? CC.ink : CC.border),
                background: filter === f ? CC.ink : CC.white,
                color: filter === f ? CC.white : CC.deep, cursor: 'pointer' }}>{f}</button>
          ))}
        </div>
      }>
      {claims.length === 0 ? (
        <Empty title={'No ' + filter + ' claims'} msg="Hosts file claims with photo evidence after checkout. You decide refund amount; Xendit auto-refunds the disputed portion of the deposit." />
      ) : (
        <Table
          columns={[
            { label: 'Filed', key: 'created_at', nowrap: true, render: r => fmtDate(r.created_at) },
            { label: 'Host', key: 'filed_by', render: r => <span style={{ fontSize: 11, color: CC.mid }}>{r.filed_by}</span> },
            { label: 'Guest · Villa', key: 'guest', render: r => (
              <>
                <div style={{ fontWeight: 500 }}>{r.booking?.guest_name || '—'}</div>
                <div style={{ color: CC.mid, fontSize: 11 }}>{r.booking?.property_name || ''}</div>
              </>
            )},
            { label: 'Type', key: 'damage_type', render: r => <Pill tone="warn">{r.damage_type}</Pill> },
            { label: 'Claimed', key: 'amount_claimed', align: 'right', numeric: true, render: r => fmtIDR(r.amount_claimed) },
            { label: 'Deposit', key: 'deposit', align: 'right', numeric: true, render: r => fmtIDR(r.booking?.deposit_amount || 0) },
            { label: 'Status', key: 'status', render: r => <Pill tone={statusTone(r.status)}>{r.status}</Pill> },
            { label: '', key: 'action', render: r => (
              <div style={{ display: 'flex', gap: 4 }}>
                <button onClick={() => setOpenClaim(r)}
                  style={{ padding: '6px 10px', background: CC.ink, color: CC.white, border: 'none', borderRadius: 100, fontSize: 10, fontWeight: 600, cursor: 'pointer' }}>Review</button>
                {(r.status === 'approved' || r.status === 'rejected') && !r.guest_responded_at && (
                  <button onClick={async () => {
                    if (!window.confirm('Email a magic-link to ' + (r.booking?.guest_email || 'guest') + ' so they can respond/dispute?')) return;
                    const token = localStorage.getItem('tlb_admin_token') || '';
                    const res = await fetch('/api/guest-link?action=issue', {
                      method: 'POST',
                      headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token },
                      body: JSON.stringify({ resource: 'damage_claim', resource_id: r.id, ttl_days: 30 }),
                    });
                    const j = await res.json();
                    if (res.ok) window.alert('Magic link sent to ' + j.guest_email + '.\n\nLink: ' + j.url);
                    else window.alert('Failed: ' + (j.error || res.statusText));
                  }}
                    style={{ padding: '6px 10px', background: CC.white, color: CC.ink, border: '1px solid ' + CC.border, borderRadius: 100, fontSize: 10, fontWeight: 600, cursor: 'pointer' }}>Send link</button>
                )}
              </div>
            )},
          ]}
          rows={claims.slice(0, 50)}
        />
      )}
      {openClaim && <AdjudicateModal claim={openClaim} onClose={() => setOpenClaim(null)} onAdjudicate={adjudicate} />}
    </SectionWrap>
  );
}

function AdjudicateModal({ claim, onClose, onAdjudicate }) {
  const [decision, setDecision] = useState('approved');
  const [amount, setAmount] = useState(parseFloat(claim.amount_claimed) || 0);
  const [note, setNote] = useState('');
  return (
    <div style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(20,20,20,.55)', backdropFilter: 'blur(6px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}>
      <Panel style={{ maxWidth: 640, width: '100%', maxHeight: '90vh', overflowY: 'auto' }}>
        <Display size={22} italic weight={400} style={{ marginBottom: 6 }}>Adjudicate damage claim.</Display>
        <Sans size={11} color={CC.mid} style={{ marginBottom: 14 }}>{claim.booking?.property_name} · {claim.booking?.guest_name} · filed by {claim.filed_by}</Sans>

        <div style={{ display: 'grid', gap: 10, marginBottom: 14, padding: 14, background: CC.offWhite, borderRadius: 8 }}>
          <div style={{ display: 'flex', justifyContent: 'space-between' }}>
            <Sans size={12} color={CC.mid}>Damage type</Sans>
            <Sans size={12} weight={500}>{claim.damage_type}</Sans>
          </div>
          <div style={{ display: 'flex', justifyContent: 'space-between' }}>
            <Sans size={12} color={CC.mid}>Amount claimed</Sans>
            <Sans size={12} weight={500}>{fmtIDR(claim.amount_claimed)}</Sans>
          </div>
          <div style={{ display: 'flex', justifyContent: 'space-between' }}>
            <Sans size={12} color={CC.mid}>Deposit on file</Sans>
            <Sans size={12} weight={500}>{fmtIDR(claim.booking?.deposit_amount || 0)}</Sans>
          </div>
          {claim.description && (
            <>
              <div style={{ borderTop: '1px solid ' + CC.border, paddingTop: 8 }}>
                <Sans size={11} weight={600} ls={0.14} upper color={CC.mid} style={{ marginBottom: 4 }}>Description</Sans>
                <Sans size={12} color={CC.deep} style={{ lineHeight: 1.55 }}>{claim.description}</Sans>
              </div>
            </>
          )}
          {Array.isArray(claim.evidence_urls) && claim.evidence_urls.length > 0 && (
            <div>
              <Sans size={11} weight={600} ls={0.14} upper color={CC.mid} style={{ marginBottom: 6 }}>Evidence</Sans>
              <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
                {claim.evidence_urls.map((u, i) => (
                  <a key={i} href={u} target="_blank" rel="noopener" style={{ display: 'block' }}>
                    <img src={u} style={{ width: 110, height: 110, objectFit: 'cover', borderRadius: 6, border: '1px solid ' + CC.border }} alt="" />
                  </a>
                ))}
              </div>
            </div>
          )}
        </div>

        <div style={{ display: 'grid', gap: 10 }}>
          <div style={{ display: 'flex', gap: 8 }}>
            <button onClick={() => setDecision('approved')}
              style={{ flex: 1, padding: '10px', border: '1px solid ' + (decision === 'approved' ? '#1f6b3a' : CC.border), background: decision === 'approved' ? '#1f6b3a' : CC.white, color: decision === 'approved' ? CC.white : CC.ink, borderRadius: 8, cursor: 'pointer', fontWeight: 600, fontSize: 12 }}>Approve (deduct from deposit)</button>
            <button onClick={() => setDecision('rejected')}
              style={{ flex: 1, padding: '10px', border: '1px solid ' + (decision === 'rejected' ? '#9a1c1c' : CC.border), background: decision === 'rejected' ? '#9a1c1c' : CC.white, color: decision === 'rejected' ? CC.white : CC.ink, borderRadius: 8, cursor: 'pointer', fontWeight: 600, fontSize: 12 }}>Reject (refund full deposit)</button>
          </div>
          {decision === 'approved' && (
            <label>
              <Sans size={10} weight={700} ls={0.16} upper color={CC.mid} style={{ marginBottom: 4 }}>Amount to approve (cap = claimed)</Sans>
              <input type="number" min="0" max={claim.amount_claimed} value={amount} onChange={e => setAmount(parseFloat(e.target.value) || 0)}
                style={{ width: '100%', padding: '10px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 13, fontFamily: 'inherit' }} />
              <Sans size={10} color={CC.mid} style={{ marginTop: 4 }}>
                Guest will be refunded: {fmtIDR(Math.max(0, (claim.booking?.deposit_amount || 0) - amount))}
              </Sans>
            </label>
          )}
          <label>
            <Sans size={10} weight={700} ls={0.16} upper color={CC.mid} style={{ marginBottom: 4 }}>Admin note (visible to guest + host)</Sans>
            <textarea value={note} onChange={e => setNote(e.target.value)} rows={3}
              style={{ width: '100%', padding: '10px 12px', border: '1px solid ' + CC.border, borderRadius: 8, fontSize: 13, fontFamily: 'inherit', resize: 'vertical' }}
              placeholder="Reason for decision, breakdown of approved amount, etc." />
          </label>
          <div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
            <button onClick={onClose} style={{ padding: '10px 20px', border: '1px solid ' + CC.border, background: CC.white, color: CC.deep, borderRadius: 100, fontSize: 12, fontWeight: 600, cursor: 'pointer' }}>Cancel</button>
            <button onClick={() => onAdjudicate(claim.id, decision, amount, note)}
              style={{ flex: 1, padding: '10px 20px', border: 'none', background: CC.ink, color: CC.white, borderRadius: 100, fontSize: 12, fontWeight: 600, cursor: 'pointer' }}>Submit decision</button>
          </div>
        </div>
      </Panel>
    </div>
  );
}

// v122 — AdminPulse: operational health snapshot. Stuck bookings,
// enquiry SLA tracker, escalation actions. Single panel showing what
// needs admin attention RIGHT NOW. Pulls /api/admin?action=bookings +
// /api/enquiries; computes "stuck" = confirmed > 4h without payment,
// "overdue" = pending enquiries > 60 min, "review" = recent reviews
// awaiting moderation.
function AdminPulse() {
  const bookingsApi  = useApi('/api/admin?action=bookings', { tokenType: 'admin' });
  const enquiriesApi = useApi('/api/enquiries?status=all',  { tokenType: 'admin' });
  const overviewApi  = useApi('/api/admin?action=overview', { tokenType: 'admin' });
  // v141.7 — pricing health: villas live but with no seasonal_rates
  // populated. After the Tropical Door rate-sheet seed (20 villas) ran,
  // the remaining 28 should still surface here so the admin can chase
  // the host for the empty cells / villas-not-in-sheet. The query is
  // server-side via PostgREST `seasonal_rates=is.null` so it scales
  // with the fleet size and doesn't pull every property row to the
  // client. Public listing scoping (status=eq.live) prevents drafts /
  // suspended villas from inflating the count.
  const pricingHealthApi = useApi(
    '/api/properties?status=eq.live&seasonal_rates=is.null&select=id,name,host_email,nightly_rate,location&limit=200',
    { tokenType: 'admin' }
  );

  const loading = bookingsApi.loading || enquiriesApi.loading;
  const error = bookingsApi.error;
  if (loading) return <SectionWrap title="Pulse"><Loading msg="Reading platform pulse…" /></SectionWrap>;
  if (error) return <SectionWrap title="Pulse"><ErrorState msg={error} onRetry={bookingsApi.refetch} /></SectionWrap>;

  const bookings = (bookingsApi.data && bookingsApi.data.data && bookingsApi.data.data.bookings)
    || (bookingsApi.data && bookingsApi.data.bookings) || [];
  const enquiries = (enquiriesApi.data && enquiriesApi.data.enquiries) || [];
  const overview = (overviewApi.data && overviewApi.data.data) || (overviewApi.data || {});

  // Stuck = confirmed > 4h, no payment yet
  const stuckBookings = bookings.filter(b => {
    if (b.status !== 'confirmed') return false;
    if ((b.payment_status || '').toLowerCase() === 'paid') return false;
    const age = b.created_at ? (Date.now() - new Date(b.created_at).getTime()) : 0;
    return age >= 4 * 3600 * 1000;
  }).sort((a, b) => new Date(a.created_at) - new Date(b.created_at));   // oldest first

  // Pending enquiries > 60 min = SLA at risk
  const overdueEnquiries = enquiries.filter(e => {
    const status = (e.status || '').toLowerCase();
    if (!(status === 'new' || status === 'pending')) return false;
    const age = e.created_at ? (Date.now() - new Date(e.created_at).getTime()) : 0;
    return age >= 60 * 60 * 1000;
  }).sort((a, b) => new Date(a.created_at) - new Date(b.created_at));

  // Bookings checking in within next 7 days (for prep monitoring)
  const upcomingCheckins = bookings.filter(b => {
    if (b.status !== 'confirmed' && b.status !== 'paid') return false;
    if (!b.check_in) return false;
    const ms = new Date(b.check_in + 'T00:00:00').getTime() - Date.now();
    return ms >= 0 && ms <= 7 * 86400 * 1000;
  }).sort((a, b) => new Date(a.check_in) - new Date(b.check_in));

  function ageLabel(ts) {
    if (!ts) return '—';
    const ms = Date.now() - new Date(ts).getTime();
    const h = ms / 3600000;
    if (h < 1) return Math.round(h * 60) + 'min';
    if (h < 24) return Math.round(h) + 'h';
    return Math.round(h / 24) + 'd';
  }
  function ageTone(ts, warnAtH, dangerAtH) {
    if (!ts) return 'neutral';
    const h = (Date.now() - new Date(ts).getTime()) / 3600000;
    if (h >= dangerAtH) return 'danger';
    if (h >= warnAtH) return 'warn';
    return 'neutral';
  }
  async function escalate(type, id) {
    // v123 — Wired to /api/escalate. Logs audit row, updates target,
    // sends email to host + admin, drops system message in thread.
    const reason = window.prompt('Escalation reason (e.g. "Host has not responded to guest in 4h, blocking booking confirmation"):');
    if (!reason) return;
    const note = window.prompt('Optional note for host (instructions, context):', '') || '';
    try {
      const res = await fetch('/api/escalate', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + (localStorage.getItem('tlb_admin_token') || '') },
        body: JSON.stringify({ target_type: type, target_id: id, reason, note, notify: ['host', 'admin'] }),
      });
      if (res.ok) {
        const j = await res.json();
        bookingsApi.refetch();
        enquiriesApi.refetch();
        window.alert('Escalation logged.\n\nNotified: ' + (j.notified || []).map(n => n.email + (n.ok ? ' ✓' : ' ✗')).join('\n'));
      } else {
        const j = await res.json().catch(() => ({}));
        window.alert('Escalation failed: ' + (j.error || res.statusText));
      }
    } catch (e) {
      window.alert('Network error: ' + e.message);
    }
  }

  return (
    <SectionWrap title="Pulse" subtitle="Operational health · stuck bookings, SLA alerts, prep queue"
      actions={<button onClick={() => { bookingsApi.refetch(); enquiriesApi.refetch(); overviewApi.refetch(); }} style={ghostBtn}>Refresh</button>}>
      {/* KPI tiles */}
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: dashGap(), marginBottom: dashGap() }}>
        <Panel style={{ padding: '14px 16px', borderLeft: stuckBookings.length > 0 ? '3px solid #9a1c1c' : '3px solid ' + CC.border }}>
          <Sans size={10} weight={700} ls={0.18} upper color={CC.mid}>Stuck Bookings</Sans>
          <Display size={28} weight={400} style={{ marginTop: 8, color: stuckBookings.length > 0 ? '#9a1c1c' : CC.ink }}>{stuckBookings.length}</Display>
          <Sans size={11} color={CC.mid} style={{ marginTop: 4 }}>confirmed &gt; 4h, no payment</Sans>
        </Panel>
        <Panel style={{ padding: '14px 16px', borderLeft: overdueEnquiries.length > 0 ? '3px solid #d97706' : '3px solid ' + CC.border }}>
          <Sans size={10} weight={700} ls={0.18} upper color={CC.mid}>Overdue Enquiries</Sans>
          <Display size={28} weight={400} style={{ marginTop: 8, color: overdueEnquiries.length > 0 ? '#d97706' : CC.ink }}>{overdueEnquiries.length}</Display>
          <Sans size={11} color={CC.mid} style={{ marginTop: 4 }}>pending &gt; 60 min</Sans>
        </Panel>
        <Panel style={{ padding: '14px 16px' }}>
          <Sans size={10} weight={700} ls={0.18} upper color={CC.mid}>Check-ins (7d)</Sans>
          <Display size={28} weight={400} style={{ marginTop: 8 }}>{upcomingCheckins.length}</Display>
          <Sans size={11} color={CC.mid} style={{ marginTop: 4 }}>arriving this week</Sans>
        </Panel>
        <Panel style={{ padding: '14px 16px' }}>
          <Sans size={10} weight={700} ls={0.18} upper color={CC.mid}>Active Bookings</Sans>
          <Display size={28} weight={400} style={{ marginTop: 8 }}>{overview.activeBookings || overview.totalBookings || 0}</Display>
          <Sans size={11} color={CC.mid} style={{ marginTop: 4 }}>currently in-stay or upcoming</Sans>
        </Panel>
      </div>

      {/* v141.7 — Pricing health card. Surfaces every live villa whose
          seasonal_rates is null so the admin can chase the host for a
          rate sheet (or fill via host dashboard). For the Tropical Door
          rollout this catches the 14 villas not in their sheet + the 14
          villas in-sheet-but-empty-cells; the 20 villas with full data
          fall out automatically once the seeder writes seasonal_rates.
          Click any name to open admin-classic ?tab=properties for that
          villa's edit row. */}
      {(() => {
        const pricingMissing = (pricingHealthApi.data && pricingHealthApi.data.properties) || [];
        if (pricingHealthApi.loading || pricingMissing.length === 0) return null;
        // Group by host so the admin can see which host owes a rate sheet
        const byHost = {};
        for (const p of pricingMissing) {
          const h = (p.host_email || '—').toLowerCase();
          (byHost[h] ||= []).push(p);
        }
        const hostRows = Object.entries(byHost).sort((a, b) => b[1].length - a[1].length);
        return (
          <Panel style={{ marginBottom: dashGap(), borderLeft: '3px solid #d97706' }}>
            <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, marginBottom: 14 }}>
              <div>
                <Display size={18} italic weight={400}>Pricing health.</Display>
                <Sans size={12} color={CC.mid} style={{ marginTop: 4 }}>
                  <strong style={{ color: '#d97706' }}>{pricingMissing.length}</strong> live villa{pricingMissing.length === 1 ? '' : 's'} have no seasonal_rates set — search/villa pages fall back to the imported nightly_rate (often the v141.4 "From" placeholder) for these. Resolve by uploading the host's rate sheet or filling per-villa via host dashboard.
                </Sans>
              </div>
              <button onClick={() => pricingHealthApi.refetch()} style={ghostBtn}>Refresh</button>
            </div>
            <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
              {hostRows.slice(0, 5).map(([host, villas]) => (
                <div key={host} style={{ padding: '10px 14px', background: CC.offWhite, borderRadius: 8, borderLeft: '3px solid #d97706' }}>
                  <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
                    <Sans size={13} weight={500} color={CC.ink}>{host}</Sans>
                    <Sans size={11} weight={600} color="#d97706">{villas.length} missing</Sans>
                  </div>
                  <Sans size={11} color={CC.mid} style={{ marginTop: 6, lineHeight: 1.55 }}>
                    {villas.slice(0, 6).map(v => v.name).join(' · ')}
                    {villas.length > 6 ? ` · +${villas.length - 6} more` : ''}
                  </Sans>
                </div>
              ))}
              {hostRows.length > 5 && (
                <Sans size={11} color={CC.mid} style={{ paddingLeft: 14 }}>
                  +{hostRows.length - 5} more host{hostRows.length - 5 === 1 ? '' : 's'} with incomplete pricing
                </Sans>
              )}
            </div>
            <div style={{ display: 'flex', gap: 8, marginTop: 14 }}>
              <button onClick={() => window.dispatchEvent(new CustomEvent('tlb-admin-tab', { detail: 'rateparity' }))}
                 style={{ ...ghostBtn, display: 'inline-flex', alignItems: 'center', cursor: 'pointer' }}>
                Open rate parity ↗
              </button>
              <a href="https://github.com/Leisure-Saas/theluxurybali/blob/main/data/tropical-door-pricing.json" target="_blank"
                 style={{ ...ghostBtn, textDecoration: 'none', display: 'inline-flex', alignItems: 'center' }}>
                Last rate-sheet snapshot ↗
              </a>
            </div>
          </Panel>
        );
      })()}

      {/* Stuck bookings panel */}
      <Panel style={{ marginBottom: dashGap() }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
          <Display size={18} italic weight={400}>Stuck bookings.</Display>
          {stuckBookings.length > 0 && <Pill tone="danger">{stuckBookings.length} need action</Pill>}
        </div>
        {stuckBookings.length === 0 ? (
          <Sans size={12} color={CC.mid}>All confirmed bookings have payment moving. Nothing stuck.</Sans>
        ) : (
          <Table
            columns={[
              { label: 'Code', key: 'id', render: r => <code style={{ color: CC.mid, fontSize: 11 }}>{(r.confirmation_code || r.id || '').slice(0, 8)}</code> },
              { label: 'Guest', key: 'guest_name', render: r => <strong style={{ color: CC.ink, fontWeight: 500 }}>{r.guest_name || r.guest_email || '—'}</strong> },
              { label: 'Villa', key: 'property_name', render: r => r.property_name || '—' },
              { label: 'Host', key: 'host_email', render: r => <span style={{ color: CC.mid, fontSize: 11 }}>{r.host_email || '—'}</span> },
              { label: 'Total', key: 'total_price', align: 'right', numeric: true, render: r => fmtIDR(parseFloat(r.total_price) || 0) },
              { label: 'Stuck for', key: 'created_at', render: r => <Pill tone={ageTone(r.created_at, 4, 24)}>{ageLabel(r.created_at)}</Pill> },
              { label: '', key: 'action', render: r => (
                <button onClick={() => escalate('booking', r.id)}
                  style={{ padding: '6px 14px', background: CC.ink, color: CC.white, border: 'none', borderRadius: 100, fontSize: 11, fontWeight: 600, cursor: 'pointer', letterSpacing: '0.04em' }}>Escalate</button>
              )},
            ]}
            rows={stuckBookings.slice(0, 20)}
            empty="No stuck bookings."
          />
        )}
      </Panel>

      {/* Overdue enquiries panel */}
      <Panel style={{ marginBottom: dashGap() }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
          <Display size={18} italic weight={400}>Overdue enquiries.</Display>
          {overdueEnquiries.length > 0 && <Pill tone="warn">{overdueEnquiries.length} need response</Pill>}
        </div>
        {overdueEnquiries.length === 0 ? (
          <Sans size={12} color={CC.mid}>All enquiries are within the 60-minute SLA window. Hosts are on it.</Sans>
        ) : (
          <Table
            columns={[
              { label: 'From', key: 'guest_name', render: r => <strong style={{ color: CC.ink, fontWeight: 500 }}>{r.guest_name || r.name || r.email || '—'}</strong> },
              { label: 'Villa', key: 'property_name', render: r => r.property_name || r.villa || '—' },
              { label: 'Host', key: 'host_email', render: r => <span style={{ color: CC.mid, fontSize: 11 }}>{r.host_email || '—'}</span> },
              { label: 'Submitted', key: 'created_at', render: r => fmtDateLong(r.created_at) },
              { label: 'Pending for', key: 'sla', render: r => <Pill tone={ageTone(r.created_at, 1, 4)}>{ageLabel(r.created_at)}</Pill> },
              { label: '', key: 'action', render: r => (
                <button onClick={() => escalate('enquiry', r.id)}
                  style={{ padding: '6px 14px', background: CC.ink, color: CC.white, border: 'none', borderRadius: 100, fontSize: 11, fontWeight: 600, cursor: 'pointer', letterSpacing: '0.04em' }}>Nudge host</button>
              )},
            ]}
            rows={overdueEnquiries.slice(0, 20)}
            empty="No overdue enquiries."
          />
        )}
      </Panel>

      {/* Upcoming check-ins panel */}
      <Panel>
        <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
          <Display size={18} italic weight={400}>Upcoming check-ins.</Display>
          <Pill tone="info">Next 7 days</Pill>
        </div>
        {upcomingCheckins.length === 0 ? (
          <Sans size={12} color={CC.mid}>No check-ins scheduled in the next 7 days.</Sans>
        ) : (
          <Table
            columns={[
              { label: 'Date', key: 'check_in', render: r => <strong>{fmtDate(r.check_in)}</strong> },
              { label: 'Guest', key: 'guest_name', render: r => r.guest_name || r.guest_email || '—' },
              { label: 'Villa', key: 'property_name', render: r => r.property_name || '—' },
              { label: 'Host', key: 'host_email', render: r => <span style={{ color: CC.mid, fontSize: 11 }}>{r.host_email || '—'}</span> },
              { label: 'Nights', key: 'nights', align: 'right', numeric: true },
              { label: 'Payment', key: 'payment_status', render: r => {
                const ps = r.payment_status || (r.status === 'paid' ? 'paid' : 'pending');
                return <Pill tone={statusTone(ps)}>{ps}</Pill>;
              }},
            ]}
            rows={upcomingCheckins.slice(0, 15)}
            empty="No upcoming check-ins."
          />
        )}
      </Panel>
    </SectionWrap>
  );
}

// ── Expose to other JSX modules via window so renderSection() can pick them up
// 100% native coverage as of v80 — every iframe section now has a React port.
window.TLB_NATIVE_SECTIONS = {
  admin: {
    // Operations
    pulse:           AdminPulse,
    hosts:           AdminHosts,
    properties:      AdminProperties,
    hostops:         AdminHostOps,
    bookings:        AdminBookings,
    inbox:           AdminInbox,            // v124 — threaded chat with admin mediation
    concierge:       AdminConcierge,        // v123 — concierge ops queue
    disputes:        AdminDamageClaims,     // v123 — damage claim adjudication
    sitemanager:     AdminSiteManager,
    rateparity:      AdminRateParity,        // v141.114 — per-villa rate vs source URL probe
    // v141.232 — Promoted from Site Manager cards to top-level Main
    // group in dashboards-s4-admin.jsx navItems. Operator's daily-ops.
    inquiries:       AdminInquiries,         // v141.220 — inquiry triage + auto-spam classifier
    traffic:         AdminTraffic,           // v141.221+222 — GA4 + GSC traffic panel
    weekly_digest:   AdminWeeklyDigest,      // v141.244 — Aetheria Phase 2: weekly intelligence
    alerts:          AdminAlerts,            // v141.255 — Aetheria Phase 4: anomaly alerts
    content_opps:    AdminContentOpportunities, // v141.257 — Aetheria Phase 5: content opportunities
    attribution:     AdminAttribution,       // v141.258 — Aetheria Phase 5: ROI per channel
    blog:            AdminBlogPosts,         // v141.263 — Aetheria Phase 5: blog system
    // Finance · Partners
    finance:         AdminFinance,
    payouts:         AdminPayouts,           // v125 — payout override controls
    efaktur:         AdminEFaktur,           // v126 — e-Faktur DJP scaffold
    seo:             AdminSEO,               // v129 — sitewide SEO control panel
    partners:        AdminPartners,
    proposals:       AdminProposals,
    pcontent:        AdminPersonalisedContent,
    pmetrics:        AdminProductMetrics,
    // Marketing · Brand
    marketingai:     AdminMarketingAI,
    otahub:          AdminOTAHub,
    otachannels:     AdminOTAChannels,       // v126 — iCal channel mappings
    brand:           AdminBrandCenter,
    virallab:        AdminViralLab,
    socialstrategy:  AdminSocialStrategy,
    creativeai:      AdminCreativeAI,
  },
  host: {
    // Operations
    analytics:    HostAnalytics,
    properties:   HostProperties,
    availability: HostAvailability,
    bookings:     HostBookings,
    welcome:      HostWelcomePackets,        // v126 — welcome packet builder
    checkin:      HostCheckIn,                // v128 — driver dispatch + photo proof
    reviews:      HostReviews,
    pricing:      HostPricing,
    // Service
    disputes:     HostDisputes,
    tasks:        HostTasks,
    concierge:    HostConcierge,
    // Marketing
    // v129 — SEO moved to admin (sitewide SEO is admin's responsibility, not host's)
    site:         HostSite,
    stories:      HostStories,
    // Settings
    settings:     HostSettings,
  },
};
