PageSourceSearch

https://app.replicants.ai/js/contacts.js?v=20261003rel9

js replicants.ai collected 2026-10-05 12:31:28 UTC 145,669 bytes, 2,357 lines download raw bytes

1// contacts.js — Unified Profiles Contact Management (v3 — archive/delete)
2
3let contactPage = 1;
4let contactSort = 'created_at';
5let contactOrder = 'desc';
6let selectedContactIds = new Set();
7let selectAllMode = false;
8let contactTotal = 0;
9let searchTimeout = null;
10let contactView = 'active'; // 'active' or 'archived'
11
12async function loadContacts() {
13  /* §SUBSCRIBERS-UI — /contacts/stats fed five counters that have been removed.
14     The health call replaces them and is deliberately NOT awaited here: the
15     list is what the coach came for, and a slow aggregate should not hold it
16     up. It fills itself in when it lands. */
17  loadContactHealth();
18  loadContactList();
19  loadContactSidebar();
20  // Rebuild the bulk-action dropdown on load (the static app.html options don't
21  // include the dynamic / capability-gated actions like "Mark as expertise
22  // users"). This also kicks off the one-time expertise-capability check.
23  updateBulkOptions();
24}
25
26/* ══ §SUBSCRIBERS-UI — list health ═══════════════════════════════════════
27 *
28 * Alex, 2026-08-07: *"you want a brief idea of what the health of your list is
29 * in different ways, in terms of engagement and so on."*
30 *
31 * Four mutually exclusive states that add up to the whole list, so the stacked
32 * bar is honest by construction rather than by arithmetic done here.
33 */
34var _health = null;
35
36/** Which of the four bands is currently being shown, or null. */
37var _activeBand = null;
38
39function _fmt(n) { return Number(n || 0).toLocaleString(); }
40
41async function loadContactHealth() {
42  try {
43    _health = await api('/contacts/health');
44  } catch (e) {
45    _health = null;   // a failed health call must never blank the page
46  }
47  renderContactHealth();
48}
49
50function renderContactHealth() {
51  var card = document.getElementById('cHealthCard');
52  var tiles = document.getElementById('cHealthTiles');
53  /* ⚠️ THE GUARD USED TO REQUIRE `cHealthBanner`, WHICH NO LONGER EXISTS.
54     Left as it was, removing the banner would have returned here on every
55     call and silently killed the bar, the stats line and the header count
56     along with it — the whole panel gone, with no error to say why. */
57  if (!card || !tiles) return;
58
59  if (!_health || !_health.total) {
60    // Nothing to say yet — say nothing, rather than showing zeroes that read
61    // as "your list is unhealthy" to someone who simply has not imported yet.
62    card.style.display = 'none';
63    tiles.innerHTML = '';
64    return;
65  }
66
67  var b = _health.buckets, total = _health.total;
68  var engagedPct = Math.round((b.engaged / total) * 100);
69  // "Healthy" is a judgement and it is written down here so it can be argued
70  // with: at least a quarter of the whole list engaged in 90 days.
71  var healthy = engagedPct >= 25;
72
73  /* ⚠️ THE HEADLINE NUMBER IS THE PAGE SUBTITLE NOW, not a green banner.
74     The banner repeated the page title, this count and both header buttons
75     (removed 2026-09-21). One sentence, two facts, no second set of controls. */
76  var sub = document.getElementById('cHeadSub');
77  if (sub) {
78    sub.innerHTML = '<strong>' + _fmt(total) + ' people.</strong> ' +
79      _fmt(_health.reachable) + ' of them can be emailed.';
80  }
81
82  // ── The bar ──
83  /* The outreach pipeline palette, in Alex's order: green is healthy and each
84     band walks back from it. Swatches are the SOLID end of each gradient, which
85     is exactly what .ps-legend .sw uses. */
86  /* ⚠️ `no_email` IS NOT A BAND, AND THAT WAS A REAL BUG ON SCREEN.
87     Alex, 2026-09-21, seeing 84% and 94% of the same total side by side.
88
89     Measured on live the same day, customer 1: 34,177 profiles, 15,590 with no
90     email, and of those 15,590 the number that DO have a phone is ZERO. So
91     `no_email` is exactly the `no_channel` population — and `total` is defined
92     as everyone MINUS that population (contact-routes /health). The band was
93     therefore showing people who are definitionally not in its own
94     denominator, which is why the percentages could never sum to 100.
95
96     Remove it and the arithmetic closes exactly: 9,000 engaged + 7,527 quiet
97     + 2,060 unsubscribed = 18,587 = total. The number is not lost — it is
98     reported as `not_contactable` in the line below, where it belongs, and
99     the `no_email` filter still opens those people from there. */
100  var BANDS = [
101    { k: 'engaged',      cls: 'b-engaged', label: 'Engaged',      swatch: '#1c8e5a' },
102    { k: 'quiet',        cls: 'b-quiet',   label: 'Gone quiet',   swatch: '#6a4cf1' },
103    { k: 'unsubscribed', cls: 'b-unsub',   label: 'Unsubscribed', swatch: '#e85a8a' }
104  ];
105  card.style.display = '';
106  document.getElementById('cHealthBar').innerHTML = BANDS.filter(function (x) { return b[x.k] > 0; })
107    .map(function (x) {
108      return '<button type="button" class="' + x.cls + '" style="flex:' + b[x.k] + '"' +
109        ' onclick="showHealthBand(\'' + x.k + '\')"' +
110        ' aria-label="' + x.label + ', ' + _fmt(b[x.k]) + ' people">' +
111        (b[x.k] / total > 0.06 ? _fmt(b[x.k]) : '') + '</button>';
112    }).join('');
113  document.getElementById('cHealthLegend').innerHTML = BANDS.map(function (x) {
114    return '<span><i style="background:' + x.swatch + '"></i>' + x.label +
115      ' <b>' + _fmt(b[x.k]) + '</b>' +
116      '<span class="pct">' + Math.round((b[x.k] / total) * 100) + '%</span></span>';
117  }).join('');
118
119  /* The "Next:" nudge lived here. It named the quiet bucket and offered a
120     button that called showHealthBand('quiet') — which is precisely what
121     clicking the Gone quiet band does. Removed 2026-09-21; the bar is the one
122     that scales, because it says the same thing about every band. */
123
124  /* ── The one stats line ───────────────────────────────────────────────
125     ⚠️ FOUR BIG TILES BECAME ONE QUIET SENTENCE. Alex, 2026-09-21: *"There's a
126     lot going on… it could be a bit overwhelming."* Every number survives; none
127     of them shouts. A 27px figure earns its size when somebody acts on it, and
128     nobody acts on "9,711 have a phone number".
129
130     `not_contactable` is here for the first time, and it is the number that
131     used to be a band on the bar. Naming it in words is the honest home for
132     it: it is not a slice of the list, it is the people who are not on it. */
133  var bits = [];
134  bits.push(_fmt(_health.new_7d) + ' joined this week' +
135    (_health.prev_7d > 0 ? ' (' + (_health.new_7d >= _health.prev_7d ? 'up' : 'down') +
136      ' from ' + _fmt(_health.prev_7d) + ')' : ''));
137  bits.push(_fmt(_health.members) + ' are community members');
138  if (_health.with_phone) bits.push(_fmt(_health.with_phone) + ' reachable by SMS or WhatsApp');
139  if (_health.not_contactable) {
140    bits.push('<button type="button" class="sub-stat-link" onclick="showHealthBand(\'no_email\')">' +
141      _fmt(_health.not_contactable) + ' have no email or phone</button>');
142  }
143  tiles.innerHTML = '<div class="sub-stats">' + bits.join('<span class="sep">\u00b7</span>') + '</div>';
144}
145
146/* Clicking a band filters the list to those people.
147 * ⚠️ These are HEALTH states, not saved segments, so they ride on their own
148 * query param rather than pretending to be a segment. The server understands
149 * `health=` and nothing else changes. */
150function showHealthBand(band) {
151  _activeBand = (_activeBand === band) ? null : band;
152  window._activeSegmentId = null;
153  window._activeTagId = null;
154  contactPage = 1;
155  var LABEL = { engaged: 'Engaged', quiet: 'Gone quiet',
156                no_email: 'No email address', unsubscribed: 'Unsubscribed' };
157  if (_activeBand && _health) {
158    showActiveFilter(LABEL[band], _health.buckets[band], false);
159  } else {
160    document.getElementById('activeFilterBanner').style.display = 'none';
161  }
162  loadContactList();
163  loadContactSidebar();
164}
165
166/** One search box over both chip lists — there used to be one each. */
167function filterGroupChips(q) {
168  var needle = String(q || '').toLowerCase().trim();
169  ['cSharedList', 'cSegmentList', 'cTagList'].forEach(function (id) {
170    var host = document.getElementById(id);
171    if (!host) return;
172    Array.prototype.forEach.call(host.querySelectorAll('.chip'), function (c) {
173      var name = c.getAttribute('data-name') || '';
174      c.style.display = (!needle || name.indexOf(needle) !== -1) ? '' : 'none';
175    });
176  });
177  /* §GROUPS-CAP — a search looks through EVERYTHING, including the chips the
178     cap is hiding, so the loop above deliberately ignores the cap. The cap
179     therefore has to be put back the moment the box is emptied: without this
180     line, typing one letter and deleting it leaves all 432 tags on screen,
181     which is the exact state the cap exists to prevent. */
182  if (!needle && typeof window.applyChipCaps === 'function') window.applyChipCaps();
183}
184
185async function loadContactList() {
186  const search = document.getElementById('cSearch')?.value || '';
187  const status = document.getElementById('cStatusFilter')?.value || '';
188  
189  /* ⚠️ The `active_hours` and `high_intent` filters were taken off the screen
190     on 2026-09-21 (legacy, unused, and clutter on a page that already had too
191     much). The QUERY PARAMS still exist on /contacts/list — deliberately
192     untouched — so this is a UI removal, not a capability removal. Nothing
193     here sends them any more because nothing on screen can ask for them. */
194  let url = '/contacts/list?page=' + contactPage + '&limit=20&sort=' + contactSort + '&order=' + contactOrder + '&view=' + contactView;
195  if (search) url += '&search=' + encodeURIComponent(search);
196  if (status) url += '&status=' + status;
197  if (window._activeTagId) url += '&tag_id=' + window._activeTagId;
198  /* 🚨 §SEGMENT-FILTER — this line did not exist, and that was the whole bug.
199     Alex, 2026-08-07: *"if I then click on the segment, it just shows the entire
200     database in the list."*
201     `activateSegment()` (segments.js:356) set `window._activeSegmentId`, drew the
202     "Viewing: <name> (62 contacts)" banner from the segment's own count endpoint,
203     and called loadContactList() — which then queried WITHOUT the segment. So the
204     banner said 62 and the list said 2,980, which is exactly the disagreement
205     visible in his screenshot ("Founding Coach — All Applicants (62 contacts)"
206     beside "Select all 2980").
207     ⚠️ It looked worst moving FROM A TAG, as he described, because activateSegment
208     clears `_activeTagId` — so the one filter that DID work was removed and
209     nothing replaced it. From cold the list was already unfiltered, so nothing
210     appeared to change.
211     The backend needed nothing: GET /list has read `segment_id` since it was
212     written (contact-routes.js:91,131). Only the client never sent it. */
213  if (window._activeSegmentId) url += '&segment_id=' + window._activeSegmentId;
214  if (_activeBand) url += '&health=' + _activeBand;
215  
216  const data = await api(url);
217  if (!data) return;
218  
219  contactTotal = data.pagination?.total || 0;
220  const contacts = data.contacts || [];
221  const isArchived = contactView === 'archived';
222  
223  // Update view toggle
224  const viewToggle = document.getElementById('archiveToggle');
225  if (viewToggle) {
226    viewToggle.innerHTML = isArchived
227      ? '<span style="color:#f59e0b;">📦 Viewing Archived</span> · <a href="#" onclick="switchView(\'active\');return false;" style="color:#7c3aed;">← Back to Active</a>'
228      : '<a href="#" onclick="switchView(\'archived\');return false;" style="color:#94a3b8;font-size:13px;">📦 View Archived</a>';
229  }
230  
231  const tbody = document.getElementById('cTableBody');
232  if (contacts.length === 0) {
233    const emptyMsg = isArchived ? 'No archived contacts' : 'No contacts found';
234    tbody.innerHTML = '<tr><td colspan="9" style="padding:40px;text-align:center;color:#666;">' + emptyMsg + '</td></tr>';
235    updatePagination(data.pagination);
236    return;
237  }
238
239  tbody.innerHTML = contacts.map(c => {
240    const name = c.display_name || 'Anonymous';
241    const initial = (name[0] || '?').toUpperCase();
242    const bgColor = isArchived ? '#4a4560' : (name === 'Anonymous' ? '#6b7280' : '#7c3aed');
243    const date = c.created_at ? App.Dates.short(c.created_at) : '-';
244    const checked = selectedContactIds.has(c.id) || selectAllMode ? 'checked' : '';
245    const rowOpacity = isArchived ? 'opacity:0.7;' : '';
246    
247    // Type badge
248    const typeBadge = c.type === 'member' 
249      ? '<span style="background:#10b981;color:#fff;padding:2px 8px;border-radius:10px;font-size:11px;">Member</span>'
250      : '<span style="background:#6b7280;color:#fff;padding:2px 8px;border-radius:10px;font-size:11px;">Guest</span>';
251    
252    // Status from waitlist source
253    const sourceLabel = {signup_form: 'Signed up', whatsapp_inbound: 'WhatsApp', sms_inbound: 'SMS', webchat: 'Webchat', email_inbound: 'Email', web: 'Web', import: 'Imported'}[c.source] || c.source || '-';
254    
255    // Verification badges
256    let verifyBadges = '';
257    if (c.phone_verified) verifyBadges += '<span title="Phone verified" style="font-size:11px;">📱✅</span> ';
258    if (c.email_verified) verifyBadges += '<span title="Email verified" style="font-size:11px;">✉️✅</span> ';
259    
260    // Tags — cap visible chips so one heavily-tagged contact doesn't balloon
261    // the row: show the first 3, then a "+N" pill (hover lists the rest; the
262    // full set lives in the profile drawer).
263    const tags = (typeof c.tags === 'string' ? JSON.parse(c.tags) : c.tags) || [];
264    const TAG_CAP = 3;
265    const tagHtml = tags.slice(0, TAG_CAP).map(t =>
266      '<span style="background:' + (t.color || '#6b7280') + ';color:#fff;padding:1px 6px;border-radius:8px;font-size:10px;margin-right:2px;white-space:nowrap;">' + _escA(t.name) + '</span>'
267    ).join('');
268    const tagMore = tags.length > TAG_CAP
269      ? '<span title="' + _escA(tags.slice(TAG_CAP).map(t => t.name).join(', ')) + '" style="background:#eef2f7;color:#475569;padding:1px 6px;border-radius:8px;font-size:10px;margin-right:2px;cursor:default;">+' + (tags.length - TAG_CAP) + '</span>'
270      : '';
271    // Phase 3.5 — Community-member pill. Appended to the same tag column;
272    // suspended members get a distinct red-tinted pill.
273    let communityPill = '';
274    if (c.community_member_at) {
275      communityPill = c.community_suspended
276        ? '<span title="Community member — suspended" style="background:#7f1d1d;color:#fee2e2;padding:1px 6px;border-radius:8px;font-size:10px;margin-right:2px;">Suspended</span>'
277        : '<span title="Community member" style="background:#5b21b6;color:#ede9fe;padding:1px 6px;border-radius:8px;font-size:10px;margin-right:2px;">Community</span>';
278    }
279    // Block E4 — high-intent pill (the outreach hand-off, in the list).
280    let intentPill = '';
281    if (c.community_high_intent) {
282      intentPill = '<span title="High community engagement — a follow-up signal" style="background:#7f1d1d;color:#fecaca;padding:1px 6px;border-radius:8px;font-size:10px;margin-right:2px;">🔥 High intent</span>';
283    }
284    const tagsAndCommunity = communityPill + intentPill + tagHtml + tagMore;
285
286    return '<tr data-id="' + c.id + '" style="border-bottom:1px solid #eef2f7;transition:background 0.15s;' + rowOpacity + '" onmouseover="this.style.background=\'#f8fafc\'" onmouseout="this.style.background=\'\'">' +
287      '<td style="padding:10px 8px;width:30px;" onclick="event.stopPropagation()"><input type="checkbox" ' + checked + ' onchange="toggleContact(' + c.id + ', this.checked)" style="accent-color:#7c3aed;cursor:pointer;"></td>' +
288      '<td style="padding:10px 12px;cursor:pointer;" onclick="openProfileDetail(' + c.id + ')"><div style="display:flex;align-items:center;gap:10px;"><div style="width:32px;height:32px;border-radius:50%;background:' + bgColor + ';color:#fff;display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:600;flex-shrink:0;">' + _escA(initial) + '</div><div><div style="font-weight:500;color:#1e293b;">' + _escA(name) + '</div><div style="font-size:12px;color:#94a3b8;">' + _escA(c.email || '') + '</div></div></div></td>' +
289      '<td style="padding:10px 12px;color:#94a3b8;font-size:13px;font-family:monospace;">' + (c.phone ? _escA(c.phone) : '<span style="color:#4a4560;">—</span>') + '</td>' +
290      '<td style="padding:10px 12px;font-size:12px;color:#94a3b8;">' + date + '</td>' +
291      '<td style="padding:10px 12px;">' + typeBadge + ' ' + verifyBadges + '</td>' +
292      '<td style="padding:10px 12px;font-size:12px;color:#94a3b8;">' + sourceLabel + '</td>' +
293      '<td style="padding:10px 8px;text-align:center;" title="' + (c.tz_indicator ? c.tz_indicator.label : 'Unknown') + '"><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:' + ({green:'#22c55e',amber:'#f59e0b',red:'#ef4444',grey:'#6b7280'}[c.tz_indicator ? c.tz_indicator.color : 'grey']) + ';"></span></td>' +
294      '<td style="padding:10px 12px;">' + tagsAndCommunity + '</td>' +
295    '</tr>';
296  }).join('');
297
298  updatePagination(data.pagination);
299  updateBulkBar();
300}
301
302function switchView(view) {
303  contactView = view;
304  contactPage = 1;
305  clearSelection();
306  loadContactList();
307  // Update bulk action dropdown options
308  updateBulkOptions();
309}
310
311// "Mark as expertise users" is platform-tenant only (Expertise 2.0). We learn
312// whether this tenant is allowed once, then re-render the dropdown.
313let canMarkExpertise = false;
314let _expertiseCapChecked = false;
315async function ensureExpertiseCapability() {
316  if (_expertiseCapChecked) return;
317  _expertiseCapChecked = true;
318  try {
319    const r = await api('/contacts/expertise-capability', { method: 'GET' });
320    canMarkExpertise = !!(r && r.can_mark);
321  } catch (_) { canMarkExpertise = false; }
322  updateBulkOptions();
323}
324
325function updateBulkOptions() {
326  const sel = document.getElementById('bulkAction');
327  if (!sel) return;
328  ensureExpertiseCapability();
329  const isArchived = contactView === 'archived';
330  let opts = '<option value="">Bulk Actions</option>';
331  if (isArchived) {
332    opts += '<option value="restore">↩ Restore</option><option value="permanent_delete">🗑️ Delete</option>';
333  } else {
334    /* §REMOVE — Archive and Delete side by side, in both views. Delete used to
335       exist only in the Archived view, so a coach looking at a list of bot
336       signups saw Archive, pressed it, and reasonably concluded we had no delete
337       (Alex, 2026-09-28). Both open the same explainer (showRemoveModal). */
338    opts += '<option value="archive">📦 Archive</option><option value="permanent_delete">🗑️ Delete</option>';
339    opts += '<option value="add_tag">🏷️ Add Tag</option><option value="remove_tag">🏷️ Remove Tag</option>';
340    opts += '<option value="add_to_community">👥 Add to community</option>';
341    if (canMarkExpertise) opts += '<option value="mark_expertise">⭐ Mark as expertise users</option>';
342  }
343  /* §EXPORT-CSV — offered in BOTH views, deliberately. Alex went looking for it
344     in exactly this menu (*"I can't see an export tag"*), and archived contacts
345     are the ones a coach most often wants a copy of before clearing them out. */
346  opts += '<option value="export_csv">⬇️ Export CSV</option>';
347  sel.innerHTML = opts;
348
349  /* §SUBSCRIBERS-UI — mirror the options as real buttons. ONE source of truth:
350     the buttons are generated FROM the option list, so a new action added above
351     appears as a button automatically and the two can never disagree.
352     Each button just sets the hidden <select> and calls the same
353     executeBulkAction() the Apply button used to, so the confirm dialogs, the
354     select-all path and the filter context are all unchanged. */
355  var host = document.getElementById('bulkActionButtons');
356  if (!host) return;
357  var TONE = { export_csv: 'primary', archive: 'danger', permanent_delete: 'danger' };
358  host.innerHTML = Array.prototype.slice.call(sel.options)
359    .filter(function (o) { return o.value; })
360    .map(function (o) {
361      return '<button type="button" class="sub-act ' + (TONE[o.value] || '') + '"' +
362        ' data-bulk="' + o.value + '">' + o.textContent + '</button>';
363    }).join('');
364  Array.prototype.forEach.call(host.querySelectorAll('[data-bulk]'), function (btn) {
365    btn.addEventListener('click', function () {
366      sel.value = btn.getAttribute('data-bulk');
367      executeBulkAction();
368    });
369  });
370}
371
372// ── Selection ──
373function toggleContact(id, checked) {
374  if (checked) selectedContactIds.add(id); else selectedContactIds.delete(id);
375  if (!checked) selectAllMode = false;
376  updateBulkBar();
377}
378
379function toggleSelectAll(cb) {
380  const checkboxes = document.querySelectorAll('#cTableBody input[type="checkbox"]');
381  if (cb.checked) {
382    checkboxes.forEach(c => { c.checked = true; const id = parseInt(c.closest('tr').dataset.id); selectedContactIds.add(id); });
383  } else {
384    checkboxes.forEach(c => { c.checked = false; });
385    selectedContactIds.clear();
386    selectAllMode = false;
387  }
388  updateBulkBar();
389}
390
391function selectAllContacts() {
392  selectAllMode = true;
393  updateBulkBar();
394}
395
396function clearSelection() {
397  selectedContactIds.clear();
398  selectAllMode = false;
399  const selAll = document.getElementById('cSelectAll');
400  if (selAll) selAll.checked = false;
401  document.querySelectorAll('#cTableBody input[type="checkbox"]').forEach(c => c.checked = false);
402  updateBulkBar();
403}
404
405function updateBulkBar() {
406  const bar = document.getElementById('bulkBar');
407  const count = selectAllMode ? contactTotal : selectedContactIds.size;
408  if (count > 0) {
409    bar.style.display = 'flex';
410    document.getElementById('bulkCount').textContent = count + ' selected';
411    const selAllBtn = document.getElementById('bulkSelectAll');
412    if (selAllBtn) {
413      selAllBtn.style.display = (!selectAllMode && selectedContactIds.size >= 20 && contactTotal > 20) ? 'inline' : 'none';
414      selAllBtn.textContent = 'Select all ' + contactTotal;
415    }
416  } else {
417    bar.style.display = 'none';
418  }
419}
420
421// The active contacts-list filter context — the SAME params loadContactList()
422// sends. Attached to every "select all" (all_in_segment) bulk call so the backend
423// resolves select-all to EXACTLY the rows on screen, never the whole tenant.
424function currentContactFilters() {
425  var fc = {
426    search: (document.getElementById('cSearch') && document.getElementById('cSearch').value) || '',
427    status: (document.getElementById('cStatusFilter') && document.getElementById('cStatusFilter').value) || '',
428    view: (typeof contactView !== 'undefined' ? contactView : 'active')
429  };
430  if (window._activeTagId) fc.tag_id = window._activeTagId;
431  /* 🚨 THE SAME OMISSION HERE WAS A DATA-LOSS HAZARD, not just a display bug.
432     This context is what makes "Select all N" resolve to the rows on screen
433     rather than the whole tenant (contact-routes.js:363). With a segment active
434     and no segment_id in here, "Select all 2980" → Archive would have archived
435     EVERY contact in the tenant while the banner said 62.
436     Alex's second screenshot is that exact screen: a segment active, "Select all
437     2980" showing, and the Bulk Actions menu open. */
438  if (window._activeSegmentId) fc.segment_id = window._activeSegmentId;
439  // The health band is part of 'what is on screen', so select-all and export
440  // must honour it too — the same reason segment_id is here.
441  if (_activeBand) fc.health = _activeBand;
442  return fc;
443}
444
445/* §EXPORT-CSV — download the current selection as a CSV.
446 *
447 * Alex, 2026-08-07: *"There's no way to export a CSV as a way to import a CSV
448 * but not to export CSV."*
449 *
450 * ⚠️ NOT routed through api(). That helper parses the response as JSON, which
451 * would turn a perfectly good CSV into a parse error — so this uses fetch
452 * directly and carries the same Authorization header by hand.
453 *
454 * The request body is deliberately the SAME shape POST /bulk sends, so the
455 * server resolves "which contacts" through one shared filter builder. An export
456 * that computed its own set is an export that disagrees with the screen.
457 */
458async function exportContactsCsv(ids, count) {
459  var btnLabel = count ? count.toLocaleString() + ' contact' + (count === 1 ? '' : 's') : 'contacts';
460  try {
461    showToast('Preparing ' + btnLabel + '…');
462    /* 🚨 TWO BUGS LIVED IN THESE FOUR LINES, both found by Alex on live:
463       *"So on live I'm trying to export. It's giving me this no token error."*
464
465       1. THE TOKEN KEY WAS WRONG. I wrote `localStorage.getItem('token')`.
466          The app stores it under **`cb_token`** (auth.js:2), so this read null,
467          sent `Bearer `, and every export 401'd. `getToken()` is the one
468          accessor — reading localStorage directly is what let me guess a key.
469       2. THE PATH WAS HARDCODED. `api()` builds every other URL from
470          `API_BASE`, which is `/dev/api` or `/staging/api` under a path prefix.
471          `/api/...` happens to be right on live and wrong elsewhere.
472
473       ⚠️ AND MY TEST DID NOT CATCH EITHER, because it asserted the string
474       `'Authorization': 'Bearer '` appeared — which it did, with an empty token
475       after it. Checking that a header EXISTS is not checking that it CARRIES
476       anything. The test now pins the accessor and the base. */
477    var res = await fetch(API_BASE + '/contacts/export.csv', {
478      method: 'POST',
479      headers: {
480        'Content-Type': 'application/json',
481        'Authorization': 'Bearer ' + (getToken() || '')
482      },
483      body: JSON.stringify({
484        profile_ids: ids,
485        all_in_segment: selectAllMode,
486        filter_context: currentContactFilters()
487      })
488    });
489
490    if (!res.ok) {
491      // The server explains itself (too large / empty / stale context); show that
492      // rather than a generic failure, because each one has a different fix.
493      var err = {};
494      try { err = await res.json(); } catch (_) {}
495      showToast(err.error || ('Export failed (' + res.status + ')'), 'error');
496      return;
497    }
498
499    var blob = await res.blob();
500    var name = 'contacts-' + new Date().toISOString().slice(0, 10) + '.csv';
501    var cd = res.headers.get('Content-Disposition') || '';
502    var m = cd.match(/filename="([^"]+)"/);
503    if (m) name = m[1];
504
505    var url = URL.createObjectURL(blob);
506    var a = document.createElement('a');
507    a.href = url;
507 a.download = name;
508    document.body.appendChild(a); a.click(); a.remove();
509    // Revoke on the next tick — revoking synchronously can cancel the download
510    // in some browsers before it has read the blob.
511    setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
512    showToast('Exported ' + btnLabel);
513  } catch (e) {
514    showToast('Export failed — ' + ((e && e.message) || 'network error'), 'error');
515  }
516}
517
518// ── Bulk Actions ──
519async function executeBulkAction() {
520  const action = document.getElementById('bulkAction').value;
521  if (!action) return;
522  
523  const ids = selectAllMode ? [] : Array.from(selectedContactIds);
524  const count = selectAllMode ? contactTotal : ids.length;
525
526  /* §EXPORT-CSV — handled before everything else because it is the one action
527     that returns a FILE, not JSON. api() parses JSON and would destroy the body. */
528  if (action === 'export_csv') { await exportContactsCsv(ids, count); return; }
529
530  if (action === 'archive' || action === 'permanent_delete') {
531    const bulkBody = { profile_ids: ids, all_in_segment: selectAllMode, filter_context: currentContactFilters() };
532    await removeContacts({
533      start: action === 'archive' ? 'archive' : 'delete',
534      count: count,
535      canArchive: contactView !== 'archived',
536      body: bulkBody,
537      done: function () { clearSelection(); loadContacts(); },
538    });
539  } else if (action === 'restore') {
540    const result = await api('/contacts/bulk', {
541      method: 'POST',
542      headers: { 'Content-Type': 'application/json' },
543      body: JSON.stringify({ action: 'restore', profile_ids: ids, all_in_segment: selectAllMode, filter_context: currentContactFilters() })
544    });
545    if (result) {
546      showToast((result.affected || 0) + ' contact(s) restored', 'success');
547      clearSelection();
548      loadContactList();
549    }
550  } else if (action === 'add_tag' || action === 'remove_tag') {
551    const isAdd = action === 'add_tag';
552    showTagPicker({
553      mode: isAdd ? 'add' : 'remove',
554      count: count,
555      run: async function (tag, report) {
556        const n = await runTagBatches(action, tag, ids, report);
557        clearSelection();
558        loadContacts();
559        return n;
560      }
561    });
562  } else if (action === 'add_to_community') {
563    const result = await api('/contacts/bulk', {
564      method: 'POST',
565      headers: { 'Content-Type': 'application/json' },
566      body: JSON.stringify({ action: 'add_to_community', profile_ids: ids, all_in_segment: selectAllMode, filter_context: currentContactFilters() })
567    });
568    if (result) {
569      if (result.error) { showToast(result.error, 'error'); return; }
570      showToast((result.affected || 0) + ' contact(s) added to the community', 'success');
571      clearSelection();
572      loadContacts();
573    }
574  } else if (action === 'mark_expertise') {
575    const result = await api('/contacts/bulk', {
576      method: 'POST',
577      headers: { 'Content-Type': 'application/json' },
578      body: JSON.stringify({ action: 'mark_expertise', profile_ids: ids, all_in_segment: selectAllMode, filter_context: currentContactFilters() })
579    });
580    if (result) {
581      if (result.error) { showToast(result.error, 'error'); return; }
582      showToast((result.affected || 0) + ' contact(s) marked as expertise users', 'success');
583      clearSelection();
584      loadContacts();
585    }
586  }
587}
588
589// ── Archive or delete: one explainer (§REMOVE) ──
590//
591// Alex, 2026-09-28: "it's not clear how to delete people. There's an archive
592// button but it doesn't delete... it needs to be some sort of UX that explains
593// how that works and differentiates between archive and delete."
594//
595// So both buttons open the SAME screen with both choices side by side, each
596// saying in plain words what happens, and the one pressed already picked. What
597// the copy promises is what the code does:
598//   Archive: status 'archived'. broadcast-sender re-reads status = 'active' at
599//            send time, so no broadcast or sequence email reaches them.
600//            Restore puts them back exactly as they were.
601//   Delete:  services/contact-delete.js. The profile and everything that cascades
602//            from it goes. Community posts and comments stay without a name
603//            (ON DELETE SET NULL). Anyone with an order or live-stream history
604//            is kept (keptNote below says which).
605//
606// The two names below are the entry points (and what the tests stub). Each
607// resolves to 'archive', 'delete' or false.
608// ⚠️ WHY SOMEBODY IS KEPT COMES FROM THE SERVER, NOT FROM A GUESS (B-63,
609// 2026-09-29). contact-delete.js keeps two kinds of people: anyone with an
610// order, and anyone with live-stream history (chat, reactions, DMs). The note
611// used to call everyone kept a buyer, so a member who had only chatted in a
612// live session was told they had bought. `kept_reasons.orders` counts kept
613// people with an order (live history or not); `kept_reasons.live_stream`
614// counts those kept for live history alone. The two add up to `kept`.
615function keptReasons(r) {
616  const why = (r && r.kept_reasons) || {};
617  return { orders: Number(why.orders) || 0, live: Number(why.live_stream) || 0 };
618}
619function keptNote(r) {
620  const n = (r && r.kept) || 0;
621  const why = keptReasons(r);
622  const has = function (k) { return k === 1 ? 'has' : 'have'; };
623  let s;
624  if (why.orders && !why.live) {
625    s = n + ' of them ' + has(n) + ' bought from you, so they stay, to keep your sales records whole.';
626  } else if (why.live && !why.orders) {
627    s = n + ' of them ' + has(n) + ' taken part in a live session, so they stay.';
628  } else if (why.orders && why.live) {
629    s = n + ' of them stay, to keep your records whole: ' +
630      why.orders + ' ' + has(why.orders) + ' bought from you and ' +
631      why.live + ' ' + has(why.live) + ' taken part in a live session.';
632  } else {
633    s = n + ' of them ' + (n === 1 ? 'stays' : 'stay') + ', to keep your records whole.';
634  }
635  return s + ' Archive them if you want them off your list.';
636}
637function keptToast(r) {
638  const n = (r && r.kept) || 0;
639  const why = keptReasons(r);
640  if (why.orders && !why.live) return n + ' kept because they have bought from you.';
641  if (why.live && !why.orders) return n + ' kept because they have taken part in a live session.';
642  return n + ' kept, to keep your records whole.';
643}
644
645function showArchiveModal(count, opts) { return showRemoveModal('archive', count, opts); }
646function showPermanentDeleteModal(count, opts) { return showRemoveModal('delete', count, opts); }
647
648function showRemoveModal(start, count, opts) {
649  opts = opts || {};
650  const canArchive = opts.canArchive !== false;
651  let choice = canArchive ? start : 'delete';
652  const who = count === 1 ? 'this contact' : count + ' contacts';
653  return new Promise(resolve => {
654    const overlay = document.createElement('div');
655    overlay.className = 'rm-overlay';
656    overlay.id = 'deleteConfirmOverlay';
657    overlay.innerHTML =
658      '<div class="rm-card" role="dialog" aria-modal="true" aria-labelledby="rmTitle">' +
659        '<h3 id="rmTitle" class="rm-title">Remove ' + who + '?</h3>' +
660        '<p class="rm-lede">Pick how. Archive can be undone. Delete cannot.</p>' +
661        '<div class="rm-choices">' +
662          (canArchive ?
663          '<button type="button" class="rm-choice" data-choice="archive">' +
664            '<span class="rm-choice-head">📦 Archive</span>' +
665            '<span class="rm-choice-sub">Hide them and keep everything</span>' +
666            '<ul class="rm-points">' +
667              '<li>Off your list, and no more broadcasts or sequence emails</li>' +
668              '<li>Their details, tags, form answers and history are all kept</li>' +
669              '<li>Bring them back any time from <strong>Archived</strong></li>' +
670            '</ul>' +
671          '</button>' : '') +
672          '<button type="button" class="rm-choice" data-choice="delete">' +
673            '<span class="rm-choice-head">🗑️ Delete</span>' +
674            '<span class="rm-choice-sub">Remove them for good</span>' +
675            '<ul class="rm-points">' +
676              '<li>Their details, tags, form answers and messages are erased</li>' +
677              '<li>Posts and comments in your community stay, with no name on them</li>' +
678              '<li>This cannot be undone</li>' +
679            '</ul>' +
680          '</button>' +
681        '</div>' +
682        '<div class="rm-delete-extra" hidden>' +
683          '<p class="rm-kept" hidden></p>' +
684          '<label class="rm-type"><span>Type <strong>DELETE</strong> to confirm</span>' +
685            '<input type="text" class="rm-type-input" autocomplete="off" spellcheck="false"></label>' +
686        '</div>' +
687        '<div class="rm-actions">' +
688          '<button type="button" class="rm-btn" data-action="cancel">Cancel</button>' +
689          '<button type="button" class="rm-btn rm-go" data-action="confirm"></button>' +
690        '</div>' +
691      '</div>';
692    document.body.appendChild(overlay);
693
694    const extra = overlay.querySelector('.rm-delete-extra');
695    const kept = overlay.querySelector('.rm-kept');
696    const input = overlay.querySelector('.rm-type-input');
697    const go = overlay.querySelector('[data-action="confirm"]');
698    let preview = null;          // the delete dry-run, fetched once, on first need
699    let previewAsked = false;
700
701    function paint() {
702      overlay.querySelectorAll('.rm-choice').forEach(function (b) {
703        const on = b.getAttribute('data-choice') === choice;
704        b.classList.toggle('is-on', on);
705        b.setAttribute('aria-pressed', on ? 'true' : 'false');
706      });
707      const del = choice === 'delete';
708      extra.hidden = !del;
709      go.classList.toggle('rm-danger', del);
710      if (!del) {
711        go.textContent = 'Archive ' + (count === 1 ? 'contact' : count + ' contacts');
712        go.disabled = false;
713        return;
714      }
715      const n = preview ? preview.to_delete : count;
716      go.textContent = n === 0 ? 'Nothing to delete' : 'Delete ' + (n === 1 ? 'contact' : n + ' contacts');
717      go.disabled = input.value.trim() !== 'DELETE' || (preview && preview.to_delete === 0);
718      if (!previewAsked && opts.body) loadPreview();
719    }
720
721    async function loadPreview() {
722      previewAsked = true;
723      try {
724        const r = await api('/contacts/bulk', {
725          method: 'POST',
726          headers: { 'Content-Type': 'application/json' },
727          body: JSON.stringify(Object.assign({}, opts.body, { action: 'delete_preview' })),
728        });
729        if (!r || r.error) return;
730        preview = r;
731        if (r.kept) {
732          kept.hidden = false;
733          kept.textContent = keptNote(r);
734        }
735        paint();
736      } catch (_) { /* the server still guards; the note is a courtesy */ }
737    }
738
739    function close(v) { overlay.remove(); document.removeEventListener('keydown', onKey); resolve(v); }
740    function onKey(e) { if (e.key === 'Escape') close(false); }
741    document.addEventListener('keydown', onKey);
742
743    overlay.querySelectorAll('.rm-choice').forEach(function (b) {
744      b.addEventListener('click', function () {
745        choice = b.getAttribute('data-choice');
746        paint();
747        if (choice === 'delete') input.focus();
748      });
749    });
750    input.addEventListener('input', paint);
751    overlay.querySelector('[data-action="cancel"]').onclick = function () { close(false); };
752    go.onclick = function () { if (!go.disabled) close(choice); };
753    overlay.onclick = function (e) { if (e.target === overlay) close(false); };
754    paint();
755    if (choice === 'delete') input.focus(); else go.focus();
756  });
757}
758
759// One path for bulk and single: ask, then do what was chosen.
760async function removeContacts({ start, count, canArchive, body, done }) {
761  const ask = start === 'archive' ? showArchiveModal : showPermanentDeleteModal;
762  const answer = await ask(count, { canArchive: canArchive, body: body });
763  if (!answer) return;
764  const chosen = answer === true ? start : answer;   // a stub or an older caller may resolve true
765  const result = await api('/contacts/bulk', {
766    method: 'POST',
767    headers: { 'Content-Type': 'application/json' },
768    body: JSON.stringify(Object.assign({}, body, { action: chosen === 'archive' ? 'archive' : 'permanent_delete' })),
769  });
770  if (!result) return;
771  if (result.error) { showToast(result.error, 'error'); return; }
772  if (chosen === 'archive') {
773    const n = result.affected || 0;
774    showToast(n + ' contact' + (n === 1 ? '' : 's') + ' archived. Restore them any time from Archived.', 'success');
775  } else {
776    const n = result.deleted || 0;
777    showToast(n + ' contact' + (n === 1 ? '' : 's') + ' deleted' +
778      (result.kept ? '. ' + keptToast(result) : '.'), 'success');
779  }
780  if (done) done(chosen, result);
781}
782
783// ── Recovery Hard-Delete Confirmation Modal (WS3) ──
784// Shows the dry-run breakdown (how many will be permanently deleted vs kept
785// because they have orders/activity) and requires a typed DELETE to confirm.
786function showRecoveryDeleteModal(summary) {
787  return new Promise(resolve => {
788    const toDelete = summary.to_delete || 0;
789    const skipped = summary.skipped || 0;
790    const overlay = document.createElement('div');
791    overlay.id = 'recoveryDeleteOverlay';
792    overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:10000;display:flex;align-items:center;justify-content:center;';
793    const skippedNote = skipped
794      ? '<p style="color:#94a3b8;font-size:13px;line-height:1.6;margin-top:10px;"><strong style="color:#16794a;">' + skipped + '</strong> contact' + (skipped !== 1 ? 's' : '') + ' will be <strong>kept</strong> because they have orders or activity.</p>'
795      : '';
796    overlay.innerHTML = '<div style="background:#13111c;border:1px solid #3d3650;border-radius:12px;padding:28px;max-width:460px;width:calc(100% - 32px);">' +
797      '<div style="text-align:center;margin-bottom:20px;">' +
798        '<div style="font-size:40px;margin-bottom:12px;">🗑️</div>' +
799        '<h3 style="color:#ef4444;margin:0 0 8px;">Permanently delete ' + toDelete + ' contact' + (toDelete !== 1 ? 's' : '') + '?</h3>' +
800        '<p style="color:#94a3b8;font-size:14px;line-height:1.6;">This permanently removes these contacts and their identifiers so the list can be re-imported cleanly. This cannot be undone.</p>' +
801        skippedNote +
802      '</div>' +
803      '<div style="margin-bottom:16px;text-align:center;">' +
804        '<label style="color:#94a3b8;font-size:13px;display:block;margin-bottom:6px;">Type <strong style="color:#ef4444;">DELETE</strong> to confirm:</label>' +
805        '<input type="text" id="recoveryDeleteInput" autocomplete="off" style="padding:8px 14px;border:1px solid #3d3650;border-radius:6px;background:#0d0b14;color:#e2e8f0;font-size:14px;text-align:center;width:120px;outline:none;font-family:monospace;">' +
806      '</div>' +
807      '<div style="display:flex;gap:10px;justify-content:center;">' +
808        '<button data-action="cancel" style="padding:10px 24px;border-radius:8px;border:1px solid #3d3650;background:transparent;color:#e2e8f0;cursor:pointer;font-size:14px;">Cancel</button>' +
809        '<button data-action="confirm" disabled style="padding:10px 24px;border-radius:8px;border:none;background:#4a4560;color:#94a3b8;cursor:not-allowed;font-size:14px;font-weight:600;">🗑️ Delete permanently</button>' +
810      '</div>' +
811    '</div>';
812    document.body.appendChild(overlay);
813
814    const input = overlay.querySelector('#recoveryDeleteInput');
815    const confirmBtn = overlay.querySelector('[data-action="confirm"]');
816    input.addEventListener('input', () => {
817      const match = input.value.trim() === 'DELETE';
818      confirmBtn.disabled = !match;
819      confirmBtn.style.background = match ? '#ef4444' : '#4a4560';
820      confirmBtn.style.color = match ? '#fff' : '#94a3b8';
821      confirmBtn.style.cursor = match ? 'pointer' : 'not-allowed';
822    });
823    input.focus();
824
825    overlay.querySelector('[data-action="cancel"]').onclick = () => { overlay.remove(); resolve(false); };
826    confirmBtn.onclick = () => { if (!confirmBtn.disabled) { overlay.remove(); resolve(true); } };
827    overlay.onclick = e => { if (e.target === overlay) { overlay.remove(); resolve(false); } };
828  });
829}
830
831// ── Single contact actions from detail ──
832function archiveOneContact(id) { return removeOneContact(id, 'archive'); }
833function permanentDeleteOneContact(id) { return removeOneContact(id, 'delete'); }
834function removeOneContact(id, start) {
835  return removeContacts({
836    start: start, count: 1,
837    canArchive: contactView !== 'archived',
838    body: { profile_ids: [id] },
839    done: function () { document.getElementById('profileDetailOverlay')?.remove(); loadContacts(); },
840  });
841}
842
843async function restoreOneContact(id) {
844  const result = await api('/contacts/bulk', {
845    method: 'POST',
846    headers: { 'Content-Type': 'application/json' },
847    body: JSON.stringify({ action: 'restore', profile_ids: [id] })
848  });
849  if (result) {
850    document.getElementById('profileDetailOverlay')?.remove();
851    showToast('Contact restored', 'success');
852    loadContacts();
853  }
854}
855
856/* ── runTagBatches — the honest progress counter ─────────────────────
857 *
858 * The tag picker's number is only worth showing if it is the number of people
859 * the database has actually been told about. So the selection is resolved to
860 * real ids first, and those ids are sent back through the SAME POST /bulk door
861 * in batches; each reply moves the counter by what that batch affected.
862 *
863 * ⚠️ ONE WRITE PATH, NOT TWO. Batching here changes the transaction size and
864 * nothing else: every batch still lands in `attachTagIds` / `detachTagIds`, so
865 * de-duplication holds and `tag_added` still fires only for rows that actually
866 * changed. A second bulk endpoint "for the progress bar" is how the two paths
867 * drift and one of them quietly stops firing automations.
868 *
869 * Three cases, and the fallback is the interesting one:
870 *   • a page selection  — the ids are already in hand, batch them.
871 *   • select-all        — ask the server for the ids, then batch them.
872 *   • select-all beyond the resolve ceiling — the server says `too_many`, so
873 *     fall back to the original one-shot call. The modal then says
874 *     "Tagging N contacts…" with no fraction rather than inventing one.
875 */
876const TAG_BATCH_SIZE = 250;
877
878async function runTagBatches(action, tag, pageIds, report) {
879  const isAdd = action === 'add_tag';
880  const fc = currentContactFilters();
881
882  let ids = Array.isArray(pageIds) ? pageIds.slice() : [];
883  if (selectAllMode) {
884    const r = await api('/contacts/resolve-selection', {
885      method: 'POST',
886      headers: { 'Content-Type': 'application/json' },
887      body: JSON.stringify({ all_in_segment: true, filter_context: fc })
888    });
889    if (!r || r.error) throw new Error((r && r.error) || 'Could not work out who is selected.');
890    if (r.too_many) {
891      // No fraction is honest here; a made-up one is not.
892      report(r.total || 0, 0);
893      const one = await api('/contacts/bulk', {
894        method: 'POST',
895        headers: { 'Content-Type': 'application/json' },
896        body: JSON.stringify({ action: action, profile_ids: [], all_in_segment: true,
897          tag_name: isAdd ? tag.name : undefined, tag_id: tag.id || undefined, filter_context: fc })
898      });
899      if (!one || one.error) throw new Error((one && one.error) || 'The tag did not go through.');
900      return one.affected || 0;
901    }
902    ids = r.ids || [];
903  }
904
905  const total = ids.length;
906  if (!total) { report(0, 0); return 0; }
907
908  /* The FIRST batch carries tag_name so a brand-new tag is created once; every
909     batch after it uses the id that came back. The INSERT is ON CONFLICT DO
910     UPDATE so re-sending the name would be safe too — this just saves the work
911     and keeps one id through the run. */
912  let tagId = tag.id || null;
913  let done = 0;     // looked at — what the bar counts
914  let changed = 0;  // actually written — what the closing line reports
915  report(0, total);
916
917  for (let i = 0; i < total; i += TAG_BATCH_SIZE) {
918    const batch = ids.slice(i, i + TAG_BATCH_SIZE);
919    const body = { action: action, profile_ids: batch, all_in_segment: false, filter_context: fc };
920    if (tagId) body.tag_id = tagId;
921    else if (isAdd) body.tag_name = tag.name;
922    const res = await api('/contacts/bulk', {
923      method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
924    });
925    if (!res || res.error) throw new Error((res && res.error) || 'Stopped after ' + done.toLocaleString() + '.');
926    if (!tagId && res.tag_id) tagId = res.tag_id;
927    /* `processed`, never `affected`. Removing a tag from 250 people where only
928       3 had it affects 3, and a bar fed that number looks stuck. */
929    done += (typeof res.processed === 'number' ? res.processed : batch.length);
930    changed += (typeof res.affected === 'number' ? res.affected : batch.length);
931    report(Math.min(done, total), total);
932  }
933  return changed;
934}
935
936/* ── Tag Picker Modal ──────────────────────────────────────────────────────
937 *
938 * Alex, 2026-09-21, after tagging 4,279 contacts:
939 *   *"When I start partially typing in the word, I'd expect it to start
940 *   filtering out the list based on whether the word that I've typed in
941 *   appears anywhere in the name. It's not doing that."*
942 *   *"When I click Add and select the tag I want, the modal immediately
943 *   disappears and it starts tagging the people. Instead it should give me an
944 *   option to confirm… or it should show it selected in the open text box as a
945 *   pill… it should maybe give me a confirmation and tell me how many people
946 *   it's being added to."*
947 *   *"Once I actually click Yes for the confirmation, the modal just
948 *   disappears and I'm left in limbo… instead the modal should say 'Tagging X
949 *   number of people.' It should be dynamically counting all the people that
950 *   have been tagged so far so I can see it's a work in progress."*
951 *
952 * Three faults, one modal, three boards:
953 *   PICK    — type to filter (substring, anywhere in the name, case-blind);
954 *             clicking a tag parks it as a PILL instead of firing the action.
955 *   CONFIRM — says the tag and the number of people, in words, before anything
956 *             is written.
957 *   RUNNING — stays open and counts what has actually landed, then says so.
958 *
959 * ⚠️ THE COUNT IS REAL, NOT A TIMER. `run` sends the contacts in batches and
960 * reports each one as it lands; a progress bar that animates on a guess is a
961 * number the coach believes, and is worse than no bar at all. Above
962 * RESOLVE_SELECTION_MAX the caller cannot batch, and then this modal says
963 * "Tagging N contacts…" with no fraction rather than inventing one.
964 *
965 * ⚠️ NOTHING CLOSES THE MODAL WHILE IT IS RUNNING — no ✕, no click-outside, no
966 * Escape. The write carries on regardless of the window, so a modal that can be
967 * dismissed mid-run puts the coach straight back in the limbo this rewrite
968 * exists to end.
969 */
970function showTagPicker(opts) {
971  var o = opts || {};
972  var mode = o.mode === 'remove' ? 'remove' : 'add';
973  var count = Number(o.count) || 0;
974  var run = o.run;                       // async (tag, report) => finalCount
975  var all = [];                          // every tag the tenant has
976  var picked = null;                     // { id, name } once chosen, else null
977  var lastQuery = '';                    // what was typed, kept across re-renders
978  var board = 'pick';
979
980  var overlay = document.createElement('div');
981  overlay.id = 'tagPickerOverlay';
982  overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:10000;display:flex;align-items:center;justify-content:center;';
983  overlay.innerHTML = '<div id="tagPickerCard" style="background:#13111c;border:1px solid #3d3650;border-radius:12px;padding:24px;max-width:420px;width:calc(100% - 32px);"></div>';
984  var card = overlay.querySelector('#tagPickerCard');
985
986  function close() { overlay.remove(); }
987  function people(n) { return n.toLocaleString() + ' contact' + (n === 1 ? '' : 's'); }
988  var verb = mode === 'remove' ? 'Remove' : 'Add';
989
990  /* Only the pick board is dismissible. See the ⚠️ above. */
991  overlay.onclick = function (e) { if (e.target === overlay && board === 'pick') close(); };
992  overlay.addEventListener('keydown', function (e) { if (e.key === 'Escape' && board === 'pick') close(); });
993
994  // ── Board 1: pick ────────────────────────────────────────────────────────
995  function renderPick() {
996    board = 'pick';
997    card.innerHTML =
998      '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;">' +
999        '<h3 style="color:#e2e8f0;margin:0;">' + verb + ' tag</h3>' +
1000        '<span data-tp="close" style="cursor:pointer;color:#94a3b8;font-size:18px;">✕</span>' +
1001      '</div>' +
1002      '<div style="color:#94a3b8;font-size:12px;margin-bottom:14px;">' + people(count) + ' selected</div>' +
1003      '<div id="tpBox" style="display:flex;gap:6px;align-items:center;margin-bottom:10px;">' +
1004        '<div id="tpField" style="flex:1;display:flex;align-items:center;gap:6px;flex-wrap:wrap;min-height:36px;padding:5px 8px;border:1px solid #3d3650;border-radius:6px;background:#0d0b14;"></div>' +
1005        '<button data-tp="go" style="padding:9px 16px;background:#7c3aed;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:13px;">' + verb + '</button>' +
1006      '</div>' +
1007      '<div id="tpHint" style="color:#64748b;font-size:12px;margin-bottom:8px;min-height:16px;"></div>' +
1008      '<div id="tpList" style="max-height:250px;overflow-y:auto;"></div>';
1009    card.querySelector('[data-tp="close"]').onclick = close;
1010    card.querySelector('[data-tp="go"]').onclick = onGo;
1011    renderField();
1012    renderList('');
1013  }
1014
1015  /* The box holds EITHER the pill or the input, never both: one tag at a time,
1016     which is what the action itself supports. The pill's ✕ gives the input back. */
1017  function renderField() {
1018    var f = card.querySelector('#tpField');
1019    if (picked) {
1020      f.innerHTML = '<span style="display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:999px;background:#2d2640;border:1px solid #4c3f6b;color:#e2e8f0;font-size:13px;">' +
1021        _escA(picked.name) + '<span data-tp="unpick" style="cursor:pointer;color:#a78bfa;font-weight:600;">✕</span></span>';
1022      f.querySelector('[data-tp="unpick"]').onclick = function () {
1023        picked = null; renderField(); renderList(lastQuery);
1024      };
1025      return;
1026    }
1027    f.innerHTML = '<input type="text" id="tpSearch" autocomplete="off" placeholder="Type to find a tag, or name a new one…" ' +
1028      'style="flex:1;min-width:120px;border:none;outline:none;background:transparent;color:#e2e8f0;font-size:13px;padding:4px 2px;">';
1029    var input = f.querySelector('#tpSearch');
1030    input.value = lastQuery;
1031    input.oninput = function () { lastQuery = input.value; renderList(input.value); };
1032    input.onkeydown = function (e) { if (e.key === 'Enter') { e.preventDefault(); onGo(); } };
1033    setTimeout(function () { input.focus(); }, 0);
1034  }
1035
1036  /* The filter Alex asked for: the typed text ANYWHERE in the name, case-blind.
1037     The old box only ever created tags, so typing "sep" left all 800 on screen. */
1038  function matches(q) {
1039    var needle = String(q || '').trim().toLowerCase();
1040    if (!needle) return all;
1041    return all.filter(function (t) { return String(t.name || '').toLowerCase().indexOf(needle) !== -1; });
1042  }
1043
1044  function renderList(q) {
1045    var list = card.querySelector('#tpList');
1046    var hint = card.querySelector('#tpHint');
1047    if (!list) return;
1048    var typed = String(q || '').trim();
1049    var found = matches(typed);
1050    var exact = all.find(function (t) { return String(t.name).toLowerCase() === typed.toLowerCase(); });
1051
1052    /* The list STAYS on screen once a tag is picked. Emptying it collapses the
1053       card to a third of its height the moment you click, which reads as the
1054       modal closing — and it takes away the one-click way to change your mind. */
1055    if (picked) {
1056      hint.textContent = 'Press ' + verb + ' to ' + verb.toLowerCase() + ' this tag ' +
1057        (mode === 'remove' ? 'from ' : 'to ') + people(count) + ', or pick a different one.';
1058    } else
1059    if (!all.length) { hint.textContent = ''; list.innerHTML = '<div style="color:#64748b;padding:12px;">No tags yet — type a name and press Add.</div>'; return; }
1060
1061    if (picked) { /* hint already set above */ }
1062    else if (typed && !found.length) {
1063      hint.textContent = mode === 'remove'
1064        ? 'No tag matches “' + typed + '”.'
1065        : 'No tag matches “' + typed + '” — press Add to create it.';
1066    } else if (typed) {
1067      hint.textContent = found.length.toLocaleString() + ' of ' + all.length.toLocaleString() + ' tags match' +
1068        (exact ? '' : (mode === 'remove' ? '' : ' — or press Add to create “' + typed + '”'));
1069    } else {
1070      hint.textContent = all.length.toLocaleString() + ' tags';
1071    }
1072
1073    list.innerHTML = found.map(function (t) {
1074      var on = picked && Number(picked.id) === Number(t.id);
1075      return '<div data-tag-id="' + t.id + '" style="padding:10px 12px;cursor:pointer;display:flex;align-items:center;gap:8px;border-radius:6px;' +
1076        (on ? 'background:#241f33;' : '') + '" ' +
1077        'onmouseover="this.style.background=\'#1e1b2e\'" onmouseout="this.style.background=\'' + (on ? '#241f33' : '') + '\'">' +
1078        '<span style="width:12px;height:12px;border-radius:50%;flex-shrink:0;background:' + _escA(t.color || '#666') + ';"></span>' +
1079        '<span style="color:#e2e8f0;flex:1;">' + _escA(t.name) + (on ? ' \u2713' : '') + '</span>' +
1080        '<span style="color:#64748b;font-size:12px;">' + Number(t.contact_count || 0).toLocaleString() + '</span>' +
1081      '</div>';
1082    }).join('') || '<div style="color:#64748b;padding:12px;">Nothing matches.</div>';
1083
1084    /* Delegated, and the name is read back from `all` by id — the old row
1085       smuggled the name through an onclick attribute with hand-rolled quote
1086       escaping, which breaks on any tag containing a quote. */
1087    Array.prototype.forEach.call(list.querySelectorAll('[data-tag-id]'), function (row) {
1088      row.onclick = function () {
1089        var id = Number(row.getAttribute('data-tag-id'));
1090        var t = all.find(function (x) { return Number(x.id) === id; });
1091        if (!t) return;
1092        picked = { id: t.id, name: t.name };
1093        renderField(); renderList(lastQuery);
1094      };
1095    });
1096  }
1097
1098  function onGo() {
1099    if (picked) return renderConfirm(picked);
1100    var input = card.querySelector('#tpSearch');
1101    var typed = input ? input.value.trim() : '';
1102    if (!typed) return;
1103    var exact = all.find(function (t) { return String(t.name).toLowerCase() === typed.toLowerCase(); });
1104    if (exact) { picked = { id: exact.id, name: exact.name }; renderField(); return renderConfirm(picked); }
1105    /* Removing a tag that does not exist is not a thing — say so rather than
1106       creating one and removing it from nobody. */
1107    if (mode === 'remove') { card.querySelector('#tpHint').textContent = 'No tag called “' + typed + '” to remove.'; return; }
1108    renderConfirm({ id: null, name: typed }, true);
1109  }
1110
1111  // ── Board 2: confirm ─────────────────────────────────────────────────────
1112  function renderConfirm(tag, isNew) {
1113    board = 'confirm';
1114    var line = mode === 'remove'
1115      ? 'Remove the tag <b style="color:#e2e8f0;">' + _escA(tag.name) + '</b> from <b style="color:#e2e8f0;">' + people(count) + '</b>?'
1116      : (isNew ? 'Create the tag ' : 'Add the tag ') + '<b style="color:#e2e8f0;">' + _escA(tag.name) + '</b>' +
1117        (isNew ? ' and add it to ' : ' to ') + '<b style="color:#e2e8f0;">' + people(count) + '</b>?';
1118    card.innerHTML =
1119      '<h3 style="color:#e2e8f0;margin:0 0 12px;">' + (mode === 'remove' ? 'Remove tag' : 'Add tag') + '</h3>' +
1120      '<p style="color:#94a3b8;font-size:14px;line-height:1.6;margin:0 0 6px;">' + line + '</p>' +
1121      (mode === 'add'
1122        ? '<p style="color:#64748b;font-size:12px;line-height:1.6;margin:0 0 20px;">Anyone who already has it is left alone, so they will not be entered into a sequence or automation twice.</p>'
1123        : '<p style="color:#64748b;font-size:12px;line-height:1.6;margin:0 0 20px;">Only people who actually have it are changed.</p>') +
1124      '<div style="display:flex;gap:10px;justify-content:flex-end;">' +
1125        '<button data-tp="back" style="padding:9px 16px;background:#2d2640;color:#e2e8f0;border:1px solid #3d3650;border-radius:6px;cursor:pointer;font-size:13px;">Back</button>' +
1126        '<button data-tp="yes" style="padding:9px 16px;background:' + (mode === 'remove' ? '#dc2626' : '#7c3aed') + ';color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:13px;">' +
1127          (mode === 'remove' ? 'Yes, remove it' : 'Yes, add it') + '</button>' +
1128      '</div>';
1129    card.querySelector('[data-tp="back"]').onclick = function () { renderPick(); };
1130    card.querySelector('[data-tp="yes"]').onclick = function () { start(tag); };
1131  }
1132
1133  // ── Board 3: running ─────────────────────────────────────────────────────
1134  function renderRunning(tag) {
1135    board = 'running';
1136    card.innerHTML =
1137      '<h3 style="color:#e2e8f0;margin:0 0 14px;">' + (mode === 'remove' ? 'Removing tag' : 'Adding tag') + '</h3>' +
1138      '<div id="tpCount" style="color:#e2e8f0;font-size:15px;margin-bottom:10px;">Starting…</div>' +
1139      '<div style="height:8px;border-radius:999px;background:#241f33;overflow:hidden;margin-bottom:8px;">' +
1140        '<div id="tpBar" style="height:100%;width:0%;background:#7c3aed;transition:width 0.25s;"></div>' +
1141      '</div>' +
1142      '<div style="color:#64748b;font-size:12px;">Tag: ' + _escA(tag.name) + '. Leave this open until it finishes.</div>';
1143  }
1144
1145  function report(done, total) {
1146    var c = card.querySelector('#tpCount');
1147    var bar = card.querySelector('#tpBar');
1148    if (!c) return;
1149    if (!total) { // the un-batchable case: honest words, no invented fraction
1150      c.textContent = (mode === 'remove' ? 'Removing from ' : 'Tagging ') + people(done) + '…';
1151      if (bar) bar.style.width = '100%';
1152      return;
1153    }
1154    c.textContent = (mode === 'remove' ? 'Removing from ' : 'Tagging ') +
1155      done.toLocaleString() + ' of ' + people(total) + '…';
1156    if (bar) bar.style.width = Math.min(100, Math.round((done / total) * 100)) + '%';
1157  }
1158
1159  function renderDone(n, err) {
1160    board = 'done';
1161    card.innerHTML =
1162      '<h3 style="color:#e2e8f0;margin:0 0 12px;">' + (err ? 'It did not finish' : 'Done') + '</h3>' +
1163      '<p style="color:' + (err ? '#fca5a5' : '#94a3b8') + ';font-size:14px;line-height:1.6;margin:0 0 20px;">' +
1164        (err ? _escA(err) : (mode === 'remove' ? 'Removed from ' : 'Tagged ') + people(n) + '.') + '</p>' +
1165      '<div style="display:flex;justify-content:flex-end;">' +
1166        '<button data-tp="done" style="padding:9px 18px;background:#7c3aed;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:13px;">Close</button>' +
1167      '</div>';
1168    card.querySelector('[data-tp="done"]').onclick = close;
1169  }
1170
1171  function start(tag) {
1172    renderRunning(tag);
1173    Promise.resolve()
1174      .then(function () { return run(tag, report); })
1175      .then(function (n) { renderDone(Number(n) || 0, null); })
1176      .catch(function (e) { renderDone(0, (e && e.message) || 'Something went wrong.'); });
1177  }
1178
1179  document.body.appendChild(overlay);
1180  card.innerHTML = '<div style="color:#94a3b8;padding:20px;">Loading tags…</div>';
1181  api('/contacts/tags').then(function (tags) {
1182    all = Array.isArray(tags) ? tags : [];
1183    renderPick();
1184  }).catch(function () { all = []; renderPick(); });
1185}
1186
1187// ── Toast ──
1188/* ⚠️ THIS IS THE APP'S showToast: contacts.js loads after ui-extras.js, so
1189   this declaration is the one window.showToast resolves to on every screen.
1190
1191   A toast never covers a control (website round 9, N10, Margaret: "Page
1192   added…" sat over the finish bar's words and beside its Publish button).
1193   Its place is centred near the bottom, as before. Before it shows, it looks
1194   at that place: if a bar floats along the bottom (fixed or sticky), it
1195   starts 12px above the highest one; and if a button, link or box to type in
1196   is still under it there, it rises a step at a time until nothing is. Tall
1197   fixed things (a sidebar, an overlay) are not bars. */
1198function toastBottomRoom(tw, th) {
1199  var base = 24;
1200  try {
1201    if (typeof document.elementsFromPoint !== 'function') return base;
1202    var h = window.innerHeight, w = window.innerWidth;
1203    var floating = function (el) {
1204      for (var n = el; n && n !== document.body && n !== document.documentElement; n = n.parentElement) {
1205        var pos = window.getComputedStyle(n).position;
1206        if (pos !== 'fixed' && pos !== 'sticky') continue;
1207        var r = n.getBoundingClientRect();
1208        if (r.height > h * 0.5 || r.bottom < h - 60 || r.width < 120) return null;
1209        return r;
1210      }
1211      return null;
1212    };
1213    var CONTROL = 'button, a[href], input, textarea, select, [role="button"], [contenteditable="true"]';
1214    var under = function (x, y) {
1215      var els = document.elementsFromPoint(x, y) || [];
1216      for (var k = 0; k < els.length; k++) {
1217        var e = els[k];
1218        if (e.closest && e.closest('[data-cb-toast]')) continue;
1219        if (e.closest && e.closest(CONTROL)) return 'control';
1220        if (floating(e)) return 'bar';
1221      }
1222      return '';
1223    };
1224    // Start above any bar floating along the bottom.
1225    var top = h;
1226    [0.3, 0.5, 0.7].forEach(function (fx) {
1227      for (var y = h - 6; y > h - 240; y -= 20) {
1228        var els = document.elementsFromPoint(Math.round(w * fx), y) || [];
1229        for (var k = 0; k < els.length; k++) {
1230          if (els[k].closest && els[k].closest('[data-cb-toast]')) continue;
1231          var r = floating(els[k]);
1232          if (r && r.top < top) top = r.top;
1233        }
1234      }
1235    });
1236    var room = top < h ? Math.max(base, Math.ceil(h - top + 12)) : base;
1237    // Then rise until no control is under the toast's own box.
1238    var left = Math.max(0, (w - tw) / 2);
1239    var clear = function (b) {
1240      var bottom = h - b, ttop = bottom - th;
1241      for (var ix = 0; ix <= 4; ix++) for (var iy = 0; iy <= 2; iy++) {
1242        if (under(left + 4 + (tw - 8) * ix / 4, ttop + 3 + (th - 6) * iy / 2)) return false;
1243      }
1244      return true;
1245    };
1246    for (var b = room; b < h * 0.6; b += 16) { if (clear(b)) return Math.ceil(b); }
1247    return room;
1248  } catch (e) { return base; }
1249}
1250function showToast(msg, type) {
1251  const toast = document.createElement('div');
1252  toast.setAttribute('data-cb-toast', '');
1253  toast.setAttribute('role', type === 'error' ? 'alert' : 'status');
1254  toast.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);max-width:min(560px, calc(100vw - 32px));box-sizing:border-box;padding:12px 24px;border-radius:8px;font-size:14px;z-index:10001;animation:fadeIn 0.3s;color:#fff;visibility:hidden;background:' + (type === 'success' ? '#10b981' : type === 'error' ? '#ef4444' : '#6b7280') + ';';
1255  toast.textContent = msg;
1256  document.body.appendChild(toast);
1257  const box = toast.getBoundingClientRect();
1258  toast.style.bottom = toastBottomRoom(box.width || 360, box.height || 46) + 'px';
1259  toast.style.visibility = '';
1260  setTimeout(() => { toast.style.opacity = '0'; toast.style.transition = 'opacity 0.3s'; setTimeout(() => toast.remove(), 300); }, 3000);
1261}
1262
1263// ── Sorting ──
1264function sortContacts(field) {
1265  if (contactSort === field) {
1266    contactOrder = contactOrder === 'asc' ? 'desc' : 'asc';
1267  } else {
1268    contactSort = field;
1269    contactOrder = 'desc';
1270  }
1271  contactPage = 1;
1272  loadContactList();
1273  document.querySelectorAll('.sort-arrow').forEach(a => a.textContent = '');
1274  const arrow = document.getElementById('sort-' + field);
1275  if (arrow) arrow.textContent = contactOrder === 'asc' ? ' ↑' : ' ↓';
1276}
1277
1278// ── Search ──
1279function debounceSearch() {
1280  clearTimeout(searchTimeout);
1281  searchTimeout = setTimeout(() => { contactPage = 1; loadContactList(); }, 300);
1282}
1283
1284// ── Pagination ──
1285function updatePagination(pag) {
1286  if (!pag) return;
1287  const info = document.getElementById('cPagInfo');
1288  const btns = document.getElementById('cPagButtons');
1289  if (!info || !btns) return;
1290  
1291  const start = ((pag.page - 1) * pag.limit) + 1;
1292  const end = Math.min(pag.page * pag.limit, pag.total);
1293  info.textContent = pag.total > 0 ? start + '–' + end + ' of ' + pag.total : '0 contacts';
1294  
1295  let html = '';
1296  if (pag.page > 1) html += '<button onclick="contactPage--;loadContactList()" style="background:#2d2640;color:#e2e8f0;border:1px solid #3d3650;padding:6px 14px;border-radius:6px;cursor:pointer;">← Prev</button>';
1297  if (pag.pages > 1) html += '<span style="color:#94a3b8;font-size:13px;padding:6px 8px;">Page ' + pag.page + '/' + pag.pages + '</span>';
1298  if (pag.page < pag.pages) html += '<button onclick="contactPage++;loadContactList()" style="background:#2d2640;color:#e2e8f0;border:1px solid #3d3650;padding:6px 14px;border-radius:6px;cursor:pointer;">Next →</button>';
1299  btns.innerHTML = html;
1300}
1301
1302// ── Profile Detail Modal ──
1303async function openProfileDetail(id) {
1304  const data = await api('/contacts/' + id);
1305  if (!data || !data.profile) return;
1306  const p = data.profile;
1307  const isArchived = p.status === 'archived';
1308
1309  // Editable contact details (name / email / phone). Stash the current values so
1310  // the inline "Edit" buttons don't have to smuggle unescaped strings through an
1311  // onclick attribute — editContactField reads them from here.
1312  window.__contactDetail = { id: p.id, display_name: p.display_name || '', email: p.primary_email || '', phone: p.primary_phone || '' };
1313  const dRow = (lbl, val, field) =>
1314    '<div style="display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid #201c2e;">' +
1315      '<span style="color:#94a3b8;font-size:12px;width:52px;flex-shrink:0;">' + lbl + '</span>' +
1316      '<span style="color:' + (val ? '#e2e8f0' : '#64748b') + ';font-size:14px;flex:1;word-break:break-word;">' + (val ? _escA(val) : '—') + '</span>' +
1317      '<button onclick="editContactField(\'' + field + '\',\'' + lbl + '\')" style="background:transparent;border:1px solid #3d3650;color:#a78bfa;padding:4px 12px;border-radius:7px;cursor:pointer;font-size:12px;flex-shrink:0;">' + (val ? 'Edit' : 'Add') + '</button>' +
1318    '</div>';
1319  const detailsBlock = isArchived ? '' :
1320    '<div style="background:#1a1726;border:1px solid #2d2640;border-radius:10px;padding:6px 16px 8px;margin-bottom:20px;">' +
1321      '<div style="color:#a78bfa;font-size:13px;font-weight:600;padding:8px 0 4px;">Contact details</div>' +
1322      dRow('Name',  p.display_name || '',  'display_name') +
1323      dRow('Email', p.primary_email || '', 'email') +
1324      dRow('Phone', p.primary_phone || '', 'phone') +
1325    '</div>';
1326  
1327  const typeBadge = p.type === 'member'
1328    ? '<span style="background:#10b981;color:#fff;padding:4px 12px;border-radius:12px;font-size:13px;">Member</span>'
1329    : '<span style="background:#6b7280;color:#fff;padding:4px 12px;border-radius:12px;font-size:13px;">Guest</span>';
1330
1331  const statusBadge = isArchived
1332    ? '<span style="background:#f59e0b;color:#000;padding:4px 12px;border-radius:12px;font-size:13px;font-weight:600;">Archived</span>' : '';
1333
1334  // Block E4 — high-intent community-engagement badge (the outreach hand-off).
1335  const intentBadge = (data.community_intent && data.community_intent.high_intent)
1336    ? '<span style="background:#ef4444;color:#fff;padding:4px 12px;border-radius:12px;font-size:13px;font-weight:600;" title="Strong community engagement — a high-priority follow-up signal">🔥 High community intent</span>'
1337    : '';
1338
1339  const identHtml = data.identifiers.map(i => {
1340    const icon = { email: '✉️', phone: '📱', fingerprint: '🔍', webchat_session: '🌐', device_id: '📲' }[i.type] || '•';
1341    const verifiedBadge = i.verified 
1342      ? '<span style="color:#10b981;font-size:11px;" title="Verified">✅</span>' 
1343      : '<span style="color:#f59e0b;font-size:11px;cursor:pointer;" title="Click to verify" onclick="event.stopPropagation();verifyIdentifier(' + p.id + ',' + i.id + ')">⚠️ unverified</span>';
1344    return '<div style="display:flex;align-items:center;gap:8px;padding:8px 0;border-bottom:1px solid #1e1b2e;">' +
1345      '<span>' + icon + '</span>' +
1346      '<span style="color:#e2e8f0;font-family:monospace;font-size:13px;">' + _escA(i.value) + '</span>' +
1347      verifiedBadge +
1348      '<span style="color:#64748b;font-size:11px;margin-left:auto;">since ' + App.Dates.short(i.first_seen_at, { year: 'auto' }) + '</span>' +
1349    '</div>';
1350  }).join('') || '<div style="color:#64748b;padding:8px;">No identifiers</div>';
1351
1352  const timeHtml = data.events.slice(0, 20).map(e => {
1353    const time = App.Dates.shortDateTime(e.created_at, { year: 'auto', timeLocale: 'en-GB', timeOptions: { hour: '2-digit' } });
1354    const channelIcon = { web: '🌐', whatsapp: '💬', sms: '📱', webchat: '🌐', email: '✉️' }[e.channel] || '';
1355    return '<div style="display:flex;align-items:center;gap:8px;padding:6px 0;font-size:13px;">' +
1356      '<span style="color:#64748b;min-width:100px;">' + time + '</span>' +
1357      '<span>' + channelIcon + '</span>' +
1358      '<span style="color:#e2e8f0;">' + e.event.replace(/_/g, ' ') + '</span>' +
1359    '</div>';
1360  }).join('') || '<div style="color:#64748b;padding:12px;">No events recorded</div>';
1361
1362  const tagHtml = data.tags.map(t =>
1363    '<span style="background:' + (t.color || '#6b7280') + ';color:#fff;padding:3px 10px;border-radius:10px;font-size:12px;margin-right:4px;">' + _escA(t.name) + '</span>'
1364  ).join('') || '<span style="color:#64748b;">No tags</span>';
1365
1366  // Block E3 — community-activity timeline (community_events for this contact).
1367  const verbLabel = {
1368    'post.created': 'Posted', 'comment.created': 'Commented', 'reply.created': 'Replied',
1369    'reaction.added': 'Reacted', 'member.joined': 'Joined the community',
1370    'member.invited': 'Invited', 'session.started': 'Signed in',
1371    'poll.voted': 'Voted in a poll', 'post.bookmarked': 'Saved a post',
1372    'follow.added': 'Followed', 'member.mentioned': 'Was mentioned',
1373  };
1374  const communityHtml = (data.community_activity || []).map(e => {
1375    const time = App.Dates.shortDateTime(e.created_at, { year: 'auto', timeLocale: 'en-GB', timeOptions: { hour: '2-digit' } });
1376    const label = verbLabel[e.verb] || String(e.verb || '').replace(/[._]/g, ' ');
1377    return '<div style="display:flex;align-items:center;gap:8px;padding:6px 0;font-size:13px;">' +
1378      '<span style="color:#64748b;min-width:100px;">' + time + '</span>' +
1379      '<span>🏘️</span>' +
1380      '<span style="color:#e2e8f0;">' + label + '</span>' +
1381    '</div>';
1382  }).join('') || '<div style="color:#64748b;padding:12px;">No community activity</div>';
1383
1384  // Signup responses — the full labelled Q&A the contact gave on the site's
1385  // form (age, goals, custom selects …). Captured in form_submissions but not on
1386  // the profile columns, so this is where "all their info" actually surfaces.
1387  const signupHtml = (data.signup_submissions || []).map(function (s) {
1388    const rows = (s.answers || []).map(function (a) {
1389      return '<div style="display:flex;gap:10px;padding:7px 0;border-bottom:1px solid #201c2e;">' +
1390        '<span style="color:#94a3b8;font-size:12px;flex:0 0 42%;">' + _escA(a.label) + '</span>' +
1391        '<span style="color:#e2e8f0;font-size:13px;flex:1;word-break:break-word;">
1391' + _escA(a.value) + '</span>' +
1392      '</div>';
1393    }).join('');
1394    const when = s.submitted_at ? App.Dates.short(s.submitted_at) : '';
1395    return '<div style="margin-bottom:6px;">' +
1396      '<div style="color:#a78bfa;font-size:12.5px;font-weight:600;padding:6px 0 2px;">' + _escA(s.form_name || 'Form') + (when ? ' <span style="color:#64748b;font-weight:400;">· ' + when + '</span>' : '') + '</div>' +
1397      rows + '</div>';
1398  }).join('');
1399  const signupBlock = (!isArchived && signupHtml)
1400    ? '<div style="background:#1a1726;border:1px solid #2d2640;border-radius:10px;padding:6px 16px 12px;margin-bottom:20px;">' +
1401        '<div style="color:#a78bfa;font-size:13px;font-weight:600;padding:8px 0 4px;">Signup responses</div>' +
1402        signupHtml +
1403      '</div>'
1404    : '';
1405
1406  // Action buttons change based on archived status
1407  let actionButtons = '';
1408  if (isArchived) {
1409    actionButtons = '<button onclick="restoreOneContact(' + p.id + ')" style="background:#10b981;color:#fff;border:none;padding:6px 14px;border-radius:8px;cursor:pointer;font-size:12px;">↩ Restore</button>' +
1410      '<button onclick="permanentDeleteOneContact(' + p.id + ')" style="background:transparent;border:1px solid #ef4444;color:#ef4444;padding:6px 14px;border-radius:8px;cursor:pointer;font-size:12px;">🗑️ Delete</button>';
1411  } else {
1412    actionButtons = (p.type === 'guest' ? '<button onclick="convertToMember(' + p.id + ')" style="background:#7c3aed;color:#fff;border:none;padding:6px 14px;border-radius:8px;cursor:pointer;font-size:12px;">Convert to Member</button>' : '') +
1413      '<button onclick="archiveOneContact(' + p.id + ')" style="background:transparent;border:1px solid #f59e0b;color:#f59e0b;padding:6px 14px;border-radius:8px;cursor:pointer;font-size:12px;">📦 Archive</button>' +
1414      '<button onclick="permanentDeleteOneContact(' + p.id + ')" style="background:transparent;border:1px solid #ef4444;color:#ef4444;padding:6px 14px;border-radius:8px;cursor:pointer;font-size:12px;">🗑️ Delete</button>';
1415  }
1416
1417  const overlay = document.createElement('div');
1418  overlay.id = 'profileDetailOverlay';
1419  overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:9999;display:flex;align-items:center;justify-content:center;';
1420  overlay.onclick = e => { if (e.target === overlay) overlay.remove(); };
1421  
1422  overlay.innerHTML = '<div style="background:#13111c;border:1px solid #2d2640;border-radius:12px;width:700px;max-height:85vh;overflow-y:auto;padding:28px;">' +
1423    '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;">' +
1424      '<div><h2 style="color:#e2e8f0;margin:0;">' + _escA(p.display_name || 'Anonymous') + '</h2>' +
1425      '<div style="color:#94a3b8;font-size:13px;margin-top:4px;">Profile #' + p.id + ' · ' + (p.source || 'unknown') + '</div></div>' +
1426      '<div style="display:flex;gap:8px;align-items:center;">' + statusBadge + ' ' + typeBadge + ' ' + intentBadge + ' ' +
1427        actionButtons +
1428        '<button onclick="this.closest(\'#profileDetailOverlay\').remove()" style="background:none;border:none;color:#94a3b8;font-size:20px;cursor:pointer;">✕</button>' +
1429      '</div>' +
1430    '</div>' +
1431    detailsBlock +
1432    signupBlock +
1433    '<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin-bottom:20px;">' +
1434      '<div style="background:#1e1b2e;padding:12px;border-radius:8px;"><div style="color:#94a3b8;font-size:11px;">Registered</div><div style="color:#e2e8f0;font-size:14px;">' + App.Dates.short(p.created_at) + '</div></div>' +
1435      '<div style="background:#1e1b2e;padding:12px;border-radius:8px;"><div style="color:#94a3b8;font-size:11px;">Last Seen</div><div style="color:#e2e8f0;font-size:14px;">' + App.Dates.short(p.last_seen_at) + '</div></div>' +
1436      '<div style="background:#1e1b2e;padding:12px;border-radius:8px;"><div style="color:#94a3b8;font-size:11px;">Identifiers</div><div style="color:#e2e8f0;font-size:14px;">' + data.identifiers.length + '</div></div>' +
1437    '</div>' +
1438    '<div style="margin-bottom:20px;"><h3 style="color:#a78bfa;font-size:14px;margin:0 0 8px;">Tags</h3>' + tagHtml + '</div>' +
1439    '<div style="margin-bottom:20px;"><h3 style="color:#a78bfa;font-size:14px;margin:0 0 8px;">Identifiers</h3>' + identHtml + '</div>' +
1440    '<div style="margin-bottom:20px;"><h3 style="color:#a78bfa;font-size:14px;margin:0 0 8px;">Community activity</h3>' + communityHtml + '</div>' +
1441    '<div style="margin-bottom:20px;"><h3 style="color:#a78bfa;font-size:14px;margin:0 0 8px;">Web &amp; email activity</h3><div id="profileActivityLog" style="color:#64748b;padding:8px 0;">Loading…</div></div>' +
1442    '<div><h3 style="color:#a78bfa;font-size:14px;margin:0 0 8px;">Timeline</h3>' + timeHtml + '</div>' +
1443  '</div>';
1444
1445  document.body.appendChild(overlay);
1446  loadProfileActivity(p.id);
1447}
1448
1449// Edit one of a contact's own details (name / email / phone) from the profile
1450// modal. Opens a small editor; on Save it asks for an explicit in-app
1451// confirmation (NO email is sent to the contact) and then PATCHes. A collision
1452// (the email/phone already belongs to another contact) comes back as a clear
1453// message. field ∈ {display_name, email, phone}; label is the display word.
1454function editContactField(field, label) {
1455  const cd = window.__contactDetail || {};
1456  const current = field === 'display_name' ? (cd.display_name || '') : field === 'email' ? (cd.email || '') : (cd.phone || '');
1457  const isEmail = field === 'email', isPhone = field === 'phone';
1458  const overlay = document.createElement('div');
1459  overlay.id = 'contactEditOverlay';
1460  overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:10000;display:flex;align-items:center;justify-content:center;';
1461  overlay.innerHTML = '<div style="background:#13111c;border:1px solid #3d3650;border-radius:12px;padding:24px;max-width:420px;width:calc(100% - 32px);">' +
1462    '<h3 style="color:#e2e8f0;margin:0 0 4px;font-size:16px;">Edit ' + label.toLowerCase() + '</h3>' +
1463    '<p style="color:#94a3b8;font-size:12.5px;margin:0 0 14px;line-height:1.5;">Update this contact’s ' + label.toLowerCase() + '. You’ll be asked to confirm — no email is sent to the contact.</p>' +
1464    '<input id="contactEditInput" type="' + (isEmail ? 'email' : isPhone ? 'tel' : 'text') + '" value="' + _escA(current) + '" placeholder="' + (isEmail ? '[email protected]' : isPhone ? '+447700900000' : 'Full name') + '" style="width:100%;box-sizing:border-box;padding:10px 12px;border:1px solid #3d3650;border-radius:8px;background:#0d0b14;color:#e2e8f0;font-size:14px;outline:none;">' +
1465    '<div id="contactEditMsg" style="color:#ef4444;font-size:12.5px;min-height:16px;margin-top:8px;"></div>' +
1466    '<div style="display:flex;gap:10px;justify-content:flex-end;">' +
1467      '<button data-action="cancel" style="padding:9px 18px;border-radius:8px;border:1px solid #3d3650;background:transparent;color:#e2e8f0;cursor:pointer;font-size:14px;">Cancel</button>' +
1468      '<button data-action="save" style="padding:9px 18px;border-radius:8px;border:none;background:#7c3aed;color:#fff;cursor:pointer;font-size:14px;font-weight:600;">Save</button>' +
1469    '</div>' +
1470  '</div>';
1471  document.body.appendChild(overlay);
1472  const input = overlay.querySelector('#contactEditInput');
1473  const msg = overlay.querySelector('#contactEditMsg');
1474  input.focus(); input.select();
1475  overlay.onclick = e => { if (e.target === overlay) overlay.remove(); };
1476  overlay.querySelector('[data-action="cancel"]').onclick = () => overlay.remove();
1477
1478  const doSave = async function () {
1479    const val = input.value.trim();
1480    msg.textContent = '';
1481    if (isEmail && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(val)) { msg.textContent = 'Enter a valid email address.'; return; }
1482    if (isPhone && !/^\+?[0-9\s()\-.]{7,20}$/.test(val)) { msg.textContent = 'Enter a valid phone number (e.g. +447700900000).'; return; }
1483    if (!isEmail && !isPhone && !val) { msg.textContent = 'Name can’t be empty.'; return; }
1484    if (val === current) { overlay.remove(); return; }               // nothing changed
1485    const ok = await contactConfirm('Change this contact’s ' + label.toLowerCase() + ' to <strong style="color:#e2e8f0;">' + _escA(val) + '</strong>?');
1486    if (!ok) return;
1487    const saveBtn = overlay.querySelector('[data-action="save"]');
1488    saveBtn.disabled = true; saveBtn.textContent = 'Saving…';
1489    const patchBody = {}; patchBody[field] = val;
1490    const resp = await api('/contacts/' + cd.id, { method: 'PATCH', body: patchBody });
1491    if (resp && resp.ok) {
1492      overlay.remove();
1493      document.getElementById('profileDetailOverlay')?.remove();
1494      openProfileDetail(cd.id);                                       // reopen with fresh data
1495      showToast(label + ' updated', 'success');
1496    } else {
1497      saveBtn.disabled = false; saveBtn.textContent = 'Save';
1498      msg.textContent = (resp && resp.error) || 'Couldn’t update. Please try again.';
1499    }
1500  };
1501  overlay.querySelector('[data-action="save"]').onclick = doSave;
1502  input.addEventListener('keydown', e => {
1503    if (e.key === 'Enter') doSave();
1504    else if (e.key === 'Escape') overlay.remove();
1505  });
1506}
1507
1508// Styled "are you sure?" confirm — resolves true/false. Used to gate a contact
1509// detail change (Alex: a confirmation MESSAGE, not a confirmation email).
1510function contactConfirm(messageHtml) {
1511  return new Promise(resolve => {
1512    const o = document.createElement('div');
1513    o.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:10001;display:flex;align-items:center;justify-content:center;';
1514    o.innerHTML = '<div style="background:#13111c;border:1px solid #3d3650;border-radius:12px;padding:24px;max-width:400px;width:calc(100% - 32px);text-align:center;">' +
1515      '<div style="font-size:32px;margin-bottom:10px;">✏️</div>' +
1516      '<p style="color:#cbd5e1;font-size:14px;line-height:1.6;margin:0 0 18px;">' + messageHtml + '</p>' +
1517      '<div style="display:flex;gap:10px;justify-content:center;">' +
1518        '<button data-action="cancel" style="padding:9px 20px;border-radius:8px;border:1px solid #3d3650;background:transparent;color:#e2e8f0;cursor:pointer;font-size:14px;">Cancel</button>' +
1519        '<button data-action="confirm" style="padding:9px 20px;border-radius:8px;border:none;background:#7c3aed;color:#fff;cursor:pointer;font-size:14px;font-weight:600;">Yes, update</button>' +
1520      '</div>' +
1521    '</div>';
1522    document.body.appendChild(o);
1523    o.querySelector('[data-action="cancel"]').onclick = () => { o.remove(); resolve(false); };
1524    o.querySelector('[data-action="confirm"]').onclick = () => { o.remove(); resolve(true); };
1525    o.onclick = e => { if (e.target === o) { o.remove(); resolve(false); } };
1526  });
1527}
1528
1529// Load + render the contact's raw web + email activity log (the r
1529ich tracking:
1530// pages visited, scroll depth, dwell time, clicks, checkout intent, email
1531// opens + link clicks) into the detail overlay. Server-merged, newest first.
1532function _escA(s){ return String(s == null ? '' : s).replace(/[&<>"]/g, function(c){ return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]; }); }
1533function _fmtSecs(s){ s = parseInt(s, 10) || 0; if (s < 60) return s + 's'; var m = Math.floor(s/60), r = s % 60; return m + 'm' + (r ? (' ' + r + 's') : ''); }
1534function _fmtActivity(it){
1535  var t = App.Dates.shortDateTime(it.at, { year: 'auto', timeLocale: 'en-GB', timeOptions: { hour: '2-digit' } });
1536  var m = it.metadata || {}, icon = '•', desc = '';
1537  if (it.kind === 'web') {
1538    switch (it.event) {
1539      case 'page_view': icon = '👁️'; desc = 'Viewed ' + (it.page || 'a page'); break;
1540      case 'scroll_depth': icon = '📜'; desc = 'Scrolled ' + (m.depth != null ? m.depth + '%' : '') + (it.page ? (' on ' + it.page) : ''); break;
1541      case 'time_on_page': icon = '⏱️'; desc = 'Spent ' + _fmtSecs(m.seconds) + (m.max_scroll != null ? (' · scrolled ' + m.max_scroll + '%') : '') + (it.page ? (' on ' + it.page) : ''); break;
1542      case 'click': icon = '🖱️'; desc = 'Clicked ' + (m.label ? ('"' + m.label + '"') : 'a link') + (it.page ? (' on ' + it.page) : ''); break;
1543      case 'checkout_started': icon = '🛒'; desc = 'Checkout intent' + (m.label ? (' · "' + m.label + '"') : '') + (it.page ? (' on ' + it.page) : ''); break;
1544      case 'form_start': icon = '📝'; desc = 'Started a form' + (it.page ? (' on ' + it.page) : ''); break;
1545      case 'form_submit': icon = '✅'; desc = 'Submitted a form' + (it.page ? (' on ' + it.page) : ''); break;
1546      case 'form_abandon': icon = '🚫'; desc = 'Abandoned a form' + (it.page ? (' on ' + it.page) : ''); break;
1547      case 'video_play': icon = '▶️'; desc = 'Played a video' + (it.page ? (' on ' + it.page) : ''); break;
1548      default: icon = '🌐'; desc = String(it.event || '').replace(/_/g, ' ') + (it.page ? (' · ' + it.page) : '');
1549    }
1550    if (it.utm_campaign) desc += ' · ' + it.utm_campaign;
1551  } else if (it.kind === 'email_open') { icon = '✉️'; desc = 'Opened email' + (it.campaign ? (' · ' + it.campaign) : ''); }
1552  else if (it.kind === 'email_sent') { icon = '📤'; desc = 'Email sent' + (it.campaign ? (' · ' + it.campaign) : ''); }
1553  else if (it.kind === 'email_click') { icon = '🔗'; desc = 'Clicked email link ' + (it.url || '') + (it.campaign ? (' · ' + it.campaign) : ''); }
1554  return { t: t, icon: icon, desc: desc };
1555}
1556async function loadProfileActivity(id){
1557  var el = document.getElementById('profileActivityLog');
1558  if (!el) return;
1559  try {
1560    var data = await api('/contacts/' + id + '/activity');
1561    var items = (data && data.activity) || [];
1562    if (!items.length) { el.innerHTML = '<div style="color:#64748b;">No web or email activity tracked yet</div>'; return; }
1563    el.innerHTML = items.map(function(it){
1564      var f = _fmtActivity(it);
1565      return '<div style="display:flex;align-items:flex-start;gap:8px;padding:6px 0;font-size:13px;border-bottom:1px solid #1a1726;">' +
1566        '<span style="color:#64748b;min-width:108px;flex-shrink:0;">' + f.t + '</span>' +
1567        '<span style="flex-shrink:0;">' + f.icon + '</span>' +
1568        '<span style="color:#e2e8f0;word-break:break-word;">' + _escA(f.desc) + '</span>' +
1569      '</div>';
1570    }).join('');
1571  } catch (e) {
1572    el.innerHTML = '<div style="color:#64748b;">Couldn\'t load activity</div>';
1573  }
1574}
1575
1576async function verifyIdentifier(profileId, identifierId) {
1577  await api('/contacts/' + profileId + '/verify/' + identifierId, { method: 'POST' });
1578  document.getElementById('profileDetailOverlay')?.remove();
1579  openProfileDetail(profileId);
1580}
1581
1582async function convertToMember(profileId) {
1583  await api('/contacts/' + profileId + '/convert', { method: 'POST' });
1584  document.getElementById('profileDetailOverlay')?.remove();
1585  openProfileDetail(profileId);
1586  loadContactList();
1587}
1588
1589function loadContactSidebar() {
1590  api('/contacts/tags').then(tags => {
1591    const el = document.getElementById('cTagList');
1592    if (!el || !tags) return;
1593    el.innerHTML = tags.map(t =>
1594      '<div onclick="filterByTag(' + t.id + ')" style="padding:8px 12px;cursor:pointer;display:flex;justify-content:space-between;border-radius:6px;transition:background 0.15s;" onmouseover="this.style.background=\'#1e1b2e\'" onmouseout="this.style.background=\'\'">' +
1595        '<span style="color:#e2e8f0;"><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:' + (t.color || '#666') + ';margin-right:8px;"></span>' + _escA(t.name) + '</span>' +
1596        '<span style="color:#64748b;font-size:12px;">' + (t.contact_count || 0) + '</span>' +
1597      '</div>'
1598    ).join('') || '<div style="color:#64748b;padding:12px;">No tags yet</div>';
1599  });
1600}
1601
1602function filterByTag(tagId) {
1603  window._activeTagId = window._activeTagId === tagId ? null : tagId;
1604  contactPage = 1;
1605  loadContactList();
1606}
1607
1608// ═══════════════════════════════════════════════════════════════════
1609// Phase 7 — + Add subscriber, Import CSV
1610// ═══════════════════════════════════════════════════════════════════
1611
1612// Single-add uses the shared openFormModal. Five fields; email required.
1613// Backend dedups via findOrResolve so "existing" emails just update the
1614// profile rather than 409.
1615window.openAddSubscriberModal = function() {
1616  openFormModal({
1617    title: 'Add subscriber',
1618    helperText: 'Creates a new contact, or updates an existing one if the email already exists in your account.',
1619    fields: [
1620      { name: 'email',      label: 'Email *',       type: 'email', placeholder: '[email protected]' },
1621      { name: 'first_name', label: 'First name',    type: 'text',  placeholder: 'Jane' },
1622      { name: 'last_name',  label: 'Last name',     type: 'text',  placeholder: 'Smith' },
1623      { name: 'phone',      label: 'Phone (E.164)', type: 'text',  placeholder: '+447700900000' },
1624      { name: 'tags',       label: 'Tags (comma-separated)', type: 'text', placeholder: 'vip, newsletter' }
1625    ],
1626    submitLabel: 'Add subscriber',
1627    onSubmit: async function(values) {
1628      if (!values.email) { App.dialog.notice({ title: 'Add an email address', body: 'We need an email address to add this subscriber.' }); return false; }
1629      try {
1630        const resp = await fetch('/api/contacts', {
1631          method: 'POST',
1632          headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' },
1633          body: JSON.stringify(values)
1634        });
1635        const data = await resp.json();
1636        if (!resp.ok) {
1637          App.dialog.notice({ title: 'That didn’t work', body: 'Please try again in a moment. If it keeps happening, tell us and we will sort it out.\n\nDetails: ' + (data.error || 'Failed') });
1638          return false;
1639        }
1640        if (typeof showToast === 'function') {
1641          showToast(data.created ? 'Subscriber added' : (data.merged ? 'Updated existing subscriber' : 'Subscriber saved'));
1642        }
1643        if (typeof loadContacts === 'function') loadContacts();
1644      } catch (err) {
1645        App.dialog.notice({ title: 'That didn’t work', body: 'Please try again in a moment. If it keeps happening, tell us and we will sort it out.\n\nDetails: ' + (err.message) });
1646        return false;
1647      }
1648    }
1649  });
1650};
1651
1652// CSV import: two-step flow (file pick + preview → submit). Uses a
1653// dedicated modal rather than openFormModal because we need a file input
1654// and a results panel that replaces the body.
1655// ── Contacts import wizard — Upload → Map fields → Review → Done ─────────────
1656// Staged, themed, non-technical-friendly. Parses CSV *and* Excel client-side
1657// (SheetJS), auto-maps columns, validates per-row, shows a dry-run impact +
1658// preview before committing, and offers a one-click Undo. SAFETY: importing only
1659// adds contacts — no email, message or automation is ever triggered (the server
1660// commits through the same send-safe findOrResolve path).
1661// opts.spaceId (Spaces round 3, item 7, Jev 1 Oct): the import starts here, in
1662// Subscribers, always; a space's Members tab opens it with that space already
1663// picked in "Also put everyone in a space". The space is only where the people
1664// go: the same reading, matching by email and commit as any other import.
1665window.openImportContactsModal = function(opts) {
1666  var modal = document.getElementById('importContactsModal');
1667  if (!modal) {
1668    modal = document.createElement('div');
1669    modal.id = 'importContactsModal';
1670    document.body.appendChild(modal);
1671  }
1672  modal.style.display = 'block';
1673  // A fresh wizard owns the modal — release it from any still-running background
1674  // import poller (that import keeps going and will notify via toast on its own).
1675  window.__cwImportTracker = null;
1676
1677  // Wizard state.
1678  var S = { step: 1, fileName: '', fileSize: 0, headers: [], rows: [], samples: [], sampleSets: [],
1679            mapping: {}, enrichMeta: {}, reasons: {}
1679, assessed: false, assessAi: false, customFields: [],
1680            normalized: [], valid: [], skipReasons: [], dupes: 0,
1681            dryRun: null, batchId: null,
1682            spaceId: (opts && opts.spaceId) ? String(opts.spaceId) : '', spaces: null,
1683            /* Round 4 (F20): opened FROM a space (its Members tab). Only this
1684               switches on the space path's additions below: a stepper that fits
1685               a phone, column notes in plain words, and closing returns to the
1686               space. Opened from Subscribers, none of them apply. */
1687            fromSpace: !!(opts && opts.spaceId) };
1688  window.__cwOnClose = (S.fromSpace && opts && typeof opts.onClose === 'function') ? opts.onClose : null;
1689  modal.className = S.fromSpace ? 'cw-space' : '';
1690
1691  var FIELDS = [
1692    { key: '',           label: "Don't import" },
1693    { key: 'email',      label: 'Email (required)' },
1694    { key: 'first_name', label: 'First name' },
1695    { key: 'last_name',  label: 'Last name' },
1696    { key: 'phone',      label: 'Phone' },
1697    { key: 'tags',       label: 'Tags' },
1698    { key: 'enrichment', label: 'Keep as a field' }
1699  ];
1700  var SYN = {
1701    email:      ['email','e-mail','emailaddress','email address','mail','e mail'],
1702    first_name: ['first name','firstname','first','given name','givenname','fname','forename'],
1703    last_name:  ['last name','lastname','last','surname','family name','familyname','lname'],
1704    phone:      ['phone','phone number','mobile','mobile number','cell','cellphone','telephone','tel','number','msisdn'],
1705    tags:       ['tags','tag','labels','label','groups','group','segment','segments']
1706  };
1707  function esc(s) { return String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
1708  function emailOk(s) { return /^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$/i.test(String(s||'').trim()); }
1709  function guessField(header) {
1710    var h = String(header||'').trim().toLowerCase().replace(/[_\-]+/g,' ').replace(/\s+/g,' ');
1711    for (var k in SYN) { if (SYN[k].indexOf(h) !== -1) return k; }
1712    if (h.indexOf('email') !== -1 || h.indexOf('mail') !== -1) return 'email';
1713    if (h.indexOf('phone') !== -1 || h.indexOf('mobile') !== -1) return 'phone';
1714    if (h.indexOf('first') !== -1) return 'first_name';
1715    if (h.indexOf('last') !== -1 || h.indexOf('surname') !== -1) return 'last_name';
1716    if (h.indexOf('tag') !== -1) return 'tags';
1717    return '';
1718  }
1719  function fmtBytes(n) { return n < 1024 ? n + ' B' : n < 1048576 ? (n/1024).toFixed(0) + ' KB' : (n/1048576).toFixed(1) + ' MB'; }
1720  function _fmtNow() {
1721    var d = new Date(), M = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
1722    var hh = ('0' + d.getHours()).slice(-2), mm = ('0' + d.getMinutes()).slice(-2);
1723    return d.getDate() + ' ' + M[d.getMonth()] + ' ' + d.getFullYear() + ' ' + hh + ':' + mm;
1724  }
1725
1726  // ── Modal chrome with a step indicator ──
1727  var STEPS = ['Upload', 'Map fields', 'Review', 'Done'];
1728  function shell(bodyHtml, footerHtml, width) {
1729    var pills = STEPS.map(function(name, i) {
1730      var n = i + 1, on = n === S.step, done = n < S.step;
1731      var circ = done
1732        ? '<span style="display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border-radius:50%;background:#4338ca;color:#fff;font-size:11px;">&#10003;</span>'
1733        : '<span style="display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border-radius:50%;background:' + (on ? '#4338ca' : '#e7e7ef') + ';color:' + (on ? '#fff' : '#9a9ab0') + ';font-size:11px;font-weight:600;">' + n + '</span>';
1734      return '<div style="display:flex;align-items:center;gap:6px;">' + circ +
1735             '<span class="cw-step-lbl' + (on ? ' on' : '') + '" style="font-size:12px;font-weight:' + (on ? '600' : '400') + ';color:' + (on||done ? '#1e1b4b' : '#9a9ab0') + ';">' + name + '</span></div>' +
1736             (n < STEPS.length ? '<div class="cw-step-line" style="flex:1;height:1px;background:#e7e7ef;margin:0 8px;min-width:14px;"></div>' : '');
1737    }).join('');
1738    /* The space path on a phone (F20: "Done" was cut off at 390px): the step
1739       you are on keeps its name, the others show their number, so all four fit. */
1740    var spaceCss = S.fromSpace
1741      ? '<style>
1741@media (max-width:520px){#importContactsModal.cw-space .cw-step-lbl:not(.on){display:none}' +
1742        '#importContactsModal.cw-space .cw-step-line{margin:0 4px!important;min-width:8px!important}}</style>'
1743      : '';
1744    modal.innerHTML = spaceCss +
1745      '<div style="position:fixed;inset:0;background:rgba(20,16,45,0.45);z-index:100000;display:flex;align-items:center;justify-content:center;padding:16px;">' +
1746      '<div style="background:#fff;border-radius:14px;width:' + (width||620) + 'px;max-width:96vw;max-height:92vh;display:flex;flex-direction:column;box-shadow:0 12px 40px rgba(0,0,0,0.25);font-family:Inter,sans-serif;overflow:hidden;">' +
1747      '<div style="padding:18px 24px 14px;border-bottom:1px solid #f0f0f4;">' +
1748      '<div style="display:flex;align-items:center;gap:0;">' + pills + '</div></div>' +
1749      '<div style="padding:22px 24px;overflow-y:auto;">' + bodyHtml + '</div>' +
1750      '<div style="padding:14px 24px;border-top:1px solid #f0f0f4;display:flex;gap:8px;align-items:center;">' + footerHtml + '</div>' +
1751      '</div></div>';
1752  }
1753  function btn(label, onclick, kind) {
1754    var base = 'padding:9px 22px;border:none;border-radius:8px;font-size:13px;font-family:Inter,sans-serif;font-weight:500;cursor:pointer;';
1755    var styles = { primary:'background:#4338ca;color:#fff;', ghost:'background:#f2f2f6;color:#1e1b4b;',
1756                   disabled:'background:#c7c3ff;color:#fff;cursor:not-allowed;', danger:'background:#fff;color:#dc2626;border:1px solid #fecaca;' };
1757    return '<button onclick="' + onclick + '" style="' + base + (styles[kind]||styles.primary) + '">' + label + '</button>';
1758  }
1759
1760  // ── Step 1: Upload ──
1761  function stepUpload(diag) {
1762    S.step = 1;
1763    var fileLine = S.fileName
1764      ? '<div style="margin-top:14px;display:flex;align-items:center;gap:10px;background:#f6f6fb;border:1px solid #ececf4;border-radius:8px;padding:10px 14px;">' +
1765        '<i class="fas fa-file-lines" style="color:#4338ca;"></i><div style="flex:1;"><div style="font-size:13px;font-weight:500;color:#1e1b4b;">' + esc(S.fileName) + '</div>' +
1766        '<div style="font-size:12px;color:#999;">' + fmtBytes(S.fileSize) + ' · ' + S.rows.length + ' row' + (S.rows.length===1?'':'s') + ' · ' + S.headers.length + ' columns</div></div>' +
1767        '<i class="fas fa-circle-check" style="color:#16a34a;"></i></div>'
1768      : '';
1769    var diagHtml = diag
1770      ? '<div style="margin-top:14px;background:#fef2f2;border:1px solid #fecaca;border-radius:8px;padding:12px 14px;font-size:13px;color:#991b1b;line-height:1.5;"><i class="fas fa-triangle-exclamation" style="margin-right:6px;"></i>' + diag + '</div>'
1771      : '';
1772    var body =
1773      '<h3 style="margin:0 0 6px;font-size:17px;font-weight:600;color:#1e1b4b;">Import your contacts</h3>' +
1774      '<p style="margin:0 0 4px;font-size:13px;color:#666;line-height:1.5;">Upload a spreadsheet of contacts — a <strong>CSV</strong> or <strong>Excel</strong> file works. We\'ll let you match the columns and preview everything before anything is saved.</p>' +
1775      /* Spaces R4-3: opened from a space, say what importing does NOT do
1776         (invite anyone) and that the welcome at the end is the invitation.
1777         Opened from Subscribers, exactly as before. */
1778      (S.fromSpace
1779        ? '<div style="margin:12px 0;background:#eef2ff;border:1px solid #c7d2fe;border-radius:8px;padding:10px 14px;font-size:13px;color:#312e81;line-height:1.5;"><strong>Importing does not invite anyone.</strong> It adds these people to your space without emailing them. At the end you can send them a welcome with a link that brings them in, and you read it first. To email people an invitation now instead, close this and use Add people in your space.</div>'
1780        : '<div style="margin:12px 0;background:#eef7f0;border:1px solid #cce9d4;border-radius:8px;padding:10px 14px;font-size:12.5px;color:#1c7a3f;line-height:1.5;"><i class="fas fa-shield-heart" style="margin-right:6px;"></i><strong>Your contacts are never emailed or messaged.</strong> Importing only adds people to your list — it never messages anyone or starts any automation. We\'ll email <strong>you</strong> a summary report when the import finishes.</div>') +
1781      '<label id="dropZone" for="importFileInput" style="display:block;border:2px dashed #cfcfe0;border-radius:12px;padding:28px 20px;text-align:center;cursor:pointer;transition:border-color .15s,background .15s;">
1781' +
1782      '<i class="fas fa-cloud-arrow-up" style="font-size:26px;color:#9a9ab0;"></i>' +
1783      '<div style="margin-top:10px;font-size:14px;color:#1e1b4b;font-weight:500;">Drag &amp; drop your file here</div>' +
1784      '<div style="margin-top:2px;font-size:12px;color:#999;">or <span style="color:#4338ca;text-decoration:underline;">browse to choose a file</span> · CSV or Excel · no contact limit</div>' +
1785      '<input type="file" id="importFileInput" accept=".csv,.xlsx,.xls,text/csv,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" style="display:none;"></label>' +
1786      '<p style="margin:12px 0 0;font-size:12px;color:#999;">Most exports work as-is — our AI sorts the columns for you. New to this? <a href="#" id="cwTemplate" style="color:#4338ca;">Download a starter template</a>.</p>' +
1787      fileLine + diagHtml;
1788    var footer = '<div style="flex:1;"></div>' + btn('Cancel', 'closeImportContactsModal()', 'ghost') +
1789      (S.fileName ? btn('Next →', 'window.__cwNext()', 'primary') : btn('Next →', '', 'disabled'));
1790    shell(body, footer);
1791
1792    var input = document.getElementById('importFileInput');
1793    var zone = document.getElementById('dropZone');
1794    input.onchange = function() { if (input.files && input.files[0]) handleFile(input.files[0]); };
1795    ['dragenter','dragover'].forEach(function(ev){ zone.addEventListener(ev, function(e){ e.preventDefault(); zone.style.borderColor='#4338ca'; zone.style.background='#f6f6fb'; }); });
1796    ['dragleave','drop'].forEach(function(ev){ zone.addEventListener(ev, function(e){ e.preventDefault(); zone.style.borderColor='#cfcfe0'; zone.style.background='transparent'; }); });
1797    zone.addEventListener('drop', function(e){ var f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]; if (f) handleFile(f); });
1798    var tpl = document.getElementById('cwTemplate');
1799    if (tpl) tpl.onclick = function(e){ e.preventDefault(); _downloadTemplate(); };
1800    if (S.fileName) window.__cwNext = function() { buildSamples(); stepMap(); };
1801  }
1802
1803  function _renderPreparing() {
1804    var body =
1805      '<h3 style="margin:0 0 6px;font-size:17px;font-weight:600;color:#1e1b4b;">Getting things ready…</h3>' +
1806      '<p style="margin:0 0 14px;font-size:13px;color:#666;line-height:1.5;">Preparing the spreadsheet reader — your file will open automatically in a moment.</p>' +
1807      '<div style="text-align:center;padding:36px 0;color:#9a9ab0;"><i class="fas fa-spinner fa-spin" style="font-size:24px;"></i></div>';
1808    shell(body, '<div style="flex:1;"></div>' + btn('Cancel', 'closeImportContactsModal()', 'ghost'));
1809  }
1810  function _renderReading(file) {
1811    var sizeTxt = file && file.size ? ' (' + fmtBytes(file.size) + ')' : '';
1812    var body =
1813      '<h3 style="margin:0 0 6px;font-size:17px;font-weight:600;color:#1e1b4b;">Reading your file' + sizeTxt + '…</h3>' +
1814      '<p style="margin:0 0 14px;font-size:13px;color:#666;line-height:1.5;">Opening your spreadsheet and reading the columns. A large file can take a few seconds — hang tight.</p>' +
1815      '<div style="text-align:center;padding:36px 0;color:#9a9ab0;"><i class="fas fa-spinner fa-spin" style="font-size:24px;"></i></div>';
1816    shell(body, '<div style="flex:1;"></div>' + btn('Cancel', 'closeImportContactsModal()', 'ghost'));
1817  }
1818  function _waitForReader(file) {
1819    _renderPreparing();
1820    var tries = 0;
1821    (function poll(){
1822      if (typeof XLSX !== 'undefined') { handleFile(file); return; }
1823      if (tries++ > 60) { stepUpload('We couldn\'t load the spreadsheet reader — please check your connection and try again.'); return; }
1824      setTimeout(poll, 250);
1825    })();
1826  }
1827
1828  // Starter template — built + downloaded entirely client-side (the /api endpoint
1829  // is auth-gated, so a plain link 401s). Most exports work as-is, but this gives
1830  // someone starting from scratch the basic shape.
1831  function _downloadTemplate() {
1832    var csv = 'email,first_name,last_name,phone,tags\n' +
1833              '[email protected],Jane,Smith,+447700900000,"vip,newsletter"\n' +
1834              '[email protected],Jim,Brown,,newsletter\n';
1835    var blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
1836    var url = URL.createObjectURL(blob);
1837    var a = document.createElement('a');
1838    a.href = url; a.download = 'contacts-template.csv';
1839    document.body.appendChild(a); a.click();
1840    setTimeout(function(){ try { document.body.removeChild(a); } catch (e) {} URL.revokeObjectURL(url); }, 200);
1841  }
1842
1843  function handleFile(file) {
1844    var name = file.name || 'file';
1845    var lower = name.toLowerCase();
1846    if (/\.pdf$/.test(lower) || file.type === 'application/pdf') return stepUpload('That looks like a <strong>PDF</strong>. Please export your contacts as a CSV or Excel file and try again.');
1847    if (/\.(numbers|pages|docx?|txt)$/.test(lower)) return stepUpload('We can\'t read <strong>' + esc(name.split('.').pop().toUpperCase()) + '</strong> files. Please save your contacts as a CSV or Excel (.xlsx) file and try again.');
1848    if (file.size > 500 * 1024 * 1024) return stepUpload('That file is ' + fmtBytes(file.size) + ' — the limit is 500 MB. If it\'s genuinely larger than that, split it into a couple of files.');
1849    // The reader (SheetJS) loads from a CDN. If a file is picked before it's
1850    // ready, show a friendly "preparing" state and open the file automatically the
1851    // moment it loads — never an error.
1852    if (typeof XLSX === 'undefined') { _waitForReader(file); return; }
1853    // Reading + parsing a big spreadsheet blocks the main thread for a moment —
1854    // show a calm "reading" state first so it never looks frozen.
1855    _renderReading(file);
1856    var reader = new FileReader();
1857    reader.onerror = function() { stepUpload('We couldn\'t read that file. Please re-save it as a CSV or Excel file and try again.'); };
1858    reader.onload = function(e) {
1859      try {
1860        var wb = XLSX.read(new Uint8Array(e.target.result), { type: 'array' });
1861        var ws = wb.Sheets[wb.SheetNames[0]];
1862        var aoa = XLSX.utils.sheet_to_json(ws, { header: 1, blankrows: false, raw: false, defval: '' });
1863        aoa = aoa.filter(function(r){ return r.some(function(c){ return String(c||'').trim() !== ''; }); });
1864        if (aoa.length < 2) return stepUpload('That file doesn\'t seem to have any contact rows under a header row. Check it has a header line (like <code>email</code>) and at least one contact.');
1865        S.headers = aoa[0].map(function(h){ return String(h||'').trim(); });
1866        S.rows = aoa.slice(1);
1867        S.fileName = name; S.fileSize = file.size;
1868        S.mapping = {};
1869        S.headers.forEach(function(h, i){ var g = guessField(h); if (g) { if (g === 'email' && Object.keys(S.mapping).some(function(k){return S.mapping[k]==='email';})) return; S.mapping[i] = g; } });
1870        stepUpload(null);
1871      } catch (err) {
1872        stepUpload('We couldn\'t read that file (' + esc(err.message||'parse error') + '). Please re-save it as a CSV or Excel file and try again.');
1873      }
1874    };
1875    reader.readAsArrayBuffer(file);
1876  }
1877
1878  function buildSamples() {
1879    S.samples = S.headers.map(function(_, i){
1880      for (var r = 0; r < S.rows.length; r++) { var v = S.rows[r][i]; if (String(v||'').trim() !== '') return String(v).trim(); }
1881      return '';
1882    });
1883    // Up to 5 non-empty samples per column — sent to the AI to judge each column.
1884    S.sampleSets = S.headers.map(function(_, i){
1885      var out = [];
1886      for (var r = 0; r < S.rows.length && out.length < 5; r++) {
1887        var v = String(S.rows[r][i] || '').trim();
1888        if (v) out.push(v.slice(0, 80));
1889      }
1890      return out;
1891    });
1892  }
1893
1894  function _slug(s) { return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 40) || 'field'; }
1895
1896  // ── Step 2: Review fields (AI-assisted) ──
1897  function stepMap() {
1898    S.step = 2;
1899    if (!S.assessed) { _renderMapLoading(); _runAssess(); return; }
1900    _renderMap();
1901  }
1902
1903  function _renderMapLoading() {
1904    var body =
1905      '<h3 style="margin:0 0 6px;font-size:17px;font-weight:600;color:#1e1b4b;">Reviewing your columns…</h3>' +
1906      '<p style="margin:0 0 14px;font-size:13px;color:#666;line-height:1.5;">Our AI is checking each column — picking out the essentials and spotting other data worth keeping for marketing. One moment.</p>' +
1907      '<div style="text-align:center;padding:42px 0;color:#9a9ab0;"><i class="fas fa-spinner fa-spin" style="font-size:24px;"></i></div>';
1908    var footer = btn('← Back', 'window.__cwBack()', 'ghost') + '<div style="flex:1;"></div>' + btn('Cancel', 'closeImportContactsModal()', 'ghost');
1909    shell(body, footer);
1910    window.__cwBack = function(){ stepUpload(null); };
1911  }
1912
1913  async function _runAssess() {
1914    var cols = S.headers.map(function(h, i){ return { header: h, samples: S.sampleSets[i] || [] }; });
1915    var res = null;
1916    try {
1917      var resp = await fetch('/api/contacts/import/ai-assess', {
1918        method: 'POST', headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' },
1919        body: JSON.stringify(S.fromSpace ? { columns: cols, plain: true } : { columns: cols })
1920      });
1921      if (resp.ok) res = await resp.json();
1922    } catch (e) { res = null; }
1923    _applyAssessment(res);
1924    S.assessed = true;
1925    _renderMap();
1926  }
1927
1928  // Map the AI result (or a local synonym fallback) onto S.mapping/enrichMeta/reasons.
1929  function _applyAssessment(res) {
1930    S.mapping = {}; S.enrichMeta = {}; S.reasons = {};
1931    var cols = (res && Array.isArray(res.columns)) ? res.columns : null;
1932    S.assessAi = !!(res && res.ai);
1933    var emailTaken = false;
1934    S.headers.forEach(function(h, i){
1935      var a = (cols && cols[i]) ? cols[i] : null;
1936      var target = a ? String(a.target || '') : '';
1937      if (!target) { target = guessField(h) || 'enrichment'; }      // endpoint failed entirely → keep, don't drop
1938      if (target === 'email') { if (emailTaken) target = 'enrichment'; else emailTaken = true; }
1939      S.reasons[i] = (a && a.reason) || '';
1940      if (target === 'ignore') return;                              // unmapped = Don't import
1941      S.mapping[i] = target;
1942      if (target === 'enrichment') {
1943        S.enrichMeta[i] = { key: (a && a.custom_key) || _slug(h), label: (a && a.label) || h, mv: (a && a.marketing_value) || 'medium' };
1944      }
1945    });
1946  }
1947
1948  function _renderMap() {
1949    var groups = { std: [], enr: [], ign: [] };
1950    S.headers.forEach(function(_, i){
1951      var t = S.mapping[i];
1952      if (t === 'enrichment') groups.enr.push(i);
1953      else if (!t) groups.ign.push(i);
1954      else groups.std.push(i);
1955    });
1956    function optionsFor(i){
1957      return FIELDS.map(function(f){
1958        var sel = (f.key === '') ? (!S.mapping[i]) : (S.mapping[i] === f.key);
1959        return '<option value="' + f.key + '"' + (sel ? ' selected' : '') + '>' + f.label + '</option>';
1960      }).join('');
1961    }
1962    function rowHtml(i){
1963      var h = S.headers[i];
1964      var reason = S.reasons[i] ? '<div style="font-size:11px;color:#9a9ab0;margin-top:2px;line-height:1.4;">' + esc(S.reasons[i]) + '</div>' : '';
1965      var labelInput = (S.mapping[i] === 'enrichment')
1966        ? '<input class="cw-enrich-label" data-col="' + i + '" value="' + esc((S.enrichMeta[i] && S.enrichMeta[i].label) || h) + '" placeholder="Field name" style="margin-top:6px;width:100%;max-width:240px;padding:6px 8px;border:1px solid #e0e0ea;border-radius:6px;font-size:12px;" />'
1967        : '';
1968      return '<div style="display:grid;grid-template-columns:1fr auto;gap:12px;align-items:start;padding:10px 0;border-bottom:1px solid #f3f3f8;">' +
1969        '<div><div style="font-size:13px;font-weight:500;color:#1e1b4b;">' + (esc(h) || '<em style="color:#bbb;">(unnamed)</em>') + '</div>' +
1970        '<div style="font-size:11.5px;color:#999;margin-top:1px;">e.g. ' + (esc(S.samples[i]) || '—') + '</div>' + reason + labelInput + '</div>' +
1971        '<select data-col="' + i + '" class="cw-map" style="padding:8px 10px;border:1px solid #e0e0ea;border-radius:7px;font-size:13px;font-family:Inter,sans-serif;background:#fff;min-width:170px;height:fit-content;">' + optionsFor(i) + '</select>' +
1972      '</div>';
1973    }
1974    function section(title, color, sub, idxs){
1975      if (!idxs.length) return '';
1976      return '<div style="margin-top:16px;"><div style="font-size:12px;font-weight:700;color:' + color + ';text-transform:uppercase;letter-spacing:.04em;">' + title + ' (' + idxs.length + ')</div>' +
1977        '<div style="font-size:11.5px;color:#999;margin:2px 0 2px;">' + sub + '</div>' + idxs.map(rowHtml).join('') + '</div>';
1978    }
1979    var aiNote = S.assessAi
1980      ? 'Our AI reviewed your columns — picking the essentials and flagging other data worth keeping. Adjust anything below; nothing is dropped without your say-so.'
1981      : 'We mapped your columns automatically (AI review wasn\'t available). Adjust anything below — nothing is dropped.';
1982    var body =
1983      '<h3 style="margin:0 0 6px;font-size:17px;font-weight:600;color:#1e1b4b;">Review fields</h3>' +
1984      '<p style="margin:0 0 4px;font-size:13px;color:#666;line-height:1.5;">' + aiNote + '</p>' +
1985      section('Recommended import', '#16794a', 'The essentials, mapped straight onto each contact.', groups.std) +
1986      section('New fields we\'ll create', '#3730a3', 'Extra data kept for segmenting later — stored against each contact.', groups.enr) +
1987      section('Ignoring', '#92400e', 'Not useful for marketing here. Switch any back on if you want it.', groups.ign) +
1988      '<div id="cwMapWarn" style="margin-top:14px;"></div>';
1989    var footer = btn('← Back', 'window.__cwBack()', 'ghost') + '<div style="flex:1;"></div>' +
1990      btn('Cancel', 'closeImportContactsModal()', 'ghost') + '<span id="cwMapNext"></span>';
1991    shell(body, footer, 680);
1992
1993    Array.prototype.forEach.call(document.querySelectorAll('.cw-map'), function(sel){
1994      sel.onchange = function(){
1995        var c = parseInt(sel.getAttribute('data-col'), 10);
1996        if (sel.value) {
1997          S.mapping[c] = sel.value;
1998          if (sel.value === 'enrichment' && !S.enrichMeta[c]) S.enrichMeta[c] = { key: _slug(S.headers[c]), label: S.headers[c], mv: 'medium' };
1999        } else { delete S.mapping[c]; }
2000        _renderMap();   // regroup
2001      };
2002    });
2003    Array.prototype.forEach.call(document.querySelectorAll('.cw-enrich-label'), function(inp){
2004      inp.onchange = function(){ var c = parseInt(inp.getAttribute('data-col'), 10); if (S.enrichMeta[c]) { S.enrichMeta[c].label = inp.value; S.enrichMeta[c].key = _slug(inp.value); } };
2005    });
2006    window.__cwBack = function(){ stepUpload(null); };
2007    window.__cwNext = function(){ normalizeAndReview(); };
2008    refreshMapNext();
2009  }
2010  function emailCols() { return Object.keys(S.mapping).filter(function(k){ return S.mapping[k] === 'email'; }); }
2011  function refreshMapNext() {
2012    var warn = document.getElementById('cwMapWarn');
2013    var next = document.getElementById('cwMapNext');
2014    var ec = emailCols();
2015    if (ec.length === 0) {
2016      warn.innerHTML = '<div style="background:#fffbeb;border:1px solid #fde68a;border-radius:8px;padding:10px 12px;font-size:12.5px;color:#92400e;"><i class="fas fa-circle-info" style="margin-right:6px;"></i>Map one column to <strong>Email</strong> to continue — it\'s how we identify each contact.</div>';
2017      next.innerHTML = btn('Next →', '', 'disabled');
2018    } else {
2019      warn.innerHTML = ec.length > 1 ? '<div style="font-size:12px;color:#999;">Two columns are set to Email — we\'ll use the first.</div>' : '';
2020      next.innerHTML = btn('Next →', 'window.__cwNext()', 'primary');
2021    }
2022  }
2023
2024  // ── Step 3: Review (validate + dry-run + preview) ──
2025  function normalizeAndReview() {
2026    var ec = emailCols(); if (ec.length === 0) return;
2027    // Enrichment columns → the per-tenant custom-field registry (unique by key).
2028    var cfMap = {};
2029    Object.keys(S.mapping).forEach(function(k){
2030      if (S.mapping[k] !== 'enrichment') return;
2031      var m = S.enrichMeta[k] || {}; var key = _slug(m.key || m.label || S.headers[k]);
2032      cfMap[key] = (m.label || S.headers[k] || key);
2033    });
2034    S.customFields = Object.keys(cfMap).map(function(k){ return { key: k, label: cfMap[k] }; });
2035
2036    var seen = new Set(); S.valid = []; S.skipReasons = []; S.dupes = 0;
2037    S.rows.forEach(function(row, idx){
2038      var obj = {}; var meta = {};
2039      Object.keys(S.mapping).forEach(function(k){
2040        var f = S.mapping[k]; if (!f) return;
2041        var v = String(row[parseInt(k,10)]||'').trim(); if (!v) return;
2042        if (f === 'enrichment') { var m = S.enrichMeta[k] || {}; meta[_slug(m.key || m.label || S.headers[k])] = v.slice(0, 500); return; }
2043        if (f === 'email') v = v.toLowerCase();
2044        obj[f] = v;
2045      });
2046      if (Object.keys(meta).length) obj.metadata = meta;
2047      var email = obj.email || '';
2048      var rowNum = idx + 2;
2049      if (!email) { S.skipReasons.push({ row: rowNum, reason: 'no email' }); return; }
2050      if (!emailOk(email)) { S.skipReasons.push({ row: rowNum, reason: 'invalid email (' + email + ')' }); return; }
2051      if (seen.has(email)) { S.dupes++; }
2052      seen.add(email);
2053      // last-wins within file: replace any earlier entry for this email
2054      var existingIdx = S.valid.findIndex(function(v){ return v.email === email; });
2055      if (existingIdx !== -1) S.valid[existingIdx] = obj; else S.valid.push(obj);
2056    });
2057    stepReview();
2058    runDryRun();
2059  }
2060  function stepReview() {
2061    S.step = 3;
2062    if (!S.uploadTag) S.uploadTag = 'Uploaded ' + _fmtNow();
2063    var uniq = S.valid.length, skip = S.skipReasons.length;
2064    var sampleRows = S.valid.slice(0, 8).map(function(o){
2065      return '<tr style="border-bottom:1px solid #f3f3f8;">' +
2066        '<td style="padding:7px 10px;font-size:12.5px;color:#1e1b4b;">' + esc(o.email) + '</td>' +
2067        '<td style="padding:7px 10px;font-size:12.5px;color:#555;">' + (esc(o.first_name)||'<span style="color:#ccc;">—</span>') + '</td>' +
2068        '<td style="padding:7px 10px;font-size:12.5px;color:#555;">' + (esc(o.last_name)||'<span style="color:#ccc;">—</span>') + '</td>' +
2069        '<td style="padding:7px 10px;font-size:12.5px;color:#555;">' + (esc(o.phone)||'<span style="color:#ccc;">—</span>') + '</td>' +
2070        '<td style="padding:7px 10px;font-size:12.5px;color:#555;">' + (esc(o.tags)||'<span style="color:#ccc;">—</span>') + '</td></tr>';
2071    }).join('');
2072    var skipNote = skip === 0 ? '' :
2073      '<details style="margin-top:10px;"><summary style="font-size:12.5px;color:#92400e;cursor:pointer;">' + skip + ' row' + (skip===1?'':'s') + ' will be skipped (missing or invalid email)</summary>' +
2074      '<ul style="margin:8px 0 0;padding-left:20px;font-size:12px;color:#92400e;max-height:120px;overflow:auto;">' +
2075      S.skipReasons.slice(0,50).map(function(s){ return '<li>Row ' + s.row + ': ' + esc(s.reason) + '</li>'; }).join('') +
2076      (skip>50 ? '<li>…and ' + (skip-50) + ' more</li>' : '') + '</ul></details>';
2077    var body =
2078      '<h3 style="margin:0 0 6px;font-size:17px;font-weight:600;color:#1e1b4b;">Review &amp; confirm</h3>' +
2079      '<p style="margin:0 0 14px;font-size:13px;color:#666;line-height:1.5;">Here\'s exactly what will be saved. Nothing is added until you press <strong>Complete import</strong>.' +
2080        ((S.customFields && S.customFields.length) ? ' We\'ll also keep <strong>' + S.customFields.length + '</strong> extra field' + (S.customFields.length === 1 ? '' : 's') + ' against each contact for segmenting.' : '') + '</p>' +
2081      '<div id="cwCounts" style="display:flex;gap:10px;margin-bottom:14px;flex-wrap:wrap;">' +
2082      '<div style="flex:1;min-width:130px;background:#f6f6fb;border:1px solid #ececf4;border-radius:8px;padding:12px;text-align:center;"><div style="font-size:22px;font-weight:700;color:#1e1b4b;">
2082' + uniq.toLocaleString() + '</div><div style="font-size:11.5px;color:#777;">ready to import</div></div>' +
2083      '<div id="cwNew" style="flex:1;min-width:130px;background:#ecfdf3;border:1px solid #ccebd7;border-radius:8px;padding:12px;text-align:center;"><div style="font-size:22px;font-weight:700;color:#16794a;">…</div><div style="font-size:11.5px;color:#16794a;">brand-new</div></div>' +
2084      '<div id="cwUpd" style="flex:1;min-width:130px;background:#eef1ff;border:1px solid #d6ddf7;border-radius:8px;padding:12px;text-align:center;"><div style="font-size:22px;font-weight:700;color:#3730a3;">…</div><div style="font-size:11.5px;color:#3730a3;">already in your list</div></div>' +
2085      '<div style="flex:1;min-width:130px;background:' + (skip?'#fff7ed':'#f6f6fb') + ';border:1px solid ' + (skip?'#fde8c8':'#ececf4') + ';border-radius:8px;padding:12px;text-align:center;"><div style="font-size:22px;font-weight:700;color:' + (skip?'#92400e':'#bbb') + ';">' + skip + '</div><div style="font-size:11.5px;color:' + (skip?'#92400e':'#999') + ';">skipped</div></div>' +
2086      '</div>' +
2087      (S.dupes ? '<div style="font-size:12px;color:#999;margin:-6px 0 12px;">' + S.dupes + ' duplicate email' + (S.dupes===1?'':'s') + ' inside your file were merged (kept the last one).</div>' : '') +
2088      '<div style="border:1px solid #f0f0f4;border-radius:8px;overflow:hidden;">' +
2089      '<table style="width:100%;border-collapse:collapse;"><thead><tr style="background:#fafafc;">' +
2090      ['Email','First','Last','Phone','Tags'].map(function(h){ return '<th style="text-align:left;padding:8px 10px;font-size:11px;font-weight:600;color:#9a9ab0;text-transform:uppercase;letter-spacing:.04em;">' + h + '</th>'; }).join('') +
2091      '</tr></thead><tbody>' + sampleRows + '</tbody></table></div>' +
2092      (uniq > 8 ? '<div style="font-size:11.5px;color:#999;margin-top:6px;">Showing the first 8 of ' + uniq.toLocaleString() + '.</div>' : '') +
2093      skipNote +
2094      '<div style="margin-top:14px;background:#f6f6fb;border:1px solid #ececf4;border-radius:8px;padding:12px 14px;">' +
2095        '<div style="font-size:12.5px;font-weight:600;color:#1e1b4b;margin-bottom:5px;"><i class="fas fa-tag" style="margin-right:6px;color:#6c63ff;"></i>Tag these contacts</div>' +
2096        '<div style="font-size:12px;color:#666;line-height:1.5;margin-bottom:9px;">For your records, everyone in this upload is automatically tagged <strong>' + esc(S.uploadTag) + '</strong>.</div>' +
2097        '<input id="cwUploadTag" type="text" maxlength="60" placeholder="Add your own tag too (optional) — e.g. Webinar leads" style="width:100%;box-sizing:border-box;padding:9px 12px;border:1px solid #d8d8e4;border-radius:7px;font-size:13px;font-family:Inter,sans-serif;" />' +
2098      '</div>' +
2099      '<div id="cwSpaceCard" style="display:none;margin-top:14px;background:#f6f6fb;border:1px solid #ececf4;border-radius:8px;padding:12px 14px;">' +
2100        '<label for="cwSpace" style="display:block;font-size:12.5px;font-weight:600;color:#1e1b4b;margin-bottom:5px;"><i class="fas fa-people-group" style="margin-right:6px;color:#6c63ff;"></i>Also put everyone in a space</label>' +
2101        '<div id="cwSpaceNote" style="font-size:12px;color:#666;line-height:1.5;margin-bottom:9px;">They join your community and that space too. Anyone already in your community is matched by email, never added twice.</div>' +
2102        '<select id="cwSpace" style="width:100%;box-sizing:border-box;padding:9px 12px;border:1px solid #d8d8e4;border-radius:7px;font-size:13px;font-family:Inter,sans-serif;background:#fff;"><option value="">No, just add them to Subscribers</option></select>' +
2103      '</div>' +
2104      '<div style="margin-top:14px;background:#eef7f0;border:1px solid #cce9d4;border-radius:8px;padding:10px 14px;font-size:12.5px;color:#1c7a3f;line-height:1.5;"><i class="fas fa-shield-heart" style="margin-right:6px;"></i>Completing this import <strong>adds these contacts only</strong>. No email, message or automation is sent to anyone. If you put them in a space, you can send them a welcome at the end, and you read it first.</div>';
2105    var footer = btn('← Back', 'window.__cwBack()', 'ghost') + '<div style="flex:1;"></div>
2105' + btn('Cancel', 'closeImportContactsModal()', 'ghost') +
2106      (uniq > 0 ? btn('Complete import', 'window.__cwCommit()', 'primary') : btn('Complete import', '', 'disabled'));
2107    shell(body, footer, 680);
2108    window.__cwBack = function(){ stepMap(); };
2109    window.__cwCommit = commit;
2110    loadSpaces();
2111  }
2112  // The coach's own spaces (top level, not archived), for "Also put everyone
2113  // in a space". No community or no spaces: the card stays hidden.
2114  async function loadSpaces() {
2115    /* The Spaces beta (pre-deploy review MEDIUM-1): without the "Spaces" switch
2116       the option is never offered and /spaces is never asked (it would 404).
2117       An import opened FROM a space already has its space. */
2118    if (!S.fromSpace && !(typeof window.hasSpacesBeta === 'function' && window.hasSpacesBeta())) { S.spaces = []; return; }
2119    try {
2120      if (S.spaces == null) {
2121        var r = await fetch('/api/community-hosting/spaces', { headers: { 'Authorization': 'Bearer ' + getToken() } });
2122        var d = r.ok ? await r.json() : null;
2123        S.spaces = ((d && d.spaces) || []).filter(function(x){ return !x.parent_space_id && !x.archived_at; });
2124      }
2125    } catch (e) { S.spaces = []; }
2126    var card = document.getElementById('cwSpaceCard'), sel = document.getElementById('cwSpace');
2127    if (!card || !sel || !S.spaces.length) return;
2128    sel.innerHTML = '<option value="">No, just add them to Subscribers</option>' + S.spaces.map(function(x){
2129      return '<option value="' + x.id + '"' + (String(x.id) === S.spaceId ? ' selected' : '') + '>' + esc(x.name) + '</option>';
2130    }).join('');
2131    sel.onchange = function(){ S.spaceId = sel.value; };
2132    card.style.display = '';
2133  }
2134  function spaceName() {
2135    var x = (S.spaces || []).filter(function(v){ return String(v.id) === String(S.spaceId); })[0];
2136    return x ? x.name : '';
2137  }
2138  async function runDryRun() {
2139    try {
2140      var resp = await fetch('/api/contacts/import/dry-run', {
2141        method: 'POST', headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' },
2142        body: JSON.stringify({ emails: S.valid.map(function(v){ return v.email; }) })
2143      });
2144      var d = await resp.json();
2145      if (!resp.ok) return;
2146      S.dryRun = d;
2147      var nNew = document.querySelector('#cwNew div'), nUpd = document.querySelector('#cwUpd div');
2148      if (nNew) nNew.textContent = (d.new_count||0).toLocaleString();
2149      if (nUpd) nUpd.textContent = (d.existing||0).toLocaleString();
2150    } catch (e) { /* counts stay as … — non-blocking */ }
2151  }
2152
2153  // ── Commit (async) + progress + Step 4: Done ──
2154  // The server commits in the BACKGROUND and returns a batch id straight away;
2155  // we poll for "row X of N" progress and let the user leave — a toast notifies
2156  // them on completion even if they've closed the modal.
2157  async function commit() {
2158    S.step = 3;
2159    // Read the tags BEFORE we swap the screen: the auto "Uploaded <when>" record
2160    // tag (always) + any custom tag the user typed.
2161    var customTag = '';
2162    try { var _ti = document.getElementById('cwUploadTag'); if (_ti) customTag = String(_ti.value || '').trim().slice(0, 60); } catch (_) {}
2163    var uploadTags = [S.uploadTag];
2164    if (customTag) uploadTags.push(customTag);
2165    try { var _sp = document.getElementById('cwSpace'); if (_sp) S.spaceId = _sp.value; } catch (_) {}
2166    var footerBtn = document.querySelector('#importContactsModal button[onclick="window.__cwCommit()"]');
2167    if (footerBtn) { footerBtn.textContent = 'Starting…'; footerBtn.style.opacity = '0.7'; footerBtn.onclick = null; }
2168    try {
2169      var resp = await fetch('/api/contacts/import-mapped', {
2170        method: 'POST', headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' },
2171        body: JSON.stringify({ rows: S.valid, filename: S.fileName, custom_fields: S.customFields || [], upload_tags: uploadTags, space_id: S.spaceId || null })
2172      });
2173      // Parse defensively — a gateway/proxy can answer with an HTML error page
2174      // (e.g. 413/502/504), which must never surface as a raw "Unexpected token '<'".
2175      var data = null;
2176      try { data = await resp.json(); } catch (e) { data = null; }
2177      if (!resp.ok || !data) {
2178        if (resp.status === 413) return stepError('That file is too large for the server to accept in one go. Try splitting it into a couple of smaller files.');
2179        if (resp.status === 504 || resp.status === 502) return stepError('The server took too long to respond. Your contacts may still be importing — give it a minute, then refresh your contacts to check before retrying.');
2180        return stepError((data && data.error) || ('The server hit a problem starting the import (status ' + resp.status + '). Please try again.'));
2181      }
2182      S.batchId = data.import_batch_id;
2183      var processTotal = data.process_total || S.valid.length || 0;
2184      // This batch now owns the modal; a prior background import won't clobber it.
2185      window.__cwImportTracker = { batchId: S.batchId };
2186      stepProgress(processTotal);
2187      trackImport(S.batchId, processTotal);
2188    } catch (err) { stepError(err.message); }
2189  }
2190
2191  // Progress screen — big, calm, and explicit that they can walk away.
2192  function stepProgress(processTotal) {
2193    S.step = 3;
2194    var totalTxt = (processTotal || 0).toLocaleString();
2195    var body =
2196      '<div style="text-align:center;padding:2px 0 2px;"><div style="display:inline-flex;align-items:center;justify-content:center;width:52px;height:52px;border-radius:50%;background:#eef1ff;margin-bottom:10px;"><i class="fas fa-spinner fa-spin" style="color:#4338ca;font-size:22px;"></i></div>' +
2197      '<h3 style="margin:0 0 4px;font-size:18px;font-weight:600;color:#1e1b4b;">Importing your contacts…</h3>' +
2198      '<p style="margin:0;font-size:13px;color:#777;line-height:1.5;">You don\'t need to wait here — we\'ll keep importing in the background and pop up a notification the moment it\'s done. No emails or messages are sent.</p></div>' +
2199      '<div style="margin:18px 0 8px;height:10px;border-radius:6px;background:#eef0f6;overflow:hidden;"><div id="cwProgBar" style="height:100%;width:0%;background:#4338ca;border-radius:6px;transition:width .4s ease;"></div></div>' +
2200      '<div id="cwProgCount" style="text-align:center;font-size:13px;color:#555;font-weight:500;">Preparing… 0 of ' + totalTxt + '</div>';
2201    var footer = '<div style="flex:1;"></div>' + btn('Close — we\'ll notify you', 'closeImportContactsModal()', 'ghost');
2202    shell(body, footer);
2203  }
2204
2205  function _renderProgress(processed, total) {
2206    var bar = document.getElementById('cwProgBar');
2207    var cnt = document.getElementById('cwProgCount');
2208    if (!bar || !cnt) return; // modal closed / moved on — poller keeps running regardless
2209    var pct = total > 0 ? Math.min(100, Math.round((processed / total) * 100)) : 0;
2210    bar.style.width = pct + '%';
2211    cnt.textContent = 'Importing row ' + (processed || 0).toLocaleString() + ' of ' + (total || 0).toLocaleString() + ' · ' + pct + '%';
2212  }
2213
2214  // Detached-friendly poller. Updates the modal only while THIS batch still owns
2215  // a visible modal; always fires a toast on completion so a user who left still
2216  // finds out. Survives the modal being closed (close just hides it).
2217  function trackImport(batchId, processTotal) {
2218    (function poll() {
2219      fetch('/api/contacts/import-batches/' + encodeURIComponent(batchId), {
2220        headers: { 'Authorization': 'Bearer ' + getToken() }
2221      }).then(function (r) { return r.ok ? r.json() : null; }).then(function (data) {
2222        if (!data) { setTimeout(poll, 2500); return; }
2223        var modal = document.getElementById('importContactsModal');
2224        var visible = modal && modal.style.display !== 'none';
2225        var owns = !!(window.__cwImportTracker && window.__cwImportTracker.batchId === batchId);
2226        var mine = owns && visible; // safe to take over the modal UI
2227        var total = data.process_total || processTotal || 0;
2228        if (data.status === 'processing') {
2229          if (mine) _renderProgress(data.processed_count || 0, total);
2230          setTimeout(poll, 1500);
2231          return;
2232        }
2233        if (data.status === 'done') {
2234          if (typeof loadContacts === 'function') loadContacts();
2235          if (mine) {
2236            _renderProgress(total, total);
2237            stepDone({ created: data.created_count || 0, merged: data.merged_count || 0,
2238                       skipped_count: data.skipped_count || 0,
2239                       space_id: data.space_id || null, space_added: data.space_added_count || 0 });
2240          } else {
2241            showToast('✓ Import complete — ' + (data.created_count || 0).toLocaleStr
2241ing() + ' added' +
2242                      (data.merged_count ? ', ' + (data.merged_count).toLocaleString() + ' updated' : ''), 'success');
2243          }
2244          return;
2245        }
2246        // error
2247        if (mine) stepError(data.error || 'Import failed');
2248        else showToast('Import didn\'t finish — please try again', 'error');
2249      }).catch(function () { setTimeout(poll, 3000); });
2250    })();
2251  }
2252  function stepDone(summary) {
2253    S.step = 4;
2254    var serverSkipped = Array.isArray(summary.skipped) ? summary.skipped.length : (summary.skipped_count || 0);
2255    var totalSkipped = S.skipReasons.length + serverSkipped;
2256    var body =
2257      '<div style="text-align:center;padding:4px 0 2px;"><div style="display:inline-flex;align-items:center;justify-content:center;width:52px;height:52px;border-radius:50%;background:#ecfdf3;margin-bottom:10px;"><i class="fas fa-check" style="color:#16a34a;font-size:22px;"></i></div>' +
2258      '<h3 style="margin:0 0 4px;font-size:18px;font-weight:600;color:#1e1b4b;">Import complete</h3>' +
2259      '<p style="margin:0;font-size:13px;color:#777;">Your contacts are in. No emails or messages were sent.</p></div>' +
2260      '<div style="display:flex;gap:10px;margin:18px 0 4px;">' +
2261      '<div style="flex:1;background:#ecfdf3;border:1px solid #ccebd7;padding:14px;border-radius:10px;text-align:center;"><div style="font-size:26px;font-weight:700;color:#16794a;">' + (summary.created||0).toLocaleString() + '</div><div style="font-size:12px;color:#16794a;">Added</div></div>' +
2262      '<div style="flex:1;background:#eef1ff;border:1px solid #d6ddf7;padding:14px;border-radius:10px;text-align:center;"><div style="font-size:26px;font-weight:700;color:#3730a3;">' + (summary.merged||0).toLocaleString() + '</div><div style="font-size:12px;color:#3730a3;">Updated</div></div>' +
2263      '<div style="flex:1;background:' + (totalSkipped?'#fff7ed':'#f6f6fb') + ';border:1px solid ' + (totalSkipped?'#fde8c8':'#ececf4') + ';padding:14px;border-radius:10px;text-align:center;"><div style="font-size:26px;font-weight:700;color:' + (totalSkipped?'#92400e':'#bbb') + ';">' + totalSkipped + '</div><div style="font-size:12px;color:' + (totalSkipped?'#92400e':'#999') + ';">Skipped</div></div>' +
2264      '</div>' +
2265      (summary.created ? '<div style="margin-top:16px;background:#f6f6fb;border:1px solid #ececf4;border-radius:8px;padding:12px 14px;display:flex;align-items:center;gap:12px;"><div style="flex:1;font-size:12.5px;color:#555;line-height:1.5;">Imported by mistake? You can undo this — it removes only the <strong>' + (summary.created||0).toLocaleString() + '</strong> contact' + ((summary.created===1)?'':'s') + ' this import added.</div><span id="cwUndoBtn">' + btn('Undo this import', 'window.__cwUndo()', 'danger') + '</span></div>' : '') +
2266      '<div id="cwUndoMsg"></div>' +
2267      (summary.space_id ? '<div id="cwWelcome" style="margin-top:16px;"></div>' : '');
2268    var footer = '<div style="flex:1;"></div>' + btn('Done', 'closeImportContactsModal()', 'primary');
2269    shell(body, footer);
2270    window.__cwUndo = doUndo;
2271    if (summary.space_id) showWelcome(summary.space_id, summary.space_added);
2272  }
2273
2274  /* Item 7: the people this import put in a space, and the welcome email the
2275     coach may send them. The exact email is shown first (subject and words, the
2276     community's own import welcome); nothing goes until they press Send, and an
2277     import sends it once (the server's claim). */
2278  async function showWelcome(spaceId, added) {
2279    var box = document.getElementById('cwWelcome'); if (!box) return;
2280    var name = spaceName() || 'your space';
2281    var head = '<div style="background:#ecfdf3;border:1px solid #ccebd7;border-radius:8px;padding:12px 14px;font-size:13px;color:#16794a;margin-bottom:12px;">' +
2282      '<i class="fas fa-people-group" style="margin-right:6px;"></i><strong>' + (added||0).toLocaleString() + '</strong> ' + (added === 1 ? 'person is' : 'people are') + ' now in <strong>' + esc(name) + '</strong>.' +
2283      (added ? '' : ' Everyone on the list was already there.') + '</div>';
2284    if (!added) { box.innerHTML = head; return; }
2285    var w = null;
2286    try {
2287      var r = await fetch('/api/community-hosting/spaces/' + encodeURIComponent(spaceId) + '/import-welcome?batch=' + encodeURIComponent(S.batchId), { headers: { 'Authorization': 'Bearer ' + getToken() } });
2288      w = r.ok ? await r.json() : null;
2289    } catch (e) { w = null; }
2290    if (!w) { box.innerHTML = head; return; }
2291    if (w.sent_at) { box.innerHTML = head + '<div style="font-size:12.5px;color:#555;">The welcome email has been sent to ' + (w.sent||0) + ' ' + (w.sent === 1 ? 'person' : 'people') + '.</div>'; return; }
2292    box.innerHTML = head +
2293      '<div style="border:1px solid #ececf4;border-radius:8px;padding:12px 14px;">' +
2294        '<div style="font-size:13px;font-weight:600;color:#1e1b4b;margin-bottom:4px;">Send them a welcome email?</div>' +
2295        '<div style="font-size:12.5px;color:#666;line-height:1.5;margin-bottom:10px;">This is exactly what ' + w.recipients + ' ' + (w.recipients === 1 ? 'person' : 'people') + ' will get, each with their own link to sign in. ' +
2296          (w.own_words ? 'These are your own words for imported members.' : 'You can put it in your own words under Community, Automated emails, Import welcome.') + '</div>' +
2297        '<div style="font-size:12px;color:#9a9ab0;">Subject</div><div style="font-size:13px;color:#1e1b4b;margin-bottom:8px;">' + esc(w.subject) + '</div>' +
2298        '<iframe id="cwWelcomeBody" sandbox="" title="The welcome email" style="width:100%;height:170px;
2298border:1px solid #f0f0f4;border-radius:6px;background:#fff;"></iframe>' +
2299        '<div id="cwWelcomeMsg" style="font-size:12.5px;color:#555;margin-top:8px;"></div>' +
2300        '<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:10px;">' +
2301          btn('Not now', 'window.__cwWelcomeSkip()', 'ghost') + btn('Send it to ' + w.recipients + ' ' + (w.recipients === 1 ? 'person' : 'people'), 'window.__cwWelcomeSend()', 'primary') +
2302        '</div>' +
2303      '</div>';
2304    var frame = document.getElementById('cwWelcomeBody');
2305    if (frame) frame.srcdoc = '<div style="font:14px Inter,Arial,sans-serif;color:#1e1b4b;padding:4px 8px;">' + w.body_html + '</div>';
2306    window.__cwWelcomeSkip = function(){ box.innerHTML = head + '<div style="font-size:12.5px;color:#555;">No email was sent. Nobody has been told about this yet.</div>'; };
2307    window.__cwWelcomeSend = async function(){
2308      var msg = document.getElementById('cwWelcomeMsg'); if (msg) msg.textContent = 'Sending…';
2309      try {
2310        var r2 = await fetch('/api/community-hosting/spaces/' + encodeURIComponent(spaceId) + '/import-welcome', {
2311          method: 'POST', headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' },
2312          body: JSON.stringify({ import_batch_id: S.batchId })
2313        });
2314        var d2 = await r2.json().catch(function(){ return {}; });
2315        if (!r2.ok) { if (msg) msg.textContent = d2.error === 'already_sent' ? 'This welcome has already been sent.' : 'It did not send. Please try again.'; return; }
2316        box.innerHTML = head + '<div style="font-size:12.5px;color:#16794a;">Sent to ' + (d2.sent||0) + ' ' + (d2.sent === 1 ? 'person' : 'people') + '.' + (d2.failed ? ' ' + d2.failed + ' could not be sent.' : '') + '</div>';
2317      } catch (e) { if (msg) msg.textContent = 'It did not send. Please try again.'; }
2318    };
2319  }
2320  async function doUndo() {
2321    var span = document.getElementById('cwUndoBtn');
2322    if (span) span.innerHTML = '<span style="font-size:12.5px;color:#999;">Undoing…</span>';
2323    try {
2324      var resp = await fetch('/api/contacts/import/undo', {
2325        method: 'POST', headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' },
2326        body: JSON.stringify({ import_batch_id: S.batchId })
2327      });
2328      var d = await resp.json();
2329      if (!resp.ok) { if (span) span.innerHTML = btn('Undo this import', 'window.__cwUndo()', 'danger'); document.getElementById('cwUndoMsg').innerHTML = '<div style="margin-top:8px;font-size:12px;color:#dc2626;">Couldn\'t undo: ' + esc(d.error||'error') + '</div>'; return; }
2330      if (span) span.innerHTML = '';
2331      document.getElementById('cwUndoMsg').innerHTML = '<div style="margin-top:10px;background:#f6f6fb;border:1px solid #ececf4;border-radius:8px;padding:10px 14px;font-size:12.5px;color:#16794a;"><i class="fas fa-rotate-left" style="margin-right:6px;"></i>Done — removed ' + (d.archived||0).toLocaleString() + ' contact' + ((d.archived===1)?'':'s') + ' (moved to Archived). You can restore them anytime from the Archived view.</div>';
2332      if (typeof loadContacts === 'function') loadContacts();
2333    } catch (e) { if (span) span.innerHTML = btn('Undo this import', 'window.__cwUndo()', 'danger'); }
2334  }
2335
2336  function stepError(msg) {
2337    var body =
2338      '<div style="text-align:center;padding:6px 0;"><div style="display:inline-flex;align-items:center;justify-content:center;width:52px;height:52px;border-radius:50%;background:#fef2f2;margin-bottom:10px;"><i class="fas fa-xmark" style="color:#dc2626;font-size:22px;"></i></div>' +
2339      '<h3 style="margin:0 0 6px;font-size:17px;font-weight:600;color:#1e1b4b;">Something went wrong</h3>' +
2340      '<p style="margin:0;font-size:13px;color:#777;line-height:1.5;">' + esc(msg) + '</p></div>';
2341    var footer = '<div style="flex:1;"></div>' + btn('Close', 'closeImportContactsModal()', 'ghost') + btn('Back to review', 'window.__cwBack()', 'primary');
2342    shell(body, footer);
2343    window.__cwBack = function(){ stepReview(); };
2344  }
2345
2346  stepUpload(null);
2347};
2348
2349window.closeImportContactsModal = function() {
2350  var m = document.getElementById('importContactsModal');
2351  if (m) m.style.display = 'none';
2352  /* Space path only (F20): back to the space the import was started from.
2353     Null when opened from Subscribers, so closing there is unchanged. */
2354  var back = window.__cwOnClose;
2355  window.__cwOnClose = null;
2356  if (typeof back === 'function') { try { back(); } catch (e) { console.error('[import] return to the space failed', e); } }
2357};

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.