← 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
A modules/linkedin/index.jsM modules/registry.jsM public/app.jsA public/panels/linkedin.htmlA public/panels/linkedin.js
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 →