← back to Marketing Command Center

public/panels/profiles.js

297 lines

// Contact Profiles panel — left: searchable contact list; right: a 360° card
// for the selected contact (lifecycle, lists, tags, segment memberships, email
// engagement timeline, cross-channel placeholders). Read-only.
window.MCC_PANELS = window.MCC_PANELS || {};
window.MCC_PANELS['profiles'] = {
  init(root) {
    const $ = s => root.querySelector(s);
    const api = (p) => fetch(`/api/profiles${p}`).then(r => r.json());
    const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
      ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
    const fmtDate = d => d ? new Date(d).toLocaleDateString(undefined,
      { year: 'numeric', month: 'short', day: 'numeric' }) : '—';
    const fmtDateTime = d => d ? new Date(d).toLocaleString(undefined,
      { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : '—';
    const fmtRelative = d => {
      if (!d) return 'never';
      const days = Math.round((Date.now() - Date.parse(d)) / 86400000);
      if (days <= 0) return 'today';
      if (days === 1) return 'yesterday';
      if (days < 30) return `${days}d ago`;
      if (days < 365) return `${Math.round(days / 30)}mo ago`;
      return `${Math.round(days / 365)}y ago`;
    };

    let anyMock = false;
    let selectedEmail = null;
    let listCache = [];
    let listIsSearch = false;

    function setMock(m) {
      if (m) {
        anyMock = true;
        $('#prof-banner').innerHTML =
          `<div class="muted-banner">Running on a <b>mock contact pool</b> (~240 luxury-design contacts).
           Paste your <code>CTCT_ACCESS_TOKEN</code> in <code>.env</code> to power profiles from your live Constant Contact roster.</div>`;
      }
    }

    // ── list rendering ────────────────────────────────────────────────────────
    function renderList(payload) {
      const el = $('#prof-list');
      listCache = payload.contacts || [];
      const meta = $('#prof-list-meta');
      if (payload.query) {
        meta.textContent = `${Number(payload.count).toLocaleString()} match${payload.count === 1 ? '' : 'es'} for “${payload.query}” · ${Number(payload.total).toLocaleString()} contacts`;
      } else {
        meta.textContent = `${Number(payload.total).toLocaleString()} contacts · sorted by most recent open`;
      }

      if (!listCache.length) {
        el.classList.remove('loading');
        el.innerHTML = `<div class="muted" style="padding:18px 0">No contacts match.</div>`;
        return;
      }
      el.classList.remove('loading');
      el.innerHTML = listCache.map(c => {
        const isSel = selectedEmail && c.email.toLowerCase() === selectedEmail.toLowerCase();
        return `<div class="prof-row" data-email="${esc(c.email)}"
          style="border-top:1px solid var(--line);padding:10px 0;cursor:pointer;${isSel ? 'background:#f7f2e8' : ''}">
          <div class="row" style="justify-content:space-between;align-items:baseline;gap:8px">
            <div style="font-weight:600">${esc(c.name || c.email)}</div>
            <span class="pill">${esc(c.type)}</span>
          </div>
          <div class="muted" style="font-size:12px">${esc(c.email)}</div>
          <div class="muted" style="font-size:11.5px;margin-top:3px">
            ${esc(c.company || '')}${c.role ? ` · ${esc(c.role)}` : ''}
          </div>
          <div class="row" style="gap:6px;margin-top:5px;align-items:center">
            <span class="pill" style="background:#eee5d4">${esc(c.lifecycle)}</span>
            <span class="muted" style="font-size:11px">last open ${fmtRelative(c.lastOpen)}</span>
          </div>
        </div>`;
      }).join('');

      el.querySelectorAll('.prof-row').forEach(row => {
        row.onclick = () => loadDetail(row.dataset.email);
      });
    }

    function loadList() {
      $('#prof-list').classList.add('loading');
      $('#prof-list').textContent = 'Loading contacts…';
      listIsSearch = false;
      return api('/list?limit=25').then(p => { setMock(p.mock); renderList(p); })
        .catch(e => { $('#prof-list').innerHTML = `<div class="muted">Error: ${esc(e.message)}</div>`; });
    }

    function runSearch(q) {
      listIsSearch = true;
      if (!q) return loadList();
      $('#prof-list').classList.add('loading');
      $('#prof-list').textContent = 'Searching…';
      return api(`/search?q=${encodeURIComponent(q)}`)
        .then(p => { setMock(p.mock); renderList(p); })
        .catch(e => { $('#prof-list').innerHTML = `<div class="muted">Error: ${esc(e.message)}</div>`; });
    }

    // ── detail rendering ───────────────────────────────────────────────────────
    function renderTimeline(events) {
      if (!events || !events.length) {
        return `<div class="muted" style="padding:14px 0">No email events recorded.</div>`;
      }
      // Limit to 60 newest so the card doesn't get unwieldy
      const visible = events.slice(0, 60);
      const iconFor = t => t === 'sent' ? '✉️' : t === 'open' ? '👁️' : t === 'click' ? '🔗' : '•';
      const colorFor = t => t === 'open' ? '#6f7a63' : t === 'click' ? '#8a5a44' : '#8a8275';
      const rows = visible.map(e => `
        <div class="row" style="gap:10px;align-items:baseline;border-top:1px solid var(--line);padding:8px 0">
          <span style="width:22px;text-align:center" title="${esc(e.type)}">${iconFor(e.type)}</span>
          <div style="flex:1;min-width:0">
            <div style="font-weight:600;font-size:13px;color:${colorFor(e.type)}">
              ${e.type === 'sent' ? 'Sent' : e.type === 'open' ? 'Opened' : 'Clicked'}
              <span style="color:var(--ink);font-weight:500">· ${esc(e.campaignName || '(campaign)')}</span>
            </div>
            <div class="muted" style="font-size:11.5px">
              ${esc(fmtDateTime(e.at))}${e.link ? ` · <code style="font-size:11px">${esc(e.link)}</code>` : ''}
              ${e.derived ? ' · <span title="Derived from CC aggregate fields — true per-event streams require ETL">derived</span>' : ''}
            </div>
          </div>
        </div>`).join('');
      return `<div>${rows}</div>
        ${events.length > visible.length
          ? `<div class="muted" style="font-size:12px;margin-top:8px">Showing ${visible.length} of ${events.length} events.</div>`
          : ''}`;
    }

    function lifecyclePill(stage, tone) {
      const bg = tone === 'hot' ? '#fde6d8' : tone === 'warm' ? '#fff1d2' :
                 tone === 'cold' ? '#e6e2da' : '#eee5d4';
      const fg = tone === 'hot' ? '#8a5a44' : tone === 'warm' ? '#7a5a2a' :
                 tone === 'cold' ? '#5a5347' : '#5a5347';
      return `<span class="pill" style="background:${bg};color:${fg}">${esc(stage)}</span>`;
    }

    function renderDetail(payload) {
      setMock(payload.mock);
      $('#prof-detail-empty').style.display = 'none';
      const c = payload.contact;
      const lc = payload.lifecycle || { stage: '—', tone: 'neutral' };
      const segs = payload.segments || [];
      const cx = payload.crossChannel || {};
      const lastVisit = cx.lastSiteVisit;
      const social = cx.social;
      const counts = payload.timelineCounts || { sent: 0, open: 0, click: 0 };
      const tags = (c.tags || []).map(t =>
        `<span class="pill" style="margin:0 4px 4px 0">${esc(t)}</span>`).join('') ||
        '<span class="muted" style="font-size:12px">no tags</span>';
      const lists = (c.lists || []).map(l =>
        `<span class="pill" style="margin:0 4px 4px 0;background:#eee5d4">${esc(l)}</span>`).join('') ||
        '<span class="muted" style="font-size:12px">no list memberships</span>';
      const segChips = segs.length
        ? segs.map(s => `<span class="pill" title="${esc(s.description)}" style="margin:0 4px 4px 0;background:#e6dec8">${esc(s.name)}</span>`).join('')
        : '<span class="muted" style="font-size:12px">Does not match any saved segment.</span>';

      const loc = c.location || {};
      const locStr = [loc.city, loc.region, loc.country].filter(Boolean).join(', ') || '—';

      const detail = $('#prof-detail');
      detail.style.display = '';
      detail.innerHTML = `
        <div class="row" style="justify-content:space-between;align-items:baseline;gap:12px">
          <div style="min-width:0">
            <h2 style="margin:0">${esc(c.name || c.email)}</h2>
            <div class="muted" style="font-size:12.5px">${esc(c.email)}</div>
          </div>
          <div class="row" style="gap:6px;align-items:center">
            ${lifecyclePill(lc.stage, lc.tone)}
            <span class="pill">${esc(c.type)}</span>
          </div>
        </div>

        <div class="grid" style="margin-top:14px;grid-template-columns:repeat(4,1fr);gap:10px">
          <div><label>Company</label><div style="font-weight:600">${esc(c.company || '—')}</div></div>
          <div><label>Role</label><div>${esc(c.role || '—')}</div></div>
          <div><label>Location</label><div>${esc(locStr)}</div></div>
          <div><label>Contact since</label><div>${esc(fmtDate(c.since))}</div></div>
        </div>

        <div class="grid" style="margin-top:14px;grid-template-columns:repeat(4,1fr);gap:10px">
          <div style="border:1px solid var(--line);border-radius:10px;padding:10px 12px">
            <div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.5px">Lifetime opens</div>
            <div style="font:600 24px/1 'Cormorant Garamond',Georgia,serif">${Number(c.opens || 0).toLocaleString()}</div>
          </div>
          <div style="border:1px solid var(--line);border-radius:10px;padding:10px 12px">
            <div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.5px">Lifetime clicks</div>
            <div style="font:600 24px/1 'Cormorant Garamond',Georgia,serif">${Number(c.clicks || 0).toLocaleString()}</div>
          </div>
          <div style="border:1px solid var(--line);border-radius:10px;padding:10px 12px">
            <div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.5px">Last open</div>
            <div style="font-weight:600">${esc(fmtRelative(c.lastOpen))}</div>
            <div class="muted" style="font-size:11px">${esc(fmtDate(c.lastOpen))}</div>
          </div>
          <div style="border:1px solid var(--line);border-radius:10px;padding:10px 12px">
            <div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.5px">Last click</div>
            <div style="font-weight:600">${esc(fmtRelative(c.lastClick))}</div>
            <div class="muted" style="font-size:11px">${esc(fmtDate(c.lastClick))}</div>
          </div>
        </div>

        <div class="grid" style="margin-top:18px;grid-template-columns:1fr 1fr;gap:16px;align-items:start">

          <div>
            <label style="margin-bottom:6px">List memberships</label>
            <div>${lists}</div>
            <label style="margin:14px 0 6px">Tags</label>
            <div>${tags}</div>
            <label style="margin:14px 0 6px">Segment memberships</label>
            <div>${segChips}</div>
          </div>

          <div>
            <label style="margin-bottom:6px">Cross-channel</label>
            <div style="border:1px solid var(--line);border-radius:10px;padding:10px 12px">
              <div class="row" style="justify-content:space-between;align-items:baseline">
                <div style="font-weight:600;font-size:13px">Last site visit</div>
                <span class="muted" style="font-size:11px">placeholder</span>
              </div>
              ${lastVisit ? `
                <div style="margin-top:4px;font-size:13px">${esc(lastVisit.pageTitle || lastVisit.page)}</div>
                <div class="muted" style="font-size:11.5px">${esc(fmtRelative(lastVisit.at))} · ${esc(fmtDate(lastVisit.at))} · source: ${esc(lastVisit.source || '—')}</div>
                <div class="muted" style="font-size:11px;margin-top:3px"><code>/${esc(lastVisit.page)}</code></div>
              ` : `<div class="muted" style="font-size:12px;margin-top:4px">No tracked site visits.</div>`}
            </div>
            <div style="border:1px solid var(--line);border-radius:10px;padding:10px 12px;margin-top:10px">
              <div class="row" style="justify-content:space-between;align-items:baseline">
                <div style="font-weight:600;font-size:13px">Social</div>
                <span class="muted" style="font-size:11px">placeholder</span>
              </div>
              ${social ? `
                <div style="margin-top:4px;font-size:13px">
                  <span class="pill" style="margin-right:6px">${esc(social.platform)}</span>
                  <code style="font-size:12px">${esc(social.handle)}</code>
                </div>
                <div class="muted" style="font-size:11.5px;margin-top:3px">${esc(social.action || 'last touch')} · ${esc(fmtRelative(social.lastTouch))}</div>
              ` : `<div class="muted" style="font-size:12px;margin-top:4px">No social signal on file.</div>`}
            </div>
            ${cx.note ? `<div class="muted" style="font-size:11.5px;margin-top:8px">${esc(cx.note)}</div>` : ''}
          </div>

        </div>

        <div style="margin-top:18px">
          <div class="row" style="justify-content:space-between;align-items:baseline">
            <label style="margin:0">Email engagement timeline</label>
            <span class="muted" style="font-size:11.5px">
              ${Number(counts.sent || 0)} sent · ${Number(counts.open || 0)} opens · ${Number(counts.click || 0)} clicks
            </span>
          </div>
          <div style="margin-top:6px">${renderTimeline(payload.timeline || [])}</div>
        </div>
      `;
    }

    function loadDetail(email) {
      selectedEmail = email;
      // re-style the list row selection
      root.querySelectorAll('.prof-row').forEach(r => {
        r.style.background = (r.dataset.email.toLowerCase() === email.toLowerCase()) ? '#f7f2e8' : '';
      });
      $('#prof-detail-empty').style.display = 'none';
      $('#prof-detail').style.display = '';
      $('#prof-detail').innerHTML = '<div class="loading" style="padding:30px 0">Loading profile…</div>';
      return api(`/contact?email=${encodeURIComponent(email)}`)
        .then(r => {
          if (r.error) {
            $('#prof-detail').innerHTML = `<div class="muted-banner">${esc(r.error)}</div>`;
            return;
          }
          renderDetail(r);
        })
        .catch(e => {
          $('#prof-detail').innerHTML = `<div class="muted-banner">Error: ${esc(e.message)}</div>`;
        });
    }

    // ── wire up ────────────────────────────────────────────────────────────────
    let searchTimer = null;
    $('#prof-q').addEventListener('input', (e) => {
      const q = e.target.value.trim();
      clearTimeout(searchTimer);
      searchTimer = setTimeout(() => runSearch(q), 220);
    });
    $('#prof-q').addEventListener('keydown', (e) => {
      if (e.key === 'Enter') {
        clearTimeout(searchTimer);
        runSearch(e.target.value.trim());
      }
    });
    $('#prof-list-refresh').addEventListener('click', () => {
      $('#prof-q').value = '';
      loadList();
    });

    loadList();
  },
};