// Helper — builds a 5-step timeline from a real booking row. Maps the // booking's status field to the canonical lifecycle (Created → Confirmed // → Paid → Checked-in → Reviewed). Used by AdminTimeline below to // replace the static D.admin.timeline mock with real booking flow. function buildTimelineFromBooking(b) { const status = (b.status || '').toLowerCase(); const created = b.created_at; const confirmed = (b.confirmed_at || (status !== 'pending' && status !== 'cancelled' ? created : null)); const paid = (b.paid_at || (status === 'paid' || status === 'confirmed' || status === 'completed' ? created : null)); const checkedIn = (b.check_in_completed_at || (status === 'completed' ? b.check_in : null)); const reviewed = b.reviewed_at || null; // v132 — Bug D-01: cancelled bookings used to retain "Payment · Pending" // as ACTIVE. They now exit the funnel — no node is active, the cancelled // node is shown DONE with a distinct flag the renderer styles in red. const isCancelled = status === 'cancelled'; const cancelledAt = b.cancelled_at || (isCancelled ? (b.updated_at || created) : null); function fmtTs(iso) { if (!iso) return '—'; try { return new Date(iso).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) + ' · ' + new Date(iso).toLocaleDateString('en-GB', { day: '2-digit', month: 'short' }); } catch { return '—'; } } function rel(iso) { if (!iso) return 'pending'; const d = (Date.now() - new Date(iso).getTime()) / 1000; if (d < 60) return 'just now'; if (d < 3600) return Math.round(d/60) + ' min ago'; if (d < 86400) return Math.round(d/3600) + ' hr ago'; return Math.round(d/86400) + ' days ago'; } return { booking: (b.id || '').slice(0, 8), villa: b.property_name || (b.property && b.property.name) || 'Villa', guest: b.guest_name || b.guest_email || 'Guest', host: b.host_email || 'Host', cancelled: isCancelled, cancelledAt, states: [ { id: 'created', label: 'Created', ts: fmtTs(created), rel: rel(created), median: 0, done: !!created, channel: 'system', who: 'guest', detail: 'Booking created via /api/bookings' }, // When cancelled, suppress all `active` flags — booking exited the funnel. { id: 'confirmed', label: 'Host confirm', ts: fmtTs(confirmed), rel: rel(confirmed), median: 240, done: !!confirmed, active: !isCancelled && !confirmed && !!created, channel: 'whatsapp', who: 'host', detail: 'Concierge nudge sent if >4h pending' }, { id: 'paid', label: 'Payment', ts: fmtTs(paid), rel: rel(paid), median: 60, done: !!paid, active: !isCancelled && !paid && !!confirmed, channel: 'email', who: 'guest', detail: 'Xendit invoice link emailed on confirm' }, { id: 'checkedin', label: 'Check-in', ts: fmtTs(checkedIn), rel: rel(checkedIn), median: 1440, done: !!checkedIn, active: !isCancelled && !checkedIn && !!paid, channel: 'whatsapp', who: 'host', detail: 'Driver dispatched, key handover photo expected' }, { id: 'reviewed', label: 'Reviewed', ts: fmtTs(reviewed), rel: rel(reviewed), median: 4320, done: !!reviewed, active: !isCancelled && !reviewed && !!checkedIn, channel: 'email', who: 'guest', detail: 'Review request 24h post-checkout' }, ], }; } // Section 4 — Admin Command Center (Pulse / Timeline / Stuck+Anomaly) // // Sidebar layout mirrors the operational /admin page exactly: an 18- // section nav rail on the left, content on the right. Sections we have // native React views for render those (Pulse, Booking, Stuck, etc.). // Sections we haven't built yet render the operational page in an // iframe with ?embed=1 — the operational page hides its own sidebar // and topbar so it slots seamlessly into the new shell. Same APIs, // same actions, same buttons — just inside the editorial preview. function AdminCommandCenter() { // v132 — Bug D-07: same deep-link routing as HostDashboard. /admin/:section // routes to here with ?section=X. const _initialSection = (function() { try { const p = new URLSearchParams(window.location.search); const s = p.get('section'); if (s) return s; const m = window.location.pathname.match(/^\/admin\/([a-z-]+)/i); if (m && m[1]) return m[1]; } catch (e) {} return 'pulse'; })(); const [section, setSection] = React.useState(_initialSection); // Re-render when live-data arrives const [, bump] = React.useState(0); React.useEffect(() => { const reload = () => bump(t => t + 1); window.addEventListener('tlb-live-loaded', reload); return () => window.removeEventListener('tlb-live-loaded', reload); }, []); // Listen for cross-section drill-ins (Pulse KPI tile click → switch section). React.useEffect(() => { const onJump = (e) => { if (e && e.detail) setSection(e.detail); }; window.addEventListener('tlb-admin-tab', onJump); return () => window.removeEventListener('tlb-admin-tab', onJump); }, []); // Sidebar items — full feature parity with operational /admin sidebar. // mode='native' → render React component; mode='iframe' → embed // operational page at /admin-classic?tab={key}&embed=1. // v141.70 — Classic-dashboard retirement plan. // Goal: every classic feature reachable from inside /dashboards/admin // with no outbound visit to /admin-classic. Items whose native React // counterparts only ship the read-side of the feature get switched to // `mode: 'iframe'` so the full classic write-flow is available inside // this same shell. The classic page itself auto-redirects direct // visits back to /dashboards/admin (preserving ?embed=1 for iframes). // // Switched native → iframe in this revision: // • partners (was read-only; classic has add/edit modal) // • proposals (was read-only; classic has Generate-Proposal flow) // • pmetrics (was thin; classic has per-partner monthly log modal) // • socialstrategy (was partial; classic has DM templates + 30-day calendar) // • creativeai (was a single textbox; classic has 8-preset workflow) // // Kept native: pulse, timeline, stuck, hosts, properties, hostops, // bookings, inbox, concierge, disputes, health, finance, payouts, // efaktur, seo, otahub, otachannels, marketing, orchestration // (all functionally complete or expanded beyond classic already). const navItems = [ // Main — daily-ops surface; operator's morning-check items live here. { key: 'pulse', label: 'Overview', group: 'Main', icon: 'activity', mode: 'native' }, { key: 'aetheria', label: 'Aetheria · co-pilot',group: 'Main', icon: 'sparkles', mode: 'native' }, // v141.232 — Phase 0 + Phase 1 surfaces promoted to top-level Main // (were buried inside Site Manager cards). The operator opens these // every morning so they need to be one click from the dashboard // landing, not nested behind another nav. { key: 'inquiries', label: 'Inquiries', group: 'Main', icon: 'mail', mode: 'native' }, { key: 'traffic', label: 'Traffic · GA4 + GSC',group: 'Main', icon: 'barChart', mode: 'native' }, // v141.244 — Aetheria Phase 2: weekly intelligence digest. Lives // in Main because the operator reads it every Monday morning // alongside Traffic + Inquiries. { key: 'weekly_digest', label: 'Weekly digest', group: 'Main', icon: 'sparkles', mode: 'native' }, // v141.255 — Aetheria Phase 4: anomaly alerts. Real-time // observation layer — operator opens this when the dashboard // hints something changed. { key: 'alerts', label: 'Alerts', group: 'Main', icon: 'alert', mode: 'native' }, // v141.257 — Aetheria Phase 5: content opportunities. Weekly // AI-clustered blog post backlog from GSC opportunity queries. { key: 'content_opps', label: 'Content backlog', group: 'Main', icon: 'fileText', mode: 'native' }, // v141.258 — Aetheria Phase 5: booking attribution ROI per channel. { key: 'attribution', label: 'Attribution', group: 'Main', icon: 'dollar', mode: 'native' }, // v141.263 — Aetheria Phase 5: blog system. Live at /blog/. { key: 'blog', label: 'Journal', group: 'Main', icon: 'fileText', mode: 'native' }, { key: 'timeline', label: 'Booking timeline', group: 'Main', icon: 'calendar', mode: 'native' }, { key: 'stuck', label: 'Stuck + anomalies', group: 'Main', icon: 'alert', mode: 'native' }, // Operations { key: 'hosts', label: 'Hosts', group: 'Operations', icon: 'users', mode: 'native' }, { key: 'properties', label: 'Properties', group: 'Operations', icon: 'building', mode: 'native' }, { key: 'hostops', label: 'Host onboarding', group: 'Operations', icon: 'checkCircle', mode: 'native' }, { key: 'bookings', label: 'Bookings · all', group: 'Operations', icon: 'list', mode: 'native' }, { key: 'inbox', label: 'Inbox · chat', group: 'Operations', icon: 'mail', mode: 'native' }, { key: 'concierge', label: 'Concierge ops', group: 'Operations', icon: 'bell', mode: 'native' }, { key: 'disputes', label: 'Damage claims', group: 'Operations', icon: 'shield', mode: 'native' }, { key: 'sitemanager', label: 'Site manager', group: 'Operations', icon: 'layout', mode: 'iframe' }, { key: 'rateparity', label: 'Rate parity', group: 'Operations', icon: 'dollar', mode: 'native' }, // v141.322 — Rate Accuracy Audit dashboard (per-villa severity, bulk re-probe) { key: 'rateaudit', label: 'Rate audit', group: 'Operations', icon: 'shield', mode: 'iframe', externalUrl: '/admin-rate-audit' }, { key: 'health', label: 'Villa Health', group: 'Operations', icon: 'activity', mode: 'native' }, // Finance & Partners { key: 'finance', label: 'Finance', group: 'Finance · Partners', icon: 'dollar', mode: 'native' }, { key: 'payouts', label: 'Payouts override', group: 'Finance · Partners', icon: 'send', mode: 'native' }, { key: 'efaktur', label: 'e-Faktur', group: 'Finance · Partners', icon: 'fileText', mode: 'native' }, // v129 — SEO control panel (moved from host). Sitewide indexability, // per-villa scoring, meta overrides, domain migration tools. { key: 'seo', label: 'SEO control', group: 'Marketing · Brand', icon: 'search', mode: 'native' }, { key: 'partners', label: 'Partners', group: 'Finance · Partners', icon: 'link', mode: 'iframe' }, { key: 'proposals', label: 'Proposals', group: 'Finance · Partners', icon: 'fileText', mode: 'iframe' }, { key: 'pcontent', label: 'Personalised content', group: 'Finance · Partners', icon: 'sparkles', mode: 'iframe' }, { key: 'pmetrics', label: 'Product metrics', group: 'Finance · Partners', icon: 'barChart', mode: 'iframe' }, // Marketing & Brand { key: 'marketing', label: 'Marketing & Sales', group: 'Marketing · Brand', icon: 'target', mode: 'native' }, { key: 'orchestration', label: 'Orchestration', group: 'Marketing · Brand', icon: 'share', mode: 'native' }, { key: 'marketingai', label: 'Marketing AI', group: 'Marketing · Brand', icon: 'cpu', mode: 'iframe' }, // v141.138 — Renamed per operator feedback. TLB doesn't actually // integrate with OTAs (Airbnb/Booking/Expedia); these pages describe // partnerships with channel-partner HOST agencies (TLN, Nakula, TTD, // Elite Havens) who manage their own villa inventory. Calling them // "OTA" was misleading both internally and to hosts reading the UI. { key: 'otahub', label: 'Host Hub', group: 'Marketing · Brand', icon: 'globe', mode: 'native' }, { key: 'otachannels', label: 'Host Channels', group: 'Marketing · Brand', icon: 'link', mode: 'native' }, { key: 'brand', label: 'Brand', group: 'Marketing · Brand', icon: 'palette', mode: 'iframe' }, { key: 'virallab', label: 'Viral lab', group: 'Marketing · Brand', icon: 'trending', mode: 'iframe' }, { key: 'socialstrategy', label: 'Social strategy', group: 'Marketing · Brand', icon: 'message', mode: 'iframe' }, { key: 'creativeai', label: 'Creative AI', group: 'Marketing · Brand', icon: 'wand', mode: 'iframe' }, ]; const liveStatus = window.TLB_LIVE_STATUS && window.TLB_LIVE_STATUS.admin; const needsAuth = liveStatus && liveStatus.needsAuth && !liveStatus.live; const activeItem = navItems.find(n => n.key === section) || navItems[0]; // Render the right-hand content for the active section. // Priority: 1) native sections from dashboards-native-sections.jsx // 2) built-in admin natives (pulse/timeline/stuck/health/etc) // 3) iframe fallback for sections not yet ported function renderSection() { // Native sections registered via dashboards-native-sections.jsx — // hosts/properties/bookings/finance/sitemanager all wired here. const NATIVE = (window.TLB_NATIVE_SECTIONS && window.TLB_NATIVE_SECTIONS.admin) || {}; if (NATIVE[section]) { const Cmp = NATIVE[section]; return ; } if (activeItem.mode === 'iframe') { // v141.322 — items can opt out of /admin-classic embedding via // `externalUrl` (e.g., rate-audit lives at its own /admin-rate-audit // dashboard, separate from the classic React app). const src = activeItem.externalUrl ? activeItem.externalUrl : ('/admin-classic?tab=' + activeItem.key + '&embed=1'); return ; } switch (section) { case 'pulse': return ; case 'aetheria': return ; case 'timeline': return ; case 'stuck': return ; case 'marketing': return ; case 'orchestration': return ; case 'health': return ; default: return ; } } return (
Command Center Air-traffic control.
} />
{/* v141.167 re-skin Stage 5 — global workspace Topbar (delivered redesign). Self-contained, sticky, above every section. */} {/* v141.137 — Shell page-header removed. Every section since Phase 2 renders its OWN editorial header (eyebrow + Display title + subtitle + action CTAs) to match the v141.123 mockup. The shell-level italic title here was duplicating the section title (visible as "Stuck + anomalies." appearing twice). Sections that don't render their own header (iframe-embedded classic pages) get their title from the iframe content. Live status pill kept as a thin sticky strip. */}
{activeItem.group || 'Admin'}
{needsAuth && (
SIGN IN TO UNLOCK LIVE ADMIN DATA Public sources (152 villas · live offers · system health) are already real. Pulse tiles, funnel, stream, stuck-list, and the embedded operational sections need an admin session.
{/* v141.72 — Was `/admin` which rewrites back to this same dashboard and never shows a sign-in form. Now points directly at /login (the only auth form in the codebase — admin-classic itself only shows a stub that links here). */}
)} {/* v141.137 — Wrap each section in an ErrorBoundary keyed by section name. If a section's render throws (e.g. missing primitive, undefined data ref), users see an actionable "section error" panel instead of a blank screen, and other sections remain accessible via sidebar nav. The key prop forces a fresh boundary on section change. */} {renderSection()}
); } // ─── ADMIN · ALL SECTIONS — feature-parity grid with operational ── function AdminAllSections() { const sections = D.admin.sections || []; return (
FEATURE PARITY · 18 OPERATIONAL SECTIONS The full operations atlas. The six tabs above are the air-traffic-control surface. The grid below opens every operational page at /admin, all already wearing the editorial design.
{sections.map(s => (
{ e.currentTarget.style.transform = 'translateY(-2px)'; e.currentTarget.style.boxShadow = '0 12px 36px rgba(20,20,20,0.06)'; e.currentTarget.style.borderColor = CC.deep; }} onMouseLeave={e => { e.currentTarget.style.transform = ''; e.currentTarget.style.boxShadow = ''; e.currentTarget.style.borderColor = CC.border; }} onClick={() => window.TLB_NAV && TLB_NAV.go(s.url)}>
{s.num} Open →
{s.name}. {s.desc}
))}
); } // ── Aetheria markdown renderer (v141.243) ─────────────────────────── // Aetheria emits markdown — **bold**, bullet lists, numbered lists, // `inline code`, paragraphs. Pre-v141.243 the UI rendered raw text // (whitespace: pre-wrap) so the operator saw literal asterisks. This // renderer is intentionally tiny — no external markdown lib needed. // Handles: **bold**, *italic*, `code`, "- " / "• " / "* " bullets, // "1. " numbered lists, blank-line paragraph breaks, single-newline // soft breaks. // ──────────────────────────────────────────────────────────────────── function renderInlineMarkdown(text) { const out = []; let i = 0; const len = text.length; let key = 0; let plainStart = 0; const flushPlain = (j) => { if (j > plainStart) out.push(text.slice(plainStart, j)); }; while (i < len) { if (text[i] === '*' && text[i + 1] === '*') { const end = text.indexOf('**', i + 2); if (end > i + 2) { flushPlain(i); out.push({text.slice(i + 2, end)}); i = end + 2; plainStart = i; continue; } } if (text[i] === '`') { const end = text.indexOf('`', i + 1); if (end > i + 1) { flushPlain(i); out.push({text.slice(i + 1, end)}); i = end + 1; plainStart = i; continue; } } // single-* italic — require non-space neighbours, not a list-bullet if (text[i] === '*' && text[i + 1] !== '*' && text[i + 1] !== ' ' && (i === 0 || /\s/.test(text[i - 1]))) { const end = text.indexOf('*', i + 1); if (end > i + 1 && text[end - 1] !== ' ' && text[end + 1] !== '*') { flushPlain(i); out.push({text.slice(i + 1, end)}); i = end + 1; plainStart = i; continue; } } i++; } flushPlain(len); return out; } function renderMarkdown(text) { if (!text) return null; const lines = String(text).split('\n'); const blocks = []; let cur = null; const flush = () => { if (cur) { blocks.push(cur); cur = null; } }; for (const line of lines) { const bullet = line.match(/^\s*[-•*]\s+(.+)$/); const numbered = line.match(/^\s*(\d+)\.\s+(.+)$/); if (bullet) { if (!cur || cur.type !== 'ul') { flush(); cur = { type: 'ul', items: [] }; } cur.items.push(bullet[1]); } else if (numbered) { if (!cur || cur.type !== 'ol') { flush(); cur = { type: 'ol', items: [] }; } cur.items.push(numbered[2]); } else if (line.trim() === '') { flush(); } else { if (!cur || cur.type !== 'p') { flush(); cur = { type: 'p', lines: [] }; } cur.lines.push(line); } } flush(); return blocks.map((b, i) => { if (b.type === 'ul') { return (
    {b.items.map((item, k) => (
  • {renderInlineMarkdown(item)}
  • ))}
); } if (b.type === 'ol') { return (
    {b.items.map((item, k) => (
  1. {renderInlineMarkdown(item)}
  2. ))}
); } const out = []; b.lines.forEach((l, k) => { if (k > 0) out.push(
); out.push({renderInlineMarkdown(l)}); }); return (

{out}

); }); } // ── AdminAetheria (v141.115 → v141.243) ───────────────────────────── // Operator chat with the role-scoped Aetheria assistant. Same engine as // the public Trip Designer (tool-using Claude), but bound to the admin/ // owner role boundary: rate-parity overview, host summaries, arrivals // today, recent bookings, owner-only financial summary. Server-side // /api/ai/aetheria-admin handles auth + role checks + audit logging. // // v141.243 — Two operator complaints fixed: // (1) Markdown ** ** showed literally → now renders via renderMarkdown() // (2) Chat history lost on navigation → now persisted to localStorage // (last 60 turns) and restored on mount. "Clear session" in the // History modal wipes both state and localStorage. const AETHERIA_STORAGE_KEY = 'tlb_aetheria_messages_v1'; const AETHERIA_PERSIST_MAX = 60; function AdminAetheria() { // v141.243 — restore from localStorage on mount (lazy initializer) const [messages, setMessages] = React.useState(() => { try { const raw = localStorage.getItem(AETHERIA_STORAGE_KEY); if (!raw) return []; const parsed = JSON.parse(raw); if (Array.isArray(parsed)) return parsed.slice(-AETHERIA_PERSIST_MAX); } catch { /* parse error / disabled storage */ } return []; }); // [{ role, content, tool_trail?, pending_actions? }] const [input, setInput] = React.useState(''); const [busy, setBusy] = React.useState(false); const [role, setRole] = React.useState(null); // v141.243 — Persist to localStorage whenever messages change. Kept // small (last 60 turns) so quota stays healthy + nothing sensitive // accumulates indefinitely. React.useEffect(() => { try { const toSave = messages.slice(-AETHERIA_PERSIST_MAX); localStorage.setItem(AETHERIA_STORAGE_KEY, JSON.stringify(toSave)); } catch { /* quota or disabled */ } }, [messages]); // v141.231 — Live tick for pending-action expiry countdown. Only // ticks when there's at least one un-resolved pending action, // otherwise we don't waste re-renders. const [now, setNow] = React.useState(Date.now()); const inputRef = React.useRef(null); const scrollRef = React.useRef(null); // v141.247 — Read prefill from sessionStorage on mount. Set by // "Ask Aetheria" buttons (and other call-sites // in future). Consumed ONCE — cleared from storage so a refresh // doesn't re-fill the input. React.useEffect(() => { try { const pre = sessionStorage.getItem('tlb_aetheria_prefill'); if (pre) { setInput(pre); sessionStorage.removeItem('tlb_aetheria_prefill'); // Focus + place cursor at the end so the operator can review + // edit before sending. setTimeout(() => { if (inputRef.current) { inputRef.current.focus(); try { inputRef.current.setSelectionRange(pre.length, pre.length); } catch {} } }, 50); } } catch {} }, []); React.useEffect(() => { if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight; }, [messages, busy]); React.useEffect(() => { const hasOpen = messages.some(m => Array.isArray(m.pending_actions) && m.pending_actions.some(p => !p._resolved && Date.parse(p.expires_at) > Date.now()) ); if (!hasOpen) return; const t = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(t); }, [messages]); const adminToken = () => { try { return localStorage.getItem('tlb_admin_token') || localStorage.getItem('tlb_token') || ''; } catch { return ''; } }; async function send(text) { const q = (text || input).trim(); if (!q || busy) return; const next = [...messages, { role: 'user', content: q }]; setMessages(next); setInput(''); setBusy(true); try { const token = adminToken(); const r = await fetch('/api/ai/aetheria-admin', { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { 'Authorization': 'Bearer ' + token } : {}) }, body: JSON.stringify({ messages: next.map(m => ({ role: m.role, content: m.content })) }), }); const j = await r.json(); if (!r.ok || !j.ok) { setMessages([...next, { role: 'assistant', content: j.error || 'Aetheria error', error: true }]); } else { setRole(j.role || null); setMessages([...next, { role: 'assistant', content: j.text || '(empty reply)', tool_trail: j.tool_trail || [], pending_actions: Array.isArray(j.pending_actions) ? j.pending_actions : [], }]); } } catch (e) { setMessages([...next, { role: 'assistant', content: 'Network error reaching Aetheria.', error: true }]); } finally { setBusy(false); if (inputRef.current) inputRef.current.focus(); } } const QUICK_PROMPTS = [ 'Rate parity status now?', 'Any drift critical villas?', 'Arrivals today', 'Hosts with stale rates (>30d)', 'Recent bookings — last 7 days', 'Villas without source URL', ]; // v141.231 — Phase 3: confirm/cancel a pending Aetheria action. // Mutates messages[].pending_actions[k]._resolved + ._outcome so // the card flips to "Executed" / "Cancelled" / "Error" + the // Confirm/Cancel buttons disappear. async function actOnPending(messageIdx, actionIdx, mode /* 'confirm' | 'cancel' */) { const m = messages[messageIdx]; if (!m || !m.pending_actions || !m.pending_actions[actionIdx]) return; const action = m.pending_actions[actionIdx]; if (action._resolved) return; // optimistic: mark working const setActionField = (patch) => setMessages(curr => { const copy = curr.slice(); const msg = { ...copy[messageIdx] }; const pas = (msg.pending_actions || []).slice(); pas[actionIdx] = { ...pas[actionIdx], ...patch }; msg.pending_actions = pas; copy[messageIdx] = msg; return copy; }); setActionField({ _working: true }); try { const token = adminToken(); const r = await fetch('/api/ai/aetheria-confirm', { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: 'Bearer ' + token } : {}) }, body: JSON.stringify({ action_id: action.action_id, [mode]: true }), }); const j = await r.json(); if (!j.ok) { setActionField({ _working: false, _resolved: true, _outcome: 'error', _error: j.error || j.detail || 'failed' }); } else if (j.cancelled) { setActionField({ _working: false, _resolved: true, _outcome: 'cancelled' }); } else if (j.executed) { setActionField({ _working: false, _resolved: true, _outcome: 'executed', _result: j.result || null }); } else { setActionField({ _working: false, _resolved: true, _outcome: 'unknown' }); } } catch (e) { setActionField({ _working: false, _resolved: true, _outcome: 'error', _error: String(e.message || e) }); } } // Pretty-print countdown to expiry. function fmtRemaining(ms) { if (ms <= 0) return 'expired'; const s = Math.floor(ms / 1000); const mm = Math.floor(s / 60); const ss = s % 60; return mm + 'm ' + (ss < 10 ? '0' + ss : ss) + 's'; } // v141.127 — Mockup port. Visual upgrade: gold-accent panel + italic // display-font replies + gold avatar circle. Engine + role-gating // preserved (server-side /api/ai/aetheria-admin already does role // checks + audit logging). const [showHistoryModal, setShowHistoryModal] = React.useState(false); const [showPermsModal, setShowPermsModal] = React.useState(false); return (
{/* Header */}
v141.115 · role-scoped {role ? `· you are ${role}` : ''} Aetheria A private secretary's voice. Ask in plain language — Aetheria reads the bookings, the rate parity, the host roster, and decides which tools to call.
{/* Quick prompts */}
Try asking
{QUICK_PROMPTS.map(p => ( ))}
{/* Transcript */}
{messages.length === 0 && !busy && (
Aetheria. Type a question or pick one above. Every query is audit-logged, every tool call is footnoted. {role === 'owner' ? ' Owner-mode also unlocks financial + contract lookups.' : ''}
)} {messages.map((m, i) => { if (m.role === 'user') { return (
{m.content}
); } return (
A
Aetheria
/
    /
      blocks // with their own spacing, so whiteSpace:pre-wrap is // dropped (the renderer handles soft breaks via
      ). fontFamily: F_TLB.ui, fontSize: 15, color: m.error ? '#9a1c1c' : CC.ink, lineHeight: 1.7, maxWidth: 720, }}> {m.error ? m.content : renderMarkdown(m.content)}
{Array.isArray(m.tool_trail) && m.tool_trail.length > 0 && (
Tools used:{' '} {m.tool_trail.map((t, ti) => ( {t.name}{t.ok ? '' : '⚠'} {ti < m.tool_trail.length - 1 ? ' · ' : ''} ))}
)} {/* v141.231 — Phase 3 pending action cards. One per action Aetheria proposed in this turn. Until the operator clicks Confirm, NOTHING has changed in the DB (server enforces this — see /api/ai/lib/ aetheria-actions.js executePending). */} {Array.isArray(m.pending_actions) && m.pending_actions.map((a, ai) => { const expMs = Math.max(0, Date.parse(a.expires_at) - now); const isExpired = expMs <= 0 && !a._resolved; return (
{a._resolved ? (a._outcome === 'executed' ? 'Executed' : a._outcome === 'cancelled' ? 'Cancelled' : a._outcome === 'error' ? 'Error' : 'Resolved') : isExpired ? 'Expired' : 'Pending confirmation'} {a.action_type} {!a._resolved && !isExpired && ( Expires in {fmtRemaining(expMs)} )}
{a.summary}
{a.params && a.params.host_response && (
{a.params.host_response}
)} {/* v141.251 — SEO/CMS field preview. Operator sees the EXACT text + char counts BEFORE clicking Confirm so we never approve a black-box write. Auto-renders for any action_type matching seo_update_* or cms_update_page; for other action types this block is invisible. */} {a.params && a.params.fields && typeof a.params.fields === 'object' && /^(seo_update_|cms_update_)/.test(String(a.action_type || '')) && (
{a.action_type === 'seo_update_property' && a.params.property_id && (
Target property: {a.params.property_id}
)} {a.action_type === 'cms_update_page' && a.params.page_slug && (
Target page: /{a.params.page_slug}
)} {Object.entries(a.params.fields).map(([fk, fv]) => { const isString = typeof fv === 'string'; const len = isString ? fv.length : null; // Length advisory thresholds (matches // api/ai/lib/seo-rules.js LENGTH_BUDGETS) const budget = fk === 'title' ? { ideal: 60, soft: 65, hard: 70 } : fk === 'description' ? { ideal: 160, soft: 170, hard: 180 } : fk === 'og_title' ? { ideal: 90, soft: 100, hard: 120 } : fk === 'og_description' ? { ideal: 200, soft: 220, hard: 250 } : null; const lenTone = !budget || len == null ? null : len > budget.hard ? '#a04545' // red — will be rejected by validator : len > budget.soft ? '#c2933b' // amber — warning : len > budget.ideal ? '#8a7a3f' // soft amber : len > 0 ? '#16613a' // green — ideal : '#a04545'; return (
{fk} {len != null && ( {len} chars{budget ? ' · ideal ≤' + budget.ideal : ''} )}
{isString ? fv : Array.isArray(fv) ? fv.join(', ') : JSON.stringify(fv)}
); })}
)} {a._resolved && a._outcome === 'executed' && a._result && (
✓ Done · {JSON.stringify(a._result).slice(0, 140)}
)} {a._resolved && a._outcome === 'error' && (
Failed: {a._error || 'unknown error'}
)} {!a._resolved && !isExpired && (
)}
); })}
); })} {busy && (
A
Aetheria is checking the data…
)}
{/* Composer */}
{/* v141.247 — Switched from (single-line, mangles multi-line prefills from "Ask Aetheria" buttons) to a