[object Object]

← back to Marketing Command Center

LinkedIn module (DTD verdict A): draft-first composer w/ DW B2B templates + char counter + wallpaper word-ban + copy/deep-link manual post + saved drafts; gated official-API publish (never auto-fires, Phase 2 token) — nav under Social Media

923cb9c4ae395f437b97da89c91495602d15665b · 2026-06-13 09:38:29 -0700 · Steve Abrams

Files touched

Diff

commit 923cb9c4ae395f437b97da89c91495602d15665b
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Jun 13 09:38:29 2026 -0700

    LinkedIn module (DTD verdict A): draft-first composer w/ DW B2B templates + char counter + wallpaper word-ban + copy/deep-link manual post + saved drafts; gated official-API publish (never auto-fires, Phase 2 token) — nav under Social Media
---
 modules/linkedin/index.js   |  93 ++++++++++++++++++++++++++++++++++++++++
 modules/registry.js         |   1 +
 public/app.js               |   2 +-
 public/panels/linkedin.html |  39 +++++++++++++++++
 public/panels/linkedin.js   | 101 ++++++++++++++++++++++++++++++++++++++++++++
 5 files changed, 235 insertions(+), 1 deletion(-)

diff --git a/modules/linkedin/index.js b/modules/linkedin/index.js
new file mode 100644
index 0000000..db386e3
--- /dev/null
+++ b/modules/linkedin/index.js
@@ -0,0 +1,93 @@
+'use strict';
+// linkedin — LinkedIn composer + drafts + gated posting for the Marketing
+// Command Center. DRAFT-FIRST (DTD verdict A 2026-06-13): compose with DW B2B
+// best-practice templates, save drafts, copy/deep-link to post manually TODAY.
+// The official LinkedIn API adapter (Community Management API — w_member_social
+// for a personal profile, w_organization_social for the DW Company Page) is
+// Phase 2: POSTING never auto-fires — it requires a connected token + confirm +
+// approve, exactly like the social/channels gates.
+//
+// LinkedIn's User Agreement PROHIBITS third-party/browser automation, so there
+// is intentionally NO scraping/headless posting here — the official API is the
+// only programmatic path, and it stays human-approved.
+const fs = require('fs');
+const path = require('path');
+
+const STORE = path.join(__dirname, '..', '..', 'data', 'linkedin-drafts.json');
+const readDrafts = () => { try { return JSON.parse(fs.readFileSync(STORE, 'utf8')); } catch { return []; } };
+const writeDrafts = a => { fs.mkdirSync(path.dirname(STORE), { recursive: true }); fs.writeFileSync(STORE, JSON.stringify(a, null, 2)); };
+const env = k => (process.env[k] || '').trim();
+// DW standing rule — "Wallpaper" is banned; "Wallcovering(s)" only.
+const deWallpaper = s => String(s || '').replace(/\bwallpapers\b/gi, 'wallcoverings').replace(/\bwallpaper\b/gi, 'wallcovering');
+let _seq = 0; const newId = () => 'li' + Date.now().toString(36) + ((_seq = (_seq + 1) % 1000).toString(36));
+
+const TEMPLATES = [
+  { id: 'reveal', name: 'Project reveal', body: 'Before → after: {room} transformed with {product}.\n\nThe brief: {goal}. The move: {why}.\n\nWhat would you have specified?' },
+  { id: 'insight', name: 'Trade insight', body: 'One thing most {audience} get wrong about {topic}:\n\n{insight}\n\nHere’s how we approach it at Designer Wallcoverings.' },
+  { id: 'feature', name: 'Product feature', body: 'Material spotlight: {product}.\n\nWhy it works: {benefit1}; {benefit2}; {benefit3}.\n\nSpecifying for a project? Let’s talk.' },
+  { id: 'lesson', name: 'Lesson from an install', body: 'A lesson from a recent install:\n\n{lesson}\n\nSmall detail, big difference.' },
+];
+
+// Connected only when a token + an author/org URN are set (Phase 2).
+const liConfigured = () => !!(env('LINKEDIN_ACCESS_TOKEN') && (env('LINKEDIN_AUTHOR_URN') || env('LINKEDIN_ORG_URN')));
+
+async function liPost({ text }) {
+  const token = env('LINKEDIN_ACCESS_TOKEN');
+  const author = env('LINKEDIN_ORG_URN') || env('LINKEDIN_AUTHOR_URN'); // urn:li:organization:… or urn:li:person:…
+  const r = await fetch('https://api.linkedin.com/rest/posts', {
+    method: 'POST',
+    headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', 'LinkedIn-Version': '202401', 'X-Restli-Protocol-Version': '2.0.0' },
+    body: JSON.stringify({
+      author, commentary: text, visibility: 'PUBLIC',
+      distribution: { feedDistribution: 'MAIN_FEED', targetEntities: [], thirdPartyDistributionChannels: [] },
+      lifecycleState: 'PUBLISHED', isReshareDisabledByAuthor: false,
+    }),
+  });
+  const id = r.headers.get('x-restli-id') || r.headers.get('x-linkedin-id');
+  let body = null; try { body = await r.json(); } catch { /* may be empty */ }
+  return { ok: r.ok, status: r.status, id, error: r.ok ? null : ((body && body.message) || `HTTP ${r.status}`) };
+}
+
+module.exports = {
+  id: 'linkedin',
+  title: 'LinkedIn',
+  icon: '💼',
+  mount(router) {
+    router.get('/connection', (_req, res) => res.json({
+      configured: liConfigured(),
+      surface: env('LINKEDIN_ORG_URN') ? 'Company Page' : (env('LINKEDIN_AUTHOR_URN') ? 'Personal profile' : null),
+      mode: liConfigured() ? 'live (gated)' : 'draft-only (manual post)',
+    }));
+    router.get('/templates', (_req, res) => res.json({ templates: TEMPLATES }));
+    router.get('/drafts', (_req, res) => res.json({ drafts: readDrafts().slice().sort((a, b) => (b.created_at || '').localeCompare(a.created_at || '')) }));
+
+    router.post('/draft', (req, res) => {
+      const text = deWallpaper(String((req.body && req.body.text) || '').slice(0, 3000));
+      const hashtags = Array.isArray(req.body && req.body.hashtags) ? req.body.hashtags.slice(0, 8) : [];
+      if (!text.trim()) return res.status(400).json({ error: 'empty post' });
+      const d = { id: newId(), text, hashtags, chars: text.length, status: 'draft', created_at: new Date().toISOString() };
+      const arr = readDrafts(); arr.push(d); writeDrafts(arr);
+      res.json({ ok: true, draft: d });
+    });
+
+    router.delete('/draft/:id', (req, res) => {
+      const arr = readDrafts(); const i = arr.findIndex(d => d.id === req.params.id);
+      if (i === -1) return res.status(404).json({ error: 'not found' });
+      const [x] = arr.splice(i, 1); writeDrafts(arr); res.json({ ok: true, id: x.id });
+    });
+
+    // Gated publish — NEVER auto-fires. Stages unless connected + confirm + approve.
+    router.post('/publish', async (req, res) => {
+      const d = req.body || {};
+      const text = deWallpaper(String(d.text || '').slice(0, 3000));
+      if (!text.trim()) return res.status(400).json({ error: 'empty post' });
+      if (d.confirm !== true) return res.status(400).json({ error: 'A live post requires confirm:true.' });
+      if (d.approved !== true) return res.status(400).json({ error: 'This post is not approved. Set approved:true to clear it for live posting.' });
+      if (!liConfigured()) {
+        return res.json({ ok: true, staged: true, posted: false, message: 'Staged — LinkedIn isn’t connected. Add LINKEDIN_ACCESS_TOKEN + author/org URN (Phase 2) to post live. Nothing was sent.' });
+      }
+      try { const r = await liPost({ text }); return res.json({ ok: r.ok, posted: r.ok, id: r.id, error: r.error }); }
+      catch (e) { return res.status(502).json({ error: e.message }); }
+    });
+  },
+};
diff --git a/modules/registry.js b/modules/registry.js
index 05d6db6..d5c3d5c 100644
--- a/modules/registry.js
+++ b/modules/registry.js
@@ -14,6 +14,7 @@ module.exports = [
   'assets',             // image asset library: upload / URL / live DW-catalog pull
   'performance',        // performance dashboard: email + GA4 KPIs, charts, top campaigns
   'social',             // social scheduler: IG/TikTok queue board, gated publish
+  'linkedin',           // LinkedIn composer + drafts + manual post (deep-link) + gated API publish (Phase 2)
   'segments',           // audience segments: rule builder + live preview over contacts
   'vendors',            // vendor IG reporting: DW + all vendor Instagram accounts roster
   'channels',           // publishing engine: FB/IG/TikTok/YouTube connectors + composer (gated live posts)
diff --git a/public/app.js b/public/app.js
index e19f805..906db9c 100644
--- a/public/app.js
+++ b/public/app.js
@@ -9,7 +9,7 @@ let PANELS = [];
 
 // Curated nav groups. Social Media leads. Any panel not listed falls into "More".
 const GROUPS = [
-  { name: 'Social Media', ids: ['social', 'channels'] },
+  { name: 'Social Media', ids: ['social', 'channels', 'linkedin'] },
   { name: 'Content Studio', ids: ['copy', 'layouts', 'assets', 'templates'] },
   { name: 'Email', ids: ['constant-contact', 'send-times'] },
   { name: 'Audience', ids: ['segments', 'profiles', 'segment-perf'] },
diff --git a/public/panels/linkedin.html b/public/panels/linkedin.html
new file mode 100644
index 0000000..8108f1e
--- /dev/null
+++ b/public/panels/linkedin.html
@@ -0,0 +1,39 @@
+<div id="li-root" style="max-width:880px;">
+  <div class="muted-banner" id="li-banner">Loading…</div>
+
+  <div class="card">
+    <h2>Compose a LinkedIn post</h2>
+    <div class="muted" style="font-size:12.5px;margin-bottom:12px;">Draft with DW B2B best-practices baked in, then <b>Copy</b> + <b>Open LinkedIn</b> to post manually (compliant today). Live one-click posting via the official API is Phase 2 (gated).</div>
+
+    <div class="row" style="align-items:flex-end;margin-bottom:10px;">
+      <div style="flex:1;min-width:200px;">
+        <label for="li-tpl">Start from a template</label>
+        <select id="li-tpl"><option value="">— blank —</option></select>
+      </div>
+    </div>
+
+    <label for="li-text">Post text</label>
+    <textarea id="li-text" rows="9" placeholder="Hook in the first 2 lines — the feed truncates ~140 chars before “see more”. One idea per post."></textarea>
+    <div style="display:flex;justify-content:space-between;font-size:11.5px;margin-top:4px;">
+      <span class="muted" id="li-hook">First line is your hook.</span>
+      <span id="li-count" class="muted">0 chars</span>
+    </div>
+
+    <label for="li-tags" style="margin-top:10px;">Hashtags <span class="muted" style="font-weight:400;">(3–5; posted in the first comment, not the body)</span></label>
+    <input id="li-tags" type="text" placeholder="#interiordesign #wallcoverings #trade">
+
+    <div class="row" style="margin-top:14px;align-items:center;">
+      <button class="btn gold" id="li-save">Save draft</button>
+      <button class="btn ghost" id="li-copy">Copy text</button>
+      <button class="btn ghost" id="li-copytags">Copy hashtags</button>
+      <button class="btn" id="li-open">Open LinkedIn to post ↗</button>
+      <button class="btn gated" id="li-publish">🚀 Publish via API…</button>
+      <span class="muted" id="li-msg"></span>
+    </div>
+  </div>
+
+  <div class="card">
+    <h2>Saved drafts <span id="li-dcount" class="pill">0</span></h2>
+    <div id="li-drafts" class="muted">No drafts yet.</div>
+  </div>
+</div>
diff --git a/public/panels/linkedin.js b/public/panels/linkedin.js
new file mode 100644
index 0000000..ac5654f
--- /dev/null
+++ b/public/panels/linkedin.js
@@ -0,0 +1,101 @@
+/* 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);
+    };
+
+    await loadConn(); await loadTemplates(); await loadDrafts(); updateCount();
+  },
+};

← c04d2b7 Image Bank: gated STAGE-ONLY /repost endpoint (rights-confir  ·  back to Marketing Command Center  ·  Image Bank: finish Repost UI — gated modal (image preview, r 0c63f9f →