Outmatch.
Outmatch.
Home
Operations
Estimates Invoices Schedule Dispatch NEW Lead Inbox NEW Billing
Outreach
Prospects AI Prospects Compose Outreach Sequences Campaigns
Channels
AI Texter PRO AI Voice ENT
Insights
Stats Analytics Tom Talks NEW Settings Refer a Contractor
← Back to website Powered by Outmatch → dang.ai
Home Estimates Invoices Voice Billing

Getting Started

Replay the setup walkthrough that shows how to configure Outmatch and use each feature.

🎬 Welcome Video

A short welcome video shown to new customers on their first 3 logins — auto-plays before the onboarding steps. Tom's personal on-camera content belongs on the Tom Talks page instead.

Weekly Email Digest

Receive a weekly summary every Monday: prospects found, emails drafted, and top prospect previews.

● Active — sent every Monday 9am

Your Numbers

0 / 5 numbers active

Search Available Numbers

Find local numbers by area code

+1
📞

Add this number?

Add +1 (000) 000-0000 to your account. $10/mo will be added to your subscription.

Stripe Payments

Connect your Stripe account so customers can pay by card when they order from you. BiteUp collects on your behalf — you just cook the food.

Checking your Stripe status...

Stripe connected
No card payments yet. Connect Stripe to accept credit and debit cards from callers ordering food. Sarah handles everything on her end — you just get paid.

Skipping means customers pay cash on delivery only. You can connect Stripe anytime from Settings.

Stripe not connected — cash on delivery only

Accept Card Payments

Connect your Stripe account so customers can pay by card when they call to order. Sarah handles everything on her end — you just get paid.

`; // Load product context const [settingsRes, icpRes] = await Promise.all([ api('/api/settings/product_context'), api('/api/user/icp-settings').catch(() => ({ success: false })) ]); if (settingsRes.success && settingsRes.value) { document.getElementById('productContext').value = settingsRes.value; } // Load ICP settings if (icpRes.success && icpRes.settings) { const s = icpRes.settings; const industryEl = document.getElementById('icpIndustry'); const sizeEl = document.getElementById('icpCompanySize'); if (industryEl && s.industry) industryEl.value = s.industry; if (sizeEl && s.company_size) sizeEl.value = s.company_size; const locEl = document.getElementById('icpLocation'); const jtEl = document.getElementById('icpJobTitle'); if (locEl) locEl.value = s.location || ''; if (jtEl) jtEl.value = s.job_title || ''; } // Load welcome video URL (owner only) if (isOwner) { loadWelcomeVideoSettings(); } } // ── Phone Numbers Settings Tab ─────────────────────────── // Load and render numbers into the settings phone-numbers tab async function loadPhoneNumbersSettingsTab() { const listEl = document.getElementById('pn-settings-list'); const countEl = document.getElementById('pn-settings-count'); const limitEl = document.getElementById('pn-settings-limit'); if (!listEl) return; try { const data = await api('/api/phone-numbers'); renderPnSettingsList(data.numbers || []); if (countEl) countEl.textContent = data.active_count || 0; if (limitEl) limitEl.textContent = data.limit || 5; } catch(e) { listEl.innerHTML = '

Failed to load numbers.

'; } } function renderPnSettingsList(numbers) { const listEl = document.getElementById('pn-settings-list'); if (!listEl) return; if (!numbers || numbers.length === 0) { listEl.innerHTML = '
' + '
📞
' + '
Your AI needs a phone number
' + '
Get one in 30 seconds to start calling and texting your prospects.
' + '
'; return; } listEl.innerHTML = numbers.map(function(num) { var statusColor = num.status === 'active' ? '#22d67a' : num.status === 'error' ? '#ef4444' : '#f5a623'; var statusLabel = num.status === 'active' ? 'Active' : num.status === 'error' ? 'Error' : num.status === 'provisioning' ? 'Provisioning…' : (num.status || 'Unknown'); var provisionedDate = num.provisioned_at ? new Date(num.provisioned_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'; return '
' + '
' + '
'+escHtml(num.phone_number||'Unknown')+'
' + '
' + ''+(num.area_code?'('+escHtml(num.area_code)+')':'')+' Twilio' + '● '+statusLabel+'' + 'Added '+provisionedDate+'' + '
' + '
'; }).join(''); } async function openPnSearchPanel() { var panel = document.getElementById('pn-search-panel'); if (panel) panel.style.display = 'block'; document.getElementById('pn-search-results').innerHTML = ''; document.getElementById('pn-search-error').textContent = ''; document.getElementById('pn-area-code-input').value = ''; document.getElementById('pn-search-btn').disabled = false; document.getElementById('pn-search-btn').textContent = 'Search'; } function closePnSearchPanel() { var panel = document.getElementById('pn-search-panel'); if (panel) panel.style.display = 'none'; } async function searchPnAreaCode() { var input = document.getElementById('pn-area-code-input'); var btn = document.getElementById('pn-search-btn'); var results = document.getElementById('pn-search-results'); var errEl = document.getElementById('pn-search-error'); var areaCode = input ? input.value.trim() : ''; if (!areaCode || !/^\u005cd{3}$/.test(areaCode)) { errEl.textContent = 'Enter a 3-digit US area code (e.g. 512)'; return; } btn.disabled = true; btn.textContent = 'Searching…'; errEl.textContent = ''; results.innerHTML = '
Searching Twilio for available numbers in '+areaCode+'…
'; try { var data = await api('/api/phone-numbers/search?area_code='+areaCode); if (!data.results || data.results.length === 0) { results.innerHTML = '

No numbers found in '+areaCode+'. Try another area code.

'; btn.disabled = false; btn.textContent = 'Search'; return; } results.innerHTML = '
Select a number to add:
' + data.results.map(function(n) { var formatted = formatPhoneNumber(n.phone_number); var localityStr = (n.locality||'') + (n.region ? ', '+n.region : ''); return '
' + '
' + '
'+formatted+'
' + '
'+(localityStr||'Local number')+'
' + '
'; }).join(''); btn.disabled = false; btn.textContent = 'Search'; } catch(e) { errEl.textContent = e.message || 'Search failed. Please try again.'; results.innerHTML = ''; btn.disabled = false; btn.textContent = 'Search'; } } async function selectPnNumber(phoneNumber, displayNumber) { var modal = document.getElementById('pn-confirm-modal'); if (modal) { document.getElementById('pn-confirm-number').textContent = displayNumber; document.getElementById('pn-confirm-err').textContent = ''; modal.style.display = 'flex'; } window._pnPendingNumber = phoneNumber; } function closePnConfirmModal() { var modal = document.getElementById('pn-confirm-modal'); if (modal) modal.style.display = 'none'; window._pnPendingNumber = null; } async function confirmPnProvision() { var phoneNumber = window._pnPendingNumber; if (!phoneNumber) { closePnConfirmModal(); return; } var btn = document.getElementById('pn-confirm-btn'); var errEl = document.getElementById('pn-confirm-err'); if (btn) { btn.disabled = true; btn.textContent = 'Adding…'; } errEl.textContent = ''; try { var result = await api('/api/phone-numbers/provision', { method: 'POST', body: { phone_number: phoneNumber } }); if (result.success) { closePnConfirmModal(); closePnSearchPanel(); toast(result.message || 'Number added!', 'success'); await loadPhoneNumbersSettingsTab(); } else { errEl.textContent = result.error || 'Failed to add number'; if (btn) { btn.disabled = false; btn.textContent = 'Add to Account'; } } } catch(e) { errEl.textContent = e.message || 'Failed to add number'; if (btn) { btn.disabled = false; btn.textContent = 'Add to Account'; } } } async function removePnSettings(id, phoneStr) { if (!confirm('Remove '+phoneStr+'? This will release the number permanently and cannot be undone.')) return; try { await api('/api/phone-numbers/'+id, { method: 'DELETE' }); toast('Number released', 'success'); await loadPhoneNumbersSettingsTab(); } catch(e) { toast('Failed to remove number', 'error'); } } function formatPhoneNumber(phoneStr) { if (!phoneStr) return ''; var digits = phoneStr.replace(/\u005cd/g, ''); if (digits.length === 11 && digits[0] === '1') digits = digits.slice(1); if (digits.length === 10) { return '+1 ('+digits.slice(0,3)+') '+digits.slice(3,6)+'-'+digits.slice(6); } return phoneStr; } function escHtml(str) { if (str == null) return ''; return String(str).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } async function loadWelcomeVideoSettings() { const section = document.getElementById('welcome-video-settings-section'); const input = document.getElementById('welcomeVideoUrl'); const status = document.getElementById('wvStatus'); if (!section) return; try { const res = await api('/api/settings/welcome-video'); if (res.success) { if (input) input.value = res.welcome_video_url || ''; section.style.display = 'block'; if (res.welcome_video_url) { const preview = document.getElementById('wvPreview'); if (preview) { preview.style.display = 'block'; preview.innerHTML = ''; } if (status) status.textContent = 'Video set'; } } } catch(e) { if (status) status.textContent = 'Failed to load'; } } async function saveWelcomeVideoUrl() { const input = document.getElementById('welcomeVideoUrl'); const status = document.getElementById('wvStatus'); const preview = document.getElementById('wvPreview'); const url = input ? input.value.trim() : ''; if (url && !/^https?:\/\/.+/.test(url)) { if (status) status.textContent = 'Invalid URL'; return; } try { const res = await api('/api/settings/welcome-video', { method: 'PATCH', body: { welcome_video_url: url } }); if (res.success) { if (status) status.textContent = 'Saved!'; if (preview) { if (res.welcome_video_url) { preview.style.display = 'block'; preview.innerHTML = ''; } else { preview.style.display = 'none'; preview.innerHTML = ''; } } } else { if (status) status.textContent = 'Failed to save'; } } catch(e) { if (status) status.textContent = 'Error saving'; } } async function clearWelcomeVideoUrl() { const input = document.getElementById('welcomeVideoUrl'); const preview = document.getElementById('wvPreview'); if (input) input.value = ''; if (preview) { preview.style.display = 'none'; preview.innerHTML = ''; } await saveWelcomeVideoUrl(); } async function saveSettings() { const value = document.getElementById('productContext').value; await api('/api/settings/product_context', { method: 'PUT', body: { value } }); const saved = document.getElementById('settingsSaved'); saved.style.display = 'inline'; setTimeout(() => saved.style.display = 'none', 2000); toast('Settings saved'); } async function saveICPSettings() { const industry = document.getElementById('icpIndustry')?.value || ''; const company_size = document.getElementById('icpCompanySize')?.value || ''; const location = document.getElementById('icpLocation')?.value || ''; const job_title = document.getElementById('icpJobTitle')?.value || ''; try { const res = await api('/api/user/icp-settings', { method: 'POST', body: { industry, company_size, location, job_title } }); if (res.success) { const saved = document.getElementById('icpSaved'); if (saved) { saved.style.display = 'inline'; setTimeout(() => saved.style.display = 'none', 2000); } toast('ICP settings saved'); } else { toast('Failed to save ICP settings', 'error'); } } catch (e) { toast('Failed to save ICP settings', 'error'); } } // ── Stripe Connect (BiteUp payment collection) ───────────────────── async function loadStripeConnectStatus() { // Called when the payments tab is opened — load and render status const loadingEl = document.getElementById('stripe-connect-loading'); const actionsEl = document.getElementById('stripe-connect-actions'); const connectedView = document.getElementById('stripe-connected-view'); const notConnectedView = document.getElementById('stripe-not-connected-view'); const skippedView = document.getElementById('stripe-skipped-view'); const statusEl = document.getElementById('stripe-connect-status'); const accountIdEl = document.getElementById('stripe-account-id'); if (!loadingEl || !actionsEl) return; try { const res = await api('/api/stripe-connect/status'); if (!res.success) { // Not a BiteUp customer or no Stripe Connect enabled for this account actionsEl.style.display = 'none'; statusEl.textContent = 'Stripe Connect is not configured for your account. Contact support.'; return; } loadingEl.style.display = 'none'; actionsEl.style.display = ''; if (res.status === 'connected' || res.connected) { connectedView.style.display = ''; notConnectedView.style.display = 'none'; skippedView.style.display = 'none'; if (accountIdEl) accountIdEl.textContent = res.account_id ? `· ${res.account_id.slice(0,12)}...` : ''; } else if (res.status === 'skipped') { connectedView.style.display = 'none'; notConnectedView.style.display = 'none'; skippedView.style.display = ''; } else { connectedView.style.display = 'none'; notConnectedView.style.display = ''; skippedView.style.display = 'none'; } // Handle query params from OAuth callback redirect const params = new URLSearchParams(window.location.search); const connectResult = params.get('stripe_connect'); if (connectResult === 'connected') { toast('Stripe connected successfully!'); window.history.replaceState({}, '', window.location.pathname); // Refresh status setTimeout(() => loadStripeConnectStatus(), 500); } else if (connectResult === 'error') { const msg = params.get('msg') || 'Stripe connection failed'; toast('Stripe: ' + decodeURIComponent(msg), 'error'); window.history.replaceState({}, '', window.location.pathname); } } catch (e) { loadingEl.style.display = 'none'; actionsEl.style.display = 'none'; if (statusEl) statusEl.textContent = 'Could not load Stripe status. Please try again.'; } } async function openStripeConnectAuth() { const btn = document.getElementById('stripe-connect-btn') || document.getElementById('biteup-connect-stripe-btn'); if (btn) { btn.disabled = true; btn.textContent = 'Opening Stripe...'; } try { const res = await api('/api/stripe-connect/authorize-url'); if (res.success && res.url) { window.location.href = res.url; } else { toast('Could not start Stripe connect. Is Stripe configured?', 'error'); if (btn) { btn.disabled = false; btn.textContent = 'Connect Stripe →'; } } } catch (e) { toast('Failed to connect Stripe', 'error'); if (btn) { btn.disabled = false; btn.textContent = 'Connect Stripe →'; } } } async function disconnectStripeConnect() { if (!confirm('Disconnect your Stripe account? Customers will need to pay cash on delivery only.')) return; try { const res = await api('/api/stripe-connect/disconnect', { method: 'DELETE' }); if (res.success) { toast('Stripe disconnected'); loadStripeConnectStatus(); loadBiteUpStripeStep(); } else { toast('Failed to disconnect', 'error'); } } catch (e) { toast('Failed to disconnect Stripe', 'error'); } } async function skipStripeConnect() { try { const res = await api('/api/stripe-connect/skip', { method: 'POST' }); if (res.success) { toast('Stripe skipped — cash on delivery for now'); loadStripeConnectStatus(); loadBiteUpStripeStep(); } else { toast('Failed to skip Stripe', 'error'); } } catch (e) { toast('Failed to skip Stripe', 'error'); } } async function loadBiteUpStripeStep() { // Show/hide the BiteUp onboarding Stripe Connect step based on connection status const stepEl = document.getElementById('biteup-stripe-connect-step'); if (!stepEl) return; try { const res = await api('/api/stripe-connect/status'); const connected = res.success && (res.status === 'connected' || res.connected || res.status === 'skipped'); stepEl.style.display = connected ? 'none' : ''; const statusEl = document.getElementById('biteup-stripe-connect-status'); if (statusEl) statusEl.textContent = res.message || ''; } catch (e) { stepEl.style.display = 'none'; } } async function optOutDigest() { if (!confirm('Stop receiving the weekly email digest?')) return; try { const res = await api('/api/user/digest-opt-out', { method: 'POST' }); if (res.success) toast('Unsubscribed from weekly digest'); else toast('Failed to unsubscribe', 'error'); } catch (e) { toast('Failed to unsubscribe', 'error'); } } // ── Sequences Page ──────────────────────────────────────── async function renderSequences(container) { container.innerHTML = `

Follow-up Sequences

Automated follow-ups sent when prospects don't reply.

Sequence Engine Queue

Explicit Day 3 + Day 7 schedule — auto-populated when emails are sent

Sequence Schedule

Default cadence for prospects who haven't replied

Pending Follow-ups

`; await loadSequenceData(); } async function loadSequenceData() { const [settingsRes, queueRes, seqStatsRes] = await Promise.all([ api('/api/followup/settings'), api('/api/followup/queue'), api('/api/followup/seq-stats').catch(() => ({ success: false })) ]); // Sequence engine stats card const engEl = document.getElementById('seqEngineStats'); if (engEl) { if (seqStatsRes.success && seqStatsRes.stats) { const s = seqStatsRes.stats; const statBox = (val, label, color, sub) => `
${val}
${label}
${sub ? `
${sub}
` : ''}
`; engEl.innerHTML = `
${statBox(s.queued, 'Queued', 'var(--warning)', s.overdue > 0 ? s.overdue + ' overdue' : null)} ${statBox(s.due_24h, 'Due in 24h', 'var(--info)', null)} ${statBox(s.sent, 'Sent', 'var(--accent)', 'all time')} ${statBox(s.skipped, 'Skipped', 'var(--text-muted)', 'opted-out/replied')}
${s.queued === 0 && s.sent === 0 ? `

No sequences scheduled yet. Sequences are auto-scheduled when you send an initial email.

` : ''} `; } else { engEl.innerHTML = `

Engine stats unavailable — run migration to create the follow_up_sequences table.

`; } } // Render timeline const settings = settingsRes.settings || {}; const steps = [ { label: 'Initial Email', day: 0, icon: '✉️', desc: 'Sent manually', color: 'var(--accent)' }, { label: 'Follow-up 1', day: settings.day3?.days || 3, icon: '↩️', desc: settings.day3?.enabled !== false ? 'Auto-sent' : 'Disabled', color: settings.day3?.enabled !== false ? 'var(--info)' : 'var(--text-dim)' }, { label: 'Follow-up 2', day: settings.day7?.days || 7, icon: '↩️', desc: settings.day7?.enabled !== false ? 'Auto-sent' : 'Disabled', color: settings.day7?.enabled !== false ? 'var(--warning)' : 'var(--text-dim)' }, { label: 'Breakup Email', day: settings.day14?.days || 14, icon: '👋', desc: settings.day14?.enabled !== false ? 'Auto-sent' : 'Disabled', color: settings.day14?.enabled !== false ? 'var(--danger)' : 'var(--text-dim)' } ]; const timeline = document.getElementById('seqTimeline'); if (timeline) { timeline.innerHTML = steps.map((s, i) => `
${s.icon}
${s.label}
${s.day === 0 ? 'Day 0' : 'Day ' + s.day}
${s.desc}
${i < steps.length - 1 ? '
' : ''}
`).join(''); } // Status banner const statusEl = document.getElementById('seqStatus'); if (statusEl) { const enabled = queueRes.enabled !== false; statusEl.innerHTML = `
● ${enabled ? 'Sequences are active — running every 4 hours automatically.' : 'Sequences are disabled. Enable them in Configure.'}
`; } // Queue const queueEl = document.getElementById('queueList'); const queueCount = document.getElementById('queueCount'); const queue = queueRes.queue || []; if (queueCount) queueCount.textContent = queue.length + ' prospect' + (queue.length !== 1 ? 's' : '') + ' pending'; if (!queueEl) return; if (queue.length === 0) { queueEl.innerHTML = `

Queue is clear

No follow-ups due right now. When prospects don't reply, they'll appear here.

`; return; } queueEl.innerHTML = queue.map(item => { const dueDate = new Date(item.due_at); const dueStr = item.overdue ? `Overdue (${timeAgo(dueDate)})` : `Due ${dueDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`; const stepColors = { 2: 'var(--info)', 3: 'var(--warning)', 4: 'var(--danger)' }; const stepColor = stepColors[item.sequence_num] || 'var(--text-muted)'; return `
${item.name || item.email}
${item.company || item.email}
${item.next_step}
${dueStr}
`; }).join(''); } async function triggerFollowupRun() { const res = await api('/api/followup/run-now', { method: 'POST' }); if (res.success) { toast('Legacy follow-up check triggered — check back in a moment'); setTimeout(() => loadSequenceData(), 2000); } else { toast(res.message || 'Error', 'error'); } } async function triggerSeqEngineRun() { const res = await api('/api/followup/seq-run-now', { method: 'POST' }); if (res.success) { toast('Sequence engine triggered — processing Day 3 + Day 7 queue...'); setTimeout(() => loadSequenceData(), 3000); } else { toast(res.message || 'Error', 'error'); } } async function openSequenceSettings() { const res = await api('/api/followup/settings'); const s = res.settings || { enabled: true, day3: { enabled: true, days: 3 }, day7: { enabled: true, days: 7 }, day14: { enabled: true, days: 14 } }; showModal('Configure Sequences', `
Enable sequences
Master switch for all automated follow-ups
${[ { key: 'day3', label: 'Follow-up 1', defaultDays: 3, desc: 'Shorter re-engagement, different angle' }, { key: 'day7', label: 'Follow-up 2', defaultDays: 7, desc: 'Social proof or urgency nudge' }, { key: 'day14', label: 'Breakup Email', defaultDays: 14, desc: 'Final touch — polite close' } ].map(step => `
${step.label}
${step.desc}
Send after days of silence
`).join('')}
`, async () => { const newSettings = { enabled: document.getElementById('seqEnabled').checked, day3: { enabled: document.getElementById('day3Enabled').checked, days: parseInt(document.getElementById('day3Days').value) || 3 }, day7: { enabled: document.getElementById('day7Enabled').checked, days: parseInt(document.getElementById('day7Days').value) || 7 }, day14: { enabled: document.getElementById('day14Enabled').checked, days: parseInt(document.getElementById('day14Days').value) || 14 } }; const r = await api('/api/followup/settings', { method: 'PUT', body: { settings: newSettings } }); if (r.success) { closeModal(); toast('Sequence settings saved'); await loadSequenceData(); } else { toast(r.message || 'Error saving', 'error'); } }, 'Save Settings'); } async function markReplied(prospectId) { const res = await api(`/api/prospects/${prospectId}/mark-replied`, { method: 'POST' }); if (res.success) { toast('Marked as replied — removed from sequence'); await loadSequenceData(); } else { toast(res.message || 'Error', 'error'); } } async function optOutProspect(prospectId) { if (!confirm('Remove this prospect from all follow-up sequences?')) return; const res = await api(`/api/prospects/${prospectId}/opt-out`, { method: 'POST' }); if (res.success) { toast('Prospect opted out'); await loadSequenceData(); } else { toast(res.message || 'Error', 'error'); } } // ── Modal System ────────────────────────────────────────── function showModal(title, content, onSave, saveText = 'Save') { document.getElementById('modalContainer').innerHTML = `

${title}

${content}
${onSave ? `
` : `
`}
`; if (onSave) { document.getElementById('modalSaveBtn').onclick = onSave; } } function closeModal() { document.getElementById('modalContainer').innerHTML = ''; } // ── AI Texter (SMS Conversations) ───────────────────────── let smsConvState = { conversations: [], activeId: null, messages: [], stats: { active: 0, meetings_booked: 0, closed: 0, opted_out: 0, total: 0 }, loading: false, msgLoading: false }; async function renderSMSConversations(el) { el.innerHTML = `

AI Texter

Your AI sales agent that texts prospects 24/7 and books meetings.

Conversations
Loading…
Select a conversation to view the thread
`; await loadSMSConvData(el); } async function loadSMSConvData(el) { try { const [convData, statsData] = await Promise.all([ api('/api/sms-conversations'), api('/api/sms-conversations/stats') ]); smsConvState.conversations = convData.conversations || []; smsConvState.stats = statsData; renderSMSConvStats(); renderSMSConvList(); } catch (e) { const items = document.getElementById('smsConvItems'); if (items) items.innerHTML = `
Failed to load conversations
`; } } function renderSMSConvStats() { const s = smsConvState.stats; const el = document.getElementById('smsConvStats'); if (!el) return; el.innerHTML = [ { label: 'Active', value: s.active, color: '#22d67a' }, { label: 'Meetings Booked', value: s.meetings_booked, color: '#6366f1' }, { label: 'Closed', value: s.closed, color: '#7a7a9a' }, { label: 'Total Sent', value: s.total, color: '#e0e0f0' }, ].map(item => `
${item.value}
${item.label}
`).join(''); } function smsConvStatusBadge(status, meetingBooked) { if (meetingBooked || status === 'meeting_booked') return `📅 Meeting`; if (status === 'active') return `● Active`; if (status === 'opted_out') return `Opted Out`; return `Closed`; } function renderSMSConvList() { const el = document.getElementById('smsConvItems'); if (!el) return; const convs = smsConvState.conversations; if (!convs.length) { el.innerHTML = `
💬
No conversations yet.
Click "Text a Prospect" to start one.
`; return; } el.innerHTML = convs.map(c => { const name = c.prospect_name || c.prospect_phone; const lastMsg = c.last_message ? (c.last_message.length > 50 ? c.last_message.slice(0, 50) + '…' : c.last_message) : '—'; const active = c.id === smsConvState.activeId; return `
${name}
${smsConvStatusBadge(c.status, c.meeting_booked)}
${lastMsg}
${c.prospect_phone} ${c.reply_count > 0 ? c.reply_count + ' repl' + (c.reply_count === 1 ? 'y' : 'ies') : ''}
`; }).join(''); } async function loadSMSThread(convId) { smsConvState.activeId = convId; renderSMSConvList(); // highlight selected const threadEl = document.getElementById('smsConvThread'); if (!threadEl) return; threadEl.innerHTML = `
Loading…
`; const conv = smsConvState.conversations.find(c => c.id === convId); if (!conv) return; try { const data = await api(`/api/sms-conversations/${convId}/messages`); const msgs = data.messages || []; const aiOn = conv.ai_enabled; threadEl.innerHTML = `
${conv.prospect_name || conv.prospect_phone}
${conv.prospect_company || conv.prospect_phone}
${smsConvStatusBadge(conv.status, conv.meeting_booked)}
${msgs.length ? msgs.map(m => renderSMSBubble(m)).join('') : '
No messages yet
'}
`; // Scroll to bottom const msgEl = document.getElementById(`smsMessages${convId}`); if (msgEl) msgEl.scrollTop = msgEl.scrollHeight; } catch (e) { threadEl.innerHTML = `
Failed to load messages
`; } } function renderSMSBubble(msg) { const isOut = msg.direction === 'outbound'; return `
${escapeHtml(msg.body)}
${isOut ? '🤖 AI' : '👤 Prospect'} · ${new Date(msg.sent_at).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'})}
`; } function escapeHtml(str) { return String(str).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } async function toggleAI(convId, enabled) { try { await api(`/api/sms-conversations/${convId}`, { method: 'PATCH', body: { aiEnabled: enabled } }); const c = smsConvState.conversations.find(x => x.id === convId); if (c) c.ai_enabled = enabled; } catch (e) { alert('Failed to update AI setting'); } } function openStartConversationModal() { showModal('Text a Prospect', `

The AI will send a personalized opening text using your product context from Settings. You can watch the conversation in real time here.

`, async () => { const phone = document.getElementById('smsStartPhone')?.value?.trim(); const name = document.getElementById('smsStartName')?.value?.trim(); const company = document.getElementById('smsStartCompany')?.value?.trim(); if (!phone) { alert('Phone number is required'); return; } try { closeModal(); const result = await api('/api/sms-conversations/start', { method: 'POST', body: { prospectPhone: phone, prospectName: name || undefined, prospectCompany: company || undefined } }); if (result.error) { alert(result.error); return; } // Refresh the page data await loadSMSConvData(null); if (result.conversation) loadSMSThread(result.conversation.id); } catch (e) { alert('Failed to start conversation. Check that your product context is set in Settings.'); } }, 'Send First Text'); } // ── AI Voice Phone Agent ────────────────────────────────── let voiceState = { status: null, loading: false }; async function renderVoicePage(el) { el.innerHTML = `

AI Voice

Your AI phone agent that answers calls, qualifies leads, and books appointments 24/7.

Loading…
`; await loadVoiceData(el); } async function loadVoiceData() { voiceState.loading = true; try { const [data, blocklistResult, faqResult] = await Promise.all([ api('/api/voice/status'), api('/api/voice/spam-blocklist').catch(() => ({ blocklist: [] })), api('/api/voice/faq').catch(() => ({ faqs: [] })) ]); data.spamBlocklist = blocklistResult.blocklist || []; data.faqList = faqResult.faqs || []; voiceState.status = data; renderVoiceContent(data); } catch (e) { document.getElementById('voiceContent').innerHTML = `
Failed to load voice status
`; } voiceState.loading = false; } // Voice setup step indicator — updates step badges/dividers for the 3-step wizard function updateSetupStep(step) { const configs = { 1: { badge: 'step1Badge', label: 'step1Label', divider: 'step1Divider', dividerPrev: null }, 2: { badge: 'step2Badge', label: 'step2Label', divider: 'step2Divider', dividerPrev: 'step1Divider' }, 3: { badge: 'step3Badge', label: 'step3Label', divider: 'step3Divider', dividerPrev: 'step2Divider' } }; const c = configs[step]; for (let i = 1; i < step; i++) { const cfg = configs[i]; const b = document.getElementById(cfg.badge); const l = document.getElementById(cfg.label); if (b) { b.style.background = '#22d67a'; b.style.borderColor = '#22d67a'; b.textContent = '✓'; b.style.color = '#fff'; } if (l) l.style.color = '#22d67a'; const d = document.getElementById(cfg.divider); if (d) d.style.background = '#22d67a40'; } const bActive = document.getElementById(c.badge); const lActive = document.getElementById(c.label); if (bActive) { bActive.style.background = '#6366f1'; bActive.style.borderColor = '#6366f1'; bActive.textContent = step; bActive.style.color = '#fff'; } if (lActive) lActive.style.color = '#818cf8'; if (c.dividerPrev) { const dp = document.getElementById(c.dividerPrev); if (dp) { dp.style.display = 'block'; dp.style.background = '#6366f140'; } } const d = document.getElementById(c.divider); if (d) { d.style.display = 'block'; d.style.background = '#2a2a4a'; } } function renderVoiceContent(data) { const content = document.getElementById('voiceContent'); const toggleArea = document.getElementById('voiceToggleArea'); if (!content) return; // Vapi not configured — show setup warning banner const vapiWarning = !data.vapiConfigured ? `
⚠️
Vapi API key not configured
The AI Voice feature requires a Vapi API key to work. Your business info is saved — once the key is added, AI Answering will activate automatically.
` : ''; // Toggle button (only show if configured) if (data.configured && toggleArea) { toggleArea.innerHTML = `
AI Answering
${data.enabled ? 'ON' : 'OFF'}
${pendingActivation ? `
⏳

AI Voice is Being Activated

Your settings for ${data.businessName} have been saved. Our team is provisioning your AI phone agent — this typically takes a few hours.

Activation in Progress
You will receive a notification once your AI phone agent is live and ready to answer calls.
` : ` ${vapiWarning}
Setup Guide — 3 Steps
1
Pick Your Number
2
Set Greeting
3
Turn It On
🛡️
Spam & robocalls filtered automatically
We automatically filter out spam and robocalls so only real customers get through to your AI.
STEP 1
Pick your number

We automatically assign a local phone number for your AI. Just tell us about your business below and we'll take care of it.

STEP 2
Set your greeting

The first thing callers hear. Keep it short and friendly — the AI handles the rest.

STEP 3
Turn it on — you're done!

Hit the button below and your AI starts answering calls immediately. You can pause it anytime from this screen.

Takes about 2 minutes. No tech skills required.
`}
`; } else { // Active dashboard const s = data.stats || {}; const calls = data.recentCalls || []; content.innerHTML = `
${[ { label: 'Total Calls', value: s.total_calls || 0, color: '#e0e0f0' }, { label: 'Bookings', value: s.bookings || 0, color: '#22d67a' }, { label: 'Avg Duration', value: s.avg_duration ? Math.round(s.avg_duration) + 's' : '—', color: '#6366f1' }, { label: 'Missed', value: s.missed_calls || 0, color: '#f5a623' }, ].map(item => `
${item.value}
${item.label}
`).join('')}
${data.enabled ? '🤖' : '⏸️'}
AI Answering
${data.enabled ? 'AI is answering your calls. Customers reach a live assistant 24/7.' : 'AI is paused. Calls ring your phone directly — no AI pickup.'}
OFF
ON
${data.phoneNumber ? `
Your AI Line ${data.enabled ? `● Live` : `⏸ Paused`}
${data.phoneNumber} ${data.enabled ? '● Active' : '⏸ Paused'}
Use this number in your Google Business Profile, website, and ads so customers can reach your AI 24/7.
${data.enabled ? '🤖' : '⏸️'}
AI Voice Agent
` : `
📞
No AI number assigned yet
We'll provision a local phone number and route it to your AI agent automatically.
`}
Phone Numbers ${(data.phoneNumbers||[]).length}
${renderPhoneNumbersList(data.phoneNumbers || [])}

Add a New Number

Leave blank for any available local number
About Twilio Numbers
We'll provision a dedicated local Twilio number and route it to your AI agent. Cost is included with your plan.
🛡️
Spam Filtering
AI blocks spam & robocalls before they reach you
Block unknown callers
${data.blockUnknownCallers ? `
✓
Only callers in your blocklist will be answered. Add numbers below to create your allowlist — everyone else goes to voicemail.
` : `
ℹ
Known spammers are blocked automatically. Toggle "Block unknown callers" to silence robocalls and telemarketers.
`}
${renderSpamBlocklist(data.spamBlocklist || [])}
🔗
Bridge to Owner
Live transfer to your cell when callers need a human
Enable
${data.bridgeEnabled ? `
Bridge triggers
Matches trigger a bridge even if the caller hasn't explicitly asked.
How Bridge works
1. Caller triggers bridge (asks for human, or lead is ready)
2. AI calls YOUR cell first, whispers a 5-10s summary ("Kitchen remodel, ZIP 06484, ready to book")
3. You press 1 to take the call, 2 for voicemail, or ignore — caller waits on hold
4. If you don't answer in 20s or press 2, caller gets: callback / voicemail / book online
` : `
Enable Bridge to let callers connect with you live, 24/7.
`}
🎙️
AI Voice
Pick who answers your calls
Name auto-suggests when you switch voices — you can always override.
${(data.voiceCatalog || []).map(v => { const isSelected = v.id === (data.selectedVoiceId || 'margaret'); return `
${isSelected ? `
Active
` : ''}
${v.name}
${v.vibe}
`; }).join('')}
❓
AI Knowledge Base
Q&A pairs the AI uses on every inbound call
The more questions you add, the smarter your AI receptionist becomes.
${renderFaqList(data.faqList || [])}

Add a Question

Recent Calls
${calls.length === 0 ? `
📵
No calls yet
Once your AI answers its first call, it'll show up here.
` : calls.map(call => { const dur = call.duration_seconds ? (call.duration_seconds >= 60 ? Math.floor(call.duration_seconds/60) + 'm ' + (call.duration_seconds%60) + 's' : call.duration_seconds + 's') : '—'; const when = call.started_at ? new Date(call.started_at).toLocaleDateString('en-US',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}) : '—'; const bridge = call.bridge_attempt; const bridgeBadge = bridge?.triggered ? ( bridge.owner_answered === true ? `🔗 Took` : bridge.owner_answered === false ? `🔗 Voicemail` : `🔗 Bridge` ) : ''; return `
${call.caller_number || 'Unknown caller'}
${when} · ${dur}
${call.summary || '—'}
${call.booking_detected ? `📅 Booked` : ''} ${call.sms_sent ? `📱 Notified` : ''} ${call.status === 'missed' ? `Missed` : ''} ${bridgeBadge}
`; }).join('')}
${!data.phoneNumber ? `
📞 Forward Your Existing Number

While your dedicated AI number is being provisioned, you can forward your existing business phone to the AI. Contact support with your current number and we'll set it up.

` : ''}`; } } // Wire up step indicators: advance step 1 → 2 as user fills in business info document.addEventListener('input', function(e) { if (!e.target) return; const id = e.target.id; if (id === 'voiceBusinessName' || id === 'voiceBusinessType') { const name = document.getElementById('voiceBusinessName')?.value?.trim(); const type = document.getElementById('voiceBusinessType')?.value; if (name && type && typeof updateSetupStep === 'function') { updateSetupStep(1); } } }); async function submitVoiceSetup() { const businessName = document.getElementById('voiceBusinessName')?.value?.trim(); const businessType = document.getElementById('voiceBusinessType')?.value; const greetingMessage = document.getElementById('voiceGreeting')?.value?.trim(); const errEl = document.getElementById('voiceSetupError'); const btn = document.getElementById('voiceSetupBtn'); if (!businessName || !businessType) { if (errEl) { errEl.style.display = 'block'; errEl.textContent = 'Business name and type are required.'; } return; } if (btn) { btn.textContent = 'Setting up…'; btn.disabled = true; } if (errEl) errEl.style.display = 'none'; // Advance step indicator to step 2 (greeting + confirm) while we work if (typeof updateSetupStep === 'function') updateSetupStep(2); try { const result = await api('/api/voice/setup', { method: 'POST', body: { businessName, businessType, greetingMessage, blockUnknownCallers: false } }); if (result.success) { if (result.status === 'pending_credentials') { // API key not configured — settings saved, show clear message if (typeof updateSetupStep === 'function') updateSetupStep(3); toast(result.message || 'Settings saved! AI Voice will activate once configured.', 'info'); await loadVoiceData(); } else { // Advance to step 3 ("Turn it on" — done!) if (typeof updateSetupStep === 'function') updateSetupStep(3); toast(result.message || 'AI Voice activated!', 'success'); await loadVoiceData(); } } else { if (errEl) { errEl.style.display = 'block'; errEl.textContent = result.error || 'Setup failed. Try again.'; } if (btn) { btn.textContent = '🚀 Activate AI Voice Agent'; btn.disabled = false; } } } catch (e) { if (errEl) { errEl.style.display = 'block'; errEl.textContent = e.message || 'Setup failed. Please try again.'; } if (btn) { btn.textContent = '🚀 Activate AI Voice Agent'; btn.disabled = false; } } } async function toggleVoice(enabled) { try { await api('/api/voice/toggle', { method: 'POST', body: { enabled } }); toast(enabled ? 'AI Voice activated' : 'AI Voice paused', 'success'); await loadVoiceData(); } catch (e) { toast('Failed to update voice status', 'error'); } } // ── Voice Picker ────────────────────────────────────────── // Catalog from voiceState; selectedVoiceId is the active pick. const VOICE_GENDERS = { margaret: 'female', sarah: 'female', david: 'male', james: 'male' }; const VOICE_NAMES = { margaret: 'Margaret', sarah: 'Sarah', david: 'David', james: 'James' }; let _voicePreviewAudio = null; async function selectVoice(voiceId) { // Update card borders immediately document.querySelectorAll('[id^="voiceCard_"]').forEach(card => { const id = card.id.replace('voiceCard_', ''); const selected = id === voiceId; card.style.border = `2px solid ${selected ? '#6366f1' : '#2a2a4a'}`; card.style.background = selected ? '#6366f115' : '#0d0d2a'; // Active badge let badge = card.querySelector('.voice-active-badge'); if (selected && !badge) { badge = document.createElement('div'); badge.className = 'voice-active-badge'; badge.style.cssText = 'position:absolute;top:8px;right:8px;background:#6366f1;color:#fff;font-size:.6rem;font-weight:700;padding:2px 7px;border-radius:8px;text-transform:uppercase'; badge.textContent = 'Active'; card.appendChild(badge); } else if (!selected && badge) { badge.remove(); } }); // Persist to server try { await api('/api/voice/voice', { method: 'POST', body: { voiceId } }); toast(`Voice set to ${VOICE_NAMES[voiceId] || voiceId}`, 'success'); } catch (e) { toast('Failed to save voice selection', 'error'); } // Check name/gender mismatch against businessName const bizNameEl = document.getElementById('voiceBusinessName'); const bizName = bizNameEl?.value?.trim() || voiceState.status?.businessName || ''; checkVoiceNameMismatch(voiceId, bizName); } function checkVoiceNameMismatch(voiceId, agentName) { const warn = document.getElementById('voiceNameMismatchWarn'); if (!warn || !agentName) return; // Crude gender from name: look for common male/female names const maleNames = /\b(mike|michael|john|james|david|tom|bob|bill|steve|dan|mark|chris|joe|jason|kevin|ryan|brian|matt|robert|richard|charles|george|william|jack|frank|gary|eric|paul|scott|patrick|larry|peter|henry|sam|alex)\b/i; const femaleNames = /\b(margaret|sarah|mary|jennifer|linda|barbara|patricia|elizabeth|jessica|susan|karen|lisa|nancy|betty|helen|dorothy|donna|carol|ruth|sharon|michelle|laura|emily|anna|alice|maria|grace|jane|kate|amy|emma|olivia)\b/i; const voiceGender = VOICE_GENDERS[voiceId] || 'unknown'; const nameLooksM = maleNames.test(agentName); const nameLooksF = femaleNames.test(agentName); const mismatch = (voiceGender === 'female' && nameLooksM) || (voiceGender === 'male' && nameLooksF); if (mismatch) { const voiceName = VOICE_NAMES[voiceId] || voiceId; const genderLabel = voiceGender === 'female' ? 'female' : 'male'; warn.style.display = 'block'; warn.textContent = `Heads up — ${voiceName} is a ${genderLabel} voice. The name "${agentName}" sounds like the other gender. Want to match them up?`; } else { warn.style.display = 'none'; } } async function playVoicePreview(voiceId) { const btn = document.getElementById(`previewBtn_${voiceId}`); const icon = document.getElementById(`previewBtnIcon_${voiceId}`); // Stop existing preview if (_voicePreviewAudio) { _voicePreviewAudio.pause(); _voicePreviewAudio = null; } // Reset all play icons document.querySelectorAll('[id^="previewBtnIcon_"]').forEach(el => { el.textContent = '▶'; }); if (btn) { btn.style.color = '#818cf8'; } if (icon) icon.textContent = '⏳'; if (btn) btn.style.color = '#555566'; const bizName = encodeURIComponent(voiceState.status?.businessName || "Tom's Construction"); const url = `/api/voice/voice/preview?voiceId=${encodeURIComponent(voiceId)}&businessName=${bizName}`; try { const audio = new Audio(url); _voicePreviewAudio = audio; audio.onended = () => { if (icon) icon.textContent = '▶'; if (btn) btn.style.color = '#818cf8'; }; audio.onerror = () => { if (icon) icon.textContent = '▶'; if (btn) btn.style.color = '#818cf8'; toast('Preview unavailable', 'error'); }; if (icon) icon.textContent = '■'; if (btn) btn.style.color = '#22d67a'; audio.play().catch(() => { if (icon) icon.textContent = '▶'; if (btn) btn.style.color = '#818cf8'; }); } catch (e) { if (icon) icon.textContent = '▶'; if (btn) btn.style.color = '#818cf8'; } } // ── Phone Numbers Management ───────────────────────────── function copyAiLine() { const display = document.getElementById('aiLineDisplay'); const icon = document.getElementById('copyAiLineIcon'); const label = document.getElementById('copyAiLineLabel'); if (!display) return; const text = display.textContent.trim(); if (!text || text === 'No AI number assigned yet') return; navigator.clipboard.writeText(text).then(() => { if (icon) icon.textContent = '✅'; if (label) label.textContent = 'Copied!'; setTimeout(() => { if (icon) icon.textContent = '📋'; if (label) label.textContent = 'Copy Number'; }, 2000); }).catch(() => { toast('Copy failed — try selecting the number manually', 'error'); }); } function copyPhoneNumber(numStr, btn) { if (!numStr || numStr === 'Unknown') return; navigator.clipboard.writeText(numStr).then(() => { const orig = btn.innerHTML; btn.innerHTML = '✅ Copied'; btn.style.color = '#22d67a'; btn.style.borderColor = '#22d67a40'; setTimeout(() => { btn.innerHTML = orig; btn.style.color = '#7a7a9a'; btn.style.borderColor = '#2a2a4a'; }, 1800); }).catch(() => {}); } let phoneModalType = 'provision'; function renderPhoneNumbersList(phoneNumbers) { if (!phoneNumbers || phoneNumbers.length === 0) { return `
📞
No numbers yet
Add a number above to start receiving AI-answered calls
`; } return phoneNumbers.map(num => { const statusColor = num.status === 'active' ? '#22d67a' : num.status === 'error' ? '#ef4444' : '#f5a623'; const statusLabel = num.status === 'active' ? 'Active' : num.status === 'error' ? 'Error' : num.status === 'provisioning' ? 'Provisioning…' : num.status; const sourceLabel = num.source === 'vapi_provisioned' ? 'AI Number' : 'Forwarded Line'; const sourceColor = num.source === 'vapi_provisioned' ? '#6366f1' : '#22d67a'; return `
${num.phone_number || 'Unknown'}
${sourceLabel} ● ${statusLabel} ${num.provisioning_error ? `${num.provisioning_error.slice(0,60)}` : ''}
`; }).join(''); } function showAddPhoneModal(type) { phoneModalType = type; const modal = document.getElementById('phoneModal'); const title = document.getElementById('phoneModalTitle'); const content = document.getElementById('phoneModalContent'); const errorEl = document.getElementById('phoneModalError'); if (errorEl) errorEl.style.display = 'none'; if (type === 'byo') { title.textContent = 'Forward Your Existing Number'; content.innerHTML = `
Calls to this number will be forwarded to your AI agent
How Forwarding Works
Call forwarding routes calls from your existing business number to the AI agent. You'll set this up in your phone carrier's settings.
`; } else { title.textContent = 'Add a New Number'; content.innerHTML = `
Leave blank for any available local number
About Twilio Numbers
We'll provision a dedicated local Twilio number and route it to your AI agent. Cost is included with your plan.
`; } if (modal) modal.style.display = 'flex'; } function closePhoneModal() { const modal = document.getElementById('phoneModal'); if (modal) modal.style.display = 'none'; } // Close modal on backdrop click document.addEventListener('click', function(e) { const modal = document.getElementById('phoneModal'); if (modal && modal.style.display === 'flex' && e.target === modal) closePhoneModal(); }); async function addPhoneNumber(type) { const areaCodeEl = document.getElementById('phoneAreaCode'); const errorEl = document.getElementById('phoneModalError'); const payload = {}; if (type === 'provision') { const areaCode = areaCodeEl?.value?.trim() || null; if (areaCode && (areaCode.length !== 3 || !/^[0-9]{3}$/.test(areaCode))) { if (errorEl) { errorEl.style.display = 'block'; errorEl.textContent = 'Please enter a valid 3-digit US area code'; } return; } const existingNumber = document.getElementById('aiLineDisplay')?.textContent?.trim(); const msg = existingNumber ? `Your AI will now answer calls at the new number (+1 ${areaCode || 'local'}) instead of ${existingNumber}. Continue?` : `Provision a new AI number${areaCode ? ` in area code ${areaCode}` : ''}? This will become your primary AI answering line.`; if (!confirm(msg)) return; payload.type = 'provision'; payload.areaCode = areaCode; } else { payload.type = 'byo_forward'; payload.phoneNumber = areaCodeEl?.value?.trim() || ''; } try { const result = await api('/api/voice/phone-numbers/add', { method: 'POST', body: payload }); if (result.success) { closePhoneModal(); toast(result.message || 'Number added', 'success'); await loadVoiceData(); } else { if (errorEl) { errorEl.style.display = 'block'; errorEl.textContent = result.error || 'Failed to add number'; } } } catch (e) { if (errorEl) { errorEl.style.display = 'block'; errorEl.textContent = e.message || 'Failed to add number'; } } } async function togglePhoneNumber(id, enabled) { try { await api(`/api/voice/phone-numbers/${id}/toggle`, { method: 'POST', body: { enabled } }); toast(`AI ${enabled ? 'enabled' : 'disabled'} for this number`, 'success'); await loadVoiceData(); } catch (e) { toast('Failed to update number', 'error'); } } async function removePhoneNumber(id, numberStr) { if (!confirm(`Remove ${numberStr || 'this number'}? This cannot be undone.`)) return; try { await api(`/api/voice/phone-numbers/${id}`, { method: 'DELETE' }); toast('Number removed', 'success'); await loadVoiceData(); } catch (e) { toast('Failed to remove number', 'error'); } } // ── Spam Filtering ────────────────────────────────────── function renderSpamBlocklist(blocklist) { if (!blocklist || blocklist.length === 0) { return `
No blocked numbers. Block known spammers above.
`; } return blocklist.map(entry => `
🚫
${entry.phone_number}
${entry.label ? `
${entry.label}
` : ''}
${new Date(entry.created_at).toLocaleDateString('en-US',{month:'short',day:'numeric'})}
`).join(''); } async function addSpamBlock() { const numInput = document.getElementById('spamBlockNumber'); const labelInput = document.getElementById('spamBlockLabel'); const phoneNumber = numInput?.value?.trim(); const label = labelInput?.value?.trim(); if (!phoneNumber) { toast('Enter a phone number to block', 'error'); return; } try { await api('/api/voice/spam-blocklist/add', { method: 'POST', body: { phoneNumber, label } }); if (numInput) numInput.value = ''; if (labelInput) labelInput.value = ''; toast('Number blocked', 'success'); await loadVoiceData(); } catch (e) { toast('Failed to block number', 'error'); } } async function removeSpamBlock(id) { try { await api(`/api/voice/spam-blocklist/${id}`, { method: 'DELETE' }); toast('Number removed from blocklist', 'success'); await loadVoiceData(); } catch (e) { toast('Failed to remove', 'error'); } } async function toggleBlockUnknown(blockUnknown) { try { await api('/api/voice/spam-filtering/toggle-unknown', { method: 'POST', body: { blockUnknownCallers: blockUnknown } }); toast(blockUnknown ? 'Unknown callers blocked' : 'All callers allowed', 'success'); await loadVoiceData(); } catch (e) { toast('Failed to update setting', 'error'); } } // ── Bridge — Live Transfer to Owner ─────────────────────── // Toggle keyword trigger checkbox visibility document.addEventListener('change', function(e) { if (e.target && e.target.id === 'bridgeTriggerKeyword') { const row = document.getElementById('bridgeKeywordsRow'); if (row) row.style.display = e.target.checked ? '' : 'none'; } }); async function toggleBridge(enabled) { try { const phone = document.getElementById('bridgePhoneInput')?.value?.trim(); await api('/api/voice/bridge', { method: 'PUT', body: { bridgeEnabled: enabled, bridgeToPhone: phone, triggers: {} } }); toast(enabled ? 'Bridge enabled' : 'Bridge disabled', 'success'); await loadVoiceData(); } catch (e) { toast('Failed to update Bridge', 'error'); } } async function saveBridge() { const errEl = document.getElementById('bridgeSaveError'); const succEl = document.getElementById('bridgeSaveSuccess'); if (errEl) errEl.style.display = 'none'; if (succEl) succEl.style.display = 'none'; const phone = document.getElementById('bridgePhoneInput')?.value?.trim(); const triggers = { humanRequested: document.getElementById('bridgeTriggerHuman')?.checked !== false, leadReady: document.getElementById('bridgeTriggerLead')?.checked !== false, keywordMatch: document.getElementById('bridgeTriggerKeyword')?.checked, keywords: (document.getElementById('bridgeKeywordsInput')?.value || '') .split(',').map(k => k.trim()).filter(Boolean).slice(0, 20), }; try { const result = await api('/api/voice/bridge', { method: 'PUT', body: { bridgeToPhone: phone, triggers } }); if (result.success) { if (succEl) { succEl.textContent = 'Bridge number saved!'; succEl.style.display = 'block'; } toast('Bridge configuration saved', 'success'); await loadVoiceData(); } else { if (errEl) { errEl.textContent = result.error || 'Failed to save'; errEl.style.display = 'block'; } } } catch (e) { if (errEl) { errEl.textContent = e.message || 'Save failed'; errEl.style.display = 'block'; } } } async function testBridgeRing() { const errEl = document.getElementById('bridgeSaveError'); if (errEl) errEl.style.display = 'none'; const phone = document.getElementById('bridgePhoneInput')?.value?.trim(); if (!phone) { if (errEl) { errEl.textContent = 'Enter your phone number first'; errEl.style.display = 'block'; } return; } try { const result = await api('/api/voice/bridge', { method: 'PUT', body: { bridgeToPhone: phone, triggers: {}, sendTestRing: true } }); if (result.testRing?.success) { toast('Test ring sent! Check your phone.', 'success'); } else { toast('Test ring failed: ' + (result.testRing?.error || 'try again'), 'error'); } } catch (e) { toast('Failed to send test ring', 'error'); } } // ── FAQ Knowledge Base ───────────────────────────────────── function renderFaqList(faqs) { if (!faqs || faqs.length === 0) { return `
🧠
No FAQ entries yet
Add questions like "Do you do emergency calls?" or "What's your service area?"
`; } return faqs.map(faq => `
Q: ${escapeHtml(faq.question)}
A: ${escapeHtml(faq.answer)}
`).join(''); } function showAddFaqModal() { document.getElementById('faqModalTitle').textContent = 'Add a Question'; document.getElementById('faqQuestionInput').value = ''; document.getElementById('faqAnswerInput').value = ''; document.getElementById('faqEditId').value = ''; document.getElementById('faqModalError').style.display = 'none'; document.getElementById('faqModal').style.display = 'flex'; } function editFaq(id) { const faq = (voiceState.status?.faqList || []).find(f => f.id === id); if (!faq) return; document.getElementById('faqModalTitle').textContent = 'Edit Question'; document.getElementById('faqQuestionInput').value = faq.question; document.getElementById('faqAnswerInput').value = faq.answer; document.getElementById('faqEditId').value = id; document.getElementById('faqModalError').style.display = 'none'; document.getElementById('faqModal').style.display = 'flex'; } function closeFaqModal() { document.getElementById('faqModal').style.display = 'none'; } async function saveFaq() { const question = document.getElementById('faqQuestionInput')?.value?.trim(); const answer = document.getElementById('faqAnswerInput')?.value?.trim(); const editId = document.getElementById('faqEditId')?.value; if (!question || !answer) { const err = document.getElementById('faqModalError'); err.textContent = 'Both question and answer are required'; err.style.display = 'block'; return; } try { if (editId) { await api(`/api/voice/faq/${editId}`, { method: 'PUT', body: { question, answer } }); toast('FAQ updated', 'success'); } else { await api('/api/voice/faq', { method: 'POST', body: { question, answer } }); toast('Q&A pair added', 'success'); } closeFaqModal(); await loadVoiceData(); } catch (e) { const err = document.getElementById('faqModalError'); err.textContent = e.message || 'Failed to save'; err.style.display = 'block'; } } async function deleteFaq(id) { if (!confirm('Delete this Q&A pair?')) return; try { await api(`/api/voice/faq/${id}`, { method: 'DELETE' }); toast('Q&A pair deleted', 'success'); await loadVoiceData(); } catch (e) { toast('Failed to delete', 'error'); } } // ── On-Site Estimate Builder ───────────────────────────────────────────── let estimateList = []; let activeEstimate = null; let editingEstimateId = null; let estimatePipelineFilter = 'all'; // ── Schedule Page (Job Scheduler) ───────────────────── function renderSchedule(container) { const today = new Date(); const dayNames = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']; const monthNames = ['January','February','March','April','May','June','July','August','September','October','November','December']; // Get the Monday of the current week const startOfWeek = new Date(today); startOfWeek.setDate(today.getDate() - today.getDay() + 1); const days = []; for (let i = 0; i < 7; i++) { const d = new Date(startOfWeek); d.setDate(startOfWeek.getDate() + i); days.push(d); } const hours = []; for (let h = 6; h <= 20; h++) { hours.push(h); } // Sample jobs from estimates const sampleJobs = estimateList.filter(e => e.status === 'approved' || e.status === 'sent').slice(0, 5); container.innerHTML = `

Schedule

${monthNames[today.getMonth()]} ${today.getFullYear()} · Week of ${startOfWeek.toLocaleDateString('en-US',{month:'short',day:'numeric'})}

${days.map((d, i) => { const isToday = d.toDateString() === today.toDateString(); return `
${dayNames[d.getDay()]} ${d.getDate()}
`; }).join('')} ${hours.map(h => { const timeLabel = h <= 12 ? `${h}${h < 12 ? 'am' : 'pm'}` : `${h-12}pm`; return `
${timeLabel}
${days.map((d, dayIdx) => { // Place sample jobs let jobHtml = ''; if (sampleJobs.length > 0) { const jobIdx = (h + dayIdx) % sampleJobs.length; const job = sampleJobs[jobIdx]; if (h === 8 && dayIdx === 0 && job) { jobHtml = `
${escHtml(job.customer_name)}
`; } if (h === 10 && dayIdx === 2 && sampleJobs[1]) { jobHtml = `
${escHtml(sampleJobs[1].customer_name)}
`; } if (h === 14 && dayIdx === 4 && sampleJobs[2]) { jobHtml = `
${escHtml(sampleJobs[2].customer_name)}
`; } } return `
${jobHtml}
`; }).join('')} `; }).join('')}
${!sampleJobs.length ? `
📅

No jobs scheduled yet

Create an estimate and approve it to start scheduling jobs.

` : ''} `; } // ── Dispatch Page ─────────────────────────────────────── function renderDispatch(container) { // Build job list from approved estimates const jobs = estimateList.filter(e => e.status === 'approved' || e.status === 'sent').map((e, i) => { const statuses = ['scheduled','en-route','on-site','completed']; const status = statuses[i % statuses.length]; return { ...e, jobStatus: status }; }); const statusCounts = { scheduled: 0, 'en-route': 0, 'on-site': 0, completed: 0 }; jobs.forEach(j => { if (statusCounts[j.jobStatus] !== undefined) statusCounts[j.jobStatus]++; }); container.innerHTML = `

Dispatch

Route crews to job sites · ${jobs.length} active jobs

${statusCounts.scheduled}
Scheduled
${statusCounts['en-route']}
En Route
${statusCounts['on-site']}
On Site
${statusCounts.completed}
Completed
${jobs.length ? `
${jobs.map(j => { const statusClass = j.jobStatus; const statusLabels = { scheduled: 'Scheduled', 'en-route': 'En Route', 'on-site': 'On Site', completed: 'Completed' }; const hasAddress = j.job_address; return `
${escHtml(j.customer_name)}
${escHtml(j.estimate_number)}
${statusLabels[statusClass]}
${j.job_description ? `
${escHtml(j.job_description)}
` : ''} ${hasAddress ? `
${escHtml(j.job_address)}
Get Directions ` : `
No address set
`}
$${parseFloat(j.total || 0).toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})}
`; }).join('')}
` : `
📍

No jobs to dispatch

Jobs appear here when estimates are sent or approved.

`} `; } // ── Lead Inbox ───────────────────────────────────── // ── Lead Inbox ───────────────────────────────────────── let leadsInboxList = []; let leadsInboxStats = {}; let leadsInboxStatusFilter = 'all'; let leadsInboxSourceFilter = 'all'; // Demo rows shown when owner has zero real leads const LEAD_SAMPLE_ROWS = [ { id: 'demo-1', _sample: true, customer_name: 'Kevin Donahue', customer_phone: '(555) 312-4489', job_type: 'Roof Replacement', job_description: 'Full roof tear-off and replacement needed after storm damage. 2,200 sq ft.', source: 'google', status: 'new', quote_amount: null, received_at: new Date(Date.now() - 2*3600000).toISOString() }, { id: 'demo-2', _sample: true, customer_name: 'Sandra Cortez', customer_email: 'sandra.c@email.com', customer_phone: '(555) 778-2034', job_type: 'HVAC Install', job_description: 'New HVAC system for 1,400 sq ft home. Old unit failed. Wants quote ASAP.', source: 'facebook', status: 'working', quote_amount: 4800, received_at: new Date(Date.now() - 18*3600000).toISOString() }, { id: 'demo-3', _sample: true, customer_name: 'R. Torres Plumbing', customer_email: 'rtorres@bizmail.com', job_type: 'Commercial Pipe Repair', job_description: 'Burst pipe in commercial kitchen. Need emergency repair + inspection.', source: 'yelp', status: 'quoted', quote_amount: 2200, received_at: new Date(Date.now() - 2*86400000).toISOString() }, ]; const LEAD_SOURCE_CHIPS = [ { key: 'all', label: 'All', icon: '', color: '' }, { key: 'google', label: 'Google', icon: '🔍', color: '#4285f4' }, { key: 'facebook', label: 'Facebook', icon: '📘', color: '#1877f2' }, { key: 'yelp', label: 'Yelp', icon: '⭐', color: '#d32323' }, { key: 'homeadvisor', label: 'HomeAdvisor', icon: '🏠', color: '#00a84f' }, { key: 'angi', label: 'Angi', icon: '🏆', color: '#cc3300' }, { key: 'thumbtack', label: 'Thumbtack', icon: '🔧', color: '#30b88a' }, { key: 'houzz', label: 'Houzz', icon: '🏡', color: '#77b948' }, { key: 'website', label: 'Website', icon: '🌐', color: '#22d67a' }, { key: 'manual', label: 'Manual', icon: '✏️', color: '#8b5cf6' }, { key: 'other', label: 'Other', icon: '📌', color: 'var(--text-muted)' }, ]; const LEAD_STATUS_OPTS = [ { key: 'all', label: 'All', color: '' }, { key: 'new', label: 'New', color: 'var(--accent)' }, { key: 'working', label: 'Working', color: 'var(--info)' }, { key: 'quoted', label: 'Quoted', color: 'var(--warning)' }, { key: 'converted', label: 'Won', color: '#22d67a' }, { key: 'lost', label: 'Lost', color: 'var(--danger)' }, ]; function sourceIcon(source) { const icons = { google:'🔍', facebook:'📘', yelp:'⭐', homeadvisor:'🏠', angi:'🏆', thumbtack:'🔧', houzz:'🏡', website:'🌐', manual:'✏️', other:'📌' }; return icons[source] || '📌'; } function sourceColor(source) { const colors = { google:'#4285f4', facebook:'#1877f2', yelp:'#d32323', homeadvisor:'#00a84f', angi:'#cc3300', thumbtack:'#30b88a', houzz:'#77b948', website:'#22d67a', manual:'#8b5cf6' }; return colors[source] || 'var(--text-muted)'; } function leadStatusColor(status) { return { new:'var(--accent)', working:'var(--info)', quoted:'var(--warning)', converted:'#22d67a', lost:'var(--danger)' }[status] || 'var(--text-muted)'; } async function renderLeadsInbox(container) { container.innerHTML = `

Lead Inbox

Every job inquiry — Google, Facebook, Yelp, and 6 more platforms — in one place

`; try { const data = await api('/leads/inbox'); leadsInboxList = data.leads || []; leadsInboxStats = data.stats || {}; } catch(e) { console.error('[LeadsInbox]', e.message); } leadsInboxRenderPipeline(); leadsInboxRenderSourceChips(); leadsInboxRenderStatusPills(); leadsInboxRenderList(); } function leadsInboxRenderPipeline() { const el = document.getElementById('leadsInboxPipelineRow'); if (!el) return; const s = leadsInboxStats; const steps = [ { key: 'new', label: 'New', count: parseInt(s.new_count || 0), color: 'var(--accent)' }, { key: 'working', label: 'Working', count: parseInt(s.working_count || 0), color: 'var(--info)' }, { key: 'quoted', label: 'Quoted', count: parseInt(s.quoted_count || 0), color: 'var(--warning)' }, { key: 'converted', label: 'Won', count: parseInt(s.converted_count || 0), color: '#22d67a' }, ]; el.innerHTML = steps.map((step, i) => ` ${i > 0 ? '›' : ''} `).join(''); } function leadsInboxRenderSourceChips() { const el = document.getElementById('leadsSourceChips'); if (!el) return; const s = leadsInboxStats; const total = parseInt(s.total || 0); const visibleSources = LEAD_SOURCE_CHIPS.filter(c => { if (c.key === 'all') return true; const cnt = parseInt(s[c.key + '_count'] || 0); return total === 0 || cnt > 0; }); el.innerHTML = visibleSources.map(c => { const active = leadsInboxSourceFilter === c.key; const cnt = c.key === 'all' ? parseInt(s.total || 0) : parseInt(s[c.key + '_count'] || 0); return ``; }).join(''); } function leadsInboxRenderStatusPills() { const el = document.getElementById('leadsStatusPills'); if (!el) return; el.innerHTML = LEAD_STATUS_OPTS.map(s => { const active = leadsInboxStatusFilter === s.key; return ``; }).join(''); } function setLeadsSourceFilter(key) { leadsInboxSourceFilter = key; leadsInboxRenderSourceChips(); leadsInboxRenderList(); } function setLeadsStatusFilter(key) { leadsInboxStatusFilter = key; leadsInboxRenderPipeline(); leadsInboxRenderStatusPills(); leadsInboxRenderList(); } function leadsInboxRenderList() { const listEl = document.getElementById('leadsInboxList'); if (!listEl) return; const isZeroState = leadsInboxList.length === 0; const displayList = isZeroState ? LEAD_SAMPLE_ROWS : leadsInboxList; let filtered = displayList; if (!isZeroState && leadsInboxStatusFilter !== 'all') filtered = filtered.filter(l => l.status === leadsInboxStatusFilter); if (!isZeroState && leadsInboxSourceFilter !== 'all') filtered = filtered.filter(l => l.source === leadsInboxSourceFilter); if (isZeroState) { listEl.innerHTML = `
🔌

No leads yet — connect a source to start capturing

Use Zapier or Make to send leads from Google, Facebook, Yelp, and other platforms to your webhook URL.

${['Google','Facebook','Yelp','HomeAdvisor','Angi','Thumbtack','Houzz'].map(p => ``).join('')}

Sample — showing what it looks like with leads

${LEAD_SAMPLE_ROWS.map(lead => renderLeadRow(lead)).join('')}
`; return; } if (!filtered.length) { listEl.innerHTML = `
📥

No leads match this filter

Try a different source or status filter.

`; return; } listEl.innerHTML = `
${filtered.map(lead => renderLeadRow(lead)).join('')}
`; } function renderLeadRow(lead) { const sc = sourceColor(lead.source); const stColor = leadStatusColor(lead.status); const time = lead.received_at ? timeAgo(lead.received_at) : ''; const quote = lead.quote_amount ? `$${parseFloat(lead.quote_amount).toLocaleString('en-US', {maximumFractionDigits:0})}` : ''; const statusLabel = { new:'New', working:'Working', quoted:'Quoted', converted:'Won', lost:'Lost' }[lead.status] || lead.status; return `
${sourceIcon(lead.source)}
${escHtml(lead.customer_name || 'Unknown')} ${lead.source}
${lead.job_type ? escHtml(lead.job_type) : (lead.job_description ? escHtml(lead.job_description).substring(0,60) : '—')}
${quote ? `
${quote}
` : ''}
${time}
`; } async function quickLeadStatusChange(leadId, newStatus) { if (leadId === 'demo-1' || leadId === 'demo-2' || leadId === 'demo-3') return; try { const data = await api('/leads/inbox/' + leadId + '/status', { method: 'POST', body: { status: newStatus } }); const idx = leadsInboxList.findIndex(l => l.id === leadId); if (idx >= 0) leadsInboxList[idx] = data.lead; const s = await api('/leads/inbox/stats'); leadsInboxStats = s.stats || leadsInboxStats; leadsInboxRenderPipeline(); leadsInboxRenderSourceChips(); leadsInboxRenderList(); toast('Status updated to ' + newStatus); } catch(e) { toast('Failed to update status', 'error'); } } async function openLeadDrawer(leadId) { if (String(leadId).startsWith('demo')) return; const drawer = document.getElementById('leadsDrawer'); const overlay = document.getElementById('leadsDrawerOverlay'); if (!drawer || !overlay) return; overlay.style.display = 'block'; drawer.style.display = 'block'; requestAnimationFrame(() => drawer.style.transform = 'translateX(0)'); drawer.innerHTML = `
Loading job details…
`; try { const data = await api('/leads/inbox/' + leadId); const lead = data.lead; const events = data.events || []; const sc = sourceColor(lead.source); const stColor = leadStatusColor(lead.status); const quote = lead.quote_amount ? `$${parseFloat(lead.quote_amount).toLocaleString('en-US', {maximumFractionDigits:0})}` : null; drawer.innerHTML = `
${sourceIcon(lead.source)}

${escHtml(lead.customer_name || 'Unknown')}

${lead.source} ${lead.received_at ? `· ${timeAgo(lead.received_at)}` : ''}
${quote ? `${quote}` : ''}
Contact
${lead.customer_email ? `
📧${escHtml(lead.customer_email)}
` : ''} ${lead.customer_phone ? `
📞${escHtml(lead.customer_phone)}
` : ''} ${(!lead.customer_email && !lead.customer_phone) ? 'No contact info' : ''}
Job
${lead.job_type ? `
${escHtml(lead.job_type)}
` : ''} ${lead.job_description ? `
${escHtml(lead.job_description)}
` : 'No description'}
${(lead.utm_source || lead.utm_medium || lead.utm_campaign || lead.source_url) ? `
Attribution
${lead.utm_source ? `
Source: ${escHtml(lead.utm_source)}
` : ''} ${lead.utm_medium ? `
Medium: ${escHtml(lead.utm_medium)}
` : ''} ${lead.utm_campaign ? `
Campaign: ${escHtml(lead.utm_campaign)}
` : ''} ${lead.source_url ? `
URL: ${escHtml(lead.source_url).substring(0,45)}…
` : ''}
` : ''}
Add Note
Activity
${events.length ? events.map(ev => renderLeadEvent(ev)).join('') : '
No activity yet.
'}
`; } catch(e) { drawer.innerHTML = `
Failed to load lead: ${e.message}
`; } } function renderLeadEvent(ev) { const icons = { created:'📥', status_changed:'🔄', updated:'✏️', note_added:'📝', converted:'✅', sms_sent:'💬', email_sent:'📧' }; const icon = icons[ev.event] || '•'; const when = ev.created_at ? timeAgo(ev.created_at) : ''; return `
${icon}
${escHtml(ev.detail || ev.event)}
${when}
`; } async function addLeadNote(leadId) { const input = document.getElementById('leadDrawerNoteInput'); const note = input?.value?.trim(); if (!note) return; try { await api('/leads/inbox/' + leadId + '/note', { method: 'POST', body: { note } }); input.value = ''; // Refresh timeline only const data = await api('/leads/inbox/' + leadId + '/events'); const tl = document.getElementById('leadDrawerTimeline'); if (tl) tl.innerHTML = (data.events || []).map(ev => renderLeadEvent(ev)).join('') || '
No activity yet.
'; toast('Note added'); } catch(e) { toast('Failed to add note', 'error'); } } function closeLeadDrawer() { const drawer = document.getElementById('leadsDrawer'); const overlay = document.getElementById('leadsDrawerOverlay'); if (drawer) { drawer.style.transform = 'translateX(100%)'; setTimeout(() => drawer.style.display = 'none', 280); } if (overlay) overlay.style.display = 'none'; } async function convertLeadToEstimate(leadId) { if (String(leadId).startsWith('demo')) return; try { const data = await api('/leads/inbox/' + leadId); closeLeadDrawer(); navigate('estimates'); setTimeout(() => typeof openEstimateModal === 'function' && openEstimateModal({ prefill: { customer_name: data.lead.customer_name, customer_email: data.lead.customer_email, customer_phone: data.lead.customer_phone, job_description: data.lead.job_description || data.lead.job_type, }}), 500); } catch(e) { toast('Failed to load lead', 'error'); } } async function editLeadInbox(leadId) { const lead = leadsInboxList.find(l => l.id === leadId); openLeadsInboxModal(lead); } function openLeadsInboxModal(lead) { const isEdit = !!lead; const modal = document.createElement('div'); modal.id = 'leadsInboxModal'; modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);display:flex;align-items:center;justify-content:center;z-index:9999;padding:20px'; modal.innerHTML = `

${isEdit ? 'Edit Lead' : 'Add Lead'}

${isEdit ? `` : ''}
`; document.body.appendChild(modal); } async function saveLeadInbox(leadId) { const name = document.getElementById('leadName').value.trim(); const email = document.getElementById('leadEmail').value.trim(); const phone = document.getElementById('leadPhone').value.trim(); const job_type = document.getElementById('leadJobType').value.trim(); const job_description = document.getElementById('leadDesc').value.trim(); const quote_amount = parseFloat(document.getElementById('leadQuote').value) || null; const status = document.getElementById('leadStatus').value; const internal_notes = document.getElementById('leadNotes').value.trim(); if (!name && !email && !phone) { toast('Name, email, or phone required', 'error'); return; } const btn = document.getElementById('leadSaveBtn'); btn.disabled = true; btn.textContent = 'Saving...'; try { if (leadId) { await api('/leads/inbox/' + leadId, { method: 'PATCH', body: { status, customer_name: name, customer_email: email, customer_phone: phone, job_type, job_description, quote_amount, internal_notes } }); } else { await api('/leads/inbox', { method: 'POST', body: { customer_name: name, customer_email: email, customer_phone: phone, job_type, job_description, quote_amount, status: status || 'new', internal_notes } }); } document.getElementById('leadsInboxModal')?.remove(); const data = await api('/leads/inbox'); leadsInboxList = data.leads || []; leadsInboxStats = data.stats || {}; leadsInboxRenderPipeline(); leadsInboxRenderSourceChips(); leadsInboxRenderList(); closeLeadDrawer(); toast(leadId ? 'Lead updated' : 'Lead added'); } catch(e) { toast(e.message || 'Failed to save', 'error'); btn.disabled = false; btn.textContent = leadId ? 'Save Changes' : 'Add Lead'; } } async function deleteLeadInbox(leadId) { if (!confirm('Delete this lead?')) return; try { await api('/leads/inbox/' + leadId, { method: 'DELETE' }); document.getElementById('leadsInboxModal')?.remove(); closeLeadDrawer(); const data = await api('/leads/inbox'); leadsInboxList = data.leads || []; leadsInboxStats = data.stats || {}; leadsInboxRenderPipeline(); leadsInboxRenderSourceChips(); leadsInboxRenderList(); toast('Lead deleted'); } catch(e) { toast(e.message || 'Failed to delete', 'error'); } } function openLeadsInboxWebhookDoc(platform) { const docs = { google: 'Google Business Profile → Google Pub/Sub or Zapier → POST /api/leads/inbox/webhook/google', facebook: 'Facebook Lead Ads → Zapier/Make → POST /api/leads/inbox/webhook/facebook', yelp: 'Yelp Lead Connect → Zapier → POST /api/leads/inbox/webhook/yelp', homeadvisor: 'HomeAdvisor → Zapier/Make → POST /api/leads/inbox/webhook/homeadvisor', angi: "Angi's List → Zapier → POST /api/leads/inbox/webhook/angi", thumbtack: 'Thumbtack → Zapier → POST /api/leads/inbox/webhook/thumbtack', houzz: 'Houzz Pro → Zapier → POST /api/leads/inbox/webhook/houzz', }; toast((docs[platform] || 'Webhook: POST /api/leads/inbox/webhook/' + platform) + ' — add fields: customer_name, customer_email, customer_phone, job_type', 'info'); } async function renderEstimates(container) { container.innerHTML = `

Estimates

Create, send, and track job estimates

`; try { const data = await api('/estimates'); estimateList = data.estimates || []; } catch (e) { console.error(e); } renderEstimatePipeline(); container.querySelector('#estimateListView').innerHTML = renderEstimateList(); } function renderEstimatePipeline() { const counts = { all: estimateList.length, draft: 0, sent: 0, approved: 0, declined: 0, converted_to_invoice: 0 }; estimateList.forEach(e => { if (counts[e.status] !== undefined) counts[e.status]++; }); const steps = [ { key: 'all', label: 'All', icon: '📋' }, { key: 'draft', label: 'Draft', icon: '✏️' }, { key: 'sent', label: 'Sent', icon: '📤' }, { key: 'approved', label: 'Approved', icon: '✓' }, { key: 'converted_to_invoice', label: 'Invoiced', icon: '💰' }, ]; const el = document.getElementById('estimatePipeline'); if (!el) return; el.innerHTML = steps.map((s, i) => ` ${i > 0 ? '›' : ''}
${s.icon} ${s.label} ${counts[s.key] || 0}
`).join(''); } function filterEstimatePipeline(status) { estimatePipelineFilter = status; renderEstimatePipeline(); document.querySelector('#estimateListView').innerHTML = renderEstimateList(); } function renderEstimateList() { const filtered = estimatePipelineFilter === 'all' ? estimateList : estimateList.filter(e => e.status === estimatePipelineFilter); if (!filtered.length) { const isEmpty = !estimateList.length; return `
📋

${isEmpty ? 'No estimates yet' : 'No estimates in this status'}

${isEmpty ? 'Create your first estimate to start tracking jobs.' : 'Estimates will appear here as they move through the pipeline.'}

${isEmpty ? `` : ''}
`; } const statusMap = { draft: ['Draft','var(--text-dim)','var(--surface-3)'], sent: ['Sent','var(--warning)','var(--warning-dim)'], approved: ['Approved','var(--accent)','var(--accent-dim)'], declined: ['Declined','var(--danger)','var(--danger-dim)'], converted_to_invoice: ['Invoiced','var(--purple)','var(--purple-dim)'] }; return `
${filtered.map(e => { const [sLabel, sColor, sBg] = statusMap[e.status] || [e.status,'var(--text-dim)','var(--surface-3)']; const hasAddress = e.job_address; return `
${escHtml(e.customer_name)}
${escHtml(e.estimate_number)}
${sLabel}
${hasAddress ? `
${escHtml(e.job_address)} ${hasAddress ? `Directions →` : ''}
` : ''}
$${parseFloat(e.total || 0).toLocaleString('en-US', {minimumFractionDigits:2,maximumFractionDigits:2})} ${timeAgo(e.created_at)}
`; }).join('')}
`; } // Open modal to create a new estimate async function openEstimateModal() { const modal = createModal('New Estimate', `
`, [ { label: 'Cancel', fn: closeModal }, { label: 'Create Estimate', fn: createNewEstimate, primary: true } ]); document.body.appendChild(modal); } async function createNewEstimate() { const customer = document.getElementById('est-customer')?.value?.trim(); if (!customer) { toast('Customer name is required', 'error'); return; } try { const data = await api('/estimates', { method: 'POST', body: { customer_name: customer, customer_phone: document.getElementById('est-phone')?.value?.trim() || null, customer_email: document.getElementById('est-email')?.value?.trim() || null, job_address: document.getElementById('est-address')?.value?.trim() || null, job_description: document.getElementById('est-description')?.value?.trim() || null } }); closeModal(); toast('Estimate created', 'success'); await openEstimateBuilder(data.estimate.id); } catch (e) { toast(e.message || 'Failed to create estimate', 'error'); } } // Open estimate builder for edit/view async function openEstimateBuilder(id) { try { const data = await api(`/estimates/${id}`); activeEstimate = data.estimate; editingEstimateId = id; renderEstimateBuilder(document.getElementById('mainContent')); } catch (e) { toast('Failed to load estimate', 'error'); } } function renderEstimateBuilder(container) { const e = activeEstimate; if (!e) return; const isEditable = e.status === 'draft'; const statusColor = { draft: '#555', sent: '#f5a623', approved: '#22d67a', declined: '#ff4d4d', converted_to_invoice: '#6366f1' }[e.status] || '#555'; const statusLabel = { draft: 'Draft', sent: 'Sent', approved: '✓ Approved', declined: '✗ Declined', converted_to_invoice: 'Invoiced' }[e.status] || e.status; const subtotal = parseFloat(e.subtotal || 0); const taxAmt = parseFloat(e.tax_amount || 0); const total = parseFloat(e.total || 0); container.innerHTML = `

${escHtml(e.customer_name)}

${escHtml(e.estimate_number)} · ${statusLabel}
${e.status === 'draft' ? `` : ''} ${e.status === 'sent' || e.status === 'draft' ? `` : ''} ${e.status === 'approved' && e.status !== 'converted_to_invoice' ? `` : ''} ${isEditable ? `` : ''}
${e.status === 'approved' ? `
✓
Approved & Signed${e.approved_signature_name ? ` · ${escHtml(e.approved_signature_name)}` : ''}${e.approved_at ? `
${new Date(e.approved_at).toLocaleDateString()}
` : ''}
` : ''}
Customer Info
${isEditable ? `
` : `
${e.customer_phone ? `
${escHtml(e.customer_phone)}
` : ''} ${e.customer_email ? `
${escHtml(e.customer_email)}
` : ''} ${e.job_address ? `
${escHtml(e.job_address)}
` : ''} ${e.job_description ? `
${escHtml(e.job_description)}
` : ''}
`}
Line Items
${isEditable ? `` : ''}
${(e.items || []).map((item, i) => `
${isEditable ? `
$${parseFloat(item.total||0).toFixed(2)}
` : `
${escHtml(item.description)} ${item.category}
${item.quantity} × $${parseFloat(item.unit_price||0).toFixed(2)}
$${parseFloat(item.total||0).toFixed(2)}
`}
`).join('')} ${!(e.items||[]).length ? `

No line items yet.${isEditable?' Add one to get started.':''}

` : ''}
${isEditable ? `` : ''}
Photos (${(e.photos||[]).length})
${isEditable ? `` : ''}
${(e.photos||[]).map(p => `
${p.photo_type} ${isEditable ? `` : ''}
`).join('')} ${!(e.photos||[]).length ? `

No photos yet.

` : ''}
Total
Subtotal $${subtotal.toFixed(2)}
${e.tax_rate > 0 ? `
Tax (${(parseFloat(e.tax_rate)*100).toFixed(1)}%) $${taxAmt.toFixed(2)}
` : ''}
Total $${total.toFixed(2)}
${isEditable ? `
` : ''}
Notes
${isEditable ? ` ` : e.notes ? `

${escHtml(e.notes)}

` : `

No notes.

`}
${(e.revisions||[]).length ? `
Revision History (${(e.revisions||[]).length})
${(e.revisions||[]).map(r => `
Rev #${r.revision_number} ${timeAgo(r.created_at)}
${r.notes ? `
${escHtml(r.notes)}
` : ''}
`).join('')}
` : ''}
`; } async function saveEstimateInfo() { try { await api(`/estimates/${editingEstimateId}`, { method: 'PUT', body: { customer_name: document.getElementById('ei-customer-name')?.value, customer_phone: document.getElementById('ei-customer-phone')?.value, customer_email: document.getElementById('ei-customer-email')?.value, job_address: document.getElementById('ei-job-address')?.value, job_description: document.getElementById('ei-job-description')?.value } }); toast('Customer info saved', 'success'); } catch (e) { toast('Failed to save', 'error'); } } async function saveTaxRate() { const rate = parseFloat(document.getElementById('ei-tax-rate')?.value || 0) / 100; try { await api(`/estimates/${editingEstimateId}`, { method: 'PUT', body: { tax_rate: rate } }); toast('Tax rate updated', 'success'); await refreshEstimate(); } catch (e) { toast('Failed to update tax rate', 'error'); } } async function saveNotes() { const notes = document.getElementById('ei-notes')?.value || ''; try { await api(`/estimates/${editingEstimateId}`, { method: 'PUT', body: { notes } }); toast('Notes saved', 'success'); } catch (e) { toast('Failed to save notes', 'error'); } } async function addEstimateItem() { const items = [...(activeEstimate.items || [])]; items.push({ description: '', category: 'labor', quantity: 1, unit_price: 0, total: 0 }); activeEstimate.items = items; // Save to server try { await api(`/estimates/${editingEstimateId}`, { method: 'PUT', body: { items: activeEstimate.items } }); await refreshEstimate(); } catch (e) { toast('Failed to add item', 'error'); } } async function updateItem(idx, field, val) { const items = [...(activeEstimate.items || [])]; if (field === 'quantity' || field === 'unit_price') { items[idx][field] = parseFloat(val) || 0; items[idx].total = (parseFloat(items[idx].quantity) * parseFloat(items[idx].unit_price)) || 0; } else { items[idx][field] = val; } activeEstimate.items = items; renderEstimateBuilder(document.getElementById('mainContent')); } async function removeEstimateItem(idx) { const items = [...(activeEstimate.items || [])]; items.splice(idx, 1); activeEstimate.items = items; try { await api(`/estimates/${editingEstimateId}`, { method: 'PUT', body: { items } }); await refreshEstimate(); } catch (e) { toast('Failed to remove item', 'error'); } } async function uploadEstimatePhoto() { const input = document.createElement('input'); input.type = 'file'; input.accept = 'image/*'; input.capture = 'environment'; input.onchange = async () => { const file = input.files[0]; if (!file) return; toast('Uploading photo...', 'info'); try { const reader = new FileReader(); reader.onload = async (e) => { const base64 = e.target.result.split(',')[1]; const resp = await api('/upload', { method: 'POST', body: { file_data: base64, filename: file.name, mime_type: file.type || 'image/jpeg' } }); await api(`/estimates/${editingEstimateId}/photos`, { method: 'POST', body: { photo_url: resp.url, photo_type: 'other' } }); toast('Photo added', 'success'); await refreshEstimate(); }; reader.onerror = () => toast('Failed to read file', 'error'); reader.readAsDataURL(file); } catch (e) { toast('Photo upload failed — ' + (e.message || ''), 'error'); } }; input.click(); } async function deleteEstimatePhoto(photoId) { try { await api(`/estimates/${editingEstimateId}/photos/${photoId}`, { method: 'DELETE' }); toast('Photo removed', 'success'); await refreshEstimate(); } catch (e) { toast('Failed to remove photo', 'error'); } } async function sendEstimate() { try { await api(`/estimates/${editingEstimateId}/send`, { method: 'POST' }); toast('Estimate marked as sent', 'success'); await refreshEstimate(); } catch (e) { toast('Failed to send', 'error'); } } function showSignatureModal() { const modal = createModal('Customer Approval', `

By typing your name below, you authorize this estimate and approve the work described.

Signing this estimate authorizes the work and pricing described above. You will receive a copy of this estimate via email.
`, [ { label: 'Cancel', fn: closeModal }, { label: 'Approve & Sign', fn: submitSignature, primary: true } ]); document.body.appendChild(modal); } async function submitSignature() { const name = document.getElementById('sig-name')?.value?.trim(); if (!name) { toast('Please enter your full name', 'error'); return; } try { await api(`/estimates/${editingEstimateId}/approve`, { method: 'POST', body: { signature_name: name } }); closeModal(); toast('Estimate approved!', 'success'); await refreshEstimate(); } catch (e) { toast(e.message || 'Failed to approve', 'error'); } } async function convertToInvoice() { if (!confirm('Convert this estimate to an invoice?')) return; try { const data = await api(`/invoices/from-estimate/${editingEstimateId}`, { method: 'POST' }); toast('Invoice created! Opening it now...', 'success'); closeModal(); setTimeout(() => navigate('invoices'), 500); } catch (e) { toast(e.message || 'Failed to create invoice', 'error'); } } async function deleteEstimate() { if (!confirm('Delete this estimate? This cannot be undone.')) return; try { await api(`/estimates/${editingEstimateId}`, { method: 'DELETE' }); toast('Estimate deleted', 'success'); backToEstimateList(); } catch (e) { toast('Failed to delete', 'error'); } } async function refreshEstimate() { try { const data = await api(`/estimates/${editingEstimateId}`); activeEstimate = data.estimate; renderEstimateBuilder(document.getElementById('mainContent')); } catch (e) { console.error(e); } } async function backToEstimateList() { activeEstimate = null; editingEstimateId = null; estimateList = []; await renderEstimates(document.getElementById('mainContent')); } // ── Invoices ──────────────────────────────────────────────── let invoiceList = []; let activeInvoice = null; let invoicePipelineFilter = 'all'; async function renderInvoices(container) { container.innerHTML = `

Invoices

Track payments and get paid faster

`; try { const data = await api('/invoices'); invoiceList = data.invoices || []; } catch (e) { console.error(e); } renderInvoicePipeline(); container.querySelector('#invoiceListView').innerHTML = renderInvoiceList(); } function renderInvoicePipeline() { const counts = { all: invoiceList.length, draft: 0, sent: 0, viewed: 0, paid: 0, overdue: 0 }; invoiceList.forEach(inv => { const isOverdue = inv.status === 'overdue' || (inv.status === 'sent' && inv.due_date && new Date(inv.due_date) < new Date()); if (isOverdue) counts.overdue++; if (counts[inv.status] !== undefined) counts[inv.status]++; }); const steps = [ { key: 'all', label: 'All', icon: '📄' }, { key: 'draft', label: 'Draft', icon: '✏️' }, { key: 'sent', label: 'Sent', icon: '📤' }, { key: 'paid', label: 'Paid', icon: '💰' }, { key: 'overdue', label: 'Overdue', icon: '⚠️' }, ]; const el = document.getElementById('invoicePipeline'); if (!el) return; el.innerHTML = steps.map((s, i) => ` ${i > 0 ? '›' : ''}
${s.icon} ${s.label} ${counts[s.key] || 0}
`).join(''); } function filterInvoicePipeline(status) { invoicePipelineFilter = status; renderInvoicePipeline(); document.querySelector('#invoiceListView').innerHTML = renderInvoiceList(); } function renderInvoiceList() { let filtered = invoiceList; if (invoicePipelineFilter !== 'all') { if (invoicePipelineFilter === 'overdue') { filtered = invoiceList.filter(inv => inv.status === 'overdue' || (inv.status === 'sent' && inv.due_date && new Date(inv.due_date) < new Date())); } else { filtered = invoiceList.filter(inv => inv.status === invoicePipelineFilter); } } if (!filtered.length) { const isEmpty = !invoiceList.length; return `
📄

${isEmpty ? 'No invoices yet' : 'No invoices in this status'}

${isEmpty ? 'Create an invoice or convert an approved estimate.' : 'Invoices will appear here as they move through stages.'}

${isEmpty ? `` : ''}
`; } const statusMap = { draft: ['Draft','var(--text-dim)','var(--surface-3)'], sent: ['Sent','var(--warning)','var(--warning-dim)'], viewed: ['Viewed','var(--purple)','var(--purple-dim)'], paid: ['Paid','var(--accent)','var(--accent-dim)'], overdue: ['Overdue','var(--danger)','var(--danger-dim)'], cancelled: ['Cancelled','var(--text-dim)','var(--surface-3)'] }; // Calculate totals for the summary bar const totalAmount = filtered.reduce((sum, inv) => sum + parseFloat(inv.total || 0), 0); const paidAmount = filtered.filter(i => i.status === 'paid').reduce((sum, inv) => sum + parseFloat(inv.total || 0), 0); const outstandingAmount = totalAmount - paidAmount; let summaryHtml = ''; if (filtered.length > 1) { summaryHtml = `
Total
$${totalAmount.toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})}
Collected
$${paidAmount.toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})}
Outstanding
$${outstandingAmount.toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})}
`; } return summaryHtml + `
${filtered.map(inv => { const isOverdue = inv.status === 'overdue' || (inv.status === 'sent' && inv.due_date && new Date(inv.due_date) < new Date()); const effectiveStatus = isOverdue ? 'overdue' : inv.status; const [sLabel, sColor, sBg] = statusMap[effectiveStatus] || [inv.status,'var(--text-dim)','var(--surface-3)']; const dueDateStr = inv.due_date ? new Date(inv.due_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : null; return `
${escHtml(inv.customer_name)}
${escHtml(inv.invoice_number)}
${sLabel}
${inv.job_description ? `
${escHtml(inv.job_description)}
` : ''}
$${parseFloat(inv.total || 0).toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})}
${dueDateStr ? `
Due ${dueDateStr}
` : ''} ${inv.recurring ? `
🔁 ${inv.recurring_interval || 'Recurring'}
` : ''} ${!dueDateStr && !inv.recurring ? `${timeAgo(inv.created_at)}` : ''}
`; }).join('')}
`; } async function openInvoiceModal() { const modal = createModal('New Invoice', `
`, [ { label: 'Cancel', fn: closeModal }, { label: 'Create Invoice', fn: createNewInvoice, primary: true } ]); document.body.appendChild(modal); } async function createNewInvoice() { const customer = document.getElementById('inv-customer')?.value?.trim(); if (!customer) { toast('Customer name is required', 'error'); return; } try { const data = await api('/invoices', { method: 'POST', body: { customer_name: customer, customer_email: document.getElementById('inv-email')?.value?.trim() || null, customer_phone: document.getElementById('inv-phone')?.value?.trim() || null, job_description: document.getElementById('inv-description')?.value?.trim() || null, due_days: parseInt(document.getElementById('inv-due-days')?.value) || 30, tax_rate: parseFloat(document.getElementById('inv-tax-rate')?.value) || 0, recurring: !!document.getElementById('inv-recurring')?.value, recurring_interval: document.getElementById('inv-recurring')?.value || null, } }); closeModal(); toast('Invoice created', 'success'); await openInvoiceBuilder(data.invoice.id); } catch (e) { toast(e.message || 'Failed to create invoice', 'error'); } } async function openInvoiceBuilder(id) { try { const data = await api(`/invoices/${id}`); activeInvoice = data.invoice; renderInvoiceBuilder(document.getElementById('mainContent')); } catch (e) { toast('Failed to load invoice', 'error'); } } function renderInvoiceBuilder(container) { const inv = activeInvoice; if (!inv) return; const isEditable = inv.status === 'draft' || inv.status === 'sent'; const statusColor = { draft: '#555', sent: '#f5a623', viewed: '#6366f1', paid: '#22d67a', overdue: '#ff4d4d', cancelled: '#999' }[inv.status] || '#555'; const statusLabel = { draft: 'Draft', sent: 'Sent — Awaiting Payment', viewed: 'Viewed', paid: '✓ Paid', overdue: '⚠ Overdue', cancelled: 'Cancelled' }[inv.status] || inv.status; const dueDateStr = inv.due_date ? new Date(inv.due_date).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }) : null; container.innerHTML = `

${escHtml(inv.customer_name)}

${escHtml(inv.invoice_number)} · ${statusLabel}
${inv.status !== 'paid' && inv.status !== 'cancelled' && inv.customer_email ? `` : ''} ${inv.status === 'draft' ? `` : ''} ${inv.status === 'draft' || inv.status === 'sent' ? `` : ''} ${inv.status === 'draft' || inv.status === 'sent' || inv.status === 'overdue' ? `` : ''}
${inv.status === 'paid' ? `
✓ PAID ${inv.paid_at ? `

Paid on ${new Date(inv.paid_at).toLocaleDateString()}

` : ''} ${inv.paid_amount ? `

Amount: $${parseFloat(inv.paid_amount).toFixed(2)}

` : ''}
` : ''} ${inv.stripe_payment_link_url && inv.status !== 'paid' ? `
Payment Link Ready
Share this link with your customer to collect payment via Stripe
Open
` : ''}
Customer & Job
${isEditable ? `
` : `
${inv.customer_email ? `
${escHtml(inv.customer_email)}
` : ''} ${inv.customer_phone ? `
${escHtml(inv.customer_phone)}
` : ''} ${inv.job_description ? `
${escHtml(inv.job_description)}
` : ''} ${inv.job_address ? `
${escHtml(inv.job_address)}
` : ''}
`}
Line Items (${(inv.items||[]).length})
${isEditable ? `` : ''}
${(inv.items||[]).map((item, i) => `
${isEditable ? `
$${parseFloat(item.total||0).toFixed(2)}
` : `
${escHtml(item.description)}${item.category}
${item.quantity} × $${parseFloat(item.unit_price||0).toFixed(2)}
$${parseFloat(item.total||0).toFixed(2)}
`}
`).join('')} ${!(inv.items||[]).length ? `

No line items yet.

` : ''}
${isEditable ? `` : ''}
Invoice Total
Subtotal$${parseFloat(inv.subtotal||0).toFixed(2)}
${inv.tax_rate > 0 ? `
Tax (${(parseFloat(inv.tax_rate)*100).toFixed(1)}%)$${parseFloat(inv.tax_amount||0).toFixed(2)}
` : ''}
Total$${parseFloat(inv.total||0).toFixed(2)}
${isEditable ? `
` : ''}
Payment
${inv.due_date ? `
Due Date${dueDateStr}
` : ''} ${inv.recurring ? `
🔁 ${inv.recurring_interval || 'Recurring'}
` : ''} ${inv.stripe_payment_link_url ? `
Payment Link ↗
` : ''}
${inv.notes || isEditable ? `
Notes
${isEditable ? ` ` : `

${escHtml(inv.notes)}

`}
` : ''}
`; } // Invoice builder helpers window.addInvoiceItem = function() { if (!activeInvoice.items) activeInvoice.items = []; activeInvoice.items.push({ category: 'labor', description: '', quantity: 1, unit_price: 0, total: 0 }); renderInvoiceBuilder(document.getElementById('mainContent')); }; window.updateInvoiceItem = function(idx, field, val) { const item = activeInvoice.items[idx]; if (field === 'quantity' || field === 'unit_price') item[field] = parseFloat(val) || 0; else item[field] = val; item.total = item.quantity * item.unit_price; }; window.removeInvoiceItem = function(idx) { activeInvoice.items.splice(idx, 1); renderInvoiceBuilder(document.getElementById('mainContent')); }; window.saveInvoiceInfo = async function() { try { await api(`/invoices/${activeInvoice.id}`, { method: 'PUT', body: { customer_name: document.getElementById('inv-customer-name')?.value, customer_email: document.getElementById('inv-customer-email')?.value, customer_phone: document.getElementById('inv-customer-phone')?.value, job_description: document.getElementById('inv-job-description')?.value, } }); toast('Saved', 'success'); } catch (e) { toast('Failed to save', 'error'); } }; window.saveInvoiceNotes = async function() { try { await api(`/invoices/${activeInvoice.id}`, { method: 'PUT', body: { notes: document.getElementById('inv-notes')?.value } }); toast('Notes saved', 'success'); } catch (e) { toast('Failed to save notes', 'error'); } }; window.updateInvoiceTax = async function(pct) { const rate = parseFloat(pct) / 100; const subtotal = activeInvoice.items?.reduce((s, it) => s + parseFloat(it.total || 0), 0) || 0; const taxAmt = subtotal * rate; const total = subtotal + taxAmt; await api(`/invoices/${activeInvoice.id}`, { method: 'PUT', body: { tax_rate: rate, items: activeInvoice.items } }); await refreshInvoice(); }; window.createInvoicePaymentLink = async function() { try { const data = await api(`/invoices/${activeInvoice.id}/create-payment-link`, { method: 'POST' }); activeInvoice.stripe_payment_link_url = data.payment_link_url; activeInvoice.stripe_payment_link_id = data.payment_link_id; renderInvoiceBuilder(document.getElementById('mainContent')); toast('Payment link created!', 'success'); } catch (e) { toast(e.message || 'Failed to create payment link', 'error'); } }; window.copyPaymentLink = function() { if (activeInvoice.stripe_payment_link_url) { navigator.clipboard.writeText(activeInvoice.stripe_payment_link_url).then(() => toast('Link copied!', 'success')); } }; window.sendInvoice = async function() { if (!activeInvoice.customer_email) { toast('No customer email set', 'error'); return; } try { await api(`/invoices/${activeInvoice.id}/send`, { method: 'POST' }); toast('Invoice sent!', 'success'); await refreshInvoice(); } catch (e) { toast(e.message || 'Failed to send invoice', 'error'); } }; window.markInvoicePaid = async function() { if (!confirm('Mark this invoice as paid?')) return; try { await api(`/invoices/${activeInvoice.id}/paid`, { method: 'POST' }); toast('Marked as paid', 'success'); await refreshInvoice(); } catch (e) { toast(e.message || 'Failed', 'error'); } }; window.cancelInvoice = async function() { if (!confirm('Cancel this invoice?')) return; try { await api(`/invoices/${activeInvoice.id}/cancel`, { method: 'POST' }); toast('Invoice cancelled', 'success'); await refreshInvoice(); } catch (e) { toast(e.message || 'Failed', 'error'); } }; async function refreshInvoice() { const data = await api(`/invoices/${activeInvoice.id}`); activeInvoice = data.invoice; renderInvoiceBuilder(document.getElementById('mainContent')); } async function backToInvoiceList() { activeInvoice = null; invoiceList = []; await renderInvoices(document.getElementById('mainContent')); } // ═══════════════════════════════════════════════════════════ // Billing Page — plan card + add-ons grid // ═══════════════════════════════════════════════════════════ async function renderBilling(container) { const user = window.CURRENT_USER || {}; const planLabel = { starter: 'Starter', pro: 'Pro', starter_annual: 'Starter (Annual)', pro_annual: 'Pro (Annual)', free: 'Free', enterprise: 'Enterprise' }[user.plan] || user.plan || 'Free'; const planColor = { starter: '#4d9fff', pro: '#a78bfa', starter_annual: '#4d9fff', pro_annual: '#a78bfa', free: '#7a7a9a', enterprise: '#f59e0b' }[user.plan] || '#7a7a9a'; const isAnnual = user.plan?.includes('annual'); let addonStatus = {}; let addonPrices = []; let checkoutLoading = null; let successAddon = new URLSearchParams(window.location.search).get('addon_success'); if (successAddon) history.replaceState({}, '', '/app'); try { const [statusRes, pricesRes] = await Promise.all([ api('/addons/status'), api('/addons/prices'), ]); addonStatus = statusRes; addonPrices = pricesRes.addons || []; } catch(e) { console.error(e); } // Map API status keys to addon keys const statusMap = { extra_seats: 'extra_seats', additional_phone: 'additional_phones', sms_automation: 'sms_automation', vehicle_module: 'vehicle_module', priority_support: 'priority_support' }; const addonCards = addonPrices.map(a => { const count = addonStatus[statusMap[a.key]] ?? (a.variable_qty ? 1 : 0); const isActive = a.variable_qty ? count >= (a.min_qty || 1) : count === true; const hasStripe = !!addonStatus.stripe_customer_id; // Pulse animation for qualifying pain point match const painPoints = user.icp_pain_points || ''; const isHighlighted = ( (a.key === 'sms_automation' && painPoints.includes('follow-up')) || (a.key === 'sms_automation' && painPoints.includes('no show')) || (a.key === 'extra_seats' && painPoints.includes('team')) ); const pulseStyle = isHighlighted ? 'animation:add-on-pulse 2s ease-in-out infinite' : ''; const badgeHtml = isActive ? `Active` : ''; const qtyHtml = a.variable_qty ? `
${count} × $${a.price_monthly}/mo
` : ''; const ctaDisabled = !hasStripe ? 'disabled' : ''; const ctaLabel = isActive ? 'Manage' : 'Add to plan'; const ctaClass = isActive ? 'btn-outline' : 'btn-primary'; return `
${a.icon}
${a.name} ${badgeHtml}
$${a.price_monthly}/mo

${a.voice_description}

${qtyHtml}
`; }).join(''); const renewalDate = user.current_period_end ? new Date(user.current_period_end * 1000).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) : 'N/A'; container.innerHTML = `

Billing

Your plan, add-ons, and account settings

${successAddon ? `
✅ Add-on activated! Refresh to see it in your account.
` : ''}
Current Plan
${planLabel} ${user.subscription_status === 'active' ? 'Active' : user.subscription_status || 'Inactive'}
${isAnnual ? 'Billed annually' : 'Billed monthly'} · Renews ${renewalDate}
$${ user.plan === 'pro' || user.plan === 'pro_annual' ? (isAnnual ? '$2,490' : '$249') : user.plan === 'starter' || user.plan === 'starter_annual' ? (isAnnual ? '$990' : '$99') : '$0' }/${isAnnual ? 'yr' : 'mo'}
${isAnnual ? '
2 months free!
' : ''}

Add-Ons

Supercharge your account. Each add-on is a monthly add-on to your subscription.

${!addonStatus.stripe_customer_id ? `
⚠️ You need an active subscription before adding add-ons. Subscribe here →
` : ''}
${addonCards}
`; // Add keyframe for pulse animation if (!document.getElementById('addon-pulse-style')) { const style = document.createElement('style'); style.id = 'addon-pulse-style'; style.textContent = ` @keyframes add-on-pulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(99,102,241,0); } 50% { box-shadow: 0 0 0 6px rgba(99,102,241,0.25); } } `; document.head.appendChild(style); } } // Billing page helpers window.billingAdjustQty = function(key, delta, min, max) { const el = document.getElementById('qty-' + key); if (!el) return; const cur = parseInt(el.textContent, 10) || min; const next = Math.min(max, Math.max(min, cur + delta)); el.textContent = next; }; window.billingAddAddon = async function(key, btn) { if (btn.disabled) return; const qtyEl = document.getElementById('qty-' + key); const qty = qtyEl ? parseInt(qtyEl.textContent, 10) : 1; btn.disabled = true; btn.textContent = 'Loading…'; try { const res = await api('/addons/checkout', { method: 'POST', body: { addon_key: key, quantity: qty } }); if (res.checkout_url) { window.location.href = res.checkout_url; } else { toast(res.error || 'Failed to start checkout', 'error'); btn.disabled = false; btn.textContent = '✚ Add to plan'; } } catch(e) { toast('Checkout failed. Please try again.', 'error'); btn.disabled = false; btn.textContent = '✚ Add to plan'; } }; window.openBillingPortal = async function() { try { const res = await api('/addons/portal', { method: 'POST' }); if (res.portal_url) window.location.href = res.portal_url; else toast(res.error || 'Failed to open portal', 'error'); } catch(e) { toast('Failed to open Stripe portal', 'error'); } }; // Modal helpers function createModal(title, html, buttons = []) { const overlay = document.createElement('div'); overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:1000;display:flex;align-items:center;justify-content:center;padding:20px'; const box = document.createElement('div'); box.style.cssText = 'background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:28px;width:100%;max-width:480px;max-height:90vh;overflow-y:auto'; box.innerHTML = `

${title}

${html}
`; const btnContainer = box.querySelector('#modal-btns'); buttons.forEach(b => { const btn = document.createElement('button'); btn.textContent = b.label; btn.style.cssText = b.primary ? 'background:var(--accent);color:#0a0a0b;border:none;border-radius:8px;padding:10px 20px;font-weight:600;cursor:pointer;font-size:14px' : 'background:transparent;color:var(--text-muted);border:1px solid var(--border);border-radius:8px;padding:10px 20px;cursor:pointer;font-size:14px'; btn.onclick = b.fn; btnContainer.appendChild(btn); }); overlay.appendChild(box); overlay.onclick = (e) => { if (e.target === overlay) closeModal(); }; return overlay; } window.createModal = createModal; window.closeModal = function() { document.querySelectorAll('[style*="z-index:1000"]').forEach(el => el.remove()); }; // ── Welcome Onboarding Overlay ──────────────────────────── // Session token: read from URL param, persist to localStorage, clean URL const SESSION_TOKEN = (() => { const sp = new URLSearchParams(window.location.search); const fromUrl = sp.get('_st'); if (fromUrl) { // Persist token so page survives refresh/bookmark/direct navigation try { localStorage.setItem('outmatch_st', fromUrl); } catch (e) {} // Clean _st from URL bar to avoid token leaking in bookmarks/history sp.delete('_st'); const clean = sp.toString(); const newUrl = window.location.pathname + (clean ? '?' + clean : '') + window.location.hash; window.history.replaceState(null, '', newUrl); return fromUrl; } // Fallback: retrieve from localStorage (bookmark / direct navigation / refresh after URL cleaned) try { return localStorage.getItem('outmatch_st') || ''; } catch (e) { return ''; } })(); function sessionHeader() { return SESSION_TOKEN ? { 'x-session-token': SESSION_TOKEN } : {}; } // Expose on window so chat widget IIFE and any late-loaded scripts can read it. // const is not a window property; this bridges the gap without rewriting the declaration. window.SESSION_TOKEN = SESSION_TOKEN; // Redirect to login if no session token at all — prevents broken dashboard if (!SESSION_TOKEN) { window.location.href = '/login'; } async function initOnboarding() { if (!SESSION_TOKEN) return; // No session — user not logged in try { // Check first-login welcome state + load 5 prospects from DB const tourDone = localStorage.getItem('welcome_tour_done') === 'true'; const wres = await fetch('/api/welcome?tour_done=' + tourDone, { headers: { 'Content-Type': 'application/json', ...sessionHeader() } }); if (!wres.ok) return; const wdata = await wres.json(); if (!wdata.success) return; // Show founder welcome card + pre-loaded prospects on first login if (wdata.show_welcome && wdata.prospects && wdata.prospects.length > 0) { showFounderCard(wdata.prospects); } // Show 3-step tour on first visit (after the card CTA is clicked or auto after 5s) if (wdata.show_welcome && !tourDone) { setTimeout(() => { if (!document.getElementById('founderCard')) return; // user already dismissed autoShow3StepTour(wdata.prospects); }, 5000); } } catch (e) { // Silent — don't break the dashboard on error } } async function showFounderCard(prospects) { // Inject the card at the top of mainContent, just below the header area const existing = document.getElementById('founderCard'); if (existing) return; // already shown const container = document.getElementById('mainContent'); if (!container) return; const prospectRows = prospects.slice(0, 3).map((p, i) => `
${i + 1}
${escapeHtml(p.name)}
${escapeHtml(p.role)} · ${escapeHtml(p.company)}
`).join(''); const card = document.createElement('div'); card.id = 'founderCard'; card.innerHTML = `
👊
Welcome to the family.
You don't have time to chase leads — that's my job now. Here's how we win.
5 prospects loaded, ready to contact:
${prospectRows}
Not now
— Tom, Founder @ Outmatch
`; // Insert at top of mainContent if (container.firstChild) { container.insertBefore(card, container.firstChild); } else { container.appendChild(card); } } function dismissFounderCard() { const card = document.getElementById('founderCard'); if (card) { card.style.opacity = '0'; card.style.transition = 'opacity 0.25s ease'; setTimeout(() => card.remove(), 250); } localStorage.setItem('welcome_tour_done', 'true'); fetch('/api/welcome/dismiss', { method: 'POST', headers: { 'Content-Type': 'application/json', ...sessionHeader() } }).catch(() => {}); } function autoShow3StepTour(prospects) { if (localStorage.getItem('welcome_tour_done') === 'true') return; start3StepTour(prospects); } let _tourStep = 0; let _tourInterval = null; function start3StepTour(prospects) { // Dismiss founder card first const fc = document.getElementById('founderCard'); if (fc) { fc.style.opacity = '0'; fc.style.transition = 'opacity 0.25s ease'; setTimeout(() => fc.remove(), 250); } const overlay = document.getElementById('welcomeOverlay'); const steps = [ { label: '1 / 3', icon: '🎯', iconClass: 'green', title: 'Your prospects, loaded and ready.', body: 'These 5 are already matched to your business. Verified emails, personalized angles — pick 3 and launch.', }, { label: '2 / 3', icon: '📬', iconClass: 'blue', title: 'Launch outreach in one click.', body: 'Head to Campaigns, paste these URLs, and we write the emails for you. Follow-ups go out Day 3 and Day 7 automatically.', }, { label: '3 / 3', icon: '🚀', iconClass: 'purple', title: 'Add-ons when you scale.', body: 'More seats, phone lines, SMS automation, priority support — all available from your account settings whenever you need them.', }, ]; _tourStep = 0; function renderStep() { const s = steps[_tourStep]; const dots = steps.map((_, i) => `
`).join(''); const isLast = _tourStep === steps.length - 1; overlay.innerHTML = `
${dots}
${s.icon}
${s.label}
${s.title}
${s.body}
${isLast ? '' : ''}
${isLast ? '' : ''}
`; overlay.style.display = 'flex'; document.body.style.overflow = 'hidden'; clearInterval(_tourInterval); if (!isLast) { _tourInterval = setInterval(() => { if (_tourStep < steps.length - 1) { _tourStep++; renderStep(); } else clearInterval(_tourInterval); }, 7000); } } renderStep(); window.tourNextStep = function() { if (_tourStep < steps.length - 1) { _tourStep++; renderStep(); } }; } async function dismiss3StepTour() { clearInterval(_tourInterval); localStorage.setItem('welcome_tour_done', 'true'); const overlay = document.getElementById('welcomeOverlay'); overlay.style.opacity = '0'; overlay.style.transition = 'opacity 0.25s ease'; setTimeout(() => { overlay.style.display = 'none'; overlay.innerHTML = ''; document.body.style.overflow = ''; }, 250); fetch('/api/welcome/dismiss', { method: 'POST', headers: { 'Content-Type': 'application/json', ...sessionHeader() } }).catch(() => {}); } function launchFirstCampaign() { clearInterval(_tourInterval); localStorage.setItem('welcome_tour_done', 'true'); const overlay = document.getElementById('welcomeOverlay'); overlay.style.opacity = '0'; overlay.style.transition = 'opacity 0.25s ease'; setTimeout(() => { overlay.style.display = 'none'; overlay.innerHTML = ''; document.body.style.overflow = ''; }, 250); navigateTo('campaigns'); fetch('/api/welcome/dismiss', { method: 'POST', headers: { 'Content-Type': 'application/json', ...sessionHeader() } }).catch(() => {}); } window.start3StepTour = start3StepTour; window.dismissFounderCard = dismissFounderCard; window.dismiss3StepTour = dismiss3StepTour; window.launchFirstCampaign = launchFirstCampaign; window.tourNextStep = () => { if (typeof window.tourNextStepFn === 'function') window.tourNextStepFn(); }; /* Interactive onboarding walkthrough — replaces static video placeholder. Slides auto-advance every 8s, user can navigate manually. Tier-aware: Pro/Enterprise/paid plans see extra SMS/Voice slides. */ let _wtInterval = null; function buildWalkthroughSlides(plan) { const isPaid = plan === 'starter' || plan === 'starter_annual' || plan === 'pro' || plan === 'pro_annual' || plan === 'growth' || plan === 'enterprise'; const isPro = plan === 'pro' || plan === 'pro_annual' || plan === 'growth' || plan === 'enterprise'; // Voice is available on all paid plans (starter+); SMS is available on pro+ const totalSteps = isPro ? 6 : (isPaid ? 5 : 4); const slides = [ { icon: '👋', iconClass: 'green', step: 'Welcome', title: 'Welcome to Outmatch', body: 'Your AI sales team finds leads, writes personalized emails, and books meetings — so you can focus on closing deals.', tip: 'This 60-second walkthrough shows you how everything works.' }, { icon: '🏠', iconClass: 'blue', step: 'Step 1 of ' + totalSteps, title: 'Your Dashboard', body: 'The Home tab is your command center. You\'ll see prospects found, emails sent, and meetings booked — all in real time.', tip: 'Check Home daily to see your AI\'s progress.' }, { icon: '🎯', iconClass: 'green', step: 'Step 2', title: 'Set Up Your Ideal Customer', body: 'Go to Settings and describe your business. Set your industry, company size, location, and target job title. The more specific you are, the better your leads.', tip: 'Do this first — it takes 2 minutes and makes everything smarter.' }, { icon: '🔍', iconClass: 'purple', step: 'Step 3', title: 'AI Finds Your Prospects', body: 'Open AI Prospects to see leads matched to your profile. Use thumbs up/down to train the AI. Better feedback = better leads over time.', tip: 'The AI improves with every rating you give.' }, { icon: '📬', iconClass: 'amber', step: 'Step 4', title: 'Email Outreach on Autopilot', body: 'Go to Compose to generate personalized emails. Hit send and they appear in Outreach. Follow-ups go out automatically on Day 3 and Day 7.', tip: 'Track opens and clicks in the Analytics tab.' } ]; if (isPaid) { slides.push({ icon: '📞', iconClass: 'pink', step: 'Step 5', title: 'AI Voice Agent', body: 'Go to AI Voice and enter your business name + industry. You\'ll get a phone number that answers inbound calls 24/7, qualifies leads, and books appointments.', tip: 'Forward your business line to this number for instant coverage.' }); } if (isPro) { slides.push({ icon: '💬', iconClass: 'blue', step: 'Step 6', title: 'AI Texter (SMS)', body: 'Open AI Texter to start SMS conversations with prospects. The AI texts them, handles replies, and books meetings — all automated, 24/7.', tip: 'Prospects can reply STOP anytime. Fully TCPA compliant.' }); } slides.push({ icon: '💡', iconClass: 'green', step: 'You\'re all set!', title: 'Need Help? We\'re Here.', body: 'Click the green chat bubble in the bottom-right corner anytime. Our AI assistant can answer questions, or you can reach a real person.', tip: 'You can replay this walkthrough anytime from Settings.' }); return slides; } function showWelcomeOverlay(user, isReplay) { const plan = user.plan || 'starter'; const isPro = plan === 'pro' || plan === 'pro_annual' || plan === 'growth' || plan === 'enterprise'; const planLabel = { starter: 'Starter', starter_annual: 'Starter', pro: 'Pro', pro_annual: 'Pro', growth: 'Pro', enterprise: 'Enterprise' }[plan] || 'Starter'; const slides = buildWalkthroughSlides(plan); let currentSlide = 0; const overlay = document.getElementById('welcomeOverlay'); const dotsHtml = slides.map((_, i) => `` ).join(''); const slidesHtml = slides.map((s, i) => `
${s.step}
${s.icon}

${s.title}

${s.body}

${s.tip ? `
${s.tip}
` : ''}
`).join(''); overlay.innerHTML = `
${planLabel} Plan — Active

Getting Started

A quick walkthrough to get you up and running.

${slidesHtml}
${dotsHtml}
`; overlay.style.display = 'flex'; document.body.style.overflow = 'hidden'; // Slide navigation logic window.goToWtSlide = function(target) { const allSlides = document.querySelectorAll('#wtSlideArea .wt-slide'); const dots = document.querySelectorAll('#wtDots .wt-dot'); let next; if (target === 'prev') next = Math.max(0, currentSlide - 1); else if (target === 'next') next = Math.min(slides.length - 1, currentSlide + 1); else next = target; if (next === currentSlide) return; allSlides[currentSlide].classList.remove('active'); allSlides[currentSlide].classList.add('exit-left'); setTimeout(() => allSlides[currentSlide === next ? 0 : currentSlide].classList.remove('exit-left'), 350); currentSlide = next; allSlides[currentSlide].classList.add('active'); dots.forEach((d, i) => d.classList.toggle('active', i === currentSlide)); document.getElementById('wtProgress').style.width = ((currentSlide + 1) / slides.length * 100) + '%'; document.getElementById('wtPrev').disabled = currentSlide === 0; const isLast = currentSlide === slides.length - 1; document.getElementById('wtNext').textContent = isLast ? '' : 'Next →'; document.getElementById('wtNext').disabled = isLast; document.getElementById('wtCta').textContent = isLast ? 'Go to my dashboard →' : 'Skip walkthrough'; // Reset auto-advance timer on manual nav clearInterval(_wtInterval); _wtInterval = setInterval(() => { if (currentSlide < slides.length - 1) window.goToWtSlide('next'); else clearInterval(_wtInterval); }, 8000); }; // Auto-advance every 8 seconds _wtInterval = setInterval(() => { if (currentSlide < slides.length - 1) window.goToWtSlide('next'); else clearInterval(_wtInterval); }, 8000); } async function dismissWelcome() { clearInterval(_wtInterval); const overlay = document.getElementById('welcomeOverlay'); overlay.style.opacity = '0'; overlay.style.transition = 'opacity 0.25s ease'; setTimeout(() => { overlay.style.display = 'none'; overlay.innerHTML = ''; document.body.style.overflow = ''; }, 250); // Mark seen in background — non-blocking if (SESSION_TOKEN) { fetch('/api/onboarding/welcome-seen', { method: 'POST', headers: { 'Content-Type': 'application/json', ...sessionHeader() } }).catch(() => {}); } } /* Replay walkthrough from Settings — shows the 3-step tour */ async function replayWalkthrough() { try { const res = await fetch('/api/auth/me', { headers: { 'Content-Type': 'application/json', ...sessionHeader() } }); if (!res.ok) return; const data = await res.json(); if (!data.success || !data.user) return; // Replay uses the same 3-step tour but skips the founder card start3StepTour(); } catch (e) { toast('Could not load walkthrough'); } } // ── Init ────────────────────────────────────────────────── // Only render dashboard if we have a session; otherwise redirect is already in progress if (SESSION_TOKEN) { renderPage(); initOnboarding(); }
Outmatch Support
AI · always on
24/7 Help
Step 1 of 3

Get your business phone number

Your AI agent needs a number to answer calls on. We'll grab one in your area code right now.

—
Step 2 of 3

Set up your voice agent

Pick a voice and tell us your business name. Preview it with your actual name.

Step 3 of 3

Hear it live

We'll call your cell right now. Your AI will introduce itself as your business — in 30 seconds you'll know exactly what your customers hear.

Skip — I'll test later
Almost done

That's what your customers will hear.

Ready to go live? Forward your existing business line to your new number — takes 60 seconds.

Your Outmatch number —

Select your carrier for exact steps:

I'll forward it later