← back to Marketing Command Center

public/panels/clients.js

360 lines

/* global window, document, location */
// Clients & Prospects panel — browse FileMaker clients + Google-Places prospect
// groups, each lazy-loaded, with LinkedIn + Instagram discovery links, emails,
// and per-contact "contacted" tracking (localStorage). Reads a manifest at
// /data/clients-manifest.json listing the staged datasets. Nothing here uploads
// to Constant Contact.
window.MCC_PANELS = window.MCC_PANELS || {};
window.MCC_PANELS['clients'] = {
  async init(root) {
    const ORIGIN = location.origin;
    const $ = s => root.querySelector(s);
    const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
    const jget = async u => { const r = await fetch(ORIGIN + u, { credentials: 'same-origin' }); if (!r.ok) throw new Error(u + ' → ' + r.status); return r.json(); };
    const CAP = 300;
    const CK = id => 'mcc_cl_' + id;                          // localStorage key per group
    const done = id => { try { return new Set(JSON.parse(localStorage.getItem(CK(id)) || '[]')); } catch { return new Set(); } };
    const save = (id, set) => { try { localStorage.setItem(CK(id), JSON.stringify([...set])); } catch {} };
    // Per-channel action log: when you Follow/message a contact on LinkedIn/IG, stamp the date+time.
    const AK = id => 'mcc_cl_acted_' + id;
    const acts = id => { try { return JSON.parse(localStorage.getItem(AK(id)) || '{}'); } catch { return {}; } };
    const saveActs = (id, a) => { try { localStorage.setItem(AK(id), JSON.stringify(a)); } catch {} };
    // Per-contact free-text notes: keyed by group id → { recId: noteText }. Local-only.
    const NK = id => 'mcc_cl_notes_' + id;
    const notesOf = id => { try { return JSON.parse(localStorage.getItem(NK(id)) || '{}'); } catch { return {}; } };
    const saveNotes = (id, o) => { try { localStorage.setItem(NK(id), JSON.stringify(o)); } catch {} };
    // Server-side notes sync so notes follow you across devices; localStorage above
    // is the instant cache. Writes debounce a POST; opening a group pulls + merges.
    const noteTimers = {};
    const notesFetch = async gid => { try { const r = await fetch(ORIGIN + '/api/clients-notes?group=' + encodeURIComponent(gid), { credentials: 'same-origin' }); if (!r.ok) return null; return (await r.json()).notes || {}; } catch { return null; } };
    const notesPush = (gid, id, text) => { fetch(ORIGIN + '/api/clients-notes', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ group: gid, id, text }) }).catch(() => {}); };
    const fmtWhen = ts => { try { return new Date(ts).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); } catch { return ''; } };

    // Normalize a record from either a "clients" (FM) or "prospects" (CSV) dataset.
    function norm(rec, kind) {
      if (kind === 'prospects') {
        // Full formatted address; fall back to composing one from the parts.
        const address = rec.address || [rec.street, [rec.city, rec.state].filter(Boolean).join(', '), rec.postal].filter(Boolean).join(', ');
        return { id: (rec.website || rec.title || '').toLowerCase(), label: rec.title || '(business)', sub: [rec.city, rec._group].filter(Boolean).join(' · '),
                 email: rec.email || '', website: rec.website || '', phone: rec.phone || '', phoneRaw: rec.phoneUnformatted || rec.phone || '',
                 address, mapsUrl: rec.mapsUrl || '', category: rec.category || '', q: [rec.title, rec.city].filter(Boolean).join(' '),
                 _raw: rec, _all: Object.values(rec).filter(v => v && typeof v === 'string').join(' ').toLowerCase() };
      }
      const label = rec.name || rec.company || '(client)';
      const sub = rec.name ? [rec.company, [rec.city, rec.state].filter(Boolean).join(', ')].filter(Boolean).join(' · ')
                           : [rec.city, rec.state].filter(Boolean).join(', ');
      return { id: rec.account || (rec.company + '|' + rec.name).toLowerCase(), label, sub, email: rec.email || '', website: '', phone: rec.phone || '', phoneRaw: rec.phone || '',
               address: '', mapsUrl: '', category: '',
               q: [rec.name, rec.company, rec.city].filter(Boolean).join(' '),
               _raw: rec, _all: Object.values(rec).filter(v => v && typeof v === 'string').join(' ').toLowerCase() };
    }
    const liURL = q => 'https://www.linkedin.com/search/results/all/?keywords=' + encodeURIComponent(q);
    const igURL = q => 'https://www.google.com/search?q=' + encodeURIComponent(q + ' instagram');
    // Human-readable domain for the website chip — turns a raw URL into e.g. "bsbdesign.com".
    const hostLabel = u => { try { return new URL(/^https?:\/\//.test(u) ? u : 'http://' + u).hostname.replace(/^www\./, ''); } catch { return 'website'; } };
    const telHref = p => 'tel:' + String(p || '').replace(/[^\d+]/g, '');
    // Recommend the single best way to CONTACT and to FOLLOW each firm, from the
    // data we actually hold. Contact prefers a direct inbox; follow prefers a
    // confirmed social profile over a search. Returns {contact, follow, keys}.
    function bestChannels(r) {
      let contact, ckey;
      if (r.email)        { contact = '✉️ Email — ' + r.email; ckey = 'mail'; }
      else if (r.phone)   { contact = '📞 Call — ' + r.phone;  ckey = 'phone'; }
      else if (r.website) { contact = '🌐 Website form — ' + hostLabel(r.website); ckey = 'site'; }
      else                { contact = '🔎 Google the firm'; ckey = ''; }
      let follow, fkey;
      if (r._raw.instagram)     { follow = 'Instagram'; fkey = 'ig'; }
      else if (r._raw.linkedin) { follow = 'LinkedIn';  fkey = 'in'; }
      else                      { follow = 'search on LinkedIn / IG'; fkey = ''; }
      return { contact, follow, ckey, fkey };
    }

    let MANIFEST = [], current = null, RECS = [];

    try {
      const m = await jget('/data/clients-manifest.json');
      MANIFEST = m.datasets || [];
    } catch { $('#cl-banner').textContent = 'No client datasets staged yet (data/clients-manifest.json missing).'; return; }
    if (!MANIFEST.length) { $('#cl-banner').textContent = 'No client datasets staged yet.'; return; }

    // Synthetic "All Prospects" group = every prospect city merged into one list.
    const prospectDs = MANIFEST.filter(x => x.kind === 'prospects');
    if (prospectDs.length > 1) {
      const fmIdx = MANIFEST.findIndex(x => x.id === 'fm-clients');
      MANIFEST.splice(fmIdx >= 0 ? fmIdx + 1 : 0, 0, {
        id: 'all-prospects', label: '★ All Prospects', kind: 'prospects',
        files: prospectDs.map(x => x.file), labels: prospectDs.map(x => x.label),
        count: prospectDs.reduce((n, x) => n + (x.count || 0), 0),
      });
    }

    $('#cl-banner').hidden = true;
    $('#cl-groupcount').textContent = MANIFEST.length + ' groups';
    $('#cl-tabs').innerHTML = MANIFEST.map(d =>
      `<button class="btn ghost" data-id="${esc(d.id)}">${esc(d.label)}${d.count ? ` <span class="pill" style="font-size:9px;">${d.count.toLocaleString()}</span>` : ''}</button>`).join('');
    $('#cl-tabs').querySelectorAll('button').forEach(b => b.onclick = () => selectGroup(b.dataset.id));

    // Coverage summary across the real groups (exclude the synthetic All-Prospects).
    const real = MANIFEST.filter(x => !x.files);
    const sum = k => real.reduce((n, x) => n + (x[k] || 0), 0);
    const totContacts = real.reduce((n, x) => n + (x.count || 0), 0);
    $('#cl-coverage').innerHTML = `Across <b>${real.length}</b> groups · <b>${totContacts.toLocaleString()}</b> contacts · ` +
      `<b>${sum('with_email').toLocaleString()}</b> emails · <b>${sum('with_instagram').toLocaleString()}</b> Instagram · <b>${sum('with_linkedin').toLocaleString()}</b> LinkedIn`;

    async function selectGroup(id) {
      const d = MANIFEST.find(x => x.id === id); if (!d) return;
      current = d;
      $('#cl-tabs').querySelectorAll('button').forEach(b => b.className = 'btn ' + (b.dataset.id === id ? 'gold' : 'ghost'));
      $('#cl-controls').hidden = false;
      $('#cl-grouptitle').textContent = d.label;
      $('#cl-list').innerHTML = '<div class="loading">Loading…</div>';
      let data;
      if (d.files) {                       // merged "All Prospects" — load every group, dedup, merge contact fields
        const parts = await Promise.all(d.files.map(f => jget('/data/' + f).catch(() => null)));
        const hostOf = u => { try { return new URL(u.startsWith('http') ? u : 'http://' + u).hostname.replace(/^www\./, ''); } catch { return ''; } };
        const seen = new Map();
        parts.forEach((p, i) => { if (!p) return; (p.businesses || []).forEach(b => {
          const key = hostOf(b.website) || ((b.title || '').toLowerCase() + '|' + (b.city || '').toLowerCase());
          if (!seen.has(key)) { seen.set(key, Object.assign({ _group: d.labels[i] }, b)); }
          else { const ex = seen.get(key);   // fill gaps from the duplicate
            ex.email = ex.email || b.email; ex.instagram = ex.instagram || b.instagram;
            ex.linkedin = ex.linkedin || b.linkedin; ex.phone = ex.phone || b.phone; }
        }); });
        const businesses = [...seen.values()];
        data = { businesses, source: `all prospect groups merged (deduped from ${parts.reduce((n, p) => n + (p ? (p.businesses || []).length : 0), 0).toLocaleString()})`, with_email: businesses.filter(b => b.email).length };
      } else {
        try { data = await jget('/data/' + d.file); } catch { $('#cl-list').innerHTML = '<div class="card muted">Could not load ' + esc(d.file) + '.</div>'; return; }
      }
      const arr = data.clients || data.businesses || [];
      RECS = arr.map(r => norm(r, d.kind));
      // Pull server-side notes for this group and merge (server wins on conflict;
      // push any local-only notes up so nothing typed offline is lost).
      const srvNotes = await notesFetch(d.id);
      if (srvNotes) {
        const local = notesOf(d.id);
        for (const k of Object.keys(local)) if (!(k in srvNotes)) notesPush(d.id, k, local[k]);
        saveNotes(d.id, Object.assign({}, local, srvNotes));
      }
      const nEmail = RECS.filter(r => r.email).length, nIg = RECS.filter(r => r._raw.instagram).length, nLi = RECS.filter(r => r._raw.linkedin).length;
      $('#cl-emailcount').textContent = nEmail.toLocaleString() + ' email';
      $('#cl-igcount').textContent = nIg ? nIg.toLocaleString() + ' IG' : '';
      $('#cl-licount').textContent = nLi ? nLi.toLocaleString() + ' LinkedIn' : '';
      $('#cl-groupnote').textContent = `Source: ${data.source || d.file}${data.updated ? ' · updated ' + data.updated : ''}. Each card shows the firm's full address (📍 opens Google Maps), phone (📞 tap to call), website domain, and a ⭐ Best channel to contact + follow. LinkedIn opens a people/company search; IG opens a Google “<name> instagram” lookup — open the right match and follow.`;
      $('#cl-search').value = '';
      $('#cl-search').oninput = render;
      $('#cl-sort').value = sortMode();
      $('#cl-sort').onchange = () => { try { localStorage.setItem(SK, $('#cl-sort').value); } catch {} render(); };
      FILTERS.clear();
      $('#cl-filters').querySelectorAll('[data-filter]').forEach(b => {
        b.className = 'btn ghost';
        b.onclick = () => { const f = b.dataset.filter; if (FILTERS.has(f)) { FILTERS.delete(f); b.className = 'btn ghost'; } else { FILTERS.add(f); b.className = 'btn gold'; } render(); };
      });
      $('#cl-copymsg').textContent = '';
      $('#cl-copyemails').onclick = copyEmails;
      $('#cl-copyunacted').onclick = copyUnactioned;
      $('#cl-density').onclick = toggleDensity;
      applyDensity();
      $('#cl-reset').onclick = () => { if (confirm('Clear contacted checks for ' + d.label + '?')) { save(d.id, new Set()); render(); } };
      $('#cl-export').onclick = () => exportCSV(d);
      render();
    }

    const SK = 'mcc_cl_sort';
    const DK = 'mcc_cl_density';
    const FILTERS = new Set();          // active quick-filters: email | instagram | linkedin | uncontacted
    const sortMode = () => { try { return localStorage.getItem(SK) || 'default'; } catch { return 'default'; } };
    function sortRecs(list) {
      const az = (a, b) => a.label.localeCompare(b.label);
      const has = f => (a, b) => ((b._raw[f] ? 1 : 0) - (a._raw[f] ? 1 : 0)) || az(a, b);
      const cmp = {
        name: az,
        city: (a, b) => (a._raw.city || '').localeCompare(b._raw.city || '') || az(a, b),
        state: (a, b) => (a._raw.state || '').localeCompare(b._raw.state || '') || az(a, b),
        email: (a, b) => ((b.email ? 1 : 0) - (a.email ? 1 : 0)) || az(a, b),
        instagram: has('instagram'),
        linkedin: has('linkedin'),
      }[sortMode()];
      return cmp ? list.slice().sort(cmp) : list;
    }
    function currentMatches() {
      const q = ($('#cl-search').value || '').trim().toLowerCase();
      let list = q ? RECS.filter(r => r._all.includes(q)) : RECS;
      if (FILTERS.size) {
        const set = FILTERS.has('uncontacted') ? done(current.id) : null;
        const A = FILTERS.has('unactioned') ? acts(current.id) : null;
        list = list.filter(r =>
          (!FILTERS.has('email') || r.email) &&
          (!FILTERS.has('instagram') || r._raw.instagram) &&
          (!FILTERS.has('linkedin') || r._raw.linkedin) &&
          (!FILTERS.has('uncontacted') || !set.has(r.id)) &&
          (!FILTERS.has('unactioned') || !(A[r.id] && (A[r.id].in || A[r.id].ig))));
      }
      return sortRecs(list);
    }

    async function copyList(emails, label) {
      if (!emails.length) { $('#cl-copymsg').textContent = 'no emails in view'; return; }
      try { await navigator.clipboard.writeText(emails.join(', ')); $('#cl-copymsg').textContent = `copied ${emails.length.toLocaleString()} ${label}`; }
      catch { $('#cl-copymsg').textContent = 'copy blocked — export CSV instead'; }
    }
    function copyEmails() { copyList(Array.from(new Set(currentMatches().map(r => r.email).filter(Boolean))), 'emails'); }
    function copyUnactioned() {
      const A = acts(current.id);
      const emails = Array.from(new Set(currentMatches()
        .filter(r => { const a = A[r.id]; return !(a && (a.in || a.ig)); })
        .map(r => r.email).filter(Boolean)));
      copyList(emails, 'not-actioned emails');
    }
    function applyDensity() {
      const compact = (() => { try { return localStorage.getItem(DK) === 'compact'; } catch { return false; } })();
      $('#cl-list').classList.toggle('cl-compact', compact);
      $('#cl-density').textContent = compact ? 'Comfortable' : 'Compact';
    }
    function toggleDensity() {
      const compact = !$('#cl-list').classList.contains('cl-compact');
      try { localStorage.setItem(DK, compact ? 'compact' : 'comfortable'); } catch {}
      applyDensity();
    }

    // Review-oriented CSV export of the current group (respects the filter).
    // Includes emails + the LinkedIn/Instagram discovery URLs. Local download —
    // this does NOT send anything to Constant Contact.
    function exportCSV(d) {
      const rows = currentMatches();
      const A = acts(d.id);
      const N = notesOf(d.id);
      const noteOf = r => N[r.id] || '';
      const actedIn = r => { const a = A[r.id]; return a && a.in ? fmtWhen(a.in) : ''; };
      const actedIg = r => { const a = A[r.id]; return a && a.ig ? fmtWhen(a.ig) : ''; };
      const cell = v => { v = String(v == null ? '' : v); return /[",\n]/.test(v) ? '"' + v.replace(/"/g, '""') + '"' : v; };
      let headers, line;
      if (d.kind === 'prospects') {
        headers = ['Business', 'Category', 'Address', 'City', 'State', 'Phone', 'Website', 'Email', 'Best contact', 'LinkedIn', 'Instagram', 'LinkedIn actioned', 'Instagram actioned', 'Notes'];
        line = r => [r._raw.title, r.category || '', r.address || '', r._raw.city, r._raw.state || '', r._raw.phone, r._raw.website, r._raw.email, bestChannels(r).contact, r._raw.linkedin || liURL(r.q), r._raw.instagram || igURL(r.q), actedIn(r), actedIg(r), noteOf(r)];
      } else {
        headers = ['Account', 'Company', 'Name', 'City', 'State', 'Email', 'Phone', 'LinkedIn Search', 'Instagram Search', 'LinkedIn actioned', 'Instagram actioned', 'Notes'];
        line = r => [r._raw.account, r._raw.company, r._raw.name, r._raw.city, r._raw.state, r._raw.email, r._raw.phone, liURL(r.q), igURL(r.q), actedIn(r), actedIg(r), noteOf(r)];
      }
      const csv = [headers.join(','), ...rows.map(r => line(r).map(cell).join(','))].join('\r\n');
      const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' });
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = url; a.download = (d.file || ('prospects-' + d.id + '.json')).replace(/\.json$/, '') + '.csv';
      document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
    }

    // ── Right-side drawer: open a chip target in-panel instead of a new tab ──
    // Firm websites embed fine; LinkedIn/Instagram/Google refuse framing
    // (X-Frame-Options / CSP frame-ancestors), so those show a one-click card.
    const NO_FRAME = /(^|\.)(linkedin\.com|instagram\.com|google\.[a-z.]+|facebook\.com|twitter\.com|x\.com|threads\.net)$/i;
    const embeddable = url => { try { return !NO_FRAME.test(new URL(url).hostname.replace(/^www\./, '')); } catch { return false; } };
    function openDrawer(url, title, kind) {
      const dr = $('#cl-drawer'); if (!dr) { window.open(url, '_blank', 'noopener'); return; }
      $('#cl-dr-title').textContent = title || '';
      $('#cl-dr-kind').textContent = kind || '';
      $('#cl-dr-open').href = url; $('#cl-dr-fb-open').href = url;
      const frame = $('#cl-dr-frame'), fb = $('#cl-dr-fallback');
      if (embeddable(url)) {
        fb.hidden = true; frame.hidden = false; frame.src = url;
      } else {
        frame.hidden = true; frame.src = 'about:blank'; fb.hidden = false;
        $('#cl-dr-fb-msg').textContent = `${kind || 'This site'} blocks being shown inside another page, so it can’t load here. Open it directly:`;
        $('#cl-dr-fb-url').textContent = url;
      }
      dr.classList.add('open'); $('#cl-scrim').classList.add('open'); dr.setAttribute('aria-hidden', 'false');
    }
    function closeDrawer() {
      const dr = $('#cl-drawer'); if (!dr) return;
      dr.classList.remove('open'); $('#cl-scrim').classList.remove('open'); dr.setAttribute('aria-hidden', 'true');
      const f = $('#cl-dr-frame'); if (f) f.src = 'about:blank';
    }
    if ($('#cl-dr-close')) $('#cl-dr-close').onclick = closeDrawer;
    if ($('#cl-scrim')) $('#cl-scrim').onclick = closeDrawer;
    if (!window.__clDrawerEsc) { window.__clDrawerEsc = true; document.addEventListener('keydown', e => { if (e.key === 'Escape') closeDrawer(); }); }

    function render() {
      const d = current; const set = done(d.id);
      const matches = currentMatches();
      const shown = matches.slice(0, CAP);
      const more = matches.length - shown.length;
      const A = acts(d.id);
      const N = notesOf(d.id);
      $('#cl-list').innerHTML = `<div class="card"><div>${shown.map((r, i) => {
        const on = set.has(r.id);
        const note = N[r.id] || '';
        const liDir = !!r._raw.linkedin, igDir = !!r._raw.instagram;
        const a = A[r.id] || {};
        const sub = r.sub ? `<span class="muted" style="font-size:11px;">${esc(r.sub)}</span>` : '';
        const best = bestChannels(r);
        const bestOn = k => (k && (best.ckey === k || best.fkey === k)) ? ' best' : '';   // gold-ring the recommended channel
        const addr = r.address
          ? `<span class="cl-addr">📍 ${r.mapsUrl
              ? `<a href="${esc(r.mapsUrl)}" target="_blank" rel="noopener" title="Open in Google Maps" onclick="event.stopPropagation()">${esc(r.address)}</a> ↗`
              : esc(r.address)}${r.category ? ` · ${esc(r.category)}` : ''}</span>`
          : '';
        const stamp = (ch) => `<span class="cl-acted" id="acted-${ch}-${i}">${a[ch] ? '🕓 ' + esc(fmtWhen(a[ch])) : ''}</span>`;
        const chips =
          (r.email ? `<a class="cl-chip mail${bestOn('mail')}" href="mailto:${esc(r.email)}" title="Email — ${esc(r.email)}" onclick="event.stopPropagation()">✉︎ ${esc(r.email)}</a>` : '') +
          (r.phone ? `<a class="cl-chip phone${bestOn('phone')}" href="${esc(telHref(r.phoneRaw))}" title="Call ${esc(r.phone)}" onclick="event.stopPropagation()">📞 ${esc(r.phone)}</a>` : '') +
          (r.website ? `<a class="cl-chip site${bestOn('site')}" href="${esc(r.website)}" target="_blank" rel="noopener" title="${esc(r.website)}" onclick="event.stopPropagation()">🌐 ${esc(hostLabel(r.website))} ↗</a>` : '') +
          `<a class="cl-chip in ${liDir ? 'direct' : 'search'}${bestOn('in')}" data-ch="in" data-idx="${i}" href="${esc(r._raw.linkedin || liURL(r.q))}" target="_blank" rel="noopener" title="${liDir ? 'LinkedIn profile' : 'LinkedIn search'}">in · ${liDir ? 'Follow' : 'Find'} ↗</a>` + stamp('in') +
          `<a class="cl-chip ig ${igDir ? 'direct' : 'search'}${bestOn('ig')}" data-ch="ig" data-idx="${i}" href="${esc(r._raw.instagram || igURL(r.q))}" target="_blank" rel="noopener" title="${igDir ? 'Instagram profile' : 'Instagram search'}">IG · ${igDir ? 'Follow' : 'Find'} ↗</a>` + stamp('ig');
        const bestLine = `<span class="cl-best">⭐ <b>Best:</b> ${esc(best.contact)} · <b>Follow</b> on ${esc(best.follow)}</span>`;
        return `<label class="cl-row" style="display:flex;align-items:flex-start;gap:9px;padding:8px 0;border-bottom:1px solid var(--line,#eee);${on ? 'opacity:.5;' : ''}">
          <input type="checkbox" data-id="${esc(r.id)}" ${on ? 'checked' : ''} style="width:16px;height:16px;flex:none;margin-top:2px;">
          <span style="flex:1;display:flex;flex-direction:column;line-height:1.3;min-width:0;">
            <b style="font-size:13px;${on ? 'text-decoration:line-through;' : ''}">${esc(r.label)}</b>
            ${sub}
            ${addr}
            <span class="cl-chips" style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-top:5px;">${chips}</span>
            ${bestLine}
            <textarea class="cl-note${note ? ' has-note' : ''}" data-idx="${i}" rows="1" title="Notes sync across your devices" placeholder="📝 Add a note — call outcome, who to ask for, follow-up date, status… (syncs across devices)" onclick="event.stopPropagation()">${esc(note)}</textarea>
          </span>
        </label>`;
      }).join('')}</div>${more > 0 ? `<div class="muted" style="font-size:11px;margin-top:8px;">Showing ${shown.length} of ${matches.length.toLocaleString()} — type in the filter to narrow.</div>` : ''}</div>`;
      // Chip clicks open the target in the RIGHT DRAWER (not a new tab); in/ig also stamp the date+time.
      $('#cl-list').querySelectorAll('a.cl-chip.site, a.cl-chip.in, a.cl-chip.ig').forEach(el => el.addEventListener('click', ev => {
        ev.preventDefault(); ev.stopPropagation();
        const url = el.getAttribute('href');
        const isIn = el.classList.contains('in'), isIg = el.classList.contains('ig');
        if (isIn || isIg) {
          const idx = +el.dataset.idx, ch = el.dataset.ch, rec = shown[idx];
          const A2 = acts(d.id); (A2[rec.id] = A2[rec.id] || {})[ch] = Date.now(); saveActs(d.id, A2);
          const span = document.getElementById(`acted-${ch}-${idx}`); if (span) span.textContent = '🕓 ' + fmtWhen(A2[rec.id][ch]);
        }
        const row = el.closest('.cl-row'); const name = row ? (row.querySelector('b') ? row.querySelector('b').textContent : '') : '';
        openDrawer(url, name, isIn ? 'LinkedIn' : isIg ? 'Instagram' : 'Website');
      }));
      $('#cl-progress').textContent = `${set.size.toLocaleString()} / ${RECS.length.toLocaleString()} contacted`;
      $('#cl-list').querySelectorAll('input[type=checkbox]').forEach(cb => cb.onchange = () => {
        const s = done(d.id); cb.checked ? s.add(cb.dataset.id) : s.delete(cb.dataset.id); save(d.id, s);
        const row = cb.closest('.cl-row'); if (row) { row.style.opacity = cb.checked ? '.5' : ''; const b = row.querySelector('b'); if (b) b.style.textDecoration = cb.checked ? 'line-through' : ''; }
        $('#cl-progress').textContent = `${s.size.toLocaleString()} / ${RECS.length.toLocaleString()} contacted`;
      });
      // Per-contact notes: auto-grow + auto-save to localStorage on each keystroke.
      $('#cl-list').querySelectorAll('textarea.cl-note').forEach(ta => {
        const grow = () => { ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 220) + 'px'; };
        if (ta.value.trim()) grow();
        ta.addEventListener('focus', grow);
        ta.addEventListener('input', () => {
          grow();
          const rec = shown[+ta.dataset.idx]; if (!rec) return;
          const O = notesOf(d.id);
          const v = ta.value.trim();
          if (v) O[rec.id] = ta.value; else delete O[rec.id];
          saveNotes(d.id, O);
          ta.classList.toggle('has-note', !!v);
          // Debounced sync to the server so the note follows the user to any device.
          const key = d.id + '' + rec.id;
          clearTimeout(noteTimers[key]);
          noteTimers[key] = setTimeout(() => notesPush(d.id, rec.id, ta.value), 500);
        });
      });
    }

    // Auto-open the smallest group so the panel isn't empty on first view.
    const smallest = [...MANIFEST].sort((a, b) => (a.count || 1e9) - (b.count || 1e9))[0];
    if (smallest) selectGroup(smallest.id);
  },
};