Quick Add

What would you like to do?

`; } // Intercept public route on load if (checkPublicRoute()) return; // No further app logic needed on public view // ── SEND DRAWER — universal send flow ───────────────────────────── function openSendFlow(kind, data) { // kind: 'invoice' | 'quote' | 'client' | 'job' // data: { client, doc (inv/quote/job), link } const c = data.client || {}; const doc = data.doc || {}; const link = data.link || ''; const firstName = (c.name || 'there').split(' ')[0]; const BIZ = window._orgProfile?.companyName || 'Our Team'; const SERVICE = window._orgProfile?.serviceLabel || 'service'; const money = doc.grand || doc.total || doc.total_amount || 0; const amt = money ? ('$' + Number(money).toFixed(2)) : ''; const num = doc.num || doc.number || ''; let templates = []; if (kind === 'invoice') { templates = [ { label: 'Invoice ready', body: `Hi ${firstName}, your invoice${num?' #'+String(num).padStart(4,'0'):''}${amt?' for '+amt:''} is ready. View & pay here: ${link} — ${BIZ}` }, { label: 'Payment reminder', body: `Hi ${firstName}, a friendly reminder about your invoice${amt?' ('+amt+')':''}. You can view it here: ${link} — Thanks, ${BIZ}` }, { label: 'Overdue notice', body: `Hi ${firstName}, your invoice${amt?' for '+amt:''} is overdue. Please settle at your earliest convenience: ${link} — ${BIZ}` } ]; } else if (kind === 'quote') { templates = [ { label: "Here's your quote", body: `Hi ${firstName}! Your quote${amt?' of '+amt:''} is ready — view & accept here: ${link} Let me know if any questions! — ${BIZ}` }, { label: 'Follow-up', body: `Hi ${firstName}, just checking in on your quote: ${link} — We'd love to get started whenever you're ready. — ${BIZ}` }, { label: 'Expires soon', body: `Hi ${firstName}, your quote expires soon. Lock it in here: ${link} — ${BIZ}` } ]; } else if (kind === 'job') { const jobDate = doc.date ? new Date(doc.date+'T12:00:00').toLocaleDateString('en-CA',{month:'short',day:'numeric'}) : ''; templates = [ { label: 'Day-before reminder', body: `Hi ${firstName}! Quick reminder — your ${SERVICE} is tomorrow${jobDate?' ('+jobDate+')':''}. We'll be there! — ${BIZ}` }, { label: 'On my way', body: `Good morning ${firstName}! Heading your way now for the ${SERVICE}. See you soon! — ${BIZ}` }, { label: 'Job complete', body: `Hi ${firstName}, all done! Thanks again for having us out. Any feedback welcome. — ${BIZ}` } ]; } else { templates = [ { label: 'Say hi', body: `Hi ${firstName}, thanks for choosing ${BIZ}! Reach out any time.` }, { label: 'Request review', body: `Hi ${firstName}, hope everything looked great. If you have a minute, we'd love a quick review! — ${BIZ}` } ]; } showSendModal({ kind, client: c, templates, link }); } window.openSendFlow = openSendFlow; function showSendModal({ kind, client, templates, link }) { closeSendModal(); const phone = (client.phone||'').replace(/\D/g,''); const email = (client.email && client.email !== '—') ? client.email : ''; const BIZ = window._orgProfile?.companyName || 'Message'; const bd = document.createElement('div'); bd.id = 'v10-send-bd'; bd.style.cssText = 'position:fixed;inset:0;background:rgba(3,5,10,0.72);backdrop-filter:blur(14px);z-index:10200;display:flex;align-items:center;justify-content:center;padding:20px;font-family:Geist,sans-serif;animation:v10FadeIn .18s;'; bd.onclick = (e) => { if (e.target === bd) closeSendModal(); }; const card = document.createElement('div'); card.style.cssText = 'width:min(560px,100%);background:linear-gradient(180deg,#0d1220,#090c18);border:1px solid rgba(91,124,255,0.25);border-radius:22px;box-shadow:0 40px 100px -20px rgba(0,0,0,0.9);overflow:hidden;animation:v10ModalIn .25s cubic-bezier(.2,.8,.2,1);'; const title = kind === 'invoice' ? 'Send Invoice' : kind === 'quote' ? 'Send Quote' : kind === 'job' ? 'Message Client' : 'Send Message'; const subline = `To ${esc(client.name || 'client')}${phone?' · '+client.phone:''}${email?' · '+email:''}`; card.innerHTML = `
${kind}
${title}
${subline}
${link?`
🔗
Share link (public view)
`:''}
Choose a template
${templates.map((t,i) => `
${esc(t.label)}
${esc(t.body.slice(0,140))}${t.body.length>140?'…':''}
`).join('')}
Your message
${phone?``:''} ${email?``:''} ${!phone && !email ? `
No contact info — add phone/email to client first
` : ''}
`; bd.appendChild(card); document.body.appendChild(bd); // Wire up interactions const msgEl = card.querySelector('#v10-msg'); const updateLinks = () => { const txt = msgEl.value; const smsA = card.querySelector('#v10-send-sms'); const emA = card.querySelector('#v10-send-email'); if (smsA && phone) smsA.href = `sms:+1${phone}${/[?]/.test('sms:')?'&':'?'}body=${encodeURIComponent(txt)}`; if (emA && email) emA.href = `mailto:${email}?subject=${encodeURIComponent(BIZ + (kind==='invoice'?' — Invoice':kind==='quote'?' — Quote':''))}&body=${encodeURIComponent(txt)}`; }; msgEl.addEventListener('input', updateLinks); updateLinks(); // Template clicks card.querySelectorAll('.v10-tpl').forEach(t => { t.addEventListener('click', () => { const i = parseInt(t.dataset.idx, 10); msgEl.value = templates[i].body; card.querySelectorAll('.v10-tpl').forEach(x => { x.style.borderColor = 'rgba(255,255,255,0.07)'; x.style.background = 'rgba(255,255,255,0.03)'; }); t.style.borderColor = 'rgba(91,124,255,0.5)'; t.style.background = 'rgba(91,124,255,0.08)'; updateLinks(); }); }); card.querySelector('#v10-send-close').onclick = closeSendModal; card.querySelector('#v10-copy-msg').onclick = () => { navigator.clipboard.writeText(msgEl.value).then(() => toast('Message copied', 'success')); }; if (link) { card.querySelector('#v10-copy-link').onclick = () => { navigator.clipboard.writeText(link).then(() => toast('Link copied', 'success')); }; } // Also close on Esc document.addEventListener('keydown', escClose); } function escClose(e) { if (e.key === 'Escape') closeSendModal(); } function closeSendModal() { const bd = document.getElementById('v10-send-bd'); if (bd) bd.remove(); document.removeEventListener('keydown', escClose); } window.closeSendModal = closeSendModal; // ── HOOK IN — hijack invoice row "Text"/"Email" buttons ────────── // Existing rows already call openComms(). We ADD a Send Pro button next to them. function decorateInvoiceRows() { const rows = document.querySelectorAll('#page-invoices tbody tr'); rows.forEach(r => { // Find actions cell (last td with buttons) const actCell = r.querySelector('td:last-child .act-row') || r.querySelector('td:last-child'); if (!actCell) return; if (actCell.querySelector('.v10-send-pro')) return; const numCell = r.querySelector('td:first-child'); const numText = numCell?.textContent?.trim().replace(/^#/, '') || ''; const num = parseInt(numText, 10); const inv = _invoices().find(i => i.num === num); if (!inv) return; const client = _clients().find(c => c.id === inv.clientId); r.dataset.v10 = '1'; const btn = document.createElement('button'); btn.className = 'act v10-send-pro'; btn.title = 'Send with link'; btn.style.cssText = 'height:28px;padding:0 8px;background:linear-gradient(135deg,#5b7cff,#7c5cff);color:#fff;border:none;border-radius:6px;font-size:10px;font-weight:700;cursor:pointer;'; btn.innerHTML = '🔗 Send'; btn.onclick = async (e) => { e.stopPropagation(); btn.innerHTML = '⏳ …'; btn.disabled = true; try { const link = await buildPublicLink('inv', { num: inv.num, date: inv.date, service: inv.service || 'Professional services', total: inv.total, hst: inv.hst, grand: inv.grand||inv.total, payStatus: inv.payStatus, notes: inv.notes, clientName: client?.name, clientAddress: client?.address, clientPhone: client?.phone, clientEmail: client?.email }); openSendFlow('invoice', { client: client || {}, doc: inv, link }); } finally { btn.innerHTML = '🔗 Send'; btn.disabled = false; } }; actCell.insertBefore(btn, actCell.firstChild); }); } // Same for clients page — add "Send Quote" & "Send Invoice" quick actions function decorateClientRows() { const rows = document.querySelectorAll('#page-clients tbody tr'); rows.forEach(r => { // Find last cell with action row (adjust to your markup) const actCell = r.querySelector('td:last-child'); if (!actCell || actCell.querySelector('.v10-send-pro')) return; // Get client name to find record const nameTd = r.querySelector('td:nth-child(2)') || r.querySelectorAll('td')[1]; const name = nameTd?.textContent?.trim() || ''; const client = _clients().find(c => (c.name||'').trim() === name) || { name, phone: '', email: '' }; r.dataset.v10 = '1'; // Add single "Message" icon const btn = document.createElement('button'); btn.className = 'act v10-send-pro'; btn.title = 'Message (text or email)'; btn.style.cssText = 'height:28px;padding:0 8px;background:linear-gradient(135deg,#10b981,#059669);color:#fff;border:none;border-radius:6px;font-size:10px;font-weight:700;cursor:pointer;margin-right:4px;'; btn.innerHTML = '💬 Msg'; btn.onclick = (e) => { e.stopPropagation(); openSendFlow('client', { client, doc: {}, link: '' }); }; actCell.insertBefore(btn, actCell.firstChild); }); } // ── PUBLIC SHARE from quote builder — add a button on quotes page ─ function decorateQuotePage() { const page = document.getElementById('page-quotes'); if (!page) return; // If form and preview exist, add a "Send" pill at the top of preview const prevWrap = document.getElementById('qb-preview-wrap'); if (!prevWrap || prevWrap.querySelector('#v10-quote-share-bar')) return; const bar = document.createElement('div'); bar.id = 'v10-quote-share-bar'; bar.style.cssText = 'position:sticky;top:12px;z-index:5;display:flex;gap:8px;margin:0 auto 14px;width:max-content;background:rgba(0,0,0,0.55);backdrop-filter:blur(12px);padding:8px 10px;border-radius:14px;border:1px solid rgba(255,255,255,0.08);box-shadow:0 10px 30px rgba(0,0,0,0.5);'; bar.innerHTML = ` `; prevWrap.insertBefore(bar, prevWrap.firstChild); const getQBData = () => { const QB = window.QB || {}; const get = (id) => document.getElementById(id)?.value || ''; const activeSvcs = (QB.services||[]).filter(s => s.on); const subtotal = activeSvcs.reduce((t,s) => t + (s.price * s.qty), 0); const taxPct = parseFloat(get('qb-tax')) || 0; const taxAmt = subtotal * (taxPct/100); const disc = parseFloat(get('qb-disc')) || 0; const dep = parseFloat(get('qb-dep')) || 0; const total = Math.max(0, subtotal + taxAmt - disc); const grand = Math.max(0, total - dep); return { num: get('qb-num') || Math.floor(Math.random()*9000+1000), date: get('qb-date'), service: activeSvcs.map(s => s.name).join(', ') || 'Services', total: subtotal, hst: taxAmt, grand, payStatus: QB.mode==='invoice' ? (QB.invStatus||'unpaid') : (QB.quoteStatus||'pending'), notes: get('qb-thanks'), clientName: [get('qb-cl-first'),get('qb-cl-last')].filter(Boolean).join(' '), clientAddress: [get('qb-cl-addr'),get('qb-cl-city'),get('qb-cl-prov'),get('qb-cl-postal')].filter(Boolean).join(', '), clientPhone: get('qb-cl-phone'), clientEmail: get('qb-cl-email'), bizName: get('qb-biz-name'), bizPhone: get('qb-biz-phone'), bizEmail: get('qb-biz-email'), bizHst: get('qb-biz-hst'), bizEtransfer: window._orgProfile?.etransferEmail, bizColor: QB.color }; }; document.getElementById('v10-qbuild-send').onclick = async (e) => { const btn = e.currentTarget; const data = getQBData(); const t = (window.QB?.mode === 'invoice') ? 'inv' : 'quote'; btn.textContent = '⏳ Preparing…'; btn.disabled = true; try { const link = await buildPublicLink(t, data); openSendFlow(t === 'inv' ? 'invoice' : 'quote', { client: { name: data.clientName, phone: data.clientPhone, email: data.clientEmail }, doc: data, link }); } finally { btn.textContent = '🔗 Send to Client'; btn.disabled = false; } }; document.getElementById('v10-qbuild-share').onclick = async (e) => { const btn = e.currentTarget; const data = getQBData(); const t = (window.QB?.mode === 'invoice') ? 'inv' : 'quote'; btn.textContent = '⏳ …'; btn.disabled = true; try { const link = await buildPublicLink(t, data); await navigator.clipboard.writeText(link); toast('Public link copied — paste anywhere', 'success'); } finally { btn.textContent = '📋 Copy link'; btn.disabled = false; } }; document.getElementById('v10-qbuild-print').onclick = async (e) => { const btn = e.currentTarget; const data = getQBData(); const t = (window.QB?.mode === 'invoice') ? 'inv' : 'quote'; btn.textContent = '⏳ …'; btn.disabled = true; try { const link = await buildPublicLink(t, data); window.open(link, '_blank'); } finally { btn.textContent = '🖨️ Print'; btn.disabled = false; } }; } // ── COMMS PAGE POLISH: add "New message from scratch" + template shortcuts ── function polishCommsPage() { const page = document.getElementById('page-comms'); if (!page || page.dataset.v10poli === '1') return; if (getComputedStyle(page).display === 'none') return; const inner = document.getElementById('comms-page-inner'); if (!inner || !inner.textContent.trim()) return; page.dataset.v10poli = '1'; // Add a floating "New message" button if not present if (!document.getElementById('v10-comms-new')) { const btn = document.createElement('button'); btn.id = 'v10-comms-new'; btn.style.cssText = 'position:fixed;right:22px;bottom:92px;z-index:200;width:auto;height:46px;padding:0 22px;background:linear-gradient(135deg,#5b7cff,#7c5cff);color:#fff;border:none;border-radius:12px;font-family:Geist,sans-serif;font-size:13px;font-weight:700;cursor:pointer;box-shadow:0 12px 30px -10px rgba(91,124,255,0.7);'; btn.innerHTML = '+ New Message'; btn.onclick = () => { // Open send-flow but let user pick client first via simple prompt const clients = window.clients || []; const names = clients.map(c => c.name).join('\n'); const name = prompt('Client name to message:\n\n' + names); if (!name) return; const c = clients.find(x => (x.name||'').toLowerCase().includes(name.toLowerCase())); if (!c) { toast('No match — check spelling', 'warn'); return; } openSendFlow('client', { client: c, doc: {}, link: '' }); }; document.body.appendChild(btn); } } // ── BOOT ── function mount() { try { decorateInvoiceRows(); } catch (e) { console.warn('v10 inv', e); } try { decorateClientRows(); } catch (e) { console.warn('v10 cli', e); } try { decorateQuotePage(); } catch (e) { console.warn('v10 qb', e); } try { polishCommsPage(); } catch (e) { console.warn('v10 comms', e); } } // CSS for send modal animations const style = document.createElement('style'); style.textContent = ` @keyframes v10FadeIn { from { opacity: 0; } to { opacity: 1; } } @keyframes v10ModalIn { from { opacity: 0; transform: translateY(16px) scale(.97); } to { opacity: 1; transform: translateY(0) scale(1); } } .v10-tpl:hover { background: rgba(91,124,255,0.1) !important; border-color: rgba(91,124,255,0.35) !important; } #v10-bell-quotes-toggle { transition: all .2s; } `; document.head.appendChild(style); // Re-run on DOM mutation (throttled) let _mountQ = false; function _schedMount(){ if (_mountQ) return; _mountQ = true; requestAnimationFrame(() => { _mountQ = false; mount(); }); } const _obs = new MutationObserver(() => { _schedMount(); }); _obs.observe(document.body, { childList: true, subtree: true }); // Reload on share-route enter/exit window.addEventListener('hashchange', () => { const h = location.hash || ''; if (h.indexOf('#/p/') === 0 || h.indexOf('#/s/') === 0) location.reload(); }); // Hook app nav() so re-mount fires after page switch if (typeof window.nav === 'function' && !window.__v10NavHooked) { window.__v10NavHooked = true; const _origNav = window.nav; window.nav = function(p){ const r = _origNav.apply(this, arguments); setTimeout(_schedMount, 120); return r; }; } // Initial if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', _schedMount, { once: true }); else _schedMount(); // Expose for programmatic use + debug window.buildPublicLink = buildPublicLink; window.buildPublicLinkSync = buildPublicLinkSync; window.__docketV10Mount = mount; window.__docketV10ShortId = shortId; console.log('%c Docket v10 ready ', 'background:linear-gradient(135deg,#5b7cff,#7c5cff);color:#fff;padding:3px 8px;border-radius:4px;font-weight:600'); })();