// 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" >
— New Lead