// ============================================================================ // Mini-CRM — "Today at Aurora One" + the studio session controls. // ---------------------------------------------------------------------------- // Three pieces, all gated on a signed-in presenter (uni_presenter_v1 with a PIN): // • MiniCRM — a registered screen (#/minicrm) listing today's walk-ins from // the relay; tap an un-attended guest to "Start presenting". // • StudioBar — a floating control cluster (sits by the presenter chip): // "Today" (opens the mini-CRM) + "End session" (opens the modal). // Also the shim that STARTS the journey tracker the moment a // customer is claimed, so per-screen dwell times are captured. // • EndSessionModal — disposition + notes + the auto-collected screen-time // summary → POST /api/studio/session-end → CRM lead timeline. // data-crm="1" everywhere so the salesperson's taps never pollute the journey. // Matches the app's ivory / gold / ink aesthetic. // ============================================================================ (function () { // Same-origin base: '' → calls resolve to /api/studio/* on whichever host serves // this page (vega.khosha.tech, century.thephygital.studio, …). Never hard-code a host. const RELAY = ''; const PKEY = 'uni_presenter_v1'; function presenter() { try { return JSON.parse(sessionStorage.getItem(PKEY)) || null; } catch (e) { return null; } } function passcode() { const p = presenter(); return p && p.passcode ? p.passcode : ''; } function signedIn() { const p = presenter(); return !!(p && p.passcode && !p.skipped); } const fmtMins = (ms) => { const m = Math.round((ms || 0) / 60000); return m < 1 ? '<1m' : m + 'm'; }; // ── RealDesk analytics: "a guest was checked in and a journey started". // PRIVACY: lead id + non-identifying stage only — never the guest's name, // phone or email. Guarded so telemetry can never break a live demo. function trackCheckin(leadId, row) { try { if (!window.RDA) return; window.RDA.track('checkin', leadId || null, 1, { via: 'minicrm-screen', stage: (row && row.stage) || null, resumed: !!(row && row.attended), }); } catch (e) {} } // ── The mini-CRM screen ───────────────────────────────────────────────── function MiniCRM() { const [rows, setRows] = React.useState([]); const [state, setState] = React.useState('idle'); // idle | loading | ready | error | denied const [err, setErr] = React.useState(''); const [claiming, setClaiming] = React.useState(null); // lead id currently being claimed const [claimErr, setClaimErr] = React.useState(''); const load = React.useCallback(async () => { let pc = passcode(); if (!pc && window.UNI_PRESENTER && window.UNI_PRESENTER.autoUnlock) { // Cold visitor landing straight on #/minicrm: the demo auto-unlock // (presenter.jsx) may still be in flight — wait for it before showing // the locked state. const p = await window.UNI_PRESENTER.autoUnlock(); pc = (p && p.passcode) || passcode(); } if (!pc) { setState('denied'); return; } setState('loading'); setErr(''); try { const res = await fetch(`${RELAY}/api/studio/walkins?passcode=${encodeURIComponent(pc)}`); const data = await res.json(); if (data && data.ok) { setRows(data.walkins || []); setState('ready'); } else { setErr(data && data.error === 'invalid_passcode' ? 'PIN not recognised — unlock again.' : 'Couldn’t load walk-ins.'); setState('error'); } } catch (e) { setErr('Couldn’t reach the studio service.'); setState('error'); } }, []); React.useEffect(() => { load(); }, [load]); // Selecting a customer + starting their journey is what LINKS them to the // salesperson — no typed phone number. Claim the lead by id via the relay, // then set them as the active session and drop onto Home to present. async function startPresenting(row) { if (claiming) return; const pc = passcode(); if (!pc) { setState('denied'); return; } setClaiming(row.id); setClaimErr(''); try { const res = await fetch(`${RELAY}/api/studio/claim-by-id`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ passcode: pc, lead_id: row.id }), }); const data = await res.json(); if (data && data.ok) { const cust = { id: data.lead_id || row.id, name: data.lead_name || row.name }; trackCheckin(data.lead_id || row.id, row); if (window.UNI_SESSION) { window.UNI_SESSION.setCustomer(cust); if (!window.UNI_SESSION.isActive()) window.UNI_SESSION.start(cust); } if (window.navigate) window.navigate('home'); } else { setClaimErr(data && data.error === 'invalid_passcode' ? 'PIN not recognised — unlock again.' : data && data.error === 'lead_not_found' ? 'This lead is no longer active.' : 'Couldn’t start the journey.'); setClaiming(null); } } catch (e) { setClaimErr('Couldn’t reach the studio service.'); setClaiming(null); } } return (
Vega Realty · Sales Studio
Today at Aurora One
Walk-ins registered today · tap an awaiting guest to begin their tour.
{state === 'denied' &&
Unlock the studio first (triple-tap the centre logo on Home).
} {state === 'error' &&
{err}
} {state === 'ready' && rows.length === 0 &&
No walk-ins yet today.
} {claimErr &&
{claimErr}
}
{rows.map((r) => (
{r.attended ? ( Attended ) : ( Awaiting )} {prettyStage(r.stage)}
{r.name || 'Guest'}
{r.phone}
{r.owner &&
Owner · {r.owner.name}
}
{r.attended ? ( ) : ( )}
))}
); } function prettyStage(s) { if (!s) return '—'; return String(s).replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); } // ── The end-session modal ─────────────────────────────────────────────── function EndSessionModal({ onClose }) { const [dispositions, setDispositions] = React.useState([]); const [pick, setPick] = React.useState(''); const [notes, setNotes] = React.useState(''); const [phase, setPhase] = React.useState('form'); // form | busy | done | error const [err, setErr] = React.useState(''); const snap = React.useRef(window.UNI_SESSION ? window.UNI_SESSION.snapshot() : null).current; const customer = (window.UNI_SESSION && window.UNI_SESSION.getCustomer()) || (snap && snap.customer) || null; React.useEffect(() => { (async () => { try { const res = await fetch(`${RELAY}/api/studio/dispositions?passcode=${encodeURIComponent(passcode())}`); const data = await res.json(); if (data && data.ok) setDispositions(data.dispositions || []); } catch (e) {} })(); }, []); const screens = (snap && snap.screens) ? snap.screens.filter((s) => s.ms > 1500) : []; async function submit() { if (!pick) { setErr('Pick a disposition.'); return; } setPhase('busy'); setErr(''); const events = (snap && snap.screens ? snap.screens : []).map((s) => ({ screen: s.path, seconds: Math.round(s.ms / 1000) })); const body = { passcode: passcode(), lead_id: customer && customer.id ? customer.id : undefined, phone: customer && customer.phone ? customer.phone : undefined, disposition: pick, notes, events, started_at: snap ? snap.startedAt : 0, }; try { const res = await fetch(`${RELAY}/api/studio/session-end`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const data = await res.json(); if (data && data.ok) { if (window.UNI_SESSION && window.UNI_SESSION.isActive()) window.UNI_SESSION.end({ disposition: pick, remark: notes }); else if (window.UNI_SESSION) window.UNI_SESSION.setCustomer(null); setPhase('done'); } else { setErr('Couldn’t save the session.'); setPhase('error'); } } catch (e) { setErr('Couldn’t reach the studio service.'); setPhase('error'); } } return (
{ if (e.target.classList.contains('esm-overlay') && phase !== 'busy') onClose(); }}>
{phase === 'done' ? (
Session saved
Logged to {customer ? customer.name : 'the guest'}’s CRM timeline.
) : (
End session
{customer ? customer.name : 'This guest'}
Time in the studio · {snap ? fmtMins(snap.durationMs) : '—'}
{screens.length ? (
{screens.map((s) => (
{s.label}{fmtMins(s.ms)}
))}
) :
No screen dwell captured yet.
}
Disposition
{dispositions.map((d) => ( ))}
Notes (optional)