← back to Dw Launches
public/hoot-extras.js
232 lines
'use strict';
/* ====================================================================
* DW Launches — "all their items" feature pack (Hootsuite parity).
* Loaded as a separate classic script so it shares the page's global
* scope (can call state/api/toast/COLORS/chanName/esc/fmtShort/etc.)
* and exposes its functions as window globals for inline onclick=.
*
* Everything here is LOCAL: no paid API, no live social call.
* - AI caption / hashtags / best-time / link-shorten (Compose helpers)
* - Bulk composer (CSV → drafts)
* - Analytics (simulated) ("Analyze" view)
* "Wallpaper" is the DW banned word — copy uses "Wallcoverings".
* ==================================================================== */
// ---------- AI caption (OwlyWriter-style, fully local/templated $0) -------
const DW_OPENERS = ['Just landed','Now to the trade','Quiet luxury, delivered','New this week','Fresh on the loom','Straight from the archive'];
const DW_SUBJECTS = ['hand-dyed grasscloth','heritage damask','natural cork','tonal raffia','metallic flocked surfaces','wide-width silk'];
const DW_COLORS = ['Oatmeal','Greige','Alabaster','Celadon','warm taupe','smoked clay'];
const DW_BENEFITS = ['adds instant depth to a quiet room','reads beautifully on a feature wall','grounds a layered neutral scheme','brings texture without noise','elevates a corridor or entry','photographs like a dream on site'];
function pickRand(a){ return a[Math.floor(Math.random()*a.length)]; }
function capFirst(s){ return s.charAt(0).toUpperCase()+s.slice(1); }
function aiCaption(){
const ta=document.getElementById('global-body');
if(ta.value.trim() && !confirm('Replace the current master body with a generated caption?')) return;
const cap=`${pickRand(DW_OPENERS)}: ${pickRand(DW_SUBJECTS)} in ${pickRand(DW_COLORS)}. ${capFirst(pickRand(DW_BENEFITS))} — to the trade now.\n\nMemo samples shipping this week. Designers, DM for trade pricing.`;
ta.value=cap; state.global.body=cap; renderCounter('global'); renderPreview();
toast('✨ Caption generated locally ($0). Edit freely before saving.');
}
function aiHashtags(){
const tags='#DesignerWallcoverings #ToTheTrade #InteriorDesign #LuxuryInteriors #Grasscloth #WallcoveringsNotWallpaper #DesignDetail';
const ta=document.getElementById('global-body');
if(ta.value.includes('#DesignerWallcoverings')){ toast('Hashtags already present.'); return; }
ta.value=(ta.value.trimEnd()+'\n'+tags); state.global.body=ta.value; renderCounter('global'); renderPreview();
toast('# DW hashtag set appended.');
}
function bestTime(){
const recs=[['Instagram','12:30 PM & 7:30 PM PT'],['Pinterest','8:00 PM PT'],['LinkedIn','9:00 AM Tue–Thu PT'],['TikTok','6:00 PM PT'],['Facebook','1:00 PM PT']];
toast('🕐 Best posting windows for a trade audience — see the popup.');
alert('Recommended posting windows (design-trade audience):\n\n'+recs.map(([c,t])=>`• ${c}: ${t}`).join('\n')+'\n\n(Heuristic, local — not from a live analytics API.)');
}
function shortenLinks(){
const ta=document.getElementById('global-body'); let n=0;
ta.value=ta.value.replace(/https?:\/\/[^\s]+/g,()=>{ n++; return 'dw.ly/'+Math.random().toString(36).slice(2,8); });
if(!n){ toast('No links found to shorten.'); return; }
state.global.body=ta.value; renderCounter('global'); renderPreview();
toast(`🔗 Shortened ${n} link${n>1?'s':''} to dw.ly (cosmetic stub — no live shortener).`);
}
// ---------- Bulk composer (CSV-ish lines → scheduled drafts) ---------------
const BULK_CH={ig:'instagram',fb:'facebook',li:'linkedin',pin:'pinterest',tt:'tiktok',x:'twitter',tw:'twitter',yt:'youtube'};
let bulkRows=[];
function bulkParse(){
const lines=document.getElementById('bulk-input').value.split('\n').map(l=>l.trim()).filter(Boolean);
bulkRows=lines.map(line=>{
const parts=line.split('|').map(s=>s.trim());
if(parts.length<3) return { bad:true, why:'need date | channels | body', raw:line };
const [dt,chRaw,...rest]=parts; const body=rest.join(' | ');
let chans;
if(/^all$/i.test(chRaw)) chans=CHANNELS.filter(c=>c.connected).map(c=>c.id);
else chans=chRaw.split(',').map(s=>s.trim().toLowerCase()).map(k=>BULK_CH[k]||k).filter(k=>CHANNELS.some(c=>c.id===k));
const when=new Date(dt.replace(' ','T'));
const bad=!chans.length?'no valid channels':(isNaN(when)?'bad date':(/wallpaper/i.test(body)?'banned word "Wallpaper"':''));
return { bad:!!bad, why:bad, dt:isNaN(when)?null:when.toISOString(), chans, body };
});
document.getElementById('bulk-preview').innerHTML=bulkRows.map(r=>{
if(r.bad) return `<div class="bulk-row bad"><span style="color:var(--danger)">✕ ${esc(r.why)}</span><span class="br-body">${esc(r.raw||r.body||'')}</span></div>`;
const dots=r.chans.map(c=>`<span class="dot" style="background:${COLORS[c]}" title="${chanName(c)}"></span>`).join('');
return `<div class="bulk-row"><span class="br-ch">${dots}</span><span class="br-body">${esc(r.body)}</span><span class="br-when">🕓 ${fmtShort(r.dt)}</span></div>`;
}).join('')||'<span class="muted">Nothing parsed.</span>';
const ok=bulkRows.filter(r=>!r.bad).length;
const btn=document.getElementById('bulk-create');
btn.style.display=ok?'inline-block':'none'; btn.textContent=`Create ${ok} draft${ok>1?'s':''} ↓`;
}
async function bulkCreate(){
const good=bulkRows.filter(r=>!r.bad); let ok=0, fail=0;
for(const r of good){
const chans={}; r.chans.forEach(ch=>{ chans[ch]={body:'',media:[],fields:{},schedule:{mode:'schedule',datetime:r.dt}}; });
const launch={ id:null, title:r.body.slice(0,60), channelsSelected:r.chans, global:{body:r.body,media:[]},
channels:chans, schedule:{mode:'schedule',datetime:r.dt}, status:'draft', approval:{approved:false,by:null,at:null}, published:false };
try{ await api('/api/launches',{method:'POST',body:JSON.stringify(launch)}); ok++; }
catch(e){ fail++; }
}
toast(`Bulk: created ${ok} draft${ok!==1?'s':''}${fail?`, ${fail} failed (banned word?)`:''}. None published — all drafts.`);
document.getElementById('bulk-input').value=''; document.getElementById('bulk-preview').innerHTML=''; document.getElementById('bulk-create').style.display='none';
}
// ---------- Analytics (SIMULATED — Hootsuite "Analyze" equivalent) --------
async function renderAnalytics(){
const a=await api('/api/analytics');
const fmt=n=>n>=1000?(n/1000).toFixed(n>=10000?0:1)+'k':String(n);
document.getElementById('ana-stats').innerHTML=[
{v:fmt(a.totals.followers),k:'Total audience',cls:'gold'},
{v:fmt(a.totals.reach),k:'Reach (7d)',cls:'info'},
{v:fmt(a.totals.engagement),k:'Engagements (7d)',cls:'ok'},
{v:a.perChannel.filter(c=>c.connected).length+'/'+a.perChannel.length,k:'Accounts connected',cls:''}
].map(x=>`<div class="stat"><div class="v ${x.cls}">${x.v}</div><div class="k">${x.k}</div></div>`).join('');
const max=Math.max(...a.series.map(s=>s.reach))||1;
document.getElementById('ana-bars').innerHTML=a.series.map(s=>
`<div class="bar" style="height:${Math.round(s.reach/max*100)}%" title="${s.reach.toLocaleString()} reach"><span class="bl">${s.day}</span></div>`).join('');
document.getElementById('ana-table').innerHTML=
`<thead><tr><th>Account</th><th>Followers</th><th>Reach (7d)</th><th>Engagements</th><th>Eng. rate</th><th>Best time</th><th>Live posts</th></tr></thead><tbody>`+
a.perChannel.map(c=>`<tr>
<td><span class="av" style="background:${COLORS[c.id]}"></span><b>${esc(c.name)}</b>${c.connected?'':' <span class="muted">· off</span>'}</td>
<td>${c.followers.toLocaleString()}</td><td>${c.reach.toLocaleString()}</td><td>${c.engagement.toLocaleString()}</td>
<td>${c.engagementRate}%</td><td>${esc(c.bestTime)}</td><td>${c.postsLive}</td></tr>`).join('')+`</tbody>`;
}
// ====================================================================
// UNIFIED INBOX — Hootsuite "Inbox" parity (simulated, local-only).
// Triage all mentions / comments / DMs across accounts; reply drafts
// locally (nothing is sent). Read/replied state persists per-browser.
// ====================================================================
// Inject inbox CSS at runtime so we never race index.html's <style> block.
(function injectInboxCSS(){
if(document.getElementById('hoot-inbox-css')) return;
const css=`
.inbox-wrap{max-width:900px;margin:0 auto;padding:18px 28px}
.inbox-bar{display:flex;align-items:center;gap:12px}
.inbox-filters{display:flex;gap:6px;flex-wrap:wrap}
.inbox-spacer{margin-left:auto}
.ibf{font-size:12px;padding:6px 13px;border-radius:999px;cursor:pointer;font-family:inherit;
background:var(--panel2);border:1px solid var(--line);color:var(--ink-2);transition:all .12s}
.ibf:hover{border-color:var(--gold-dim);color:var(--ink)}
.ibf.sel{background:var(--gold-faint2);border-color:var(--gold);color:var(--gold)}
.ibf .n{opacity:.65;margin-left:5px;font-size:11px}
.inbox-item{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);
padding:13px 15px;margin-bottom:9px;transition:border-color .12s}
.inbox-item.unread{border-left:3px solid var(--gold)}
.inbox-item.replied{opacity:.72}
.inbox-item .ii-top{display:flex;align-items:center;gap:10px}
.inbox-item .ii-av{width:34px;height:34px;border-radius:50%;flex-shrink:0;display:flex;align-items:center;
justify-content:center;font-size:13px;color:#0d0b06;font-weight:700}
.inbox-item .ii-name{font-size:13px;color:var(--ink);font-weight:600;line-height:1.15}
.inbox-item .ii-sub{font-size:11px;color:var(--muted)}
.inbox-item .ii-kind{font-size:9px;text-transform:uppercase;letter-spacing:.6px;padding:2px 7px;border-radius:999px;
border:1px solid var(--line);color:var(--ink-2);background:var(--panel2)}
.inbox-item .ii-kind.mentions{color:#6aacda;border-color:rgba(58,106,160,.3)}
.inbox-item .ii-kind.comments{color:var(--gold);border-color:var(--gold-dim)}
.inbox-item .ii-kind.dms{color:#5ab880;border-color:rgba(58,138,96,.3)}
.inbox-item .ii-when{margin-left:auto;font-size:11px;color:var(--muted);white-space:nowrap}
.inbox-item .ii-text{font-size:13px;color:var(--ink-2);margin:9px 0 0;line-height:1.5}
.inbox-item .ii-actions{display:flex;gap:14px;margin-top:10px;color:var(--muted);font-size:12px}
.inbox-item .ii-actions span{cursor:pointer;transition:color .12s}
.inbox-item .ii-actions span:hover{color:var(--gold)}
.inbox-item .ii-reply{display:flex;gap:8px;margin-top:10px}
.inbox-item .ii-reply textarea{min-height:60px}
.inbox-item .ii-reply button{flex:0 0 auto;align-self:flex-end}
.inbox-item .ii-done{font-size:11px;color:#5ab880;margin-top:8px}`;
const s=document.createElement('style'); s.id='hoot-inbox-css'; s.textContent=css; document.head.appendChild(s);
})();
let inboxFilter='all';
function inboxState(){ try{ return JSON.parse(localStorage.getItem('dwlaunch:inboxState'))||{}; }catch(e){ return {}; } }
function inboxSet(id,patch){ const s=inboxState(); s[id]=Object.assign({},s[id],patch); localStorage.setItem('dwlaunch:inboxState',JSON.stringify(s)); }
async function renderInbox(){
// populate channel select once
const csel=document.getElementById('inbox-channel');
if(csel && !csel.options.length){
csel.innerHTML='<option value="all">All accounts</option>'+CHANNELS.map(c=>`<option value="${c.id}">${esc(c.name)}</option>`).join('');
}
const ch=(csel&&csel.value)||'all';
const kindParam=inboxFilter==='all'||inboxFilter==='unread'?'':inboxFilter;
let data;
try{ data=await api(`/api/inbox?channel=${encodeURIComponent(ch)}${kindParam?`&kind=${kindParam}`:''}`); }
catch(e){ document.getElementById('inbox-list').innerHTML='<span class="muted">Could not load inbox.</span>'; return; }
const st=inboxState();
// filter chips
const c=data.counts||{};
const localUnread=data.items.filter(it=>it.unread && !(st[it.id]&&st[it.id].read)).length;
const chips=[['all','All',c.all],['mentions','Mentions',c.mentions],['comments','Comments',c.comments],['dms','DMs',c.dms],['unread','Unread',localUnread]];
document.getElementById('inbox-filters').innerHTML=chips.map(([k,l,n])=>
`<button class="ibf${inboxFilter===k?' sel':''}" data-f="${k}">${l}<span class="n">${n||0}</span></button>`).join('');
document.querySelectorAll('#inbox-filters .ibf').forEach(b=>b.onclick=()=>{ inboxFilter=b.getAttribute('data-f'); renderInbox(); });
updateInboxPill(localUnread);
let items=data.items;
if(inboxFilter==='unread') items=items.filter(it=>it.unread && !(st[it.id]&&st[it.id].read));
const list=document.getElementById('inbox-list');
if(!items.length){ list.innerHTML='<span class="muted">Nothing here — inbox zero. 🎉</span>'; return; }
list.innerHTML=items.map(it=>inboxItem(it,st[it.id]||{})).join('');
// wire actions
list.querySelectorAll('.inbox-item').forEach(el=>{
const id=el.getAttribute('data-id'), ch=el.getAttribute('data-ch'), handle=el.getAttribute('data-handle');
const reply=el.querySelector('[data-act=reply]'), read=el.querySelector('[data-act=read]'), like=el.querySelector('[data-act=like]');
const box=el.querySelector('.ii-reply');
if(reply) reply.onclick=()=>{ box.classList.toggle('hide'); inboxSet(id,{read:true}); const ta=box.querySelector('textarea'); if(ta){ta.focus();} };
if(read) read.onclick=()=>{ inboxSet(id,{read:true}); renderInbox(); };
if(like) like.onclick=()=>{ like.textContent='♥ Liked'; like.style.color='var(--gold)'; toast('Simulated like — nothing sent.'); };
const send=el.querySelector('[data-act=send]');
if(send) send.onclick=()=>{
const ta=box.querySelector('textarea'); const txt=(ta.value||'').trim();
if(/wallpaper/i.test(txt)){ toast('Blocked: "Wallpaper" is banned — use "Wallcoverings".',true); return; }
inboxSet(id,{read:true,replied:true});
toast(`Reply to @${handle} drafted (simulated — nothing sent).`); renderInbox();
};
const compose=el.querySelector('[data-act=compose]');
if(compose) compose.onclick=()=>{ inboxSet(id,{read:true}); replyToFeed(id,ch,handle); };
});
}
function inboxItem(it,s){
const initials=(it.author.name||'?').split(' ').map(w=>w[0]).join('').slice(0,2).toUpperCase();
const unread=it.unread && !s.read;
const when=(()=>{ try{ return new Date(it.datetime).toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); }catch(e){ return ''; } })();
return `<div class="inbox-item${unread?' unread':''}${s.replied?' replied':''}" data-id="${esc(it.id)}" data-ch="${esc(it.channel)}" data-handle="${esc(it.author.handle)}">
<div class="ii-top">
<span class="ii-av" style="background:${it.author.color}">${initials}</span>
<span><span class="ii-name">${esc(it.author.name)}</span><br><span class="ii-sub">@${esc(it.author.handle)} · ${chanName(it.channel)}</span></span>
<span class="ii-kind ${it.kind}">${it.kind}</span>
<span class="ii-when">${when}</span>
</div>
<div class="ii-text">${esc(it.text)}</div>
<div class="ii-actions">
<span data-act="reply">↩ Reply</span>
<span data-act="compose">↗ Open in Compose</span>
<span data-act="like">♥ ${it.likes||0}</span>
${unread?'<span data-act="read">✓ Mark read</span>':''}
</div>
<div class="ii-reply hide">
<textarea placeholder="Write a reply to @${esc(it.author.handle)}… (simulated — nothing sent)"></textarea>
<button class="btn" data-act="send">Send (sim)</button>
</div>
${s.replied?'<div class="ii-done">✓ Replied (simulated)</div>':''}
</div>`;
}
function updateInboxPill(n){
const p=document.getElementById('inbox-pill'); if(!p) return;
if(n>0){ p.textContent=n; p.classList.remove('hide'); } else p.classList.add('hide');
}