// Section 3 — Host Dashboard function HostDashboard() { const h = D.host; // v132 — Bug D-07: read initial section from ?section= URL param so // deep-links like /host/booking-inbox land on the right tab. Vercel // catch-all rewrite (vercel.json) routes /host/:section* → here. The // raw URL path is also accepted as a fallback in case the rewrite // didn't fire. const _initialSection = (function() { try { const p = new URLSearchParams(window.location.search); const s = p.get('section'); if (s) return s.replace(/^booking-/, '').replace(/-inbox$/, ''); // booking-inbox → inbox const m = window.location.pathname.match(/^\/host\/([a-z-]+)/i); if (m && m[1]) return m[1].replace(/^booking-/, '').replace(/-inbox$/, ''); } catch (e) {} return 'overview'; })(); const [section, setSection] = React.useState(_initialSection); const [, bump] = React.useState(0); React.useEffect(() => { const h = () => bump(t => t + 1); window.addEventListener('tlb-live-loaded', h); return () => window.removeEventListener('tlb-live-loaded', h); }, []); // v141.126 — Cross-section drill-ins for HostOverview KPI tile clicks // and per-row action buttons (Open → inbox, Manage all → properties). // Same pattern as admin's tlb-admin-tab event handler. React.useEffect(() => { const onJump = (e) => { if (e && e.detail) setSection(e.detail); }; window.addEventListener('tlb-host-tab', onJump); return () => window.removeEventListener('tlb-host-tab', onJump); }, []); // Sidebar items mirror the operational /host sidebar exactly. // Native React views: overview, inbox, calendar, messages, earnings. // iframe-embedded operational pages for the rest (analytics, properties, // reviews, disputes, tasks, concierge, seo, pricing, site, stories, // settings, admin). // v141.70 — Classic-dashboard retirement. The host classic at // /host-classic had three feature areas with NO native equivalent: // 1. Per-villa SEO Center (SERP preview, schema builder, FAQ tools, // keyword rankings) — moved to admin sitewide in v129 BUT hosts // still need the per-villa view; surfaced here via iframe. // 2. Settings → Integrations (Aetheria OS connection, auto-reply + // business hours, platform integrations: Airbnb/Booking/GA4/WA, // Trust & Verification panel). Native HostSettings only covers // profile + payout; full Integrations panel is the classic page. // 3. Admin Host Management (role-gated): purge demos, publish drafts, // inventory report, hosts CRUD. Lives only in /host-classic today. // // Also switched native → iframe for `stories` (classic has the full // upload/staging/publish flow; native was read-only grid). // // Native sections preserved because they're functionally complete: // overview, inbox, calendar, messages, earnings, analytics, // properties, availability, bookings, welcome, checkin, reviews, // pricing, disputes, tasks, concierge, site, settings. // // After this revision, every classic /host-classic?tab=xxx feature is // reachable from /dashboards/host — visiting /host-classic directly // bounces to /dashboards/host (see public/host-classic.html top-of-body). const navItems = [ // Main { key: 'overview', label: 'Overview', group: 'Main', icon: 'home', mode: 'native' }, { key: 'inbox', label: 'Booking inbox', group: 'Main', icon: 'inbox', mode: 'native' }, { key: 'calendar', label: 'Calendar', group: 'Main', icon: 'calendar', mode: 'native' }, { key: 'messages', label: 'Messages', group: 'Main', icon: 'messageSq', mode: 'native' }, { key: 'earnings', label: 'Earnings', group: 'Main', icon: 'dollar', mode: 'native' }, // Operations { key: 'analytics', label: 'Analytics', group: 'Operations', icon: 'barChart', mode: 'native' }, { key: 'properties', label: 'Properties', group: 'Operations', icon: 'building', mode: 'native' }, { key: 'availability', label: 'Availability', group: 'Operations', icon: 'calCheck', mode: 'native' }, { key: 'bookings', label: 'Bookings · all', group: 'Operations', icon: 'list', mode: 'native' }, { key: 'welcome', label: 'Welcome packets', group: 'Operations', icon: 'mail', mode: 'native' }, { key: 'checkin', label: 'Check-in', group: 'Service', icon: 'key', mode: 'native' }, { key: 'reviews', label: 'Reviews', group: 'Operations', icon: 'star', mode: 'native' }, { key: 'pricing', label: 'Pricing', group: 'Operations', icon: 'tag', mode: 'native' }, // Service { key: 'disputes', label: 'Disputes', group: 'Service', icon: 'alert', mode: 'native' }, { key: 'tasks', label: 'Tasks', group: 'Service', icon: 'checkSquare', mode: 'native' }, { key: 'concierge', label: 'Concierge requests', group: 'Service', icon: 'phone', mode: 'native' }, // Marketing — v129: SEO moved to admin (sitewide control); host // focuses on per-villa content via Public site editor. { key: 'site', label: 'Public site', group: 'Marketing', icon: 'globe', mode: 'native' }, { key: 'stories', label: 'Stories', group: 'Marketing', icon: 'bookOpen', mode: 'iframe' }, // v141.70 — Per-villa SEO Center accessed via classic iframe. // Sitewide SEO controls live in /dashboards/admin → SEO control. { key: 'seo', label: 'SEO · per villa', group: 'Marketing', icon: 'search', mode: 'iframe' }, // Settings { key: 'settings', label: 'Profile · payout', group: 'Settings', icon: 'settings', mode: 'native' }, // v141.70 — Integrations + Trust & Verification + Aetheria OS panels // (auto-reply, business hours, platform connectors). These live in // /host-classic?tab=settings under the "Integrations" / "Trust" / // "Aetheria" sub-sections; surfaced here as a separate nav item so // the native Settings page can stay focused on profile + payout. { key: 'integrations', label: 'Integrations · trust', group: 'Settings', icon: 'link', mode: 'iframe' }, // v141.70 — Admin Host Management (role-gated). Purge demo villas, // publish all drafts, inventory report, host CRUD. Sidebar item is // hidden for non-admin sessions in the sidebar component below. { key: 'admin', label: 'Host management', group: 'Settings', icon: 'shield', mode: 'iframe', adminOnly: true }, // v110 — opens the system health dashboard in a new tab. Hosts use // it to see at a glance which of their villas need attention // (broken iCal, stale sync, missing seasonal rates). { key: 'health', label: 'Villa Health', group: 'Settings', icon: 'activity', mode: 'external', href: '/admin/health' }, ]; const liveStatus = window.TLB_LIVE_STATUS && window.TLB_LIVE_STATUS.host; const needsAuth = liveStatus && liveStatus.needsAuth && !liveStatus.live; // v141.70 — role detection for `adminOnly` nav items (Host management // panel). Reads tlb_session JSON; falls back to tlb_admin_token // presence so legacy sessions still get the elevated controls. const isAdminSession = (function() { try { const raw = localStorage.getItem('tlb_session') || localStorage.getItem('tlb_user'); if (raw) { const sess = JSON.parse(raw); if (sess && (sess.isAdmin || sess.role === 'admin')) return true; } if (localStorage.getItem('tlb_admin_token')) return true; } catch {} return false; })(); const visibleNavItems = navItems.filter(n => !n.adminOnly || isAdminSession); const activeItem = visibleNavItems.find(n => n.key === section) || visibleNavItems[0]; const greetingName = (h.name || 'Putu').split(' ')[0]; function renderSection() { // Native sections registered via dashboards-native-sections.jsx — // properties/bookings/reviews/pricing/settings all wired here. const NATIVE = (window.TLB_NATIVE_SECTIONS && window.TLB_NATIVE_SECTIONS.host) || {}; if (NATIVE[section]) { const Cmp = NATIVE[section]; return ; } if (activeItem.mode === 'iframe') { return ; } switch (section) { case 'overview': return ; case 'inbox': return ; case 'calendar': return ; case 'messages': return ; case 'earnings': return ; default: return ; } } return (
{ // External nav items open in new tab instead of switching section const item = visibleNavItems.find(n => n.key === key); if (item && item.mode === 'external' && item.href) { window.open(item.href, '_blank', 'noopener'); return; } setSection(key); }} brand="TLB" sub="Host" dark={true} footer={
{(h.name || 'Host').split(' ')[0]} {h.properties || 0} properties.
} />
{/* v141.167 re-skin Stage 5 — global workspace Topbar (delivered redesign). Self-contained, sticky, above every section. */} {/* v141.137 — Shell page-header collapsed to just the group-eyebrow + LivePill. Section-level Display titles were duplicating with the section's own internal header. Each section since Phase 2 renders its own editorial header (eyebrow + Display title + subtitle + action CTAs) to match the v141.123 mockup. The overview greeting "Selamat sore, X" moves into HostOverview itself if needed. */}
{activeItem.group || 'Host'}
{needsAuth && (
SIGN IN TO UNLOCK LIVE HOST DATA Public villa portfolio (152 properties) is already real. KPIs, action queue, calendar, inbox, earnings, and the embedded operational sections need a host session.
{/* v141.72 — Was `/host` which rewrites back to this same dashboard. Now points at the classic page's loginScreen (?signin=1 bypasses the auto-redirect). */}
)} {/* v141.137 — ErrorBoundary so a crashing section shows a fallback instead of a blank screen. Keyed on `section` to reset on nav. */} {renderSection()}
); } // ─── HOST · ALL SECTIONS — feature-parity grid with operational ──── function HostAllSections() { const sections = D.host.sections || []; return (
FEATURE PARITY · 17 OPERATIONAL SECTIONS Every host workspace section. The five tabs above cover daily flow. The grid below opens each operational section in the editorial-styled live host workspace at /host.
{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}
))}
); } // v141.125 — HostOverview, v141.123 mockup port. // • 4-KPI unified border-grid (replaces separate KPI panels) // • Action queue with Overdue/Today/This-week buckets + Snooze/Open // • Activity feed with Dot tone (sage = ok, mid = neutral) // • Properties grid — 3 cards with gradient header + bottom info row // • All 4 states: loading · error · empty · populated // • Buttons wired: Print, Add property, Acknowledge all, Open feed, // Manage all, per-row Snooze/Open function HostOverview() { const propsApi = useApi('/api/properties?status=all', { tokenType: 'host' }); const bookApi = useApi('/api/bookings?status=all', { tokenType: 'host' }); const villas = (propsApi.data && propsApi.data.properties) || []; const bookings = (bookApi.data && bookApi.data.bookings) || []; // ── KPIs (this month) ─────────────────────────────────────── const now = new Date(); const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); const monthBookings = bookings.filter(b => { const t = b.check_in || b.created_at; return t && new Date(t) >= monthStart; }); const mtdRevenue = monthBookings.reduce((s, b) => s + (parseFloat(b.total_price) || 0), 0); const totalNights = monthBookings.reduce((s, b) => s + (parseInt(b.nights, 10) || 0), 0); const adr = totalNights > 0 ? Math.round(mtdRevenue / totalNights) : 0; const elapsedDays = now.getDate(); const possibleNights = Math.max(1, villas.length * elapsedDays); const occPct = Math.min(100, Math.round((totalNights / possibleNights) * 100)); const fmtMoney = (n) => n >= 1_000_000 ? 'Rp ' + (n / 1_000_000).toFixed(1) + 'M' : 'Rp ' + Math.round(n).toLocaleString('id-ID'); // Last-month deltas — needs last month's data; we approximate from the // earlier portion of `bookings` (it's an "all-time" feed). const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1); const lastMonthEnd = new Date(now.getFullYear(), now.getMonth(), 0); const lmBookings = bookings.filter(b => { const t = b.check_in || b.created_at; return t && new Date(t) >= lastMonthStart && new Date(t) <= lastMonthEnd; }); const lmRevenue = lmBookings.reduce((s, b) => s + (parseFloat(b.total_price) || 0), 0); const revDelta = lmRevenue > 0 ? Math.round((mtdRevenue - lmRevenue) / lmRevenue * 100) : 0; const bookDelta = monthBookings.length - lmBookings.length; const kpis = [ { key: 'rev', label: 'MTD revenue', value: fmtMoney(mtdRevenue), delta: (revDelta >= 0 ? '+' : '') + revDelta + '% MoM' }, { key: 'bk', label: 'Bookings', value: monthBookings.length, delta: (bookDelta >= 0 ? '+' : '') + bookDelta + ' vs last mo' }, { key: 'occ', label: 'Avg occupancy', value: occPct + '%', delta: villas.length + ' villas tracked' }, { key: 'adr', label: 'ADR', value: adr > 0 ? fmtMoney(adr) : '—', delta: totalNights + ' night-stays' }, ]; // ── Action queue: bucketed by SLA age ─────────────────────── const queueRaw = bookings.filter(b => /pending|enquir|new/i.test(b.status || '')); function bucketOf(b) { if (!b.created_at) return 'This week'; const hoursSince = (Date.now() - new Date(b.created_at).getTime()) / 3600000; if (hoursSince > 24) return 'Overdue'; if (hoursSince > 6) return 'Today'; return 'This week'; } const queue = queueRaw.slice(0, 12).map(b => ({ id: b.id, bucket: bucketOf(b), kind: (b.status || 'new').toLowerCase(), label: (b.guest_name || b.guest_email || 'Guest') + ' · ' + (b.property_name || 'villa'), age: b.created_at ? relTime(b.created_at) : '—', when: b.check_in ? new Date(b.check_in).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }) : '', })); // ── Activity feed ─────────────────────────────────────────── const activity = bookings.slice(0, 6).map(b => { const status = (b.status || 'new').toLowerCase(); return { kind: /confirm|complete|paid/i.test(status) ? 'ok' : 'mid', who: b.guest_name || b.guest_email || 'Guest', what: status + ' · ' + (b.property_name || 'villa') + (b.total_price ? ` · ${fmtMoney(parseFloat(b.total_price))}` : ''), when: b.created_at ? relTime(b.created_at) : '—', id: b.id, }; }); // ── Properties grid — top 3 by revenue ────────────────────── const propStats = villas.map(v => { const myBookings = bookings.filter(b => b.property_id === v.id || b.property_name === v.name); const myRev = myBookings .filter(b => { const t = b.check_in; return t && new Date(t) >= monthStart; }) .reduce((s, b) => s + (parseFloat(b.total_price) || 0), 0); const myNights = myBookings .filter(b => { const t = b.check_in; return t && new Date(t) >= monthStart; }) .reduce((s, b) => s + (parseInt(b.nights, 10) || 0), 0); const occ = elapsedDays > 0 ? Math.min(100, Math.round((myNights / elapsedDays) * 100)) : 0; // Cover image — first photo if available, else gradient fallback. const cover = v.cover_image || v.image || (Array.isArray(v.photos) && v.photos[0]) || null; return { id: v.id, name: v.name || 'Villa', place: (v.location || 'Bali').split(',')[0].trim(), slug: v.url_slug || v.id, beds: v.bedrooms || v.beds || '—', rate: v.nightly_rate_idr ? fmtMoney(v.nightly_rate_idr) : (v.price_per_night ? '$' + v.price_per_night : '—'), score: v.health_score != null ? v.health_score : null, status: v.status || 'live', occ, rev: myRev, cover, }; }).sort((a, b) => b.rev - a.rev).slice(0, 3); const loading = propsApi.loading || bookApi.loading; const error = propsApi.error || bookApi.error; if (loading) return ; if (error) return { propsApi.refetch(); bookApi.refetch(); }} />; const empty = villas.length === 0 && bookings.length === 0; const subtitle = empty ? 'A new host. Onboarding complete — the first booking will surface here.' : `${villas.length} ${villas.length === 1 ? 'villa' : 'villas'}, ${monthBookings.length} ${monthBookings.length === 1 ? 'booking' : 'bookings'} this month, ${queue.length} ${queue.length === 1 ? 'thing' : 'things'} asking for your attention.`; return (
{/* ── Header with Print + Add property ─────────────────────── */}
Your villas · the month so far Live data
{subtitle}
window.print()}>Print window.dispatchEvent(new CustomEvent('tlb-host-tab', { detail: 'properties' }))}>Add property
{/* ── KPI grid (unified border) ────────────────────────────── */}
{kpis.map((k, i) => (
{k.label}
{empty ? '—' : k.value}
{empty ? '—' : k.delta}
))}
{/* ── Action queue + Activity feed ─────────────────────────── */}
{queue.length} {queue.length === 1 ? 'item' : 'items'} · clustered by SLA Action queue.
{ if (!queue.length) return; if (!confirm('Acknowledge all ' + queue.length + ' pending items? They will be moved to "viewed" state.')) return; // Best-effort batch ack — bulk action endpoint not yet built; // for now navigate to inbox where the user can action each. window.dispatchEvent(new CustomEvent('tlb-host-tab', { detail: 'inbox' })); }} disabled={!queue.length}>Acknowledge all →
Last 14 days Activity.
window.dispatchEvent(new CustomEvent('tlb-host-tab', { detail: 'messages' }))}>Open feed →
{empty || activity.length === 0 ? ( ) : (
{activity.map((a, i) => (
{a.who}{' '} {a.what}
{a.when}
))}
)}
{/* ── Properties grid ──────────────────────────────────────── */}
{villas.length} {villas.length === 1 ? 'listing' : 'listings'} · {villas.filter(v => (v.status || 'live') === 'live').length} live Properties.
window.dispatchEvent(new CustomEvent('tlb-host-tab', { detail: 'properties' }))}>Manage all →
{propStats.length === 0 ? ( ) : (
{propStats.map(p => )}
)}
); } // ─── Helpers for HostOverview ────────────────────────────────────── function relTime(iso) { try { 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) + 'h ago'; return Math.round(d / 86400) + 'd ago'; } catch { return '—'; } } function HostBtn({ kind = 'ghost', onClick, children, disabled = false }) { const isPrimary = kind === 'primary'; const isSubtle = kind === 'subtle'; const base = { fontFamily: F_TLB.ui, fontSize: isSubtle ? 11 : 12, fontWeight: 600, letterSpacing: isSubtle ? 0.06 : 0.02, padding: isSubtle ? '6px 12px' : '10px 18px', borderRadius: 100, cursor: disabled ? 'not-allowed' : 'pointer', transition: 'background .15s ease', opacity: disabled ? 0.5 : 1, }; const style = isPrimary ? { ...base, background: CC.ink, color: CC.white, border: '1px solid ' + CC.ink } : isSubtle ? { ...base, background: 'transparent', color: CC.deep, border: '1px solid ' + CC.border } : { ...base, background: CC.white, color: CC.deep, border: '1px solid ' + CC.border }; return ; } function HostDot({ tone = 'sage' }) { const color = tone === 'red' ? '#9a1c1c' : tone === 'amber' ? '#c08a3e' : tone === 'mid' ? CC.mid : '#69d28a'; return ; } function HostEmpty({ headline, hint }) { return (
{headline} {hint}
); } function HostActionQueueBuckets({ items, empty, fmtMoney }) { if (empty || !items.length) { return ; } const buckets = ['Overdue', 'Today', 'This week']; return (
{buckets.map((b, bi) => { const list = items.filter(i => i.bucket === b); if (!list.length) return null; const tone = b === 'Overdue' ? '#9a1c1c' : b === 'Today' ? '#c08a3e' : CC.mid; return (
{b} {list.length}
{list.map((it, i) => (
{it.label}
{it.kind} · {it.age}
{ // Snooze — adds 6 hours to created_at via local override try { const map = JSON.parse(localStorage.getItem('tlb_host_snoozed') || '{}'); map[it.id] = Date.now() + 6 * 3600 * 1000; localStorage.setItem('tlb_host_snoozed', JSON.stringify(map)); } catch {} }}>Snooze
))}
); })}
); } function HostVillaCard({ v, fmtMoney }) { const seed = (v.name || v.id || 'x').charCodeAt(0) * 17; const hue = seed % 360; return (
window.location.href = '/villa/' + v.slug} style={{ background: CC.white, border: `1px solid ${CC.border}`, borderRadius: 14, overflow: 'hidden', cursor: 'pointer', transition: 'all .25s ease' }} onMouseEnter={(e) => { e.currentTarget.style.borderColor = CC.deep; e.currentTarget.style.boxShadow = '0 12px 36px rgba(20,20,20,0.06)'; }} onMouseLeave={(e) => { e.currentTarget.style.borderColor = CC.border; e.currentTarget.style.boxShadow = ''; }} >
{v.place}
{v.status}

{v.name}

{v.beds} {v.beds === 1 ? 'bedroom' : 'bedrooms'} · {v.place}
Rate
{v.rate}
OCC
{v.occ}%
Health
{v.score == null ? '—' : v.score}
); } // v141.126 — HostInbox, v141.123 mockup port. // • 2-pane layout: 380px thread list (left) + 1fr conversation (right) // • Thread row: avatar + guest + status pill + villa + dates + late banner // • Detail pane: ID + headline ("Guest aboard Villa") + 3-stat // row (Total / Per night / Channel) + guest message + Confirm/Quote/Decline // • Header: Decline reasons (modal) + Confirm selected (bulk) // • All 4 states: loading / error / empty / populated // • Buttons wired: Confirm + Decline → POST /api/host-booking-action; // Quote a rate → opens classic with prefilled rate form; // Decline reasons → modal with quick presets function HostInbox() { const { data, loading, error, refetch } = useApi('/api/bookings?status=all', { tokenType: 'host' }); const all = (data && data.bookings) || []; const inquiries = all.filter(b => /pending|enquir|new|review/i.test(b.status || '')); const [selId, setSelId] = React.useState(null); const [filter, setFilter] = React.useState('all'); const [selectedIds, setSelectedIds] = React.useState(new Set()); const [showDeclineModal, setShowDeclineModal] = React.useState(false); const [acting, setActing] = React.useState(null); // booking id currently in flight // Re-select first inquiry whenever the visible list changes React.useEffect(() => { if (!inquiries.length) { setSelId(null); return; } if (!inquiries.find(b => b.id === selId)) setSelId(inquiries[0].id); }, [inquiries.length]); // eslint-disable-line function slaMinutes(b) { if (!b.created_at) return 24 * 60; return Math.max(0, Math.round((new Date(b.created_at).getTime() + 24 * 3600 * 1000 - Date.now()) / 60000)); } const today = new Date(); const todayStart = new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime(); const visible = filter === 'urgent' ? inquiries.filter(b => slaMinutes(b) < 60) : filter === 'today' ? inquiries.filter(b => b.created_at && new Date(b.created_at).getTime() >= todayStart) : inquiries; const sel = visible.find(b => b.id === selId) || visible[0]; const escalating = inquiries.filter(b => slaMinutes(b) < 5).length; async function actOnBooking(bookingId, action, reason) { if (!bookingId || !action) return; setActing(bookingId); try { const token = localStorage.getItem('tlb_host_token') || localStorage.getItem('tlb_token') || ''; const r = await fetch('/api/host-booking-action', { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { 'Authorization': 'Bearer ' + token } : {}) }, body: JSON.stringify({ booking_id: bookingId, action, reason: reason || '' }), }); if (!r.ok) { const j = await r.json().catch(() => ({})); throw new Error(j.error || j.reason || `Server returned ${r.status}`); } // Refetch the list so the actioned booking falls off refetch(); // Clear selection set setSelectedIds(new Set()); } catch (e) { alert('Could not ' + action + ': ' + (e.message || 'unknown error')); } finally { setActing(null); } } function bulkConfirm() { const ids = [...selectedIds]; if (!ids.length) return; if (!confirm(`Confirm ${ids.length} booking${ids.length === 1 ? '' : 's'}? Each guest gets a confirmation email immediately.`)) return; Promise.all(ids.map(id => actOnBooking(id, 'confirm', ''))).then(refetch); } if (loading) return ; if (error) return ; const fmtDate = (iso) => iso ? new Date(iso).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }) : '—'; const fmtMoney = (n) => { const num = parseFloat(n) || 0; if (num === 0) return '—'; return num >= 1_000_000 ? 'Rp ' + (num / 1_000_000).toFixed(1) + 'M' : 'Rp ' + Math.round(num).toLocaleString('id-ID'); }; return (
{/* Header */}
{inquiries.length} pending · {escalating} escalating < 5m Booking inbox Two-pane inquiry queue. Auto-confirm in 24 hours if no response — banner on every late thread.
setShowDeclineModal(true)}>Decline reasons Confirm selected{selectedIds.size ? ` · ${selectedIds.size}` : ''}
{/* Filter chips */}
{[ { key: 'all', label: `All · ${inquiries.length}` }, { key: 'urgent', label: `Urgent · ${inquiries.filter(b => slaMinutes(b) < 60).length}` }, { key: 'today', label: `Today · ${inquiries.filter(b => b.created_at && new Date(b.created_at).getTime() >= todayStart).length}` }, ].map(c => ( ))}
{/* 2-pane */}
{/* List pane */}
{visible.length === 0 ? ( ) : visible.map((b, i) => { const active = b.id === selId; const sla = slaMinutes(b); const late = sla < 60 * 6; // < 6h to auto-confirm const isSel = selectedIds.has(b.id); const initials = (b.guest_name || b.guest_email || 'G').split(/[\s@]/)[0][0] || 'G'; return (
setSelId(b.id)} style={{ padding: '16px 20px', borderTop: i === 0 ? '0' : `1px solid ${CC.border}`, background: active ? CC.offWhite : 'transparent', cursor: 'pointer', position: 'relative', }} > {active &&
}
{ e.stopPropagation(); setSelectedIds(prev => { const next = new Set(prev); if (next.has(b.id)) next.delete(b.id); else next.add(b.id); return next; }); }} onClick={(e) => e.stopPropagation()} style={{ width: 14, height: 14, accentColor: CC.ink, cursor: 'pointer' }} />
{initials.toUpperCase()}
{b.guest_name || b.guest_email || 'Guest'}
{(b.status || 'enquiry').toUpperCase()}
{b.property_name || '—'}
{fmtDate(b.check_in)} → {fmtDate(b.check_out)} · {b.nights || '—'} nights · {fmtMoney(b.total_price)}
{late && (
Auto-confirm in {Math.max(0, Math.round(sla / 60))} hours
)}
); })}
{/* Detail pane */}
{!sel ? (
) : (
BK-{String(sel.id || '').slice(-4).toUpperCase()}

{sel.guest_name || 'Guest'} aboard {sel.property_name || 'Villa'}

{fmtDate(sel.check_in)} → {fmtDate(sel.check_out)} · {sel.nights || '—'} nights · {sel.received_via || 'website'}
{(sel.status || 'enquiry').toUpperCase()}
{/* 3-stat row */}
{[ ['Total', fmtMoney(sel.total_price)], ['Per night', sel.nights > 0 ? fmtMoney((parseFloat(sel.total_price) || 0) / sel.nights) : '—'], ['Channel', sel.received_via || 'website'], ].map(([l, v], i) => (
{l}
{v}
))}
{/* Guest message */} Message from the guest

{sel.guest_message || sel.notes || `Booking ${sel.nights || 'a'} ${sel.nights === 1 ? 'night' : 'nights'} at ${sel.property_name || 'the villa'}. ${sel.guests || 2} guests. Looking forward to it.`}

{/* Actions */}
{ window.open('/host-classic?tab=bookings&id=' + encodeURIComponent(sel.id || '') + '&action=quote', '_blank'); }}>Quote a rate setShowDeclineModal(true)}>Decline
)}
{/* Escalation banner */} {escalating > 0 && (
{escalating} BOOKING{escalating > 1 ? 'S' : ''} WILL ESCALATE TO ADMIN IN < 5 MINUTES
)} {/* Decline reasons modal */} {showDeclineModal && sel && ( setShowDeclineModal(false)} onDecline={async (reason) => { await actOnBooking(sel.id, 'decline', reason); setShowDeclineModal(false); }} /> )}
); } // ─── Decline reasons modal ───────────────────────────────────────── function HostDeclineModal({ booking, onClose, onDecline }) { const [reason, setReason] = React.useState(''); const [submitting, setSubmitting] = React.useState(false); const presets = [ "Dates aren't available — already booked through another channel", 'Property under maintenance for this window', 'Group size exceeds villa capacity', 'Insufficient notice — needs more lead time for prep', 'Rate quoted no longer valid for selected dates', 'Custom (write below)', ]; return (
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)' }}> Decline reason. The guest sees this message verbatim. Be warm but firm.
{presets.map((p, i) => ( ))}