// 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.189 — Was gated on native confirm(); Chrome
// silently returns false from confirm() once the user
// ticks "prevent more dialogs" / in embedded contexts,
// so the click did nothing ("Sign Out tidak merespon").
// tlbConfirm renders its own overlay and always works.
window.tlbConfirm('Sign out of the host dashboard?', { danger: true, confirmText: 'Sign out' }).then((ok) => {
if (!ok) return;
try {
['tlb_token','tlb_admin_token','tlb_host_token','tlb_session','tlb_user']
.forEach(k => localStorage.removeItem(k));
document.cookie = 'tlb_session=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
} catch (e) {}
// /host-classic?signin=1 shows the classic loginScreen
// (its redirect-guard bypasses on ?signin=1 + empty
// token). replace() so Back doesn't return to the
// signed-out dashboard.
location.replace('/host-classic?signin=1');
});
}}
style={{
width: '100%', padding: '8px 12px', borderRadius: 8,
border: '1px solid rgba(240,240,240,0.10)', background: 'transparent',
color: 'rgba(240,240,240,0.65)', fontSize: 11, fontWeight: 500,
cursor: 'pointer', letterSpacing: '0.04em', fontFamily: F_TLB.ui,
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
transition: 'background .15s, color .15s, border-color .15s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(240,240,240,0.06)'; e.currentTarget.style.color = '#fff'; e.currentTarget.style.borderColor = 'rgba(240,240,240,0.20)'; }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'rgba(240,240,240,0.65)'; e.currentTarget.style.borderColor = 'rgba(240,240,240,0.10)'; }}
title="Clear session and return to sign-in screen"
>
Sign out
}
/>
{/* 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). */}
window.open('/host-classic?signin=1', '_blank')}>Open sign-in →
location.reload()}>I signed in · reload
)}
{/* 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) => (
))}
)}
{/* ── 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 {children} ;
}
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
{
// Open — navigate to inbox section pre-selected to this booking
try { localStorage.setItem('tlb_host_inbox_target', it.id || ''); } catch {}
window.dispatchEvent(new CustomEvent('tlb-host-tab', { detail: 'inbox' }));
}} style={{
fontFamily: F_TLB.ui, fontSize: 11, fontWeight: 600, letterSpacing: 0.06,
padding: '6px 12px', borderRadius: 100, cursor: 'pointer',
background: CC.ink, color: CC.white, border: '1px solid ' + CC.ink,
}}>Open
))}
);
})}
);
}
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.name}
{v.beds} {v.beds === 1 ? 'bedroom' : 'bedrooms'} · {v.place}
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 => (
setFilter(c.key)} style={{
padding: '6px 14px', borderRadius: 100, cursor: 'pointer',
background: filter === c.key ? CC.ink : 'transparent',
color: filter === c.key ? CC.white : CC.deep,
border: '1px solid ' + (filter === c.key ? CC.ink : CC.border),
fontFamily: F_TLB.ui, fontSize: 11, fontWeight: 600, letterSpacing: '0.04em',
}}>{c.label}
))}
{/* 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) => (
))}
{/* 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 */}
actOnBooking(sel.id, 'confirm', '')}
disabled={acting === sel.id}
style={{
fontFamily: F_TLB.ui, fontSize: 14, fontWeight: 600,
padding: '14px 36px', borderRadius: 100, cursor: acting === sel.id ? 'wait' : 'pointer',
background: CC.ink, color: CC.white, border: '1px solid ' + CC.ink,
opacity: acting === sel.id ? 0.6 : 1,
}}
>{acting === sel.id ? 'Confirming…' : 'Confirm'}
{
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) => (
setReason(p === presets[presets.length - 1] ? '' : p)} style={{
textAlign: 'left', padding: '12px 14px', borderRadius: 10,
border: '1px solid ' + (reason === p ? CC.ink : CC.border),
background: reason === p ? CC.offWhite : CC.white,
color: CC.deep, fontFamily: F_TLB.ui, fontSize: 13, cursor: 'pointer',
}}>{p}
))}
);
}
// v141.126 — HostCalendar, v141.123 mockup port.
// • 28-day rolling Gantt (was calendar-month grid)
// • Day header with weekday letter + day number + today highlight
// • Per-row gantt bars: booking (ink solid), block (chip + hatched),
// iCal sync (dashed outline)
// • Header CTAs: Month view (toggle 28d ↔ 60d) + Add block (modal)
// • Legend at bottom
// • All 4 states: loading / error / empty / populated
function HostCalendar() {
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) || []).slice(0, 8);
const bookings = (bookApi.data && bookApi.data.bookings) || [];
const [viewDays, setViewDays] = React.useState(28); // 28 or 60
const [showAddBlock, setShowAddBlock] = React.useState(false);
const today = new Date();
today.setHours(0, 0, 0, 0);
const dayLabels = React.useMemo(() => {
return Array.from({ length: viewDays }, (_, i) => {
const d = new Date(today); d.setDate(d.getDate() + i);
return {
d: d.getDate(),
w: ['S','M','T','W','T','F','S'][d.getDay()],
weekend: d.getDay() === 0 || d.getDay() === 6,
iso: d.toISOString().slice(0, 10),
isToday: i === 0,
};
});
}, [viewDays]);
// Per-villa blocked_dates fetched lazily
const [blocksMap, setBlocksMap] = useState({});
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,
source: b.source || 'manual',
confidence: b.confidence || 'authoritative',
label: b.source === 'airbnb' || b.source === 'booking.com' ? 'iCal · ' + b.source : 'Block',
}));
}
} catch {}
}
if (!cancelled) setBlocksMap(next);
})();
return () => { cancelled = true; };
}, [villas.map(v => v.id).join(',')]);
// Map a date range onto [startDay, span] within the visible window.
// startDay is 0-indexed (column offset), span is number of days.
function bandRange(startStr, endStr) {
if (!startStr || !endStr) return null;
const winStart = today.getTime();
const winEnd = winStart + viewDays * 86400000;
const s = new Date(startStr).getTime();
const e = new Date(endStr).getTime();
if (e <= winStart || s >= winEnd) return null;
const startDay = Math.max(0, Math.round((s - winStart) / 86400000));
const endDay = Math.min(viewDays, Math.round((e - winStart) / 86400000));
const span = endDay - startDay;
return span > 0 ? { s: startDay, span } : null;
}
const loading = propsApi.loading || bookApi.loading;
if (loading) return ;
if (propsApi.error || bookApi.error) return { propsApi.refetch(); bookApi.refetch(); }} />;
const empty = villas.length === 0;
const labelColumnWidth = 160;
const monthBoundary = (() => {
const m = today.getMonth();
return dayLabels.findIndex(dl => new Date(dl.iso).getMonth() !== m);
})();
return (
{/* Header */}
Next {viewDays} days · {villas.length} {villas.length === 1 ? 'villa' : 'villas'}
Calendar
Each row is one villa. Click a band to open the booking. Hatched bars are iCal-synced blackouts from Airbnb / Booking.com.
setViewDays(viewDays === 28 ? 60 : 28)}>
{viewDays === 28 ? 'Month view' : '28-day view'}
setShowAddBlock(true)} disabled={empty}>
Add block
{empty ? (
) : (
{/* Day header */}
{dayLabels.map((dl, i) => {
const isMonthBreak = i > 0 && monthBoundary === i;
return (
);
})}
{/* Villa rows */}
{villas.map((p, ri) => {
const villaBookings = bookings.filter(b => b.property_id === p.id || b.property_name === p.name);
const villaBlocks = blocksMap[p.id] || [];
const bands = [];
villaBookings.forEach(b => {
const r = bandRange(b.check_in, b.check_out);
if (r) bands.push({
...r,
label: (b.guest_name || 'BK-' + String(b.id || '').slice(-4)).slice(0, 14),
tone: 'ink', id: b.id,
});
});
villaBlocks.forEach(b => {
const r = bandRange(b.start, b.end);
if (r) bands.push({
...r,
label: b.label,
tone: b.source === 'airbnb' || b.source === 'booking.com' ? 'dot' : 'mute',
});
});
return (
{p.name}
{(p.location || 'Bali').split(',')[0].trim()}
{dayLabels.map((dl, i) => (
))}
{bands.map((b, bi) => {
const left = `calc(${labelColumnWidth}px + ${b.s} * (100% - ${labelColumnWidth}px) / ${viewDays} + 2px)`;
const w = `calc(${b.span} * (100% - ${labelColumnWidth}px) / ${viewDays} - 4px)`;
const isInk = b.tone === 'ink';
const isMute = b.tone === 'mute';
const isDot = b.tone === 'dot';
return (
{
if (b.id) {
try { localStorage.setItem('tlb_host_inbox_target', b.id); } catch {}
window.dispatchEvent(new CustomEvent('tlb-host-tab', { detail: 'inbox' }));
}
}}
title={b.label + (b.id ? ' · click to open booking' : '')}
style={{
position: 'absolute', left, width: w, top: 6, bottom: 6,
background: isInk ? CC.ink : isMute ? CC.offWhite : 'transparent',
color: isInk ? CC.white : CC.deep,
border: isDot ? `1px dashed ${CC.mid}` : isMute ? `1px solid ${CC.border}` : '0',
borderRadius: 7, padding: '0 12px',
display: 'flex', alignItems: 'center',
fontFamily: F_TLB.ui, fontSize: 10.5, fontWeight: 600,
letterSpacing: '0.02em', whiteSpace: 'nowrap', overflow: 'hidden',
backgroundImage: isMute ? 'repeating-linear-gradient(135deg, transparent 0 5px, rgba(20,20,20,0.04) 5px 6px)' : 'none',
cursor: b.id ? 'pointer' : 'default',
}}
>{b.label}
);
})}
);
})}
)}
{/* Legend */}
{!empty && (
Booked
Manual block
iCal sync
)}
{/* Add block modal */}
{showAddBlock && (
setShowAddBlock(false)} onSuccess={() => {
setShowAddBlock(false);
// Re-fetch all 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, source: b.source || 'manual', label: 'Block' }));
}
} catch {}
}
setBlocksMap(next);
})();
}} />
)}
);
}
// Add block modal — POSTs to /api/blocked-dates with property + range
function HostAddBlockModal({ villas, onClose, onSuccess }) {
const [form, setForm] = React.useState({
property_id: villas[0] ? villas[0].id : '',
date_start: new Date().toISOString().slice(0, 10),
date_end: new Date(Date.now() + 86400000).toISOString().slice(0, 10),
reason: '',
});
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_host_token') || localStorage.getItem('tlb_token') || '';
const r = await fetch('/api/blocked-dates', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(token ? { 'Authorization': 'Bearer ' + token } : {}) },
body: JSON.stringify({
property_id: form.property_id,
date_start: form.date_start,
date_end: form.date_end,
source: 'manual',
notes: form.reason || 'Manual block from host dashboard',
}),
});
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(j.error || j.message || `Server returned ${r.status}`);
onSuccess();
} 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: 14, background: CC.white, color: CC.ink, outline: 'none',
};
return (
e.stopPropagation()} style={{ background: CC.white, borderRadius: 18, padding: 32, maxWidth: 480, width: '100%', maxHeight: '90vh', overflow: 'auto', boxShadow: '0 12px 40px rgba(20,20,20,0.18)' }}>
Add block.
Block dates on a villa so it doesn't appear available for booking — maintenance, personal use, owner stays.
{error &&
{error} }
Cancel
{submitting ? 'Saving…' : 'Add block'}
);
}
function HostMessages() {
// v124 — Full inbox with threaded chat. Two-pane layout: thread list
// (left, 320px) + active chat (right, fills remaining). Polls for
// new messages every 4s when a thread is open + tab visible.
return ;
}
// ChatInbox — used by both HostMessages and AdminInbox
function ChatInbox({ role }) {
const tokenType = role === 'admin' ? 'admin' : 'host';
const threadsApi = useApi('/api/chat?action=threads&status=open', { tokenType });
const [activeThreadId, setActiveThreadId] = useState(null);
const [messages, setMessages] = useState([]);
const [input, setInput] = useState('');
const [internal, setInternal] = useState(false);
const [sending, setSending] = useState(false);
const [lastSeenAt, setLastSeenAt] = useState(null);
const [pollTick, setPollTick] = useState(0);
const messagesEndRef = React.useRef(null);
const threads = (threadsApi.data && threadsApi.data.threads) || [];
const activeThread = threads.find(t => t.id === activeThreadId);
// Token getter
function getToken() {
return localStorage.getItem(role === 'admin' ? 'tlb_admin_token' : 'tlb_host_token')
|| localStorage.getItem('tlb_token') || '';
}
// Initial load when thread switches
useEffect(() => {
if (!activeThreadId) { setMessages([]); return; }
let cancelled = false;
(async () => {
const r = await fetch('/api/chat?thread_id=' + activeThreadId, {
headers: { Authorization: 'Bearer ' + getToken() },
});
if (cancelled) return;
if (r.ok) {
const j = await r.json();
setMessages(j.messages || []);
if (j.messages && j.messages.length) {
setLastSeenAt(j.messages[j.messages.length - 1].created_at);
} else {
setLastSeenAt(new Date().toISOString());
}
}
// Mark thread as read on open
fetch('/api/chat?action=mark_read', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + getToken() },
body: JSON.stringify({ thread_id: activeThreadId }),
}).then(() => threadsApi.refetch());
})();
return () => { cancelled = true; };
}, [activeThreadId]);
// Polling loop — every 4 seconds when a thread is open + tab visible
useEffect(() => {
if (!activeThreadId || !lastSeenAt) return;
let interval = setInterval(() => {
if (document.hidden) return;
setPollTick(t => t + 1);
}, 4000);
return () => clearInterval(interval);
}, [activeThreadId, lastSeenAt]);
useEffect(() => {
if (!activeThreadId || !lastSeenAt || pollTick === 0) return;
let cancelled = false;
(async () => {
const r = await fetch('/api/chat?action=poll&thread_id=' + activeThreadId + '&since=' + encodeURIComponent(lastSeenAt), {
headers: { Authorization: 'Bearer ' + getToken() },
});
if (cancelled || !r.ok) return;
const j = await r.json();
if (Array.isArray(j.messages) && j.messages.length) {
setMessages(prev => {
const seenIds = new Set(prev.map(m => m.id));
const fresh = j.messages.filter(m => !seenIds.has(m.id));
if (!fresh.length) return prev;
return [...prev, ...fresh];
});
setLastSeenAt(j.messages[j.messages.length - 1].created_at);
}
})();
return () => { cancelled = true; };
}, [pollTick]);
// Auto-scroll on new message
useEffect(() => {
if (messagesEndRef.current) messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
}, [messages.length]);
async function send() {
const text = input.trim();
if (!text || !activeThreadId) return;
setSending(true);
try {
const r = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + getToken() },
body: JSON.stringify({ thread_id: activeThreadId, body: text, is_internal_note: internal }),
});
if (r.ok) {
const j = await r.json();
setMessages(prev => [...prev, j.message]);
setLastSeenAt(j.message.created_at);
setInput('');
threadsApi.refetch();
} else {
const j = await r.json().catch(() => ({}));
window.alert('Send failed: ' + (j.error || r.statusText));
}
} finally {
setSending(false);
}
}
// v141.138 — Loading timeout. Was: stayed "Loading inbox…" forever
// if the fetch hung. Now after 12 seconds, switch to an actionable
// "Taking longer than expected" message with a Retry button so the
// user isn't stuck.
const [loadingTimedOut, setLoadingTimedOut] = React.useState(false);
React.useEffect(() => {
if (!threadsApi.loading) { setLoadingTimedOut(false); return; }
const t = setTimeout(() => setLoadingTimedOut(true), 12_000);
return () => clearTimeout(t);
}, [threadsApi.loading]);
if (threadsApi.loading && !loadingTimedOut) return ;
if (threadsApi.loading && loadingTimedOut) {
return (
{ setLoadingTimedOut(false); threadsApi.refetch(); }}
/>
);
}
if (threadsApi.error) return ;
const totalUnread = threads.reduce((s, t) => s + (t['unread_for_' + role] || 0), 0);
return (
{/* Thread list */}
Inbox.
{totalUnread > 0 &&
{totalUnread} unread }
{threads.length} thread{threads.length !== 1 ? 's' : ''} · auto-refresh 4s
{threads.length === 0 ? (
No threads yet.
) : threads.map(t => {
const isActive = t.id === activeThreadId;
const unread = t['unread_for_' + role] || 0;
return (
setActiveThreadId(t.id)}
style={{
display: 'block', width: '100%', textAlign: 'left',
padding: '14px 18px', border: 'none',
borderBottom: '1px solid ' + CC.border,
background: isActive ? CC.white : 'transparent',
borderLeft: '3px solid ' + (isActive ? CC.ink : (t.is_escalated ? '#9a1c1c' : 'transparent')),
cursor: 'pointer', fontFamily: 'inherit',
}}>
0 ? 600 : 500} color={CC.ink} style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{t.guest_email || t.host_email || 'Thread'}
{unread > 0 && {unread} }
{t.subject || (t.property_id ? 'Property thread' : 'Direct')}
{t.last_message_at ? new Date(t.last_message_at).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) : '—'}
{t.is_escalated &&
Escalated }
);
})}
{/* Active chat panel */}
{!activeThread ? (
Select a thread.
Choose a conversation from the left to read or reply.
) : (
<>
{/* Chat header */}
{activeThread.guest_email || activeThread.host_email || 'Thread'}
{activeThread.subject || (activeThread.property_id ? 'Property thread' : 'Direct')}
{activeThread.is_escalated &&
Escalated }
Live · 4s polling
{/* Messages scrollable */}
{messages.length === 0 ? (
No messages in this thread yet. Start the conversation.
) : messages.map((m, i) => {
const isMe = (role === 'admin' && m.sender_type === 'admin') || (role === 'host' && m.sender_type === 'host');
const isSystem = m.sender_type === 'system';
const isInternal = m.is_internal_note;
if (isSystem) {
return
· {m.body} ·
;
}
return (
{isInternal &&
Internal note · only admin sees this }
{m.sender_type} · {m.sender_email || ''}
{m.body}
{m.created_at ? new Date(m.created_at).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) : '—'}
);
})}
{/* Composer */}
{role === 'admin' && (
setInternal(e.target.checked)} />
Internal note (admin-only — guest + host won't see)
)}
>
)}
);
}
// Expose ChatInbox so AdminInbox section in dashboards-native-sections.jsx can use it
window.TLB_CHAT_INBOX = ChatInbox;
// v141.126 — HostEarnings, v141.123 mockup port.
// • 1.6fr/1fr split: YTD hero (giant Money + YoY pill + AreaChart) + FX preview
// • Annual goal + on-pace stats below chart
// • Per-villa YTD net with horizontal progress bar + Statement button
// • 4 payout tiles preserved at top (Pending/Available/Paid Out/Total)
// • Payout history table preserved at bottom
// • Header CTAs: Statement (CSV) + Bank settings (nav to integrations)
// • All 4 states preserved
function HostEarnings() {
const { data, loading, error, refetch } = useApi('/api/bookings?status=all', { tokenType: 'host' });
const propsApi = useApi('/api/properties?status=all', { tokenType: 'host' });
const balanceQ = useApi('/api/payouts?action=balance', { tokenType: 'host' });
const historyQ = useApi('/api/payouts', { tokenType: 'host' });
const bookings = (data && data.bookings) || [];
const villas = (propsApi.data && propsApi.data.properties) || [];
const balance = (balanceQ.data && balanceQ.data.balance) || {};
const payoutHistory = (historyQ.data && historyQ.data.payouts) || [];
function nextMonday() {
const d = new Date();
const day = d.getDay();
const offset = day === 1 ? 7 : (8 - day) % 7 || 7;
const m = new Date(d.getTime() + offset * 86400000);
return m.toLocaleDateString('en-GB', { day: '2-digit', month: 'short' });
}
// Group bookings by month for current year
const now = new Date();
const year = now.getFullYear();
const byMonth = {};
let totalYtd = 0;
let payoutCount = 0;
bookings.forEach(b => {
const t = b.check_in || b.created_at;
if (!t) return;
const d = new Date(t);
if (d.getFullYear() !== year) return;
if (!/confirmed|paid|complete/i.test(b.status || '')) return;
const mIdx = d.getMonth();
const amount = parseFloat(b.total_price) || 0;
byMonth[mIdx] = (byMonth[mIdx] || 0) + amount;
totalYtd += amount;
payoutCount++;
});
let lyTotal = 0;
bookings.forEach(b => {
const t = b.check_in || b.created_at;
if (!t) return;
const d = new Date(t);
if (d.getFullYear() !== year - 1) return;
if (d > new Date(year - 1, now.getMonth(), now.getDate())) return;
if (!/confirmed|paid|complete/i.test(b.status || '')) return;
lyTotal += parseFloat(b.total_price) || 0;
});
const lyDelta = lyTotal > 0 ? ((totalYtd - lyTotal) / lyTotal * 100) : null;
// Per-villa YTD totals (sorted descending)
const villaTotals = villas.map(v => {
const total = bookings.filter(b => {
if (b.property_id !== v.id && b.property_name !== v.name) return false;
const t = b.check_in || b.created_at;
if (!t || new Date(t).getFullYear() !== year) return false;
return /confirmed|paid|complete/i.test(b.status || '');
}).reduce((s, b) => s + (parseFloat(b.total_price) || 0), 0);
return { id: v.id, name: v.name, area: (v.location || 'Bali').split(',')[0].trim(), beds: v.bedrooms || v.beds, earned: total };
}).sort((a, b) => b.earned - a.earned);
const maxVillaEarned = Math.max(1, ...villaTotals.map(v => v.earned));
// Annual goal — use 1.5x last year if available, else 4.2B IDR fallback (matches mockup)
const annualGoal = lyTotal > 0 ? Math.round(lyTotal * 1.5) : 4_200_000_000;
const daysInYear = 365;
const daysElapsed = Math.floor((now - new Date(year, 0, 1)) / 86400000);
const expectedByNow = annualGoal * (daysElapsed / daysInYear);
const pacePercent = expectedByNow > 0 ? Math.round(totalYtd / expectedByNow * 100) : 0;
const onPace = pacePercent >= 100;
const monthShort = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const chartData = monthShort.map((label, i) => ({ label, v: byMonth[i] || 0 }));
const fmtIDRm = (n) => {
const num = parseFloat(n) || 0;
if (num >= 1_000_000_000) return 'Rp ' + (num / 1_000_000_000).toFixed(2) + 'B';
if (num >= 1_000_000) return 'Rp ' + (num / 1_000_000).toFixed(1) + 'M';
return 'Rp ' + Math.round(num).toLocaleString('id-ID');
};
function downloadStatement() {
// Annual statement: per-booking CSV for this year
const headers = ['Date', 'Booking', 'Guest', 'Villa', 'Status', 'Total IDR'];
const rows = bookings
.filter(b => {
const t = b.check_in || b.created_at;
return t && new Date(t).getFullYear() === year && /confirmed|paid|complete/i.test(b.status || '');
})
.map(b => [
(b.check_in || b.created_at || '').slice(0, 10),
(b.confirmation_code || b.id || '').slice(0, 16),
b.guest_name || '',
b.property_name || '',
b.status || '',
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-earnings-${year}.csv`;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
URL.revokeObjectURL(url);
}
if (loading) return ;
if (error) return ;
return (
{/* Header */}
YTD · {year} · your {villas.length} {villas.length === 1 ? 'villa' : 'villas'}
Earnings
Year-to-date revenue, per-payout breakdown, foreign-currency conversion preview, and your annual goal.
Statement
window.dispatchEvent(new CustomEvent('tlb-host-tab', { detail: 'settings' }))}>
Bank settings
{/* Payout state tiles — preserved (operator-critical for cashflow) */}
{[
{ label: 'Pending', value: balance.pending || 0, badge: '7-day hold', tone: 'warn', sub: 'Bookings within dispute window' },
{ label: 'Available', value: balance.available || 0, badge: 'Pays ' + nextMonday(), tone: 'success', sub: 'Auto-wires next Monday 13:00 WITA' },
{ label: 'Paid out', value: balance.paid_out || 0, badge: null, tone: null, sub: 'Cumulative wire transfers' },
{ label: 'Total earned', value: balance.total_earned || 0, badge: null, tone: null, sub: 'Net of TLB commission' },
].map((t, i) => (
{t.label}
{t.badge &&
{t.badge} }
{fmtIDRm(t.value)}
{t.sub}
))}
{/* YTD hero + FX preview */}
YTD net · {year}
{fmtIDRm(totalYtd)}
{lyDelta !== null && (
= 0 ? 'success' : 'danger'}>
{lyDelta >= 0 ? '+' : ''}{lyDelta.toFixed(1)}% YoY
)}
v >= 1_000_000 ? 'Rp ' + (v / 1_000_000).toFixed(0) + 'M' : 'Rp ' + Math.round(v / 1000) + 'k'}
highlightIdx={now.getMonth()}
/>
Annual goal
{fmtIDRm(annualGoal)}
On pace
{pacePercent}% by {now.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })}
FX preview
In your currency
{/* Per-villa YTD net */}
Per villa · YTD net
{villaTotals.length === 0 ? (
) : villaTotals.map((v, i) => (
{v.name}
{v.area} · {v.beds} {v.beds === 1 ? 'bedroom' : 'bedrooms'}
{fmtIDRm(v.earned)}
{
// Per-villa statement — re-uses the same CSV generator filtered by villa
try { localStorage.setItem('tlb_host_statement_villa', v.id); } catch {}
downloadStatement();
}}>Statement
))}
{/* Payout history */}
Recent payouts
Weekly · Mondays
{payoutHistory.length === 0 ? (
No payouts yet — your first weekly transfer will appear here after a guest checks out and clears the 7-day dispute window.
) : (
{['Date', 'Booking', 'Amount', 'Status', 'Reference'].map((h, i) => (
{h}
))}
{payoutHistory.slice(0, 12).map((p, i) => (
{p.processed_at ? new Date(p.processed_at).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) : (p.created_at ? new Date(p.created_at).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) : '—')}
{(p.booking_id || '').slice(0, 8)}
{p.currency === 'USD' ? '$' + Number(p.amount).toLocaleString('en-US') : 'Rp ' + Number(p.amount).toLocaleString('id-ID')}
{p.status || 'pending'}
{p.stripe_transfer_id || '—'}
))}
)}
Payouts run every Monday at 13:00 WITA after the 7-day hold. Each transfer covers all guest checkouts from {balance.hold_days || 7}+ days ago.
Indonesian taxes (PHR 10% etc.) are bundled into the displayed nightly_rate; the equivalent share is included in your payout — please remit to your kabupaten Bapenda by the 15th of the following month.
);
}
function HostFxBlock({ totalYtd }) {
const [fx, setFx] = useState(null);
useEffect(() => {
fetch('/api/currency').then(r => r.json()).then(j => setFx(j)).catch(() => {});
}, []);
const idrPerUsd = (fx && (fx.IDR || fx.rates?.IDR)) || 17394; // fallback
const usdEquivalent = idrPerUsd > 0 ? totalYtd / idrPerUsd : 0;
return (
<>
1 USD = {Math.round(idrPerUsd).toLocaleString()} IDR
{fx && fx.timestamp ? 'Live · refreshed ' + new Date(fx.timestamp).toLocaleString() : fx ? 'Live mid-market' : 'Mid-market · cached'}
If IDR weakens 5%, USD-priced bookings appreciate on the next clearing.
>
);
}
window.HostDashboard = HostDashboard;