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 & 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 {'&':'&','<':'<','>':'>','"':'"'}[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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); } 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;">✓</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 & 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 & 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.