/* global React, SparkyIcons */ const { useState, useEffect, useRef } = React; const I = window.SparkyIcons; // ─────────────────────────── shared bits ─────────────────────────── function useClock() { const fmt = () => new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); const [time, setTime] = useState(fmt); useEffect(() => { const t = setInterval(() => setTime(fmt()), 30000); return () => clearInterval(t); }, []); return time; } const StatusBar = ({ dark = false }) => { const time = useClock(); return (
{time}
); }; const HomeBar = ({ dark = false }) =>
; const Wave = ({ count = 23, color, style }) => { const bars = Array.from({ length: count }, (_, i) => { const delay = (i * 0.07) % 0.8; const dur = 0.6 + ((i * 0.13) % 0.6); return ; }); return
{bars}
; }; const TabBar = ({ active, onNav, dark = false }) => { const tabs = [ { id: "main", label: "Home", icon: I.IconHome }, { id: "schedule", label: "Schedule", icon: I.IconCalendar }, { id: "usage", label: "Usage", icon: I.IconClock }, { id: "settings", label: "Settings", icon: I.IconSettings }, ]; return (
{tabs.map(t => { const Ic = t.icon; return (
onNav(t.id)}> {t.label}
); })}
); }; // ───────────────────── 1. ONBOARDING (Hero) ───────────────────── const Onboarding = ({ onNext }) => (
built for ServiceM8

Run your day
by voice.

Quote, dispatch, log time and book customers — hands-free. Connect ServiceM8 in one tap.

20
min free
0
cards
1-tap
connect
SOC 2 · OAuth · no passwords stored
); // ───────────────────── 2. CONNECT (OAuth) ───────────────────── const Connect = ({ onNext, onBack, oauthUrl }) => { const [stage, setStage] = useState("intro"); // intro → authorizing → done const handleAuthorize = () => { if (oauthUrl) { window.location.href = oauthUrl; } else { setStage("authorizing"); setTimeout(() => { setStage("done"); setTimeout(onNext, 600); }, 1500); } }; return (
Step 1 of 1
{[0,1,2].map(i =>
)}
SM8

Connect ServiceM8

One tap. We'll redirect to ServiceM8 to authorise — takes about 10 seconds.

What we'll access
{[ [I.IconCalendar, "Read jobs & schedule"], [I.IconUser, "Read & manage clients"], [I.IconZap, "Create & update jobs"], [I.IconChat, "Add notes to jobs"], ].map(([Ic, t], i) => (
{t}
))}
Your payment data is never accessed or stored — billing is handled securely by Stripe
LoopTradie is an independent add-on and is not affiliated with or endorsed by ServiceM8™.
); }; // ───────────────────── 3. MAIN (Voice-first home) ───────────────────── const Main = ({ isDesktop = false, onNav, onCall, onStartJob, onSuggest, secondsLeft = null, userName, jobsToday, unreadMessages, nextJob = null, checkoutBanner, onDismissBanner, callActive = false, onReturnToCall, tenantTz = null }) => { const balanceLoaded = secondsLeft !== null; const totalSec = Math.max(0, Math.floor(secondsLeft ?? 0)); const mins = Math.floor(totalSec / 60); const secs = totalSec % 60; const greeting = userName ? `Hi, ${userName}` : "Good morning"; const jobsHeading = (() => { if (jobsToday === null && unreadMessages === null) return Loading your day…; const jobPart = jobsToday === null ? '' : `${jobsToday} job${jobsToday !== 1 ? 's' : ''} today`; if (unreadMessages > 0) return <>{jobPart}, {unreadMessages} unread messages.; return <>{jobPart || 'No jobs scheduled today'}.; })(); return (
{callActive && (
Call in progress — tap to return
)} {checkoutBanner === 'success' && (
You're subscribed — your balance has been topped up.
)}
{balanceLoaded ? `${mins}:${String(secs).padStart(2,"0")} left` : "…"}
{greeting}

{jobsHeading}

{/* Big mic */}
0 ? onCall : undefined} style={{ opacity: balanceLoaded ? 1 : 0.4, cursor: balanceLoaded && totalSec > 0 ? "pointer" : "default" }}>
Tap to talk
or try a suggestion below
{/* Suggestions */}
Try saying
{[ ["What jobs do I have today?", "📅"], ["Create a new job for a leaking tap", "🔧"], ["Add a call-out fee to my last job", "💵"], ["Mark my current job as completed", "✅"], ].map(([t, e], i) => (
0 ? "pointer" : "default", opacity: balanceLoaded && totalSec === 0 ? 0.4 : 1 }} onClick={balanceLoaded && totalSec > 0 ? () => onSuggest ? onSuggest(t) : onCall() : undefined}> {e} {t}
))}
{/* Up next — only shown when a job is scheduled */} {nextJob && (
{(() => { const isCompleted = (nextJob.status || '').toLowerCase() === 'completed'; const statusLabel = isCompleted ? 'completed' : nextJob.minutes_until <= 0 ? 'in progress' : 'en route'; const timeLabel = isCompleted ? `Up next · ${nextJob.status}` : nextJob.minutes_until <= 0 ? 'Up next · in progress' : `Up next · ${nextJob.minutes_until} min`; return (
{timeLabel}
{!isCompleted && } {statusLabel}
); })()}
{nextJob.customer_name || "Unknown customer"}{nextJob.job_description ? ` — ${nextJob.job_description}` : ""}
{nextJob.job_address || "No address"} {nextJob.start_date ? ` · arrive ${new Date(nextJob.start_date).toLocaleTimeString('en-AU', { hour: 'numeric', minute: '2-digit', timeZone: tenantTz || Intl.DateTimeFormat().resolvedOptions().timeZone })}` : ""}
{(nextJob.status || '').toLowerCase() !== 'completed' && ( )} {nextJob.job_address && ( )}
)}
{/* /max-width wrapper */}
{!isDesktop && } {!isDesktop && }
); }; // ───────────────────── 4. CALL (active voice) — living conversation ───────────────────── // State-aware orb: listening → thinking → speaking → acting const STATE_LABEL = { listening: "Listening", thinking: "Thinking", speaking: "Speaking", acting: "Working" }; const StateOrb = ({ state, size = 76 }) => (
{state === "speaking" ?
{[0,1,2,3,4].map(i => )}
: state === "thinking" ?
{[0,1,2].map(i => )}
: }
); // Inline action card that fills in live (pending → done) const ActionCard = ({ kind, status }) => { if (kind === "booking") { const done = status === "done"; return (
{done ? "Job created" : "Creating job…"} {done ? : }
Mrs Lee — leaking tap
Thu 2:00 PM · 15 Park St {done && "· #4429"}
{done && (
✓ SMS sent to client
)}
); } return null; }; // Self-contained ticking clock — isolated so the call screen's message // stream doesn't re-render (and restart entrance animations) every second. const CallTimer = () => { const [elapsed, setElapsed] = useState(0); useEffect(() => { const t = setInterval(() => setElapsed(e => e + 1), 1000); return () => clearInterval(t); }, []); const mm = String(Math.floor(elapsed / 60)).padStart(2, "0"); const ss = String(elapsed % 60).padStart(2, "0"); return {mm}:{ss}; }; const Call = ({ isDesktop = false, onEnd, onNav, live = false, stream: extStream = [], callState: extState = "listening", caption: extCaption = "Listening…", onSendText, secondsLeft = 1200, error = null, onDismissError, isMuted = false, onToggleMute }) => { const [view, setView] = useState("convo"); // convo | voice | visual | inbound const [state, setState] = useState(live ? extState : "listening"); const [caption, setCaption] = useState(live ? extCaption : "Listening…"); const [textVal, setTextVal] = useState(""); const streamRef = useRef(null); const textInputRef = useRef(null); // Stream comes entirely from parent — typed messages are inserted there too, // so chronological order is maintained alongside transcription bubbles. const stream = extStream; // sync live props into local state useEffect(() => { if (!live) return; setState(extState); setCaption(extCaption); }, [live, extState, extCaption]); // autoscroll stream useEffect(() => { if (streamRef.current) streamRef.current.scrollTop = streamRef.current.scrollHeight; }, [stream, state]); const MicOffIcon = ({ size = 20 }) => ( ); const Controls = () => (
{isMuted && (
Mic muted — type to send a message
)}
); if (error) return (
Couldn't connect
{error}
); return (
{/* top bar */}
{STATE_LABEL[state]}
{/* mode tabs */}
{[["convo","Conversation"],["voice","Voice"],["visual","Visual"],["inbound","Inbound"]].map(([id, label]) => ( ))}
{/* ── CONVERSATION (new default: living stream) ── */} {view === "convo" && (
{/* compact orb header + caption */}
{caption}
{/* stream */}
{stream.map((m, i) => m.type === "action" ? : (
{m.who === "you" ? "You" : "Loop"}
{m.text}
))}
{live && onSendText && (
setTextVal(e.target.value)} onKeyDown={e => { if (e.key === "Enter" && !e.shiftKey && textVal.trim()) { e.preventDefault(); onSendText(textVal.trim()); setTextVal(""); } }} />
)}
{live ? `Say "close chat" or "close call" · or tap ⏹ to end` : `Just speak to interrupt · say "close chat" or tap ⏹ to end`}
)} {/* ── VOICE (big immersive orb) ── */} {view === "voice" && (
{(() => { const last = [...stream].reverse().find(s => s.text); return last ? `"${last.text}"` : "Listening…"; })()}
)} {/* ── VISUAL (live quote draft) ── */} {view === "visual" && (
Drafting live as you speak
Quote · Q-2204 · draft live
Wong — kitchen tap
Mixer tap (Reece SKU 8821)$180
Labour (2h @ $80)$160
Subtotal$340
)} {/* ── INBOUND (customer-facing) ── */} {view === "inbound" && (
Incoming · live 01:34
Caller — matched to client
Tom Henderson
+61 400 123 456 · client since 2021 · 8 jobs
"hot water's been out since this morning"
Loop
Sorry to hear that, Tom. I can get Jamie to you Tuesday at 4 PM — shall I lock it in?
)} {live && secondsLeft <= 30 && secondsLeft > 0 && (
{secondsLeft}s left — upgrade to keep talking
)} {Controls()}
{!isDesktop && }
); }; // ───────────────────── 5. USAGE ───────────────────── const Usage = ({ isDesktop = false, onNav, secondsLeft = 0, secondsUsed = 0, planSeconds = 1200, callCount = 0, isTrial = true, onChoosePlan }) => { const [loadingPlan, setLoadingPlan] = React.useState(null); const balance = Math.max(0, Math.floor(secondsLeft)); const total = planSeconds || 1200; // Derive used from plan - balance when seconds_used_total is missing/zero but balance was consumed const rawUsed = Math.max(0, Math.floor(secondsUsed)); const derived = Math.max(0, total - balance); const used = rawUsed > 0 ? rawUsed : derived; const pct = Math.min(100, (balance / total) * 100); const low = balance < 60; const poolLabel = isTrial ? `Trial · ${Math.floor(total / 60)}-min pool` : `Prepaid · ${Math.floor(total / 60)}-min pool`; const subLabel = isTrial ? 'Free trial — upgrade to keep going' : 'Your prepaid pool'; const fmtSec = (s) => { const mm = Math.floor(s / 60); const ss = s % 60; return `${mm}:${String(ss).padStart(2, "0")}`; }; const handleChoose = async (planKey) => { if (!onChoosePlan || loadingPlan) return; setLoadingPlan(planKey); try { await onChoosePlan(planKey); } finally { setLoadingPlan(null); } }; return (
Usage
{subLabel}
{poolLabel}
{fmtSec(balance)}
seconds left
{fmtSec(used)} used {fmtSec(total)} total
{balance <= 0 && (
Trial time used up
Choose a plan below to keep talking to your AI assistant.
)}
Voice used
{fmtSec(used)}
{callCount} call{callCount !== 1 ? 's' : ''}
Remaining
{fmtSec(balance)}
{low ? "running low" : "available"}
Plans
{[ ["starter", "Starter", "$29", "50 min · solo operator", false], ["pro", "Pro", "$59", "150 min · 1–3 person crew", true], ["business", "Business", "$99", "350 min · multi-truck team", false], ].map(([key, name, price, desc, hot]) => (
{hot && most popular}
{name}
{price}/mo
{desc}
))}
{!isDesktop && } {!isDesktop && }
); }; // ───────────────────── 6. SETTINGS ───────────────────── const Settings = ({ isDesktop = false, onNav, onSignOut, tenantName, isTrial, secondsLeft }) => (
Settings
Tune your agent.
{tenantName ? tenantName.charAt(0).toUpperCase() : "?"}
{tenantName || "Your business"}
ServiceM8 account
Connection
ServiceM8
● connected {tenantName ? ` · ${tenantName}` : ""}
Voice
{[ [I.IconWaveform, "#7c3aed", "Voice", "Mara (en-AU, female)"], [I.IconStar, "var(--accent)", "Wake word", '"Hey Loop" · off'], [I.IconBell, "var(--good)", "Notifications", "SMS · push"], ].map(([Ic, color, t, sub], i) => (
{t}
{sub}
Coming soon
))}
Privacy & plan
onNav("privacy")}>
Terms & Privacy
Terms of use · transcripts kept 30 days
onNav("usage")}>
Plan
{isTrial ? (secondsLeft !== null && secondsLeft <= 0 ? "Trial ended · tap to choose a plan" : "Trial · tap to view plans") : "Active plan · tap to manage"}
Loop v1.0 · made for ServiceM8
{!isDesktop && } {!isDesktop && }
); // ───────────────────── 7. PRIVACY / TERMS ───────────────────── const Privacy = ({ isDesktop = false, onNav }) => (
Terms & Privacy
Last updated: June 2026

1. Agreement to Terms

By accessing or using the Looptradie service ("Service"), you agree to be bound by these Terms and Conditions ("Terms"). If you do not agree, you must not use the Service. Continued use of the Service after any changes to these Terms constitutes your acceptance of the revised Terms.

2. About the Service

Looptradie provides an AI-powered voice assistant that connects to your ServiceM8 account to help manage jobs, scheduling, and customer communications. The Service is operated by Looptradie ("we", "us", "our").

3. No Professional Advice

All responses generated by the AI assistant are for informational and operational convenience only. They do not constitute professional, legal, financial, or trade advice. You are solely responsible for verifying any information provided by the AI before acting on it. We make no warranty that the AI's responses are accurate, complete, or suitable for your specific circumstances.

4. Limitation of Liability

To the maximum extent permitted by law, Looptradie and its directors, employees, and agents shall not be liable for any direct, indirect, incidental, special, consequential, or punitive damages arising from: (a) your use of or inability to use the Service; (b) errors, inaccuracies, or omissions in AI-generated content; (c) any action or decision taken in reliance on the Service; or (d) unauthorised access to or use of our servers or any personal information stored therein.

5. ServiceM8 Data

Looptradie accesses your ServiceM8 account data solely on your behalf and only to the extent necessary to operate the Service. We do not sell your data to third parties. Voice transcripts are retained for up to 30 days to enable session review, after which they are permanently deleted.

6. Accuracy of AI Responses

The AI assistant may occasionally produce incorrect, outdated, or incomplete information. You acknowledge that AI technology has inherent limitations and agree not to rely solely on the Service for critical business decisions. Always confirm important details directly within ServiceM8 or with the relevant parties.

7. Acceptable Use

You agree not to use the Service for any unlawful purpose, to harass or harm others, to transmit malicious code, or to attempt to gain unauthorised access to any systems. We reserve the right to suspend or terminate your access immediately for breach of these Terms.

8. Service Availability & Suspension

We do not guarantee uninterrupted availability of the Service. Access may be suspended or terminated at any time for non-payment, misuse, or maintenance. We will endeavour to provide reasonable notice where practicable.

9. Intellectual Property

All intellectual property in the Looptradie Service, including software, design, and content (excluding your ServiceM8 data), remains the property of Looptradie. Nothing in these Terms grants you any rights in that intellectual property.

10. Changes to the Service

We may modify, suspend, or discontinue any part of the Service at any time. We may also update these Terms at any time. Continued use of the Service after changes are posted constitutes acceptance of the updated Terms.

11. Governing Law

These Terms are governed by the laws of Australia. Any disputes arising from these Terms or your use of the Service shall be subject to the exclusive jurisdiction of the courts of Australia.

12. Contact

For questions about these Terms, contact us at support@looptradie.com.

By using Looptradie, you acknowledge that you have read, understood, and agree to be bound by these Terms and Conditions.
); // ───────────────────── 7. TIME EXPIRED ───────────────────── const TimeExpired = ({ onNav }) => (
Time's up
Your free trial has ended. Choose a plan to keep using your AI assistant.
); // ───────────────────── SCHEDULE ───────────────────── const Schedule = ({ isDesktop = false, onNav, schedule = [], tenantTz = null }) => { const tz = tenantTz || Intl.DateTimeFormat().resolvedOptions().timeZone; const today = new Date().toLocaleDateString('en-CA', { timeZone: tz }); const tomorrow = new Date(Date.now() + 86400000).toLocaleDateString('en-CA', { timeZone: tz }); const dayLabel = (dateStr) => { if (dateStr === today) return "Today"; if (dateStr === tomorrow) return "Tomorrow"; return new Date(dateStr + 'T12:00:00').toLocaleDateString('en-AU', { weekday: 'short', day: 'numeric', month: 'short' }); }; const grouped = schedule.reduce((acc, item) => { const key = item.date || (item.start_date || '').slice(0, 10); if (!acc[key]) acc[key] = []; acc[key].push(item); return acc; }, {}); const sortedDates = Object.keys(grouped).sort(); return (
Schedule
Next 7 days.
{sortedDates.length === 0 && (
No jobs scheduled in the next 7 days.
)} {sortedDates.map(dateKey => (
{dayLabel(dateKey)}
{grouped[dateKey].map((item, i) => { const startTime = item.start_date ? new Date(item.start_date).toLocaleTimeString('en-AU', { hour: 'numeric', minute: '2-digit', timeZone: tz }) : null; const endTime = item.end_date ? new Date(item.end_date).toLocaleTimeString('en-AU', { hour: 'numeric', minute: '2-digit', timeZone: tz }) : null; return (
{startTime}{endTime ? ` – ${endTime}` : ""}
{item.status && {item.status}}
{item.customer_name || "Unknown"}{item.job_description ? ` — ${item.job_description}` : ""}
{item.job_address || "No address"}
); })}
))}
{!isDesktop && } {!isDesktop && }
); }; // ───────────────────── DESKTOP COMPONENTS ───────────────────── const DesktopNav = ({ screen, onNav, userName, tenantName }) => { const tabs = [ { id:"main", label:"Home", Icon:I.IconHome }, { id:"schedule", label:"Schedule", Icon:I.IconCalendar }, { id:"usage", label:"Usage", Icon:I.IconClock }, { id:"settings", label:"Settings", Icon:I.IconSettings }, ]; return (
{tabs.map(({ id, label, Icon }) => (
onNav(id)}> {label}
))}
{tenantName || "Your business"} {userName ? `Hi, ${userName}` : "ServiceM8 account"}
); }; const DesktopSchedulePanel = ({ schedule = [], nextJob, onNav, tenantTz = null }) => { const tz = tenantTz || Intl.DateTimeFormat().resolvedOptions().timeZone; const today = new Date().toLocaleDateString('en-CA', { timeZone: tz }); const todayJobs = schedule.filter(j => (j.date || (j.start_date||'').slice(0,10)) === today); return (
Today's schedule
{nextJob && (
{nextJob.minutes_until<=0 ? "In progress" : `In ${nextJob.minutes_until} min`}
{nextJob.customer_name}{nextJob.job_description?` — ${nextJob.job_description}`:""}
{nextJob.job_address}
)} {todayJobs.length === 0 && !nextJob ?
No jobs scheduled today.
: todayJobs.map((item, i) => { const t = item.start_date ? new Date(item.start_date).toLocaleTimeString('en-AU', { hour: 'numeric', minute: '2-digit', timeZone: tz }) : null; return (
{t &&
{t}
}
{item.customer_name}
{item.job_description||item.job_address}
); }) }
); }; const DesktopTranscriptPanel = ({ stream = [] }) => { const ref = useRef(null); useEffect(() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; }, [stream]); return (
Live transcript
{stream.filter(m=>m.text).map((m,i) => (
{m.who==="you" ? "You" : "Loop"}
{m.text}
))} {stream.length === 0 && (
Transcript will appear here once the call starts.
)}
); }; window.SparkyScreens = { Onboarding, Connect, Main, Call, Usage, Settings, Privacy, TimeExpired, Schedule, DesktopNav, DesktopSchedulePanel, DesktopTranscriptPanel };