K777 Mail
Campaign Platform
U
K777 Mail

Campaign

`; // Populate the viewer modal with preview data VIEWER_CAMPAIGN = { id: '__preview__', subject, body_html: html, body_text: body, expires_at: expiresAt, expired: false, sent_at: null, }; const overlay = document.getElementById('viewer-overlay'); const vpTitle = document.getElementById('vp-title'); const vpBody = document.getElementById('vp-body'); vpTitle.textContent = subject; vpBody.innerHTML = ''; // Compose info bar const infoBar = document.createElement('div'); infoBar.style.cssText = 'padding:12px 20px;background:#1D1D1B;color:rgba(255,255,255,.65);font-size:12px;line-height:1.6;flex-shrink:0'; infoBar.innerHTML = `
From:${esc(fromName || SESSION.name || SESSION.email)}
Subject:${esc(subject)}
${schedNote}${expNote}${attachNote}
Preview — not sent
`; vpBody.appendChild(infoBar); // Render email in iframe const iframe = document.createElement('iframe'); iframe.style.cssText = 'width:100%;border:none;display:block;min-height:400px;flex:1'; iframe.sandbox = 'allow-same-origin'; vpBody.appendChild(iframe); overlay.classList.remove('hid'); // Write HTML after iframe is in DOM requestAnimationFrame(() => { const doc = iframe.contentDocument || iframe.contentWindow.document; doc.open(); doc.write(html); doc.close(); setTimeout(() => { try { iframe.style.height = iframe.contentDocument.documentElement.scrollHeight + 'px'; } catch(_){} }, 150); }); } async function sendPreview() { const subject = document.getElementById('c-subject')?.value.trim(); const body = document.getElementById('c-body')?.value.trim(); if (!subject || !body) return toast('Fill in subject and body first', 'err'); try { const fd = new FormData(); fd.append('to', SESSION.email); fd.append('subject', '[PREVIEW] ' + subject); fd.append('body', body); ATTACHMENTS.forEach(a => fd.append('attachments', a.file, a.file.name)); await api('POST', '/send', fd, true); toast('Preview sent to ' + SESSION.email, 'ok'); } catch (e) { toast('Preview failed: ' + e.message, 'err'); } } function setSendStatus(t) { const el = document.getElementById('send-status'); if(el) el.textContent = t; } function setProgress(p) { const el = document.getElementById('send-pbar'); if(el) el.style.width = p + '%'; } // ═══════════════════════════════════════════════════ // CONTACTS & LISTS (unchanged from v4.1) // ═══════════════════════════════════════════════════ async function renderContacts() { const el = document.getElementById('p-contacts'); el.innerHTML = '

Loading…

'; try { const q = window.CONTACT_SEARCH || ''; const [contacts, lists] = await Promise.all([ api('GET', '/contacts' + (q ? `?q=${encodeURIComponent(q)}` : '')), api('GET', '/lists') ]); const safeLists = arrify(lists); const counts = {}; safeLists.forEach(l => { counts[l.id] = arrify(contacts).filter(c => c.list_id === l.id).length; }); el.innerHTML = `
👥

Contacts

Lists
Create lists, import contacts, and add single contacts inside the right list.
${canEdit()?`
`:''}
${safeLists.map(l=>`
${esc(l.name)}
${counts[l.id]||0}
${canEdit()?``:'Read only'}
`).join('') || '
No lists yet. Create a list to start adding contacts.
'}
${arrify(contacts).map(c=>``).join('') || ''}
NameFamily NameCompanyEmailPhone numberPostal AddressListSourceStatusOpenedClicked
${esc(c.name||'—')} ${esc(c.family_name||'—')} ${esc(c.company||'—')} ${esc(c.email)} ${esc(c.phone_number||'—')} ${esc(c.postal_address||'—')} ${esc(c.list_name || '—')} ${esc(c.source||'—')} ${esc(c.status||'active')} ${c.last_opened_at?fmtDatetime(c.last_opened_at):'—'} ${c.last_clicked_at?fmtDatetime(c.last_clicked_at):'—'} ${canEdit()?``:''}
No contacts found
`; } catch(e) { el.innerHTML = `

${e.message}

`; } } function applyContactSearch(){ window.CONTACT_SEARCH = document.getElementById('contactSearch')?.value.trim() || ''; renderContacts(); } function clearContactSearch(){ window.CONTACT_SEARCH=''; renderContacts(); } function showUnifiedCsvImport(){ document.getElementById('contact-form-wrap').innerHTML = `
Upload contacts CSV
Supported columns: Name, Family Name, Company, Email, Phone number, Postal Address, List, Source. Any other columns are ignored.
`; } async function uploadUnifiedCsv(){ const file = document.getElementById('csv-upload-file')?.files?.[0]; if(!file) return toast('Choose a CSV file first','err'); const csv = await file.text(); try{ const r = await api('POST','/contacts/import',{csv}); toast(`Imported ${r.added}, skipped ${r.skipped}`,'ok'); document.getElementById('contact-form-wrap').innerHTML=''; renderContacts(); } catch(e){ toast(e.message,'err'); } } function showAddContact(listId='', listName='') { document.getElementById('contact-form-wrap').innerHTML=`
Add contact${listName?` to ${listName}`:''}
`; } async function addContact(listId='') { const email=document.getElementById('nc-email')?.value.trim(); if(!email) return toast('Email required','err'); try{ await api('POST','/contacts',{ email, listId: listId || null, name:document.getElementById('nc-name')?.value.trim(), family_name:document.getElementById('nc-family')?.value.trim(), company:document.getElementById('nc-company')?.value.trim(), phone_number:document.getElementById('nc-phone')?.value.trim(), postal_address:document.getElementById('nc-address')?.value.trim(), source:document.getElementById('nc-source')?.value.trim(), status:document.getElementById('nc-status')?.value || 'active' }); renderContacts();toast('Contact added','ok'); } catch(e){toast(e.message,'err');} } async function openContactMeta(id,email){ const wrap = document.getElementById('contact-meta-wrap'); wrap.innerHTML = `

Manage ${email}

Loading…
`; try{ const [fields] = await Promise.all([api('GET', `/contacts/${id}/fields`).catch(()=>({}))]); wrap.innerHTML = `

Manage ${email}

Comma-separated. Saving replaces current tags.
One field per line as key=value
`; } catch(e){ wrap.innerHTML = `

${esc(e.message)}

`; } } async function saveContactMeta(id,email){ const tags = (document.getElementById('cm-tags')?.value || '').split(',').map(s=>s.trim()).filter(Boolean); const fieldsText = document.getElementById('cm-fields')?.value || ''; const fields = {}; fieldsText.split('\n').forEach(line=>{ const x=line.trim(); if(!x) return; const idx=x.indexOf('='); if(idx<1) return; fields[x.slice(0,idx).trim()] = x.slice(idx+1).trim(); }); try{ await api('POST', `/contacts/${id}/tags`, { tags }); await api('PUT', `/contacts/${id}/fields`, fields); toast('Contact metadata saved','ok'); openContactMeta(id,email); }catch(e){ toast(e.message,'err'); } } function showAddList(mode) { const isCSV = mode === 'csv'; document.getElementById('list-form-wrap').innerHTML = `
${isCSV ? `
` : ''}
`; document.getElementById('nl-name')?.focus(); window._listCsvContent = ''; } let _listCsvContent = ''; function handleListCsvFile(input) { const file = input.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = e => { _listCsvContent = e.target.result; const lines = _listCsvContent.split('\n').filter(l => l.trim()).length - 1; document.getElementById('list-csv-name').textContent = `✓ ${file.name} — approx. ${Math.max(0,lines)} rows`; }; reader.readAsText(file); } async function addList() { const name = document.getElementById('nl-name')?.value.trim(); if (!name) return toast('List name is required', 'err'); try { await api('POST', '/lists', { name }); renderContacts(); toast('List created', 'ok'); } catch(e) { toast(e.message, 'err'); } } async function createListFromCsv() { const name = document.getElementById('nl-name')?.value.trim(); if (!name) return toast('List name is required', 'err'); if (!_listCsvContent) return toast('Choose a CSV file first', 'err'); try { const list = await api('POST', '/lists', { name }); const result = await api('POST', '/contacts/import', { csv: _listCsvContent, listId: list.id }); renderContacts(); toast(`List “${name}” created — ${result.added} contacts added, ${result.skipped} skipped`, 'ok'); _listCsvContent = ''; } catch(e) { toast(e.message, 'err'); } } function showImportToList(listId, listName) { document.getElementById('list-form-wrap').innerHTML = `
Upload contacts into “${esc(listName)}”
`; } let _existingListCsv = ''; function handleExistingListFile(input) { const file = input.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = e => { _existingListCsv = e.target.result; const lines = _existingListCsv.split('\n').filter(l => l.trim()).length - 1; document.getElementById('existing-csv-name').textContent = `✓ ${file.name} — approx. ${Math.max(0,lines)} rows`; }; reader.readAsText(file); } async function importToExistingList(listId) { if (!_existingListCsv) return toast('Choose a CSV file first', 'err'); try { const r = await api('POST', '/contacts/import', { csv: _existingListCsv, listId }); toast(`${r.added} contacts added, ${r.skipped} skipped`, 'ok'); _existingListCsv = ''; renderContacts(); } catch(e) { toast(e.message, 'err'); } } async function deleteList(id, name) { if (!confirm(`Delete list "${name}"? Contacts will not be deleted.`)) return; try { await api('DELETE', '/lists/' + id); renderContacts(); } catch(e) { toast(e.message, 'err'); } } function resetSegmentForm(){ SEGMENT_EDIT_ID=null; SEGMENT_FORM={ logic:'AND', rules:[{ field:'tag', operator:'contains', value:'' }] }; const n=document.getElementById('segment-name'); if(n) n.value=''; renderSegmentsEditor(); const p=document.getElementById('segment-preview'); if(p) p.innerHTML=''; } function editSegment(id, name, rulesJson){ SEGMENT_EDIT_ID=id; SEGMENT_FORM=safeParseJson(rulesJson, { logic:'AND', rules:[{ field:'tag', operator:'contains', value:'' }] }); document.getElementById('segment-name').value=name||''; renderSegmentsEditor(); window.scrollTo({top:0,behavior:'smooth'}); } function resetFlowForm(){ FLOW_EDIT_ID=null; FLOW_FORM={ logic:'AND', rules:[{ field:'tag', operator:'contains', value:'' }] }; FLOW_ACTIONS=[{ action_type:'add_tag', config:{ tag:'', delay_minutes:0 } }]; ['flow-name','flow-trigger-type','flow-trigger-event','flow-segment'].forEach(id=>{ const el=document.getElementById(id); if(!el) return; if(id==='flow-trigger-type') el.value='event'; else if(id==='flow-trigger-event') el.value='contact_created'; else el.value=''; }); renderFlowsEditor(); } function editFlow(flow){ FLOW_EDIT_ID=flow.id; FLOW_FORM=safeParseJson(flow.trigger_rules_json, { logic:'AND', rules:[{ field:'tag', operator:'contains', value:'' }] }); document.getElementById('flow-name').value=flow.name||''; document.getElementById('flow-trigger-type').value=flow.trigger_type||'event'; document.getElementById('flow-trigger-event').value=flow.trigger_event||'contact_created'; document.getElementById('flow-segment').value=flow.segment_id||''; renderFlowsEditor(); loadFlowActions(flow.id); window.scrollTo({top:0,behavior:'smooth'}); } function flowActionTypeOptions(selected){ return ['add_tag','set_status','send_email','send_campaign','wait_until','branch_if'].map(v=>``).join(''); } function setFlowActionType(i,v){ FLOW_ACTIONS[i].action_type=v; const delay = parseInt(FLOW_ACTIONS[i].config?.delay_minutes||0,10)||0; if(v==='add_tag') FLOW_ACTIONS[i].config={tag:'',delay_minutes:delay}; else if(v==='set_status') FLOW_ACTIONS[i].config={status:'active',delay_minutes:delay}; else if(v==='send_email') FLOW_ACTIONS[i].config={subject:'',body:'',delay_minutes:delay}; else if(v==='send_campaign') FLOW_ACTIONS[i].config={campaign_id:'',delay_minutes:delay}; else if(v==='wait_until') FLOW_ACTIONS[i].config={wait_type:'fixed_datetime',wait_until:'',field_name:'',delay_minutes:delay}; else FLOW_ACTIONS[i].config={delay_minutes:delay, rules:{ logic:'AND', rules:[{ field:'tag', operator:'contains', value:'' }] }}; renderFlowsEditor(); } function setFlowActionConfig(i,k,v){ if(k==='delay_minutes') FLOW_ACTIONS[i].config[k] = Math.max(0, parseInt(v||0,10)||0); else FLOW_ACTIONS[i].config[k]=v; } function setFlowBranchLogic(i,v){ if(!FLOW_ACTIONS[i].config.rules) FLOW_ACTIONS[i].config.rules={logic:'AND',rules:[{ field:'tag', operator:'contains', value:'' }]}; FLOW_ACTIONS[i].config.rules.logic=v; renderFlowsEditor(); } function setFlowBranchRule(i,ri,k,v){ if(!FLOW_ACTIONS[i].config.rules) FLOW_ACTIONS[i].config.rules={logic:'AND',rules:[{ field:'tag', operator:'contains', value:'' }]}; FLOW_ACTIONS[i].config.rules.rules[ri][k]=v; } function addFlowBranchRule(i){ if(!FLOW_ACTIONS[i].config.rules) FLOW_ACTIONS[i].config.rules={logic:'AND',rules:[]}; FLOW_ACTIONS[i].config.rules.rules.push({ field:'tag', operator:'contains', value:'' }); renderFlowsEditor(); } function removeFlowBranchRule(i,ri){ if(!FLOW_ACTIONS[i].config.rules) return; FLOW_ACTIONS[i].config.rules.rules.splice(ri,1); if(!FLOW_ACTIONS[i].config.rules.rules.length) FLOW_ACTIONS[i].config.rules.rules=[{ field:'tag', operator:'contains', value:'' }]; renderFlowsEditor(); } function flowBranchEditorHtml(action, i){ const rs = action.config?.rules || { logic:'AND', rules:[{ field:'tag', operator:'contains', value:'' }] }; return `
${(rs.rules||[]).map((r,ri)=>`
`).join('')}
If the branch rules fail, later actions in this enrollment are skipped.
`; } function flowActionDelayLabel(action, i){ const mins = parseInt(action?.config?.delay_minutes||0,10)||0; return `
`; } function addFlowAction(){ FLOW_ACTIONS.push({ action_type:'add_tag', config:{ tag:'', delay_minutes:0 } }); renderFlowsEditor(); } function removeFlowAction(i){ FLOW_ACTIONS.splice(i,1); if(!FLOW_ACTIONS.length) FLOW_ACTIONS=[{ action_type:'add_tag', config:{ tag:'', delay_minutes:0 } }]; renderFlowsEditor(); } async function loadFlowActions(id){ try{ const actions=await api('GET', `/flows/${id}/actions`); FLOW_ACTIONS=(actions||[]).map(a=>({ action_type:a.action_type, config:{ delay_minutes:0, ...(a.config||{}) } })); if(!FLOW_ACTIONS.length) FLOW_ACTIONS=[{ action_type:'add_tag', config:{ tag:'', delay_minutes:0 } }]; renderFlowsEditor(); }catch(_){ FLOW_ACTIONS=[{ action_type:'add_tag', config:{ tag:'', delay_minutes:0 } }]; renderFlowsEditor(); } } function flowActionsHtml(){ return `
Flow actions
Actions run top to bottom. Delay minutes stack in sequence. Use wait_until to pause until a date, and branch_if to stop later actions unless a condition passes.
${FLOW_ACTIONS.map((a,i)=>`
${flowActionDelayLabel(a,i)}${a.action_type==='add_tag'?`
`:''}${a.action_type==='set_status'?`
`:''}${a.action_type==='send_email'?`
`:''}${a.action_type==='send_campaign'?`
`:''}${a.action_type==='wait_until'?`
Action itself does nothing. It delays the next step.
${(a.config.wait_type||'fixed_datetime')==='fixed_datetime'?`
`:`
`}`:''}${a.action_type==='branch_if'?`
${flowBranchEditorHtml(a,i)}
`:''}
`).join('')}
`; } function editSocial(post){ SOCIAL_EDIT_ID=post.id; document.getElementById('sp-platform').value=post.platform||'Facebook'; document.getElementById('sp-title').value=post.title||''; document.getElementById('sp-body').value=post.body_text||''; document.getElementById('sp-scheduled').value=post.scheduled_at?new Date(post.scheduled_at).toISOString().slice(0,16):''; document.getElementById('sp-expires').value=post.expires_at?new Date(post.expires_at).toISOString().slice(0,16):''; const btn=document.getElementById('sp-save-btn'); if(btn) btn.textContent='Update post'; window.scrollTo({top:0,behavior:'smooth'}); } function resetSocialForm(){ SOCIAL_EDIT_ID=null; ['sp-title','sp-body','sp-scheduled','sp-expires','sp-media'].forEach(id=>{ const el=document.getElementById(id); if(el) el.value=''; }); const p=document.getElementById('sp-platform'); if(p) p.value='Facebook'; const btn=document.getElementById('sp-save-btn'); if(btn) btn.textContent='Save post'; } function socialStatusBadge(status='draft'){ const map={draft:'Draft',scheduled:'Scheduled',ready:'Ready',publishing:'Publishing',published:'Published',failed:'Failed',retry:'Retry',expired:'Expired'}; return `${esc(map[status]||status)}`; } async function saveSocialAccount(platform){ try{ const modeEl=document.getElementById(`sa-mode-${platform}`); const labelEl=document.getElementById(`sa-label-${platform}`); const webhookEl=document.getElementById(`sa-webhook-${platform}`); const tokenEl=document.getElementById(`sa-token-${platform}`); const refreshEl=document.getElementById(`sa-refresh-${platform}`); const clientIdEl=document.getElementById(`sa-client-${platform}`); const clientSecretEl=document.getElementById(`sa-secret-${platform}`); const redirectEl=document.getElementById(`sa-redirect-${platform}`); const scopeEl=document.getElementById(`sa-scope-${platform}`); const pageEl=document.getElementById(`sa-page-${platform}`); const igEl=document.getElementById(`sa-ig-${platform}`); const liEl=document.getElementById(`sa-li-${platform}`); const versionEl=document.getElementById(`sa-version-${platform}`); const activeEl=document.getElementById(`sa-active-${platform}`); await api('PUT', `/social-accounts/${platform}`, { publish_mode: modeEl?.value || 'mock', account_label: labelEl?.value || '', webhook_url: webhookEl?.value || '', auth_token: tokenEl?.value || '', refresh_token: refreshEl?.value || '', client_id: clientIdEl?.value || '', client_secret: clientSecretEl?.value || '', oauth_redirect_uri: redirectEl?.value || '', scope: scopeEl?.value || '', page_id: pageEl?.value || '', ig_user_id: igEl?.value || '', linkedin_author_urn: liEl?.value || '', api_version: versionEl?.value || '', is_active: !!activeEl?.checked }); if(tokenEl) tokenEl.value=''; if(refreshEl) refreshEl.value=''; if(clientSecretEl) clientSecretEl.value=''; toast('Social account saved','ok'); if (typeof renderSettings==='function') renderSettings('social'); } catch(e){ toast(e.message,'err'); } } async function testSocialAccount(platform){ try{ const out=await api('POST', `/social-accounts/${platform}/test`, {}); toast(out.message||'Account looks ready','ok'); } catch(e){ toast(e.message,'err'); } } async function startSocialOAuth(platform){ try{ await saveSocialAccount(platform); const out = await api('GET', `/social-accounts/${platform}/oauth/start`); window.open(out.auth_url, '_blank', 'noopener,noreferrer'); toast('OAuth window opened','ok'); } catch(e){ toast(e.message,'err'); } } async function refreshSocialToken(platform){ try{ await api('POST', `/social-accounts/${platform}/refresh`, {}); toast('Social token refreshed','ok'); renderSocial(); } catch(e){ toast(e.message,'err'); } } async function socialAccountStatus(platform){ try{ const out = await api('GET', `/social-accounts/${platform}/status`); const ident = out.status?.identity ? JSON.stringify(out.status.identity) : 'Connected'; toast(ident.slice(0,180),'ok'); } catch(e){ toast(e.message,'err'); } } async function publishSocialNow(id){ try{ await api('POST', `/social-posts/${id}/publish`, {}); toast('Social post published','ok'); renderSocial(); } catch(e){ toast(e.message,'err'); } } async function retrySocialPost(id){ try{ await api('POST', `/social-posts/${id}/retry`, {}); toast('Social post queued for retry','ok'); renderSocial(); } catch(e){ toast(e.message,'err'); } } async function viewSocialRuns(id, title){ try{ const rows = await api('GET', `/social-posts/${id}/runs`); const ov=document.getElementById('viewer-overlay'); ov.classList.remove('hid'); ov.innerHTML = `

${esc(title||'Social post')} publish runs

${rows.length?`
${rows.map(r=>``).join('')}
StatusPublishedExternal IDURLErrorCreated
${esc(r.status)}${fmtDatetime(r.published_at)}${esc(r.external_post_id||'—')}${r.publish_url?`Open`:'—'}${esc(r.error_msg||'—')}${fmtDatetime(r.created_at)}
`:'

No publish runs yet.

'}
`; } catch(e){ toast(e.message,'err'); } } async function renderSegments() { const el = document.getElementById('p-segments'); el.innerHTML = '

Loading…

'; try { const segments = await api('GET', '/segments'); el.innerHTML = `
🧩

Saved Segments

NameRulesPreview
${segments.length===0?'

No segments yet.

':''}
`; // rebuild action buttons separately to avoid escaping issues const tbody = el.querySelector('tbody'); if (tbody) tbody.innerHTML = segments.map(s=>{ const rules = safeParseJson(s.rules_json, {rules:[]}); return `${esc(s.name)}${(rules.rules||[]).length}${s.preview_count||0} `; }).join(''); renderSegmentsEditor(); } catch(e) { el.innerHTML = `

${esc(e.message)}

`; } } function renderSegmentsEditor(){ const el=document.getElementById('segments-editor'); if(el) el.innerHTML = rulesEditorHtml('segment', SEGMENT_FORM); } async function previewSegmentRules(){ try{ const data = await api('POST', '/segments/preview', SEGMENT_FORM); document.getElementById('segment-preview').innerHTML = `
Preview
${data.count}
matching contacts
${data.contacts?.length?`
${data.contacts.slice(0,8).map(c=>esc(c.email)).join('
')}
`:''}
`; } catch(e){ toast(e.message,'err'); } } async function saveSegment(){ const name = document.getElementById('segment-name')?.value.trim(); if(!name) return toast('Segment name required','err'); try{ if (SEGMENT_EDIT_ID) await api('PUT', `/segments/${SEGMENT_EDIT_ID}`, { name, rules: SEGMENT_FORM }); else await api('POST', '/segments', { name, rules: SEGMENT_FORM }); toast(`Segment ${SEGMENT_EDIT_ID?'updated':'saved'}`,'ok'); resetSegmentForm(); renderSegments(); } catch(e){ toast(e.message,'err'); } } async function deleteSegment(id){ if(!confirm('Delete this segment?')) return; try{ await api('DELETE', `/segments/${id}`); toast('Segment deleted','ok'); if(SEGMENT_EDIT_ID===id) resetSegmentForm(); renderSegments(); } catch(e){ toast(e.message,'err'); } } async function renderFlows() { const el = document.getElementById('p-flows'); if (!canEdit()) { el.innerHTML = denyCard('Flows are editor-only', 'Your current role can view reporting, but not create or change automations.'); return; } el.innerHTML = '

Loading…

'; try { const [flows, segments, campaigns] = await Promise.all([api('GET','/flows'), api('GET','/segments'), api('GET','/campaigns')]); FLOW_CAMPAIGNS = campaigns || []; el.innerHTML = `

Flow Triggers

NameTriggerSegmentStatus
`; const tbody = el.querySelector('tbody'); if (tbody) tbody.innerHTML = flows.map(f=>`${esc(f.name)}${esc(f.trigger_type)}${f.trigger_event?` / ${esc(f.trigger_event)}`:''}${esc((segments.find(s=>s.id===f.segment_id)||{}).name || '—')}${esc(f.status||'active')} `).join(''); renderFlowsEditor(); } catch(e) { el.innerHTML = `

${esc(e.message)}

`; } } function renderFlowsEditor(){ const el=document.getElementById('flows-editor'); if(el) el.innerHTML = rulesEditorHtml('flow', FLOW_FORM) + flowActionsHtml(); } function applyFlowPreset(kind){ if(kind==='welcome'){ FLOW_FORM={ name:'Welcome sequence', trigger_type:'event', trigger_event:'contact_created', segment_id:'', trigger_rules_json:{logic:'AND',rules:[]} }; FLOW_ACTIONS=[{action_type:'send_email',config:{subject:'Welcome to our list',body:'Thanks for joining us.',delay_minutes:0}},{action_type:'add_tag',config:{tag:'new-subscriber',delay_minutes:5}}]; } else if(kind==='reengage'){ FLOW_FORM={ name:'Re-engagement', trigger_type:'event', trigger_event:'campaign_opened', segment_id:'', trigger_rules_json:{logic:'AND',rules:[{field:'status',operator:'equals',value:'active'}]} }; FLOW_ACTIONS=[{action_type:'wait_until',config:{wait_mode:'delay',delay_minutes:1440}},{action_type:'send_email',config:{subject:'Still interested?',body:'We would love to hear from you again.',delay_minutes:0}}]; } else { FLOW_FORM={ name:'VIP follow-up', trigger_type:'event', trigger_event:'campaign_clicked', segment_id:'', trigger_rules_json:{logic:'AND',rules:[{field:'tag',operator:'contains',value:'vip'}]} }; FLOW_ACTIONS=[{action_type:'add_tag',config:{tag:'engaged-vip',delay_minutes:0}},{action_type:'send_email',config:{subject:'Personal follow-up',body:'Thanks for clicking through — here is your next step.',delay_minutes:30}}]; } FLOW_EDIT_ID=null; renderFlowsEditor(); toast('Preset loaded','ok'); } async function duplicateFlow(id){ try{ await api('POST', `/flows/${id}/duplicate`, {}); toast('Flow duplicated','ok'); renderFlows(); } catch(e){ toast(e.message,'err'); } } async function saveFlow(){ const name = document.getElementById('flow-name')?.value.trim(); if(!name) return toast('Flow name required','err'); try{ const payload={ name, trigger_type: document.getElementById('flow-trigger-type')?.value || 'event', trigger_event: document.getElementById('flow-trigger-event')?.value || '', segment_id: document.getElementById('flow-segment')?.value || null, trigger_rules_json: FLOW_FORM, status: 'active' }; let id = FLOW_EDIT_ID; if (FLOW_EDIT_ID) await api('PUT', `/flows/${FLOW_EDIT_ID}`, payload); else { const created = await api('POST','/flows',payload); id = created.id; } await api('PUT', `/flows/${id}/actions`, { actions: FLOW_ACTIONS }); toast(`Flow ${FLOW_EDIT_ID?'updated':'saved'}`,'ok'); resetFlowForm(); renderFlows(); } catch(e){ toast(e.message,'err'); } } async function deleteFlow(id){ if(!confirm('Delete this flow?')) return; try{ await api('DELETE', `/flows/${id}`); toast('Flow deleted','ok'); if(FLOW_EDIT_ID===id) resetFlowForm(); renderFlows(); } catch(e){ toast(e.message,'err'); } } async function viewFlowEnrollments(id,name){ try{ const rows = await api('GET', `/flows/${id}/enrollments`); const ov=document.getElementById('viewer-overlay'); ov.classList.remove('hid'); ov.innerHTML = `

${esc(name)} enrollments

${rows.length?`
${rows.map(r=>``).join('')}
EmailNameStatusProgressCreated
${esc(r.email)}${esc(r.name||'—')}${esc(r.status||'active')}${Number(r.action_runs_done||0)}/${Number(r.action_runs||0)}${fmtDatetime(r.created_at)}
`:'

No enrollments yet.

'}
`; } catch(e){ toast(e.message,'err'); } } async function viewFlowRuns(id,name){ try{ const rows = await api('GET', `/flows/${id}/runs`); const ov=document.getElementById('viewer-overlay'); ov.classList.remove('hid'); ov.innerHTML = `

${esc(name)} queued actions

${rows.length?`
${rows.map(r=>``).join('')}
EmailActionStatusExecutesExecutedError
${esc(r.email)}${esc(r.action_type)}${r.config?.delay_minutes?` (+${Number(r.config.delay_minutes)}m)`:''}${esc(r.status)}${fmtDatetime(r.execute_at)}${fmtDatetime(r.executed_at)}${esc(r.error_msg||'—')}
`:'

No queued actions yet.

'}
`; } catch(e){ toast(e.message,'err'); } } async function renderSocial() { const el = document.getElementById('p-social'); el.innerHTML = '

Loading…

'; try { const posts = await api('GET','/social-posts'); el.innerHTML = `
📣

Social Media Posts

PDF uploads are converted into inline images for social posting. They are stored as body media, not attachments.
${posts.map(p=>``).join('')}
PlatformTitleMediaStatusSchedulePublished
${esc(p.platform)}${esc(p.title||'—')}${p.publish_url?``:''}${p.last_error?`
${esc(p.last_error)}
`:''}
${Number(p.media_count||0)}${p.converted_pdf_pages?` (${Number(p.converted_pdf_pages)} PDF page image${Number(p.converted_pdf_pages)===1?'':'s'})`:''}${socialStatusBadge(p.status||'draft')}${fmtDatetime(p.scheduled_at)}${fmtDatetime(p.published_at)} ${p.status!=='published'?``:''} ${p.status==='failed'?``:''}
`; } catch(e) { el.innerHTML = `

${esc(e.message)}

`; } } async function saveSocialPost(){ const platform = document.getElementById('sp-platform')?.value; const body = document.getElementById('sp-body')?.value.trim(); if(!platform || !body) return toast('Platform and body are required','err'); const fd = new FormData(); fd.append('platform', platform); fd.append('title', document.getElementById('sp-title')?.value.trim() || ''); fd.append('body', body); if (document.getElementById('sp-scheduled')?.value) fd.append('scheduledAt', new Date(document.getElementById('sp-scheduled').value).toISOString()); if (document.getElementById('sp-expires')?.value) fd.append('expiresAt', new Date(document.getElementById('sp-expires').value).toISOString()); [...(document.getElementById('sp-media')?.files || [])].forEach(file => fd.append('media', file, file.name)); try{ if (SOCIAL_EDIT_ID) await api('PUT', `/social-posts/${SOCIAL_EDIT_ID}`, fd, true); else await api('POST','/social-posts', fd, true); toast(`Social post ${SOCIAL_EDIT_ID?'updated':'saved'}`,'ok'); resetSocialForm(); renderSocial(); } catch(e){ toast(e.message,'err'); } } async function deleteSocialPost(id){ if(!confirm('Delete this social post?')) return; try{ await api('DELETE', `/social-posts/${id}`); toast('Social post deleted','ok'); if(SOCIAL_EDIT_ID===id) resetSocialForm(); renderSocial(); } catch(e){ toast(e.message,'err'); } } function safeParseJson(v, fallback){ try { return typeof v==='string' ? JSON.parse(v) : (v || fallback); } catch(_) { return fallback; } } function settingsTabButton(id,label,active){ return ``; } function switchSettingsTab(tab){ window.__settingsTab = tab; renderSettings(); } async function renderSettings(){ const el = document.getElementById('p-settings'); if(!canAdmin() && SESSION?.role!=='super'){ el.innerHTML = denyCard('Settings','Your role does not have access to settings.'); return; } el.innerHTML = '

Loading settings…

'; try{ const activeTab = window.__settingsTab || 'smtp'; const isSuper = SESSION?.role === 'super'; const [settings, usersRaw, auditRows, opsStatus, backups, socialAccounts, templates, orgs] = await Promise.all([ isSuper ? api('GET','/admin/system-settings').catch(()=>({})) : api('GET','/settings').catch(()=>({})), isSuper ? Promise.resolve([]) : api('GET','/users').catch(()=>({ users:[] })), isSuper ? api('GET','/admin/audit').catch(()=>[]) : api('GET','/audit').catch(()=>[]), api('GET','/ops/status').catch(()=>({})), api('GET','/backups').catch(()=>({ items:[] })), isSuper ? Promise.resolve([]) : api('GET','/social-accounts').catch(()=>[]), api('GET','/templates').catch(()=>[]), isSuper ? api('GET','/admin/orgs').catch(()=>[]) : Promise.resolve([]), ]); const users = normalizeUsersPayload(usersRaw); const templatesList = arrify(templates); const socialList = arrify(socialAccounts); const backupsList = Array.isArray(backups) ? backups : arrify(backups?.items); const auditList = arrify(auditRows); const orgList = arrify(orgs); const tabs = [ ['smtp','SMTP'], ['templates','Templates'], ['users','Users'], ['social','Social'], ['ops','Operations'], ['audit','Audit'] ]; function smtpPane(){ if(isSuper) return `

K777 SMTP

`; if (String(settings.smtp_mode||'custom') === 'k777') return `

SMTP

This organisation uses K777 managed SMTP.

SMTP configuration is controlled by K777 admin.

`; return `

SMTP & company

`; } function templatesPane(){ return `

Templates

${templatesList.length?`
${templatesList.map(t=>``).join('')}
NameSubjectUpdated
${esc(t.name)}${esc(t.subject||'—')}${fmtDatetime(t.updated_at||t.created_at)}
`:'

No templates yet.

'}
`; } function usersPane(){ const canManage = canAdmin() || isSuper; const orgRows = orgList.map(o=>`${esc(o.name)}${esc(o.owner_email||'')}${esc((o.smtp_mode||'custom')==='k777'?'K777 SMTP':'Own SMTP')}${esc(String(o.plan_price||0))} ${esc(o.plan_currency||'GBP')}${fmtDatetime(o.created_at)}`).join(''); const orgInfo = isSuper ? `

Organisations

${orgList.length?`
${orgRows}
NameOwner emailSMTP modePlanCreated
`:'

No organisations yet.

'}
` : `

Organisation

Workspace
${esc(SESSION?.orgName || 'Current organisation')}

Users in this organisation are managed here.

`; return `${orgInfo}

Users

${canManage?'':''}
${users.length?`
${users.map(u=>``).join('')}
NameEmailRoleStatusLast login
${esc(u.name||'—')}${esc(u.email||'')}${roleChip(u.role||'viewer')}${u.is_active===0?'disabled':'active'}${fmtDatetime(u.last_login_at)}${u.id==='owner'?'Owner account':canManage?` ${canOwner()&&u.role!=='owner'?``:''}`:''}
`:'

No users yet.

'}
`; } function socialPane(){ if(isSuper) return `

Social account settings are organisation-level. Sign in as an organisation owner or admin to manage them.

`; const platforms = [{id:'facebook',label:'Facebook'},{id:'instagram',label:'Instagram'},{id:'linkedin',label:'LinkedIn'},{id:'x',label:'X'}]; return `

Social settings

${platforms.map(p=>{ const a=socialList.find(x=>String(x.platform||'').toLowerCase()===p.id)||{platform:p.id,publish_mode:'mock'}; return `
${p.label}
`}).join('')}
`; } function opsPane(){ if(isSuper) return `

Operations

Backup folder: ${esc((opsStatus.backups&&opsStatus.backups.dir)||'')}
${backupsList.length?`
${backupsList.map(b=>``).join('')}
FilePathStatusCreated
${esc(b.file_name||'')}${esc(b.file_path||'')}${esc(b.status||'done')}${fmtDatetime(b.created_at)}
`:'

No backups yet.

'}
`; const jobs = opsStatus.jobs||{}; return `

Operations

Uptime
${esc(String(opsStatus.uptimeSeconds||opsStatus.uptime_s||0))}s
Queued jobs
${esc(String(jobs.queued||0))}
Running jobs
${esc(String(jobs.running||0))}
Dead jobs
${esc(String(jobs.dead||0))}
Backup folder: ${esc((opsStatus.backups&&opsStatus.backups.dir)||'')}
${backupsList.length?`
${backupsList.map(b=>``).join('')}
FilePathStatusSizeCreated
${esc(b.file_name||b.filename||'')}${esc(b.file_path||'')}${esc(b.status||'done')}${esc(String(b.file_size_human||b.size_bytes||b.file_size||0))}${fmtDatetime(b.created_at)}
`:'

No backups yet.

'}
`; } function auditPane(){ return `

Audit

${auditList.length?`
${auditList.map(r=>``).join('')}
WhenActorActionTarget
${fmtDatetime(r.created_at)}${esc(r.actor_email||'system')}${esc(r.action||'')}${esc((r.target_type||'') + (r.target_id?(' / '+r.target_id):''))}
`:'

No audit rows available.

'}
`; } const panes = { smtp:smtpPane, templates:templatesPane, users:usersPane, social:socialPane, ops:opsPane, audit:auditPane }; el.innerHTML = `

Settings

${tabs.map(([id,label])=>settingsTabButton(id,label,activeTab===id)).join('')}
${(panes[activeTab]||smtpPane)()}
`; }catch(e){ el.innerHTML = `

${esc(e.message)}

`; } } async function saveSuperSmtpFromSettings(){ const body = { sys_smtp_host:document.getElementById('ss-shost')?.value.trim()||'', sys_smtp_port:document.getElementById('ss-sport')?.value.trim()||'587', sys_smtp_user:document.getElementById('ss-suser')?.value.trim()||'', sys_smtp_from_name:document.getElementById('ss-sfname')?.value.trim()||'K777 Mail', sys_smtp_from_email:document.getElementById('ss-sfemail')?.value.trim()||'' }; const spass = document.getElementById('ss-spass')?.value.trim(); if(spass) body.sys_smtp_pass = spass; try{ await api('PUT','/admin/system-settings',body); toast('K777 SMTP saved','ok'); renderSettings('smtp'); } catch(e){ toast(e.message,'err'); } } async function saveOrgSettings(){ try{ await api('PUT','/settings',{ smtpHost:document.getElementById('st-smtp-host')?.value||'', smtpPort:document.getElementById('st-smtp-port')?.value||'', smtpUser:document.getElementById('st-smtp-user')?.value||'', smtpPass:document.getElementById('st-smtp-pass')?.value||'', fromName:document.getElementById('st-from-name')?.value||'', fromEmail:document.getElementById('st-from-email')?.value||'', companyDisplayName:document.getElementById('st-company-name')?.value||'', companyPhone:document.getElementById('st-company-phone')?.value||'', companyAddress:document.getElementById('st-company-address')?.value||'' }); toast('Settings saved','ok'); renderSettings('smtp'); }catch(e){ toast(e.message,'err'); } } function showUserForm(raw=''){ const data = typeof raw === 'string' ? (raw ? JSON.parse(raw) : {}) : (raw || {}); const wrap = document.getElementById('user-form-wrap'); if(!wrap) return; wrap.innerHTML = `
`; } async function saveUserForm(id=''){ const payload = { name:document.getElementById('uf-name')?.value||'', email:document.getElementById('uf-email')?.value||'', role:document.getElementById('uf-role')?.value||'viewer', password:document.getElementById('uf-password')?.value||'', is_active:document.getElementById('uf-active')?.checked?1:0 }; try{ if(id) await api('PUT', `/users/${id}`, payload); else await api('POST','/users', payload); toast(id?'User updated':'User created','ok'); renderSettings(); }catch(e){ toast(e.message,'err'); } } async function resetUserPassword(id){ const password = prompt('New password for this user'); if(!password) return; try{ await api('POST', `/users/${id}/reset-password`, { password }); toast('Password reset','ok'); }catch(e){ toast(e.message,'err'); } } async function deleteUserRow(id,email){ if(!confirm(`Delete user ${email}?`)) return; try{ await api('DELETE', `/users/${id}`); toast('User deleted','ok'); renderSettings(); }catch(e){ toast(e.message,'err'); } } function showTemplateEditor(raw=''){ const data = typeof raw === 'string' ? (raw ? JSON.parse(raw) : {}) : (raw || {}); const wrap = document.getElementById('template-editor-wrap'); if(!wrap) return; wrap.innerHTML = `
`; } async function saveTemplateForm(id=''){ try{ const payload={ name:document.getElementById('tpl-name')?.value||'', subject:document.getElementById('tpl-subject')?.value||'', body_text:document.getElementById('tpl-body')?.value||'', category:'email' }; if(id) await api('PUT', `/templates/${id}`, payload); else await api('POST','/templates', payload); toast('Template saved','ok'); renderSettings(); }catch(e){ toast(e.message,'err'); } } async function deleteTemplate(id){ if(!confirm('Delete this template?')) return; try{ await api('DELETE', `/templates/${id}`); toast('Template deleted','ok'); renderSettings(); }catch(e){ toast(e.message,'err'); } } async function runBackupNow(){ try{ await api('POST','/backups/run',{}); toast('Backup started','ok'); renderSettings(); }catch(e){ toast(e.message,'err'); } } function showOrgCreateForm(raw=''){ const data = typeof raw==='string' ? {} : (raw||{}); const wrap=document.getElementById('org-form-wrap'); if(!wrap) return; wrap.innerHTML=`
`; } async function createOrganisationFromSettings(){ try{ await api('POST','/admin/orgs',{ name:document.getElementById('org-name')?.value||'', ownerEmail:document.getElementById('org-owner-email')?.value||'', password:document.getElementById('org-owner-pass')?.value||'', planPrice:parseFloat(document.getElementById('org-price')?.value||'0')||0, smtpMode:document.getElementById('org-smtp-mode')?.value||'custom' }); toast('Organisation created','ok'); renderSettings('users'); }catch(e){ toast(e.message,'err'); } } async function updateOrganisationFromSettings(id){ try{ await api('PUT',`/admin/orgs/${id}`,{ name:document.getElementById('org-name')?.value||'', password:document.getElementById('org-owner-pass')?.value||'', planPrice:parseFloat(document.getElementById('org-price')?.value||'0')||0, smtpMode:document.getElementById('org-smtp-mode')?.value||'custom' }); toast('Organisation updated','ok'); renderSettings('users'); }catch(e){ toast(e.message,'err'); } } async function deleteContact(id){ if(!confirm('Delete this contact?')) return; try{ await api('DELETE', `/contacts/${id}`); toast('Contact deleted','ok'); renderContacts(); }catch(e){ toast(e.message,'err'); } } // ═══════════════════════════════════════════════════ // UTILITIES // ═══════════════════════════════════════════════════ function esc(s){ return String(s||'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } function fmtDate(ts){ if(!ts)return'—'; return new Date(ts).toLocaleDateString('en-GB',{day:'numeric',month:'short',year:'numeric'}); } function fmtDatetime(ts){ if(!ts)return'—'; return new Date(ts).toLocaleString('en-GB',{day:'numeric',month:'short',year:'numeric',hour:'2-digit',minute:'2-digit'}); } function isExpired(ts){ return ts ? new Date(ts)setTimeout(r,ms)); } // ═══════════════════════════════════════════════════ // BOOT // ═══════════════════════════════════════════════════ (function boot(){ const saved = localStorage.getItem('k777_session'); if (saved) { try { const s = JSON.parse(saved); // Validate the stored session has the fields we need if (s && s.token && s.role) { SESSION = s; if (SESSION.role === 'super') { go('superdash'); } else if (SESSION.requiresTerms) { showTermsModal(); } else { go('dashboard'); } return; } } catch (_) {} // Malformed session — clear it and show login localStorage.removeItem('k777_session'); } go('login'); })(); // Restored missing functions from known-good baseline function showImport() { document.getElementById('contact-form-wrap').innerHTML=`
`; } async function importContacts() { const csv=document.getElementById('imp-csv')?.value; if(!csv)return; try{const r=await api('POST','/contacts/import',{csv});toast(`Imported ${r.added}, skipped ${r.skipped}`,'ok');renderContacts();} catch(e){toast(e.message,'err');} } async function renderLists() { const el = document.getElementById('p-lists'); el.innerHTML = '

Loading…

'; try { const lists = await api('GET', '/lists'); // Count contacts per list const counts = {}; await Promise.all(lists.map(async l => { try { const c = await api('GET', '/contacts?listId=' + l.id); counts[l.id] = c.length; } catch(_) { counts[l.id] = 0; } })); el.innerHTML = `
📋

Mailing Lists

${lists.length ? lists.map(l=>`
${esc(l.name)} ${counts[l.id]||0} contact${counts[l.id]!==1?'s':''}
`).join('') : '

No lists yet — create one above

'}
`; } catch(e) { el.innerHTML = `

${esc(e.message)}

`; } } function handleListCsvDrop(e) { const file = e.dataTransfer.files[0]; if (!file) return; handleListCsvFile({ files: e.dataTransfer.files }); } function handleExistingListDrop(e) { handleExistingListFile({ files: e.dataTransfer.files }); } function analyticsStat(label, value, sub='') { return `
${value}
${label}
${sub?`
${sub}
`:''}
`; } function miniBarChart(points, key){ const max = Math.max(1, ...points.map(p => Number(p[key]||0))); return `
${points.map(p=>{ const val=Number(p[key]||0); const h=Math.max(8, Math.round((val/max)*120)); const lbl=(p.day||'').slice(5); return `
${lbl}
`; }).join('')}
`; } function pct(n){ return `${Number(n||0).toFixed(1)}%`; } async function renderCampaigns() { const el = document.getElementById('p-campaigns'); el.innerHTML = '

Loading…

'; try { const [campaigns, analytics] = await Promise.all([ api('GET', '/campaigns'), api('GET', '/campaigns/analytics/summary') ]); const scheduled = campaigns.filter(c => c.status === 'scheduled' || c.status === 'sending'); const history = campaigns.filter(c => c.status === 'sent' || c.status === 'failed'); const t = analytics.totals || {}; const r = analytics.rates || {}; const top = analytics.topCampaigns || []; const daily = analytics.daily || []; el.innerHTML = `
${analyticsStat('Recipients', t.recipients||0, `${t.campaigns||0} campaigns`)} ${analyticsStat('Delivered', t.delivered||0, pct(r.delivery_rate||0))} ${analyticsStat('Unique opens', t.unique_opens||0, `${t.opens||0} total · ${pct(r.open_rate||0)}`)} ${analyticsStat('Unique clicks', t.unique_clicks||0, `${t.clicks||0} total · ${pct(r.click_rate||0)}`)}
📈

14-day activity

Sent emails
${miniBarChart(daily,'sent')}
Open rate: ${pct(r.open_rate||0)} Click rate: ${pct(r.click_rate||0)} CTOR: ${pct(r.click_to_open_rate||0)}
🏆

Top recent campaigns

${top.map(c=>``).join('') || ''}
SubjectDeliveredOpenClick
${esc(c.subject||'Untitled')}
${fmtDate(c.sent_at)}
${c.delivered||0} ${pct(c.delivered ? ((c.unique_opens||0)/c.delivered)*100 : 0)} ${pct(c.delivered ? ((c.unique_clicks||0)/c.delivered)*100 : 0)}
No sent campaigns yet
${scheduled.length ? `
🗓

Scheduled

${scheduled.length} campaign${scheduled.length!==1?'s':''} queued
${scheduled.map(c => campaignRow(c)).join('')}
SubjectRecipientsSend OnStatus
` : ''}
📊

Campaign History

${history.map(c => campaignRow(c)).join('')}
SubjectList / RecipientsSentStatusOnline LinkExpiry
${history.length===0?'

No campaigns sent yet

':''}
`; } catch(e) { el.innerHTML = `

${e.message}

`; } } function statusBadge(status) { const map = { scheduled: ['🗓', 'status-scheduled', 'Scheduled'], sending: ['⏳', 'status-sending', 'Sending…'], sent: ['✅', 'status-sent', 'Sent'], failed: ['❌', 'status-failed', 'Failed'], }; const [icon, cls, label] = map[status] || ['?', '', status]; return `${icon} ${label}`; } function expiryBadge(c) { if (!c.expires_at) return ``; if (c.expired) return `⏱ ${fmtDatetime(c.expires_at)}`; return `✓ ${fmtDatetime(c.expires_at)}`; } function campaignRow(c) { const isScheduled = c.status === 'scheduled' || c.status === 'sending'; let recipCount = ''; try { recipCount = JSON.parse(c.recipients_json||'[]').length + ' recipients'; } catch(_){} if (isScheduled) { return ` ${esc(c.subject)} ${esc(c.list_name||recipCount||'—')} ${c.scheduled_at ? fmtDatetime(c.scheduled_at) : '—'} ${statusBadge(c.status)}
${c.status!=='sending'?` `:'Sending…'}
`; } return ` ${esc(c.subject)} ${esc(c.list_name||'—')} ${fmtDate(c.sent_at)} ${statusBadge(c.status||'sent')} ${c.expired ? `Expired` : `View ↗` } ${expiryBadge(c)}
`; } async function showCampEdit(id) { // Toggle: clicking Edit again closes it const existing = document.getElementById('cedit-' + id); if (existing) { existing.remove(); return; } document.querySelectorAll('[id^="cedit-"]').forEach(el => el.remove()); const row = document.getElementById('crow-' + id); if (!row) return; let c; try { c = await api('GET', '/campaigns/' + id); } catch(e) { return toast(e.message, 'err'); } let recips = []; try { recips = JSON.parse(c.recipients_json || '[]'); } catch(_){} const schedDate = c.scheduled_at ? new Date(c.scheduled_at).toISOString().slice(0,10) : ''; const schedTime = c.scheduled_at ? new Date(c.scheduled_at).toTimeString().slice(0,5) : '09:00'; const expDate = c.expires_at ? new Date(c.expires_at).toISOString().slice(0,10) : ''; const expTime = c.expires_at ? new Date(c.expires_at).toTimeString().slice(0,5) : '23:59'; const colSpan = row.children.length; const tr = document.createElement('tr'); tr.id = 'cedit-' + id; tr.innerHTML = `

✏ Edit Campaign

Scheduled Send Date & Time
Online Link Expiry Optional
`; row.insertAdjacentElement('afterend', tr); tr.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } async function saveCampEdit(id) { const subject = document.getElementById('ce-subj-' + id)?.value.trim(); const body = document.getElementById('ce-body-' + id)?.value.trim(); const fromName= document.getElementById('ce-from-' + id)?.value.trim(); if (!subject || !body) return toast('Subject and body are required', 'err'); const sd = document.getElementById('ce-sd-' + id)?.value; const st = document.getElementById('ce-st-' + id)?.value || '09:00'; const scheduledAt = sd ? new Date(`${sd}T${st}:00`).toISOString() : null; const ed = document.getElementById('ce-ed-' + id)?.value; const et = document.getElementById('ce-et-' + id)?.value || '23:59'; const expiresAt = ed ? new Date(`${ed}T${et}:00`).toISOString() : null; const recipRaw = document.getElementById('ce-recip-' + id)?.value || ''; const recipients = JSON.stringify( recipRaw.split(/[\n,]+/).map(e => e.trim().toLowerCase()).filter(e => e.includes('@')) ); try { await api('PUT', '/campaigns/' + id, { subject, body, fromName, scheduledAt, expiresAt, recipients }); toast('Campaign saved', 'ok'); document.getElementById('cedit-' + id)?.remove(); renderCampaigns(); } catch(e) { toast(e.message, 'err'); } } async function sendNow(id, subject) { if (!confirm(`Send "${subject}" immediately?`)) return; try { await api('POST', `/campaigns/${id}/send-now`); toast('Campaign queued — sending now', 'ok'); setTimeout(renderCampaigns, 2000); } catch(e) { toast(e.message, 'err'); } } async function deleteCampaign(id, subject) { if (!confirm(`Delete campaign "${subject}"?\n\nThis cannot be undone.`)) return; try { await api('DELETE', '/campaigns/' + id); toast('Campaign deleted', 'ok'); document.getElementById('crow-' + id)?.remove(); document.getElementById('cedit-' + id)?.remove(); } catch(e) { toast(e.message, 'err'); } } function showEditExpiry(id) { const existing = document.getElementById('expiry-edit-' + id); if (existing) { existing.remove(); return; } const row = document.getElementById('crow-' + id); if (!row) return; const colSpan = row.children.length; const tr = document.createElement('tr'); tr.id = 'expiry-edit-' + id; tr.innerHTML = `
Expiry Date
Expiry Time
After expiry the public link will show an expired page. Campaign data stays on your account.
`; row.insertAdjacentElement('afterend', tr); } async function saveExpiry(id) { const d = document.getElementById('ee-date-' + id)?.value; const t = document.getElementById('ee-time-' + id)?.value || '23:59'; if (!d) return toast('Select a date', 'err'); const expiresAt = new Date(`${d}T${t}:00`).toISOString(); try { await api('PATCH', `/campaigns/${id}/expiry`, { expiresAt }); toast('Expiry saved', 'ok'); document.getElementById('expiry-edit-' + id)?.remove(); renderCampaigns(); } catch(e) { toast(e.message, 'err'); } } async function clearExpiry(id) { try { await api('PATCH', `/campaigns/${id}/expiry`, { expiresAt: null }); toast('Expiry removed', 'ok'); document.getElementById('expiry-edit-' + id)?.remove(); renderCampaigns(); } catch(e) { toast(e.message, 'err'); } } async function openViewer(id) { const overlay = document.getElementById('viewer-overlay'); const vpBody = document.getElementById('vp-body'); const vpTitle = document.getElementById('vp-title'); overlay.classList.remove('hid'); vpBody.innerHTML = '

Loading…

'; try { const c = await api('GET', '/campaigns/' + id); VIEWER_CAMPAIGN = c; vpTitle.textContent = c.subject; // Render email in a sandboxed iframe const iframe = document.createElement('iframe'); iframe.id = 'viewer-email-frame'; iframe.style.cssText = 'width:100%;border:none;display:block;min-height:500px'; iframe.sandbox = 'allow-same-origin'; vpBody.innerHTML = ''; // Expiry info bar if (c.expires_at) { const bar = document.createElement('div'); bar.style.cssText = 'padding:10px 20px;background:var(--bgs);border-bottom:1px solid var(--brd);font-size:12px;color:var(--mut);display:flex;align-items:center;gap:8px'; bar.innerHTML = c.expired ? `⏱ Link expired ${fmtDatetime(c.expires_at)} — Public URL is no longer accessible` : `✓ Link active Expires ${fmtDatetime(c.expires_at)} `; vpBody.appendChild(bar); } vpBody.appendChild(iframe); // Write email HTML into iframe const doc = iframe.contentDocument || iframe.contentWindow.document; doc.open(); doc.write(c.body_html || `
${(c.body_text||'').replace(/\n/g,'
')}
`); doc.close(); // Auto-resize iframe setTimeout(() => { try { iframe.style.height = iframe.contentDocument.documentElement.scrollHeight + 'px'; } catch(_){} }, 150); } catch(e) { vpBody.innerHTML = `

${e.message}

`; } } function closeViewer() { document.getElementById('viewer-overlay').classList.add('hid'); VIEWER_CAMPAIGN = null; } function downloadPDF() { if (!VIEWER_CAMPAIGN) return; const c = VIEWER_CAMPAIGN; if (c.id === '__preview__') return toast('Save and schedule first, then download the PDF', 'inf'); // Build clean print-ready HTML in the hidden print-area const area = document.getElementById('print-area'); area.style.display = 'block'; const bodyContent = c.body_html ? stripViewOnlineBanner(c.body_html) : (c.body_text || '').replace(/\n/g, '
'); const sentDate = c.sent_at ? new Date(c.sent_at).toLocaleString('en-GB', { dateStyle: 'long', timeStyle: 'short' }) : ''; const expiryLine = c.expires_at ? `
Online link ${c.expired?'expired':'expires'} ${fmtDatetime(c.expires_at)}
` : ''; area.innerHTML = ` `; // Trigger print dialog window.print(); // Clean up after print dialog closes setTimeout(() => { area.style.display = 'none'; area.innerHTML = ''; }, 1000); } function stripViewOnlineBanner(html) { const tmp = document.createElement('div'); tmp.innerHTML = html; // Remove the banner div (identified by its distinctive inline style) tmp.querySelectorAll('div').forEach(d => { if (d.textContent.includes('Having trouble viewing') || d.textContent.includes('Click here to view it online')) { d.remove(); } }); // Extract just the body content const bodyDiv = tmp.querySelector('div[style*="max-width:640px"]') || tmp.querySelector('div[style*="max-width: 640px"]'); return bodyDiv ? bodyDiv.innerHTML : tmp.innerHTML; } async function showResults(id, subject) { const wrap = document.getElementById('camp-results-wrap'); wrap.innerHTML = '

Loading…

'; try { const data = await api('GET', `/campaigns/${id}/analytics`); const rows = data.recipients || []; const s = data.summary || {}; const r = data.rates || {}; const links = data.links || []; wrap.innerHTML = `
📬

${esc(subject)}

✅ ${s.delivered||0} delivered 👁 ${s.unique_opens||0} unique opens 🖱 ${s.unique_clicks||0} unique clicks Export CSV
${analyticsStat('Recipients', s.recipients||0, `${s.failed||0} failed`)} ${analyticsStat('Open rate', pct(r.open_rate||0), `${s.opens||0} total opens`)} ${analyticsStat('Click rate', pct(r.click_rate||0), `${s.clicks||0} total clicks`)} ${analyticsStat('CTOR', pct(r.click_to_open_rate||0), 'click to open')}
🔗

Top clicked links

${links.map(l=>``).join('') || ''}
URLClicksUnique
${esc(l.url)}${l.clicks}${l.unique_clicks}
No clicks tracked yet

Campaign summary

Sent: ${fmtDate(data.campaign?.sent_at)}

Delivered: ${s.delivered||0} of ${s.recipients||0}

Unique opens: ${s.unique_opens||0}

Unique clicks: ${s.unique_clicks||0}

${rows.map(r=>``).join('')}
EmailStatusOpensClicksDateError
${esc(r.email)} ${r.ok?'Sent':'Failed'} ${r.opens||0} ${r.clicks||0} ${fmtDate(r.sent_at)} ${esc(r.error_msg||'')}
`; } catch(e) { wrap.innerHTML = `

${e.message}

`; } } function openTemplateEditor(t){ if(!canEdit()) return; const box=document.getElementById('template-editor'); if(!box) return; box.innerHTML = `
`; } function editTemplate(t){ openTemplateEditor(t); } async function saveTemplate(id){ try{ const payload={ name:document.getElementById('tpl-name')?.value||'', category:document.getElementById('tpl-cat')?.value||'email', subject:document.getElementById('tpl-subject')?.value||'', body_text:document.getElementById('tpl-body')?.value||'' }; if(id) await api('PUT','/templates/'+id,payload); else await api('POST','/templates',payload); toast('Template saved','ok'); renderSettings(); } catch(e){ toast(e.message,'err'); } } function openUserEditor(u){ if(!canAdmin()) return; const box=document.getElementById('user-editor'); if(!box) return; box.innerHTML = `
`; } function editUser(u){ openUserEditor(u); } async function saveUser(id){ try{ const payload={ name:document.getElementById('usr-name')?.value||'', email:document.getElementById('usr-email')?.value||'', role:document.getElementById('usr-role')?.value||'viewer', is_active:Number(document.getElementById('usr-active')?.value||1), password:document.getElementById('usr-pass')?.value||'' }; if(id) await api('PUT','/users/'+id,payload); else await api('POST','/users',payload); toast('User saved','ok'); renderSettings(); } catch(e){ toast(e.message,'err'); } } async function deleteUser(id,email){ if(!confirm(`Delete ${email}?`)) return; try{ await api('DELETE','/users/'+id); toast('User deleted','ok'); renderSettings(); } catch(e){ toast(e.message,'err'); } } async function adminResetUserPassword(id,email){ const password = prompt(`Enter a new temporary password for ${email}`); if(!password) return; try{ await api('POST',`/users/${id}/reset-password`,{ password }); toast('Password reset saved','ok'); } catch(e){ toast(e.message,'err'); } } async function loadOpsPanel(){ const box=document.getElementById('ops-wrap'); if(!box) return; try{ const [ops,jobs,backups] = await Promise.all([api('GET','/ops/status'), api('GET','/jobs/summary'), api('GET','/backups')]); box.innerHTML = `
${analyticsStat('Uptime', Math.round((ops.uptime_seconds||0)/60)+'m', ops.status)}${analyticsStat('Dead jobs', ops.dead_jobs||0, 'warn threshold '+(ops.health_warn_dead_jobs||'—'))}${analyticsStat('Queued jobs', ops.job_counts?.queued||0, 'running '+(ops.job_counts?.running||0))}${analyticsStat('Backups', backups.items?.length||0, ops.last_backup_at?('last '+fmtDatetime(ops.last_backup_at)):'none yet')}
🧰

Queue summary

${(jobs.byStatus||[]).map(r=>``).join('') || ''}
StatusCount
${esc(r.status)}${r.count}
No jobs
${(jobs.dead||[]).length?`
Latest dead job: ${(jobs.dead[0].job_type||'job')} · ${(jobs.dead[0].last_error||'').slice(0,120)}
`:''}
💾

Backups

${(backups.items||[]).slice(0,8).map(b=>``).join('') || ''}
WhenStatusFileSize
${fmtDatetime(b.created_at)}${esc(b.status)}${esc(b.file_name||'—')}${esc(b.file_size_human||'—')}
No backups yet
`; } catch(e){ box.innerHTML=`
${esc(e.message)}
`; } } async function runManualBackup(){ try{ await api('POST','/backups/run',{}); toast('Backup started','ok'); loadOpsPanel(); } catch(e){ toast(e.message,'err'); } } async function saveSettings() { const body = { smtpHost:document.getElementById('s-host')?.value.trim(), smtpPort:parseInt(document.getElementById('s-port')?.value||'587'), smtpUser:document.getElementById('s-user')?.value.trim(), fromName:document.getElementById('s-fname')?.value.trim(), fromEmail:document.getElementById('s-femail')?.value.trim() }; const p=document.getElementById('s-pass')?.value; if(p) body.smtpPass=p; const np=document.getElementById('s-newpass')?.value; if(np) body.password=np; try { await api('PUT', '/settings', body); toast('Settings saved', 'ok'); } catch(e) { toast(e.message, 'err'); } } async function renderAdmin() { const el = document.getElementById('p-admin'); el.innerHTML = '

Loading…

'; try { const orgs = await api('GET', '/admin/orgs'); el.innerHTML = `
🏢

Organisations

${orgs.map(o => orgRow(o)).join('')}
NameEmailPlanUsedLimitCreated
${orgs.length===0?'

No organisations yet

':''}
`; } catch(e) { el.innerHTML = `

${e.message}

`; } } function orgRow(o) { // Embed org data as a JSON data attribute so Edit needs no API call const data = encodeURIComponent(JSON.stringify(o)); return ` ${esc(o.name)} ${esc(o.owner_email)} ${o.unlimited?'Unlimited':o.reset_monthly?'Monthly':'Fixed'} ${o.campaigns_used} ${o.unlimited?'∞':o.campaign_limit} ${fmtDate(o.created_at)}
`; } function orgFormHtml(o, targetId) { const cancelAction = o ? `document.getElementById('oedit-${targetId||''}')?.remove()` : `document.getElementById('org-create-wrap').innerHTML=''`; const submitAction = o ? `updateOrg('${o?.id||''}')` : `createOrg()`; return `
${o?'Edit Organisation':'New Organisation'}
`; } function showCreateOrg() { document.getElementById('org-create-wrap').innerHTML = orgFormHtml(null, null); document.getElementById('of-name')?.focus(); } function showEditOrg(btn) { // Remove any already-open edit forms document.querySelectorAll('[id^="oedit-"]').forEach(el => el.remove()); const o = JSON.parse(decodeURIComponent(btn.dataset.org)); const row = document.getElementById('orow-' + o.id); if (!row) return; const tr = document.createElement('tr'); tr.id = 'oedit-' + o.id; tr.innerHTML = `${orgFormHtml(o, o.id)}`; row.insertAdjacentElement('afterend', tr); // Scroll the form into view smoothly tr.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); document.getElementById('of-name')?.focus(); } function readOrgForm(req) { const pass = document.getElementById('of-pass')?.value; const b = { name: document.getElementById('of-name')?.value.trim(), ownerEmail: document.getElementById('of-email')?.value.trim(), campaignLimit: parseInt(document.getElementById('of-limit')?.value || '10'), unlimited: document.getElementById('of-unlimited')?.checked, resetMonthly: document.getElementById('of-monthly')?.checked, }; if (pass) b.password = pass; else if (req) { toast('Password required', 'err'); return null; } return b; } async function createOrg() { const b=readOrgForm(true); if(!b)return; try{await api('POST','/admin/orgs',b);renderAdmin();toast('Organisation created','ok');}catch(e){toast(e.message,'err');} } async function updateOrg(id) { const b=readOrgForm(false); if(!b)return; try{await api('PUT','/admin/orgs/'+id,b);document.getElementById('oedit-'+id)?.remove();renderAdmin();toast('Updated','ok');}catch(e){toast(e.message,'err');} } async function deleteOrg(id,name) { if(!confirm(`Delete "${name}" and all their data?`))return; try{await api('DELETE','/admin/orgs/'+id);renderAdmin();}catch(e){toast(e.message,'err');} } async function resetUsage(id,name) { if(!confirm(`Reset usage for "${name}"?`))return; try{await api('POST',`/admin/orgs/${id}/reset-usage`);renderAdmin();toast('Usage reset','ok');}catch(e){toast(e.message,'err');} } function statusBadge(status) { const map = { scheduled:['sb-blue','Scheduled'], sending:['sb-amber','Sending…'], sent:['sb-green','Sent'], failed:['sb-red','Failed'], expired:['sb-gray','Expired'], draft:['sb-gray','Draft'], due:['sb-amber','Due'], overdue:['sb-red','Overdue'], paid:['sb-green','Paid'], cancelled:['sb-gray','Cancelled'], refunded:['sb-purple','Refunded'], active:['sb-green','Active'], open:['sb-teal','Opened'], click:['sb-purple','Clicked'], }; const [cls,label]=map[status]||['sb-gray',status]; return `${label}`; } function invoiceStatus(inv) { if (inv.status==='paid') return statusBadge('paid'); if (inv.status==='cancelled') return statusBadge('cancelled'); if (inv.status==='refunded') return statusBadge('refunded'); if (inv.status==='draft') return statusBadge('draft'); if (inv.overdue) return statusBadge('overdue'); if (inv.status==='sent') { if (inv.due_at) { const d=Math.ceil((new Date(inv.due_at)-new Date())/86400000); if (d<=3&&d>=0) return `Due in ${d}d`; } return statusBadge('sent'); } return statusBadge(inv.status); } function subscriptionBadge(sub) { if (!sub||!sub.expires_at) return statusBadge('expired'); const d=Math.ceil((new Date(sub.expires_at)-new Date())/86400000); if (d<0) return statusBadge('expired'); if (d<=7) return `${d}d left`; return `${d} days left`; } function fmtCurrency(amount,currency='GBP'){ const sym=currency==='GBP'?'£':currency==='EUR'?'€':'$'; return sym+parseFloat(amount||0).toFixed(2); } async function renderDashboard() { const el=document.getElementById('p-dashboard'); el.innerHTML='

Loading…

'; try { const [d,campaigns,invoices]=await Promise.all([api('GET','/dashboard'),api('GET','/campaigns'),api('GET','/invoices')]); const counts={}; (d.campaignCounts||[]).forEach(r=>counts[r.status]=r.c); const total=Object.values(counts).reduce((a,b)=>a+b,0); const sub=d.subscription; const pendingInvs=invoices.filter(i=>i.status==='sent'||i.status==='draft'||i.overdue); const daysLeft=sub?Math.ceil((new Date(sub.expires_at)-new Date())/86400000):null; const subColor=daysLeft===null?'var(--rd)':daysLeft<0?'var(--rd)':daysLeft<=7?'#BA7517':'var(--te)'; el.innerHTML=`
${sub?`
${daysLeft===null||daysLeft<0?'Expired':daysLeft+'d'}
${esc(sub.plan_name||'Subscription')}
Expires ${fmtDate(sub.expires_at)}
${subscriptionBadge(sub)}
`:''}

Campaigns

${counts.sent||0}
Sent
${counts.scheduled||0}
Scheduled
${counts.sending||0}
Sending
${counts.failed||0}
Failed
${d.opens||0}
Opens
${total}
Total
${pendingInvs.length?`
🧾

Invoices outstanding

Paid total: ${fmtCurrency(d.invoicePaidTotal||0)}
${pendingInvs.map(i=>``).join('')}
RefAmountDueStatus
${esc(i.reference)} ${fmtCurrency(i.total_amount,i.currency)} ${fmtDate(i.due_at)} ${invoiceStatus(i)}
`:''}
📊

Recent campaigns

${renderCampaignTableHtml(campaigns.slice(0,8))} ${campaigns.length===0?'

No campaigns yet

':''}
`; } catch(e) { el.innerHTML=`

${esc(e.message)}

`; } } function renderCampaignTableHtml(campaigns) { if (!campaigns.length) return ''; return `
${campaigns.map(c=>``).join('')}
SubjectStatusSent / Scheduled
${esc(c.subject)} ${statusBadge(c.status||'sent')} ${c.scheduled_at?fmtDatetime(c.scheduled_at):fmtDate(c.sent_at)}
`; } function handleLogoUpload(input) { const file=input.files[0]; if(!file) return; if(file.size>500*1024){toast('Logo must be under 500 KB','err');return;} const reader=new FileReader(); reader.onload=e=>{ const img=new Image(); img.onload=()=>{ const maxW=180,maxH=64; let w=img.width,h=img.height; if(w>maxW||h>maxH){const r=Math.min(maxW/w,maxH/h);w=Math.round(w*r);h=Math.round(h*r);} const cv=document.createElement('canvas');cv.width=w;cv.height=h; cv.getContext('2d').drawImage(img,0,0,w,h); _logoBase64=cv.toDataURL(file.type==='image/png'?'image/png':'image/jpeg',0.92); document.getElementById('logo-upload-name').textContent=`✓ ${file.name} (${w}×${h}px)`; }; img.src=e.target.result; }; reader.readAsDataURL(file); } function clearLogo(){_logoBase64='__clear__';toast('Logo will be removed on save','inf');} async function saveCompanyProfile() { const body={ companyDisplayName:document.getElementById('s-cname')?.value.trim(), companyAddress:document.getElementById('s-caddr')?.value.trim(), companyReg:document.getElementById('s-creg')?.value.trim(), companyVat:document.getElementById('s-cvat')?.value.trim(), companyWebsite:document.getElementById('s-cweb')?.value.trim(), companyPhone:document.getElementById('s-cphone')?.value.trim(), defaultCc:document.getElementById('s-dcc')?.value.trim(), }; if(_logoBase64==='__clear__') body.logoBase64=''; else if(_logoBase64) body.logoBase64=_logoBase64; try{await api('PUT','/settings',body);toast('Company profile saved','ok');_logoBase64='';} catch(e){toast(e.message,'err');} } async function renderInvoices() { const el=document.getElementById('p-invoices'); el.innerHTML='

Loading…

'; try { const invs=await api('GET','/invoices'); const paid=invs.filter(i=>i.status==='paid').reduce((s,i)=>s+i.total_amount,0); el.innerHTML=`
🧾

Invoices

Paid total: ${fmtCurrency(paid)}
${invs.map(i=>``).join('')}
ReferenceAmountIssuedDueStatus
${esc(i.reference)} ${fmtCurrency(i.total_amount,i.currency)} ${fmtDate(i.issued_at)} ${fmtDate(i.due_at)} ${invoiceStatus(i)}
${invs.length===0?'

No invoices yet

':''}
`; } catch(e){el.innerHTML=`

${esc(e.message)}

`;} } async function viewInvoiceModal(id) { // Re-use viewer overlay to show invoice const overlay=document.getElementById('viewer-overlay'); const vpTitle=document.getElementById('vp-title'); const vpBody=document.getElementById('vp-body'); vpTitle.textContent='Invoice'; vpBody.innerHTML='

Loading…

'; overlay.classList.remove('hid'); try { const allInvs=await api('GET','/invoices'); const inv=allInvs.find(i=>i.id===id); if(!inv){vpBody.innerHTML='

Not found

';return;} _currentInvoice=inv; vpTitle.textContent=inv.reference; vpBody.innerHTML=buildInvoiceHtml(inv); } catch(e){vpBody.innerHTML=`

${esc(e.message)}

`;} } async function downloadInvoicePdf(id) { const allInvs=await api('GET','/invoices'); const inv=allInvs.find(i=>i.id===id); if(!inv) return toast('Invoice not found','err'); _currentInvoice=inv; printInvoice(inv); } function printInvoice(inv) { const area=document.getElementById('print-area'); area.style.display='block'; area.innerHTML=buildInvoiceHtml(inv); window.print(); setTimeout(()=>{area.style.display='none';area.innerHTML='';},1000); } function buildInvoiceHtml(inv) { const items=JSON.parse(inv.line_items_json||'[]'); const stamp=inv.status==='paid'?'paid':inv.status==='cancelled'?'cancelled':inv.status==='refunded'?'refunded':''; const k777Logo='' ; // will be injected from system settings if available return `
${stamp?`
${stamp.toUpperCase()}
`:''}
${k777Logo?`Logo`:`
K777 MAIL
`}
Invoice
${esc(inv.reference)}
${invoiceStatus(inv)}
Issue date
${fmtDate(inv.issued_at)}
Due date
${fmtDate(inv.due_at)}
${inv.paid_at?`
Paid on
${fmtDate(inv.paid_at)}
`:''}
${items.map(it=>``).join('')}
DescriptionQtyUnit priceTotal
${esc(it.description||'')} ${parseFloat(it.qty||1)} ${fmtCurrency(it.unit_price||0,inv.currency)} ${fmtCurrency((parseFloat(it.qty||1)*parseFloat(it.unit_price||0)),inv.currency)}
${inv.vat_enabled?`
Subtotal${fmtCurrency(inv.amount,inv.currency)}
VAT @ ${inv.vat_rate}%${fmtCurrency(inv.vat_amount,inv.currency)}
`:''}
${inv.vat_enabled?'Total inc. VAT':'Total'} (${inv.currency})${fmtCurrency(inv.total_amount,inv.currency)}
Pay by bank transfer
Loading bank details…
K777 Ltd ${esc(inv.reference)}
`; } function loadBankDetails() { // Called lazily after invoices are shown } async function injectBankDetails(invId) { try { const s=await api('GET','/admin/system-settings').catch(()=>null); if (!s) return; const el=document.getElementById('inv-bank-details-'+invId); if(el) el.innerHTML=`
Bank
${esc(s.bank_name||'')}
Account
${esc(s.bank_account||'')}
Sort code
${esc(s.bank_sort_code||'')}
Account name
${esc(s.bank_account_name||'')}
IBAN
${esc(s.bank_iban||'')}
Reference
${invId}
`; const leg=document.getElementById('inv-legal-'+invId); if(leg){ const parts=['K777 Ltd',s.k777_reg?'Reg: '+s.k777_reg:'',s.vat_enabled==='1'&&s.vat_number?'VAT No: '+s.vat_number:''].filter(Boolean); leg.textContent=parts.join(' · '); } } catch(_){} } function showTermsModal() { api('GET', '/admin/system-settings').catch(()=>null).then(s => { const tc = (s && s.terms_text) ? s.terms_text : 'Please review the terms of service for K777 Mail.\n\n1. You agree to use this service lawfully.\n2. Payment is due within 7 days of invoice.\n3. These terms are governed by English law.'; const ov = document.createElement('div'); ov.id = 'tc-overlay'; ov.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:9999;display:flex;align-items:center;justify-content:center;padding:16px'; ov.innerHTML = '
' + '
Terms and conditions
Read and accept before continuing
' + '
' + esc(tc) + '
' + '
' + '
' + '
Scroll to read all terms
' + '
'; document.body.appendChild(ov); }); } function checkTcScroll() { const el = document.getElementById('tc-body'); const pct = Math.min(100, Math.round((el.scrollTop / Math.max(1, el.scrollHeight - el.clientHeight)) * 100)); document.getElementById('tc-bar').style.width = pct + '%'; const btn = document.getElementById('tc-btn'); if (pct >= 95) { btn.disabled=false; btn.style.opacity='1'; btn.style.cursor='pointer'; document.getElementById('tc-hint').textContent='You have read all the terms'; } else { document.getElementById('tc-hint').textContent='Scroll to read all terms ('+pct+'%)'; } } async function acceptTerms() { try { await api('POST', '/auth/accept-terms', {}); SESSION.requiresTerms = false; localStorage.setItem('k777_session', JSON.stringify(SESSION)); document.getElementById('tc-overlay').remove(); go(SESSION.role === 'super' ? 'superdash' : 'dashboard'); } catch(e) { toast(e.message, 'err'); } } async function renderSuperDash() { const el = document.getElementById('p-superdash'); el.innerHTML = '

Loading…

'; try { const d = await api('GET', '/admin/dashboard'); const overdue = d.overdueInvs || []; const alertHtml = overdue.length ? '
' + '' + ''+overdue.length+' overdue invoice'+(overdue.length!==1?'s':'')+' — ' + overdue.slice(0,3).map(i=>esc(i.org_name)+' ('+esc(i.reference)+')').join(', ') + (overdue.length>3?' and '+(overdue.length-3)+' more':'')+'' + '
' : ''; const stats = [ ['Organisations', d.orgCount, 'var(--txt)'], ['Campaigns', d.totalCamps, '#378ADD'], ['Delivered', d.totalDelivered, 'var(--te)'], ['Opens', d.opens, '#1D9E75'], ['Paid revenue', '£'+parseFloat(d.revenue||0).toFixed(2), '#EF9F27'], ].map(([l,v,c],i)=>'
'+'
'+v+'
'+'
'+l+'
').join(''); const rows = (d.orgStats||[]).map(o => { const sb = !o.subscription ? 'No sub' : subscriptionBadge(o.subscription); const ib = o.latestInvoice ? invoiceStatus({...o.latestInvoice,overdue:o.invOverdue}) : 'None'; const bar = '
'+o.openRate+'%
'; return ''+esc(o.name)+''+o.campaigns+''+o.delivered+''+bar+''+sb+''+ib+'' +''; }).join(''); el.innerHTML = '
'+alertHtml +'

Platform overview

' +'
'+stats+'
' +'
🏢

Organisation performance

' +'' +'' +'
' +'
' +''+rows+'
OrganisationCampaignsDeliveredOpen rateSubscriptionInvoice
' +((d.orgStats||[]).length===0?'

No organisations yet

':'') +'
'; } catch(e) { el.innerHTML = '

'+esc(e.message)+'

'; } } async function renderSuperSettings() { const el=document.getElementById('p-supersettings'); el.innerHTML='

Loading…

'; try { const s=await api('GET','/admin/system-settings'); el.innerHTML='
' +'
📧

System SMTP

' +'

Used for welcome emails and invoice reminders. Not used for campaigns.

' +'
' +'
' +'
' +'
' +'
' +'
' +'
' +'
🏦

Bank payment details

' +'
' +'
' +'
' +'
' +'
' +'
' +'
💰

VAT settings

' +'
' +'' +'
' +'
' +'
' +'
' +'
' +'
🏷

K777 logo

' +'

Appears on invoice headers. Ideal: 140×48px.

' +(s.k777_logo_base64?'':'
') +'
' +(s.k777_logo_base64?'':'') +'
' +'
' +'
🏢

K777 company details

' +'
' +'
' +'
' +'
' +'
📄

Terms & conditions

' +'

Shown to users at first login. They must scroll to the bottom and accept before accessing their account.

' +'
' +'
' +'' +'
'; } catch(e){el.innerHTML='

'+esc(e.message)+'

';} } function toggleVatFields() { const on=document.getElementById('ss-vatena')?.checked; const f=document.getElementById('ss-vat-fields'); if(f) f.style.cssText=on?'':'opacity:.4;pointer-events:none'; } function clearSuperLogo(){_superLogo='__clear__';toast('Logo will be removed on save','inf');} function handleSuperLogo(input) { const file=input.files[0]; if(!file) return; if(file.size>500*1024){toast('Logo must be under 500 KB','err');return;} const reader=new FileReader(); reader.onload=e=>{ const img=new Image(); img.onload=()=>{ const maxW=180,maxH=64; let w=img.width,h=img.height; if(w>maxW||h>maxH){const r=Math.min(maxW/w,maxH/h);w=Math.round(w*r);h=Math.round(h*r);} const cv=document.createElement('canvas');cv.width=w;cv.height=h; cv.getContext('2d').drawImage(img,0,0,w,h); _superLogo=cv.toDataURL(file.type==='image/png'?'image/png':'image/jpeg',0.92); document.getElementById('ss-logo-name').textContent=`✓ ${file.name} (${w}×${h}px)`; const prev=document.getElementById('ss-logo-preview'); if(prev){prev.src=_superLogo;prev.style.display='block';} }; img.src=e.target.result; }; reader.readAsDataURL(file); } async function saveSuperSettings() { const vatOn=document.getElementById('ss-vatena')?.checked; if(vatOn){ if(!document.getElementById('ss-vatno')?.value.trim()) return toast('VAT registration number is required when VAT is enabled','err'); if(!document.getElementById('ss-vatrate')?.value) return toast('VAT rate % is required when VAT is enabled','err'); } const spass=document.getElementById('ss-spass')?.value; const body={ sys_smtp_host:document.getElementById('ss-shost')?.value.trim(), sys_smtp_port:document.getElementById('ss-sport')?.value.trim()||'587', sys_smtp_user:document.getElementById('ss-suser')?.value.trim(), sys_smtp_from_name:document.getElementById('ss-sfname')?.value.trim(), sys_smtp_from_email:document.getElementById('ss-sfemail')?.value.trim(), bank_name:document.getElementById('ss-bname')?.value.trim(), bank_account_name:document.getElementById('ss-baname')?.value.trim(), bank_account:document.getElementById('ss-bacc')?.value.trim(), bank_sort_code:document.getElementById('ss-bsort')?.value.trim(), bank_iban:document.getElementById('ss-biban')?.value.trim(), vat_enabled:vatOn?'1':'0', vat_number:document.getElementById('ss-vatno')?.value.trim()||'', vat_rate:document.getElementById('ss-vatrate')?.value.trim()||'0', k777_company_name:document.getElementById('ss-cname')?.value.trim(), k777_reg:document.getElementById('ss-creg')?.value.trim(), k777_address:document.getElementById('ss-caddr')?.value.trim(), terms_text:document.getElementById('ss-terms')?.value||'', }; if(spass) body.sys_smtp_pass=spass; if(_superLogo==='__clear__') body.k777_logo_base64=''; else if(_superLogo) body.k777_logo_base64=_superLogo; try{await api('PUT','/admin/system-settings',body);toast('Settings saved','ok');_superLogo='';} catch(e){toast(e.message,'err');} } async function renderSuperSubs() { const el=document.getElementById('p-supersubs'); el.innerHTML='

Loading…

'; try { const [subs,orgs]=await Promise.all([api('GET','/admin/subscriptions'),api('GET','/admin/orgs')]); el.innerHTML=`
📅

Subscriptions

${subs.map(s=>``).join('')}
OrgPlanExpiresStatus
${esc(s.org_name||'')} ${esc(s.plan_name||'')} ${fmtDate(s.expires_at)} ${subscriptionBadge(s)}
${subs.length===0?'

No subscriptions

':''}
`; window._subOrgs=orgs; } catch(e){el.innerHTML=`

${esc(e.message)}

`;} } function showAddSub() { const orgs=window._subOrgs||[]; document.getElementById('sub-form-wrap').innerHTML=`
`; } async function createSub() { const orgId=document.getElementById('sub-org')?.value; const expiresAt=document.getElementById('sub-expires')?.value; if(!orgId||!expiresAt) return toast('Organisation and expiry required','err'); try{ await api('POST','/admin/subscriptions',{orgId,planName:document.getElementById('sub-plan')?.value,startsAt:document.getElementById('sub-starts')?.value,expiresAt}); renderSuperSubs();toast('Subscription created','ok'); }catch(e){toast(e.message,'err');} } async function extendSub(id,planName) { const d=prompt('New expiry date (YYYY-MM-DD):'); if(!d) return; try{await api('PUT','/admin/subscriptions/'+id,{expiresAt:new Date(d).toISOString()});renderSuperSubs();toast('Updated','ok');} catch(e){toast(e.message,'err');} } async function deleteSub(id) { if(!confirm('Delete this subscription?')) return; try{await api('DELETE','/admin/subscriptions/'+id);renderSuperSubs();}catch(e){toast(e.message,'err');} } async function renderSuperInvoices() { const el=document.getElementById('p-superinv'); el.innerHTML='

Loading…

'; try { const [invs,orgs,settings]=await Promise.all([api('GET','/admin/invoices'),api('GET','/admin/orgs'),api('GET','/admin/system-settings')]); const paidTotal=invs.filter(i=>i.status==='paid').reduce((s,i)=>s+i.total_amount,0); window._invOrgs=orgs; window._sysSettings=settings; el.innerHTML=`
🧾

Invoices

Paid total: ${fmtCurrency(paidTotal)}
${invs.map(inv=>superInvRow(inv)).join('')}
ReferenceOrganisationAmountIssuedDueStatus
${invs.length===0?'

No invoices

':''}
`; } catch(e){el.innerHTML=`

${esc(e.message)}

`;} } function superInvRow(inv) { return ` ${esc(inv.reference)} ${esc(inv.org_name||'')} ${fmtCurrency(inv.total_amount,inv.currency)} ${fmtDate(inv.issued_at)} ${fmtDate(inv.due_at)} ${invoiceStatus({...inv,overdue:inv.status==='sent'&&isExpired(inv.due_at)})}
${inv.status==='draft'?``:''} ${inv.status==='sent'||inv.status==='draft'?``:''} ${inv.status!=='cancelled'&&inv.status!=='paid'?``:''} ${inv.status==='paid'?``:''}
`; } async function superInvAction(id, action) { let body={}; if(action==='paid'){const notes=prompt('Payment notes (optional):');if(notes!==null)body.paidNotes=notes;} try{await api('PATCH',`/admin/invoices/${id}/${action}`,body);renderSuperInvoices();toast(`Invoice ${action==='paid'?'marked as paid':action+'d'}`, 'ok');} catch(e){toast(e.message,'err');} } async function superDeleteInv(id) { if(!confirm('Delete this invoice?')) return; try{await api('DELETE','/admin/invoices/'+id);renderSuperInvoices();}catch(e){toast(e.message,'err');} } async function superViewInv(id) { const invs=await api('GET','/admin/invoices'); const inv=invs.find(i=>i.id===id); if(!inv) return; const wrap=document.getElementById('inv-view-wrap'); const s=window._sysSettings||{}; const logoHtml=s.k777_logo_base64?`Logo`:`
K777 MAIL
`; const overdue=inv.status==='sent'&&isExpired(inv.due_at); wrap.innerHTML=`
🧾

${esc(inv.reference)}

${buildInvoiceHtmlFull(inv,s,overdue)}
`; } function buildInvoiceHtmlFull(inv,s,overdue) { const items=JSON.parse(inv.line_items_json||'[]'); const stamp=inv.status==='paid'?'paid':inv.status==='cancelled'?'cancelled':inv.status==='refunded'?'refunded':''; return `
${stamp?`
${stamp.toUpperCase()}
`:''}
${s.k777_logo_base64?`Logo`:`
K777 MAIL
`}
Invoice
${esc(inv.reference)}
${invoiceStatus({...inv,overdue})}
Billed to
${esc(inv.org_name||'')}
Billed from
${esc(s.k777_company_name||'K777 Ltd')}
Issue date
${fmtDate(inv.issued_at)}
Due date
${fmtDate(inv.due_at)}
${items.map(it=>``).join('')}
DescriptionQtyUnit priceTotal
${esc(it.description||'')}${parseFloat(it.qty||1)} ${fmtCurrency(it.unit_price||0,inv.currency)} ${fmtCurrency((parseFloat(it.qty||1)*parseFloat(it.unit_price||0)),inv.currency)}
${inv.vat_enabled?`
Subtotal${fmtCurrency(inv.amount,inv.currency)}
VAT @ ${inv.vat_rate}%${fmtCurrency(inv.vat_amount,inv.currency)}
`:''}
${inv.vat_enabled?'Total inc. VAT':'Total'} (${inv.currency})${fmtCurrency(inv.total_amount,inv.currency)}
Pay by bank transfer
Bank
${esc(s.bank_name||'')}
Account
${esc(s.bank_account||'')}
Sort code
${esc(s.bank_sort_code||'')}
Account name
${esc(s.bank_account_name||'')}
IBAN
${esc(s.bank_iban||'')}
Reference
${esc(inv.reference)}
${[esc(s.k777_company_name||'K777 Ltd'),s.k777_reg?'Reg: '+esc(s.k777_reg):'',s.vat_enabled==='1'&&s.vat_number?'VAT No: '+esc(s.vat_number):''].filter(Boolean).join(' · ')} ${esc(inv.reference)}
`; } async function superPrintInv(id) { const invs=await api('GET','/admin/invoices'); const inv=invs.find(i=>i.id===id); if(!inv) return; const s=window._sysSettings||{}; const overdue=inv.status==='sent'&&isExpired(inv.due_at); const area=document.getElementById('print-area'); area.style.display='block'; area.innerHTML=buildInvoiceHtmlFull(inv,s,overdue); window.print(); setTimeout(()=>{area.style.display='none';area.innerHTML='';},1000); } function showCreateInvoice() { const orgs=window._invOrgs||[]; const s=window._sysSettings||{}; const vatOn=s.vat_enabled==='1'; document.getElementById('inv-create-wrap').innerHTML=`
Line items
${vatOn?`
VAT @ ${s.vat_rate}% will be applied automatically
`:''}
Total: £0.00${vatOn?` (inc. VAT @ ${s.vat_rate}%)`:''}
`; window._niItems=[]; autoFillPlanPrice(); addInvoiceItem(); } function autoFillPlanPrice() { const sel=document.getElementById('ni-org'); if(!sel) return; const opt=sel.options[sel.selectedIndex]; const price=parseFloat(opt.dataset.price||0); const currency=opt.dataset.currency||'GBP'; document.getElementById('ni-currency').value=currency; if(price>0&&window._niItems&&window._niItems.length>0){ const firstItem=document.getElementById('ni-item-0'); if(firstItem){document.getElementById('ni-desc-0').value='K777 Mail Plan (monthly)';document.getElementById('ni-price-0').value=price;} } updateNiTotal(); } function addInvoiceItem() { const i=_niCount++; if(!window._niItems) window._niItems=[]; window._niItems.push(i); const wrap=document.getElementById('ni-items'); const row=document.createElement('div'); row.id=`ni-item-${i}`; row.style.cssText='display:grid;grid-template-columns:2fr 1fr 1fr auto;gap:8px;margin-bottom:8px;align-items:flex-end'; row.innerHTML=`
`; wrap.appendChild(row); if(i===0) setTimeout(autoFillPlanPrice,50); } function updateNiTotal() { const s=window._sysSettings||{}; const vatOn=s.vat_enabled==='1'; const vatRate=parseFloat(s.vat_rate||0); const currency=document.getElementById('ni-currency')?.value||'GBP'; const sym=currency==='GBP'?'£':currency==='EUR'?'€':'$'; let subtotal=0; document.querySelectorAll('[id^="ni-qty-"]').forEach(el=>{ const i=el.id.replace('ni-qty-',''); const qty=parseFloat(el.value||1); const price=parseFloat(document.getElementById('ni-price-'+i)?.value||0); subtotal+=qty*price; }); const vatAmt=vatOn?subtotal*vatRate/100:0; const total=subtotal+vatAmt; const el=document.getElementById('ni-total-preview'); if(el) el.textContent=sym+total.toFixed(2); } async function createInvoice() { const orgId=document.getElementById('ni-org')?.value; const currency=document.getElementById('ni-currency')?.value||'GBP'; const items=[]; document.querySelectorAll('[id^="ni-desc-"]').forEach(el=>{ const i=el.id.replace('ni-desc-',''); const desc=el.value.trim(); const qty=parseFloat(document.getElementById('ni-qty-'+i)?.value||1); const price=parseFloat(document.getElementById('ni-price-'+i)?.value||0); if(desc||price) items.push({description:desc,qty,unit_price:price}); }); if(!items.length) return toast('Add at least one line item','err'); try{await api('POST','/admin/invoices',{orgId,lineItems:items,currency});renderSuperInvoices();toast('Invoice created','ok');} catch(e){toast(e.message,'err');} } async function showTermsModal() { let termsText = 'Loading terms…'; try { const s = await api('GET', '/admin/system-settings').catch(() => ({})); termsText = s.terms_text || defaultTermsText(); } catch(_) { termsText = defaultTermsText(); } const overlay = document.createElement('div'); overlay.id = 'tc-overlay'; overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:9999;display:flex;align-items:center;justify-content:center;padding:20px'; overlay.innerHTML = `
Terms and conditions
Please read and accept before continuing to your account
${esc(termsText)}
Scroll to read all terms
`; document.body.appendChild(overlay); } function tcScroll() { const el = document.getElementById('tc-text'); const pct = Math.min(100, Math.round((el.scrollTop / Math.max(1, el.scrollHeight - el.clientHeight)) * 100)); document.getElementById('tc-bar').style.width = pct + '%'; const btn = document.getElementById('tc-accept-btn'); const hint = document.getElementById('tc-hint'); if (pct >= 95) { btn.disabled = false; btn.style.cssText = 'padding:9px 22px;border-radius:7px;border:none;font-size:13px;font-weight:600;background:#1D1D1B;color:#EF9F27;cursor:pointer'; hint.textContent = 'You have read all the terms'; } else { hint.textContent = 'Scroll to read all terms (' + pct + '%)'; } } async function acceptTerms() { try { await api('POST', '/auth/accept-terms', {}); SESSION.requiresTerms = false; localStorage.setItem('k777_session', JSON.stringify(SESSION)); document.getElementById('tc-overlay')?.remove(); go('dashboard'); } catch(e) { toast(e.message, 'err'); } } function defaultTermsText() { return `1. Acceptance of Terms By accessing and using K777 Mail, you agree to be bound by these Terms and Conditions. If you do not agree, you may not use this service. 2. Use of the Service K777 Mail is provided for the purpose of sending email campaigns to your contacts. You agree not to use the service to send spam, unsolicited communications, or content that violates applicable law. 3. Data and Privacy You are responsible for ensuring that your use of the service complies with data protection regulations including UK GDPR. You must have appropriate consent from recipients before sending campaigns. 4. Payment Subscription fees are invoiced monthly. Payment is due within 7 days of invoice issue. Failure to pay may result in suspension of your account. 5. Limitation of Liability K777 Ltd shall not be liable for any indirect, incidental, or consequential damages arising from use of the service. Our maximum liability is limited to fees paid in the preceding month. 6. Governing Law These terms are governed by the laws of England and Wales. Any disputes shall be resolved in the courts of England and Wales.`; } async function renderSuperDash() { const el = document.getElementById('p-superdash'); el.innerHTML = '

Loading…

'; try { const d = await api('GET', '/admin/dashboard'); const overdue = d.overdueInvoices || []; const orgs = d.orgBreakdown || []; const paidRev = parseFloat(d.paidRevenue || 0); el.innerHTML = `
${overdue.length ? `
${overdue.length} invoice${overdue.length!==1?'s':''} overdue: ${overdue.slice(0,3).map(i=>`${esc(i.org_name)} (${esc(i.reference)})`).join(', ')}${overdue.length>3?` and ${overdue.length-3} more`:''}
` : ''}
${d.orgs||0}
Organisations
${d.totalCamps||0}
Campaigns
${d.delivered||0}
Delivered
${d.opens||0}
Opens tracked
£${paidRev.toFixed(2)}
Paid revenue
🏠

Organisation performance

${orgs.map(o => { const openRate = o.total_sent > 0 ? Math.round((o.opens / o.total_sent) * 100) : 0; const subDays = o.sub_expires ? Math.ceil((new Date(o.sub_expires)-new Date())/86400000) : null; const subBadge = subDays===null ? 'No sub' : subDays<0 ? 'Expired' : subDays<=7 ? `${subDays}d left` : `Active`; const invBadge = !o.latest_inv_status ? 'None' : statusBadge(o.latest_inv_status); return ``; }).join('')}
OrganisationCampaignsDeliveredOpen rateSubscriptionLatest invoice
${esc(o.name)} ${o.campaign_count||0} ${o.delivered||0}
${openRate}%
${subBadge} ${invBadge}
${orgs.length===0?'

No organisations yet

':''}
`; } catch(e) { el.innerHTML=`

${esc(e.message)}

`; } } async function renderSuperSettings() { const el = document.getElementById('p-supersettings'); el.innerHTML = '

Loading…

'; try { const s = await api('GET', '/admin/system-settings'); el.innerHTML = `
🔗

System SMTP

Used to send welcome emails to new users and invoice overdue reminders. Not used for campaigns.

🏦

Bank payment details

💰

VAT settings

🏷

K777 company logo (invoices)

Appears in the invoice header. Ideal: 140×48px. PNG or JPG, max 500 KB.

${s.k777_logo_base64?``:'
'}
${s.k777_logo_base64?'':''}
🏠

K777 company details

📝

Terms and conditions text

Shown to new users at their first login. They must scroll to the bottom and accept before accessing their account.

`; } catch(e) { el.innerHTML=`

${esc(e.message)}

`; } } async function saveSuperSettings() { const vatOn = document.getElementById('ss-vatena')?.checked; if (vatOn) { if (!document.getElementById('ss-vatno')?.value.trim()) return toast('VAT registration number is required when VAT is enabled', 'err'); if (!document.getElementById('ss-vatrate')?.value.trim()) return toast('VAT rate % is required when VAT is enabled', 'err'); } const body = { sys_smtp_host: document.getElementById('ss-shost')?.value.trim(), sys_smtp_port: document.getElementById('ss-sport')?.value.trim()||'587', sys_smtp_user: document.getElementById('ss-suser')?.value.trim(), sys_from_name: document.getElementById('ss-sfname')?.value.trim(), sys_from_email: document.getElementById('ss-sfemail')?.value.trim(), bank_name: document.getElementById('ss-bname')?.value.trim(), bank_account_name: document.getElementById('ss-baname')?.value.trim(), bank_account: document.getElementById('ss-bacc')?.value.trim(), bank_sort_code: document.getElementById('ss-bsort')?.value.trim(), bank_iban: document.getElementById('ss-biban')?.value.trim(), vat_enabled: vatOn ? '1' : '0', vat_number: document.getElementById('ss-vatno')?.value.trim()||'', vat_rate: document.getElementById('ss-vatrate')?.value.trim()||'0', k777_company_name: document.getElementById('ss-cname')?.value.trim(), k777_reg: document.getElementById('ss-creg')?.value.trim(), k777_address: document.getElementById('ss-caddr')?.value.trim(), terms_text: document.getElementById('ss-terms')?.value, }; const spass = document.getElementById('ss-spass')?.value; if (spass) body.sys_smtp_pass = spass; if (_superLogo === '__clear__') body.k777_logo_base64 = ''; else if (_superLogo) body.k777_logo_base64 = _superLogo; try { await api('PUT', '/admin/system-settings', body); toast('Settings saved', 'ok'); _superLogo = ''; } catch(e) { toast(e.message, 'err'); } } function rulesEditorHtml(kind, model) { const fields = [ ['tag','Tag'],['source','Source'],['email','Email'],['status','Status'],['unsubscribed','Unsubscribed'], ['opened_campaign','Opened campaign'],['clicked_campaign','Clicked campaign'], ['last_opened_at','Last opened'],['last_clicked_at','Last clicked'] ]; const ops = ['equals','not_equals','contains','not_contains','exists','not_exists','true','false','before','after']; return `
${(model.rules||[]).map((r,i)=>`
`).join('')}
`; } function segmentSetLogic(v){ SEGMENT_FORM.logic=v; renderSegmentsEditor(); } function segmentSetRule(i,k,v){ SEGMENT_FORM.rules[i][k]=v; } function segmentAddRule(){ SEGMENT_FORM.rules.push({ field:'tag', operator:'contains', value:'' }); renderSegmentsEditor(); } function segmentRemoveRule(i){ SEGMENT_FORM.rules.splice(i,1); if(!SEGMENT_FORM.rules.length) SEGMENT_FORM.rules=[{ field:'tag', operator:'contains', value:'' }]; renderSegmentsEditor(); } function flowSetLogic(v){ FLOW_FORM.logic=v; renderFlowsEditor(); } function flowSetRule(i,k,v){ FLOW_FORM.rules[i][k]=v; } function flowAddRule(){ FLOW_FORM.rules.push({ field:'tag', operator:'contains', value:'' }); renderFlowsEditor(); } function flowRemoveRule(i){ FLOW_FORM.rules.splice(i,1); if(!FLOW_FORM.rules.length) FLOW_FORM.rules=[{ field:'tag', operator:'contains', value:'' }]; renderFlowsEditor(); }