← back to Rentv 2026

public/admin/pr-intelligence/linkedin.html

236 lines

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>CRE PR Intelligence — LinkedIn Network</title>
<link rel="stylesheet" href="/admin/pr-intelligence/pr-shared.css">
<style>
  .ln-steps{counter-reset:s;margin:0 0 18px;padding:0;list-style:none}
  .ln-steps li{position:relative;padding:10px 0 10px 40px;border-bottom:1px solid var(--line,#e4e7ea);font-size:13.5px;line-height:1.5}
  .ln-steps li:last-child{border-bottom:0}
  .ln-steps li::before{counter-increment:s;content:counter(s);position:absolute;left:0;top:9px;width:26px;height:26px;
    display:flex;align-items:center;justify-content:center;border-radius:999px;background:#0a6cff;color:#fff;font-weight:700;font-size:12px}
  .ln-steps a{font-weight:600}
  .ln-card{border:1px solid var(--line,#e4e7ea);border-radius:10px;padding:18px 20px;margin:0 0 18px;background:var(--card,#fff)}
  .ln-card h3{margin:0 0 6px;font-size:15px}
  .ln-note{font-size:12.5px;color:var(--muted,#5b636b);margin:8px 0 0}
  .ln-safe{display:inline-block;background:#e7f6ec;color:#1c7a3e;border-radius:6px;padding:2px 8px;font-size:11px;font-weight:700;letter-spacing:.03em}
  .ln-drop{border:2px dashed #b9c4d0;border-radius:10px;padding:26px;text-align:center;color:var(--muted,#5b636b);cursor:pointer;transition:border-color .15s,background .15s}
  .ln-drop.hot{border-color:#0a6cff;background:#f3f8ff}
  .ln-grid{width:100%;border-collapse:collapse;font-size:12.5px;margin-top:12px}
  .ln-grid th,.ln-grid td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line,#eee);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:220px}
  .ln-grid th{color:var(--muted,#5b636b);font-size:11px;text-transform:uppercase;letter-spacing:.04em}
  .ln-stat{display:inline-block;margin-right:16px;font-size:13px}
  .ln-stat b{font-size:18px;display:block}
  .warn{color:#b23a1e}
</style>
</head>
<body>
<script src="/admin/pr-intelligence/pr-shared.js"></script>
<script>
(async function () {
  const c = PR.frame('linkedin', 'LinkedIn Network',
    'Grow RENTV by importing Steve Boom’s own LinkedIn connections — the TOS-safe way. Boom exports his data from LinkedIn (no password ever leaves his account), you drop the CSV here, and every contact lands in People for ranked outreach.');

  // ── How-to (self-export) ──────────────────────────────────────────────────
  const how = document.createElement('div'); how.className = 'ln-card';
  how.innerHTML = `
    <h3>Get Steve Boom’s connections <span class="ln-safe">TOS-SAFE · NO PASSWORD</span></h3>
    <p class="ln-note">LinkedIn does not expose a follower/connection list to any API or scraper — the only complete, compliant source is the account owner’s own data export. It takes Boom ~2 minutes and LinkedIn emails him the file.</p>
    <ol class="ln-steps">
      <li>Signed in as Steve Boom, open <a href="https://www.linkedin.com/mypreferences/d/download-my-data" target="_blank" rel="noopener noreferrer">Settings → Data Privacy → Get a copy of your data</a>.</li>
      <li>Choose <b>“Want something in particular?”</b> and tick <b>Connections</b> (not the full archive — it’s faster).</li>
      <li>Click <b>Request archive</b> and confirm the LinkedIn password prompt. LinkedIn emails a download link, usually within a few minutes.</li>
      <li>Download <code>Connections.csv</code> from that email and drop it below. That’s the complete list of his 1st-degree network.</li>
    </ol>
    <p class="ln-note"><b>Followers vs. connections:</b> the export covers 1st-degree <i>connections</i> (mutual). Pure one-way <i>followers</i> aren’t individually listed by LinkedIn for anyone — if RENTV has a LinkedIn <b>Company Page</b>, its admin can see follower <i>analytics/demographics</i> under Page → Analytics → Followers, but not a per-person CSV. Connections are the actionable list.</p>`;
  c.appendChild(how);

  // ── Drop / upload ─────────────────────────────────────────────────────────
  const up = document.createElement('div'); up.className = 'ln-card';
  up.innerHTML = `
    <h3>Import the export</h3>
    <div class="ln-drop" id="drop">
      <div><b>Drop <code>Connections.csv</code> here</b> or click to choose</div>
      <div class="ln-note" id="fname">The LinkedIn “Notes:” preamble and First/Last split are handled automatically.</div>
    </div>
    <input type="file" id="file" accept=".csv,text/csv" hidden>
    <div id="parsed"></div>`;
  c.appendChild(up);

  const runCard = document.createElement('div'); runCard.className = 'ln-card'; runCard.style.display = 'none';
  runCard.innerHTML = `
    <h3>Preview &amp; import</h3>
    <div id="preview"></div>
    <div style="margin-top:14px">
      <button class="btn" id="btn-preview">🔍 Preview (dry run)</button>
      <button class="btn" id="btn-import" disabled>⬇ Import into People</button>
      <span class="ln-note" id="run-note" style="margin-left:10px"></span>
    </div>`;
  c.appendChild(runCard);

  // ── Recent batches ────────────────────────────────────────────────────────
  const batchCard = document.createElement('div'); batchCard.className = 'ln-card';
  batchCard.innerHTML = `<h3>Recent imports</h3><div id="batches"></div>`;
  c.appendChild(batchCard);

  // ── CSV helpers (client-side) ─────────────────────────────────────────────
  // Minimal RFC-4180-ish parser: quoted fields, embedded commas/newlines/quotes.
  function parseCSV(text) {
    const rows = []; let row = [], field = '', i = 0, q = false;
    text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
    while (i < text.length) {
      const ch = text[i];
      if (q) {
        if (ch === '"') { if (text[i + 1] === '"') { field += '"'; i += 2; continue; } q = false; i++; continue; }
        field += ch; i++; continue;
      }
      if (ch === '"') { q = true; i++; continue; }
      if (ch === ',') { row.push(field); field = ''; i++; continue; }
      if (ch === '\n') { row.push(field); rows.push(row); row = []; field = ''; i++; continue; }
      field += ch; i++;
    }
    if (field.length || row.length) { row.push(field); rows.push(row); }
    return rows.filter((r) => r.length && !(r.length === 1 && r[0].trim() === ''));
  }
  function csvField(v) { v = String(v == null ? '' : v); return /[",\n]/.test(v) ? '"' + v.replace(/"/g, '""') + '"' : v; }
  function toCSV(rows, headers) { return [headers.join(',')].concat(rows.map((r) => headers.map((h) => csvField(r[h])).join(','))).join('\n'); }

  const TARGET = ['full_name', 'linkedin_url', 'organization_name', 'exact_title', 'public_work_email'];
  const COLMAP = Object.fromEntries(TARGET.map((t) => [t, t]));

  // Turn a raw LinkedIn Connections.csv into a normalized people_csv string.
  function normalizeLinkedIn(raw) {
    const grid = parseCSV(raw);
    // LinkedIn prepends a "Notes:" preamble; the real header is the first row that
    // contains a name column. Find it and slice from there.
    const headerIdx = grid.findIndex((r) => {
      const low = r.map((x) => x.trim().toLowerCase());
      return low.includes('first name') || low.includes('full name') || (low.includes('url') && low.includes('company'));
    });
    if (headerIdx < 0) return { error: 'Could not find a LinkedIn header row (expected columns like First Name, Last Name, URL, Company, Position). Is this the Connections.csv from the LinkedIn data export?' };
    const H = grid[headerIdx].map((x) => x.trim());
    const col = (name) => H.findIndex((h) => h.toLowerCase() === name);
    const iFirst = col('first name'), iLast = col('last name'), iFull = col('full name'),
          iUrl = col('url'), iEmail = col('email address'), iCompany = col('company'), iPos = col('position');
    const out = [];
    for (const r of grid.slice(headerIdx + 1)) {
      const full = iFull >= 0 ? (r[iFull] || '').trim()
        : [iFirst >= 0 ? r[iFirst] : '', iLast >= 0 ? r[iLast] : ''].map((s) => (s || '').trim()).filter(Boolean).join(' ');
      if (!full) continue;
      out.push({
        full_name: full,
        linkedin_url: iUrl >= 0 ? (r[iUrl] || '').trim() : '',
        organization_name: iCompany >= 0 ? (r[iCompany] || '').trim() : '',
        exact_title: iPos >= 0 ? (r[iPos] || '').trim() : '',
        public_work_email: iEmail >= 0 ? (r[iEmail] || '').trim() : '',
      });
    }
    if (!out.length) return { error: 'Header found but no contact rows parsed.' };
    return { rows: out, csv: toCSV(out, TARGET) };
  }

  // ── Wire upload ───────────────────────────────────────────────────────────
  let normCsv = null, normRows = null, filename = null;
  const drop = document.getElementById('drop'), fileInput = document.getElementById('file');
  const parsedBox = document.getElementById('parsed'), previewBox = document.getElementById('preview');
  const btnPreview = document.getElementById('btn-preview'), btnImport = document.getElementById('btn-import');

  function handleFile(f) {
    filename = f.name;
    const rd = new FileReader();
    rd.onload = () => {
      const res = normalizeLinkedIn(String(rd.result || ''));
      if (res.error) { normCsv = null; parsedBox.innerHTML = `<p class="warn">⚠ ${PR.esc(res.error)}</p>`; runCard.style.display = 'none'; return; }
      normCsv = res.csv; normRows = res.rows;
      document.getElementById('fname').innerHTML = `📄 <b>${PR.esc(f.name)}</b> — ${res.rows.length.toLocaleString()} connections parsed`;
      const sample = res.rows.slice(0, 8);
      parsedBox.innerHTML = `
        <table class="ln-grid"><thead><tr><th>Name</th><th>Company</th><th>Title</th><th>Email</th><th>LinkedIn</th></tr></thead>
        <tbody>${sample.map((r) => `<tr>
          <td>${PR.esc(r.full_name)}</td><td>${PR.esc(r.organization_name)}</td><td>${PR.esc(r.exact_title)}</td>
          <td>${PR.esc(r.public_work_email) || '—'}</td>
          <td>${r.linkedin_url ? `<a href="${PR.esc(r.linkedin_url)}" target="_blank" rel="noopener noreferrer">profile</a>` : '—'}</td>
        </tr>`).join('')}</tbody></table>
        <p class="ln-note">Showing ${sample.length} of ${res.rows.length.toLocaleString()}. Emails only appear for connections who chose to share them.</p>`;
      runCard.style.display = ''; btnImport.disabled = true;
      previewBox.innerHTML = ''; document.getElementById('run-note').textContent = '';
    };
    rd.readAsText(f);
  }

  drop.addEventListener('click', () => fileInput.click());
  fileInput.addEventListener('change', () => { if (fileInput.files[0]) handleFile(fileInput.files[0]); });
  ['dragover', 'dragenter'].forEach((ev) => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.add('hot'); }));
  ['dragleave', 'drop'].forEach((ev) => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.remove('hot'); }));
  drop.addEventListener('drop', (e) => { const f = e.dataTransfer.files[0]; if (f) handleFile(f); });

  // ── Preview (dry run through the standard importer) ───────────────────────
  btnPreview.addEventListener('click', () => PR.guard(async () => {
    if (!normCsv) { PR.toast('Upload a Connections.csv first', true); return; }
    btnPreview.disabled = true; document.getElementById('run-note').textContent = 'Running dry-run…';
    try {
      const p = await PR.api('/import/run', { method: 'POST', body: {
        kind: 'people_csv', csv: normCsv, columnMap: COLMAP, dry_run: true, filename,
        source_note: 'Steve Boom LinkedIn network (self-exported connections)',
      } });
      const r = p.report;
      previewBox.innerHTML = `
        <div style="margin:8px 0">
          <span class="ln-stat"><b>${(r.created.length).toLocaleString()}</b>would be added</span>
          <span class="ln-stat"><b>${(r.duplicates.length).toLocaleString()}</b>already in CRM</span>
          <span class="ln-stat"><b>${(r.skipped.length + r.errors.length).toLocaleString()}</b>skipped</span>
        </div>
        <p class="ln-note">Dry run only — nothing was written (batch #${p.batch_id} logged as a dry run). Click <b>Import</b> to write the ${(r.created.length).toLocaleString()} new contacts.</p>`;
      btnImport.disabled = r.created.length === 0;
      document.getElementById('run-note').textContent = '';
    } finally { btnPreview.disabled = false; }
  }));

  // ── Import (real write) ───────────────────────────────────────────────────
  btnImport.addEventListener('click', () => PR.guard(async () => {
    if (!normCsv) return;
    if (!confirm('Import these connections into People? New organizations will be created for unknown companies. This is reversible from Recent imports.')) return;
    btnImport.disabled = true; document.getElementById('run-note').textContent = 'Importing…';
    try {
      const p = await PR.api('/import/run', { method: 'POST', body: {
        kind: 'people_csv', csv: normCsv, columnMap: COLMAP, dry_run: false, filename,
        source_note: 'Steve Boom LinkedIn network (self-exported connections)',
      } });
      const r = p.report;
      PR.toast(`Imported ${r.created.length} contacts (batch #${p.batch_id})`);
      previewBox.innerHTML = `<p>✅ Added <b>${r.created.length.toLocaleString()}</b> · duplicates ${r.duplicates.length.toLocaleString()} · skipped ${(r.skipped.length + r.errors.length).toLocaleString()}.
        <a href="/admin/pr-intelligence/people">Open People →</a></p>`;
      loadBatches();
    } finally { btnImport.disabled = false; document.getElementById('run-note').textContent = ''; }
  }, null));

  // ── Recent import batches (with reverse) ──────────────────────────────────
  async function loadBatches() {
    try {
      const j = await PR.api('/import/batches');
      const rows = (j.rows || []).filter((b) => (b.kind === 'people_csv' || b.kind === 'contact_list'));
      const box = document.getElementById('batches');
      if (!rows.length) { box.innerHTML = '<div class="empty">No imports yet</div>'; return; }
      box.innerHTML = `<table class="ln-grid"><thead><tr>
        <th>#</th><th>File</th><th>When</th><th>Added</th><th>Dupes</th><th>Dry?</th><th></th></tr></thead><tbody>
        ${rows.map((b) => `<tr>
          <td>${b.id}</td><td>${PR.esc(b.filename || '—')}</td><td>${PR.date(b.created_at)}</td>
          <td>${(b.created_count || 0).toLocaleString()}</td><td>${(b.duplicate_count || 0).toLocaleString()}</td>
          <td>${b.dry_run ? 'dry' : '—'}</td>
          <td>${(!b.dry_run && (b.created_count || 0) > 0) ? `<a href="#" data-reverse="${b.id}">reverse</a>` : ''}</td>
        </tr>`).join('')}</tbody></table>`;
      box.querySelectorAll('[data-reverse]').forEach((a) => a.addEventListener('click', (e) => {
        e.preventDefault();
        const id = a.getAttribute('data-reverse');
        if (!confirm(`Reverse import batch #${id}? This archives the contacts it created.`)) return;
        PR.guard(async () => { await PR.api('/import/' + id + '/reverse', { method: 'POST' }); loadBatches(); }, 'Batch reversed');
      }));
    } catch (e) { document.getElementById('batches').innerHTML = `<p class="warn">${PR.esc(e.message)}</p>`; }
  }
  loadBatches();
})();
</script>
</body>
</html>