PageSourceSearch

https://app.replicants.ai/js/subscriber-automations.js?v=20261004rel13

js replicants.ai collected 2026-10-05 12:31:39 UTC 54,267 bytes, 650 lines download raw bytes

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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 &mdash; 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: &lt;button url="https://…"&gt;Label&lt;/button&gt;. 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.