/* global React, ReactDOM, SparkyScreens, SparkyIcons */ const { useState, useEffect, useRef } = React; const S = window.SparkyScreens; const I = window.SparkyIcons; const CONFIG = { N8N_BASE: 'https://api.looptradie.com', LIVEKIT_URL: 'wss://test1-ntnp1zzo.livekit.cloud', OAUTH_CLIENT_ID: '681217', USE_WEBHOOK_DISPATCH: false, }; const DASHBOARD_URL = `${CONFIG.N8N_BASE}/webhook/sm8-dashboard`; const N8N_WEBHOOK_URL = `${CONFIG.N8N_BASE}/webhook/generate-voice-token`; const DISPATCH_URL = `${CONFIG.N8N_BASE}/webhook/dispatch-agent`; const OAUTH_REDIRECT_URI = `${CONFIG.N8N_BASE}/webhook/servicem8-oauth-callback`; const SESSION_KEY = 'sm8_session'; const OAUTH_SCOPES = [ 'vendor', 'read_staff', 'manage_customers', 'manage_customer_contacts', 'manage_jobs', 'create_jobs', 'manage_job_contacts', 'manage_job_materials', 'read_job_payments', 'read_schedule', 'manage_schedule', 'publish_job_notes', 'read_job_notes', 'read_inventory', ]; const OAUTH_URL = [ 'https://go.servicem8.com/oauth/authorize', '?response_type=code', `&client_id=${CONFIG.OAUTH_CLIENT_ID}`, `&scope=${encodeURIComponent(OAUTH_SCOPES.join(' '))}`, `&redirect_uri=${encodeURIComponent(OAUTH_REDIRECT_URI)}`, '&state=tenant_001', ].join(''); function App() { const [screen, setScreen] = useState('loading'); const [sessionCtx, setSessionCtx] = useState(null); const [secondsLeft, setSecondsLeft] = useState(null); // null = not yet loaded from server const [secondsUsed, setSecondsUsed] = useState(0); const [planSeconds, setPlanSeconds] = useState(1200); const [callCount, setCallCount] = useState(0); const [isTrial, setIsTrial] = useState(true); const [tenantName, setTenantName] = useState(''); // live call state const [callStream, setCallStream] = useState([]); const [callState, setCallState] = useState('listening'); const [callCaption, setCallCaption] = useState('Listening…'); const [isMuted, setIsMuted] = useState(false); const [checkoutBanner, setCheckoutBanner] = useState(null); // 'success' | 'cancel' | null const [callError, setCallError] = useState(null); // error message string | null // dashboard data const [jobsToday, setJobsToday] = useState(null); const [unreadMessages, setUnreadMessages] = useState(null); const [nextJob, setNextJob] = useState(null); const [schedule, setSchedule] = useState([]); const [tenantTz, setTenantTz] = useState(null); // desktop layout detection const [isDesktop, setIsDesktop] = useState(() => window.innerWidth >= 900); useEffect(() => { const mq = window.matchMedia('(min-width: 900px)'); const h = e => setIsDesktop(e.matches); mq.addEventListener('change', h); return () => mq.removeEventListener('change', h); }, []); const roomRef = useRef(null); const callIntervalRef = useRef(null); const callSecondsRef = useRef(0); const heartbeatIntervalRef = useRef(null); // segMap: bubble-key -> { who, text, firstTs, seq } // Rendered ordered by firstTs then seq, so bubbles never jump or duplicate. // AGENT: one stable segment.id per utterance, growing text — replaced in place. // USER: xAI emits a NEW segment.id per interim hypothesis, each already final=true, // with revised (not always prefix-clean) text. So we keep ONE open user bubble per // turn and overwrite it with the latest text; an agent segment closes the turn. const segMap = useRef(new Map()); const seqCounter = useRef(0); // userBubble: the currently-open user bubble { key, ts } for the active turn, or null const userBubble = useRef(null); useEffect(() => { init(); }, []); function signOut() { const session = sessionCtx?.session_token; if (session) fetch(`${CONFIG.N8N_BASE}/webhook/sign-out`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session }), }).catch(() => {}); localStorage.removeItem(SESSION_KEY); setSessionCtx(null); setScreen('onboarding'); } async function fetchDashboard(token) { if (!token) return; try { // Let the backend derive "today" from the tenant timezone (it falls back to that when no // date is supplied). Sending the device-local date caused "today" to mismatch the tenant // day when the device tz differs, hiding same-day bookings. const res = await fetch(`${DASHBOARD_URL}?session=${token}`); if (!res.ok) return; const data = await res.json(); setJobsToday(data.jobs_today ?? 0); setUnreadMessages(data.unread_messages ?? 0); setNextJob(data.next_job ?? null); setSchedule(data.schedule ?? []); if (data.timezone) setTenantTz(data.timezone); } catch(e) {} } async function init() { const params = new URLSearchParams(window.location.search); const urlToken = params.get('session'); const checkout = params.get('checkout'); // 'success' | 'cancel' from Stripe redirect if (checkout) { window.history.replaceState({}, '', window.location.pathname); if (checkout === 'success') setCheckoutBanner('success'); } if (urlToken) { try { const res = await fetch(`${N8N_WEBHOOK_URL}?session=${urlToken}`); if (res.ok) { const data = await res.json(); if (data.first_name) { const ctx = { first_name: data.first_name, session_token: data.session_token || urlToken }; localStorage.setItem(SESSION_KEY, JSON.stringify(ctx)); setSessionCtx(ctx); applyBalanceData(data); window.history.replaceState({}, '', window.location.pathname); setScreen('main'); fetchDashboard(ctx.session_token); return; } } } catch(e) {} } const stored = localStorage.getItem(SESSION_KEY); if (stored) { try { const ctx = JSON.parse(stored); if (ctx.first_name) { setSessionCtx(ctx); setScreen('main'); refreshBalance(ctx.session_token); fetchDashboard(ctx.session_token); return; } } catch(e) {} } if (window.smClient) { try { const data = await window.smClient.invoke('get_session_context', {}); if (data && data.session_token) { setSessionCtx(data); setScreen('main'); return; } } catch(e) {} } setScreen('onboarding'); } function applyBalanceData(data) { if (data.seconds_balance !== undefined) setSecondsLeft(Number(data.seconds_balance)); if (data.seconds_used_total !== undefined) setSecondsUsed(Number(data.seconds_used_total)); if (data.plan_seconds !== undefined) setPlanSeconds(Number(data.plan_seconds)); if (data.call_count !== undefined) setCallCount(Number(data.call_count)); if (data.is_trial !== undefined) setIsTrial(!!data.is_trial); if (data.tenant_name) setTenantName(data.tenant_name); } async function refreshBalance(token) { const t = token ?? sessionCtx?.session_token; if (!t) return; try { const res = await fetch(`${N8N_WEBHOOK_URL}?session=${t}`); if (!res.ok) return; applyBalanceData(await res.json()); } catch(e) {} } async function recordCall(session, seconds) { try { const res = await fetch(`${CONFIG.N8N_BASE}/webhook/record-call`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session, seconds }), }); if (res.ok) applyBalanceData(await res.json()); } catch(e) {} } async function startCall(jobUuid, promptText) { if (!sessionCtx) return; if (secondsLeft !== null && secondsLeft <= 0) { setScreen('timeexpired'); return; } setCallStream([]); setCallState('connecting'); setCallCaption('Getting secure token…'); setCallError(null); setIsMuted(false); segMap.current = new Map(); seqCounter.current = 0; userBubble.current = null; setScreen('call'); try { const tz = encodeURIComponent(Intl.DateTimeFormat().resolvedOptions().timeZone); const jobParam = jobUuid ? `&job_uuid=${encodeURIComponent(jobUuid)}` : ''; const res = await fetch(`${N8N_WEBHOOK_URL}?session=${sessionCtx.session_token}&call=1&timezone=${tz}${jobParam}`); if (!res.ok) { let msg = 'Could not start the call. Please try again.'; try { const j = await res.json(); if (j.error) msg = j.error; } catch {} setCallError(msg); return; } const data = await res.json(); applyBalanceData(data); const { Room, RoomEvent } = window.LivekitClient; const room = new Room(); roomRef.current = room; // Unlock the browser audio context now, while we still have the user gesture in scope. // TrackSubscribed fires asynchronously (after fetch + connect), so el.play() there would // be blocked by autoplay policy without this pre-unlock. room.startAudio().catch(() => {}); room.on(RoomEvent.TrackSubscribed, (track) => { if (track.kind !== 'audio') return; const el = track.attach(); el.autoplay = true; el.muted = false; document.body.appendChild(el); el.play().catch(() => {}); }); room.on(RoomEvent.TranscriptionReceived, (segments, participant) => { const isLocal = !!participant?.isLocal; // local = user mic STT, remote = agent const map = segMap.current; const now = Date.now(); for (const seg of segments) { const text = (seg.text || '').trim(); if (!text) continue; if (!isLocal) { // AGENT: stable segment.id, growing text — replace in place or open a new // bubble. An agent segment also closes any open user bubble (turn boundary). userBubble.current = null; const existing = map.get(seg.id); if (existing) existing.text = text; else map.set(seg.id, { who: 'agent', text, firstTs: now, seq: seqCounter.current++ }); continue; } // USER: xAI emits a new segment.id per interim hypothesis (each final=true) with // revised text that is not always a clean prefix. Keep ONE open bubble for the // turn and overwrite it with the latest text; a >10s gap starts a fresh turn. const ub = userBubble.current; const open = ub && map.has(ub.key) && (now - ub.ts) < 10000; if (open) { map.get(ub.key).text = text; ub.ts = now; } else { const key = `user-${now}-${seqCounter.current}`; map.set(key, { who: 'you', text, firstTs: now, seq: seqCounter.current++ }); userBubble.current = { key, ts: now }; } } // Order strictly by first-seen time, then insertion seq — never jumps on updates const bubbles = [...map.values()] .sort((a, b) => a.firstTs - b.firstTs || a.seq - b.seq) .map(b => ({ who: b.who, text: b.text })); setCallStream(bubbles); setCallState(isLocal ? 'listening' : 'speaking'); setCallCaption(isLocal ? 'Listening…' : 'Speaking…'); const joined = segments.map(s => s.text).join('').trim(); if (isLocal && /\bclose\s+(chat|call)\b/i.test(joined)) { setTimeout(() => { if (roomRef.current) roomRef.current.disconnect(); }, 800); } }); room.on(RoomEvent.Disconnected, async () => { if (callIntervalRef.current) { clearInterval(callIntervalRef.current); callIntervalRef.current = null; } if (heartbeatIntervalRef.current) { clearInterval(heartbeatIntervalRef.current); heartbeatIntervalRef.current = null; } const elapsed = callSecondsRef.current; callSecondsRef.current = 0; roomRef.current = null; // Record call duration — this persists the deduction in the DB if (elapsed > 0) await recordCall(sessionCtx?.session_token, elapsed); await new Promise(r => setTimeout(r, 500)); // If balance is now 0, show the time-expired upgrade screen setScreen(s => { const bal = Number(secondsLeft ?? 1); return bal <= 0 ? 'timeexpired' : 'main'; }); refreshBalance(); fetchDashboard(sessionCtx?.session_token); }); setCallCaption('Connecting to AI…'); await room.connect(CONFIG.LIVEKIT_URL, data.token); const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; await room.localParticipant.setMetadata(JSON.stringify({ first_name: data.first_name || sessionCtx.first_name || '', session_token: data.session_token || sessionCtx.session_token, timezone: userTimezone, })); await new Promise(r => setTimeout(r, 1000)); if (CONFIG.USE_WEBHOOK_DISPATCH) { fetch(DISPATCH_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ room_name: data.room_name }), }).catch(() => {}); } setCallCaption('Waiting for assistant…'); await new Promise(resolve => { const check = () => { if (Array.from(room.remoteParticipants.values()).some(p => p.identity.startsWith('agent'))) { resolve(); return; } setTimeout(check, 500); }; room.on('participantConnected', p => { if (p.identity.startsWith('agent')) resolve(); }); check(); }); // Start countdown only once the agent has joined — connecting time is not billed callSecondsRef.current = 0; callIntervalRef.current = setInterval(() => { callSecondsRef.current += 1; setSecondsLeft(s => { const next = Math.max(0, s - 1); if (next === 0) { setTimeout(() => { if (roomRef.current) roomRef.current.disconnect(); }, 0); } return next; }); }, 1000); // Heartbeat: persist elapsed seconds to server every 60s so other devices see live balance heartbeatIntervalRef.current = setInterval(async () => { const elapsed = callSecondsRef.current; if (elapsed > 0 && sessionCtx?.session_token) { try { await fetch(`${CONFIG.N8N_BASE}/webhook/heartbeat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session: sessionCtx.session_token, seconds: elapsed }), }); } catch {} } }, 60000); await new Promise(r => setTimeout(r, 500)); await room.localParticipant.setMicrophoneEnabled(true); setCallState('listening'); setCallCaption('Listening…'); await new Promise(r => setTimeout(r, 800)); if (promptText) { // Suggestion chip: add to stream as a user bubble then send await sendText(promptText); } else { // Plain call: send a silent kick so the agent delivers its greeting. // Don't add to the stream — the user didn't type or say this. try { await room.localParticipant.sendText('hi', { topic: 'lk.chat' }); } catch(e) {} } } catch(err) { console.error('[looptradie] call error:', err); if (callIntervalRef.current) { clearInterval(callIntervalRef.current); callIntervalRef.current = null; } if (heartbeatIntervalRef.current) { clearInterval(heartbeatIntervalRef.current); heartbeatIntervalRef.current = null; } roomRef.current = null; setCallError('Connection failed. Check your internet and try again.'); } } async function toggleMute() { if (!roomRef.current) return; const next = !isMuted; try { await roomRef.current.localParticipant.setMicrophoneEnabled(!next); setIsMuted(next); } catch(e) {} } async function endCall() { if (callIntervalRef.current) { clearInterval(callIntervalRef.current); callIntervalRef.current = null; } if (heartbeatIntervalRef.current) { clearInterval(heartbeatIntervalRef.current); heartbeatIntervalRef.current = null; } if (roomRef.current) await roomRef.current.disconnect(); roomRef.current = null; setIsMuted(false); setScreen('main'); } async function sendText(text) { if (!text || !roomRef.current) return; // Insert into segMap so typed messages are ordered chronologically alongside // transcription bubbles. Use a synthetic id — it never collides with LiveKit ids. // Close any open user bubble so a typed message is its own bubble and the next // spoken segment opens a fresh one. userBubble.current = null; const id = `typed-${Date.now()}`; segMap.current.set(id, { who: 'you', text, firstTs: Date.now(), seq: seqCounter.current++ }); setCallStream([...segMap.current.values()] .sort((a, b) => a.firstTs - b.firstTs || a.seq - b.seq) .map(b => ({ who: b.who, text: b.text }))); try { await roomRef.current.localParticipant.sendText(text, { topic: 'lk.chat' }); } catch(e) {} } async function onChoosePlan(plan) { if (!sessionCtx) return; try { const res = await fetch(`${CONFIG.N8N_BASE}/webhook/create-checkout-session`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session: sessionCtx.session_token, plan }), }); if (!res.ok) throw new Error('checkout ' + res.status); const data = await res.json(); if (data.url) window.location.href = data.url; } catch(e) { console.error('[looptradie] checkout error:', e); } } const goNav = id => { // If a call is active, redirect to the call screen regardless of where the user tried to go if (roomRef.current) { setScreen('call'); return; } if (id === 'main') setScreen('main'); else if (id === 'schedule') setScreen('schedule'); else if (id === 'usage') setScreen('usage'); else if (id === 'settings') setScreen('settings'); else if (id === 'privacy') setScreen('privacy'); }; if (screen === 'loading') { return (