// Shared chrome: TopRail, CommandPalette, Sparkline, a tiny useAsync hook.
function useAsync(loader, deps) {
const [data, setData] = React.useState(null);
const [error, setError] = React.useState(null);
const [loading, setLoading] = React.useState(true);
const reload = React.useCallback(() => {
let live = true;
setLoading(true);
Promise.resolve()
.then(loader)
.then(d => { if (live) { setData(d); setError(null); } })
.catch(e => { if (live) { setError(e); } })
.finally(() => { if (live) setLoading(false); });
return () => { live = false; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps || []);
React.useEffect(() => reload(), [reload]);
return { data, error, loading, reload };
}
function useMediaQuery(query) {
const [matches, setMatches] = React.useState(() =>
typeof window !== 'undefined' ? window.matchMedia(query).matches : false
);
React.useEffect(() => {
const mql = window.matchMedia(query);
const handler = (e) => setMatches(e.matches);
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}, [query]);
return matches;
}
const EVENT_LABEL = {
contact_created: { label: 'New lead', ico: '✳' },
day_pass_booked: { label: 'Day pass booked', ico: '◆' },
appointment_booked: { label: 'Appointment booked', ico: '◆' },
appointment_cancelled: { label: 'Appointment cancelled', ico: '✕' },
appointment_updated: { label: 'Appointment updated', ico: '↻' },
tommy_escalation: { label: 'Tommy needs review', ico: '◩' },
tommy_sent: { label: 'SMS sent', ico: '→' },
sms_inbound: { label: 'Inbound SMS', ico: '←' },
};
function eventMeta(t) {
return EVENT_LABEL[t] || { label: String(t || 'event').replace(/_/g, ' '), ico: '•' };
}
function NotificationBell({ onNavigate }) {
const [count, setCount] = React.useState(0);
const [open, setOpen] = React.useState(false);
const [events, setEvents] = React.useState([]);
const [loading, setLoading] = React.useState(false);
const wrapRef = React.useRef(null);
const refreshCount = React.useCallback(async () => {
try {
const j = await api.get('/api/v2/events/unread-count');
setCount(Number(j.count) || 0);
} catch {}
}, []);
React.useEffect(() => {
refreshCount();
const onEv = (e) => {
const d = e.detail || {};
if (d.event_type || d.kind) refreshCount();
};
window.addEventListener('raw:event', onEv);
const t = setInterval(refreshCount, 60000);
return () => { clearInterval(t); window.removeEventListener('raw:event', onEv); };
}, [refreshCount]);
// Fetch events when dropdown opens
React.useEffect(() => {
if (!open) return;
let live = true;
setLoading(true);
api.get('/api/v2/events?limit=30')
.then(r => { if (live) setEvents(r.events || []); })
.catch(() => { if (live) setEvents([]); })
.finally(() => { if (live) setLoading(false); });
return () => { live = false; };
}, [open]);
// Close on outside click / Escape
React.useEffect(() => {
if (!open) return;
function onClick(e) {
if (!wrapRef.current?.contains(e.target)) setOpen(false);
}
function onKey(e) { if (e.key === 'Escape') setOpen(false); }
document.addEventListener('mousedown', onClick);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onClick);
document.removeEventListener('keydown', onKey);
};
}, [open]);
const markAll = async () => {
try {
await api.post('/api/v2/events/mark-all-read', {});
setEvents(es => es.map(ev => ({...ev, is_read: true, read_at: new Date().toISOString()})));
setCount(0);
} catch {}
};
const onEventClick = async (ev) => {
if (!ev.is_read) {
try { await api.post(`/api/v2/events/${ev.id}/read`, {}); } catch {}
setEvents(es => es.map(x => x.id === ev.id ? {...x, is_read: true} : x));
setCount(c => Math.max(0, c - 1));
}
setOpen(false);
if (ev.contact_id && onNavigate) {
onNavigate({view:'lead', id: ev.contact_id});
}
};
return (
{open && (
Notifications
{count > 0 && (
)}
{loading &&
Loading…
}
{!loading && events.length === 0 && (
No recent events
)}
{!loading && events.map(ev => {
const meta = eventMeta(ev.event_type);
return (
);
})}
)}
);
}
function TopRail({ view, onNavigate, contactName, onOpenCmd, user, onSignOut }) {
const u = user || window.CURRENT_USER || { name: 'Staff', email: '' };
const badge = staffBadge(u.name || u.email || 'Staff');
const openMobileNav = () => window.dispatchEvent(new CustomEvent('raw:mobile-nav-open'));
return (
/
{view === 'home' && Dashboard}
{view === 'leads' && <>
onNavigate({view:'home'})} style={{cursor:'pointer'}}>Dashboard
/Leads
>}
{view === 'lead' && <>
onNavigate({view:'home'})} style={{cursor:'pointer'}}>Dashboard
/
onNavigate({view:'leads'})} style={{cursor:'pointer'}}>Leads
/{contactName || '—'}
>}
{view === 'tommy' && <>
onNavigate({view:'home'})} style={{cursor:'pointer'}}>Dashboard
/Tommy
>}
{view === 'calendar' && <>
onNavigate({view:'home'})} style={{cursor:'pointer'}}>Dashboard
/Calendar
>}
{view === 'analytics' && <>
onNavigate({view:'home'})} style={{cursor:'pointer'}}>Dashboard
/Analytics
>}
{view === 'growth' && <>
onNavigate({view:'home'})} style={{cursor:'pointer'}}>Dashboard
/Growth
>}
{view === 'staff' && <>
onNavigate({view:'home'})} style={{cursor:'pointer'}}>Dashboard
/Staff
>}
{view === 'settings' && <>
onNavigate({view:'home'})} style={{cursor:'pointer'}}>Dashboard
/Settings
>}
{view === 'system' && <>
onNavigate({view:'home'})} style={{cursor:'pointer'}}>Dashboard
/System
>}
Search members, bookings, commands…
⌘
K
onNavigate({view:'settings'})} style={{cursor:'pointer'}} title="Account settings">
{badge.initials}
{(u.name || u.email || 'Staff').split(' ')[0]}
);
}
function CommandPalette({ open, onClose, onNavigate, onSignOut }) {
const [q, setQ] = React.useState('');
const [idx, setIdx] = React.useState(0);
const [members, setMembers] = React.useState([]);
const inputRef = React.useRef(null);
React.useEffect(() => {
if (open) {
setQ(''); setIdx(0);
setTimeout(() => inputRef.current?.focus(), 30);
// Lazy load members when palette opens. Cheap: 50 at a time.
const cached = cachedContacts();
if (cached.length) { setMembers(cached); }
api.get('/api/v2/leads?limit=50')
.then(r => { const list = (r?.leads || []).map(adaptLead); cacheContacts(list); setMembers(list); })
.catch(() => {});
}
}, [open]);
const allItems = React.useMemo(() => {
const navItems = [
{ group:'Navigate', ico:'◻', label:'Go to Dashboard', hint:'G H', action:()=> onNavigate({view:'home'}) },
{ group:'Navigate', ico:'◼', label:'Open Leads', hint:'G L', action:()=> onNavigate({view:'leads'}) },
{ group:'Navigate', ico:'◩', label:'Tommy inbox', hint:'G T', action:()=> onNavigate({view:'tommy'}) },
{ group:'Navigate', ico:'▦', label:'Calendar', hint:'G C', action:()=> onNavigate({view:'calendar'}) },
{ group:'Navigate', ico:'⎈', label:'Analytics', hint:'G A', action:()=> onNavigate({view:'analytics'}) },
{ group:'Navigate', ico:'↗', label:'Growth', hint:'G G', action:()=> onNavigate({view:'growth'}) },
{ group:'Navigate', ico:'⚙', label:'Settings', hint:'G ,', action:()=> onNavigate({view:'settings'}) },
];
const memberItems = (members || []).slice(0, 40).map(c => ({
group:'Members',
ico: initialsOf(c.name),
label: c.name, hint: c.stage,
action: () => onNavigate({view:'lead', id:c.id}),
}));
const actions = [
{ group:'Actions', ico:'↻', label:'Sync contacts from GHL', hint:'R', action: async ()=>{
try { await api.post('/api/v2/admin/sync-contacts', {}); } catch {}
}},
{ group:'Actions', ico:'⎋', label:'Sign out', hint:'', action: () => onSignOut && onSignOut() },
];
return [...navItems, ...memberItems, ...actions];
}, [onNavigate, members, onSignOut]);
const filtered = React.useMemo(() => {
const n = q.trim().toLowerCase();
if (!n) return allItems;
return allItems.filter(i => i.label.toLowerCase().includes(n) || i.group.toLowerCase().includes(n));
}, [q, allItems]);
const grouped = React.useMemo(() => {
const g = {};
filtered.forEach(i => { (g[i.group] = g[i.group] || []).push(i); });
return g;
}, [filtered]);
const flat = React.useMemo(() => {
const out = [];
Object.entries(grouped).forEach(([, items]) => items.forEach(i => out.push(i)));
return out;
}, [grouped]);
React.useEffect(() => { setIdx(0); }, [q]);
function handleKey(e) {
if (e.key === 'Escape') { e.preventDefault(); onClose(); return; }
if (e.key === 'ArrowDown') { e.preventDefault(); setIdx(i => Math.min(flat.length-1, i+1)); return; }
if (e.key === 'ArrowUp') { e.preventDefault(); setIdx(i => Math.max(0, i-1)); return; }
if (e.key === 'Enter') { e.preventDefault(); flat[idx]?.action(); onClose(); return; }
}
if (!open) return null;
let runningIdx = 0;
return (
e.stopPropagation()}>
setQ(e.target.value)}
onKeyDown={handleKey}
/>
{flat.length === 0 &&
No results
}
{Object.entries(grouped).map(([group, items]) => (
{group}
{items.map(item => {
const myIdx = runningIdx++;
return (
setIdx(myIdx)}
onClick={() => { item.action(); onClose(); }}
>
{item.ico}
{item.label}
{item.hint}
);
})}
))}
↑↓ navigate
↵ open
esc close
RAW · v2.1.0
);
}
function Sparkline({ data, color='currentColor', width=96, height=32 }) {
if (!data || data.length < 2) {
return ;
}
const max = Math.max(...data), min = Math.min(...data);
const range = Math.max(1, max - min);
const step = width / (data.length - 1);
const points = data.map((v, i) => [i*step, height - ((v - min)/range) * (height-4) - 2]);
const d = points.map((p, i) => (i===0?'M':'L') + p[0].toFixed(1) + ' ' + p[1].toFixed(1)).join(' ');
const last = points[points.length-1];
return (
);
}
// Sidebar icons: 18px stroked SVGs, Lucide-inspired. Colour inherits via
// currentColor so .sb-item.active flips them to var(--signal) with no JS.
const SB_ICON_PROPS = {
viewBox: '0 0 24 24',
width: 18,
height: 18,
fill: 'none',
stroke: 'currentColor',
strokeWidth: 1.75,
strokeLinecap: 'round',
strokeLinejoin: 'round',
};
const NAV_ICONS = {
home: (
),
leads: (
),
analytics: (
),
growth: (
),
calendar: (
),
tommy: (
),
settings: (
),
signout: (
),
};
// Manual-entry modal for staff to add a lead they met in person or on the
// phone. POSTs to /api/v2/leads (which also round-trips to GHL if the PIT
// is configured). On success, fires a `raw:lead-created` CustomEvent so
// the Sidebar pipeline counts and the Home KPIs refresh.
const LEAD_SOURCES = [
{ value: 'walk-in', label: 'Walk-in' },
{ value: 'referral', label: 'Referral' },
{ value: 'facebook-ad', label: 'Paid social' },
{ value: 'website', label: 'Website' },
{ value: 'manual', label: 'Other' },
];
function LeadRegistrationModal({ open, onClose }) {
const [name, setName] = React.useState('');
const [phone, setPhone] = React.useState('');
const [email, setEmail] = React.useState('');
const [source, setSource] = React.useState('walk-in');
const [notes, setNotes] = React.useState('');
const [submitting, setSubmitting] = React.useState(false);
const [error, setError] = React.useState(null);
const firstFieldRef = React.useRef(null);
// Reset form and focus name field each time the modal opens
React.useEffect(() => {
if (!open) return;
setName(''); setPhone(''); setEmail('');
setSource('walk-in'); setNotes('');
setError(null); setSubmitting(false);
setTimeout(() => firstFieldRef.current?.focus(), 0);
}, [open]);
// Esc closes
React.useEffect(() => {
if (!open) return;
function onKey(e) { if (e.key === 'Escape' && !submitting) onClose(); }
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open, submitting, onClose]);
if (!open) return null;
const canSubmit = name.trim() && phone.trim() && !submitting;
async function submit(e) {
e?.preventDefault();
if (!canSubmit) return;
setSubmitting(true);
setError(null);
try {
const payload = {
name: name.trim(),
phone: phone.trim(),
email: email.trim() || undefined,
source,
notes: notes.trim() || undefined,
};
const res = await api.post('/api/v2/leads', payload);
if (res?.error) throw new Error(res.error);
window.dispatchEvent(new CustomEvent('raw:lead-created', { detail: res }));
onClose();
} catch (err) {
setError(err?.message || 'Failed to create lead');
setSubmitting(false);
}
}
return (
{ if (e.target === e.currentTarget && !submitting) onClose(); }}
role="dialog"
aria-modal="true"
aria-labelledby="lead-modal-title"
>
);
}
function Sidebar({ view, onNavigate, onSignOut, onOpenLead }) {
const pipeline = useAsync(() => api.get('/api/v2/pipeline-counts'), []);
const pending = useAsync(() => api.get('/api/v2/tommy/pending-approvals').catch(() => ({ approvals: [] })), []);
const [mobileOpen, setMobileOpen] = React.useState(false);
const isMobile = useMediaQuery('(max-width: 768px)');
const drawerRef = React.useRef(null);
const lastFocusRef = React.useRef(null);
// Refresh pipeline counts when a new lead is created from the modal.
React.useEffect(() => {
function onLead() { pipeline.reload(); }
window.addEventListener('raw:lead-created', onLead);
return () => window.removeEventListener('raw:lead-created', onLead);
}, [pipeline.reload]);
const [tommyActive, setTommyActive] = React.useState(true);
React.useEffect(() => {
function onOpen() { setMobileOpen(true); }
function onKey(e) { if (e.key === 'Escape') setMobileOpen(false); }
window.addEventListener('raw:mobile-nav-open', onOpen);
window.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('raw:mobile-nav-open', onOpen);
window.removeEventListener('keydown', onKey);
};
}, []);
React.useEffect(() => {
if (!mobileOpen) return;
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}, [mobileOpen]);
React.useEffect(() => {
if (!isMobile || !mobileOpen) return;
lastFocusRef.current = document.activeElement;
const drawer = drawerRef.current;
if (drawer) {
const firstFocusable = drawer.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
(firstFocusable || drawer).focus();
}
const onKeyDown = (e) => {
if (e.key !== 'Tab' || !drawer) return;
const focusables = drawer.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (!focusables.length) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('keydown', onKeyDown);
if (lastFocusRef.current && lastFocusRef.current.focus) {
lastFocusRef.current.focus();
}
};
}, [isMobile, mobileOpen]);
const go = (next) => {
onNavigate(next);
setMobileOpen(false);
};
const navItems = [
{ view: 'home', label: 'Home', ico: NAV_ICONS.home },
{ view: 'leads', label: 'Leads', ico: NAV_ICONS.leads },
{ view: 'analytics', label: 'Analytics', ico: NAV_ICONS.analytics },
{ view: 'growth', label: 'Growth', ico: NAV_ICONS.growth },
{ view: 'calendar', label: 'Calendar', ico: NAV_ICONS.calendar },
{ view: 'tommy', label: 'Tommy', ico: NAV_ICONS.tommy },
{ view: 'settings', label: 'Settings', ico: NAV_ICONS.settings },
];
const counts = pipeline.data?.counts || {};
const total = pipeline.data?.total;
const pending_ = (pending.data?.approvals || pending.data?.pending || []).length;
const pipelineRows = [
{ label: 'New Lead', n: counts['NEW LEAD'] || 0, color: 'var(--muted-2)' },
{ label: 'First Touch', n: counts['FIRST TOUCH'] || 0, color: 'var(--blue)' },
{ label: 'Interested', n: counts['INTERESTED'] || 0, color: 'var(--warn)' },
{ label: 'Day Pass Scheduled', n: counts['DAY PASS SCHEDULED'] || 0, color: 'var(--signal)' },
{ label: 'Showed', n: counts['SHOWED'] || 0, color: 'var(--green)' },
];
return (
<>
setMobileOpen(false)}
aria-hidden="true"
/>
>
);
}
Object.assign(window, { Sidebar, TopRail, CommandPalette, LeadRegistrationModal, Sparkline, useAsync, useMediaQuery });