← 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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();
  },
};