← back to Marketing Command Center

public/panels/linkedin.js

192 lines

/* global window, document, navigator, localStorage */
window.MCC_PANELS = window.MCC_PANELS || {};
window.MCC_PANELS['linkedin'] = {
  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 => (await fetch(ORIGIN + u, { credentials: 'same-origin' })).json();
    const msg = (t, err) => { $('#li-msg').textContent = t || ''; $('#li-msg').style.color = err ? '#c0563f' : 'var(--mut)'; };
    const fmtWhen = iso => { const d = new Date(iso); return isNaN(d) ? '' : d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); };
    const deWP = s => String(s || '').replace(/\bwallpapers\b/gi, 'wallcoverings').replace(/\bwallpaper\b/gi, 'wallcovering');

    let TEMPLATES = [];

    async function loadConn() {
      try {
        const c = await jget('/api/linkedin/connection');
        $('#li-banner').innerHTML = c.configured
          ? `Connected · posting to <b>${esc(c.surface)}</b> — posts still require confirm + approve.`
          : 'Draft-only mode — compose here, then Copy + Open LinkedIn to post manually. Live API posting is Phase 2 (needs an approved LinkedIn app + token).';
      } catch { $('#li-banner').textContent = 'Could not load status.'; }
    }
    async function loadTemplates() {
      try { TEMPLATES = (await jget('/api/linkedin/templates')).templates || []; } catch { TEMPLATES = []; }
      $('#li-tpl').innerHTML = '<option value="">— blank —</option>' + TEMPLATES.map(t => `<option value="${esc(t.id)}">${esc(t.name)}</option>`).join('');
    }
    $('#li-tpl').addEventListener('change', e => {
      const t = TEMPLATES.find(x => x.id === e.target.value);
      if (t) { $('#li-text').value = t.body; updateCount(); }
    });

    // char counter + wallpaper-word guard
    function updateCount() {
      const v = $('#li-text').value;
      const n = v.length;
      let note = `${n} chars`;
      if (n < 1300) note += ' · aim 1,300–1,900';
      else if (n > 1900) note += ' · a touch long';
      else note += ' · ✓ sweet spot';
      if (/\bwallpaper/i.test(v)) note += ' · ⚠ “wallpaper” → wallcovering on save';
      $('#li-count').textContent = note;
    }
    $('#li-text').addEventListener('input', updateCount);

    async function loadDrafts() {
      let drafts = [];
      try { drafts = (await jget('/api/linkedin/drafts')).drafts || []; } catch { /* */ }
      $('#li-dcount').textContent = drafts.length;
      $('#li-drafts').innerHTML = drafts.length ? drafts.map(d => `
        <div class="card" style="margin:0 0 10px;padding:12px 14px;">
          <div style="white-space:pre-wrap;font-size:13px;line-height:1.4;max-height:7.5em;overflow:hidden;">${esc(d.text)}</div>
          <div style="display:flex;align-items:center;gap:8px;margin-top:8px;flex-wrap:wrap;">
            <span class="pill" style="font-size:9.5px;">${d.chars} chars</span>
            <span class="when" title="${esc(d.created_at)}" style="font-size:10.5px;color:var(--mut);">🕓 ${esc(fmtWhen(d.created_at))}</span>
            <span style="flex:1;"></span>
            <button class="btn ghost" data-load="${esc(d.id)}" style="font-size:11px;padding:5px 9px;">Edit</button>
            <button class="btn ghost" data-copy="${esc(d.id)}" style="font-size:11px;padding:5px 9px;">Copy</button>
            <button class="btn ghost" data-del="${esc(d.id)}" style="font-size:11px;padding:5px 9px;color:#c0563f;">✕</button>
          </div>
        </div>`).join('') : '<div class="muted">No drafts yet.</div>';
      const byId = Object.fromEntries(drafts.map(d => [d.id, d]));
      $('#li-drafts').querySelectorAll('[data-load]').forEach(b => b.onclick = () => { $('#li-text').value = byId[b.dataset.load].text; updateCount(); window.scrollTo(0, 0); });
      $('#li-drafts').querySelectorAll('[data-copy]').forEach(b => b.onclick = () => copy(byId[b.dataset.copy].text));
      $('#li-drafts').querySelectorAll('[data-del]').forEach(b => b.onclick = async () => {
        await fetch(ORIGIN + '/api/linkedin/draft/' + encodeURIComponent(b.dataset.del), { method: 'DELETE', credentials: 'same-origin' });
        loadDrafts();
      });
    }

    async function copy(t) { try { await navigator.clipboard.writeText(t); msg('Copied to clipboard.'); } catch { msg('Select and ⌘C: ' + t.slice(0, 40)); } }

    $('#li-save').onclick = async () => {
      const text = $('#li-text').value.trim();
      if (!text) { msg('Write something first.', true); return; }
      const hashtags = $('#li-tags').value.split(/[\s,]+/).filter(Boolean);
      const r = await fetch(ORIGIN + '/api/linkedin/draft', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ text, hashtags }) });
      const d = await r.json();
      if (d.error) { msg('✗ ' + d.error, true); return; }
      msg('Draft saved.'); $('#li-text').value = deWP(text); updateCount(); loadDrafts();
    };
    $('#li-copy').onclick = () => { const t = deWP($('#li-text').value.trim()); if (!t) return msg('Nothing to copy.', true); copy(t); };
    $('#li-copytags').onclick = () => { const t = $('#li-tags').value.trim(); if (!t) return msg('No hashtags.', true); copy(t); };
    $('#li-open').onclick = () => {
      const t = deWP($('#li-text').value.trim());
      if (t) copy(t);
      window.open('https://www.linkedin.com/feed/?shareActive=true', '_blank', 'noopener');
      msg('Text copied — paste it into the LinkedIn composer that just opened.');
    };
    $('#li-publish').onclick = async () => {
      const text = deWP($('#li-text').value.trim());
      if (!text) { msg('Write something first.', true); return; }
      if (!confirm('Publish to LinkedIn via the API?\n\nFires only if LinkedIn is connected; otherwise it stages. Proceed?')) return;
      msg('Publishing…');
      const r = await fetch(ORIGIN + '/api/linkedin/publish', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ text, confirm: true, approved: true }) });
      const d = await r.json();
      msg(d.error ? '✗ ' + d.error : (d.posted ? '✓ Posted to LinkedIn.' : (d.message || 'Staged.')), !!d.error);
    };

    // ── Mode toggle: Compose ⇄ Follow list ──────────────────────────────────
    function setMode(m) {
      const follow = m === 'follow';
      $('#li-mode-compose').hidden = follow;
      $('#li-mode-follow').hidden = !follow;
      $('#li-mode-compose-btn').className = 'btn ' + (follow ? 'ghost' : 'gold');
      $('#li-mode-follow-btn').className = 'btn ' + (follow ? 'gold' : 'ghost');
      try { localStorage.setItem('mcc_li_mode', m); } catch {}
      if (follow) initFollowList();
    }
    $('#li-mode-compose-btn').onclick = () => setMode('compose');
    $('#li-mode-follow-btn').onclick = () => setMode('follow');

    // ── Follow list (from public/data/linkedin-followlist.json) ─────────────
    const FL_KEY = 'mcc_li_followed';           // localStorage Set of followed hrefs
    const flDone = () => { try { return new Set(JSON.parse(localStorage.getItem(FL_KEY) || '[]')); } catch { return new Set(); } };
    const flSave = set => { try { localStorage.setItem(FL_KEY, JSON.stringify([...set])); } catch {} };
    let FL = null, flLoaded = false;

    async function initFollowList() {
      if (flLoaded) return; flLoaded = true;
      try { FL = await jget('/data/linkedin-followlist.json'); }
      catch { $('#li-fl-sections').innerHTML = '<div class="card muted">Could not load the follow list.</div>'; return; }
      $('#li-fl-guidance').textContent = FL.guidance || '';
      $('#li-fl-updated').textContent = FL.updated ? `Source: ${esc(FL.source || 'li-clicklist')} · updated ${esc(FL.updated)}` : '';
      $('#li-fl-searches').innerHTML = (FL.searches || []).map(g =>
        `<div style="margin-bottom:10px;"><div class="muted" style="font-size:11.5px;margin-bottom:4px;">${esc(g.group)}</div>` +
        g.links.map(l => `<a class="btn ghost" style="font-size:11px;padding:5px 10px;margin:0 6px 6px 0;display:inline-block;" target="_blank" rel="noopener" href="${esc(l.href)}">${esc(l.label)} ↗</a>`).join('') +
        `</div>`).join('');
      renderFollow();
      $('#li-fl-search').oninput = renderFollow;
      $('#li-fl-reset').onclick = () => { if (confirm('Clear all followed checkmarks on this device?')) { flSave(new Set()); renderFollow(); } };
    }

    function renderFollow() {
      const done = flDone();
      const q = ($('#li-fl-search').value || '').trim().toLowerCase();
      const CAP = 300;   // big sections (e.g. 2.4k brokers) render capped; filter to see the rest
      const html = (FL.sections || []).map(sec => {
        const secDone = sec.people.filter(p => done.has(p.href)).length;
        const matches = sec.people.filter(p => !q || p.name.toLowerCase().includes(q) || (p.sub || '').toLowerCase().includes(q));
        if (!matches.length) return '';
        const shown = matches.slice(0, CAP);
        const more = matches.length - shown.length;
        const rowHTML = shown.map(p => {
          const on = done.has(p.href);
          const badge = p.kind === 'salesnav' ? '<span class="pill" style="font-size:9px;">Sales Nav</span>'
            : p.kind === 'search' ? '<span class="pill" style="font-size:9px;">name search</span>' : '';
          const sub = p.sub ? `<span class="muted" style="font-size:11px;">${esc(p.sub)}</span>` : '';
          return `<label class="li-fl-row" style="display:flex;align-items:center;gap:9px;padding:5px 0;border-bottom:1px solid var(--line,#eee);cursor:pointer;${on ? 'opacity:.5;' : ''}">
              <input type="checkbox" data-href="${esc(p.href)}" ${on ? 'checked' : ''} style="width:16px;height:16px;flex:none;">
              <span style="flex:1;display:flex;flex-direction:column;line-height:1.25;min-width:0;">
                <a href="${esc(p.href)}" target="_blank" rel="noopener" style="${on ? 'text-decoration:line-through;' : ''}" onclick="event.stopPropagation()">${esc(p.name)} ↗</a>
                ${sub}
              </span>
              ${badge}
            </label>`;
        }).join('');
        const moreNote = more > 0 ? `<div class="muted" style="font-size:11px;margin-top:8px;">Showing ${shown.length} of ${matches.length}${q ? ' matches' : ''} — type a name in the filter to narrow.</div>` : '';
        return `<div class="card">
          <h2 style="font-size:14px;">${esc(sec.title)} <span class="pill" data-secpill="${esc(sec.id)}">${secDone}/${sec.people.length}</span></h2>
          <div class="muted" style="font-size:11.5px;margin-bottom:10px;">${esc(sec.note || '')}</div>
          <div>${rowHTML}</div>${moreNote}
        </div>`;
      }).join('');
      $('#li-fl-sections').innerHTML = html || '<div class="card muted">No matches.</div>';
      updateFollowStats(done);
      $('#li-fl-sections').querySelectorAll('input[type=checkbox]').forEach(cb => cb.onchange = () => {
        const set = flDone();
        cb.checked ? set.add(cb.dataset.href) : set.delete(cb.dataset.href);
        flSave(set);
        const row = cb.closest('.li-fl-row');   // in-place update — no full rebuild (2.6k rows)
        if (row) { row.style.opacity = cb.checked ? '.5' : ''; const a = row.querySelector('a'); if (a) a.style.textDecoration = cb.checked ? 'line-through' : ''; }
        updateFollowStats(set);
      });
    }

    function updateFollowStats(done) {
      let total = 0, doneCount = 0;
      (FL.sections || []).forEach(sec => {
        let sd = 0; sec.people.forEach(p => { if (done.has(p.href)) sd++; });
        total += sec.people.length; doneCount += sd;
        const pill = root.querySelector(`[data-secpill="${sec.id}"]`); if (pill) pill.textContent = `${sd}/${sec.people.length}`;
      });
      $('#li-fl-progress').textContent = `${doneCount} / ${total} followed`;
      $('#li-fl-bar').style.width = total ? (100 * doneCount / total).toFixed(1) + '%' : '0';
    }

    await loadConn(); await loadTemplates(); await loadDrafts(); updateCount();
    // Restore last mode (default compose).
    setMode((() => { try { return localStorage.getItem('mcc_li_mode') || 'compose'; } catch { return 'compose'; } })());
  },
};