← back to Marketing Command Center
public/panels/social.js
352 lines
// Social Scheduler panel — queue board grouped by status + add/edit form,
// gated dry-run publish, and a curated best-times helper card.
// Registers into the shell's MCC_PANELS map (see public/app.js).
window.MCC_PANELS = window.MCC_PANELS || {};
window.MCC_PANELS['social'] = {
init(root) {
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
const CHAN_ICON = { instagram: '📷', tiktok: '🎵', pinterest: '📌', facebook: '👍' };
const CHAN_NAME = { instagram: 'Instagram', tiktok: 'TikTok', pinterest: 'Pinterest', facebook: 'Facebook' };
// Approval pipeline: Save draft → Submit for approval → Steve sign-off → Publish.
const COLUMNS = [
{ status: 'idea', title: 'Ideas' },
{ status: 'drafted', title: 'Drafted' },
{ status: 'pending-approval', title: 'Pending approval' },
{ status: 'approved', title: 'Approved · ready to publish' },
{ status: 'posted', title: 'Posted' },
];
const $ = sel => root.querySelector(sel);
const board = $('#soc-board');
const form = $('#soc-form');
const cardTpl = $('#soc-card-tpl');
// Whether a real social backend is connected (so the UI tells the truth about
// live vs simulated instead of always saying "dry-run"). Filled by loadStatus().
const state = { posts: [], editingId: null, live: false, backend: null, statusNote: '' };
async function load() {
try {
const r = await fetch('/api/social/posts').then(r => r.json());
state.posts = (r && r.posts) || [];
} catch (_) { state.posts = []; }
renderBoard();
}
// Pull live/simulated state and update the banner so it's accurate.
async function loadStatus() {
try {
const s = await fetch('/api/social/status').then(r => r.json());
state.live = !!s.live; state.backend = s.backend || null; state.statusNote = s.note || '';
} catch (_) { state.live = false; }
const banner = root.querySelector('.muted-banner');
if (banner) {
banner.innerHTML = state.live
? `<b>● LIVE</b> — backend <b>${esc(state.backend)}</b> connected. Approved + confirmed posts go to <b>real accounts</b>. Pipeline: Save draft → Submit for approval → Steve sign-off ✓ → Publish.`
: `<b>● SIMULATED</b> — no social backend connected, so a publish <b>stages only</b> (nothing is sent). ${esc(state.statusNote)} Pipeline still works end-to-end: Save draft → Submit for approval → Steve sign-off ✓ → Publish.`;
banner.style.borderLeft = state.live ? '3px solid #3a8a4a' : '3px solid #c79a3a';
banner.style.paddingLeft = '10px';
}
}
function whenLabel(p) {
if (!p.date && !p.time) return 'no date';
let out = '';
if (p.date) {
const d = new Date(p.date + 'T00:00:00');
out = isNaN(d) ? p.date : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
if (p.time) out = (out ? out + ' · ' : '') + p.time;
return out;
}
function renderBoard() {
board.innerHTML = '';
for (const col of COLUMNS) {
const items = state.posts.filter(p => p.status === col.status);
const el = document.createElement('div');
el.className = 'soc-col';
el.innerHTML = `<h3>${esc(col.title)}<span class="ct">${items.length}</span></h3>`;
if (!items.length) {
const e = document.createElement('div');
e.className = 'soc-empty';
e.textContent = '—';
el.appendChild(e);
} else {
for (const p of items) el.appendChild(buildCard(p));
}
board.appendChild(el);
}
}
function buildCard(p) {
const node = cardTpl.content.firstElementChild.cloneNode(true);
node.querySelector('.sc-chan').textContent =
`${CHAN_ICON[p.channel] || '•'} ${CHAN_NAME[p.channel] || p.channel}`;
node.querySelector('.sc-when').textContent = whenLabel(p);
const cap = node.querySelector('.sc-cap');
cap.textContent = p.caption || '(no caption yet)';
if (!p.caption) cap.classList.add('muted');
const tags = node.querySelector('.sc-tags');
(p.hashtags || []).slice(0, 12).forEach(t => {
const s = document.createElement('span');
s.className = 'pill';
s.textContent = t;
tags.appendChild(s);
});
// Status-driven pipeline action on each card:
// drafted → Submit for approval · pending-approval → Sign off ✓ (Steve) · approved → Publish
const pub = node.querySelector('.sc-pub');
if (p.status === 'drafted') {
pub.className = 'btn ghost sc-pub';
pub.textContent = 'Submit for approval →';
pub.title = 'Move into the approval queue';
pub.addEventListener('click', () => transition(p, 'submit'));
} else if (p.status === 'pending-approval') {
pub.className = 'btn gold sc-pub';
pub.textContent = 'Sign off ✓';
pub.title = "Steve's approval — clears this post for live publishing";
pub.addEventListener('click', () => transition(p, 'approve'));
} else if (p.status === 'approved' || p.status === 'scheduled') {
pub.className = 'btn gated sc-pub';
pub.textContent = state.live ? 'Publish (live)…' : 'Publish (staged)…';
pub.title = state.live ? 'Gated — posts to the real account' : 'Gated — stages only (no backend connected)';
pub.addEventListener('click', () => publishFlow(p, node));
} else {
pub.remove(); // idea / posted → no pipeline action
}
node.querySelector('.sc-edit').addEventListener('click', () => startEdit(p));
node.querySelector('.sc-del').addEventListener('click', () => removePost(p.id));
return node;
}
// ── pipeline transitions: Submit for approval / Steve sign-off ───────────
async function transition(p, action) {
if (action === 'approve' && !confirm(
`Sign off on this ${CHAN_NAME[p.channel] || p.channel} post?\n\n` +
`This is Steve's approval — it clears the post for live publishing.`)) return;
try {
await fetch(`/api/social/${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: p.id }),
});
} catch (_) {}
await load();
}
// ── gated publish: dry-run → confirm → real publish (or stage if no backend)
async function publishFlow(p, node) {
const box = node.querySelector('.sc-result');
box.hidden = false;
box.classList.remove('staged');
box.textContent = 'Running dry run…';
let dry;
try {
dry = await fetch('/api/social/publish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: p.id, dryRun: true }),
}).then(r => r.json());
} catch (_) {
box.textContent = 'Could not reach the publish endpoint.';
return;
}
if (!dry || !dry.ok) {
box.textContent = (dry && dry.error) || 'Dry run failed.';
return;
}
const w = dry.would || {};
const summary = `Post to ${CHAN_NAME[w.channel] || w.channel} at ${w.when}.`;
const prompt = state.live
? `${summary}\n\n⚠️ LIVE — this posts to the REAL ${CHAN_NAME[w.channel] || w.channel} account right now. This cannot be undone from here.\n\nPublish for real?`
: `${summary}\n\nThis is a GATED action. No social backend is connected, so it will be STAGED only — nothing goes to a live account.\n\nStage this publish?`;
if (!confirm(prompt)) {
box.textContent = state.live ? 'Publish cancelled. Nothing was sent.' : 'Dry run only — staging cancelled. Nothing was sent.';
return;
}
box.textContent = state.live ? 'Publishing…' : 'Staging…';
let res;
try {
res = await fetch('/api/social/publish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: p.id, dryRun: false, confirm: true }),
}).then(r => r.json());
} catch (_) {
box.textContent = 'Staging request failed.';
return;
}
if (res && res.staged) {
box.classList.add('staged');
box.textContent = res.message || 'Staged — connect a social backend to enable live posting.';
} else if (res && res.live && res.ok) {
box.textContent = '✓ Posted live to the real account.';
await load(); // moves the card into Posted
} else if (res && res.live && !res.ok) {
const err = res.result && (res.result.error || (res.result.message)) || res.error;
box.textContent = 'Live publish failed: ' + (typeof err === 'string' ? err : JSON.stringify(err || 'unknown'));
} else {
box.textContent = (res && (res.message || res.error)) || 'Unexpected response.';
}
}
async function removePost(id) {
if (!confirm('Remove this post from the queue?')) return;
try {
await fetch(`/api/social/posts?delete=${encodeURIComponent(id)}`, { method: 'POST' });
} catch (_) {}
if (state.editingId === id) resetForm();
await load();
}
// ── add / edit form ──────────────────────────────────────────────────────
function startEdit(p) {
state.editingId = p.id;
form.channel.value = p.channel || 'instagram';
form.date.value = p.date || '';
form.time.value = p.time || '';
form.status.value = p.status || 'idea';
form.caption.value = p.caption || '';
form.hashtags.value = (p.hashtags || []).join(' ');
form.imageUrl.value = p.imageUrl || '';
form.notes.value = p.notes || '';
$('#soc-save').textContent = 'Save changes';
$('#soc-editing').textContent = 'Editing existing post';
form.scrollIntoView({ behavior: 'smooth', block: 'start' });
form.caption.focus();
}
function resetForm() {
state.editingId = null;
form.reset();
$('#soc-save').textContent = 'Add to queue';
$('#soc-editing').textContent = '';
}
form.addEventListener('submit', async e => {
e.preventDefault();
const fd = new FormData(form);
const body = {
id: state.editingId || undefined,
channel: fd.get('channel'),
date: (fd.get('date') || '').toString(),
time: (fd.get('time') || '').toString(),
status: fd.get('status'),
caption: (fd.get('caption') || '').toString().trim(),
hashtags: (fd.get('hashtags') || '').toString(),
imageUrl: (fd.get('imageUrl') || '').toString().trim(),
notes: (fd.get('notes') || '').toString().trim(),
};
const btn = $('#soc-save');
btn.disabled = true;
const prev = btn.textContent;
btn.textContent = 'Saving…';
try {
await fetch('/api/social/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
} catch (_) {}
btn.disabled = false;
btn.textContent = prev;
resetForm();
await load();
});
$('#soc-reset').addEventListener('click', resetForm);
// ── Instant post: Save + auto-approve + publish in one gated step ─────────
$('#soc-instant').addEventListener('click', async () => {
const fd = new FormData(form);
const channel = fd.get('channel');
const caption = (fd.get('caption') || '').toString().trim();
const box = $('#soc-instant-result');
box.hidden = false; box.classList.remove('staged');
if (!caption) { box.textContent = 'Add a caption first.'; return; }
// 1) create/update the post, pre-approved (instant skips the queue stages).
box.textContent = 'Saving…';
let entry;
try {
const r = await fetch('/api/social/posts', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: state.editingId || undefined, channel,
date: (fd.get('date') || '').toString(), time: (fd.get('time') || '').toString(),
caption, hashtags: (fd.get('hashtags') || '').toString(),
imageUrl: (fd.get('imageUrl') || '').toString().trim(),
notes: (fd.get('notes') || '').toString().trim(),
status: 'approved', approved: true,
}),
}).then(r => r.json());
entry = r && r.entry;
} catch (_) {}
if (!entry) { box.textContent = 'Could not save the post.'; return; }
// 2) gated confirm — truthful about live vs staged.
const where = CHAN_NAME[channel] || channel;
const prompt = state.live
? `⚡ INSTANT LIVE POST\n\nThis posts to the REAL ${where} account right now. This cannot be undone.\n\nPost now?`
: `⚡ Instant post\n\nNo backend connected, so this STAGES only — nothing is sent to ${where}.\n\nStage it?`;
if (!confirm(prompt)) { box.textContent = 'Cancelled — saved to the board as Approved.'; resetForm(); await load(); return; }
// 3) publish (confirm + approve inline).
box.textContent = state.live ? 'Publishing…' : 'Staging…';
let res;
try {
res = await fetch('/api/social/publish', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: entry.id, dryRun: false, confirm: true, approve: true }),
}).then(r => r.json());
} catch (_) { box.textContent = 'Publish request failed.'; return; }
if (res && res.staged) { box.classList.add('staged'); box.textContent = res.message || 'Staged — no backend connected.'; }
else if (res && res.live && res.ok) box.textContent = '✓ Posted live to the real account.';
else if (res && res.live && !res.ok) { const e = res.result && (res.result.error || res.result.message) || res.error; box.textContent = 'Live publish failed: ' + (typeof e === 'string' ? e : JSON.stringify(e || 'unknown')); }
else box.textContent = (res && (res.message || res.error)) || 'Unexpected response.';
resetForm();
await load();
});
// ── best times helper ────────────────────────────────────────────────────
async function loadBestTimes() {
const box = $('#soc-besttimes');
let data;
try {
data = await fetch('/api/social/best-times').then(r => r.json());
} catch (_) {
box.innerHTML = `<div class="cal-empty muted">Could not load guidance.</div>`;
return;
}
const chans = (data && data.channels) || [];
if (!chans.length) {
box.innerHTML = `<div class="cal-empty muted">No guidance available.</div>`;
return;
}
box.innerHTML = chans.map(c => {
const wins = (c.windows || []).map(w => `<span class="pill">${esc(w)}</span>`).join('');
return `<div class="bt-row">
<div class="bh">${CHAN_ICON[c.channel] || '•'} ${esc(c.label || c.channel)}</div>
<div class="bt-win">${wins}</div>
<div class="bt-note">${esc(c.note || '')}</div>
</div>`;
}).join('');
}
loadStatus();
load();
loadBestTimes();
},
};