← back to Marketing Command Center
public/panels/composer.js
237 lines
window.MCC_PANELS = window.MCC_PANELS || {};
window.MCC_PANELS['composer'] = {
async init(root) {
const O = location.origin;
const $ = s => root.querySelector(s);
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
const jget = async u => { try { const r = await fetch(O + u, { credentials: 'same-origin' }); return r.ok ? await r.json() : null; } catch { return null; } };
const jpost = async (u, b) => { try { const r = await fetch(O + u, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(b) }); return await r.json().catch(() => ({})); } catch (e) { return { error: e.message }; } };
const state = { mediaUrl: '', videoUrl: '', src: 'assets', selected: new Set(), pages: new Set() };
const NEW_ARRIVALS = 'designerwallcoverings.com/collections/new-arrivals'; // bare → clickable via facets
// ── RIGHT rail: publish targets, connection-aware ──────────────────────────
async function loadTargets() {
const [t, s] = await Promise.all([jget('/api/composer/targets'), jget('/api/channels/status')]);
const targets = (t && t.targets) || [];
const st = (s && (s.platforms || s)) || {};
const wrap = $('#cmp-targets'); wrap.innerHTML = '';
state.connected = [];
targets.forEach(tg => {
const on = !!(st[tg.id] && st[tg.id].connected);
if (on) state.connected.push(tg.id);
const el = document.createElement('label');
el.className = 'cmp-tgt' + (on ? '' : ' off');
el.innerHTML = `<input type="checkbox" ${on ? '' : 'disabled'} data-ch="${tg.id}">
<span class="ico">${tg.icon}</span><span class="nm">${esc(tg.label)}</span>
<span class="st ${on ? 'on' : 'off'}">${on ? 'connected' : 'not connected'}</span>`;
const cb = el.querySelector('input');
cb.addEventListener('change', () => {
cb.checked ? state.selected.add(tg.id) : state.selected.delete(tg.id);
if (tg.id === 'facebook') toggleFbPages();
});
wrap.appendChild(el);
});
toggleFbPages();
}
$('#cmp-all').addEventListener('click', () => {
root.querySelectorAll('#cmp-targets input[data-ch]:not([disabled])').forEach(cb => { cb.checked = true; state.selected.add(cb.dataset.ch); });
toggleFbPages();
});
// ── Facebook Page picker: shown only when Facebook is a selected target ──
function toggleFbPages() {
const on = state.selected.has('facebook');
$('#cmp-fbpages').style.display = on ? '' : 'none';
if (on && !state._pagesLoaded) loadPages();
}
async function loadPages(refresh) {
const box = $('#cmp-fbpages-list'); box.innerHTML = '<div class="loading">Loading…</div>';
const d = await jget('/api/channels/pages' + (refresh ? '?refresh=1' : ''));
const pages = (d && d.pages) || [];
state._pagesLoaded = true;
if (!pages.length) { box.innerHTML = `<div class="bd-empty" style="font-size:11.5px">${d && d.hasToken ? 'No Pages found — hit ↻ to fetch from Meta.' : 'Meta not connected — add a token in the Setup panel.'}</div>`; return; }
box.innerHTML = '';
pages.forEach(p => {
const row = document.createElement('label'); row.className = 'cmp-fbrow';
row.innerHTML = `<input type="checkbox" data-pid="${esc(p.id)}"><span class="nm">${esc(p.name || p.id)}</span>${p.hasIG ? `<span class="ig">@${esc(p.igUsername || 'ig')}</span>` : ''}`;
const cb = row.querySelector('input');
cb.addEventListener('change', () => { cb.checked ? state.pages.add(p.id) : state.pages.delete(p.id); });
box.appendChild(row);
});
}
$('#cmp-fball').addEventListener('click', () => {
root.querySelectorAll('#cmp-fbpages-list input[data-pid]').forEach(cb => { cb.checked = true; state.pages.add(cb.dataset.pid); });
});
$('#cmp-fbrefresh').addEventListener('click', () => { state.pages.clear(); loadPages(true); });
$('#cmp-fbsearch').addEventListener('input', e => {
const q = e.target.value.toLowerCase();
root.querySelectorAll('#cmp-fbpages-list .cmp-fbrow').forEach(r => {
r.style.display = (r.querySelector('.nm').textContent || '').toLowerCase().includes(q) ? '' : 'none';
});
});
// ── LEFT rail: assets + banners ────────────────────────────────────────────
async function loadSource(kind) {
const grid = $('#cmp-srcgrid'); grid.innerHTML = '<div class="loading">Loading…</div>';
grid.classList.toggle('reels', kind === 'reels');
if (kind === 'assets') {
const d = await jget('/api/assets/list');
const items = (d && d.assets) || [];
if (!items.length) { grid.innerHTML = '<div class="bd-empty" style="grid-column:1/-1">No assets yet — add some in the Assets panel.</div>'; return; }
grid.innerHTML = '';
items.forEach(a => {
const url = a.src || a.url || a.image || a.thumb || '';
if (!url) return;
const d1 = document.createElement('div');
d1.className = 'cmp-thumb'; d1.dataset.url = url;
d1.innerHTML = `<img loading="lazy" src="${esc(url)}"><span class="lbl">${esc(a.name || a.title || '')}</span>`;
// A dead/placeholder asset URL (e.g. a junk test.jpg) 404s and would show a
// broken tile + spam the console. Drop any thumbnail whose image can't load.
d1.querySelector('img').addEventListener('error', () => d1.remove());
d1.addEventListener('click', () => select(d1, url));
grid.appendChild(d1);
});
} else if (kind === 'reels') {
// Reel / Story assets — the generated portrait videos. Selecting one sets the
// post's videoUrl (a public .mp4 the platforms fetch), not a still image.
const d = await jget('/api/composer/reels');
const items = (d && d.reels) || [];
if (!items.length) { grid.innerHTML = '<div class="bd-empty" style="grid-column:1/-1">No reels found yet — generate some in the Reels app.</div>'; return; }
grid.innerHTML = '';
items.forEach(r => {
const el = document.createElement('div');
el.className = 'cmp-reel'; el.dataset.url = r.url;
// #t=0.1 nudges the <video> to paint a real first-frame poster (not a black tile)
el.innerHTML = `<video src="${esc(r.url)}#t=0.1" muted preload="metadata"></video><span class="play">▶</span><span class="lbl">${esc(r.title || r.file)}</span>`;
el.addEventListener('click', () => selectReel(el, r));
grid.appendChild(el);
});
} else {
// Banners: click a template → render it LIVE (real HTML from /api/layouts/render)
// into the middle preview, using the current caption as the headline and the
// selected asset (if any) as the banner's product photo. This is a genuine
// designed preview. Posting a banner AS AN IMAGE (HTML→PNG) is the next step,
// drafted for Steve — see pending-approval/mcc-composer-banner-image.md.
const d = await jget('/api/layouts/templates');
const items = (d && d.templates) || [];
grid.innerHTML = `<div class="bd-empty" style="grid-column:1/-1;font-size:11.5px">Click a template to preview it here → the caption becomes the headline, a selected asset becomes the photo. <b>Preview only</b> for now; one-click "post as image" is coming.</div>`;
items.forEach(t => {
const b = document.createElement('div');
b.className = 'cmp-tgt'; b.style.gridColumn = '1/-1'; b.style.cursor = 'pointer';
b.innerHTML = `<span class="ico">🎏</span><span class="nm">${esc(t.name || t.id)}</span><span class="st on" style="text-transform:none">${esc(t.aspect || t.kind || '')}</span>`;
b.addEventListener('click', () => renderBanner(t.id, b, t.aspect));
grid.appendChild(b);
});
}
}
function select(el, url) {
root.querySelectorAll('.cmp-thumb.sel, .cmp-reel.sel').forEach(x => x.classList.remove('sel'));
el.classList.add('sel');
state.mediaUrl = url; state.videoUrl = ''; // image chosen → clear any reel
const img = $('#cmp-img'); img.src = url; img.style.display = 'block';
const v = $('#cmp-video'); if (v) { v.style.display = 'none'; v.removeAttribute('src'); }
const bf = $('#cmp-banner'); if (bf) bf.style.display = 'none'; // asset chosen → hide any banner preview
$('#cmp-preview').querySelector('.cmp-ph').style.display = 'none';
}
// Pick a reel → the post carries its public video URL (videoUrl), not a still.
function selectReel(el, r) {
root.querySelectorAll('.cmp-reel.sel, .cmp-thumb.sel, .cmp-tgt.sel').forEach(x => x.classList.remove('sel'));
el.classList.add('sel');
state.videoUrl = r.url; state.mediaUrl = '';
const img = $('#cmp-img'); img.style.display = 'none';
const bf = $('#cmp-banner'); if (bf) bf.style.display = 'none';
const v = $('#cmp-video'); if (v) { v.src = r.url; v.style.display = 'block'; } // guard: mirror select()
$('#cmp-preview').querySelector('.cmp-ph').style.display = 'none';
// seed the reel's own caption when the box is empty (keeps media + copy aligned)
if (r.caption && !$('#cmp-caption').value.trim()) { $('#cmp-caption').value = r.caption; countIt(); }
}
// Render a Layouts template LIVE into the preview via /api/layouts/render (returns HTML).
async function renderBanner(templateId, el, aspect) {
root.querySelectorAll('.cmp-tgt.sel').forEach(x => x.classList.remove('sel')); if (el) el.classList.add('sel');
// #3 — without a selected asset the banner renders a gradient placeholder where the
// product photo should be; tell the user so it doesn't read as broken.
const note = $('#cmp-bannernote');
if (note) note.textContent = state.mediaUrl ? '' : '⚠ No asset selected — pick one from the Assets tab to fill the banner’s photo slot.';
const caption = ($('#cmp-caption').value || '').trim();
const headline = caption.split('\n')[0].slice(0, 80) || 'Designer Wallcoverings';
const r = await jpost('/api/layouts/render', {
template: templateId, headline, subhead: 'New arrivals', cta: 'Shop the collection',
productName: 'Designer Wallcoverings', imageUrl: state.mediaUrl || '',
});
if (!r || !r.html) return;
const img = $('#cmp-img'); img.style.display = 'none';
$('#cmp-preview').querySelector('.cmp-ph').style.display = 'none';
let f = $('#cmp-banner');
if (!f) { f = document.createElement('iframe'); f.id = 'cmp-banner'; f.setAttribute('sandbox', 'allow-same-origin'); f.style.cssText = 'width:100%;border:0;background:#fff;border-radius:12px'; $('#cmp-preview').appendChild(f); }
// #2 — respect the template's real aspect (a 9:16 story ≠ a 4:1 banner in the preview).
f.style.aspectRatio = (aspect || '1/1').replace(' ', '');
f.style.display = 'block'; f.srcdoc = r.html;
}
root.querySelectorAll('.cmp-tab').forEach(tab => tab.addEventListener('click', () => {
root.querySelectorAll('.cmp-tab').forEach(t => t.classList.remove('on'));
tab.classList.add('on'); state.src = tab.dataset.src; loadSource(state.src);
}));
// ── MIDDLE: copy ───────────────────────────────────────────────────────────
const cap = $('#cmp-caption');
const countIt = () => { $('#cmp-count').textContent = cap.value.length; };
cap.addEventListener('input', countIt);
$('#cmp-gen').addEventListener('click', async () => {
$('#cmp-gen').disabled = true; $('#cmp-gen').textContent = '…generating';
const tone = $('#cmp-tone').value;
// kind MUST be a valid copy KIND id (instagram/tiktok/…); an unknown value like
// 'social' silently degrades server-side to a 'headline' — the wrong content type.
const r = await jpost('/api/copy/generate', { kind: 'instagram', tone, topic: `${tone} wallcovering social post for Designer Wallcoverings`, product: 'any' });
const v = (r && r.variants) || [];
let first = v.length ? (typeof v[0] === 'string' ? v[0] : (v[0].text || v[0].body || v[0].caption || '')) : '';
// HARD RULE: always link back to DW new arrivals (the facet detector makes the bare
// domain clickable). Append it if the copy doesn't already reference the site.
if (first && !/designerwallcoverings\.com/i.test(first)) first += `\n\nShop new arrivals → ${NEW_ARRIVALS}`;
if (first) { cap.value = first; countIt(); }
$('#cmp-gen').disabled = false; $('#cmp-gen').textContent = '✨ Generate copy';
});
// ── PUBLISH ────────────────────────────────────────────────────────────────
$('#cmp-publish').addEventListener('click', async () => {
const out = $('#cmp-result'); out.innerHTML = '';
const channels = [...state.selected];
if (!channels.length) { out.innerHTML = '<span class="warn">Pick at least one connected channel.</span>'; return; }
// HARD RULE: DW never posts text-only — a photo/video/reel is required.
if (!state.mediaUrl && !state.videoUrl) { out.innerHTML = '<span class="warn">📷 A photo, reel, or video is required — DW never posts text-only. Pick an asset or reel on the left first.</span>'; return; }
if (!cap.value.trim()) { out.innerHTML = '<span class="warn">Add a caption.</span>'; return; }
if (channels.includes('facebook') && !state.pages.size) { out.innerHTML = '<span class="warn">📘 Pick at least one Facebook Page to post to.</span>'; return; }
const dry = $('#cmp-dry').checked;
$('#cmp-publish').disabled = true; $('#cmp-publish').textContent = dry ? 'Staging…' : 'Publishing…';
const r = await jpost('/api/channels/publish', {
channels, caption: cap.value.trim(),
mediaUrl: state.mediaUrl, videoUrl: state.videoUrl,
pages: [...state.pages],
confirm: !dry, dryRun: dry,
});
if (r && r.results) {
out.innerHTML = r.results.map(x => {
const cls = x.live && x.ok ? 'ok' : (x.staged ? 'warn' : (x.ok ? 'ok' : 'err'));
const msg = x.live && x.ok ? 'posted live ✓' : (x.staged ? ('staged — ' + (x.reason || '')) : (x.ok ? 'ok' : ('failed' + (x.detail && x.detail[0] && x.detail[0].error ? ': ' + x.detail[0].error : ''))));
return `<div class="${cls}">${esc(x.channel)}: ${esc(msg)}</div>`;
}).join('') + `<div class="muted" style="margin-top:6px;font-size:11px">${esc(r.note || '')}</div>`;
} else {
out.innerHTML = `<span class="err">${esc((r && r.error) || 'publish failed')}</span>`;
}
$('#cmp-publish').disabled = false; $('#cmp-publish').textContent = 'Publish';
});
$('#cmp-refresh').addEventListener('click', () => { loadTargets(); loadSource(state.src); });
$('#cmp-search').addEventListener('input', e => {
const q = e.target.value.toLowerCase();
root.querySelectorAll('#cmp-srcgrid .cmp-thumb').forEach(t => {
const l = (t.querySelector('.lbl') || {}).textContent || '';
t.style.display = l.toLowerCase().includes(q) ? '' : 'none';
});
});
await Promise.all([loadTargets(), loadSource('assets')]);
countIt();
},
};