[object Object]

← back to Rentv 2026

CRM: add LinkedIn Posting section (setup + composer preview, gated non-live)

7ac88855f9d5e8e654c39fdfd2d4224eb9a789a2 · 2026-08-04 15:46:04 -0700 · steve

Files touched

Diff

commit 7ac88855f9d5e8e654c39fdfd2d4224eb9a789a2
Author: steve <steve@designerwallcoverings.com>
Date:   Tue Aug 4 15:46:04 2026 -0700

    CRM: add LinkedIn Posting section (setup + composer preview, gated non-live)
---
 public/admin/pr-intelligence/linkedin-posting.html | 126 +++++++++++++++++++++
 public/admin/pr-intelligence/pr-shared.js          |   1 +
 2 files changed, 127 insertions(+)

diff --git a/public/admin/pr-intelligence/linkedin-posting.html b/public/admin/pr-intelligence/linkedin-posting.html
new file mode 100644
index 00000000..c89cc502
--- /dev/null
+++ b/public/admin/pr-intelligence/linkedin-posting.html
@@ -0,0 +1,126 @@
+<!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 Posting</title>
+<link rel="stylesheet" href="/admin/pr-intelligence/pr-shared.css">
+<style>
+  .lp-banner{display:flex;align-items:center;gap:10px;border-radius:10px;padding:12px 16px;margin:0 0 18px;
+    background:#fff6e5;border:1px solid #f0d38a;color:#8a5a00;font-size:13.5px;font-weight:600}
+  .lp-banner .dot{width:9px;height:9px;border-radius:999px;background:#e0a020;flex:0 0 auto}
+  .lp-card{border:1px solid var(--line,#e4e7ea);border-radius:10px;padding:18px 20px;margin:0 0 18px;background:var(--card,#fff)}
+  .lp-card h3{margin:0 0 6px;font-size:15px}
+  .lp-note{font-size:12.5px;color:var(--muted,#5b636b);margin:8px 0 0}
+  .lp-check{list-style:none;margin:6px 0 0;padding:0}
+  .lp-check li{position:relative;padding:9px 0 9px 30px;border-bottom:1px solid var(--line,#eee);font-size:13.5px;line-height:1.5}
+  .lp-check li:last-child{border-bottom:0}
+  .lp-check li::before{position:absolute;left:0;top:9px;font-size:15px}
+  .lp-check li.done::before{content:"✅"} .lp-check li.todo::before{content:"⬜"}
+  .lp-check code{background:var(--wash,#f5f6f8);padding:1px 5px;border-radius:4px;font-size:12px}
+  .lp-two{display:grid;grid-template-columns:1fr 1fr;gap:20px} @media(max-width:820px){.lp-two{grid-template-columns:1fr}}
+  .lp-field{margin:0 0 12px} .lp-field label{display:block;font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--muted,#5b636b);margin:0 0 4px}
+  .lp-field select,.lp-field textarea{width:100%;box-sizing:border-box;font:inherit;font-size:13px;padding:8px 10px;border:1px solid var(--line,#d7dde3);border-radius:8px;background:#fff}
+  .lp-field textarea{min-height:120px;resize:vertical}
+  /* LinkedIn card preview */
+  .li-card{border:1px solid #d7dde3;border-radius:10px;overflow:hidden;background:#fff;max-width:100%}
+  .li-head{display:flex;gap:10px;padding:12px 14px 0}
+  .li-av{width:44px;height:44px;border-radius:999px;background:#0a66c2;color:#fff;display:flex;align-items:center;justify-content:center;font-weight:800;flex:0 0 auto}
+  .li-who b{font-size:13.5px} .li-who span{display:block;font-size:11.5px;color:#5b636b}
+  .li-body{padding:10px 14px 12px;font-size:13.5px;white-space:pre-wrap;line-height:1.5}
+  .li-link{border-top:1px solid #eef1f4;background:#f3f6f8}
+  .li-link img{width:100%;display:block;aspect-ratio:1.91/1;object-fit:cover;background:#e6ebf0}
+  .li-link .m{padding:10px 14px;font-size:12.5px} .li-link .m b{display:block;font-size:13px;color:#111}
+  .li-link .m .u{color:#5b636b;font-size:11.5px;margin-top:2px}
+  .disabled-wrap{position:relative}
+  .disabled-wrap .btn[disabled]{opacity:.55;cursor:not-allowed}
+  .lp-gate{font-size:12px;color:#8a5a00;margin-top:8px}
+</style>
+</head>
+<body>
+<script src="/admin/pr-intelligence/pr-shared.js"></script>
+<script>
+(async function () {
+  const c = PR.frame('linkedin-posting', 'LinkedIn Posting',
+    'Publish RENTV articles to Steve Bloom’s LinkedIn network — the official, TOS-safe way (OAuth, no password). This section is SET UP but NOT LIVE: nothing can post until the LinkedIn app is connected and Steve greenlights it.');
+
+  c.insertAdjacentHTML('beforeend',
+    `<div class="lp-banner"><span class="dot"></span>Status: <b style="margin:0 4px">Parked — pending green light.</b> Posting is disabled. This is the setup + preview surface only; no post can be sent yet.</div>`);
+
+  // ── Setup checklist ────────────────────────────────────────────────────────
+  const setup = document.createElement('div'); setup.className = 'lp-card';
+  setup.innerHTML = `
+    <h3>Setup checklist</h3>
+    <p class="lp-note">One-time setup. Steps 2–4 unlock the composer below. Everything here is TOS-compliant: LinkedIn’s official OAuth + Posts API — we store a revocable token, never Bloom’s password.</p>
+    <ul class="lp-check">
+      <li class="done"><b>RENTV LinkedIn Company Page</b> — confirmed to exist. LinkedIn requires an app to be linked to a Page, so this prerequisite is met.</li>
+      <li class="todo"><b>Create a LinkedIn Developer App</b> linked to the RENTV Page → at <a href="https://www.linkedin.com/developers/apps" target="_blank" rel="noopener noreferrer">linkedin.com/developers</a>. Add products <em>“Sign In with LinkedIn using OpenID Connect”</em> + <em>“Share on LinkedIn”</em>. Whitelist redirect URI <code>https://rentv.agentabrams.com/api/pr/linkedin/callback</code>.</li>
+      <li class="todo"><b>Add the app credentials</b> — hand Steve the <code>client_id</code> + <code>client_secret</code>; they’re routed via the secrets manager (stored server-side, never in the browser).</li>
+      <li class="todo"><b>Connect &amp; authorize</b> — Bloom clicks <em>Connect LinkedIn</em> once and approves the scopes. That mints the revocable access token used to post.</li>
+    </ul>
+    <div style="margin-top:14px" class="disabled-wrap">
+      <button class="btn" id="connect" disabled title="Available once the LinkedIn app is created and credentials are added">🔗 Connect LinkedIn</button>
+      <div class="lp-gate">⚠ Disabled until the Developer App is created and Steve greenlights go-live.</div>
+    </div>`;
+  c.appendChild(setup);
+
+  // ── Composer preview (non-live) ────────────────────────────────────────────
+  const comp = document.createElement('div'); comp.className = 'lp-card';
+  comp.innerHTML = `
+    <h3>Composer — preview</h3>
+    <p class="lp-note">This is exactly what a post will look like. It pulls real RENTV articles so you can see the flow, but the <b>Post</b> button is inert until connected.</p>
+    <div class="lp-two" style="margin-top:14px">
+      <div>
+        <div class="lp-field"><label>RENTV article</label><select id="art"></select></div>
+        <div class="lp-field"><label>Caption</label><textarea id="cap"></textarea></div>
+        <div class="disabled-wrap">
+          <button class="btn" id="post" disabled title="Connect LinkedIn to enable posting">📣 Post to LinkedIn</button>
+          <button class="btn" id="queue" disabled title="Connect LinkedIn to enable queuing">🗓️ Add to queue</button>
+          <div class="lp-gate">⚠ Draft-and-approve by design — nothing auto-posts. Both actions stay disabled until go-live.</div>
+        </div>
+      </div>
+      <div>
+        <label style="display:block;font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--muted,#5b636b);margin:0 0 4px">Preview</label>
+        <div class="li-card" id="preview"></div>
+      </div>
+    </div>`;
+  c.appendChild(comp);
+
+  // ── Wire the (read-only) preview ───────────────────────────────────────────
+  const origin = location.origin; // NEVER rentv.com — always the RENTV site's own article URL
+  let articles = [];
+  try {
+    const j = await fetch('/api/news').then((r) => r.json());
+    articles = (j.items || j || []).slice(0, 30).map((a) => ({
+      id: a.id, title: a.title, cat: a.cat || '', image: a.image || '',
+      url: origin + '/article?id=' + encodeURIComponent(a.id), // site article page, not rentv.com
+    }));
+  } catch (e) { /* preview degrades gracefully */ }
+
+  const sel = document.getElementById('art'), cap = document.getElementById('cap'), preview = document.getElementById('preview');
+  if (!articles.length) { articles = [{ id: '0', title: 'RENTV article headline', cat: 'Multifamily', image: '', url: origin + '/article?id=0' }]; }
+  sel.innerHTML = articles.map((a, i) => `<option value="${i}">${PR.esc(a.title.slice(0, 80))}</option>`).join('');
+
+  function caption(a) {
+    const tag = (a.cat || '').replace(/[^a-z0-9]/gi, '');
+    return `🏢 ${a.title}\n\nThe latest in commercial real estate across the Western U.S. — read it on RENTV.\n\n#CommercialRealEstate #CRE${tag ? ' #' + tag : ''}`;
+  }
+  function render() {
+    const a = articles[+sel.value] || articles[0];
+    if (document.activeElement !== cap) cap.value = caption(a);
+    const img = a.image ? (a.image.startsWith('http') ? a.image : origin + a.image) : '';
+    preview.innerHTML = `
+      <div class="li-head"><div class="li-av">SB</div>
+        <div class="li-who"><b>Steve Bloom</b><span>RENTV — Commercial Real Estate News · Now</span></div></div>
+      <div class="li-body">${PR.esc(cap.value)}</div>
+      <div class="li-link">
+        ${img ? `<img src="${PR.esc(img)}" alt="" onerror="this.style.display='none'">` : ''}
+        <div class="m"><b>${PR.esc(a.title)}</b><div class="u">${PR.esc(a.url)}</div></div>
+      </div>`;
+  }
+  sel.addEventListener('change', () => { cap.value = ''; render(); });
+  cap.addEventListener('input', render);
+  render();
+})();
+</script>
+</body>
+</html>
diff --git a/public/admin/pr-intelligence/pr-shared.js b/public/admin/pr-intelligence/pr-shared.js
index a10cfab1..969720d7 100644
--- a/public/admin/pr-intelligence/pr-shared.js
+++ b/public/admin/pr-intelligence/pr-shared.js
@@ -8,6 +8,7 @@
     ['organizations', '🏢', 'Organizations'],
     ['people', '👤', 'People'],
     ['linkedin', '🔗', 'LinkedIn Network'],
+    ['linkedin-posting', '📣', 'LinkedIn Posting'],
     ['review', '✅', 'Review Queue'],
     ['__sep1', '', 'Outreach'],
     ['campaigns', '📮', 'Campaigns'],

← ad9276b6 rentv: add Closings link to site-wide nav across 22 public p  ·  back to Rentv 2026  ·  chore: v0.18.0 — Property Closings feature (session close) 2cef1f2a →