← back to Marketing Command Center

public/panels/quickpost.js

218 lines

// Kid-simple social post wizard. Combines existing asset, sounds, copy, account,
// and channel APIs into five small choices with one explicit live-post gate.
window.MCC_PANELS = window.MCC_PANELS || {};
window.MCC_PANELS.quickpost = {
  async init(root) {
    const O = location.origin;
    const $ = s => root.querySelector(s);
    const $$ = s => [...root.querySelectorAll(s)];
    const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
    const icons = {facebook:'📘',instagram:'📸',bluesky:'🦋',linkedin:'💼',tiktok:'🎵',youtube:'▶️',threads:'🧵'};
    const state = { step:1, mediaUrl:'', song:null, channels:[], targets:[], status:{}, view:'grid', cols:4, showroomVendors:[], showroomSelected:false };
    let allAssets = [];
    const PICKER_KEY = 'mcc.quickpost.picker';
    try { const s = JSON.parse(localStorage.getItem(PICKER_KEY) || '{}'); if (['grid','list','kv'].includes(s.view)) state.view = s.view; if (+s.cols >= 2 && +s.cols <= 8) state.cols = +s.cols; } catch (_) {}
    const savePicker = () => { try { localStorage.setItem(PICKER_KEY, JSON.stringify({ view: state.view, cols: state.cols })); } catch (_) {} };
    const names = ['','Picture','Music','Words','Where','Check'];

    function note(step, msg) {
      const p = root.querySelector(`[data-step="${step}"]`);
      let e = p.querySelector('.kp-error');
      if (!e) { e = document.createElement('div'); e.className = 'kp-error'; p.appendChild(e); }
      e.textContent = msg;
    }
    function showStep(n) {
      state.step = Math.max(1, Math.min(5, n));
      $$('.kp-pane').forEach(p => { const on = +p.dataset.step === state.step; p.hidden = !on; p.classList.toggle('on', on); });
      $$('.kp-step').forEach(b => { const x = +b.dataset.go; const locked = x > state.step; b.classList.toggle('on', x === state.step); b.classList.toggle('done', x < state.step); b.disabled = locked; b.setAttribute('aria-selected', String(x === state.step)); b.setAttribute('aria-disabled', String(locked)); });
      $('#kp-progress').textContent = state.step + ' of 5';
      $('#kp-back').disabled = state.step === 1;
      $('#kp-next').hidden = state.step === 5;
      if (state.step < 5) $('#kp-next').textContent = 'Next: ' + names[state.step + 1] + ' →';
      if (state.step === 5) renderReview();
      root.scrollIntoView({behavior:'smooth', block:'start'});
    }
    $$('.kp-step').forEach(b => b.onclick = () => {
      const target = +b.dataset.go;
      if (target > state.step) return note(state.step, 'Finish this step first, then press Next.');
      showStep(target);
    });
    $('#kp-back').onclick = () => showStep(state.step - 1);
    $('#kp-next').onclick = () => {
      if (state.step === 1 && !state.mediaUrl) return note(1, 'Pick a picture or paste a picture link first.');
      if (state.step === 3 && !$('#kp-caption').value.trim()) return note(3, 'Pick suggested words or write your own first.');
      if (state.step === 4 && !state.channels.length) return note(4, 'Pick at least one social account first.');
      showStep(state.step + 1);
    };

    async function loadAssets() {
      try {
        // Browse pool (general pictures) + saved library + the SEPARATE showroom
        // area. Showroom-only lines (e.g. Phillip Jeffries) are absent from the
        // browse pages by design, so we pull them via the dedicated endpoint —
        // the only place they surface, and always with the correct language.
        const [saved, catalog, showroom] = await Promise.all([
          fetch(O + '/api/assets/list', {credentials:'same-origin'}).then(r => r.json()),
          fetch(O + '/api/assets/catalog?limit=200', {credentials:'same-origin'}).then(r => r.ok ? r.json() : {products:[]}).catch(() => ({products:[]})),
          fetch(O + '/api/assets/showroom', {credentials:'same-origin'}).then(r => r.ok ? r.json() : {products:[]}).catch(() => ({products:[]}))
        ]);
        // Canonical showroom vendor list rides along with either catalog call —
        // key off the LIST, never a hardcoded vendor name.
        state.showroomVendors = catalog.showroomVendors || showroom.showroomVendors || ['Phillip Jeffries'];
        const svLower = state.showroomVendors.map(s => String(s).toLowerCase());
        // Saved assets carry no vendor field → infer showroom from the name only.
        const savedAssets = (saved.assets || []).filter(a => a.src).map(a => {
          const hay = `${a.name || ''} ${(a.tags || []).join(' ')}`.toLowerCase();
          return Object.assign({}, a, { showroom: !!a.showroom || svLower.some(s => s && hay.includes(s)) });
        });
        const catalogAssets = (catalog.products || []).filter(p => p.url).map(p => ({
          id: `catalog-${p.handle || p.url}`, name: p.title || 'DW catalog image', kind: 'catalog', src: p.url,
          tags: ['dw-catalog', p.type || 'wallcovering'], aiTags: {}, vendor: p.vendor || '', showroom: !!p.showroom
        }));
        const showroomAssets = (showroom.products || []).filter(p => p.url).map(p => ({
          id: `showroom-${p.handle || p.url}`, name: p.title || 'Showroom line', kind: 'catalog', src: p.url,
          tags: ['dw-catalog', p.vendor || '', p.type || ''].filter(Boolean), aiTags: {}, vendor: p.vendor || '', showroom: true
        }));
        const seen = new Set();
        allAssets = [...savedAssets, ...showroomAssets, ...catalogAssets].filter(a => { const key = a.src; if (seen.has(key)) return false; seen.add(key); return true; });
        renderAssets();
      } catch (_) { $('#kp-assets').innerHTML = '<div class="kp-error">Pictures could not load. Paste a picture link below.</div>'; }
    }
    function cardInner(a, i) {
      const name = esc(a.name || 'DW picture');
      const img = `<img loading="lazy" src="${esc(a.src)}" alt="${esc(a.name || 'Wallpaper picture')}">`;
      const badge = a.showroom ? '<span class="kp-badge-showroom">🏛️ Showroom</span>' : '';
      if (state.view === 'grid') return `${badge}${img}<span class="kp-asset-name">${name}</span>`;
      const kind = a.showroom ? 'Showroom line' : ({ catalog:'DW pick', upload:'My upload' }[String(a.kind || '').toLowerCase()] || esc(a.kind || 'Picture'));
      const chips = state.view === 'kv'
        ? `<span class="kp-kv"><span class="kp-kv-chip"><i>Kind</i>${kind}</span>${(a.tags && a.tags.length) ? `<span class="kp-kv-chip"><i>Tags</i>${esc(a.tags.slice(0,3).join(' · '))}</span>` : ''}</span>`
        : '';
      return `<span class="kp-thumb">${img}</span><span class="kp-row-main"><b class="kp-row-name">${name}</b>${chips}${a.showroom ? badge : ''}</span>`;
    }
    function renderAssets() {
      const filter = root.querySelector('.kp-filter button.on')?.dataset.filter || 'all';
      const q = ($('#kp-asset-search')?.value || '').trim().toLowerCase();
      const matchesQ = a => !q || `${a.name || ''} ${(a.tags || []).join(' ')} ${a.vendor || ''} ${JSON.stringify(a.aiTags || {})}`.toLowerCase().includes(q);
      const assets = allAssets.filter(a => {
        if (!matchesQ(a)) return false;
        // Showroom-only lines appear ONLY in the dedicated Showroom area — never
        // in "All ideas" or "DW picks" (addressable-but-not-discoverable).
        if (filter === 'showroom') return !!a.showroom;
        if (a.showroom) return false;
        if (filter === 'all') return true;
        return String(a.kind || '').toLowerCase() === filter;
      }).slice(0, 120);
      const box = $('#kp-assets');
      box.classList.remove('view-grid','view-list','view-kv'); box.classList.add('view-' + state.view);
      box.style.setProperty('--cols', state.cols);
      const emptyMsg = filter === 'showroom'
        ? 'No showroom pictures yet. Showroom lines stay reachable by direct link and on-site search.'
        : 'No matching pictures yet. Try another word or paste a link below.';
      box.innerHTML = assets.length ? assets.map((a,i) => `<button class="kp-asset${a.showroom ? ' is-showroom' : ''}" type="button" data-url="${esc(new URL(a.src, O).href)}" data-showroom="${a.showroom ? '1' : '0'}" aria-label="Choose ${esc(a.name || 'picture '+(i+1))}${a.showroom ? ' (showroom line)' : ''}" aria-pressed="false">${cardInner(a,i)}</button>`).join('') : `<div class="kp-wait">${emptyMsg}</div>`;
      $$('.kp-asset').forEach(b => {
        if (b.dataset.url === state.mediaUrl) { b.classList.add('on'); b.setAttribute('aria-pressed','true'); }
        b.onclick = () => { $$('.kp-asset').forEach(x => { x.classList.remove('on'); x.setAttribute('aria-pressed','false'); }); b.classList.add('on'); b.setAttribute('aria-pressed','true'); state.mediaUrl = b.dataset.url; state.showroomSelected = b.dataset.showroom === '1'; $('#kp-media').value = state.mediaUrl; updateShowroomNote(); };
      });
      updateShowroomNote();
    }

    // Persistent correct-language reminder. Shows whenever the Showroom area is
    // active OR a showroom picture is selected. Framing per the doctrine:
    // showroom lines may be featured on social/email ONLY to drive people to the
    // physical showroom — invite the visit, never "shop now / buy online".
    function updateShowroomNote() {
      const el = $('#kp-showroom-note'); if (!el) return;
      const filter = root.querySelector('.kp-filter button.on')?.dataset.filter || 'all';
      const active = filter === 'showroom' || state.showroomSelected;
      const vendors = state.showroomVendors && state.showroomVendors.length ? state.showroomVendors : ['Phillip Jeffries'];
      const list = vendors.length === 1 ? esc(vendors[0])
        : esc(vendors.slice(0, -1).join(', ')) + ' and ' + esc(vendors[vendors.length - 1]);
      el.hidden = !active;
      if (active) el.innerHTML = `<b>🏛️ Showroom-only line — invite people IN, don’t sell online</b>`
        + `${list} is shown by appointment in our showroom, not on the online store. Feature it to bring people to us — `
        + `<em>“See it in person at the Designer Wallcoverings showroom”</em> or <em>“Book a showroom visit to feel the samples.”</em> `
        + `Please don’t write <span class="no">Shop now</span> or <span class="no">Buy online</span> — this line isn’t sold on the site.`;
    }
    function syncViewUI() {
      $$('.kp-views button').forEach(b => { const on = b.dataset.view === state.view; b.classList.toggle('on', on); b.setAttribute('aria-pressed', String(on)); });
      const dens = root.querySelector('.kp-density'); if (dens) dens.hidden = state.view !== 'grid';
      const r = $('#kp-density'); if (r) r.value = state.cols;
    }
    $$('.kp-views button').forEach(b => b.onclick = () => { state.view = b.dataset.view; savePicker(); syncViewUI(); renderAssets(); });
    $('#kp-density').addEventListener('input', e => { state.cols = +e.target.value; savePicker(); renderAssets(); });
    syncViewUI();
    $$('.kp-filter button').forEach(b => b.onclick = () => { $$('.kp-filter button').forEach(x => x.classList.remove('on')); b.classList.add('on'); renderAssets(); });
    $('#kp-asset-search').addEventListener('input', renderAssets);
    $('#kp-media').addEventListener('input', e => { state.mediaUrl = e.target.value.trim(); let sel = null; $$('.kp-asset').forEach(x => { const on = x.dataset.url === state.mediaUrl; x.classList.toggle('on', on); if (on) sel = x; }); state.showroomSelected = !!(sel && sel.dataset.showroom === '1'); updateShowroomNote(); });

    async function loadMusic() {
      try {
        const d = await fetch(O + '/api/sounds/catalog', {credentials:'same-origin'}).then(r => r.json());
        const items = (d.catalog || []).flatMap(s => s.items || []).filter(x => x.badge === 'brandsafe' && x.previewUrl).slice(0, 8);
        $('#kp-music').innerHTML = `<div class="kp-music-card on"><button class="kp-choice on" type="button" data-music=""><b>🔇 No music</b><small>That is completely okay.</small></button></div>` + items.map(x => { const audio = new URL(x.previewUrl, O).href; return `<div class="kp-music-card"><button class="kp-choice" type="button" data-music="${esc(new URL(x.url, O).href)}" data-name="${esc(x.name)}" data-license="${esc(x.license || '')}" data-duration="${esc(x.duration || '')}"><b>🎵 ${esc(x.name)} · ${esc(x.duration || '')}</b><small>${esc((x.note || 'Brand-safe music').slice(0,100))}</small></button><audio controls preload="metadata" src="${esc(audio)}" aria-label="Preview ${esc(x.name)}"></audio><div class="kp-license">${esc(x.license || 'Check license before use')}</div></div>`; }).join('');
        $$('.kp-choice').forEach(b => { b.setAttribute('aria-pressed', b.classList.contains('on')); b.onclick = () => { $$('.kp-music-card').forEach(x => x.classList.remove('on')); $$('.kp-choice').forEach(x => { x.classList.remove('on'); x.setAttribute('aria-pressed','false'); }); b.closest('.kp-music-card').classList.add('on'); b.classList.add('on'); b.setAttribute('aria-pressed','true'); state.song = b.dataset.music ? {name:b.dataset.name,url:b.dataset.music,license:b.dataset.license,duration:b.dataset.duration,suggestionOnly:true} : null; }; });
      } catch (_) { $('#kp-music').innerHTML = '<button class="kp-choice on" type="button"><b>🔇 No music</b><small>Music choices could not load.</small></button>'; }
    }

    function count() { const c = $('#kp-caption'); if (c.value.length > 2200) c.value = c.value.slice(0,2200); $('#kp-count').textContent = c.value.length.toLocaleString() + ' / 2,200'; }
    $('#kp-caption').addEventListener('input', count);
    $('#kp-suggest').onclick = async () => {
      const topic = $('#kp-topic').value.trim();
      if (!topic) return note(3, 'First, type what the post is about.');
      const btn = $('#kp-suggest'); btn.disabled = true; btn.textContent = 'Writing ideas…'; $('#kp-suggestions').innerHTML = '<div class="kp-wait">Thinking of three friendly captions…</div>';
      try {
        const d = await fetch(O + '/api/copy/generate', {method:'POST', credentials:'same-origin', headers:{'content-type':'application/json'}, body:JSON.stringify({kind:'social',topic,product:'wallcovering',audience:'designers and homeowners',tone:'warm',n:3})}).then(r => r.json());
        const variants = (d.variants || []).map(v => typeof v === 'string' ? v : v.text).filter(Boolean);
        $('#kp-suggestions').innerHTML = variants.map(v => `<button class="kp-suggestion" type="button" data-text="${esc(v)}">${esc(v)}</button>`).join('') || '<div class="kp-error">No ideas came back. Try a few different words.</div>';
        $$('.kp-suggestion').forEach(b => b.onclick = () => { $('#kp-caption').value = b.dataset.text; count(); });
      } catch (_) { $('#kp-suggestions').innerHTML = '<div class="kp-error">Ideas could not load. You can still write your own.</div>'; }
      finally { btn.disabled = false; btn.textContent = '✨ Give me ideas'; }
    };

    async function loadChannels() {
      try {
        const [t,s] = await Promise.all([fetch(O + '/api/composer/targets',{credentials:'same-origin'}).then(r=>r.json()), fetch(O + '/api/channels/status',{credentials:'same-origin'}).then(r=>r.json())]);
        state.targets = t.targets || []; state.status = s.platforms || s.status || s || {};
        $('#kp-channels').innerHTML = state.targets.map(x => { const st=state.status[x.id]||{}; const works=!!st.connected && st.postable!==false; const why=works?'Ready to post':(st.postableNote||'Not connected yet'); return `<button class="kp-channel${works?'':' off'}" type="button" data-channel="${esc(x.id)}" aria-pressed="false" ${works?'':'disabled'}><b>${icons[x.id]||x.icon||'●'} ${esc(x.label)}</b><small>${esc(why)}</small></button>`; }).join('');
        $$('.kp-channel:not(.off)').forEach(b => b.onclick = () => { b.classList.toggle('on'); b.setAttribute('aria-pressed', String(b.classList.contains('on'))); state.channels = $$('.kp-channel.on').map(x=>x.dataset.channel); });
      } catch (_) { $('#kp-channels').innerHTML = '<div class="kp-error">Social accounts could not be checked.</div>'; }
    }
    $('#kp-all').onclick = () => { $$('.kp-channel:not(.off)').forEach(b=>{ b.classList.add('on'); b.setAttribute('aria-pressed','true'); }); state.channels = $$('.kp-channel.on').map(x=>x.dataset.channel); };
    $('#kp-none').onclick = () => { $$('.kp-channel').forEach(b=>{ b.classList.remove('on'); b.setAttribute('aria-pressed','false'); }); state.channels=[]; };
    async function loadAccounts() { let a=[]; try { a=(window.MCC_ACCOUNTS&&await window.MCC_ACCOUNTS.load())||[]; } catch (_) {} $('#kp-account').innerHTML='<option value="">Main Designer Wallcoverings account</option>'+a.map(x=>`<option value="${esc(x.handle)}">@${esc(x.handle)}${x.name?' — '+esc(x.name):''}</option>`).join(''); }

    function renderReview() {
      const cap=$('#kp-caption').value.trim(); const isVideo=/\.(mp4|mov|webm|m4v)(\?|$)/i.test(state.mediaUrl);
      const media=state.mediaUrl ? (isVideo?`<video src="${esc(state.mediaUrl)}" controls playsinline></video>`:`<img src="${esc(state.mediaUrl)}" alt="Chosen post picture">`) : '<div class="kp-wait">No picture</div>';
      const tags=state.channels.map(c=>`<span class="kp-tag">${icons[c]||'●'} ${esc(c)}</span>`).join('');
      const song = state.song ? `${esc(state.song.name)} · ${esc(state.song.duration)}<div class="kp-disclosure">Music suggestion only. It is saved with the post recipe; some social apps require adding the track inside their own app.</div>` : 'No music';
      const showroomWarn = state.showroomSelected ? `<div class="kp-showroom-note" style="margin:0 0 14px"><b>🏛️ Showroom-only line</b>Make sure the words invite people to the Designer Wallcoverings showroom (see it in person / book a visit) — not to shop or buy online.</div>` : '';
      $('#kp-review').innerHTML=`<div class="kp-preview">${media}</div><div class="kp-summary">${showroomWarn}<h4>Your words</h4><p>${esc(cap||'No words')}</p><h4>Music suggestion</h4><p>${song}</p><h4>Posting to</h4><div class="kp-tags">${tags||'<span class="kp-tag">None chosen</span>'}</div></div>`;
      $('#kp-confirm').checked=false; $('#kp-publish').disabled=true; $('#kp-result').textContent='';
    }
    $('#kp-confirm').onchange=e => { $('#kp-publish').disabled=!e.target.checked; };
    async function submit(live) {
      const result=$('#kp-result'); const btn=live?$('#kp-publish'):$('#kp-draft');
      if(!state.mediaUrl||!state.channels.length||!$('#kp-caption').value.trim()){result.className='kp-result bad';result.textContent='Go back and choose a picture, words, and at least one account.';return;}
      if(live&&!$('#kp-confirm').checked)return;
      btn.disabled=true; result.className='kp-result'; result.textContent=live?'Posting… Please keep this page open.':'Saving drafts…';
      try {
        const isVideo=/\.(mp4|mov|webm|m4v)(\?|$)/i.test(state.mediaUrl);
        const d=await fetch(O+'/api/channels/publish',{method:'POST',credentials:'same-origin',headers:{'content-type':'application/json'},body:JSON.stringify({channels:state.channels,handles:$('#kp-account').value?[$('#kp-account').value]:[],caption:$('#kp-caption').value.trim(),mediaUrl:state.mediaUrl,videoUrl:isVideo?state.mediaUrl:'',song:state.song,confirm:live,dryRun:!live})}).then(async r=>{const j=await r.json();if(!r.ok)throw new Error(j.error||'Could not post');return j;});
        const posted=(d.results||[]).filter(x=>x.live&&x.ok).length, staged=(d.results||[]).filter(x=>!x.live).length;
        result.className = 'kp-result ok';
        if (live) {
          const saved = staged ? `; ${staged} unavailable account${staged === 1 ? ' was' : 's were'} saved instead` : '';
          result.textContent = `Done. ${posted} account${posted === 1 ? '' : 's'} posted${saved}.`;
        } else {
          result.textContent = `Saved ${staged} draft${staged === 1 ? '' : 's'}. Nothing was posted.`;
        }
      } catch(e) { result.className='kp-result bad'; result.textContent='Not posted: '+e.message; }
      finally { btn.disabled=false; if(live){$('#kp-confirm').checked=false;$('#kp-publish').disabled=true;} }
    }
    $('#kp-publish').onclick=()=>submit(true); $('#kp-draft').onclick=()=>submit(false);
    await Promise.all([loadAssets(),loadMusic(),loadChannels(),loadAccounts()]);
    showStep(1);
  }
};