/* ════════════════════════════════════════════════════════════════════════════ dashboards-native-sections.jsx Native React replacements for the iframe sections in /dashboards/admin and /dashboards/host. Each section: • Uses the same API endpoints the operational pages call • Renders with the new design language (Panel, Display, Sans, CC tokens) • Has loading + error + empty states • Falls back to demo data from D.* when not authed (so the preview is browseable end-to-end without login) Goal: when every section is wired native, the operational dashboards /admin + /host can be retired and these previews become the production dashboards. Until then, sections that aren't yet ported stay as iframe embeds with a "PORTING" badge. ════════════════════════════════════════════════════════════════════════════ */ const D = window.TLB_DASH_DATA; const { useState, useEffect, useMemo, useRef } = React; // ── Helper — resolve legacy tlb_token only if the stored session role // matches the requested tokenType. Backward-compat for users who logged // in BEFORE the v82 saveSession update populated tlb_admin_token / // tlb_host_token. Reads tlb_session JSON to check the role marker. function _resolveLegacyTokenIfRole(wantRole) { try { const raw = localStorage.getItem('tlb_session') || localStorage.getItem('tlb_user'); if (raw) { const sess = JSON.parse(raw); const sessRole = sess && sess.role; if (sessRole === wantRole) { return localStorage.getItem('tlb_token') || ''; } // Role mismatch — DON'T use the legacy token. Sending a host token // to admin endpoints would always 403 anyway, so silent skip is fine. return ''; } // No session marker — single legacy token, treat as host (most common). if (wantRole === 'host') return localStorage.getItem('tlb_token') || ''; return ''; } catch { return ''; } } // ── 0 · Generic data hook ─────────────────────────────────────────────── // Fetches once on mount, exposes { data, loading, error, refetch }. Token // type defaults to admin; pass 'host' for /api/bookings + /api/reviews. function useApi(url, { tokenType = null } = {}, deps = []) { const [state, setState] = useState({ data: null, loading: true, error: null }); function refetch() { setState(s => ({ ...s, loading: true, error: null })); // Round-15 fix — strict role-based token resolution. The new dashboards // route admin sections to /api/admin* and host sections to /api/properties // etc. We MUST NOT mix tokens — sending a host token to an admin endpoint // wastes a server roundtrip + leaks 403s into the UI; sending an admin // token to a host endpoint succeeds but shows the WRONG host's data. // // Resolution order (strict, no cross-fallback): // tokenType='admin' → tlb_admin_token only (then generic fallback) // tokenType='host' → tlb_host_token only (then generic fallback) // The generic tlb_token fallback remains because legacy login flows // populated only that key. After v82 saveSession update, NEW logins // populate the role-specific key in addition to the generic one. let token = ''; try { if (tokenType === 'admin') { token = localStorage.getItem('tlb_admin_token') || _resolveLegacyTokenIfRole('admin') || ''; } else if (tokenType === 'host') { token = localStorage.getItem('tlb_host_token') || _resolveLegacyTokenIfRole('host') || ''; } else { // No tokenType passed (rare) — anonymous request. token = ''; } } catch {} const headers = token ? { 'Authorization': 'Bearer ' + token } : {}; fetch(url, { headers }) .then(r => { if (r.status === 401) throw new Error('Sign in required to view live data.'); if (!r.ok) throw new Error('Request failed (' + r.status + ').'); return r.json(); }) .then(data => setState({ data, loading: false, error: null })) .catch(err => setState({ data: null, loading: false, error: err.message || 'Failed to load' })); } useEffect(refetch, deps); // eslint-disable-line return { ...state, refetch }; } // ── 1 · Shared atoms ──────────────────────────────────────────────────── function SectionHeader({ title, subtitle, actions, badge }) { return (
{/* v141.163 re-skin Stage 2 — calm upright Playfair section title per the delivered redesign (was full-italic + period). */} {title} {badge}
{subtitle && {subtitle}}
{actions &&
{actions}
}
); } function Loading({ msg = 'Loading…' }) { return (
{msg} ); } function ErrorState({ msg, onRetry }) { const isAuth = /sign in|401/i.test(msg || ''); return ( {isAuth ? 'Authentication required' : 'Could not load'} {msg}
{onRetry && ( )} {isAuth && ( Sign in )}
); } function Empty({ title, msg }) { return ( {title}. {msg} ); } function Pill({ tone = 'neutral', children }) { const toneMap = { success: { bg: 'rgba(105,210,138,0.12)', color: '#1f6b3a' }, warn: { bg: 'rgba(232,168,69,0.12)', color: '#8a6324' }, danger: { bg: 'rgba(201,68,68,0.12)', color: '#9a1c1c' }, info: { bg: 'rgba(72,128,200,0.10)', color: '#1f4f8f' }, neutral: { bg: CC.offWhite, color: CC.mid }, }; const t = toneMap[tone] || toneMap.neutral; return ( {children} ); } function Table({ columns, rows, empty = 'No rows yet.' }) { if (!rows || !rows.length) return ; return (
{columns.map((c, i) => ( ))} {rows.map((row, ri) => ( {columns.map((c, ci) => ( ))} ))}
{c.label}
{c.render ? c.render(row, ri) : row[c.key]}
); } function fmtIDR(n) { if (n == null || isNaN(n)) return '—'; return 'Rp ' + Math.round(n).toLocaleString('id-ID'); } function fmtUSD(n) { if (n == null || isNaN(n)) return '—'; return '$' + Math.round(n).toLocaleString('en-US'); } function fmtDate(s) { if (!s) return '—'; try { return new Date(s).toLocaleDateString('en-GB', { day: '2-digit', month: 'short' }); } catch { return s; } } function fmtDateLong(s) { if (!s) return '—'; try { return new Date(s).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }); } catch { return s; } } function statusTone(s) { s = String(s || '').toLowerCase(); if (/(active|live|published|confirmed|completed|paid|verified)/.test(s)) return 'success'; if (/(pending|review|draft|in.?progress)/.test(s)) return 'warn'; if (/(cancel|refund|fail|reject|disabled|inactive)/.test(s)) return 'danger'; if (/(new|enquiry|message)/.test(s)) return 'info'; return 'neutral'; } // ════════════════════════════════════════════════════════════════════════ // SHARED COMPONENTS (used by both admin + host sections) // ════════════════════════════════════════════════════════════════════════ // v125 — CancellationModal: preview refund based on policy + cutoffs, // then issue Xendit refund on confirm. Used from any booking row's // "Cancel & refund" action button. function CancellationModal({ booking, role, onClose, onSuccess }) { const [preview, setPreview] = useState(null); const [loading, setLoading] = useState(true); const [reason, setReason] = useState(''); const [refundType, setRefundType] = useState('policy'); // 'policy' | 'full' (admin only) const [submitting, setSubmitting] = useState(false); const [err, setErr] = useState(''); useEffect(() => { let cancelled = false; (async () => { const tokenKey = role === 'admin' ? 'tlb_admin_token' : 'tlb_host_token'; const token = localStorage.getItem(tokenKey) || localStorage.getItem('tlb_token') || ''; try { const r = await fetch('/api/payment?action=cancellation-preview&booking_id=' + booking.id, { headers: { Authorization: 'Bearer ' + token }, }); if (cancelled) return; if (r.ok) setPreview(await r.json()); else { const j = await r.json().catch(() => ({})); setErr(j.error || 'Failed to load preview'); } } catch (e) { if (!cancelled) setErr(e.message); } finally { if (!cancelled) setLoading(false); } })(); return () => { cancelled = true; }; }, [booking.id]); async function confirmCancel() { setErr(''); setSubmitting(true); const tokenKey = role === 'admin' ? 'tlb_admin_token' : 'tlb_host_token'; const token = localStorage.getItem(tokenKey) || localStorage.getItem('tlb_token') || ''; try { const r = await fetch('/api/payment?action=refund', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token }, body: JSON.stringify({ booking_id: booking.id, reason, refund_type: refundType }), }); const j = await r.json(); if (r.ok) { onSuccess && onSuccess(j); onClose(); } else { setErr(j.error || 'Refund failed'); setSubmitting(false); } } catch (e) { setErr(e.message); setSubmitting(false); } } function fmtMoney(n, cur) { const c = (cur || 'IDR').toUpperCase(); return c === 'USD' ? '$' + Math.round(n).toLocaleString() : 'Rp ' + Math.round(n).toLocaleString('id-ID'); } function fmtDate(d) { return d ? new Date(d).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) : '—'; } return (
Cancel + refund. {booking.property_name} · {booking.guest_name || booking.guest_email} {loading ? ( Calculating refund per policy… ) : err ? ( {err} ) : !preview ? null : ( <>
Cancellation policy {(preview.policy_detail?.name) || preview.cancellation_policy}
Check-in {fmtDate(preview.check_in)} · {preview.days_until_check_in}d away
Amount paid {fmtMoney(preview.amount_paid, preview.currency)}
Refund (policy applied) 0 ? '#1f6b3a' : '#9a1c1c', fontVariantNumeric: 'tabular-nums' }}> {fmtMoney(preview.refund_amount, preview.currency)}
{preview.refund_percent}% refund · {preview.band.replace(/_/g, ' ')} {preview.forfeit_amount > 0 && ( Guest forfeits {fmtMoney(preview.forfeit_amount, preview.currency)} per the {(preview.policy_detail?.name) || 'policy'}. )}
{role === 'admin' && (
Override (admin only)
{refundType === 'full' && preview.refund_percent < 100 && ( Override grants the guest a full refund of {fmtMoney(preview.amount_paid, preview.currency)}, beyond what the policy requires. Use only for goodwill / TLB-side error. )}
)}