1/** 2 * src/public/js/subscriber-automations.js â Subscribers â Automations. 3 * 4 * THE ARTEFACT IS THE SPEC. Alex approved the five-board Design artefact on 5 * 2026-09-17 ("Automations v1": Kit's visual automations, one straight line, 6 * our styling) and this file renders those five boards and nothing else: 7 * 8 * 1 The list My automations (n) · Templates; All/Active/Inactive/ 9 * Paused; search; sort; cards with a status pill, â®, 10 * "N subscribers" and the first three steps 11 * 2 The flow crumbs; the name (pencil); Flow · Subscribers tabs and 12 * the Active toggle; a banner; the straight line: entry 13 * node, step nodes with Completed / Currently (Only if: 14 * Passed / Stopped here), + between every pair and after 15 * the last, END OF AUTOMATION 16 * 3 The picker a right-hand panel: Add a step · â · All / When⦠/ 17 * Then⦠/ Only if⦠· the eight entry points, seven 18 * actions, three filters, the greyed "Later" group; then 19 * the small form for the chosen type 20 * 4 Subscribers filter · search · + Add subscribers · table of 21 * Subscriber · Entered · Where they are · Status · â® 22 * 5 Templates six cards, three step pills each, Use this template 23 * 24 * Every door is under /automations/v1 and calls the same service the 25 * Replicants MCP tools call, so the screen and the agent cannot drift. 26 */ 27(function () { 28 'use strict'; 29 30 var API = '/automations/v1'; 31 var S = { list: null, tab: 'mine', filter: 'all', q: '', sort: 'updated', templates: null, 32 a: null, atab: 'flow', picker: null, subs: null, sq: { status: 'active', q: '' }, 33 look: null, addOpen: false, addHits: [], addPicked: {} }; 34 35 var ENTRIES = [ 36 ['joins_form', 'Joins a form', 'Any website or landing-page form. Forms can already tag on submit; this reaches the engine today.'], 37 ['joins_community', 'Joins the community', 'Magic link, Google sign-in, or a coach invite.'], 38 ['buys_course', 'Buys a course', 'The buyer, the moment the course is granted.'], 39 ['free_course_signup', 'Signs up to a free course', 'The free-course signup path.'], 40 ['webinar_registered', 'Registers for a webinar', 'Webinar registration.'], 41 ['tag_added', 'Gets a tag', 'Any tag, from anywhere: a form, an import, Maya, another automation.'], 42 ['tag_removed', 'Loses a tag', 'A tag removed by hand or by an automation.'], 43 ['added_by_hand', 'Added by hand or import', 'Contacts â Add, or a CSV import.'], 44 /* Funnel triggers (docs/projects/funnel-emails/SPEC.md). Maya sets up the 45 main product's pair for every funnel; these let the coach add their own 46 for any sale in any funnel, upsells included (Alex, 2026-09-24). */ 47 ['left_checkout', 'Left checkout without paying', 'Gave their email on a funnelâs order form, or their card was declined, and hasnât bought 45 minutes later.'], 48 ['bought_from_funnel', 'Bought from a funnel', 'Any sale in the funnel you choose: the main product, a bump, an upsell or a downsell.'], 49 ]; 50 /* Joins a space (R10-Prog): offered only to a Spaces beta account 51 (hasSpacesBeta, the one client gate; the server refuses it otherwise). */ 52 var SPACE_ENTRY = ['joins_space', 'Joins a space', 'Invited, joins by themselves, buys a place, unlocks it on a path, or is imported. Only the first time they join that space.']; 53 function spacesOn() { return typeof window.hasSpacesBeta === 'function' && !!window.hasSpacesBeta(); } 54 /* Answers a form (the one question engine): ships with Spaces, so behind the same gate. */ 55 var ANSWERS_ENTRY = ['answers_form', 'Answers a form', 'Finishes every question on a form. You can pick the form, and the result they got.']; 56 /* A round ends (the one question engine, 1d): behind the same gate. */ 57 var ROUND_ENTRY = ['round_ends', 'A round ends', 'The day after a group\u2019s end date, once for each person in that group. Only rounds that end after you switch this on.'];
58 var ACCEPTED_ENTRY = ['application_accepted', 'Application accepted', 'You said yes to somebody who applied to join a space.']; 59 function entries() { return spacesOn() ? ENTRIES.concat([SPACE_ENTRY, ANSWERS_ENTRY, ROUND_ENTRY, ACCEPTED_ENTRY]) : ENTRIES; } 60 var ACTIONS = [ 61 ['add_tag', 'Add tag', 'Choose a tag, or type a new one.'], 62 ['remove_tag', 'Remove tag', 'Take a tag off them.'], 63 ['add_to_sequence', 'Add to sequence', 'They join the sequence now and start at email 1. Re-entry follows the sequenceâs own rule.'], 64 ['remove_from_sequence', 'Remove from sequence', 'What they have had stays sent; what is coming is cancelled.'], 65 ['wait', 'Wait', 'Minutes, hours or days. Survives a restart: it is a scheduled job, not a timer.'], 66 ['send_email', 'Send an email', 'One email, written here, through the broadcast pipeline.'], 67 ['notify_me', 'Notify me', 'An email to you when someone reaches this step.'], 68 ]; 69 var FILTERS = [ 70 ['has_tag', 'Only if they have a tag', 'The step after runs only for people wearing the tag.'], 71 ['no_tag', 'Only if they do not have a tag', 'â¦or only for people without it.'], 72 ['in_segment', 'Only if they are in a segment', 'Any saved segment.'], 73 ]; 74 var LATER = [['A date arrives', 'Kit: date events'], ['A custom field changes', 'Kit: custom fields'], ['Opened or clicked an email', 'Kit: email-open conditions'], 75 ['Move to another automation', 'Kit: move subscriber'], ['Yes / No branches', 'Kit: a fork; v1 is one straight line']]; 76 77 function esc(s) { 78 return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { 79 return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; 80 }); 81 } 82 function host() { return document.getElementById('automationsHost'); } 83 function toast(m) { if (window.showToast) showToast(m); else if (window.App && App.toast) App.toast(m); else console.log('[automations]', m); } 84 // Dates: the tenant's order, month in letters (date-format.js). 85 function fmtDate(v) { if (!v) return 'â'; return App.Dates.short(v) || 'â'; } 86 function fmtWhen(v) { if (!v) return '?'; return App.Dates.weekdayShortTime(v, { year: 'auto' }) || '?'; } 87 function icon(name) { 88 var P = { 89 pencil: '<path d="M12 20h9M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z"/>', 90 search: '<circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/>', 91 bolt: '<path d="M13 2L3 14h8l-1 8 10-12h-8z"/>', 92 tag: '<path d="M20 12l-8 8-9-9V3h8z"/><circle cx="7.5" cy="7.5" r="1.5"/>', 93 mail: '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 7l9 6 9-6"/>', 94 clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>', 95 bell: '<path d="M6 8a6 6 0 0112 0v5l2 3H4l2-3z"/><path d="M10 20a2 2 0 004 0"/>', 96 list: '<path d="M4 6h16M4 12h16M4 18h16"/>', 97 filter: '<path d="M3 5h18l-7 8v6l-4-2v-4z"/>', 98 sort: '<path d="M7 4v16M7 20l-3-3M7 20l3-3M17 20V4M17 4l-3 3M17 4l3 3"/>', 99 }[name] || ''; 100 return '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + P + '</svg>'; 101 } 102 function kindOf(t) { return t === 'only_if' ? 'cond' : 'action'; } 103 function stepIcon(t) { return { add_tag: 'tag', remove_tag: 'tag', add_to_sequence: 'list', remove_from_sequence: 'list', wait: 'clock', send_email: 'mail', notify_me: 'bell', only_if: 'filter' }[t] || 'bolt'; } 104 function ic(kind, size, name) { return '<span class="au-ic au-ic-' + kind + ' au-ic-' + size + '">' + (size === 'sm' ? '' : icon(name)) + '</span>'; } 105 function pill(status) { 106 return { active: ['Active', 'seq-pill-good'], inactive: ['Inactive', 'seq-pill-muted'], paused: ['Paused', 'seq-pill-warn'] }[status] || [status, 'seq-pill-muted']; 107 } 108 function stepLabel(step) { return step.label || step.step_type; } 109 110 // ââ lookups, fetched once ââââââââââââââââââââââââââââââââââââââââââââââ 111 async function lookups() { 112 if (S.look) return S.look; 113 var L = { tags: [], drips: [], segments: [], forms: [], courses: [], webinars: [], funnels: [], products: [], spaces: [] }; 114 var arr = function (r, keys) { if (Array.isArray(r)) return r; for (var i = 0; i < keys.length; i++) if (r && Array.isArray(r[keys[i]])) return r[keys[i]]; return []; }; 115 await Promise.all([ 116 api('/contacts/tags').then(function (r) { L.tags = arr(r, ['tags']); }).catch(function () {}), 117 api('/community-hosting/sequences').then(function (r) { L.drips = arr(r, ['sequences']); }).catch(function () {}), 118 api('/segments').then(function (r) { L.segments = arr(r, ['segments']); }).catch(function () {}), 119 // GET /api/forms/configs â { configs: [{ id, form_id, name }] }; the engine's joins_form carries form_configs.id 120 api('/forms/configs').then(function (r) { L.forms = arr(r, ['configs']); }).catch(function () {}), 121 api('/community-hosting/courses').then(function (r) { L.courses = arr(r, ['courses', 'rows', 'items']); }).catch(function () {}), 122 api('/webinars').then(function (r) { L.webinars = arr(r, ['webinars']); }).catch(function () {}), 123 api('/funnels').then(function (r) { L.funnels = arr(r, ['funnels']); }).catch(function () {}), 124 api('/products').then(function (r) { L.products = arr(r, ['products']); }).catch(function () {}),
125 // Top-level spaces only (a working group is not a space anybody is welcomed into); never asked without the beta (it would 404). 126 spacesOn() ? api('/community-hosting/spaces').then(function (r) { L.spaces = arr(r, ['spaces']).filter(function (x) { return !x.parent_space_id && !x.archived_at; }); }).catch(function () {}) : Promise.resolve(), 127 ]); 128 S.look = L; 129 return L; 130 } 131 132 // ââ Board 1 · the list, Board 5 · templates ââââââââââââââââââââââââââââ 133 function listHead() { 134 var t = S.tab === 'templates'; 135 return '<div class="page-head">' + 136 '<div><div class="page-head-crumbs"><span class="crumb-current">Subscribers</span><span class="crumb-sep">/</span><span class="crumb-current">Automations</span></div>' + 137 '<h1 class="display">Automations</h1>' + 138 '<div class="sub">' + (t ? 'Start from one of ours. Every template is three steps; change any of them.' : 'When something happens to a subscriber, do something: tag them, start a sequence, wait, email. Every automation is one straight line.') + '</div></div>' + 139 '<div class="page-head-meta"><button class="btn btn-primary" type="button" data-au-new>+ New automation</button></div>' + 140 '</div>' + 141 '<div class="seq-tabs au-tabs" role="tablist">' + 142 '<button type="button" role="tab" class="seq-tab' + (t ? '' : ' on') + '" data-au-ltab="mine">My automations' + (S.list ? '<span class="seq-pill seq-pill-muted seq-pill-sm">' + S.list.length + '</span>' : '') + '</button>' + 143 '<button type="button" role="tab" class="seq-tab' + (t ? ' on' : '') + '" data-au-ltab="templates">Templates</button>' + 144 '</div>'; 145 } 146 147 async function load() { 148 var h = host(); 149 if (!h) return; 150 if (window._pendingAutomationOpen) { var pid = window._pendingAutomationOpen; window._pendingAutomationOpen = null; return open(pid); } 151 if (window._pendingAutomationNew) { var pn = window._pendingAutomationNew; window._pendingAutomationNew = null; return createForFunnel(pn); } 152 S.a = null; S.picker = null; 153 h.innerHTML = listHead() + '<div class="au-empty">Loading your automationsâ¦</div>'; 154 try { 155 S.list = await api(API); 156 if (!Array.isArray(S.list)) throw new Error('bad shape'); 157 } catch (e) { 158 h.innerHTML = listHead() + '<div class="au-empty">Couldnât load your automations just now â <a href="#" data-au-reload>try again</a>.</div>'; 159 wireList(); return; 160 } 161 paintList(); 162 } 163 164 function miniFlow(entry, steps) { 165 var rows = [{ kind: 'entry', label: entry }].concat((steps || []).slice(0, 2).map(function (s) { return { kind: kindOf(s.step_type), label: stepLabel(s) }; })); 166 return '<div class="au-mini">' + rows.map(function (r, i) { 167 return (i ? '<div class="l"></div>' : '') + '<div class="n">' + ic(r.kind, 'sm') + '<span>' + esc(r.label) + '</span></div>'; 168 }).join('') + '</div>'; 169 } 170 171 function paintList() { 172 var h = host(); 173 if (S.tab === 'templates') return paintTemplates(); 174 var rows = (S.list || []).filter(function (a) { 175 return S.filter === 'all' || (S.filter === 'funnels' ? !!a.funnel_website_id : a.status === S.filter); 176 }) 177 .filter(function (a) { return !S.q || String(a.name).toLowerCase().indexOf(S.q.toLowerCase()) >= 0; }); 178 rows.sort(S.sort === 'name' ? function (a, b) { return String(a.name).localeCompare(String(b.name)); } 179 : function (a, b) { return String(b.updated_at || '').localeCompare(String(a.updated_at || '')); }); 180 var chip = function (k, label) { return '<button type="button" class="seq-chip' + (S.filter === k ? ' on' : '') + '" data-au-filter="' + k + '">' + label + '</button>'; }; 181 var body = rows.length ? '<div class="au-grid">' + rows.map(function (a) { 182 var p = pill(a.status); 183 return '<div class="seq-card au-card" data-au-open="' + a.id + '">' + 184 '<div class="au-card-head"><div class="au-card-t">' + esc(a.name) + '</div><div class="au-card-meta"><span class="seq-pill ' + p[1] + '">' + p[0] + '</span>' + 185 '<details class="seq-menu"><summary aria-label="More">â®</summary><div>' + 186 '<button type="button" data-au-rename="' + a.id + '">Rename</button>' + 187 '<button type="button" data-au-toggle="' + a.id + '">' + (a.enabled ? 'Turn off' : 'Turn on') + '</button>' + 188 '<button type="button" data-au-delete="' + a.id + '">Delete</button></div></details></div></div>' + 189 '<div class="au-card-n">' + a.subscribers + ' subscriber' + (a.subscribers === 1 ? '' : 's') + 190 /* Which funnel it belongs to (funnel home, 2026-09-26): the funnel's 191 emails live here, and the label is how a coach tells them apart. */ 192 (a.funnel_website_id 193 ? ' <span class="seq-pill seq-pill-muted seq-pill-sm">' + (a.funnel_deleted ? 'From a deleted funnel' : 'From the ' + esc(a.funnel_name) + ' funnel') + '</span>' 194 : '') + '</div>' + 195 miniFlow(a.entry_label, a.steps) + '</div>'; 196 }).join('') + '</div>' 197 : '<div class="au-empty">' + (S.list && S.list.length ? 'Nothing matches that.' : 'No automations yet. Start from a <a href="#" data-au-ltab-link="templates">template</a>, or press <b>+ New automation</b>.') + '</div>'; 198 h.innerHTML = listHead() + 199 '<div class="seq-list-bar">' + 200 '<div class="seq-chips">' + chip('all', 'All') + chip('active', 'Active') + chip('inactive', 'Inactive') + chip('paused', 'Paused') + 201 ((S.list || []).some(function (a) { return a.funnel_website_id; }) ? chip('funnels', 'From funnels') : '') + '</div>' + 202 '<div class="seq-row-inline"><label class="input seq-search" style="width:280px;flex:none">
202' + icon('search') + '<input data-au-q placeholder="Searchâ¦" value="' + esc(S.q) + '"></label>' + 203 '<label class="seq-sort">' + icon('sort') + '<select class="input" data-au-sort><option value="updated"' + (S.sort === 'updated' ? ' selected' : '') + '>Recently updated</option><option value="name"' + (S.sort === 'name' ? ' selected' : '') + '>Alphabetical</option></select></label></div>' + 204 '</div>' + body; 205 wireList(); 206 } 207 208 async function paintTemplates() { 209 var h = host(); 210 if (!S.templates) { h.innerHTML = listHead() + '<div class="au-empty">Loading templatesâ¦</div>'; S.templates = await api(API + '/templates'); if (!Array.isArray(S.templates)) S.templates = []; } 211 h.innerHTML = listHead() + '<div class="au-grid">' + S.templates.filter(function (t) { return !t.spaces || spacesOn(); }).map(function (t) { 212 return '<div class="seq-card au-card" style="padding:18px;cursor:default">' + 213 '<div class="au-card-t">' + esc(t.name) + '</div><div class="au-card-d">' + esc(t.description) + '</div>' + 214 miniFlow(t.entry_label, t.steps) + 215 '<button type="button" class="btn au-tpl-use" data-au-use="' + esc(t.key) + '">Use this template</button></div>'; 216 }).join('') + '</div>'; 217 wireList(); 218 } 219 220 function wireList() { 221 var h = host(); 222 var nb = h.querySelector('[data-au-new]'); if (nb) nb.addEventListener('click', create); 223 var rl = h.querySelector('[data-au-reload]'); if (rl) rl.addEventListener('click', function (ev) { ev.preventDefault(); load(); }); 224 Array.prototype.forEach.call(h.querySelectorAll('[data-au-ltab]'), function (b) { b.addEventListener('click', function () { S.tab = b.getAttribute('data-au-ltab'); paintList(); }); }); 225 var tl = h.querySelector('[data-au-ltab-link]'); if (tl) tl.addEventListener('click', function (ev) { ev.preventDefault(); S.tab = 'templates'; paintList(); }); 226 Array.prototype.forEach.call(h.querySelectorAll('[data-au-filter]'), function (b) { b.addEventListener('click', function () { S.filter = b.getAttribute('data-au-filter'); paintList(); }); }); 227 var q = h.querySelector('[data-au-q]'); if (q) q.addEventListener('input', function () { S.q = q.value; var keep = q.value; paintList(); var again = h.querySelector('[data-au-q]'); if (again) { again.value = keep; again.focus(); } }); 228 var so = h.querySelector('[data-au-sort]'); if (so) so.addEventListener('change', function () { S.sort = so.value; paintList(); }); 229 Array.prototype.forEach.call(h.querySelectorAll('[data-au-open]'), function (c) { 230 c.addEventListener('click', function (ev) { if (ev.target.closest('details')) return; open(Number(c.getAttribute('data-au-open'))); }); 231 }); 232 Array.prototype.forEach.call(h.querySelectorAll('[data-au-rename]'), function (b) { b.addEventListener('click', function () { rename(Number(b.getAttribute('data-au-rename'))); }); }); 233 Array.prototype.forEach.call(h.querySelectorAll('[data-au-toggle]'), function (b) { b.addEventListener('click', async function () { await toggle(Number(b.getAttribute('data-au-toggle'))); load(); }); }); 234 Array.prototype.forEach.call(h.querySelectorAll('[data-au-delete]'), function (b) { b.addEventListener('click', async function () { if (await remove(Number(b.getAttribute('data-au-delete')))) load(); }); }); 235 Array.prototype.forEach.call(h.querySelectorAll('[data-au-use]'), function (b) { 236 b.addEventListener('click', async function () { 237 var r = await api(API + '/from-template', { method: 'POST', body: { key: b.getAttribute('data-au-use') } }); 238 if (!r || r.error) { toast('Couldnât create it.'); return; } 239 S.list = null; open(r.id); 240 }); 241 }); 242 } 243 244 async function create() { 245 var name = await App.dialog.ask({ title: 'Name your automation', body: 'Pick something you will recognise. You can change it later.', label: 'Automation name', value: 'New automation', confirmLabel: 'Create automation' }); 246 if (!name) return; 247 var r = await api(API, { method: 'POST', body: { name: name, trigger_type: 'joins_form', trigger_config: { form_id: null }, steps: [] } }); 248 if (!r || r.error) { toast('Couldnât create it.'); return; } 249 S.list = null; 250 await open(r.id); 251 S.picker = { mode: 'entry', group: 'when' }; paint(); 252 } 253 /* "+ Add an email for this funnel" on the funnel home's Emails tab: a new 254 automation with the funnel already chosen as the trigger, opened on the 255 trigger so the coach can pick the product, or "Left checkout" instead. */ 256 async function createForFunnel(p) { 257 var h = host(); 258 /* Asked first, as "+ New automation" asks: this used to make an empty 259 automation on the click alone, and a coach who was only looking had
260 one they never meant to (tester, dev5 2026-09-26). */ 261 /* The funnel home asks for the name in its own page now (UX round 2: the 262 browser prompt read as "not your app"); the prompt stays only for a 263 caller that sends none. */ 264 var name = p.name || await App.dialog.ask({ title: 'Name this email', label: 'Email name', value: 'Email for ' + (p.funnel_name || 'my funnel'), confirmLabel: 'Carry on' }); 265 if (!name) { 266 if (window.FunnelManagement && window.FunnelManagement.openFunnelEmails) window.FunnelManagement.openFunnelEmails(p.funnel_website_id); 267 return; 268 } 269 /* The way back to the funnel. The head's own link reads the automation's 270 funnel_website_id, which only Maya's follow-ups carry. */ 271 S.fromFunnel = { id: Number(p.funnel_website_id), name: p.funnel_name || 'your funnel' }; 272 var cfg = { funnel_id: Number(p.funnel_website_id) }; 273 var r = await api(API, { method: 'POST', body: { name: name, trigger_type: p.trigger || 'bought_from_funnel', trigger_config: cfg, steps: [] } }); 274 if (!r || r.error) { if (h) h.innerHTML = listHead() + '<div class="au-empty">Couldnât start that automation â <a href="#" data-au-reload>back to the list</a>.</div>'; wireList(); return; } 275 S.list = null; 276 S.fromFunnel.for = r.id; 277 await open(r.id); 278 S.picker = { mode: 'entry', group: 'when', type: p.trigger || 'bought_from_funnel', cfg: Object.assign({}, cfg) };
279 paint(); 280 } 281 async function rename(id) { 282 var cur = S.a && S.a.id === id ? S.a.name : ((S.list || []).find(function (a) { return a.id === id; }) || {}).name; 283 var name = await App.dialog.ask({ title: 'Rename this automation', label: 'Automation name', value: cur || '', confirmLabel: 'Save name' }); 284 if (!name || name === cur) return; 285 var r = await api(API + '/' + id, { method: 'PUT', body: { name: name } }); 286 if (!r || r.error) { toast('Couldnât rename it.'); return; } 287 if (S.a && S.a.id === id) { S.a = r; paint(); } else load(); 288 } 289 async function toggle(id) { 290 var r = await api(API + '/' + id + '/toggle', { method: 'POST', body: {} }); 291 if (!r || r.error) { toast(r && r.code === 'step_incomplete' ? 'A step still needs a choice before this can run.' : r && r.code === 'steps_required' ? 'Add at least one step first.' : 'Couldnât switch it.'); return null; } 292 /* â ï¸ SAY WHAT THE SWITCH ACTUALLY STOPPED, AND ADMIT WHAT IT COULD NOT. 293 Turning an automation off now holds the emails it had already queued 294 (automations-service.toggle). A coach who hits Pause forty thousand 295 recipients into a send needs to see a number, or they cannot tell a 296 pause that worked from one that did nothing â and they need to know 297 that the handful already handed to Mailgun are gone, so three more 298 arriving is not a bug report. */ 299 var n = Number(r.held || 0), m = Number(r.released || 0); 300 if (n) toast('Paused. ' + n.toLocaleString() + ' email' + (n === 1 ? '' : 's') + ' held before sending. Any already handed to Mailgun in the last second or two still arrive.'); 301 else if (m) toast('Back on. ' + m.toLocaleString() + ' held email' + (m === 1 ? '' : 's') + ' released' + (r.resumed ? ', ' + r.resumed + ' waiting subscriber' + (r.resumed === 1 ? '' : 's') + ' moved on' : '') + '.'); 302 else if (r.resumed) toast('Back on. ' + r.resumed + ' waiting subscriber' + (r.resumed === 1 ? '' : 's') + ' moved on.'); 303 return r; 304 } 305 async function remove(id) { 306 if (!(await App.dialog.confirm({ title: 'Delete this automation?', body: 'Everyone in it is taken out. Nothing else changes.', confirmLabel: 'Delete automation', cancelLabel: 'Keep it', tone: 'danger' }))) return false; 307 var r = await api(API + '/' + id, { method: 'DELETE' }); 308 if (!r || r.error) { toast('Couldnât delete it.'); return false; } 309 return true; 310 } 311 312 // ââ Boards 2â4 · the automation ââââââââââââââââââââââââââââââââââââââââ 313 async function open(id, keepTab) { 314 var h = host(); 315 if (!h) return; 316 h.innerHTML = '<div class="au-empty">Loadingâ¦</div>'; 317 var r = await api(API + '/' + id); 318 if (!r || r.error) { toast('Couldnât open that automation.'); return load(); } 319 S.a = r; if (!keepTab) { S.atab = 'flow'; S.picker = null; S.subs = null; S.addOpen = false; } 320 await lookups();
321 paint(); 322 } 323 async function refresh() { 324 if (!S.a) return; 325 var r = await api(API + '/' + S.a.id); 326 if (r && !r.error) S.a = r; 327 paint(); 328 } 329 330 function head() { 331 var a = S.a, on = !!a.enabled; 332 /* Made for a funnel's follow-up emails: the way back to that funnel's 333 Emails screen (docs/projects/funnel-emails/SPEC.md). */ 334 var ff = S.fromFunnel && S.fromFunnel.for === a.id ? S.fromFunnel : null; 335 var fromFunnel = (a.funnel_website_id && a.funnel_name) || ff; 336 return (fromFunnel ? '<a href="#" class="seq-funnel-back" data-au-funnel-back>â¹ Back to ' + esc(a.funnel_name || ff.name) + ' emails</a>' : '') + 337 '<div class="page-head au-ed-head">' + 338 '<div><div class="page-head-crumbs"><a href="#" class="crumb" data-au-back>Subscribers</a><span class="crumb-sep">/</span><a href="#" class="crumb" data-au-back>Automations</a><span class="crumb-sep">/</span><span class="crumb-current">' + esc(a.name) + '</span></div>' + 339 '<h1 class="display">' + esc(a.name) + ' <button type="button" class="seq-iconbtn" data-au-rename-cur aria-label="Rename">' + icon('pencil') + '</button></h1></div>' + 340 '<div class="page-head-meta"><div class="au-ed-tabs" role="tablist">' + 341 '<button type="button" role="tab" class="seq-tab' + (S.atab === 'flow' ? ' on' : '') + '" data-au-tab="flow">Flow</button>' + 342 '<button type="button" role="tab" class="seq-tab' + (S.atab === 'subscribers' ? ' on' : '') + '" data-au-tab="subscribers">Subscribers</button></div>' + 343 '<div class="au-toggle-wrap"><span>' + (on ? 'Active' : 'Inactive') + '</span>' + 344 '<button type="button" class="seq-toggle' + (on ? ' on' : '') + '" role="switch" aria-checked="' + on + '" data-au-toggle-cur aria-label="' + (on ? 'Turn off' : 'Turn on') + '"><i></i></button></div></div>' + 345 '</div>'; 346 } 347 function banner() { 348 var a = S.a, c = a.counts || {}; 349 if (a.enabled) { 350 return '<div class="seq-banner"><span><b>Running.</b> ' + c.total + ' subscriber' + (c.total === 1 ? ' has' : 's have') + ' been through it; ' + c.open + (c.open === 1 ? ' is' : ' are') + ' in it now. Changes save as you make them and apply to everyone who arrives next.</span><button type="button" class="btn" data-au-toggle-cur>Pause</button></div>'; 351 } 352 return '<div class="seq-banner"><span><b>Not running.</b> ' + (a.status === 'paused' ? c.open + ' subscriber' + (c.open === 1 ? ' is' : 's are') + ' paused inside it. ' : '') + 'Nothing starts until you switch it on; you can build and change it freely.</span><button type="button" class="btn btn-primary" data-au-toggle-cur>Turn on</button></div>'; 353 } 354 355 function paint() { 356 var h = host(); if (!h || !S.a) return; 357 var body = S.atab === 'subscribers' ? subscribersBoard() : flowBoard(); 358 h.innerHTML = head() + banner() + body; 359 wireHead(); 360 if (S.atab === 'subscribers') wireSubscribers(); else wireFlow(); 361 if (S.atab === 'subscribers' && !S.subs) loadSubscribers(); 362 } 363 364 function wireHead() { 365 var h = host(); 366 Array.prototype.forEach.call(h.querySelectorAll('[data-au-back]'), function (b) { b.addEventListener('click', function (ev) { ev.preventDefault(); load(); }); }); 367 var fb = h.querySelector('[data-au-funnel-back]'); 368 if (fb) fb.addEventListener('click', function (ev) { 369 ev.preventDefault(); 370 var fid = S.a.funnel_website_id || (S.fromFunnel && S.fromFunnel.id); 371 if (window.FunnelManagement && window.FunnelManagement.openFunnelEmails) window.FunnelManagement.openFunnelEmails(fid); 372 }); 373 var rn = h.querySelector('[data-au-rename-cur]'); if (rn) rn.addEventListener('click', function () { rename(S.a.id); }); 374 Array.prototype.forEach.call(h.querySelectorAll('[data-au-tab]'), function (b) { b.addEventListener('click', function () { S.atab = b.getAttribute('data-au-tab'); S.picker = null; paint(); }); }); 375 Array.prototype.forEach.call(h.querySelectorAll('[data-au-toggle-cur]'), function (b) { b.addEventListener('click', async function () { var r = await toggle(S.a.id); if (r) refresh(); }); }); 376 } 377 378 // Board 2 · the flow (and Board 3 · the picker beside it) 379 function flowBoard() { 380 var a = S.a, c = a.counts || {}, pk = S.picker; 381 var plus = function (after) { 382 var on = pk && pk.mode === 'add' && pk.after === after; 383 return '<div class="au-line"></div><button type="button" class="au-plus' + (on ? ' on' : '') + '" data-au-plus="' + after + '" aria-label="Add a step here">+</button><div class="au-line"></div>'; 384 }; 385 /* Tolerant of a payload from before entered/ongoing/exited shipped â 386 a mid-deploy cached client must not render "undefined" at a coach. */
387 var n = function (v) { return Number(v || 0).toLocaleString(); }; 388 var nodes = '<button type="button" class="au-node' + (pk && pk.mode === 'entry' ? ' on' : '') + '" data-au-entry>' + ic('entry', 'lg', 'bolt') + 389 '<div class="au-node-t">' + esc(a.entry_label) + '</div>' + 390 /* â ï¸ THE ENTRY NODE CARRIED THE SAME LIE. `counts.total` is every run 391 ever started â everyone who ENTERED the automation â and it was 392 labelled "Completed". On live that read "Completed 12,857 393 subscribers" while 5,233 of them were still sitting in the first 394 Wait. Entered is cumulative and never falls, which is exactly what 395 this number is. `counts.completed` is the one that means finished. */ 396 '<div class="au-stats"><div><b>Entered</b>' + n(c.total) + ' subscriber' + (Number(c.total) === 1 ? '' : 's') + '</div>' + 397 '<div><b>Finished</b>' + n(c.completed) + '</div></div>' + 398 '</button>'; 399 /* â ï¸ "N subscribers waiting here" used to hang under this node and it said 400 two untrue things at once. `at_entry` counts runs that have entered but 401 not finished step ONE, so they are waiting at that step, not at the 402 entry â and now that every step carries its own Ongoing, the number was 403 also a duplicate of the one directly below it. Two places showing the 404 same figure is how they come to disagree. The step owns it. */ 405 nodes += plus(0); 406 (a.steps || []).forEach(function (s) { 407 var cond = s.step_type === 'only_if'; 408 var on = pk && pk.mode === 'edit' && pk.stepId === s.id; 409 nodes += '<button type="button" class="au-node' + (on ? ' on' : '') + '" data-au-step="' + s.id + '">' + ic(cond ? 'cond' : 'action', 'lg', stepIcon(s.step_type)) + 410 '<div class="au-node-t' + (s.incomplete ? ' au-incomplete' : '') + '">' + esc(stepLabel(s)) + '</div>' + 411 /* Entered · Ongoing · Exited. "Completed" used to sit under a node 412 named after a sequence and read as "completed the sequence" â the 413 number was right and the word was a lie. Entered accumulates and 414 never falls; ongoing is entered minus exited minus anyone stopped 415 here; stopped only appears when it is not zero, so the common case 416 stays three numbers. */ 417 '<div class="au-stats">' + (cond 418 ? '<div><b>Passed</b>' + n(s.exited) + '</div><div><b>Stopped here</b>' + n(s.stopped) + '</div>' 419 : '<div><b>Entered</b>' + n(s.entered) + '</div><div><b>Ongoing</b>' + n(s.ongoing) + '</div>' + 420 '<div><b>Exited</b>' + n(s.exited) + '</div>' + 421 (Number(s.stopped) > 0 ? '<div><b>Stopped</b>' + n(s.stopped) + '</div>' : '')) + '</div></button>'; 422 nodes += plus(s.step_order); 423 }); 424 nodes += '<div class="au-end">END OF AUTOMATION</div>'; 425 return '<div class="au-content' + (pk ? ' au-picker-open' : '') + '">' + 426 '<div class="seq-card au-canvas' + (pk ? ' au-dim' : '') + '">' + nodes + '</div>' + (pk ? picker() : '') + '</div>' + 427 '<div class="seq-help">Every node shows how many have <b>entered</b> it (a running total), how many are <b>ongoing</b> at it right now, and how many have <b>exited</b> to the next step. Exited means they left this step, not that they finished anything it started — an <i>Add to sequence</i> step exits the moment somebody is enrolled, while their emails run for days. Click a node to change it, or + to add a step.</div>'; 428 } 429 430 function wireFlow() { 431 var h = host(); 432 var en = h.querySelector('[data-au-entry]'); if (en) en.addEventListener('click', function () { S.picker = S.picker && S.picker.mode === 'entry' ? null : { mode: 'entry', group: 'when', type: S.a.trigger_type, cfg: Object.assign({}, S.a.trigger_config) }; paint(); }); 433 Array.prototype.forEach.call(h.querySelectorAll('[data-au-plus]'), function (b) { 434 b.addEventListener('click', function () { var after = Number(b.getAttribute('data-au-plus')); S.picker = S.picker && S.picker.mode === 'add' && S.picker.after === after ? null : { mode: 'add', after: after, group: 'all' }; paint(); }); 435 }); 436 Array.prototype.forEach.call(h.querySelectorAll('[data-au-step]'), function (b) { 437 b.addEventListener('click', function () { 438 var id = Number(b.getAttribute('data-au-step')); var s = S.a.steps.find(function (x) { return x.id === id; }); 439 S.picker = S.picker && S.picker.mode === 'edit' && S.picker.stepId === id ? null : { mode: 'edit', stepId: id, type: s.step_type, cfg: Object.assign({}, s.step_config) }; paint(); 440 }); 441 }); 442 wirePicker(); 443 } 444 445 // Board 3 · the picker 446 function picker() { 447 var pk = S.picker; 448 var title = pk.mode === 'entry' ? 'Whenâ¦' : pk.mode === 'edit' ? 'Edit step' : 'Add a step'; 449 var inner; 450 if (pk.type) inner = form(pk); 451 else { 452 var g = pk.group || 'all'; 453 var tab = function (k, l) { return '<button type="button" class="seq-tab' + (g === k ? ' on' : '') + '" data-au-pgroup="' + k + '">' + l + '</button>'; }; 454 var group = function (label, items, kind) { 455 return '<div class="au-mono">' + label + '</div><div class="au-picks">' + items.map(function (it) { 456 return '<button type="button" class="au-pick" data-au-pick="' + it[0] + '">' + ic(kind, 'md', kind === 'entry' ? 'bolt' : stepIcon(it[0] === 'has_tag' || it[0] === 'no_tag' || it[0] === 'in_segment' ? 'only_if' : it[0])
456) + '<div><div class="k">' + esc(it[1]) + '</div><div class="d">' + esc(it[2]) + '</div></div></button>'; 457 }).join('') + '</div>'; 458 }; 459 inner = (pk.mode === 'entry' ? '' : '<div class="seq-tabs" style="margin:0">' + tab('all', 'All') + tab('when', 'Whenâ¦') + tab('then', 'Thenâ¦') + tab('only', 'Only ifâ¦') + '</div>') + 460 ((g === 'all' && pk.mode !== 'add') || g === 'when' ? group('When⦠(entry points)', entries(), 'entry') : '') + 461 (pk.mode !== 'entry' && (g === 'all' || g === 'then') ? group('Then⦠(actions)', ACTIONS, 'action') : '') + 462 (pk.mode !== 'entry' && (g === 'all' || g === 'only') ? group('Only if⦠(a filter, not a fork)', FILTERS, 'cond') : '') + 463 (pk.mode !== 'entry' && g === 'all' ? '<div class="au-mono">Later (Kit has these; v1 does not)</div><div class="au-picks">' + LATER.map(function (l) { 464 return '<div class="au-pick later">' + ic('action', 'md', 'bolt') + '<div><div class="k">' + esc(l[0]) + '</div><div class="d">' + esc(l[1]) + '</div></div></div>'; 465 }).join('') + '</div>' : ''); 466 } 467 return '<div class="seq-card au-picker" data-au-picker><div class="au-picker-head"><h2>' + title + '</h2><button type="button" class="seq-iconbtn" data-au-pclose aria-label="Close">â</button></div>' + inner + '</div>'; 468 } 469 470 function sel(name, options, value, anyLabel) { 471 return '<select class="input" data-au-f="' + name + '">' + (anyLabel ? '<option value="">' + esc(anyLabel) + '</option>' : '') + 472 options.map(function (o) { return '<option value="' + esc(o.id) + '"' + (String(o.id) === String(value == null ? '' : value) ? ' selected' : '') + '>' + esc(o.name || o.title || ('#' + o.id)) + '</option>'; }).join('') + '</select>'; 473 } 474 function form(pk) { 475 var L = S.look || { tags: [], drips: [], segments: [], forms: [], courses: [], webinars: [] }; 476 var c = pk.cfg || {}; 477 var t = pk.type, f = ''; 478 var label = function (s) { return '<div class="seq-label">' + s + '</div>'; }; 479 if (pk.mode === 'entry') { 480 var e = entries().concat([SPACE_ENTRY, ANSWERS_ENTRY, ROUND_ENTRY, ACCEPTED_ENTRY]).find(function (x) { return x[0] === t; }) || [t, t]; 481 f += '<div class="seq-help-lg"><b>' + esc(e[1]) + '</b></div>'; 482 if (t === 'joins_form') f += label('Which form') + sel('form_id', L.forms, c.form_id, 'Any form'); 483 else if (t === 'buys_course' || t === 'free_course_signup') f += label('Which course') + sel('course_id', L.courses, c.course_id, 'Any course'); 484 else if (t === 'webinar_registered') f += label('Which webinar') + sel('webinar_id', L.webinars, c.webinar_id, 'Any webinar'); 485 else if (t === 'joins_space' || t === 'round_ends' || t === 'application_accepted') f += label('Which space') + sel('space_id', L.spaces || [], c.space_id, 'Any space'); 486 else if (t === 'answers_form') { 487 f += label('Which form') + sel('form_id', L.forms, c.form_id, 'Any form') + 488 label('Only when the result is') + 489 sel('tier', [{ id: 'passed', name: 'Passed (a quiz)' }, { id: 'failed', name: 'Did not pass (a quiz)' }, { id: 'qualified', name: 'Qualified' }, { id: 'disqualified', name: 'Not qualified' }], c.tier, 'Any result'); 490 } 491 // A funnel trigger needs its funnel: one with none would fire on every checkout the coach has. 492 else if (t === 'left_checkout' || t === 'bought_from_funnel') { 493 f += label('Which funnel') + sel('funnel_id', L.funnels || [], c.funnel_id, 'Choose a funnelâ¦') + 494 label('Which product') + sel('product_id', L.products || [], c.product_id, 'Any product in that funnel'); 495 } 496 else if (t === 'tag_added' || t === 'tag_removed') f += label('Which tag' + (t === 'tag_added' ? '<span class="seq-info" tabindex="0" role="note" aria-label="Who joins, and when">i<span><b>
496Only people tagged from now on.</b> Turning this on does not bring in anyone who already wears the tag, and nor does a tag applied while it was off. Tagging is what lets a person in, so to add someone, give them the tag. If they already have it, take it off and put it back, because re-applying a tag somebody already wears changes nothing. Or add them by hand from Subscribers.</span></span>' : '')) + sel('tag_id', L.tags, c.tag_id, 'Choose a tagâ¦'); 497 else if (t === 'added_by_hand') f += label('How') + '<select class="input" data-au-f="source"><option value=""' + (!c.source ? ' selected' : '') + '>By hand or import</option><option value="manual"' + (c.source === 'manual' ? ' selected' : '') + '>By hand only</option><option value="import"' + (c.source === 'import' ? ' selected' : '') + '>Import only</option></select>'; 498 else f += '<div class="seq-help">No settings for this one.</div>'; 499 return '<div class="au-form">' + f + '<div class="au-msg" data-au-msg></div><div class="au-form-actions"><button type="button" class="btn btn-primary" data-au-save>Save</button><button type="button" class="btn" data-au-pback>Choose a different entry</button></div></div>'; 500 } 501 var a = ACTIONS.concat(FILTERS).find(function (x) { return x[0] === t; }) || [t, t]; 502 f += '<div class="seq-help-lg"><b>' + esc(a[1]) + '</b></div>'; 503 switch (t) { 504 case 'add_tag': f += label('Tag') + sel('tag_id', L.tags, c.tag_id, 'Choose a tagâ¦') + label('or type a new one') + '<input class="input" data-au-f="tag_name" placeholder="new tag" value="' + esc(c.tag_id ? '' : (c.tag_name || '')) + '">'; break; 505 case 'remove_tag': f += label('Tag') + sel('tag_id', L.tags, c.tag_id, 'Choose a tagâ¦'); break; 506 case 'add_to_sequence': case 'remove_from_sequence': f += label('Sequence') + sel('drip_id', L.drips, c.drip_id, 'Choose a sequenceâ¦'); break; 507 case 'wait': f += label('How long') + '<div class="au-form-row"><input class="input" type="number" min="0" step="1" data-au-f="amount" value="' + esc(c.amount == null ? 1 : c.amount) + '">' + 508 '<select class="input" data-au-f="unit">' + ['minutes', 'hours', 'days'].map(function (u) { return '<option value="' + u + '"' + ((c.unit || 'days') === u ? ' selected' : '') + '>' + u + '</option>'; }).join('') + '</select></div><div class="seq-help">Minutes go in fives.</div>'; break; 509 case 'send_email': f += label('Subject') + '<input class="input" data-au-f="subject" value="' + esc(c.subject || '') + '">' + label('Preview text') + '<input class="input" data-au-f="preview_text" value="' + esc(c.preview_text || '') + '">' + 510 label('Body') + '<textarea class="input" data-au-f="body">' + esc(c.body || '') + '</textarea><div class="seq-help">{{first_name}}, {{last_name}}, {{email}} and {{first_name | default: "there"}} work. A button: <button url="https://â¦">Label</button>. Renders in your default email design.' + 511 (spacesOn() ? ' {{form_link:<i>form key</i>}} is a link to one of your forms that knows who they are; {{next_round_link}} is where they save a place in the next round.' : '') + '</div>'; break; 512 case 'notify_me': f += label('A note to yourself (optional)') + '<input class="input" data-au-f="note" value="' + esc(c.note || '') + '">'; break; 513 case 'has_tag': case 'no_tag': f += label('Tag') + sel('tag_id', L.tags, c.tag_id, 'Choose a tagâ¦'); break; 514 case 'in_segment': f += label('Segment') + sel('segment_id', L.segments, c.segment_id, 'Choose a segmentâ¦'); break; 515 case 'only_if': f += label('Tag or segment') + (c.kind === 'in_segment' ? sel('segment_id', L.segments, c.segment_id, 'Choose a segmentâ¦') : sel('tag_id', L.tags, c.tag_id, 'Choose a tagâ¦')); break; 516 } 517 return '<div class="au-form">' + f + '<div class="au-msg" data-au-msg></div><div class="au-form-actions">' + 518 '<button type="button" class="btn btn-primary" data-au-save>' + (pk.mode === 'edit' ? 'Save' : 'Add step') + '</button>' + 519 (pk.mode === 'edit' ? '<button type="button" class="btn btn-danger" data-au-step-delete>Delete step</button>' : '<button type="button" class="btn" data-au-pback>Back</button>') + '</div></div>'; 520 } 521 522 function readForm() { 523 var h = host(), out = {}; 524 Array.prototype.forEach.call(h.querySelectorAll('[data-au-f]'), function (el) { out[el.getAttribute('data-au-f')] = el.value; }); 525 return out; 526 } 527 function wirePicker() { 528 var h = host(), pk = S.picker; if (!pk) return; 529 var cl = h.querySelector('[data-au-pclose]'); if (cl) cl.addEventListener('click', function () { S.picker = null;
529 paint(); }); 530 Array.prototype.forEach.call(h.querySelectorAll('[data-au-pgroup]'), function (b) { b.addEventListener('click', function () { pk.group = b.getAttribute('data-au-pgroup'); paint(); }); }); 531 Array.prototype.forEach.call(h.querySelectorAll('[data-au-pick]'), function (b) { 532 b.addEventListener('click', function () { 533 var k = b.getAttribute('data-au-pick'); 534 var isEntry = entries().some(function (e) { return e[0] === k; }); 535 if (isEntry) { S.picker = { mode: 'entry', group: 'when', type: k, cfg: k === S.a.trigger_type ? Object.assign({}, S.a.trigger_config) : {} }; } 536 else if (k === 'has_tag' || k === 'no_tag' || k === 'in_segment') { pk.type = k; pk.cfg = { kind: k }; } 537 else { pk.type = k; pk.cfg = k === 'wait' ? { amount: 1, unit: 'days' } : {}; } 538 paint(); 539 }); 540 }); 541 var back = h.querySelector('[data-au-pback]'); if (back) back.addEventListener('click', function () { pk.type = null; pk.cfg = null; if (pk.mode === 'entry') pk.group = 'when'; paint(); }); 542 var save = h.querySelector('[data-au-save]'); if (save) save.addEventListener('click', savePicker); 543 var del = h.querySelector('[data-au-step-delete]'); if (del) del.addEventListener('click', async function () { 544 if (!(await App.dialog.confirm({ title: 'Delete this step?', body: 'Anyone waiting at it moves on to the next step.', confirmLabel: 'Delete step', cancelLabel: 'Keep it', tone: 'danger' }))) return; 545 var r = await api(API + '/' + S.a.id + '/steps/' + pk.stepId, { method: 'DELETE' }); 546 if (!r || r.error) { toast('Couldnât delete the step.'); return; } 547 S.a = r; S.picker = null; paint(); 548 }); 549 } 550 async function savePicker() { 551 var h = host(), pk = S.picker, v = readForm(), msg = h.querySelector('[data-au-msg]'); 552 var say = function (m) { if (msg) msg.textContent = m; }; 553 var r; 554 if (pk.mode === 'entry') { 555 r = await api(API + '/' + S.a.id, { method: 'PUT', body: { trigger_type: pk.type, trigger_config: v } }); 556 } else { 557 var t = pk.type, cfg; 558 if (t === 'has_tag' || t === 'no_tag' || t === 'in_segment' || t === 'only_if') { var kind = t === 'only_if' ? (pk.cfg && pk.cfg.kind) : t; cfg = { kind: kind, tag_id: v.tag_id || null, segment_id: v.segment_id || null }; t = 'only_if'; } 559 else if (t === 'wait') cfg = { amount: Number(v.amount), unit: v.unit }; 560 else if (t === 'add_tag') cfg = v.tag_id ? { tag_id: Number(v.tag_id) } : { tag_name: v.tag_name }; 561 else cfg = v; 562 r = pk.mode === 'edit' 563 ? await api(API + '/' + S.a.id + '/steps/' + pk.stepId, { method: 'PUT', body: { step_config: cfg } }) 564 : await api(API + '/' + S.a.id + '/steps', { method: 'POST', body: { after_order: pk.after, step_type: t, step_config: cfg } }); 565 } 566 if (!r || r.error) { 567 var codes = { tag_required: 'Choose a tag.', funnel_required: 'Choose the funnel.', sequence_required: 'Choose a sequence.', segment_required: 'Choose a segment.', delay_step: 'Minutes go in steps of 5.', delay_invalid: 'Give a whole number of minutes, hours or days.', subject_required: 'Give the email a subject.', body_required: 'Write the email.' }; 568 say(codes[r && r.code] || 'Couldnât save that.'); return; 569 } 570 S.a = r.automation || r; S.picker = null; S.look = null; await lookups(); paint(); 571 } 572 573 // Board 4 · subscribers 574 function subscribersBoard() { 575 var out = S.subs, sq = S.sq; 576 var rows = out ? (out.rows || []).map(function (r) { 577 var p = { running: ['Active', 'seq-pill-good'], waiting: ['Active', 'seq-pill-good'], completed: ['Completed', 'seq-pill-muted'], stopped: ['Stopped', 'seq-pill-muted'], removed: ['Removed', 'seq-pill-muted'], failed: ['Failed', 'seq-pill-warn'] }[r.status] || [r.status, 'seq-pill-muted']; 578 var where = r.status === 'waiting' && r.wait_until ? r.where.replace(/ · until .*$/, '') + ' · until ' + fmtWhen(r.wait_until) : r.where; 579 var openRun = r.status === 'running' || r.status === 'waiting'; 580 return '<tr><td><div class="seq-row-n">' + esc(r.name || r.email || ('#' + r.profile_id)) + '</div><div class="seq-row-d">' + esc(r.email || '') + '</div></td>' + 581 '<td>' + fmtDate(r.entered_at) + '</td><td>' + esc(where) + (r.error ? '<div class="seq-row-d">' + esc(r.error) + '</div>' : '') + '</td><td>
581<span class="seq-pill ' + p[1] + '">' + p[0] + '</span></td>' + 582 '<td class="seq-kebab-cell"><details class="seq-menu"><summary aria-label="More">â®</summary><div>' + 583 (openRun ? '<button type="button" data-au-advance="' + r.profile_id + '">Move to next step now</button><button type="button" data-au-sremove="' + r.profile_id + '">Remove from automation</button>' : '<button type="button" data-au-readd="' + r.profile_id + '">Add again</button>') + 584 '</div></details></td></tr>'; 585 }).join('') : '<tr><td colspan="5" class="seq-muted">Loadingâ¦</td></tr>'; 586 if (out && !(out.rows || []).length) rows = '<tr><td colspan="5" class="seq-muted">Nobody here yet.</td></tr>'; 587 var addPanel = S.addOpen ? '<div class="seq-card seq-addpanel"><div class="seq-row-inline"><label class="input seq-search">' + icon('search') + '<input data-au-add-q placeholder="Search your subscribers by name or email"></label><button type="button" class="btn" data-au-add-close">Close</button></div>' + 588 '<div class="seq-add-hits">' + (S.addHits.length ? S.addHits.map(function (c) { 589 return '<label class="seq-add-hit"><input type="checkbox" data-au-add-pick="' + c.id + '"' + (S.addPicked[c.id] ? ' checked' : '') + '> <span>' + esc(c.display_name || c.name || c.email || c.primary_email) + '</span> <span class="seq-muted">' + esc(c.email || c.primary_email || '') + '</span></label>'; 590 }).join('') : '<div class="seq-help">Type to search. Anyone you pick starts at the first step now.</div>') + '</div>' + 591 '<div><button type="button" class="btn btn-primary" data-au-add-go' + (Object.keys(S.addPicked).length ? '' : ' disabled') + '>Add ' + (Object.keys(S.addPicked).length || '') + ' to automation</button></div></div>' : ''; 592 return '<div class="seq-stack">' + 593 '<div class="au-sub-bar"><select class="input au-sub-filter" data-au-sstatus>' + [['active', 'Active'], ['waiting', 'Waiting'], ['completed', 'Completed'], ['stopped', 'Stopped'], ['removed', 'Removed'], ['failed', 'Failed'], ['all', 'Everyone']].map(function (o) { return '<option value="' + o[0] + '"' + (sq.status === o[0] ? ' selected' : '') + '>' + o[1] + '</option>'; }).join('') + '</select>' + 594 '<label class="input au-sub-search">' + icon('search') + '<input data-au-sq placeholder="Search by email or name" value="' + esc(sq.q) + '"></label>' + 595 '<button type="button" class="btn" data-au-add-open>+ Add subscribers</button></div>' + addPanel + 596 '<div class="seq-card seq-table-wrap au-sub-table"><table class="seq-table"><thead><tr><th class="au-th-name">Subscriber</th><th>Entered</th><th>Where they are</th><th>Status</th><th></th></tr></thead><tbody>' + rows + '</tbody></table></div>' + 597 '<div class="seq-help">' + (out ? out.total + ' shown. ' : '') + 'âWhere they areâ is the step whose run row is open for them. The â® menu moves someone to the next step now, or removes them from the automation.</div></div>'; 598 } 599 var _sqTimer = null; 600 async function loadSubscribers() { 601 var r = await api(API + '/' + S.a.id + '/subscribers?status=' + encodeURIComponent(S.sq.status) + '&q=' + encodeURIComponent(S.sq.q) + '&limit=100'); 602 if (!r || r.error) { toast('Couldnât load subscribers.'); return; } 603 S.subs = r; paint(); 604 } 605 function wireSubscribers() { 606 var h = host(); 607 var st = h.querySelector('[data-au-sstatus]'); if (st) st.addEventListener('change', function () { S.sq.status = st.value; loadSubscribers(); }); 608 var q = h.querySelector('[data-au-sq]'); if (q) q.addEventListener('input', function () { S.sq.q = q.value; clearTimeout(_sqTimer); _sqTimer = setTimeout(loadSubscribers, 300); }); 609 var ao = h.querySelector('[data-au-add-open]'); if (ao) ao.addEventListener('click', function () { S.addOpen = true; S.addHits = []; S.addPicked = {}; paint(); }); 610 var ac = h.querySelector('[data-au-add-close]'); if (ac) ac.addEventListener('click', function () { S.addOpen = false; paint(); }); 611 var aq = h.querySelector('[data-au-add-q]'); 612 if (aq) { aq.focus(); aq.addEventListener('input', function () { 613 clearTimeout(_sqTimer); 614 _sqTimer = setTimeout(async function () { 615 var r = await api('/contacts/list?search=' + encodeURIComponent(aq.value) + '&limit=10'); 616 S.addHits = (r && (r.contacts || r.rows || r.items)) || (Array.isArray(r) ? r : []); 617 var keep = aq.value;
617 paint(); var again = h.querySelector('[data-au-add-q]'); if (again) { again.value = keep; again.focus(); } 618 }, 300); 619 }); } 620 Array.prototype.forEach.call(h.querySelectorAll('[data-au-add-pick]'), function (cb) { 621 cb.addEventListener('change', function () { if (cb.checked) S.addPicked[cb.getAttribute('data-au-add-pick')] = true; else delete S.addPicked[cb.getAttribute('data-au-add-pick')]; var go = h.querySelector('[data-au-add-go]'); if (go) { go.disabled = !Object.keys(S.addPicked).length; go.textContent = 'Add ' + (Object.keys(S.addPicked).length || '') + ' to automation'; } }); 622 }); 623 var go = h.querySelector('[data-au-add-go]'); if (go) go.addEventListener('click', async function () { 624 var r = await api(API + '/' + S.a.id + '/subscribers', { method: 'POST', body: { profile_ids: Object.keys(S.addPicked).map(Number) } }); 625 if (!r || r.error) { toast('Couldnât add them.'); return; } 626 toast(r.added + ' added' + (r.skipped && r.skipped.length ? ', ' + r.skipped.length + ' skipped (already in it)' : '') + '.'); 627 S.addOpen = false; S.addPicked = {}; S.subs = null; var a = await api(API + '/' + S.a.id); if (a && !a.error) S.a = a; paint(); 628 }); 629 Array.prototype.forEach.call(h.querySelectorAll('[data-au-advance]'), function (b) { 630 b.addEventListener('click', async function () { 631 var r = await api(API + '/' + S.a.id + '/subscribers/' + b.getAttribute('data-au-advance') + '/advance', { method: 'POST', body: {} }); 632 if (!r || r.error) toast('Couldnât move them on.'); S.subs = null; paint(); 633 }); 634 }); 635 Array.prototype.forEach.call(h.querySelectorAll('[data-au-sremove]'), function (b) { 636 b.addEventListener('click', async function () { 637 if (!(await App.dialog.confirm({ title: 'Take them out of this automation?', body: 'What has already happened stays. Nothing more will run for them.', confirmLabel: 'Take them out', cancelLabel: 'Keep them in', tone: 'danger' }))) return; 638 await api(API + '/' + S.a.id + '/subscribers/' + b.getAttribute('data-au-sremove'), { method: 'DELETE' }); S.subs = null; paint(); 639 }); 640 }); 641 Array.prototype.forEach.call(h.querySelectorAll('[data-au-readd]'), function (b) { 642 b.addEventListener('click', async function () { 643 var r = await api(API + '/' + S.a.id + '/subscribers', { method: 'POST', body: { profile_ids: [Number(b.getAttribute('data-au-readd'))] } }); 644 if (!r || r.error) toast('Couldnât add them.'); S.subs = null; paint(); 645 }); 646 }); 647 } 648 649 window.Automations = { load: load, open: open, create: create, _state: S }; 650})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.