Saturn Star
Mission Control · CRM Access
Default password: saturn2025

Dashboard

Syncing…
Connecting...
0:00
📞
Incoming Call
Unknown
🔍 ESC
Type to search leads, quotes, and clients…
`); win.document.close(); } // ============================================================ // EMAIL & QUOTE LINK // ============================================================ function getSettings() { try { return JSON.parse(localStorage.getItem('sscrm_settings')) || {}; } catch { return {}; } } function saveSettings(s) { localStorage.setItem('sscrm_settings', JSON.stringify(s)); } function generateQuoteURL(id) { const quote = DB.getQuote(id); const client = DB.getClient(quote.clientId); const data = { ...quote, clientName: client ? client.name : '—', clientEmail: client ? client.email : '', }; const encoded = btoa(unescape(encodeURIComponent(JSON.stringify(data)))); const base = window.location.href.replace(/[^/]*$/, 'quote-view.html'); return `${base}?q=${encoded}`; } function generateAcceptURL(id) { const base = window.location.href.replace(/[^/]*$/, 'quote-accept.html'); return `${base}?id=${encodeURIComponent(id)}`; } function copyAcceptLink(id) { const url = generateAcceptURL(id); navigator.clipboard.writeText(url).then(() => { toast('Customer acceptance link copied! ✓', 'success'); }).catch(() => { toast('Link: ' + url, 'info'); }); } function copyQuoteLink(id) { const url = generateQuoteURL(id); navigator.clipboard.writeText(url).then(() => { toast('Quote link copied to clipboard!', 'success'); const el = document.getElementById('quote-link-text'); if (el) { el.textContent = url; el.style.color = '#1a2744'; } }).catch(() => { // fallback const el = document.getElementById('quote-link-text'); if (el) { el.textContent = url; el.style.color = '#1a2744'; } toast('Link generated — copy it above', 'info'); }); } function showSendEmailModal(id, type = 'initial') { const quote = DB.getQuote(id); const client = DB.getClient(quote.clientId); if (!client || !client.email) { toast('No email address on file for this client', 'error'); return; } const url = generateQuoteURL(id); const clientFirst = client.name.split(' ')[0]; const templates = { initial: { subject: `Your Moving Quote from Saturn Star — ${quote.number}`, body: `Hi ${clientFirst},\n\nThank you for reaching out to Saturn Star Moving Company. Please find your personalized moving quote below.\n\nQuote #: ${quote.number}\nTotal: ${$(quote.total)} (incl. HST)\nDeposit to Book: ${$(quote.deposit)}\n${quote.moveDate ? 'Move Date: ' + fmtDate(quote.moveDate) + '\n' : ''}\nView and respond to your quote here:\n${url}\n\nThis quote is valid for ${quote.validDays || 30} days. Once you accept, a deposit of ${$(quote.deposit)} will confirm your booking.\n\nFeel free to call or reply to this email with any questions.\n\nBest regards,\nSaturn Star Moving Company\n(226) 215-9874\nbusiness@starmovers.ca`, }, followup: { subject: `Following Up — Your Quote ${quote.number} from Saturn Star`, body: `Hi ${clientFirst},\n\nI just wanted to follow up on the quote we sent over for your upcoming move.\n\nWe understand you may be comparing options — we'd love the opportunity to earn your business and make your move as smooth as possible.\n\nQuote #: ${quote.number}\nTotal: ${$(quote.total)} (incl. HST)\nValid Until: ${validUntil(quote)}\n\nView your quote here:\n${url}\n\nIf you have any questions or would like to adjust anything on the quote, just say the word — we're happy to work with you.\n\nBest regards,\nSaturn Star Moving Company\n(226) 215-9874\nbusiness@starmovers.ca`, }, }; const tpl = templates[type] || templates.initial; document.getElementById('modal-container').innerHTML = ` `; } function copyEmailBody() { const body = document.getElementById('em-body').value; navigator.clipboard.writeText(body).then(() => toast('Email body copied!', 'success')); } async function sendViaResendFromModal(id, type) { const to = document.getElementById('em-to').value.trim(); const subject = document.getElementById('em-subject').value.trim(); const body = document.getElementById('em-body').value.trim(); if (!to || !subject || !body) { toast('Fill in all fields', 'error'); return; } const btn = document.getElementById('resend-send-btn'); if (btn) { btn.textContent = 'Sending...'; btn.disabled = true; } const quote = DB.getQuote(id); // Find lead linked to this quote const lead = DB.getLeads().find(l => l.quoteId === id); const result = await sendEmail({ to, subject, body, leadId: lead?.id || null, templateType: type === 'followup' ? 'follow_up' : 'quote_sent' }); if (result) { if (type === 'initial' || type !== 'followup') { const today = new Date().toISOString().split('T')[0]; if (quote && !quote.sentAt) DB.updateQuote(id, { status: 'sent', sentAt: today }); logFollowUp(id, 'email'); } else { logFollowUp(id, 'followup_email'); } closeModal(); toast('Email sent via Resend ✓', 'success'); setTimeout(() => render(), 300); } else { if (btn) { btn.textContent = '📨 Send Now'; btn.disabled = false; } } } function sendViaMailto(id, type) { const to = document.getElementById('em-to').value; const subject = encodeURIComponent(document.getElementById('em-subject').value); const body = encodeURIComponent(document.getElementById('em-body').value); window.location.href = `mailto:${to}?subject=${subject}&body=${body}`; // Mark as sent if initial email if (type === 'initial') { const today = new Date().toISOString().split('T')[0]; const q = DB.getQuote(id); if (!q.sentAt) { DB.updateQuote(id, { status: 'sent', sentAt: today }); } // Log follow-up sent logFollowUp(id, 'email'); } else if (type === 'followup') { logFollowUp(id, 'followup_email'); } closeModal(); toast('Opening your email app...', 'success'); setTimeout(() => render(), 500); } // ============================================================ // FOLLOW-UP TRACKING // ============================================================ function daysSince(dateStr) { if (!dateStr) return null; const d = new Date(dateStr + 'T12:00:00'); const now = new Date(); return Math.floor((now - d) / (1000 * 60 * 60 * 24)); } function logFollowUp(quoteId, type) { const key = 'sscrm_followups'; const logs = JSON.parse(localStorage.getItem(key) || '[]'); const entry = { id: 'fu_' + Date.now() + '_' + Math.random().toString(36).slice(2,7), quoteId, type, date: new Date().toISOString().split('T')[0], createdAt: new Date().toISOString(), }; logs.push(entry); localStorage.setItem(key, JSON.stringify(logs)); // Persist to Supabase so it's not lost if localStorage is cleared sbUpsert('crm_followup_logs', entry.id, entry).catch(() => {}); } function getFollowUpLogs(quoteId) { const logs = JSON.parse(localStorage.getItem('sscrm_followups') || '[]'); return logs.filter(l => l.quoteId === quoteId); } function getFollowUpStatus(quote) { if (!['sent', 'viewed'].includes(quote.status)) return null; const logs = getFollowUpLogs(quote.id); const followupsSent = logs.filter(l => l.type === 'followup_email').length; const days = daysSince(quote.sentAt); if (days === null) return null; if (quote.status === 'sent' && days >= 2 && followupsSent === 0) { return { level: 'warn', action: 'email', label: `No response in ${days} days — send a follow-up email`, days }; } if (quote.status === 'sent' && days >= 5 && followupsSent >= 1) { return { level: 'urgent', action: 'call', label: `${days} days with no response — time to call them`, days }; } if (quote.status === 'viewed' && days >= 1 && followupsSent === 0) { return { level: 'warn', action: 'email', label: `Client viewed but hasn't responded — follow up now`, days }; } if (quote.status === 'viewed' && days >= 3 && followupsSent >= 1) { return { level: 'urgent', action: 'call', label: `Viewed ${days} days ago — pick up the phone`, days }; } if (days >= 25) { return { level: 'urgent', action: 'expiring', label: `Quote expires in ${(quote.validDays || 30) - days} days`, days }; } return { level: 'ok', action: null, label: `Sent ${days} day${days !== 1 ? 's' : ''} ago — on track`, days }; } function renderFollowUpBanner(quote) { const fu = getFollowUpStatus(quote); if (!fu) return ''; const colors = { ok: { bg: '#e8f5e9', border: '#a5d6a7', text: '#2e7d32', icon: '✓' }, warn: { bg: '#fff8e1', border: '#ffe082', text: '#f57f17', icon: '⚡' }, urgent: { bg: '#fdecea', border: '#ef9a9a', text: '#c62828', icon: '🔴' }, }; const c = colors[fu.level]; return `
${c.icon} ${fu.label}
${fu.action === 'email' ? `` : ''} ${fu.action === 'call' ? `📞 Call Now` : ''}
`; } function renderOpsActionNeeded(leads) { const today = new Date(); const todayStr = today.toISOString().split('T')[0]; // Booked moves needing confirmation call (1-2 days out, conf_done not checked) const needsConfCall = leads.filter(l => { if (l.stage !== 'booked' || !l.moveDate) return false; const daysToMove = Math.floor((new Date(l.moveDate + 'T12:00:00') - today) / 86400000); return daysToMove >= 1 && daysToMove <= 2 && !(l.ops?.conf_done); }); // Moves today const movesToday = leads.filter(l => l.stage === 'booked' && l.moveDate === todayStr); // Moves this week const movesThisWeek = leads.filter(l => { if (l.stage !== 'booked' || !l.moveDate) return false; const daysTo = Math.floor((new Date(l.moveDate + 'T12:00:00') - today) / 86400000); return daysTo >= 0 && daysTo <= 7; }); if (!needsConfCall.length && !movesToday.length && !movesThisWeek.length) return ''; return `
🚛 Operations — Upcoming Moves
systematize → scale
${movesToday.map(l => `
🚛 MOVE TODAY
${l.name}
${l.originCity || ''} → ${l.destCity || ''}
`).join('')} ${needsConfCall.map(l => { const daysTo = Math.floor((new Date(l.moveDate + 'T12:00:00') - today) / 86400000); return `
☎️ CONF CALL DUE (in ${daysTo}d)
${l.name}
Move: ${l.moveDate}
`;}).join('')} ${movesThisWeek.filter(l => l.moveDate !== todayStr).slice(0,3).map(l => { const daysTo = Math.floor((new Date(l.moveDate + 'T12:00:00') - today) / 86400000); const opsComplete = l.ops?.close_thumbsup; return `
📅 In ${daysTo} day${daysTo!==1?'s':''} ${opsComplete ? '✓ Ops ready' : ''}
${l.name}
${l.moveDate}
`;}).join('')}
`; } function renderSalesMachineDashboard(leads, quotes) { const today = new Date(); const todayStr = today.toISOString().split('T')[0]; // Leads never contacted const neverContacted = leads.filter(l => !['booked','lost'].includes(l.stage) && !(l.callLogs||[]).some(c => ['call','sms','email','visit'].includes(c.type))); // Leads in active follow-up cadence const inCadence = leads.filter(l => { if (['booked','lost'].includes(l.stage)) return false; const contacts = (l.callLogs||[]).filter(c => ['call','sms','email','visit'].includes(c.type)); if (contacts.length === 0) return false; const last = new Date(contacts.sort((a,b)=>new Date(b.date)-new Date(a.date))[0].date); const daysSince = Math.floor((today - last) / 86400000); return contacts.length < 4 && daysSince < 14; }); // Quotes sent needing follow-up (3+ days, not closed) const quotesNeedingFU = quotes.filter(q => { if (!['sent','viewed'].includes(q.status)) return false; const age = q.createdAt ? Math.floor((today - new Date(q.createdAt + (q.createdAt.length===10?'T12:00:00':''))) / 86400000) : 0; return age >= 3; }); // Leads going cold (10+ days no contact, not booked/lost) const goingCold = leads.filter(l => { if (['booked','lost'].includes(l.stage)) return false; const contacts = (l.callLogs||[]).filter(c => ['call','sms','email','visit'].includes(c.type)); if (contacts.length === 0) return false; const last = new Date(contacts.sort((a,b)=>new Date(b.date)-new Date(a.date))[0].date); return Math.floor((today - last) / 86400000) >= 10; }); // Missing lead data (no phone or no origin) const missingData = leads.filter(l => !['booked','lost'].includes(l.stage) && (!l.phone || (!l.originCity && !l.originAddress))); const hasIssues = neverContacted.length || quotesNeedingFU.length || goingCold.length || missingData.length; if (!hasIssues) return ''; const items = [ neverContacted.length ? { color:'#7c3aed', icon:'🚨', label:'Never contacted', count:neverContacted.length, detail:'Call these NOW — speed to lead wins every time', leads:neverContacted } : null, quotesNeedingFU.length ? { color:'#e65100', icon:'🔥', label:'Quotes past follow-up', count:quotesNeedingFU.length, detail:'3+ days since quote sent — call to close before they go elsewhere', leads:[] } : null, goingCold.length ? { color:'#1565c0', icon:'❄️', label:'Going cold', count:goingCold.length, detail:'10+ days no contact — one more outreach before marking lost', leads:goingCold } : null, missingData.length ? { color:'#888', icon:'📋', label:'Incomplete lead data', count:missingData.length, detail:'Missing phone or origin — fill in on next contact', leads:missingData } : null, ].filter(Boolean); return `
⚡ Sales Machine — Action Required
Keep the machine running
${items.map(item => `
${item.icon} ${item.count} ${item.label}
${item.detail}
`).join('')}
`; } function renderFollowUps(el, actions) { const quotes = DB.getQuotes().filter(q => ['sent', 'viewed'].includes(q.status)); const withStatus = quotes.map(q => ({ quote: q, fu: getFollowUpStatus(q) })).filter(x => x.fu); const urgent = withStatus.filter(x => x.fu.level === 'urgent'); const warn = withStatus.filter(x => x.fu.level === 'warn'); const ok = withStatus.filter(x => x.fu.level === 'ok'); el.innerHTML = `
📖 Louis Masaro Follow-Up Cadence
Day 0: First contact — call + text same day the lead comes in
Day 2: Follow-up #2 — call + email if no response
Day 5: Follow-up #3 — mix it up (email, text)
Day 10: Final attempt — personal, make it count
Day 14+: Mark lost or park for re-engage later
🎯 Key Principles
Never ask "when should I follow up?" — you decide
• Following up = you want their business, not desperation
• Price objection → show value, not lower rates
• If things are slow → fix lead volume, not price
• Every call has ONE purpose: book the move
${urgent.length === 0 && warn.length === 0 ? `

All caught up!

No follow-ups needed right now.

` : ''} ${urgent.length > 0 ? `
🔴 Urgent — Action Required (${urgent.length})
${urgent.map(({ quote: q, fu }) => { const c = DB.getClient(q.clientId); return `
${q.number} — ${c ? c.name : '—'}
${fu.label}
${fu.action === 'call' ? `📞 Call` : ''}
`; }).join('')}
` : ''} ${warn.length > 0 ? `
⚡ Follow-Up Recommended (${warn.length})
${warn.map(({ quote: q, fu }) => { const c = DB.getClient(q.clientId); return `
${q.number} — ${c ? c.name : '—'}
${fu.label}
`; }).join('')}
` : ''} ${ok.length > 0 ? `
✓ On Track (${ok.length})
${ok.map(({ quote: q, fu }) => { const c = DB.getClient(q.clientId); return ``; }).join('')}
Quote #ClientStatusTotalDays Since Sent
${q.number} ${c ? c.name : '—'} ${q.status} ${$(q.total)} ${fu.days} day${fu.days !== 1 ? 's' : ''}
` : ''} `; } // ============================================================ // SETTINGS // ============================================================ function renderSettings(el, actions) { const s = getSettings(); el.innerHTML = `

Email Settings

Configure your default email details. These pre-fill when sending quotes.

Follow-Up Sequence

Customize when the CRM prompts you to follow up.

Company Info (on quotes)

✉️ Email — Zoho Mail

Connected — business@starmovers.ca
Emails send directly from your Zoho inbox. Replies land there too.
Powered by Zoho Mail API (zohocloud.ca). Credentials stored securely in Cloudflare Worker secrets — never in the browser.

Supabase Integration

Used for address lookup (direct mail attribution) and AI inventory fetching.

💰 Rate Card

Used to auto-fill quote line items. Keeps pricing fast and consistent.

🔒 Security

Change the password required to access Mission Control. Min 6 characters.

Data Export

All data is stored locally in your browser and synced to Supabase.

⚠️ Data Management

Use with caution. These actions delete data from both the browser and Supabase.

Clear Test Leads
Removes leads with "test", "demo", or blank names. Keeps: Cheryl, Amber Rose, Billy Quinn, Johnny Broker.
Clear Inbound Inbox
Removes all unclaimed inbound leads from the inbox queue. Real leads already on the board are not affected.
Nuke Everything
Clear ALL local data (leads, quotes, clients, emails). Last resort only.
`; } async function wipeTestLeads() { const leads = DB.getLeads(); const testPatterns = /^(test|demo|sample|fake|example|\s*)$/i; const nameTestPatterns = /test|demo|sample|fake|example/i; const realLeads = leads.filter(l => { const name = (l.name || '').trim(); if (!name) return false; if (nameTestPatterns.test(name)) return false; return true; }); const toDelete = leads.filter(l => !realLeads.find(r => r.id === l.id)); if (toDelete.length === 0) { toast('No test leads found — already clean!', 'info'); return; } if (!confirm(`Remove ${toDelete.length} test lead(s)? Real leads (Cheryl, Amber Rose, Billy Quinn, Johnny Broker) will be kept.`)) return; DB.saveLeads(realLeads); for (const l of toDelete) { await sbSoftDelete('crm_leads', l.id); } toast(`Removed ${toDelete.length} test lead${toDelete.length > 1 ? 's' : ''}`, 'success'); render(); } async function wipeInboundLeads() { if (!confirm('Remove ALL inbound leads from the inbox queue? This cannot be undone.')) return; const sbUrl = _sbBase(); const sbKey = _sbKey(); const h = { 'apikey': sbKey, 'Authorization': 'Bearer ' + sbKey, 'Content-Type': 'application/json' }; try { await fetch(`${sbUrl}/rest/v1/inbound_leads`, { method: 'DELETE', headers: { ...h, 'Prefer': 'return=minimal' }, }); localStorage.removeItem('sscrm_inbound'); toast('Inbox cleared — fresh start!', 'success'); updateInboxBadge(); render(); } catch(e) { toast('Error: ' + e.message, 'error'); } } function saveSettingsForm() { const s = getSettings(); saveSettings({ ...s, senderName: document.getElementById('s-name').value.trim(), replyEmail: document.getElementById('s-email').value.trim(), phone: document.getElementById('s-phone').value.trim(), validDays: parseInt(document.getElementById('s-valid').value) || 30, followup1: parseInt(document.getElementById('s-fu1').value) || 2, followup2: parseInt(document.getElementById('s-fu2').value) || 5, companyName: document.getElementById('s-company').value.trim(), address: document.getElementById('s-address').value.trim(), fromEmail: 'business@starmovers.ca', fromName: 'Saturn Star Moving Company', supabaseUrl: document.getElementById('s-sb-url')?.value?.trim() || '', supabaseKey: document.getElementById('s-sb-key')?.value?.trim() || '', rc_hourly: parseFloat(document.getElementById('s-rc-hourly')?.value) || 55, rc_truck: parseFloat(document.getElementById('s-rc-truck')?.value) || 75, rc_minhours: parseFloat(document.getElementById('s-rc-minhours')?.value)|| 3, rc_km: parseFloat(document.getElementById('s-rc-km')?.value) || 3.50, rc_fuel: parseFloat(document.getElementById('s-rc-fuel')?.value) || 5, rc_packing: parseFloat(document.getElementById('s-rc-packing')?.value) || 25, rc_deposit: parseInt(document.getElementById('s-rc-deposit')?.value) || 40, }); toast('Settings saved!', 'success'); } async function saveNewPassword() { const pw = document.getElementById('s-newpw')?.value || ''; const confirm = document.getElementById('s-confirmpw')?.value || ''; if (pw.length < 6) { toast('Password must be at least 6 characters', 'error'); return; } if (pw !== confirm) { toast('Passwords do not match', 'error'); return; } await changePassword(pw); document.getElementById('s-newpw').value = ''; document.getElementById('s-confirmpw').value = ''; } function exportData() { const data = { clients: DB.getClients(), quotes: DB.getQuotes(), exported: new Date().toISOString(), }; const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `saturn-crm-export-${new Date().toISOString().split('T')[0]}.json`; a.click(); } function clearAllData() { if (!confirm('This will delete ALL clients, quotes, and data. This cannot be undone. Are you sure?')) return; localStorage.clear(); toast('All data cleared', 'info'); seedDemo(); nav('dashboard'); } // ============================================================ // SUPABASE ADDRESS LOOKUP + INVENTORY // ============================================================ const SUPABASE_DEFAULT_URL = 'https://idbyrtwdeeruiutoukct.supabase.co'; const SUPABASE_DEFAULT_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImlkYnlydHdkZWVydWl1dG91a2N0Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzgyNTk0NjQsImV4cCI6MjA1MzgzNTQ2NH0.Hw0oJmIuDGdITM3TZkMWeXkHy53kO4i8TCJMxb6_hko'; async function lookupAddressInSupabase(address) { const s = getSettings(); const url = s.supabaseUrl || SUPABASE_DEFAULT_URL; const key = s.supabaseKey || SUPABASE_DEFAULT_KEY; if (!address || address.length < 5) return []; try { const encoded = encodeURIComponent('%' + address + '%'); const res = await fetch(`${url}/rest/v1/listings?address=ilike.${encoded}&select=zpid,address,city,is_furnished,furniture_scan_date&limit=5`, { headers: { 'apikey': key, 'Authorization': 'Bearer ' + key } }); const data = await res.json(); return Array.isArray(data) ? data : []; } catch (e) { console.warn('Address lookup failed:', e); return []; } } async function fetchInventoryForListing(zpid) { const s = getSettings(); const url = s.supabaseUrl || SUPABASE_DEFAULT_URL; const key = s.supabaseKey || SUPABASE_DEFAULT_KEY; try { const res = await fetch(`${url}/rest/v1/listing_inventory_scans?zpid=eq.${zpid}&status=eq.completed&order=scanned_at.desc&limit=1&select=*`, { headers: { 'apikey': key, 'Authorization': 'Bearer ' + key } }); const data = await res.json(); if (!data || !data.length) return null; const scan = data[0]; let inventory = scan.inventory_items; if (typeof inventory === 'string') { try { inventory = JSON.parse(inventory); } catch { inventory = []; } } let roomBreakdown = scan.room_breakdown; if (typeof roomBreakdown === 'string') { try { roomBreakdown = JSON.parse(roomBreakdown); } catch { roomBreakdown = {}; } } return { inventory: inventory || [], totalItems: scan.total_items || 0, totalCubicFeet: scan.total_cubic_feet || 0, roomBreakdown: roomBreakdown || {} }; } catch (e) { console.warn('Inventory fetch failed:', e); return null; } } // ============================================================ // RECORDING ENGINE // ============================================================ let activeRecorder = null; let recChunks = []; let recTimerInterval = null; let recStartTime = null; async function startRecording(leadId) { try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); activeRecorder = new MediaRecorder(stream); recChunks = []; recStartTime = Date.now(); activeRecorder.ondataavailable = e => { if (e.data.size > 0) recChunks.push(e.data); }; activeRecorder.start(1000); const btn = document.getElementById('rec-btn'); if (btn) { btn.className = 'recording-btn active'; btn.innerHTML = '⏹ Stop Recording'; btn.onclick = () => stopRecording(leadId); } recTimerInterval = setInterval(() => { const elapsed = Math.floor((Date.now() - recStartTime) / 1000); const m = String(Math.floor(elapsed / 60)).padStart(2,'0'); const s = String(elapsed % 60).padStart(2,'0'); const t = document.getElementById('rec-timer'); if (t) t.textContent = m + ':' + s; }, 1000); } catch (e) { toast('Microphone access denied — check browser permissions', 'error'); } } async function stopRecording(leadId) { if (!activeRecorder) return; clearInterval(recTimerInterval); return new Promise(resolve => { activeRecorder.onstop = () => { const blob = new Blob(recChunks, { type: 'audio/webm' }); const reader = new FileReader(); reader.onloadend = () => { const audioDataUrl = reader.result; const duration = Math.floor((Date.now() - recStartTime) / 1000); const m = String(Math.floor(duration / 60)).padStart(2,'0'); const s = String(duration % 60).padStart(2,'0'); addCallLogEntry(leadId, { type: 'recording', notes: 'Consultation recording — ' + m + ':' + s, audioDataUrl, duration, date: new Date().toISOString() }); toast('Recording saved (' + m + ':' + s + ')', 'success'); activeRecorder = null; nav('lead-detail', leadId); resolve(); }; reader.readAsDataURL(blob); activeRecorder.stream.getTracks().forEach(t => t.stop()); }; activeRecorder.stop(); }); } function addCallLogEntry(leadId, entry) { const lead = DB.getLead(leadId); if (!lead) return; const logs = [...(lead.callLogs || [])]; logs.unshift({ id: 'cl_' + uid(), ...entry }); DB.updateLead(leadId, { callLogs: logs }); } // ============================================================ // LEAD BOARD // ============================================================ const LEAD_STAGES = [ { id: 'new', label: 'New Call' }, { id: 'contacted', label: 'Contacted' }, { id: 'quoted', label: 'Quoted' }, { id: 'booked', label: 'Booked' }, { id: 'lost', label: 'Lost' }, ]; const LEAD_SOURCES = { direct_mail: 'Direct Mail', referral: 'Referral', google: 'Google', cold_call: 'Cold Call', walk_in: 'Walk-In', other: 'Other' }; function renderLeadBoard(el, actions) { const newBtn = document.createElement('button'); newBtn.className = 'btn btn-primary'; newBtn.textContent = '+ New Lead'; newBtn.onclick = () => showNewLeadModal(); actions.appendChild(newBtn); const viewToggle = document.createElement('button'); viewToggle.className = 'btn btn-secondary'; viewToggle.textContent = leadsViewMode === 'list' ? '⊞ Board View' : '☰ List View'; viewToggle.onclick = () => { leadsViewMode = leadsViewMode === 'list' ? 'board' : 'list'; render(); }; actions.appendChild(viewToggle); const allLeads = DB.getLeads(); const today = new Date(); const todayStr = today.toISOString().split('T')[0]; // ── Smart view filtering ── function getHotReadiness(lead) { const logs = lead.callLogs || []; for (const l of logs) { if (l.aiSummary?.moveReadiness === 'hot') return true; } return false; } let leads = allLeads; if (leadsSmartView === 'call_today') { leads = allLeads.filter(l => l.followUpDate && l.followUpDate <= todayStr && !['booked','lost'].includes(l.stage)); } else if (leadsSmartView === 'hot') { leads = allLeads.filter(l => getHotReadiness(l) && !['booked','lost'].includes(l.stage)); } else if (leadsSmartView === 'needs_quote') { leads = allLeads.filter(l => ['new','contacted'].includes(l.stage) && !l.quoteId); } else if (leadsSmartView === 'quote_out') { leads = allLeads.filter(l => l.stage === 'quoted'); } else if (leadsSmartView === 'overdue') { leads = allLeads.filter(l => l.followUpDate && l.followUpDate < todayStr && !['booked','lost'].includes(l.stage)); } else if (leadsSmartView === 'booked') { leads = allLeads.filter(l => l.stage === 'booked'); } const svTabs = [ { id: 'all', label: `All ${allLeads.length}` }, { id: 'call_today', label: '📅 Call Today' }, { id: 'hot', label: '🔥 Hot' }, { id: 'needs_quote', label: '⚡ Needs Quote' }, { id: 'quote_out', label: '📄 Quote Out' }, { id: 'overdue', label: '⚠️ Overdue' }, { id: 'booked', label: '✓ Booked' }, ]; if (leadsViewMode === 'board') { // ── BOARD VIEW (original kanban) ── el.innerHTML = `
${svTabs.map(t => ``).join('')}
${LEAD_STAGES.map(stage => { const sl = leads.filter(l => l.stage === stage.id); return `
${stage.label} ${sl.length}
${sl.length === 0 ? `
No leads
` : sl.map(lead => { const age = lead.createdAt ? Math.floor((today - new Date(lead.createdAt + 'T12:00:00')) / (1000*60*60*24)) : 0; const hasInv = lead.inventory && lead.inventory.length > 0; let fuBadge = ''; if (lead.followUpDate) { const tomorrowDate = new Date(today); tomorrowDate.setDate(today.getDate() + 1); const tomorrowStr = tomorrowDate.toISOString().split('T')[0]; const in3 = new Date(today); in3.setDate(today.getDate() + 3); const fuDate = new Date(lead.followUpDate + 'T12:00:00'); if (lead.followUpDate <= todayStr) { fuBadge = `Follow up TODAY`; } else if (lead.followUpDate === tomorrowStr) { fuBadge = `Follow up tomorrow`; } else if (fuDate <= in3) { fuBadge = `Follow up ${fmtDate(lead.followUpDate)}`; } } const score = lead.leadScore != null ? lead.leadScore : calculateLeadScore(lead); const scoreCls = score >= 70 ? 'background:#e8f5e9;color:#2e7d32' : score >= 40 ? 'background:#fff8e1;color:#e65100' : 'background:#f5f5f5;color:#888'; const scoreLbl = score >= 70 ? 'Hot 🔥' : score >= 40 ? 'Medium' : 'Low'; return `
${lead.name || 'Unknown'}
${lead.originAddress ? lead.originAddress + ', ' + (lead.originCity||'') : '—'}
${LEAD_SOURCES[lead.source]||lead.source} ${lead.directMailAttributed ? `` : ''} ${hasInv ? `📦` : ''} ${age}d
${fuBadge}
${score} · ${scoreLbl}
`; }).join('')}
`; }).join('')}
`; return; } // ── LIST VIEW ── const stageBadgeStyle = { new: 'background:#e8eaf6;color:#283593', contacted: 'background:#e3f2fd;color:#1565c0', quoted: 'background:#f3e5f5;color:#6a1b9a', booked: 'background:#e8f5e9;color:#2e7d32', lost: 'background:#f5f5f5;color:#555', }; el.innerHTML = `
${svTabs.map(t => ``).join('')}
${leads.length === 0 ? `
📋

No leads in this view

Try a different filter or add a new lead.

` : ` ${leads.map(lead => { const score = lead.leadScore != null ? lead.leadScore : calculateLeadScore(lead); const scoreCls = score >= 70 ? 'background:#e8f5e9;color:#2e7d32' : score >= 40 ? 'background:#fff8e1;color:#e65100' : 'background:#f5f5f5;color:#888'; const scoreLbl = score >= 70 ? '🔥 Hot' : score >= 40 ? 'Medium' : 'Low'; const stBadge = stageBadgeStyle[lead.stage] || 'background:#f5f5f5;color:#555'; const stLabel = LEAD_STAGES.find(s=>s.id===lead.stage)?.label || lead.stage; const isOverdue = lead.followUpDate && lead.followUpDate < todayStr && !['booked','lost'].includes(lead.stage); const isDueToday = lead.followUpDate && lead.followUpDate === todayStr; const fuColor = isOverdue ? '#ef4444' : isDueToday ? '#e65100' : 'var(--text-muted)'; const fuText = lead.followUpDate ? fmtDate(lead.followUpDate) : '—'; return ``; }).join('')}
Name / Contact Stage Source Move Date Follow-Up Score Actions
${lead.name || 'Unknown'}
${lead.phone || ''}
${stLabel} ${LEAD_SOURCES[lead.source]||lead.source} ${lead.moveDate ? fmtDate(lead.moveDate) : '—'} ${fuText}${isOverdue?' ⚠️':isDueToday?' 📅':''} ${score} · ${scoreLbl} ${lead.phone ? `📞` : ''}
`}
`; } // ============================================================ // NEW LEAD MODAL // ============================================================ let _pendingListing = null; function showNewLeadModal() { _pendingListing = null; document.getElementById('modal-container').innerHTML = ` `; } function clearLookupResult() { _pendingListing = null; const el = document.getElementById('nl-lookup-result'); if (el) el.innerHTML = ''; } async function doAddressLookup() { const address = document.getElementById('nl-origin')?.value?.trim(); const resultEl = document.getElementById('nl-lookup-result'); const btn = document.getElementById('nl-lookup-btn'); if (!address || address.length < 5) { resultEl.innerHTML = `
Enter at least 5 characters of the address.
`; return; } btn.textContent = '⏳'; btn.disabled = true; resultEl.innerHTML = `
Searching direct mail database...
`; const listings = await lookupAddressInSupabase(address); btn.textContent = '🔍 Lookup'; btn.disabled = false; if (!listings || listings.length === 0) { _pendingListing = null; resultEl.innerHTML = `
📭 Not found in direct mail database — lead will be created without attribution.
`; return; } const listing = listings[0]; _pendingListing = listing; const hasScan = listing.furniture_scan_date != null; resultEl.innerHTML = `
✅ Direct Mail Match — ${listing.address}, ${listing.city}
${hasScan ? `📦 Inventory Scan Available — will auto-load` : `No inventory scan yet (can trigger manually)`}
`; } async function saveNewLead() { const name = document.getElementById('nl-name')?.value?.trim(); if (!name) { toast('Name is required', 'error'); return; } const lead = { id: 'ld_' + uid(), name, phone: document.getElementById('nl-phone')?.value?.trim() || '', email: document.getElementById('nl-email')?.value?.trim() || '', source: document.getElementById('nl-source')?.value || 'other', stage: 'new', moveType: document.getElementById('nl-type')?.value || 'residential', moveDate: document.getElementById('nl-date')?.value || '', originAddress: document.getElementById('nl-origin')?.value?.trim() || '', originCity: document.getElementById('nl-origin-city')?.value?.trim() || 'Windsor', destCity: document.getElementById('nl-dest-city')?.value?.trim() || '', directMailAttributed: !!_pendingListing, supabaseListing: _pendingListing || null, inventory: [], totalCubicFeet: 0, totalItems: 0, callLogs: [], quoteId: null, notes: document.getElementById('nl-notes')?.value?.trim() || '', followUpDate: document.getElementById('nl-followup')?.value || null, leadScore: 0, createdAt: new Date().toISOString().split('T')[0], }; lead.leadScore = calculateLeadScore(lead); DB.addLead(lead); closeModal(); _pendingListing = null; toast('Lead created!', 'success'); nav('lead-detail', lead.id); if (lead.supabaseListing?.zpid) { setTimeout(async () => { const inv = await fetchInventoryForListing(lead.supabaseListing.zpid); if (inv && inv.inventory.length > 0) { DB.updateLead(lead.id, { inventory: inv.inventory, totalCubicFeet: inv.totalCubicFeet, totalItems: inv.totalItems }); toast('📦 AI inventory loaded — ' + inv.totalItems + ' items detected!', 'success'); if (view === 'lead-detail' && detailId === lead.id) render(); } }, 200); } } // ============================================================ // LEAD DETAIL // ============================================================ let leadDetailTab = 'calllog'; function renderLeadDetail(el, actions, id) { const lead = DB.getLead(id); if (!lead) { el.innerHTML = '

Lead not found

'; return; } document.getElementById('page-title').textContent = lead.name; const stageSelect = document.createElement('select'); stageSelect.className = 'status-select'; LEAD_STAGES.forEach(s => { const opt = document.createElement('option'); opt.value = s.id; opt.textContent = s.label; opt.selected = lead.stage === s.id; stageSelect.appendChild(opt); }); stageSelect.onchange = () => { if (stageSelect.value === 'lost') { moveLeadToLost(id); // Reset selector back to current stage in case user cancelled const current = DB.getLead(id); if (current) stageSelect.value = current.stage; } else { const updated = DB.getLead(id); const score = calculateLeadScore({ ...updated, stage: stageSelect.value }); DB.updateLead(id, { stage: stageSelect.value, leadScore: score }); toast('Stage updated', 'success'); render(); } }; actions.appendChild(stageSelect); const editBtn = document.createElement('button'); editBtn.className = 'btn btn-secondary'; editBtn.textContent = '✏️ Edit Lead'; editBtn.onclick = () => showEditLeadModal(id); actions.appendChild(editBtn); const estBtn = document.createElement('button'); estBtn.className = 'btn btn-primary'; estBtn.innerHTML = '⚡ Send Estimate'; estBtn.style.cssText = 'background:var(--gold-dark);border-color:var(--gold-dark);font-size:14px;font-weight:800;'; estBtn.onclick = () => showQuickSendModal(id); actions.appendChild(estBtn); if (!lead.quoteId) { const qBtn = document.createElement('button'); qBtn.className = 'btn btn-secondary'; qBtn.textContent = '+ Full Quote Builder'; qBtn.onclick = () => createQuoteFromLead(id); actions.appendChild(qBtn); } const hasInv = lead.inventory && lead.inventory.length > 0; el.innerHTML = `
${lead.stage === 'booked' ? `` : ''} ${(() => { const smsCnt = lead.phone ? smsMessages.filter(m => (m.from_number||'').replace(/\D/g,'') === (lead.phone||'').replace(/\D/g,'') || (m.to_number||'').replace(/\D/g,'') === (lead.phone||'').replace(/\D/g,'')).length : 0; const smsUnread = lead.phone ? smsMessages.filter(m => m.direction==='inbound' && (m.from_number||'').replace(/\D/g,'') === (lead.phone||'').replace(/\D/g,'')).length : 0; return ``; })()}
${leadDetailTab === 'overview' ? renderLeadOverviewTab(lead) : ''} ${leadDetailTab === 'inventory' ? renderLeadInventoryTab(lead) : ''} ${leadDetailTab === 'calllog' ? renderLeadCallLogTab(lead) : ''} ${leadDetailTab === 'consult' ? renderLeadConsultTab(lead) : ''} ${leadDetailTab === 'script' ? renderLeadScriptTab(lead) : ''} ${leadDetailTab === 'ops' ? renderLeadOpsTab(lead) : ''} ${leadDetailTab === 'emails' ? renderLeadEmailsTab(lead) : ''} ${leadDetailTab === 'sms' ? renderLeadSMSTab(lead) : ''}
`; } function setLeadTab(tab, id) { leadDetailTab = tab; nav('lead-detail', id); if (tab === 'sms') setTimeout(leadSmsScrollBottom, 80); } function showEditLeadModal(id) { const lead = DB.getLead(id); if (!lead) return; const m = document.createElement('div'); m.id = 'edit-lead-modal'; m.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:1000;backdrop-filter:blur(2px);padding:16px;'; m.innerHTML = `
✏️ Edit Lead — ${lead.name}
`; document.body.appendChild(m); } function saveEditLead(id) { const first = document.getElementById('el-first')?.value?.trim() || ''; const last = document.getElementById('el-last')?.value?.trim() || ''; const name = [first, last].filter(Boolean).join(' '); DB.updateLead(id, { name: name || DB.getLead(id)?.name || '', phone: document.getElementById('el-phone')?.value?.trim() || '', email: document.getElementById('el-email')?.value?.trim() || '', moveDate: document.getElementById('el-movedate')?.value || '', moveType: document.getElementById('el-movetype')?.value || 'residential', originAddress: document.getElementById('el-origin')?.value?.trim() || '', originCity: document.getElementById('el-origincity')?.value?.trim() || '', destCity: document.getElementById('el-destcity')?.value?.trim() || '', moveReason: document.getElementById('el-movereason')?.value?.trim() || '', notes: document.getElementById('el-notes')?.value?.trim() || '', }); document.getElementById('edit-lead-modal')?.remove(); toast('Lead updated ✓', 'success'); nav('lead-detail', id); } function renderLeadOverviewTab(lead) { const hasInv = lead.inventory?.length > 0; let estLow = 0, estHigh = 0; if (lead.totalCubicFeet > 0) { const r = lead.moveType === 'long-distance' ? [5, 8] : [3, 5]; estLow = Math.round(lead.totalCubicFeet * r[0] / 100) * 100; estHigh = Math.round(lead.totalCubicFeet * r[1] / 100) * 100; } return `
Lead Info
Stage
${LEAD_STAGES.find(s=>s.id===lead.stage)?.label||lead.stage}
Source
${LEAD_SOURCES[lead.source]||lead.source} ${lead.directMailAttributed ? ' ✉ Mail Match' : ''}
Lead Score
${(() => { const score = lead.leadScore != null ? lead.leadScore : calculateLeadScore(lead); const cls = score >= 70 ? 'background:#e8f5e9;color:#2e7d32' : score >= 40 ? 'background:#fff8e1;color:#e65100' : 'background:#f5f5f5;color:#555'; const lbl = score >= 70 ? 'Hot 🔥' : score >= 40 ? 'Medium' : 'Low'; return `${score} — ${lbl}`; })()}
Follow-up Date
${lead.followUpDate ? fmtDate(lead.followUpDate) : 'Not set'}
${lead.phone ? `
Phone
${lead.phone}
` : ''} ${lead.email ? `
Email
${lead.email}
` : ''}
Move Type
${lead.moveType}
${lead.moveDate ? `
Move Date
${fmtDate(lead.moveDate)}
` : ''}
Lead Created
${fmtDate(lead.createdAt)}
Move Details
Moving From
${lead.originAddress||'—'}${lead.originCity?', '+lead.originCity:''}
${lead.destCity ? `
Moving To
${lead.destCity}
` : ''} ${hasInv ? `
📦 AI Inventory Summary
${lead.totalItems||lead.inventory.length}
Items
${(lead.totalCubicFeet||0).toLocaleString()}
Cu Ft
${estLow ? `
Est. Quote: ${$(estLow)} – ${$(estHigh)}
` : ''}
` : `
No inventory yet
${lead.supabaseListing?.zpid ? `` : ``}
`} ${lead.moveReason ? `
💬 Why They're Moving
"${lead.moveReason}"
` : ''} ${lead.notes ? `
Notes
${lead.notes}
` : ''}
${renderNextActions(lead)}
📝 Add Note
${lead.quoteId ? (() => { const q = DB.getQuote(lead.quoteId); let expiryBanner = ''; if (q && !['accepted','invoiced','declined'].includes(q.status) && q.createdAt) { const daysOld = Math.floor((Date.now() - new Date(q.createdAt + 'T12:00:00')) / (1000*60*60*24)); if (daysOld > 7) { expiryBanner = `
⚠️ Quote sent ${daysOld} day${daysOld!==1?'s':''} ago — consider following up
`; } } return `
Quote Linked
This lead has an associated quote
${expiryBanner}
`; })() : ''}`; } function saveQuickNote(leadId, type) { const ta = document.getElementById(`quick-note-${leadId}`); if (!ta || !ta.value.trim()) { toast('Write something first', 'error'); return; } addCallLogEntry(leadId, { type, notes: ta.value.trim(), date: new Date().toISOString() }); ta.value = ''; toast('Logged ✓', 'success'); nav('lead-detail', leadId); } function renderNextActions(lead) { const actions = []; const logs = lead.callLogs || []; // Sort logs newest first const sortedLogs = [...logs].sort((a,b) => new Date(b.date) - new Date(a.date)); const contactLogs = sortedLogs.filter(l => ['call','sms','email','visit'].includes(l.type)); const lastContact = contactLogs.length ? new Date(contactLogs[0].date) : null; const daysSince = lastContact ? Math.floor((Date.now() - lastContact) / 86400000) : 999; const contactCount = contactLogs.length; const hasQuote = !!lead.quoteId; const quote = hasQuote ? DB.getQuote(lead.quoteId) : null; const quoteAge = quote?.createdAt ? Math.floor((Date.now() - new Date(quote.createdAt + (quote.createdAt.length === 10 ? 'T12:00:00' : ''))) / 86400000) : 0; // ── MISSING INFO WARNINGS ── const missing = []; if (!lead.phone) missing.push('phone'); if (!lead.email) missing.push('email'); if (!lead.moveDate) missing.push('move date'); if (!lead.originCity && !lead.originAddress) missing.push('origin'); if (!lead.destCity) missing.push('destination'); if (!lead.moveType) missing.push('home size/type'); // ── ACTION LOGIC ── if (['lost','booked'].includes(lead.stage)) { if (lead.stage === 'booked') { actions.push({ icon:'🎉', color:'#059669', text:'Move is BOOKED — confirm deposit received and prep crew', action:`nav('quote-detail','${lead.quoteId}')`, btn:'View Booking', priority:'high' }); // Confirmation call — Louis Masaro: 1-2 days before move if (lead.moveDate) { const moveDateObj = new Date(lead.moveDate + 'T12:00:00'); const daysToMove = Math.floor((moveDateObj - today) / 86400000); if (daysToMove === 2 || daysToMove === 1) { actions.push({ icon:'☎️', color:'#1565c0', text:`Confirmation call due — move is in ${daysToMove} day${daysToMove!==1?'s':''}! Call to confirm crew size, timing, elevator/parking access, and any last-minute items. This prevents surprises and shows you care.`, action:`makeCall('${lead.phone}','${lead.id}','${(lead.name||'').replace(/'/g,"\\'")}')`, btn:'Confirm Call', priority:'urgent' }); } else if (daysToMove <= 7 && daysToMove > 2) { actions.push({ icon:'📅', color:'#888', text:`Move in ${daysToMove} days — plan your confirmation call for 1-2 days out to confirm all details`, action:`showEditLeadModal('${lead.id}')`, btn:'View Details' }); } else if (daysToMove <= 0) { actions.push({ icon:'🚛', color:'#e65100', text:'Move day is today or past — make sure crew is dispatched and customer is set', action:`makeCall('${lead.phone}','${lead.id}','${(lead.name||'').replace(/'/g,"\\'")}')`, btn:'Check In', priority:'urgent' }); } } } } else { // No contact yet — show lead age and 5-minute rule if (contactCount === 0) { const leadAge = lead.createdAt ? Math.floor((today - new Date(lead.createdAt + (lead.createdAt.length===10?'T12:00:00':''))) / 60000) : null; // minutes let ageText = ''; if (leadAge !== null) { if (leadAge < 5) ageText = ` — lead is ${leadAge}m old — call RIGHT NOW`; else if (leadAge < 60) ageText = ` — ${leadAge}m since lead came in (target: <5 min!)`; else if (leadAge < 1440) ageText = ` — ${Math.floor(leadAge/60)}h ${leadAge%60}m since lead came in — every hour your close rate drops`; else ageText = ` — ${Math.floor(leadAge/1440)} day${Math.floor(leadAge/1440)!==1?'s':''} old — call immediately`; } actions.push({ icon:'🚨', color:'#7c3aed', text:`Call NOW — never been contacted${ageText}. Target: within 5 minutes of lead coming in. After call → send voicemail + email + text (CMET).`, action:`makeCall('${lead.phone}','${lead.id}','${(lead.name||'').replace(/'/g,"\\'")}')`, btn:'Call Now', priority:'urgent' }); } // Follow-up cadence — Louis Masaro 2+1 / 3+2+1 system else if (lead.stage !== 'booked' && lead.stage !== 'lost') { // Day 1: try to get 2 contacts in if (contactCount === 1 && daysSince === 0) { actions.push({ icon:'📞', color:'#e65100', text:`2+1 Cadence — Call #2 today. Louis Masaro system: 2 attempts on Day 1. Try again now — call + CMET.`, action:`makeCall('${lead.phone}','${lead.id}','${(lead.name||'').replace(/'/g,"\\'")}')`, btn:'Call Again', priority:'high' }); } // Day 2: 3rd attempt if (contactCount <= 2 && daysSince >= 1 && daysSince < 3) { actions.push({ icon:'📞', color:'#e65100', text:`Day 2 follow-up due — attempt ${contactCount+1}. Call + leave message + send email + text (CMET all 4).`, action:`makeCall('${lead.phone}','${lead.id}','${(lead.name||'').replace(/'/g,"\\'")}')`, btn:'Call + CMET', priority:'high' }); } // Day 5: mix up the channel if (contactCount >= 2 && contactCount <= 3 && daysSince >= 3 && daysSince < 7) { actions.push({ icon:'📧', color:'#1565c0', text:`Day 5 follow-up — mix up the channel. Try a personal email or text this time.`, action:`setLeadTab('emails','${lead.id}')`, btn:'Send Email', priority:'high' }); } // Day 10: final attempt if (contactCount >= 3 && daysSince >= 7) { actions.push({ icon:'🔔', color:'#c62828', text:`Day 10 — Final attempt. Make it personal and count. After this, mark lost or park for re-engage.`, action:`makeCall('${lead.phone}','${lead.id}','${(lead.name||'').replace(/'/g,"\\'")}')`, btn:'Final Call', priority:'urgent' }); } } // No inventory yet if (!hasQuote && !lead.inventory?.length && contactCount > 0) { actions.push({ icon:'📦', color:'#e65100', text:'No inventory yet — you need this to build an accurate quote and close', action:`setLeadTab('inventory','${lead.id}')`, btn:'Add Inventory' }); } // Has inventory, ready to quote if (!hasQuote && lead.inventory?.length > 0) { actions.push({ icon:'📋', color:'#059669', text:'Inventory captured — ready to build quote and go for the close', action:`createQuoteFromLead('${lead.id}')`, btn:'Build Quote', priority:'high' }); } // Quote drafted, not sent if (hasQuote && quote?.status === 'draft') { actions.push({ icon:'📤', color:'#1565c0', text:'Quote is drafted but NOT sent — send it now, don\'t lose momentum', action:`nav('quote-detail','${lead.quoteId}')`, btn:'Send Quote', priority:'high' }); } // Quote sent — follow up if (hasQuote && ['sent','viewed'].includes(quote?.status)) { if (quoteAge >= 1 && quoteAge < 3) { actions.push({ icon:'⏰', color:'#888', text:`Quote sent ${quoteAge} day${quoteAge!==1?'s':''} ago — give it until day 3, then call`, action:`nav('quote-detail','${lead.quoteId}')`, btn:'View Quote' }); } if (quoteAge >= 3) { actions.push({ icon:'🔥', color:'#e65100', text:`Quote follow-up OVERDUE — ${quoteAge} days since sent. Call now to close before they go elsewhere`, action:`makeCall('${lead.phone}','${lead.id}','${(lead.name||'').replace(/'/g,"\\'")}')`, btn:'Close Now', priority:'urgent' }); } } // Quote accepted if (hasQuote && quote?.status === 'accepted') { actions.push({ icon:'💰', color:'#059669', text:'Quote ACCEPTED — collect deposit to lock in the date immediately', action:`nav('quote-detail','${lead.quoteId}')`, btn:'Confirm Deposit', priority:'urgent' }); } // No move date if (!lead.moveDate) { actions.push({ icon:'📅', color:'#888', text:'Move date missing — always get the date, it creates urgency and helps you close', action:`showEditLeadModal('${lead.id}')`, btn:'Add Date' }); } // Long silence if (daysSince >= 14 && contactCount > 0) { actions.push({ icon:'❄️', color:'#1565c0', text:`Lead going cold — ${daysSince} days since last contact. One more personal outreach before marking lost.`, action:`makeCall('${lead.phone}','${lead.id}','${(lead.name||'').replace(/'/g,"\\'")}')`, btn:'Wake Up Lead' }); } } // ── LEAD DATA QUALITY ── const missingCard = missing.length > 0 ? `
⚠️ Missing lead data: ${missing.join(', ')} — capture this on your next call for an accurate estimate
` : ''; if (actions.length === 0 && !missingCard) return ''; // Sort: urgent first actions.sort((a,b) => (b.priority==='urgent'?2:b.priority==='high'?1:0) - (a.priority==='urgent'?2:a.priority==='high'?1:0)); return `
🤖 Next Actions
${missingCard}
${actions.map(a => `
${a.icon}
${a.text}
`).join('')}
`; } function renderLeadInventoryTab(lead) { const inventory = lead.inventory || []; const byRoom = {}; inventory.forEach(item => { const r = item.room || 'Other'; if (!byRoom[r]) byRoom[r] = []; byRoom[r].push(item); }); const rooms = Object.keys(byRoom); return `

AI Inventory${inventory.length > 0 ? ' — ' + inventory.length + ' items' : ''}

${lead.totalCubicFeet ? `
${lead.totalCubicFeet.toLocaleString()} cubic feet total
` : ''}
${lead.supabaseListing?.zpid ? `` : ''}
${inventory.length === 0 ? `
📦

No inventory yet

${lead.supabaseListing?.zpid ? 'Click Re-Scan to fetch AI inventory from MLS photos' : 'Add items manually or match an address with a direct mail listing'}

${lead.supabaseListing?.zpid ? `` : ``}
` : `
${lead.totalItems||inventory.length}
Items
${(lead.totalCubicFeet||0).toLocaleString()}
Cubic Feet
${rooms.length}
Rooms
${rooms.map(room => `
${room} ${byRoom[room].length} item${byRoom[room].length!==1?'s':''} ${byRoom[room].reduce((s,i)=>s+(i.cubicFeet||0)*(i.qty||1),0).toFixed(0)} cu ft
${byRoom[room].map(item => { const idx = inventory.indexOf(item); return `
${item.label||item.name||'Item'}${item.qty>1?' ×'+item.qty:''}
${item.cubicFeet ? `${item.cubicFeet} cu ft` : ''}
`; }).join('')}
`).join('')}
`}
`; } function renderLeadCallLogTab(lead) { const logs = [...(lead.callLogs || [])].map(l => ({ ...l, _src: 'log' })); // Merge live SMS messages for this lead's phone number const normPhone = p => (p || '').replace(/\D/g, ''); const leadPhone = normPhone(lead.phone); if (leadPhone) { smsMessages .filter(m => normPhone(m.from_number) === leadPhone || normPhone(m.to_number) === leadPhone) .forEach(m => { const alreadyLogged = logs.some(l => l._src === 'log' && l.type === 'sms' && l.notes && l.notes.includes(m.body?.slice(0, 30))); if (!alreadyLogged) { logs.push({ id: m.id, type: 'sms', _src: 'sms', notes: m.body || '(no body)', date: m.created_at, _direction: m.direction, }); } }); } // Merge live Email messages for this lead's email address const leadEmail = (lead.email || '').toLowerCase().trim(); if (leadEmail) { emailMessages .filter(m => (m.from_address||'').toLowerCase() === leadEmail || (m.to_address||'').toLowerCase() === leadEmail) .forEach(m => { const alreadyLogged = logs.some(l => l._src === 'log' && l.type === 'email' && l.notes && l.notes.includes(m.subject?.slice(0, 30))); if (!alreadyLogged) { logs.push({ id: m.id, type: 'email', _src: 'email', notes: m.subject || '(no subject)', _preview: m.body_preview || '', date: m.created_at, _direction: m.direction, }); } }); } logs.sort((a,b) => new Date(b.date) - new Date(a.date)); const icons = { call:'📞', visit:'🏠', note:'📝', recording:'🎙', email:'✉️', sms:'💬' }; const labels = { call:'Phone Call', visit:'In-Person Visit', note:'Note', recording:'Consultation Recording', email:'Email', sms:'SMS' }; return `

Activity Timeline

${logs.length === 0 ? `
📋

No activity yet

Every call, note, email, and visit appears here automatically.

` : `
${logs.map(log => `
${icons[log.type]||'📋'}
${ log._src === 'sms' ? (log._direction === 'inbound' ? '💬 SMS received' : '💬 SMS sent') : log._src === 'email' ? (log._direction === 'inbound' ? '✉️ Email received' : '✉️ Email sent') : (labels[log.type]||log.type) } ${log.date ? new Date(log.date).toLocaleDateString('en-CA',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}) : '—'}
${log.notes ? `
${escHtml ? escHtml(log.notes) : log.notes}
` : ''} ${log._preview ? `
${escHtml ? escHtml(log._preview.slice(0,120)) : log._preview.slice(0,120)}${log._preview.length>120?'…':''}
` : ''} ${log.audioDataUrl ? `
🎙 On-site recording
` : ''} ${log.recordingUrl ? `
📞 Call Recording${log.recordingDuration ? ' — '+Math.floor(log.recordingDuration/60)+'m '+log.recordingDuration%60+'s' : ''}
${log.aiSummary ? `
🤖 AI CALL ANALYSIS ${log.aiSummary.moveReadiness||'unknown'}
${log.aiSummary.summary||''}
${log.aiSummary.leadConcern?`
Their Concern
${log.aiSummary.leadConcern}
`:''} ${log.aiSummary.decisionMaker?`
Decision Maker
${log.aiSummary.decisionMaker}
`:''}
${log.aiSummary.nextAction?`
▶ Next Action: ${log.aiSummary.nextAction}
`:''} ${log.aiSummary.coachingTip?`
💡 Coach: ${log.aiSummary.coachingTip}
`:''}
` : ''} ${log.transcript ? `
📝 View full transcript
${log.transcript}
` : ''}` : (log.type==='call' && log.notes?.includes('Recording processing') ? `
⏳ Recording & transcript processing — check back in ~2 min
` : '')}
`).join('')}
`}
`; } function renderLeadConsultTab(lead) { const isRec = activeRecorder !== null; // Find the most recent call log entry that has an AI summary const callLogs = lead.callLogs || []; const latestAI = callLogs.find(l => l.aiSummary); const ai = latestAI?.aiSummary || null; const latestTranscript = latestAI?.transcript || null; const latestCallDate = latestAI?.date ? new Date(latestAI.date).toLocaleDateString('en-CA', { month:'short', day:'numeric', hour:'2-digit', minute:'2-digit' }) : null; const readinessColor = { hot: '#e53935', warm: '#fb8c00', cold: '#1565c0' }; const readinessBg = { hot: '#ffebee', warm: '#fff3e0', cold: '#e3f2fd' }; const readinessEmoji = { hot: '🔥', warm: '🌡', cold: '❄️' }; const aiBriefHTML = ai ? `
🤖
AI Call Brief
${latestCallDate ? `
From call on ${latestCallDate}
` : ''}
${readinessEmoji[ai.moveReadiness]||''} ${(ai.moveReadiness||'').toUpperCase()}
${ai.summary ? `
Summary
${ai.summary}
` : ''}
${ai.leadConcern ? `
⚠️ Main Concern
${ai.leadConcern}
` : ''} ${ai.decisionMaker ? `
👤 Decision Maker
${ai.decisionMaker}
` : ''}
${ai.nextAction ? `
🎯 Next Action
${ai.nextAction}
` : ''} ${(ai.followUpDays || lead.followUpDate) ? `
📅
Follow-Up Scheduled
${lead.followUpDate ? `${lead.followUpDate}` : `In ${ai.followUpDays} day${ai.followUpDays>1?'s':''}`} ${ai.followUpReason ? ` — ${ai.followUpReason}` : ''}
` : ''} ${ai.coachingTip ? `
💡 Coaching Note
${ai.coachingTip}
` : ''} ${latestTranscript ? `
📄 View Transcript
${latestTranscript}
` : ''}
` : callLogs.some(l => l.recordingUrl) ? `
AI analysis in progress — transcript and summary will appear here once processing is complete (usually under 2 min).
` : ''; return `
${aiBriefHTML}

🎙 Record Consultation

Record on-site conversations. Audio saves automatically to the Call Log.

${isRec ? '00:00' : ''}
Tips: • Open on your phone before entering the client's home
• Hit record before the walkthrough begins
• Recording saves automatically when you stop
• Find all recordings in the Call Log tab

📋 On-Site Checklist

Walk through this on every in-person visit.

${['Introduce yourself & Saturn Star brand','Walk each room — confirm inventory','Note specialty items (piano, safe, antiques)','Check access: stairs, elevator, parking','Note fragile / high-value items','Destination access & parking details','Packing needs (full/partial/DIY)','Confirm move date & flexibility','Present price & payment terms','Handle objections','Ask for deposit to lock in date'].map((item, i) => ` `).join('')}
`; } function renderLeadScriptTab(lead) { const isPhone = !lead._scriptMode || lead._scriptMode === 'phone'; return `
${isPhone ? renderPhoneScript() : renderVisitScript()}
`; } function setLeadScriptMode(mode, leadId) { DB.updateLead(leadId, { _scriptMode: mode }); nav('lead-detail', leadId); } function renderPhoneScript() { return `
🎯 MISSION — NEVER FORGET
"Your ONLY purpose on this call is to book the move."
Don't just give a quote — secure the date. Every question you ask should move you closer to a deposit.
1. OPEN WITH ENERGY 😁
"Hi! Thank you for calling Saturn Star Moving — this is [Name], how can I help you today?"
Audible smile — your energy sets the tone. Be enthusiastic, be warm.
📝 As soon as they speak — start writing down EVERYTHING (name, number, details).
🔍 Listen for who else might be involved in the decision. "Is it just yourself moving, or the whole family?"
2. ASK WHY THEY'RE MOVING ❤️ — DO THIS FIRST
"So — what's going on? Why are you guys moving?"
Then stop talking and let them go. They'll tell you everything.

Common reasons: retirement, near grandkids, better schools, bigger home, job relocation, divorce

Whatever they say — respond with ONE of these three:
"That's awesome." / "Congratulations — that's exciting!" / "I'm really happy for you, sounds like you're going exactly where you should be."
💡 "Be interested, not interesting." — Nobody cares what you know until they know you care. This one question separates you from every other mover they'll speak to that day. They will pay more for the person they feel connected to. Price is #7 on why people buy.
3. GET THE DETAILS (TAKE DETAILED NOTES)
"Where are you moving from?" (full address if possible)
"And where are you headed?"
"What's your target move date?" (if flexible, note that)
"Is it a house, condo, or apartment?"
"How many bedrooms?"
"Any specialty items — piano, safe, pool table, antiques?"
"Will you need packing help, or are you handling that?"
💡 Control the conversation — you ask the questions. Don't let them just ask for a price and hang up. The more they talk, the more invested they are.
4. IDENTIFY THE DECISION MAKER
"Is it just yourself making this decision, or is your partner/spouse involved too?"

If they mention a partner — "Would it be helpful to have them on the call, or are you comfortable moving forward?"
⚠️ Never pitch to the wrong person. The one on the phone may not be the one who signs off. Find out early and adjust your approach.
5. PAINT THE PICTURE 🎨
"So here's what moving day looks like with us — we'll send you [X] professional movers, the truck, and all the equipment. They'll come right to your home, quilt-pad and wrap every piece of your furniture, load it onto the truck, and unload it at the new place. If there's anything that needs to be disassembled or reassembled — bed frames, shelving, whatever — they'll take care of that too. And they'll set everything up and place it exactly where you'd like it in the new home."
Same crew, start to finish — no strangers on move day
Fully insured & licensed — everything protected
No hidden fees — price we quote is what you pay
You'll hear from us, not disappear after booking
🎯 Don't just list bullet points — say it like a story. When they can visualize their furniture in their new home, they're already emotionally sold. Never skip unloading, placement, or disassembly even if it "seems obvious" — they don't know what you do until you tell them.
🚫 NEVER badmouth competition."They're good. Here's what makes us different for YOUR move…"
6. GIVE RANGE + TYPE OF ESTIMATE BENEFIT
Tell them HOW you charge AND why it's the best way:

"We charge by the hour — the benefit of that is you're completely in control. When our crew arrives you sign them in, when they're done you sign them out. You only pay for what you actually need."

Then the range: "Based on what you've told me, you're looking at roughly [RANGE]. To lock in an exact price, I can do a quick 20-minute walkthrough — in person or video. Which works better?"
💡 Whatever billing method you use — make them believe it's the ONLY way to do it. Sell the method, not just the price.
7. ROLL INTO THE RESERVATION 🎯
Assume the sale. Don't ask "do you want to book?" — just roll right in:

"Okay, I've got you set up — did you want to put that deposit on Visa or Mastercard?"
— or —
"We have a morning and an afternoon spot available on [date] — which would you prefer?"
💡 Give two options, both lead to booking. Just like an airline assumes you're calling to buy a ticket — assume they're calling to book a move. It's elegant, not pushy.
🤐 After you offer the options — STOP TALKING. The next person who speaks, loses.
8. OBJECTIONS + TENTATIVE RESERVATION
"Price is too high"
"I get it — with moving companies, you're not comparing apples to apples. The last thing I want is for you to go with someone cheaper and end up stressed with damaged stuff. What's your budget? Let me see what we can do."

"I need to think / talk to my husband"
"Totally fair. What's the main thing on your mind — is it the price, the date, or something else?" (address it, then…)

"I'm getting other quotes"
"Smart move. What's most important to you besides price?" (then close on that thing)
🔑 TENTATIVE RESERVATION — Use this when they still won't commit:
"No problem at all — let me set you up on a tentative reservation. There's absolutely no obligation, it's just a courtesy we do for customers. This way if you need to cancel or change, you do so with no penalty. And it locks you in at today's price since rates may go up closer to the date. Can I get your name and email to hold that?"
📌 A good % will just say "let's just do it for real." The rest? You now have their info, they're in your system, and follow-up feels natural. Win either way.
9. CLOSE ON A HIGH NOTE & SEND CMET 🌟
"Awesome — you're going to love how smooth this goes. I'll send over your info right now. Looking forward to taking care of you!"
After every contact attempt — do all 4 (CMET):
📞 Call — already done
💬 Message (voicemail) — leave a warm, specific message if no answer
📧 Email — send pre-written follow-up email immediately
📱 Text — send a short, personal text right after
Different people respond to different channels. Cover all 4 every time — takes 3 minutes, doubles your contact rate.
`; } function renderVisitScript() { return `
🎯 MISSION — IN-PERSON CLOSE
"You are standing in their home. This is the highest-value sales opportunity you have. Leave with a deposit."
In-person close rate should be 70%+. If you're not closing 7/10 on-site visits, your process needs work.
1. ARRIVE RIGHT — FIRST IMPRESSION IS EVERYTHING 😊
On time, professional appearance, presentation folder in hand
Build rapport immediately — comment on the home, kids, pets, something real

Presentation Folder (non-negotiable):
• Outside: Logo, phone, website
• Inside: 15-18 real customer testimonials (ask customers to fill a 2-question survey → give $5 Starbucks card)
• Back: Your photo + cell number + "Call me anytime"
• Print at gotprint.com — ~$1 per folder. You spend all that effort getting in the door — spend the dollar.
2. ASK WHY THEY'RE MOVING — BEFORE ANYTHING ELSE ❤️
"Thanks for having me in. Before we walk through — what's going on? Why are you guys moving?"
Then stop talking and let them go. Some will talk for 10 minutes. Don't interrupt — not one word.

Common reasons: retiring, near grandkids, better schools, bigger home, job relocation, divorce

Respond with one of: "That's awesome." / "Congratulations — you're going exactly where you should be." / "I'm really happy for you."

📝 Whatever they say — write it down. Use it later in the close. (e.g. "I know this move is all about being closer to the grandkids — we're going to make sure this part is completely stress-free for you.")
💡 "Be interested, not interesting." You're a consultant who cares about them as a person, not a salesman chasing the next paycheck. The mover who asks "why?" is the one they remember — and the one they'll pay more for. Price is #7 on why people buy.
3. ROOM-BY-ROOM INVENTORY (GO DEEP)
"Let's start in the master bedroom — does that work?" You lead, they follow.

For EVERY room — dig on each item:
• What size is the bed? Headboard? Footboard?
• What's under the bed / in the closet?
• Lamps on the nightstands? Rugs? Pictures on walls?
• Dresser — how many pieces? Mirrors attached?
• Anything not going? Mark it as "leaving"

Don't miss: garage, basement, shed, balcony, storage unit, attic
Specialty items: piano, safe, pool table, antiques, large TV, hot tub
💡 Accurate inventory = accurate estimate = no surprises on move day. Digging takes 5 extra minutes and saves hours of headaches.
4. LOGISTICS DISCOVERY
Origin: Elevator? Stairs? How many floors? Long carry from door to truck?
Destination: Same questions — do they know yet?
Parking: Street, driveway, loading dock? Permit needed?
Packing: Will they pack, or do they need us? Partial pack?
Storage: Anything going into storage? How long?
Timing: Fixed date or flexible? Morning or afternoon preferred?
Access: Building access codes? Key handoff time?
5. IDENTIFY THE DECISION MAKER
Before you present price — confirm: "Is it just yourself making this decision today, or would your partner want to be part of this?"

If partner isn't there → "Is there any way they could join us? Even a quick call? I want to make sure everyone's on the same page."
⚠️ Never pitch to the wrong person. Pitching to someone who can't say yes is the #1 wasted close opportunity.
6. PAINT THE PICTURE + PRESENT PRICE
Before the number — paint the move for them:
"Here's what moving day looks like with us — we'll send [X] professional movers and the truck. They'll arrive right on time, quilt-pad and wrap every piece of furniture, load it all, drive to your new place, set everything exactly where you want it. If anything needs to be disassembled and reassembled, they'll handle that too. Fully insured, start to finish."

Then: "Based on everything I've seen today — I'm putting you at [PRICE]. That's the full job, nothing hidden."
💡 When they can visualize the move going perfectly, the price feels like a relief — not a shock.
7. CREATE URGENCY + ASK FOR THE BUSINESS
"[Move month] is filling up — we've already got a few jobs around your date. To hold your spot, all I need is a 40% deposit today. Balance on moving day."

Then — ask directly and stay quiet:
"Can we get that locked in today? I can take e-transfer or card."
🤐 After you ask — STOP TALKING. The next person who speaks, loses. Let them respond.
8. HANDLE OBJECTIONS CONFIDENTLY
"It's too expensive"
"I hear you. With moving companies, you're not comparing apples to apples — our movers are trained, experienced, and insured. The last thing I want is for you to go with someone cheaper and end up with damaged items and a stressful day. What's your budget? Let's see what we can do."

"I need to talk to my partner first"
"Of course — can we get them on a quick call right now? I'm happy to walk them through it."

"I'm still getting other quotes"
"Smart. Beyond price, what's most important to you for this move?" (then address that concern)

"I need more time"
"Totally fair. I can hold your date soft until [2 days from now] — that way you're not scrambling. Would that work?"
9. CLOSE & LEAVE THEM EXCITED 🌟
"You're in great hands. We're going to take amazing care of you. I'll send your confirmation and receipt right over — reach out anytime if you have questions."
Leave with energy. They should feel like they made a great decision. Word of mouth starts the moment you walk out the door.
`; } async function refetchInventory(leadId) { const lead = DB.getLead(leadId); if (!lead?.supabaseListing?.zpid) return; toast('Fetching AI inventory...', 'info'); const inv = await fetchInventoryForListing(lead.supabaseListing.zpid); if (inv && inv.inventory.length > 0) { DB.updateLead(leadId, { inventory: inv.inventory, totalCubicFeet: inv.totalCubicFeet, totalItems: inv.totalItems }); toast('📦 ' + inv.totalItems + ' items loaded!', 'success'); nav('lead-detail', leadId); } else { toast('No inventory data found for this listing.', 'info'); } } function showAddInventoryItemModal(leadId) { document.getElementById('modal-container').innerHTML = ` `; } function doAddInventoryItem(leadId) { const label = document.getElementById('ai-label').value.trim(); if (!label) { toast('Item name required', 'error'); return; } const lead = DB.getLead(leadId); const items = [...(lead.inventory || [])]; const qty = parseInt(document.getElementById('ai-qty').value) || 1; const cubicFeet = parseFloat(document.getElementById('ai-cuft').value) || 10; items.push({ label, qty, room: document.getElementById('ai-room').value, cubicFeet, confidence: 1, notes: 'Manually added' }); const totalCubicFeet = Math.round(items.reduce((s,i)=>s+(i.cubicFeet||0)*(i.qty||1),0)); DB.updateLead(leadId, { inventory: items, totalItems: items.length, totalCubicFeet }); closeModal(); toast('Item added', 'success'); nav('lead-detail', leadId); } function removeInventoryItem(leadId, index) { const lead = DB.getLead(leadId); const items = [...(lead.inventory || [])]; items.splice(index, 1); const totalCubicFeet = Math.round(items.reduce((s,i)=>s+(i.cubicFeet||0)*(i.qty||1),0)); DB.updateLead(leadId, { inventory: items, totalItems: items.length, totalCubicFeet }); nav('lead-detail', leadId); } function showAddLogModal(leadId) { document.getElementById('modal-container').innerHTML = ` `; } function doAddLog(leadId) { const notes = document.getElementById('log-notes').value.trim(); if (!notes) { toast('Notes are required', 'error'); return; } addCallLogEntry(leadId, { type: document.getElementById('log-type').value, notes, date: new Date().toISOString() }); closeModal(); toast('Activity logged', 'success'); nav('lead-detail', leadId); } function createQuoteFromLead(leadId) { const lead = DB.getLead(leadId); if (!lead) return; let clientId = null; const existing = DB.getClients().find(c => c.name === lead.name || (lead.phone && c.phone === lead.phone)); if (existing) { clientId = existing.id; } else { clientId = 'cli_' + uid(); DB.addClient({ id: clientId, name: lead.name, phone: lead.phone, email: lead.email, type: lead.moveType === 'long-distance' ? 'long-distance' : 'residential', company: '', createdAt: new Date().toISOString().split('T')[0] }); } nav('new-quote'); setTimeout(() => { const cs = document.getElementById('qb-client'); if (cs) { cs.value = clientId; onClientChange(clientId); } const ts = document.getElementById('qb-type'); if (ts) { ts.value = lead.moveType === 'long-distance' ? 'long-distance' : 'residential'; onTypeChange(ts.value); } const md = document.getElementById('qb-movedate'); if (md && lead.moveDate) md.value = lead.moveDate; const oa = document.getElementById('qb-oa'); if (oa) oa.value = lead.originAddress || ''; const oc = document.getElementById('qb-oc'); if (oc) oc.value = lead.originCity || 'Windsor'; const dc = document.getElementById('qb-dc'); if (dc) dc.value = lead.destCity || ''; if (lead.totalCubicFeet > 0) { const isLD = lead.moveType === 'long-distance'; const est = Math.round(lead.totalCubicFeet * (isLD ? 6 : 4) / 50) * 50; qLineItems = [{ description: 'Moving Service — Flat Rate', details: lead.totalCubicFeet + ' cu ft estimated (AI scan), ' + (isLD ? 'long-distance' : 'local') + ' move', amount: est }]; renderLineItems(); renderTotals(); } toast('Quote pre-filled from lead — review & save', 'info'); }, 50); const leadForScore = DB.getLead(leadId); const newScore = leadForScore ? calculateLeadScore({ ...leadForScore, stage: 'quoted' }) : 0; DB.updateLead(leadId, { stage: 'quoted', leadScore: newScore }); } // ============================================================ // ANALYTICS // ============================================================ function renderAnalytics(el, actions) { const leads = DB.getLeads(); const quotes = DB.getQuotes(); const booked = leads.filter(l => l.stage === 'booked').length; const dmLeads = leads.filter(l => l.directMailAttributed).length; const dmBooked = leads.filter(l => l.directMailAttributed && l.stage === 'booked').length; const convRate = leads.length ? Math.round(booked / leads.length * 100) : 0; const dmConv = dmLeads ? Math.round(dmBooked / dmLeads * 100) : 0; const wonVal = quotes.filter(q => q.status === 'accepted' || q.status === 'invoiced').reduce((s,q)=>s+(q.total||0),0); const pipeVal = quotes.filter(q => ['sent','viewed'].includes(q.status)).reduce((s,q)=>s+(q.total||0),0); // Revenue forecast from leads const quotedLeads = leads.filter(l => l.stage === 'quoted'); const bookedLeads = leads.filter(l => l.stage === 'booked'); const quotedPipelineVal = quotedLeads.reduce((s, l) => { if (l.quoteId) { const q = DB.getQuote(l.quoteId); return s + (q ? (q.total||0) : 0); } if (l.totalCubicFeet > 0) { const isLD = l.moveType === 'long-distance'; return s + Math.round(l.totalCubicFeet * (isLD ? 6 : 4) / 50) * 50; } return s; }, 0); const bookedRevenueVal = bookedLeads.reduce((s, l) => { if (l.quoteId) { const q = DB.getQuote(l.quoteId); return s + (q ? (q.total||0) : 0); } return s; }, 0); const srcCount = {}; leads.forEach(l => { srcCount[l.source] = (srcCount[l.source]||0)+1; }); const maxSrc = Math.max(...Object.values(srcCount), 1); const stageCount = {}; LEAD_STAGES.forEach(s => { stageCount[s.id] = leads.filter(l=>l.stage===s.id).length; }); const maxStage = Math.max(...Object.values(stageCount), 1); const qStatCount = {}; ['draft','sent','viewed','accepted','declined','invoiced'].forEach(s => { qStatCount[s] = quotes.filter(q=>q.status===s).length; }); const maxQ = Math.max(...Object.values(qStatCount), 1); const srcColors = { direct_mail:'#4caf50', referral:'#1565c0', google:'#f57c00', cold_call:'#6a1b9a', walk_in:'#c62828', other:'#888' }; const stageColors = { new:'#3949ab', contacted:'#1565c0', quoted:'#6a1b9a', booked:'#2e7d32', lost:'#888' }; const qColors = { draft:'#888', sent:'#1565c0', viewed:'#6a1b9a', accepted:'#2e7d32', declined:'#c62828', invoiced:'#e65100' }; el.innerHTML = `
Total Leads
${leads.length}
All time
Booked
${booked}
${convRate}% conversion
Pipeline Value
${$(pipeVal)}
Open quotes
Won Revenue
${$(wonVal)}
Accepted quotes
Direct Mail ROI
${dmLeads}
${dmBooked} booked (${dmConv}%)

Leads by Source

${!leads.length ? `

No leads yet

` : `
${Object.entries(srcCount).sort((a,b)=>b[1]-a[1]).map(([src,cnt]) => `
${LEAD_SOURCES[src]||src}
 
${cnt}
`).join('')}
`}

Lead Pipeline Funnel

${!leads.length ? `

No leads yet

` : `
${LEAD_STAGES.map(s => { const cnt = stageCount[s.id]||0; const pct = leads.length ? Math.round(cnt/leads.length*100) : 0; const w = Math.max(cnt ? Math.round(cnt/maxStage*100) : 0, 8); return `
${s.label}
${cnt}
${pct}%
`; }).join('')}
`}

Quote Status Breakdown

${!quotes.length ? `

No quotes yet

` : `
${['draft','sent','viewed','accepted','declined','invoiced'].filter(s=>qStatCount[s]>0).map(s => `
${s}
 
${qStatCount[s]}
`).join('')}
`}

Revenue Forecast

Quoted Pipeline
${quotedLeads.length} lead${quotedLeads.length!==1?'s':''} in quoted stage
${$(quotedPipelineVal)}
Booked Revenue
${bookedLeads.length} lead${bookedLeads.length!==1?'s':''} booked
${$(bookedRevenueVal)}
Combined Forecast
Quoted + booked
${$(quotedPipelineVal + bookedRevenueVal)}

Direct Mail Attribution

${!leads.length ? `

No leads yet

` : `
${dmLeads}
leads from direct mail campaigns
${dmBooked}
Booked from Mail
${dmConv}%
Mail Conv. Rate
`}
`; } // ============================================================ // DIALER — Twilio Voice SDK // ============================================================ let selectedInboundId = null; let twilioDevice = null; let activeCall = null; let incomingCall = null; let callStartTime = null; let callLeadId = null; let callTimerInt = null; let isMuted = false; let dialerReady = false; function toE164(phone) { if (!phone) return ''; const d = phone.replace(/\D/g, ''); if (d.length === 10) return '+1' + d; if (d.length === 11 && d.startsWith('1')) return '+' + d; return d.startsWith('+') ? phone : '+' + d; } async function initDialer() { if (dialerReady) return true; try { const res = await fetch(`${WORKER_URL}/voice-token`); const data = await res.json(); if (!data.ok || !data.token) throw new Error(data.error || 'No token'); twilioDevice = new Twilio.Device(data.token, { logLevel: 1, codecPreferences: ['opus', 'pcmu'], edge: 'roaming', allowIncomingWhileBusy: false, }); await twilioDevice.register(); dialerReady = true; const dot = document.getElementById('dialer-status-dot'); if (dot) dot.style.display = 'inline-block'; // ── Incoming call handler ── twilioDevice.on('incoming', call => { incomingCall = call; const from = call.parameters?.From || call.parameters?.from || ''; const displayFrom = from ? formatIncomingNumber(from) : 'Unknown Caller'; document.getElementById('incoming-name').textContent = displayFrom; document.getElementById('incoming-number').textContent = from || ''; document.getElementById('incoming-banner').classList.add('active'); // Auto-dismiss banner if caller hangs up before we answer call.on('cancel', hideIncomingBanner); call.on('disconnect', hideIncomingBanner); }); // Refresh token every 55 min setInterval(async () => { try { const r = await fetch(`${WORKER_URL}/voice-token`); const d = await r.json(); if (d.ok) twilioDevice.updateToken(d.token); } catch {} }, 55 * 60 * 1000); return true; } catch (e) { console.error('Dialer init failed:', e); toast('Dialer init failed: ' + e.message, 'error'); return false; } } function hideIncomingBanner() { document.getElementById('incoming-banner').classList.remove('active'); incomingCall = null; } function formatIncomingNumber(raw) { const d = raw.replace(/\D/g, ''); if (d.length === 11 && d.startsWith('1')) return `+1 (${d.slice(1,4)}) ${d.slice(4,7)}-${d.slice(7)}`; if (d.length === 10) return `(${d.slice(0,3)}) ${d.slice(3,6)}-${d.slice(6)}`; return raw; } async function answerIncoming() { if (!incomingCall) return; const from = incomingCall.parameters?.From || incomingCall.parameters?.from || ''; hideIncomingBanner(); try { const ac = new AudioContext(); if (ac.state === 'suspended') await ac.resume(); } catch {} activeCall = incomingCall; incomingCall = null; activeCall.accept(); callStartTime = Date.now(); // Try to match to existing lead by phone number const formattedFrom = formatIncomingNumber(from); const existingLead = DB.getLeads().find(l => l.phone && l.phone.replace(/\D/g,'') === from.replace(/\D/g,'')); if (existingLead) { callLeadId = existingLead.id; showCallBar(existingLead.name, formattedFrom, 'active'); toast(`📞 Matched to lead: ${existingLead.name}`, 'info'); } else { callLeadId = '_inbound_' + Date.now(); // temp ID — will prompt to create after showCallBar(formattedFrom || 'Incoming Call', from, 'active'); } activeCall.on('disconnect', () => onInboundCallEnded(from, formattedFrom, existingLead)); } function declineIncoming() { if (!incomingCall) return; incomingCall.reject(); hideIncomingBanner(); } async function makeCall(phone, leadId, displayName) { const e164 = toE164(phone); if (!e164) { toast('No phone number for this lead', 'error'); return; } // Always init inside user gesture so AudioContext starts in active state if (!dialerReady) { toast('Connecting dialer...', 'info'); const ok = await initDialer(); if (!ok) return; } // Resume AudioContext — browsers suspend it until user gesture try { if (typeof AudioContext !== 'undefined') { const ac = new AudioContext(); if (ac.state === 'suspended') await ac.resume(); } } catch {} // Ensure speaker output is active try { if (twilioDevice.audio) { await twilioDevice.audio.speakerDevices.set('default'); } } catch {} try { callLeadId = leadId || null; isMuted = false; callStartTime = null; activeCall = await twilioDevice.connect({ params: { To: e164 } }); showCallBar(displayName || phone, e164, 'ringing'); activeCall.on('accept', () => { callStartTime = Date.now(); startCallTimer(); showCallBar(displayName || phone, e164, 'active'); toast('Call connected', 'success'); }); activeCall.on('disconnect', () => onCallEnded(displayName, e164)); activeCall.on('cancel', () => onCallEnded(displayName, e164, true)); activeCall.on('error', (err) => { toast('Call error: ' + err.message, 'error'); onCallEnded(displayName, e164, true); }); } catch (e) { toast('Could not connect: ' + e.message, 'error'); } } function onInboundCallEnded(rawFrom, formattedFrom, existingLead) { const duration = callStartTime ? Math.floor((Date.now() - callStartTime) / 1000) : 0; const mins = Math.floor(duration / 60), secs = duration % 60; const durStr = duration > 0 ? `${mins}m ${secs}s` : 'missed'; const callSid = activeCall?.parameters?.CallSid || null; if (existingLead) { // Log to existing lead const logId = uid(); const logs = existingLead.callLogs || []; logs.unshift({ id: logId, type: 'call', notes: `Inbound call from ${formattedFrom} — ${durStr}.${duration>0?' Recording processing…':''}`, date: new Date().toISOString(), duration: durStr, phone: formattedFrom, callSid }); DB.updateLead(existingLead.id, { callLogs: logs }); if (callSid && duration > 0) { fetch(`${_sbBase()}/rest/v1/crm_call_sids`, { method:'POST', headers:_sbH({'Prefer':'resolution=merge-duplicates,return=minimal'}), body:JSON.stringify({call_sid:callSid,lead_id:existingLead.id,call_log_id:logId}) }).catch(()=>{}); } } else if (duration > 0) { // Unknown caller — show prompt to create lead showPostCallPrompt(rawFrom, formattedFrom, durStr, callSid); } hideCallBar(); activeCall = null; callLeadId = null; callStartTime = null; if (duration > 0) toast(`Call ended — ${durStr}`, 'info'); if (view === 'lead-detail') render(); } function showPostCallPrompt(rawFrom, formattedFrom, durStr, callSid) { const m = document.createElement('div'); m.id = 'post-call-modal'; m.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:1000;backdrop-filter:blur(2px);padding:16px;'; m.innerHTML = `
📞 New Call — Create Lead?
Inbound from ${formattedFrom} · ${durStr}
`; document.body.appendChild(m); setTimeout(() => document.getElementById('pc-first')?.focus(), 100); } function createLeadFromCall(rawFrom, callSid, andSendEstimate = false) { const first = document.getElementById('pc-first')?.value?.trim() || ''; const last = document.getElementById('pc-last')?.value?.trim() || ''; const name = [first, last].filter(Boolean).join(' ') || rawFrom; const phone = document.getElementById('pc-phone')?.value?.trim() || rawFrom; const note = document.getElementById('pc-note')?.value?.trim() || ''; const logId = uid(); const lead = { id: 'ld_' + uid(), name, phone, email: '', source: 'twilio_call', stage: 'new', moveType: 'residential', moveDate: '', originAddress: '', originCity: 'Windsor', destCity: '', directMailAttributed: false, supabaseListing: null, inventory: [], totalItems: 0, totalCubicFeet: 0, callLogs: [{ id: logId, type: 'call', notes: `Inbound call — answered.${note ? ' ' + note : ''}${callSid ? ' Recording processing…' : ''}`, date: new Date().toISOString(), phone, callSid: callSid || null }], notes: note, quoteId: null, createdAt: new Date().toISOString().split('T')[0], }; DB.addLead(lead); if (callSid) { fetch(`${_sbBase()}/rest/v1/crm_call_sids`, { method:'POST', headers:_sbH({'Prefer':'resolution=merge-duplicates,return=minimal'}), body:JSON.stringify({call_sid:callSid,lead_id:lead.id,call_log_id:logId}) }).catch(()=>{}); } document.getElementById('post-call-modal')?.remove(); toast(`Lead "${name}" created ✓`, 'success'); nav('lead-detail', lead.id); if (andSendEstimate) { setTimeout(() => showQuickSendModal(lead.id), 200); } } function onCallEnded(name, phone, cancelled = false) { const duration = callStartTime ? Math.floor((Date.now() - callStartTime) / 1000) : 0; const mins = Math.floor(duration / 60); const secs = duration % 60; const durStr = duration > 0 ? `${mins}m ${secs}s` : (cancelled ? 'cancelled' : 'no answer'); // Log to lead if (callLeadId) { const lead = DB.getLead(callLeadId); if (lead) { const logId = uid(); const callSid = activeCall?.parameters?.CallSid || activeCall?.parameters?.callsid || null; const logs = lead.callLogs || []; logs.unshift({ id: logId, type: 'call', notes: `Outbound call to ${phone} — ${durStr}.${duration > 0 ? ' Recording processing…' : ''}`, date: new Date().toISOString(), duration: durStr, phone, callSid, }); DB.updateLead(callLeadId, { callLogs: logs }); // Save CallSid → leadId mapping so recording webhook can find this lead if (callSid && duration > 0) { fetch(`${_sbBase()}/rest/v1/crm_call_sids`, { method: 'POST', headers: _sbH({ 'Prefer': 'resolution=merge-duplicates,return=minimal' }), body: JSON.stringify({ call_sid: callSid, lead_id: callLeadId, call_log_id: logId }), }).catch(() => {}); } } } hideCallBar(); activeCall = null; callLeadId = null; callStartTime = null; if (duration > 0) toast(`Call ended — ${durStr}`, 'info'); if (view === 'lead-detail') render(); } function hangupCall() { if (activeCall) activeCall.disconnect(); else hideCallBar(); } function toggleMute() { if (!activeCall) return; isMuted = !isMuted; activeCall.mute(isMuted); const btn = document.getElementById('mute-btn'); if (btn) { btn.textContent = isMuted ? '🔇 Unmute' : '🎙 Mute'; btn.classList.toggle('active', isMuted); } toast(isMuted ? 'Muted' : 'Unmuted', 'info'); } function showCallBar(name, number, state = 'active') { const bar = document.getElementById('call-bar'); const ind = document.getElementById('call-indicator'); const nm = document.getElementById('call-bar-name'); const num = document.getElementById('call-bar-number'); const time = document.getElementById('call-timer'); if (!bar) return; nm.textContent = name; num.textContent = number; time.textContent = '0:00'; bar.className = state === 'ringing' ? 'active ringing' : 'active'; ind.className = state === 'ringing' ? 'call-indicator ringing' : 'call-indicator'; } function hideCallBar() { stopCallTimer(); const bar = document.getElementById('call-bar'); if (bar) bar.className = ''; } function startCallTimer() { stopCallTimer(); callTimerInt = setInterval(() => { if (!callStartTime) return; const s = Math.floor((Date.now() - callStartTime) / 1000); const m = Math.floor(s / 60); const sec = String(s % 60).padStart(2, '0'); const el = document.getElementById('call-timer'); if (el) el.textContent = `${m}:${sec}`; }, 1000); } function stopCallTimer() { if (callTimerInt) { clearInterval(callTimerInt); callTimerInt = null; } } function renderDialer(el, actions) { const leads = DB.getLeads().filter(l => l.phone); actions.innerHTML = `
${dialerReady ? 'Ready' : 'Initialising...'}
`; el.innerHTML = `
Quick Dial
${[['1',''],['2','ABC'],['3','DEF'],['4','GHI'],['5','JKL'],['6','MNO'],['7','PQRS'],['8','TUV'],['9','WXYZ'],['*',''],['0','+'],['#','']].map(([n,l]) => `` ).join('')}
Your Number
(226) 773-2993
Shows on recipient's caller ID
🔴 Calls are recorded automatically.
Recordings saved to lead timeline.
Leads — ${leads.length} with phone numbers
${leads.length === 0 ? `
📵

No leads with phone numbers

Add phone numbers to leads to call them here.

` : leads.map(lead => { const stage = LEAD_STAGES.find(s => s.id === lead.stage); const callCount = (lead.callLogs || []).filter(l => l.type === 'call').length; return `
👤
${lead.name}
${lead.phone}
${stage?.label || lead.stage} ${callCount > 0 ? `${callCount} call${callCount!==1?'s':''}` : ''}
`; }).join('')}
`; } // ============================================================ // EMAIL MODULE // ============================================================ function getEmailTemplate(type, lead) { const s = getSettings(); const firstName = lead ? (lead.name?.split(' ')[0] || 'there') : 'there'; const company = s.fromName || s.companyName || 'Saturn Star Moving Company'; const phone = s.phone || '(226) 215-9874'; const replyEmail = s.replyEmail || 'business@starmovers.ca'; const sig = `\n\n— ${s.senderName || 'John'}\n${company}\n${phone} | ${replyEmail}`; const moveInfo = lead ? [ lead.moveDate ? `Move Date: ${lead.moveDate}` : '', lead.originCity && lead.destCity ? `Route: ${lead.originCity} → ${lead.destCity}` : '', ].filter(Boolean).join(' · ') : ''; const quote = lead?.quoteId ? DB.getQuote(lead.quoteId) : null; const T = { first_contact: { label: 'First Contact', subject: `Your Move Inquiry — ${company}`, body: `Hi ${firstName},\n\nThank you for reaching out to us! We'd love to help with your upcoming move.\n\nTo prepare the most accurate quote, could we schedule a quick 10-minute call? I'm available most days and happy to work around your schedule.\n\nYou can also reach me directly at ${phone} or just reply here.${sig}`, }, quote_sent: { label: 'Quote Ready', subject: `Your Moving Quote${quote ? ` — ${quote.number}` : ''} is Ready`, body: `Hi ${firstName},\n\nThank you for the opportunity to quote your upcoming move${moveInfo ? ` (${moveInfo})` : ''}.\n\nYour detailed quote includes everything we discussed${quote ? `. Total: $${quote.total?.toLocaleString()}` : ''}.\n\nFeel free to reply with any questions or changes — happy to adjust anything. To confirm, just let me know and we'll lock in your date right away.${sig}${lead?.moveReason ? `\n\nP.S. ${lead.moveReason.toLowerCase().includes('retire') ? `Congratulations on the retirement — this is such an exciting chapter. We're going to make sure this move is completely stress-free for you.` : lead.moveReason.toLowerCase().includes('school') || lead.moveReason.toLowerCase().includes('kids') || lead.moveReason.toLowerCase().includes('family') ? `Moving closer to family is such a special thing. We're honoured to be part of that journey for you.` : `Congratulations on the move — sounds like you're going exactly where you should be. We're looking forward to taking great care of you!`}` : `\n\nP.S. [Add a personal note here about why they're moving — one genuine sentence. This alone can get you a yes in 5 minutes.]`}`, }, follow_up: { label: 'Follow-Up', subject: `Following Up — Your Moving Quote`, body: `Hi ${firstName},\n\nI just wanted to make sure you received our quote and had a chance to look it over. Things get busy, so no worries at all!\n\nDo you have any questions, or would you like us to adjust anything? I'm happy to hop on a quick call if that's easier — just reach me at ${phone}.${sig}`, }, booking_confirm: { label: 'Move Confirmed', subject: `Your Move is Confirmed! 🎉`, body: `Hi ${firstName},\n\nGreat news — your move is officially confirmed!\n\n${moveInfo ? `📍 ${moveInfo}\n\n` : ''}What to expect:\n• Our crew arrives 8:00–9:00 AM on move day\n• We'll handle everything with the same care we discussed\n• Questions anytime? Call us at ${phone}\n\nLooking forward to making your move smooth and stress-free!${sig}`, }, estimate_sms_combo: { label: 'Estimate + SMS', subject: `Your Moving Estimate from ${company}`, body: `Hi ${firstName},\n\nThank you for choosing ${company}! Please find your moving estimate below.\n\n${moveInfo ? `📍 ${moveInfo}\n` : ''}${quote ? `💰 Total: $${quote.total?.toLocaleString()} (incl. HST)\n📋 Quote #: ${quote.number}\n` : ''}\nTo book your move or ask any questions, reply here or call/text us at ${phone}.\n\nWe look forward to serving you!${sig}`, }, thank_you: { label: 'Thank You', subject: `Thank You — It Was a Pleasure! ⭐`, body: `Hi ${firstName},\n\nThank you so much for choosing ${company} for your move. It was a genuine pleasure working with you!\n\nIf you have a moment, a Google review would mean the world to our team — it helps other families find us when they need reliable movers.\n\nAnd if you ever need help again — or know someone who does — we'd love to be your go-to moving company.\n\nWishing you all the best in your new home!${sig}`, }, re_engage: { label: 'Re-Engage', subject: `Still Planning Your Move? We're Here`, body: `Hi ${firstName},\n\nI wanted to reach out one more time. We still have your details on file and would love to help whenever the timing is right.\n\nIf anything has changed — your timeline, budget, or destination — just let us know and we'll update your quote. No pressure at all.\n\nWe're here when you're ready.${sig}`, }, }; return T[type] || T.first_contact; } const WORKER_URL = 'https://saturn-lead-intake.johnowolabi80.workers.dev'; async function sendEmail({ to, subject, body, leadId, templateType }) { try { const res = await fetch(`${WORKER_URL}/send-email`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ to, subject, body }), }); const data = await res.json(); if (!data.ok) throw new Error(data.error || 'Send failed'); const email = DB.addEmail({ id: data.messageId || ('em_' + uid()), leadId: leadId || null, to, subject, body, from: 'business@starmovers.ca', templateType: templateType || null, direction: 'outbound', status: 'sent', sentAt: new Date().toISOString(), }); updateEmailBadge(); return email; } catch (e) { console.error('Email send error:', e); toast('Send failed: ' + e.message, 'error'); return null; } } async function sendSMS(to, body, leadId) { const workerUrl = 'https://saturn-lead-intake.johnowolabi80.workers.dev/send-sms'; try { const res = await fetch(workerUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ to, body, leadId: leadId || null }), }); return res.ok; } catch (e) { console.warn('SMS send error:', e); return false; } } function updateEmailBadge() { const count = DB.getEmails().filter(e => e.direction === 'outbound' && e.status === 'sent').length; const badge = document.getElementById('email-badge'); if (!badge) return; if (count > 0) { badge.textContent = count; badge.style.display = 'inline-block'; } else { badge.style.display = 'none'; } } // ───────────────────────────────────────────────────────────────────────────── // SMS INBOX — Two-way SMS conversations (iMessage style) // Polls Supabase sms_messages table every 15 seconds // ───────────────────────────────────────────────────────────────────────────── let smsMessages = []; // flat array from Supabase let smsLastPoll = null; // ISO string of last poll let smsPollingTimer = null; let smsActivePhone = null; // currently viewed contact phone // ── Email messages (inbound + outbound) synced from Worker/Supabase ────────── let emailMessages = []; // flat array {id,from_address,to_address,subject,body_preview,direction,lead_id,created_at} let emailLastSync = null; // timestamp of last Zoho sync let emailPollingTimer = null; async function pollEmailMessages() { try { const res = await fetch(`${WORKER_URL}/email-messages`); if (!res.ok) return; const data = await res.json(); if (data.ok && Array.isArray(data.messages)) { emailMessages = data.messages; emailLastSync = new Date().toISOString(); updateEmailInboxBadge(); if (view === 'email') render(); if (view === 'leads' || document.querySelector('.lead-detail-overlay')) { // Refresh active lead detail if open so timeline updates const openLead = document.getElementById('lead-detail-content'); if (openLead) render(); } } } catch (e) { /* silent — worker may be unavailable */ } } function updateEmailInboxBadge() { const unread = emailMessages.filter(m => m.direction === 'inbound').length; const badge = document.getElementById('email-badge'); if (!badge) return; if (unread > 0) { badge.textContent = unread; badge.style.display = 'inline-block'; } else badge.style.display = 'none'; } function startEmailPolling() { if (emailPollingTimer) return; pollEmailMessages(); // immediate first poll emailPollingTimer = setInterval(pollEmailMessages, 60000); // every 60s } const MY_NUMBER = '+12267732993'; // Get the "contact" phone from a message (whichever side is NOT us) function smsContactPhone(msg) { return msg.direction === 'inbound' ? msg.from_number : msg.to_number; } // Group messages into conversations keyed by contact phone function smsBuildConversations(messages) { const convMap = {}; messages.forEach(msg => { const phone = smsContactPhone(msg); if (!convMap[phone]) convMap[phone] = { phone, messages: [], latestAt: msg.created_at, unread: 0 }; convMap[phone].messages.push(msg); if (msg.created_at > convMap[phone].latestAt) convMap[phone].latestAt = msg.created_at; if (msg.direction === 'inbound') convMap[phone].unread++; }); // Sort conversations by latest message desc return Object.values(convMap).sort((a, b) => b.latestAt.localeCompare(a.latestAt)); } // Look up a lead name matching a phone number function smsNameForPhone(phone) { const leads = DB.getLeads(); const norm = (p) => (p || '').replace(/\D/g, ''); const match = leads.find(l => norm(l.phone) === norm(phone)); return match ? match.name : null; } async function pollSMSMessages() { const s = DB.get('settings')[0] || {}; const url = s.supabaseUrl || SUPABASE_DEFAULT_URL; const key = s.supabaseKey || SUPABASE_DEFAULT_KEY; if (!url || !key) return; try { let query = `${url}/rest/v1/sms_messages?order=created_at.asc&limit=500`; if (smsLastPoll) query += `&created_at=gt.${encodeURIComponent(smsLastPoll)}`; const res = await fetch(query, { headers: { apikey: key, Authorization: `Bearer ${key}` }, }); if (!res.ok) return; const rows = await res.json(); if (!Array.isArray(rows) || rows.length === 0) return; let hasNew = false; const existingIds = new Set(smsMessages.map(m => m.id)); rows.forEach(row => { if (!existingIds.has(row.id)) { smsMessages.push(row); hasNew = true; } }); smsLastPoll = new Date().toISOString(); if (hasNew) { updateSMSBadge(); if (view === 'sms') render(); // Refresh SMS tab if open on a lead detail page if (view === 'lead-detail' && leadDetailTab === 'sms' && detailId) { const l = DB.getLead(detailId); if (l) { document.getElementById('lead-tab-content').innerHTML = renderLeadSMSTab(l); leadSmsScrollBottom(); } } } } catch (e) { /* silent — table may not exist yet */ } } function updateSMSBadge() { const convs = smsBuildConversations(smsMessages); const unread = convs.reduce((sum, c) => sum + (c.unread > 0 ? 1 : 0), 0); const badge = document.getElementById('sms-badge'); if (!badge) return; if (unread > 0) { badge.textContent = unread; badge.style.display = 'inline-block'; } else badge.style.display = 'none'; } function startSMSPolling() { if (smsPollingTimer) return; pollSMSMessages(); // immediate first poll smsPollingTimer = setInterval(pollSMSMessages, 15000); } function renderSMSView(el, actions) { startSMSPolling(); const convs = smsBuildConversations(smsMessages); actions.innerHTML = ` `; function renderConvList() { const listEl = document.getElementById('sms-conv-list'); if (!listEl) return; if (convs.length === 0) { listEl.innerHTML = `
No SMS conversations yet.

Texts will appear here when leads reply or when you send from the lead card.
`; return; } listEl.innerHTML = convs.map(conv => { const last = conv.messages[conv.messages.length - 1]; const name = smsNameForPhone(conv.phone) || conv.phone; const preview = last ? last.body.slice(0, 55) + (last.body.length > 55 ? '…' : '') : '—'; const isActive = smsActivePhone === conv.phone; return `
${name} ${timeAgo(conv.latestAt)}
${conv.unread > 0 && !isActive ? `` : ''} ${last?.direction === 'outbound' ? '↗ ' : ''}${preview}
`; }).join(''); } function renderThread(phone) { const threadEl = document.getElementById('sms-thread-pane'); if (!threadEl) return; if (!phone) { threadEl.innerHTML = `
💬

Select a conversation

`; return; } const conv = convs.find(c => c.phone === phone); const name = smsNameForPhone(phone) || phone; const msgs = conv ? conv.messages : []; threadEl.innerHTML = `
${(smsNameForPhone(phone) || phone)[0].toUpperCase()}
${name}
${phone}
${msgs.length === 0 ? `
No messages yet
` : msgs.map(m => `
${escHtml(m.body)}
${formatDateTime(m.created_at)}
`).join('')}
`; // Scroll to bottom const area = document.getElementById('sms-msg-area'); if (area) setTimeout(() => { area.scrollTop = area.scrollHeight; }, 50); } el.innerHTML = `
Conversations
💬

Select a conversation

`; renderConvList(); if (smsActivePhone) renderThread(smsActivePhone); // expose helpers for onclick window.selectSMSConv = function(phone) { smsActivePhone = phone; renderConvList(); renderThread(phone); }; window.smsSendReply = async function(phone) { const input = document.getElementById('sms-reply-input'); const body = input?.value?.trim(); if (!body) return; input.value = ''; input.style.height = 'auto'; const leadMatch = DB.getLeads().find(l => { const norm = p => (p || '').replace(/\D/g, ''); return norm(l.phone) === norm(phone); }); const ok = await sendSMS(phone, body, leadMatch?.id || null); if (ok) { // optimistically add to local state before Supabase poll smsMessages.push({ id: 'local_' + Date.now(), from_number: MY_NUMBER, to_number: phone, body, direction: 'outbound', lead_id: leadMatch?.id || null, created_at: new Date().toISOString(), }); // Re-render const updatedConvs = smsBuildConversations(smsMessages); Object.assign(convs, updatedConvs); convs.length = 0; updatedConvs.forEach(c => convs.push(c)); renderConvList(); renderThread(phone); } else { toast('Failed to send SMS', 'error'); } }; } let emailComposeLeadId = null; let emailActiveTpl = 'first_contact'; function renderEmailView(el, actions) { const leads = DB.getLeads().filter(l => l.email); const allEmails = DB.getEmails(); actions.innerHTML = ``; el.innerHTML = `
Leads with Email
${leads.length === 0 ? `
No leads with email yet
` : leads.map(lead => { const sentCount = allEmails.filter(e => e.leadId === lead.id).length; const latest = allEmails.filter(e => e.leadId === lead.id)[0]; const stageInfo = LEAD_STAGES.find(s => s.id === lead.stage); return ``; }).join('')}
✉️

Select a lead to email

Pick a lead from the left, or click Compose for a new email.

`; window.selectEmailLead = (id) => { document.querySelectorAll('.email-lead-row').forEach(r => r.style.background = 'white'); const row = document.querySelector(`[data-leadid="${id}"]`); if (row) row.style.background = '#f0f4ff'; renderEmailPanelForLead(id); }; window.filterEmailLeads = () => { const q = document.getElementById('email-search')?.value?.toLowerCase() || ''; document.querySelectorAll('.email-lead-row').forEach(r => { const txt = r.textContent.toLowerCase(); r.style.display = txt.includes(q) ? '' : 'none'; }); }; } function renderEmailPanelForLead(leadId) { const panel = document.getElementById('email-right-panel'); if (!panel) return; const lead = DB.getLead(leadId); if (!lead) return; const emails = DB.getEmailsForLead(leadId); emailComposeLeadId = leadId; panel.innerHTML = renderLeadEmailsTab(lead, true); } function renderLeadOpsTab(lead) { const ops = lead.ops || {}; const moveDate = lead.moveDate || '—'; const crewSize = lead.crewSize || ops.crewSize || ''; const truckCount = ops.truckCount || ''; function check(key, label) { const done = ops[key] === true; return `
${done ? '✅' : '⬜'} ${label}
`; } return `
🚛 Move Day: ${moveDate}  |  ${lead.name}
${lead.originAddress || lead.originCity || '?'} → ${lead.destCity || lead.destAddress || '?'}
☎️ Scripted Confirmation Call (1-2 days before)
Do NOT just say "we'll be there at 8am." Go through every point below.
${check('conf_address', 'Confirm pickup address & destination address (both correct?)')} ${check('conf_timing', 'Confirm arrival time window (morning vs afternoon)')} ${check('conf_crew', 'Confirm crew size & number of trucks')} ${check('conf_charges', 'Review all charges so there are NO surprises on move day')} ${check('conf_packing', 'Any packing needed? (opportunity to upsell)')} ${check('conf_storage', 'Any storage needed? (opportunity to upsell)')} ${check('conf_access', 'Elevator / stairs / long carry / parking at both locations?')} ${check('conf_specialty', 'Specialty items — piano, safe, antiques, large TVs, pool table?')} ${check('conf_disassembly', 'Anything that needs disassembly / reassembly?')} ${check('conf_extras', 'Anything new since they booked? Any changes?')} ${check('conf_payment', 'Confirm payment method — how is balance being paid on move day?')} ${check('conf_done', '✅ Confirmation call complete — move detail sheet prepared for crew')}
📦 Truck Inventory (Before Dispatch)
Verify equipment is on the truck. Track it out, track it back in. Stop losing pads and dollies.
${check('truck_pads', 'Moving pads — correct qty loaded')} ${check('truck_dollies', 'Dollies (hand truck + furniture dolly)')} ${check('truck_straps', 'Straps & ratchets')} ${check('truck_wrap', 'Shrink wrap rolls')} ${check('truck_tape', 'Tape, bubble wrap, packing materials')} ${check('truck_tools', 'Disassembly tools (Allen keys, screwdrivers, wrench)')} ${check('truck_paperwork', 'Move detail sheet + inventory + bill of lading in cab')} ${check('truck_runners', 'Floor runners / door jam protectors')} ${check('truck_extra', 'Extra boxes / packing supplies for upsell on job')}
📋 On-The-Move Checklist (The Perfect Move)
Goal: raving fans, not just satisfied customers. Every crew should do this every time.
Upon Arrival
${check('arrival_ontime', 'Arrived on time (or called ahead if running late)')} ${check('arrival_appearance', 'Crew is in uniform, looks professional, pads on shoulder / ready to work')} ${check('arrival_intro', 'Crew introduces themselves by name — warm, professional greeting')} ${check('arrival_walkthrough', 'Walk through the home with customer before starting — confirm what\'s going and what\'s staying')} ${check('arrival_paperwork', 'Bill of lading / work order signed by customer')} ${check('arrival_protection', 'Floor runners laid, door jams protected')}
During Load
${check('load_wrap', 'All furniture quilt-padded and shrink-wrapped before moving')} ${check('load_disassemble', 'Items requiring disassembly taken apart carefully (hardware bagged & labeled)')} ${check('load_fragile', 'Fragile and high-value items wrapped extra care (TVs, art, antiques)')} ${check('load_truck', 'Truck loaded strategically — heavy items first, fragile on top')} ${check('load_secure', 'All items strapped in truck — nothing loose or sliding')} ${check('load_final', 'Final walk-through of every room, closets, garage, shed, attic — nothing left behind')} ${check('load_customer', 'Customer confirms everything is loaded before leaving')}
At Delivery
${check('delivery_walkthrough', 'Walk through destination with customer — where does each room\'s stuff go?')} ${check('delivery_unload', 'Items unloaded carefully, placed exactly where customer wants them')} ${check('delivery_reassemble', 'All disassembled items reassembled and set up')} ${check('delivery_unwrap', 'All wrapping and pads removed and taken back to truck')} ${check('delivery_inspect', 'Customer inspects all items — any damage noted on bill of lading')}
Closing Out
${check('close_payment', 'Balance collected (e-transfer / card / cash)')} ${check('close_signed', 'Bill of lading signed by customer confirming job complete')} ${check('close_review', 'Asked customer for Google / Yelp review (while they\'re happy, right now)')} ${check('close_referral', 'Asked for referral — "Do you know anyone else who might be moving?"')} ${check('close_equipment', 'All equipment returned to truck — pads, dollies, straps, tools counted')} ${check('close_thumbsup', '✅ Customer happy — Raving Fan created')}
🧠 CEO Mindset: "I am responsible for everything that happens in my company." — If a customer has a bad experience, trace it back to a process gap. Fix the system, not just the person.
`; } window.toggleOpsCheck = function(leadId, key) { const lead = DB.getLead(leadId); if (!lead) return; const ops = lead.ops || {}; ops[key] = !ops[key]; DB.updateLead(leadId, { ops }); }; function renderLeadEmailsTab(lead, fullHeight = false) { const emails = DB.getEmailsForLead(lead.id); const tplKeys = ['first_contact','quote_sent','follow_up','booking_confirm','estimate_sms_combo','thank_you','re_engage']; const tpl = getEmailTemplate(emailActiveTpl || 'first_contact', lead); return `
${emails.length > 0 ? `
Email History — ${emails.length} sent
${emails.map(e => ` ` : `
No emails sent yet — compose below.
`}
New Email
`; } // ───────────────────────────────────────────────────────────────────────────── // SMS TAB — iMessage-style thread embedded in lead detail // ───────────────────────────────────────────────────────────────────────────── function renderLeadSMSTab(lead) { const phone = lead.phone; if (!phone) return `
No phone number on this lead.
`; const norm = p => (p || '').replace(/\D/g, ''); const msgs = smsMessages .filter(m => norm(smsContactPhone(m)) === norm(phone)) .sort((a, b) => a.created_at.localeCompare(b.created_at)); const firstName = (lead.name || '').split(' ')[0] || 'there'; const hasQuote = !!lead.quoteId; const defaultMsg = msgs.length === 0 ? (hasQuote ? `Hey ${firstName}! Just wanted to follow up on your moving quote from Saturn Star. Happy to answer any questions or adjust anything — just reply here! 😊` : `Hey ${firstName}! This is John from Saturn Star Moving. Are you still looking for help with your upcoming move? We'd love to take care of you. 🌟`) : ''; return `
${(lead.name || phone)[0].toUpperCase()}
${lead.name || phone}
${phone}${msgs.length > 0 ? ` · ${msgs.length} message${msgs.length>1?'s':''}` : ''}
${msgs.length === 0 ? `
💬
No messages yet
Send the first message below
` : msgs.map(m => `
${escHtml(m.body)}
${formatDateTime(m.created_at)}
`).join('')}
`; } function leadSmsScrollBottom() { const area = document.getElementById('lead-sms-area'); if (area) setTimeout(() => { area.scrollTop = area.scrollHeight; }, 40); } window.leadSmsSend = async function(leadId, phone) { const input = document.getElementById('lead-sms-input'); const body = input?.value?.trim(); if (!body) return; input.value = ''; input.style.height = 'auto'; const ok = await sendSMS(phone, body, leadId); if (ok) { smsMessages.push({ id: 'local_' + Date.now(), from_number: MY_NUMBER, to_number: phone, body, direction: 'outbound', lead_id: leadId, created_at: new Date().toISOString(), }); const l = DB.getLead(leadId); if (l && leadDetailTab === 'sms') { document.getElementById('lead-tab-content').innerHTML = renderLeadSMSTab(l); leadSmsScrollBottom(); } } else { toast('Failed to send SMS', 'error'); } }; // ── Smart Compose ───────────────────────────────────────────────────────────── const SC_GOALS = [ { id:'follow_up', icon:'🔄', label:'Follow Up', desc:'Check in after a quote or call' }, { id:'send_estimate', icon:'📋', label:'Send Estimate', desc:'Introduce the quote/pricing' }, { id:'re_engage', icon:'💡', label:'Re-engage', desc:'Lead went cold — wake them up' }, { id:'confirm_booking', icon:'✅', label:'Confirm Booking', desc:'Booking confirmed, next steps' }, { id:'check_in', icon:'👋', label:'Friendly Check-in', desc:'Just touching base' }, { id:'custom', icon:'✏️', label:'Custom Goal', desc:'Describe your own goal below' }, ]; function openSmartCompose(leadId, channel) { const lead = DB.getLead(leadId); if (!lead) return; // Filter SMS + email history for this lead const normP = p => (p || '').replace(/\D/g, ''); const leadPhone = normP(lead.phone); const leadEmail = (lead.email || '').toLowerCase(); const smsHistory = smsMessages.filter(m => leadPhone && (normP(m.from_number) === leadPhone || normP(m.to_number) === leadPhone) ); const emailHistory = emailMessages.filter(m => leadEmail && ((m.from_address||'').toLowerCase() === leadEmail || (m.to_address||'').toLowerCase() === leadEmail) ); let selectedGoal = null; let customGoalText = ''; const overlay = document.createElement('div'); overlay.className = 'sc-overlay'; overlay.innerHTML = `
Smart Compose
AI will read ${lead.name}'s full history and write a personalized ${channel === 'sms' ? 'text' : 'email'}
What's the goal?
${SC_GOALS.map(g => ` `).join('')}
`; document.body.appendChild(overlay); window.scSelectGoal = (goalId) => { selectedGoal = goalId; document.querySelectorAll('.sc-goal-btn').forEach(b => b.classList.toggle('selected', b.dataset.goal === goalId)); document.getElementById('sc-custom-wrap').style.display = goalId === 'custom' ? 'block' : 'none'; const btn = document.getElementById('sc-generate-btn'); if (btn) { btn.disabled = goalId === 'custom' && !customGoalText; btn.style.opacity = btn.disabled ? '.5' : '1'; } }; window.scCustomInput = (val) => { customGoalText = val.trim(); const btn = document.getElementById('sc-generate-btn'); if (btn) { btn.disabled = !customGoalText; btn.style.opacity = btn.disabled ? '.5' : '1'; } }; window.scGenerate = async () => { const goal = selectedGoal === 'custom' ? customGoalText : SC_GOALS.find(g => g.id === selectedGoal)?.label; if (!goal) return; // Show loading document.getElementById('sc-body').innerHTML = `
Reading ${lead.name}'s history…
Calls, SMS, emails, recordings — all of it
`; document.getElementById('sc-footer').innerHTML = ''; try { const res = await fetch(`${WORKER_URL}/smart-compose`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ lead, smsHistory, emailHistory, goal, channel }), }); const data = await res.json(); if (!data.ok) throw new Error(data.error || 'AI generation failed'); // Show editable draft document.getElementById('sc-body').innerHTML = `
✨ AI Draft — edit freely before sending
${channel === 'email' && data.subject ? `
SUBJECT
` : ''}
${channel === 'email' ? 'BODY' : 'MESSAGE'}
${channel === 'sms' ? `
${data.draft.length} chars
` : ''}`; if (channel === 'sms') { document.getElementById('sc-draft-out').addEventListener('input', e => { const cc = document.getElementById('sc-char-count'); if (cc) cc.textContent = e.target.value.length + ' chars'; }); } document.getElementById('sc-footer').innerHTML = ` `; } catch (e) { document.getElementById('sc-body').innerHTML = `
⚠️ ${e.message}
`; document.getElementById('sc-footer').innerHTML = ` `; } }; window.scUseDraft = (lId, ch) => { const draft = document.getElementById('sc-draft-out')?.value || ''; const subject = document.getElementById('sc-subject-out')?.value || ''; if (ch === 'email') { const subjectEl = document.getElementById('ec-subject'); const bodyEl = document.getElementById('ec-body'); if (subjectEl && subject) subjectEl.value = subject; if (bodyEl && draft) bodyEl.value = draft; toast('Draft loaded into email compose ✓', 'success'); } else { const smsEl = document.getElementById('sms-reply-input'); if (smsEl) { smsEl.value = draft; smsEl.style.height = 'auto'; smsEl.style.height = smsEl.scrollHeight + 'px'; } toast('Draft loaded into SMS compose ✓', 'success'); } document.querySelector('.sc-overlay').remove(); }; } function setEmailTemplate(type, leadId) { emailActiveTpl = type; if (leadId) renderEmailPanelForLead(leadId); else if (view === 'lead-detail') nav('lead-detail', detailId); } async function fireEmail(leadId) { const to = document.getElementById('ec-to')?.value?.trim(); const subject = document.getElementById('ec-subject')?.value?.trim(); const body = document.getElementById('ec-body')?.value?.trim(); if (!to || !subject || !body) { toast('Fill in To, Subject, and body', 'error'); return; } const btn = document.querySelector('.email-send-bar .btn-primary'); if (btn) { btn.textContent = 'Sending...'; btn.disabled = true; } const result = await sendEmail({ to, subject, body, leadId, templateType: emailActiveTpl }); if (result) { toast('Email sent ✓', 'success'); // Add to call log const lead = DB.getLead(leadId); if (lead) { const logs = lead.callLogs || []; logs.unshift({ id: uid(), type: 'email', notes: `Email sent: "${subject}" → ${to}`, date: new Date().toISOString() }); DB.updateLead(leadId, { callLogs: logs }); } if (view === 'lead-detail') nav('lead-detail', detailId); else renderEmailPanelForLead(leadId); } else { if (btn) { btn.textContent = 'Send Email ↗'; btn.disabled = false; } } } async function sendQuoteEmailSMS(leadId) { const lead = DB.getLead(leadId); if (!lead) return; const quote = lead.quoteId ? DB.getQuote(lead.quoteId) : null; if (!quote) { toast('No quote attached to this lead', 'error'); return; } const to = document.getElementById('ec-to')?.value || lead.email; const subject = document.getElementById('ec-subject')?.value || `Your Moving Estimate — ${quote.number}`; const body = document.getElementById('ec-body')?.value; if (!to || !body) { toast('Fill in the email before sending', 'error'); return; } const btn = document.querySelector('[onclick*="sendQuoteEmailSMS"]'); if (btn) { btn.textContent = '⏳ Sending...'; btn.disabled = true; } // Fire email const emailResult = await sendEmail({ to, subject, body, leadId, templateType: 'estimate_sms_combo' }); // Fire SMS if phone available let smsSent = false; if (lead.phone) { const smsBody = `Hi ${lead.name?.split(' ')[0]}, your moving estimate from Saturn Star is ready! Quote #${quote.number} — Total $${quote.total?.toLocaleString()}. Reply or call (226) 215-9874 to confirm. 🌟`; smsSent = await sendSMS(lead.phone, smsBody); } if (emailResult) { const logs = lead.callLogs || []; logs.unshift({ id: uid(), type: 'email', notes: `Estimate sent by Email${smsSent ? ' + SMS' : ''}: "${subject}" — $${quote.total?.toLocaleString()}`, date: new Date().toISOString() }); DB.updateLead(leadId, { callLogs: logs }); toast(`Estimate sent by email${smsSent ? ' + SMS 📱' : ''}`, 'success'); if (view === 'lead-detail') nav('lead-detail', detailId); else renderEmailPanelForLead(leadId); } } function openEmailCompose(leadId) { if (leadId) { emailComposeLeadId = leadId; nav('lead-detail', leadId); setLeadTab('emails', leadId); } else { nav('email'); } } // ============================================================ // INBOX — LEAD SOURCE INTEGRATION // ============================================================ const SOURCE_META = { twilio_call: { icon:'📞', label:'Phone Call', color:'#7c3aed' }, twilio_sms: { icon:'💬', label:'SMS', color:'#2563eb' }, website_form: { icon:'🌐', label:'Website Form', color:'#059669' }, facebook_dm: { icon:'📘', label:'Facebook', color:'#1877f2' }, instagram_dm: { icon:'📸', label:'Instagram', color:'#e1306c' }, email: { icon:'✉️', label:'Email', color:'#d97706' }, }; function getInbound() { try { return JSON.parse(localStorage.getItem('sscrm_inbound')) || []; } catch { return []; } } function saveInbound(d) { localStorage.setItem('sscrm_inbound', JSON.stringify(d)); } function seedInbound() { // No fake inbound leads — real leads will come from Twilio/website/social // Inbox starts empty and fills from live sources if (localStorage.getItem('sscrm_inbound_version') !== DATA_VERSION) { localStorage.removeItem('sscrm_inbound'); localStorage.setItem('sscrm_inbound_version', DATA_VERSION); } } function updateInboxBadge() { const count = getInbound().filter(i => !i.claimed).length; const badge = document.getElementById('inbox-badge'); if (!badge) return; if (count > 0) { badge.textContent = count; badge.style.display = 'inline-block'; } else { badge.style.display = 'none'; } } function timeAgo(iso) { const diff = Math.floor((Date.now() - new Date(iso)) / 1000); if (diff < 60) return 'just now'; if (diff < 3600) return Math.floor(diff/60) + 'm ago'; if (diff < 86400) return Math.floor(diff/3600) + 'h ago'; return Math.floor(diff/86400) + 'd ago'; } function formatDateTime(iso) { if (!iso) return ''; const d = new Date(iso); const today = new Date(); const isToday = d.toDateString() === today.toDateString(); const time = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); if (isToday) return time; return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ' ' + time; } function escHtml(str) { return String(str || '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } function dismissInbound(id) { const list = getInbound().map(i => i.id === id ? {...i, claimed:true} : i); saveInbound(list); updateInboxBadge(); render(); // Mirror claimed status to Supabase so other devices don't see dismissed leads fetch(`${_sbBase()}/rest/v1/inbound_leads?id=eq.${encodeURIComponent(id)}`, { method: 'PATCH', headers: _sbH({ 'Prefer': 'return=minimal' }), body: JSON.stringify({ claimed: true, claimed_at: new Date().toISOString() }), }).catch(() => {}); } function openClaimModal(id) { const item = getInbound().find(i => i.id === id); if (!item) return; const s = SOURCE_META[item.source] || { icon:'📥', label:'Unknown' }; const modal = document.createElement('div'); modal.id = 'claim-modal'; modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:1000;backdrop-filter:blur(2px);'; modal.innerHTML = `
${s.icon}
Claim This Lead
${s.label} — ${item.phone || item.email || 'Unknown'}

Gold-highlighted fields were auto-filled from the source. Edit as needed.

${item.phone ? '
⚡ Auto-filled from source
' : ''}
${item.email ? '
⚡ Auto-filled from source
' : ''}
⚡ Auto-filled from source
`; document.body.appendChild(modal); } function confirmClaim(id) { const item = getInbound().find(i => i.id === id); if (!item) return; const first = document.getElementById('cl-first')?.value?.trim() || ''; const last = document.getElementById('cl-last')?.value?.trim() || ''; const name = [first, last].filter(Boolean).join(' ') || item.phone || 'Unknown'; const lead = { id: 'ld_' + Date.now(), name, phone: document.getElementById('cl-phone')?.value?.trim() || item.phone || '', email: document.getElementById('cl-email')?.value?.trim() || item.email || '', source: item.source, stage: document.getElementById('cl-stage')?.value || 'new', moveType: document.getElementById('cl-type')?.value || 'residential', moveDate: '', originAddress: '', originCity: 'Windsor', destCity: '', directMailAttributed: false, supabaseListing: null, inventory: [], totalItems: 0, totalCubicFeet: 0, callLogs: [{ id:'cl_'+Date.now(), type:'note', notes: document.getElementById('cl-notes')?.value?.trim() || item.message || '', date: new Date().toISOString() }], quoteId: null, notes: document.getElementById('cl-notes')?.value?.trim() || '', createdAt: new Date().toISOString().split('T')[0], inboundId: id, }; DB.addLead(lead); // triggers sbUpsert via wrapped saveLeads const list = getInbound().map(i => i.id === id ? {...i, claimed:true} : i); saveInbound(list); // Mirror claimed to Supabase so other devices hide this inbound item fetch(`${_sbBase()}/rest/v1/inbound_leads?id=eq.${encodeURIComponent(id)}`, { method: 'PATCH', headers: _sbH({ 'Prefer': 'return=minimal' }), body: JSON.stringify({ claimed: true, claimed_at: new Date().toISOString() }), }).catch(() => {}); document.getElementById('claim-modal')?.remove(); updateInboxBadge(); toast(`Lead "${name}" added to Lead Board`, 'success'); nav('lead-detail', lead.id); } // Poll Supabase for new inbound leads every 60s async function pollInboundLeads() { const s = DB.get('settings')[0] || {}; const url = s.supabaseUrl || SUPABASE_DEFAULT_URL; const key = s.supabaseKey || SUPABASE_DEFAULT_KEY; if (!url || !key) return; try { const existing = getInbound(); const existingIds = new Set(existing.map(i => i.id)); const res = await fetch(`${url}/rest/v1/inbound_leads?claimed=eq.false&order=created_at.desc&limit=50`, { headers: { 'apikey': key, 'Authorization': `Bearer ${key}` } }); if (!res.ok) return; const rows = await res.json(); let added = 0; rows.forEach(row => { if (!existingIds.has(row.id)) { existing.unshift({ id: row.id, source: row.source, name: row.name || '', phone: row.phone || '', email: row.email || '', message: row.message || '', created_at: row.created_at, claimed: false }); added++; } }); if (added > 0) { saveInbound(existing); updateInboxBadge(); if (view === 'inbox') render(); } } catch (e) { /* silent fail — offline or table not created yet */ } } function renderInbox(el, actions) { seedInbound(); const all = getInbound(); let activeFilter = 'all'; actions.innerHTML = ` `; function getShown(filter) { const unclaimed = all.filter(i => !i.claimed); return filter === 'all' ? unclaimed : unclaimed.filter(i => i.source === filter); } function renderDetail(itemId) { const pane = document.getElementById('inbox-detail-pane'); if (!pane) return; const item = all.find(i => i.id === itemId); if (!item) { pane.innerHTML = `
👈

Select a message to respond

`; return; } const s = SOURCE_META[item.source] || { icon:'📥', label:'Unknown', color:'#888' }; const canCall = !!item.phone; const canSMS = !!item.phone; const canEmail = !!item.email; // Detect if this contact already exists as a lead const normP = p => (p || '').replace(/\D/g, ''); const allLeads = DB.getLeads(); const matchedLead = allLeads.find(l => (item.phone && normP(l.phone) === normP(item.phone)) || (item.email && l.email && l.email.toLowerCase() === item.email.toLowerCase()) ); const defaultSMS = `Hi${item.name ? ' ' + item.name.split(' ')[0] : ''}! Thanks for reaching out to Saturn Star Moving. How can we help with your move?`; const defaultEmail = canEmail ? `Hi${item.name ? ' ' + item.name.split(' ')[0] : ''},\n\nThanks for contacting Saturn Star Moving! We'd love to help with your upcoming move.\n\nCould you share your move date and locations so we can put together a free estimate?\n\nBest,\nSaturn Star Moving` : ''; pane.innerHTML = `
${s.icon}
${item.name || 'Unknown'} ${s.label} New
${item.phone ? `📞 ${item.phone}` : ''} ${item.email ? `✉️ ${item.email}` : ''} 🕐 ${timeAgo(item.created_at)}
${matchedLead ? `
🔗
Existing lead matched
${matchedLead.name} — ${matchedLead.stage || 'new'}
` : ''}
Message
${item.message || 'No message body'}
${canCall ? `
` : ''} ${canSMS ? `
💬 Reply via SMS
` : ''} ${canEmail ? `
✉️ Reply via Email
To: ${item.email}  ·  Subject:
` : ''} ${!canSMS && !canEmail ? `
No contact info to reply — claim as lead and add details.
` : ''}
`; // live char count if (canSMS) { const ta = document.getElementById(`sms-reply-${item.id}`); const cc = document.getElementById(`sms-char-${item.id}`); if (ta && cc) ta.addEventListener('input', () => { cc.textContent = `~${ta.value.length} chars`; }); } } function selectItem(id) { selectedInboundId = id; // update highlight document.querySelectorAll('.inbox-list-item').forEach(el => { el.classList.toggle('selected', el.dataset.id === id); }); renderDetail(id); } function renderList(filter) { activeFilter = filter; const unclaimed = all.filter(i => !i.claimed); const shown = getShown(filter); const counts = {}; Object.keys(SOURCE_META).forEach(k => { counts[k] = unclaimed.filter(i => i.source === k).length; }); // auto-select first if nothing selected or selected item is gone if (!selectedInboundId || !shown.find(i => i.id === selectedInboundId)) { selectedInboundId = shown.length ? shown[0].id : null; } el.innerHTML = `
Live — syncing every 60s
${unclaimed.length} unclaimed
${[['all','All', unclaimed.length], ...Object.entries(SOURCE_META).map(([k,v])=>[k, v.icon, counts[k]||0])].map(([k,label,cnt]) => `
${label} ${cnt}
`).join('')}
${shown.length === 0 ? `
📭
No unclaimed messages
` : shown.map(item => { const s = SOURCE_META[item.source] || { icon:'📥', label:'Unknown', color:'#888' }; const displayName = item.name || item.phone || item.email || 'Unknown'; const preview = item.message ? item.message.slice(0, 60) + (item.message.length > 60 ? '…' : '') : '—'; return `
${s.icon} ${displayName}
${preview}
${s.label} ${timeAgo(item.created_at)}
`; }).join('')}
👈

Select a message to respond

`; // render detail for selected if (selectedInboundId) renderDetail(selectedInboundId); } window.inboxFilter = (f) => renderList(f); window.inboxSelect = (id) => selectItem(id); window.inboxSendSMS = async (itemId, phone) => { const ta = document.getElementById(`sms-reply-${itemId}`); if (!ta || !ta.value.trim()) return; const btn = ta.closest('.inbox-reply-box').querySelector('button'); const orig = btn.textContent; btn.textContent = 'Sending…'; btn.disabled = true; const ok = await sendSMS(phone, ta.value.trim()); if (ok) { btn.textContent = '✓ Sent!'; btn.style.background = '#059669'; setTimeout(() => { btn.textContent = orig; btn.style.background = ''; btn.disabled = false; }, 3000); } else { btn.textContent = 'Failed'; btn.style.background = 'var(--danger)'; setTimeout(() => { btn.textContent = orig; btn.style.background = ''; btn.disabled = false; }, 2500); } }; window.inboxSendEmail = async (itemId, toEmail) => { const ta = document.getElementById(`email-reply-${itemId}`); const subj = document.getElementById(`email-subj-${itemId}`); if (!ta || !ta.value.trim()) return; const btn = ta.closest('.inbox-reply-box').querySelector('button'); const orig = btn.textContent; btn.textContent = 'Sending…'; btn.disabled = true; try { await sendEmail({ to: toEmail, subject: subj?.value || 'Following up — Saturn Star Moving', body: ta.value.trim(), leadId: null, templateType: 'first_contact' }); btn.textContent = '✓ Sent!'; btn.style.background = '#059669'; setTimeout(() => { btn.textContent = orig; btn.style.background = ''; btn.disabled = false; }, 3000); } catch (e) { btn.textContent = 'Failed'; btn.style.background = 'var(--danger)'; setTimeout(() => { btn.textContent = orig; btn.style.background = ''; btn.disabled = false; }, 2500); } }; renderList('all'); } // ============================================================ // QUICK ESTIMATE & SEND — the core sales loop // call → fill details → rate card calc → email + SMS in one shot // ============================================================ function showQuickSendModal(leadId) { const lead = DB.getLead(leadId); if (!lead) return; const s = getSettings(); const isLong = lead.moveType === 'long-distance'; window._qsLeadId = leadId; const m = document.createElement('div'); m.id = 'quick-send-modal'; m.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;z-index:2000;backdrop-filter:blur(3px);padding:16px;'; m.innerHTML = `
⚡ Quick Estimate & Send
Build from rate card → send email + SMS → done
Customer
Job Details
${lead.email ? '✅ Email ready' : '⚠️ No email — SMS only'}
${lead.phone ? '✅ SMS ready' : '⚠️ No phone — email only'}
`; document.body.appendChild(m); updateQSPreview(); } function updateQSPreview() { const preview = document.getElementById('qs-preview'); if (!preview) return; const s = getSettings(); const leadId = window._qsLeadId; const lead = DB.getLead(leadId); const isLong = (document.getElementById('qs-type')?.value || lead?.moveType || 'residential') === 'long-distance'; const crew = parseInt(document.getElementById('qs-crew')?.value) || 3; const hours = parseFloat(document.getElementById('qs-hours')?.value) || 3; const rph = s.rc_hourly || 55; const truck = s.rc_truck || 75; const fuelPct = (s.rc_fuel || 5) / 100; const depPct = (s.rc_deposit|| 40) / 100; const items = []; if (isLong) { const km = hours; // "hours" field = km in long distance mode items.push({ d:'Transportation', det:`${km} km × $${s.rc_km||3.50}/km`, amt: Math.round(km * (s.rc_km||3.50)) }); items.push({ d:'Load & Unload Labour',det:`${crew} movers × ${s.rc_minhours||3} hrs × $${rph}/hr`, amt: Math.round(rph * crew * (s.rc_minhours||3)) }); } else { items.push({ d:'Moving Labour', det:`${crew} movers × ${hours} hrs × $${rph}/hr`, amt: Math.round(rph * crew * hours) }); items.push({ d:'Truck & Travel', det:'Flat rate incl. mileage', amt: truck }); } const preFuel = items.reduce((t,i)=>t+i.amt,0); const fuel = Math.round(preFuel * fuelPct); if (fuelPct > 0) items.push({ d:'Fuel Surcharge', det:`${s.rc_fuel||5}%`, amt: fuel }); const sub = items.reduce((t,i)=>t+i.amt,0); const hst = Math.round(sub * 0.13); const total = sub + hst; const dep = Math.round(total * depPct); preview.innerHTML = `
Estimate Preview
${items.map(i=>`
${i.d} ${i.det}$${i.amt.toLocaleString()}
`).join('')}
HST (13%)$${hst.toLocaleString()}
Total$${total.toLocaleString()}
Deposit to confirm (${s.rc_deposit||40}%)$${dep.toLocaleString()}
`; window._qsCalc = { items, sub, hst, total, deposit: dep, balance: total - dep }; } function updateQSSendStatus() { const email = document.getElementById('qs-email')?.value?.trim(); const phone = document.getElementById('qs-phone')?.value?.trim(); const eEl = document.getElementById('qs-email-status'); const sEl = document.getElementById('qs-sms-status'); if (eEl) { eEl.textContent = email ? '✅ Email ready' : '⚠️ No email — SMS only'; eEl.style.background = email?'#e8f5e9':'#fff3e0'; eEl.style.color = email?'#2e7d32':'#e65100'; } if (sEl) { sEl.textContent = phone ? '✅ SMS ready' : '⚠️ No phone — email only'; sEl.style.background = phone?'#e8f5e9':'#fff3e0'; sEl.style.color = phone?'#2e7d32':'#e65100'; } } async function sendQuickEstimate(leadId) { const lead = DB.getLead(leadId); if (!lead) return; const s = getSettings(); const calc = window._qsCalc; if (!calc) { toast('Refresh estimate first', 'error'); return; } const email = document.getElementById('qs-email')?.value?.trim() || lead.email; const phone = document.getElementById('qs-phone')?.value?.trim() || lead.phone; const moveDate = document.getElementById('qs-date')?.value || lead.moveDate; const moveReason = document.getElementById('qs-reason')?.value?.trim() || lead.moveReason; if (!email && !phone) { toast('Enter at least an email or phone number', 'error'); return; } const btn = document.getElementById('qs-send-btn'); if (btn) { btn.textContent = '⏳ Sending…'; btn.disabled = true; } // Persist any new contact details to lead DB.updateLead(leadId, { email: email||lead.email, phone: phone||lead.phone, moveDate: moveDate||lead.moveDate, moveReason: moveReason||lead.moveReason }); // Create or find client record let clientId = null; const existing = DB.getClients().find(c => c.name === lead.name || (phone && c.phone === phone) || (email && c.email === email)); if (existing) { clientId = existing.id; DB.updateClient(clientId, { phone: phone||existing.phone, email: email||existing.email }); } else { clientId = 'cli_' + uid(); DB.addClient({ id: clientId, name: lead.name, phone: phone||lead.phone, email: email||lead.email, type: lead.moveType||'residential', company: '', createdAt: new Date().toISOString().split('T')[0] }); } // Build & save quote const quoteId = 'q_' + uid(); const quoteNum = 'SS-' + Date.now().toString().slice(-6); const moveTypeVal = document.getElementById('qs-type')?.value || lead.moveType || 'residential'; const quote = { id: quoteId, clientId, number: quoteNum, status: 'sent', moveType: moveTypeVal, moveDate, validDays: s.validDays || 30, origin: { address: lead.originAddress||'', city: lead.originCity||'Windsor', province:'ON', floor:'' }, destination: { address: '', city: lead.destCity||'', province:'ON', floor:'' }, lineItems: calc.items.map(i => ({ description: i.d, details: i.det, amount: i.amt })), subtotal: calc.sub, hst: calc.hst, total: calc.total, deposit: calc.deposit, balance: calc.balance, scopeItems: [], includedItems: [], notes: '', createdAt: new Date().toISOString().split('T')[0], sentAt: new Date().toISOString().split('T')[0], clientName: lead.name, }; DB.addQuote(quote); // Link quote → lead, advance stage, set follow-up const fuDate = (() => { const d = new Date(); d.setDate(d.getDate() + (s.followup1||2)); return d.toISOString().split('T')[0]; })(); DB.updateLead(leadId, { quoteId, stage:'quoted', leadScore: calculateLeadScore({...lead, stage:'quoted'}), followUpDate: fuDate }); // Build acceptance URL const acceptUrl = generateAcceptURL(quoteId); // Personalized PS const firstName = (lead.name||'').split(' ')[0] || 'there'; const reason = (moveReason||'').toLowerCase(); const ps = moveReason ? ( reason.includes('retire') ? `\n\nP.S. Congratulations on the retirement — this is such an exciting chapter. We're going to make this move completely stress-free for you.` : reason.includes('school')||reason.includes('kids')||reason.includes('family') ? `\n\nP.S. Moving closer to family is such a special thing. We're honoured to be part of that journey for you.` : `\n\nP.S. Congratulations on the move — sounds like you're going exactly where you should be. We're looking forward to taking great care of you!` ) : ''; const emailBody = `Hi ${firstName}, Thank you for speaking with us — here's your moving estimate as promised. Quote #: ${quoteNum} Total: $${calc.total.toLocaleString()} (incl. HST) Deposit to confirm: $${calc.deposit.toLocaleString()} (${s.rc_deposit||40}%) ${moveDate ? 'Move Date: ' + fmtDate(moveDate) + '\n' : ''} ✅ Accept your quote online here: ${acceptUrl} Just click that link to confirm — or reply to this email or call us at (226) 215-9874. We look forward to taking great care of you! Saturn Star Moving Company (226) 215-9874 · business@starmovers.ca${ps}`; let emailSent = false; let smsSent = false; if (email) { emailSent = await sendEmail({ to: email, subject: `Your Moving Estimate — ${quoteNum}`, body: emailBody, leadId, templateType: 'estimate_sms_combo' }); } if (phone) { const smsBody = `Hi ${firstName}! Your moving estimate from Saturn Star is ready 🌟\n\nQuote #${quoteNum} — Total: $${calc.total.toLocaleString()}\nDeposit to confirm: $${calc.deposit.toLocaleString()}\n\n✅ Confirm here: ${acceptUrl}\n\nQuestions? Call (226) 215-9874`; smsSent = await sendSMS(phone, smsBody); } // Log it const logs = DB.getLead(leadId)?.callLogs || []; logs.unshift({ id: uid(), type: 'email', notes: `Estimate ${quoteNum} sent${emailSent?' via email':''}${smsSent?' + SMS':''} — $${calc.total.toLocaleString()}. Follow-up set for ${fuDate}.`, date: new Date().toISOString() }); DB.updateLead(leadId, { callLogs: logs }); document.getElementById('quick-send-modal')?.remove(); const channels = [emailSent?'email':'', smsSent?'SMS':''].filter(Boolean).join(' + '); toast(`Estimate sent${channels?' via '+channels:''}! Follow-up set for ${fmtDate(fuDate)} ✓`, 'success'); nav('lead-detail', leadId); } // ============================================================ // AUTH GATE // ============================================================ const AUTH_SESSION_KEY = 'sscrm_session'; const AUTH_PASS_KEY = 'sscrm_pw_hash'; const DEFAULT_PASSWORD = 'saturn2025'; async function sha256(str) { const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str)); return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2,'0')).join(''); } function hasValidSession() { try { const s = JSON.parse(sessionStorage.getItem(AUTH_SESSION_KEY)); return !!(s && Date.now() < s.expiry); } catch { return false; } } function createSession() { sessionStorage.setItem(AUTH_SESSION_KEY, JSON.stringify({ expiry: Date.now() + 8 * 60 * 60 * 1000 })); } function logout() { sessionStorage.removeItem(AUTH_SESSION_KEY); location.reload(); } async function doLogin() { const input = document.getElementById('auth-input'); const errEl = document.getElementById('auth-error'); const pw = input.value; if (!pw) return; const enteredHash = await sha256(pw); const storedHash = localStorage.getItem(AUTH_PASS_KEY); let valid = false; if (!storedHash) { // No custom password yet — accept default and save it const defHash = await sha256(DEFAULT_PASSWORD); if (enteredHash === defHash) { localStorage.setItem(AUTH_PASS_KEY, defHash); valid = true; } } else { valid = (enteredHash === storedHash); } if (valid) { createSession(); document.getElementById('auth-gate')?.remove(); // Hide default hint once any password has been set initCRM(); } else { input.classList.add('shake'); errEl.textContent = 'Incorrect password — try again.'; input.value = ''; input.focus(); setTimeout(() => input.classList.remove('shake'), 500); } } async function changePassword(newPw) { if (!newPw || newPw.length < 6) { toast('Password must be at least 6 characters', 'error'); return; } const hash = await sha256(newPw); localStorage.setItem(AUTH_PASS_KEY, hash); toast('Password updated ✓ — use it next time you sign in', 'success'); } // ============================================================ // AUTO-FILL QUOTE FROM RATE CARD // ============================================================ function showAutoFillModal() { const s = getSettings(); const moveType = document.getElementById('qb-type')?.value || 'residential'; const isLong = moveType === 'long-distance'; document.getElementById('modal-container').innerHTML = ` `; updateAutoFillPreview(); } function calcAutoFillItems() { const s = getSettings(); const moveType= document.getElementById('qb-type')?.value || 'residential'; const isLong = moveType === 'long-distance'; const crew = parseInt(document.getElementById('af-crew')?.value) || 3; const hours = parseFloat(document.getElementById('af-hours')?.value)|| 3; const km = parseFloat(document.getElementById('af-km')?.value) || 0; const packing = document.getElementById('af-packing')?.checked; const ratePerMover = s.rc_hourly || 55; const truck = s.rc_truck || 75; const fuelPct = (s.rc_fuel || 5) / 100; const packExtra = s.rc_packing || 25; const laborAmt = Math.round(ratePerMover * crew * hours); const items = []; if (isLong) { const kmRate = s.rc_km || 3.50; items.push({ description: 'Transportation', details: `${km} km × $${kmRate}/km`, amount: Math.round(km * kmRate) }); items.push({ description: 'Loading & Unloading', details: `${crew} movers × ${hours} hrs × $${ratePerMover}/hr`, amount: laborAmt }); } else { items.push({ description: 'Moving Labour', details: `${crew} movers × ${hours} hrs × $${ratePerMover}/hr`, amount: laborAmt }); items.push({ description: 'Truck & Travel', details: 'Flat rate incl. fuel & mileage', amount: truck }); } if (packing) { const packHrs = Math.max(2, Math.round(hours * 0.5)); items.push({ description: 'Packing Service', details: `${packHrs} hrs × $${ratePerMover + packExtra}/hr`, amount: Math.round(packHrs * (ratePerMover + packExtra)) }); } const sub = items.reduce((t, i) => t + i.amount, 0); const fuel = Math.round(sub * fuelPct); if (fuelPct > 0) items.push({ description: 'Fuel Surcharge', details: `${s.rc_fuel || 5}%`, amount: fuel }); return items; } function updateAutoFillPreview() { const preview = document.getElementById('af-preview'); if (!preview) return; try { const items = calcAutoFillItems(); const sub = items.reduce((t, i) => t + i.amount, 0); const hst = Math.round(sub * 0.13); const s = getSettings(); const dep = (s.rc_deposit || 40) / 100; preview.innerHTML = items.map(i => `
${i.description}$${i.amount.toLocaleString()}
`).join('') + `
HST (13%)$${hst.toLocaleString()}
Total$${(sub+hst).toLocaleString()}
Deposit (${s.rc_deposit||40}%)$${Math.round((sub+hst)*dep).toLocaleString()}
`; } catch { preview.textContent = '—'; } } function applyAutoFill() { qLineItems = calcAutoFillItems().map(i => ({ description: i.description, details: i.details, amount: i.amount })); renderLineItems(); renderTotals(); closeModal(); toast('Line items filled from rate card ✓', 'success'); } // ============================================================ // INIT // ============================================================ function initCRM() { seedDemo(); seedInbound(); updateInboxBadge(); updateEmailBadge(); render(); syncFromSupabase(); startSMSPolling(); startEmailPolling(); initSearch(); setInterval(() => { syncFromSupabase(true); pollInboundLeads(); updateInboxBadge(); updateSMSBadge(); updateEmailInboxBadge(); }, 30000); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') syncFromSupabase(true); }); (function() { let dialerInitialized = false; function tryInitDialer() { if (dialerInitialized) return; dialerInitialized = true; initDialer().catch(() => {}); } document.addEventListener('click', tryInitDialer, { once: true }); document.addEventListener('keydown', tryInitDialer, { once: true }); })(); } document.querySelectorAll('.nav-item').forEach(item => { item.addEventListener('click', () => nav(item.dataset.view)); }); // Auth check — if session valid, start CRM immediately; otherwise show login gate if (hasValidSession()) { document.getElementById('auth-gate')?.remove(); // Also hide default password hint if a custom one has been set if (localStorage.getItem(AUTH_PASS_KEY)) { const hint = document.getElementById('auth-hint-text'); if (hint) hint.style.display = 'none'; } initCRM(); } // else: doLogin() will call initCRM() after successful auth