← back to Prestige Car Wash
public/admin/index.html
680 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Prestige Car Wash — Growth Command Center</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/assets/pcw.css">
<style>
.tabs{display:flex;gap:6px;overflow-x:auto;padding:10px 0;border-bottom:1px solid var(--line);position:sticky;top:60px;background:rgba(11,15,22,.9);backdrop-filter:blur(8px);z-index:30}
.tabs button{white-space:nowrap;background:var(--panel);border:1px solid var(--line);color:var(--mut);border-radius:999px;padding:8px 14px;font-size:13px;font-weight:700;cursor:pointer}
.tabs button.on{background:linear-gradient(135deg,var(--brand),var(--brand2));color:#001018;border-color:transparent}
.when{margin-top:8px;color:var(--mut);font-size:10.5px;font-variant-numeric:tabular-nums}
.stat{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:18px}
.stat .s{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:12px 16px;min-width:120px}
.stat .s b{font-size:24px;color:var(--brand);display:block}
.stat .s span{font-size:12px;color:var(--mut)}
.rate{color:var(--gold);font-weight:800}
.badge{font-size:10px;border-radius:999px;padding:2px 8px;font-weight:700;text-transform:uppercase;letter-spacing:.5px}
.b-good{background:rgba(58,210,159,.15);color:var(--good)} .b-warn{background:rgba(255,180,84,.15);color:var(--warn)} .b-bad{background:rgba(255,107,107,.15);color:var(--bad)} .b-mut{background:var(--panel2);color:var(--mut)}
.heat{display:grid;grid-template-columns:repeat(7,1fr);gap:6px;max-width:560px}
.heat .d{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:8px;text-align:center;font-size:12px}
.heat .d b{display:block;font-size:11px;color:var(--mut)}
code{background:var(--panel2);padding:1px 6px;border-radius:6px;font-size:12px}
.kv{display:flex;justify-content:space-between;gap:10px;border-bottom:1px solid var(--line);padding:8px 0;font-size:14px}
/* Compose & Post */
.cmp{display:grid;grid-template-columns:1fr 340px;gap:16px;align-items:start}
@media(max-width:820px){.cmp{grid-template-columns:1fr}}
.cmp textarea{width:100%;min-height:150px;background:var(--panel);border:1px solid var(--line);color:var(--ink,#e9eef5);border-radius:12px;padding:12px;font:inherit;font-size:14px;line-height:1.5;resize:vertical}
.cmp .lab{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.6px;color:var(--mut);margin:14px 0 6px}
.chips{display:flex;gap:6px;flex-wrap:wrap}
.chip{background:var(--panel);border:1px solid var(--line);color:var(--mut);border-radius:999px;padding:6px 11px;font-size:12px;font-weight:700;cursor:pointer;user-select:none}
.chip:hover{border-color:var(--brand);color:var(--ink,#e9eef5)}
.chip.on{background:linear-gradient(135deg,var(--brand),var(--brand2));color:#001018;border-color:transparent}
.assets{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;max-height:460px;overflow:auto;padding-right:4px}
.asset{position:relative;border:2px solid var(--line);border-radius:10px;overflow:hidden;cursor:pointer;aspect-ratio:1;background:var(--panel)}
.asset.on{border-color:var(--brand);box-shadow:0 0 0 2px rgba(10,132,255,.35)}
.asset img,.asset video{width:100%;height:100%;object-fit:cover;display:block}
.asset .k{position:absolute;top:4px;right:4px;font-size:10px;background:rgba(0,0,0,.6);color:#fff;border-radius:6px;padding:1px 5px;font-weight:700}
.cmpbtns{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}
.hint{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:10px 12px;font-size:12.5px;color:var(--mut);margin-top:10px}
/* Promos */
.promos{display:grid;grid-template-columns:repeat(var(--cols,3),1fr);gap:16px}
.promo{background:var(--panel);border:1px solid var(--line);border-radius:14px;overflow:hidden;display:flex;flex-direction:column}
.promo .pv{position:relative;background:#000}
.promo video{width:100%;aspect-ratio:16/9;object-fit:cover;display:block;background:#000}
.promo .dur{position:absolute;bottom:8px;right:8px;font-size:11px;font-weight:800;background:rgba(0,0,0,.72);color:#fff;border-radius:6px;padding:2px 7px;font-variant-numeric:tabular-nums}
.promo .pb{padding:12px 14px 14px}
.promo .pttl{font-size:16px;font-weight:800;margin:2px 0 6px}
.promo .pbtns{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}
.promo .pbtns .btn{font-size:12.5px;padding:7px 11px}
.k-brand-promo{background:rgba(10,132,255,.16);color:var(--brand)}
.k-hyperframes-motion{background:rgba(255,180,84,.16);color:var(--warn)}
.k-internal-pitch{background:var(--panel2);color:var(--mut)}
/* --- idea analysis panel (SWOT · costs · payback) --- */
details.analysis{margin-top:10px;border:1px solid var(--line);border-radius:10px;background:var(--panel2);overflow:hidden}
details.analysis>summary{cursor:pointer;list-style:none;padding:9px 12px;font-size:12.5px;font-weight:700;color:var(--brand);user-select:none}
details.analysis>summary::-webkit-details-marker{display:none}
details.analysis>summary::before{content:'▸ ';font-weight:800}
details.analysis[open]>summary::before{content:'▾ '}
.analysis-body{padding:4px 12px 12px}
.swot-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin:6px 0 10px}
.swot-col{border-radius:8px;padding:8px 10px;font-size:12.5px}
.swot-col h5{margin:0 0 5px;font-size:11px;text-transform:uppercase;letter-spacing:.4px}
.swot-col ul{margin:0;padding-left:15px} .swot-col li{margin:3px 0;line-height:1.35}
.s-str{background:rgba(48,209,88,.10)} .s-str h5{color:var(--good,#30d158)}
.s-wk{background:rgba(255,180,84,.10)} .s-wk h5{color:var(--warn,#ffb454)}
.s-op{background:rgba(10,132,255,.10)} .s-op h5{color:var(--brand)}
.s-th{background:rgba(255,69,58,.10)} .s-th h5{color:#ff453a}
.cost-card{border:1px solid var(--line);border-radius:8px;padding:8px 10px;margin:8px 0}
.cost-card h5{margin:0 0 4px;font-size:12px;text-transform:capitalize}
.cost-card.model{border-color:var(--brand);border-left-width:3px}
.cost-card.model h5{text-transform:none}
@media(max-width:560px){.swot-grid{grid-template-columns:1fr}}
.lead-pipe{display:flex;align-items:center;gap:8px;margin-top:10px;font-size:12px;color:var(--mut)}
.lead-pipe select{background:var(--panel2);color:var(--ink,#e9eef5);border:1px solid var(--line);border-radius:8px;padding:5px 8px;font:inherit;font-size:12.5px}
/* --- Action Brief button + modal (dtd:A, 2026-07-26) --- */
.brief-btn{margin-top:10px;background:var(--panel);border:1px solid var(--line);color:var(--brand);border-radius:8px;padding:6px 11px;font-size:12px;font-weight:700;cursor:pointer}
.brief-btn:hover{border-color:var(--brand)}
#brief-modal{display:none;position:fixed;inset:0;z-index:100}
#brief-modal.open{display:block}
#brief-modal .backdrop{position:absolute;inset:0;background:rgba(0,0,0,.6)}
#brief-modal .sheet{position:relative;max-width:720px;margin:5vh auto;max-height:90vh;overflow:auto;background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:24px 26px}
#brief-modal h2{margin:0 0 4px;font-size:22px}
#brief-modal .meta{color:var(--mut);font-size:12.5px;margin-bottom:14px}
#brief-modal .sec{margin:14px 0}
#brief-modal .sec h4{margin:0 0 6px;font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:var(--brand)}
#brief-modal .sec ul{margin:4px 0;padding-left:18px} #brief-modal .sec li{margin:3px 0;line-height:1.4}
.brief-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:20px;padding-top:14px;border-top:1px solid var(--line)}
@media print{
body *{visibility:hidden}
#brief-modal,#brief-modal *{visibility:visible}
#brief-modal{position:absolute;inset:0;display:block}
#brief-modal .backdrop,.brief-actions{display:none}
#brief-modal .sheet{margin:0;max-height:none;border:0;background:#fff;color:#000}
}
</style>
</head>
<body>
<nav class="nav"><div class="wrap row">
<div class="brand"><span class="mk">🚗</span><div>Prestige <small>Admin · Growth Center</small></div></div>
<div class="links" style="margin-left:auto"><a href="/" target="_blank">View site ↗</a></div>
</div></nav>
<section id="lhf"></section>
<script src="/assets/lhf.js"></script>
<div class="wrap">
<div class="tabs" id="tabs"></div>
<div class="controls" id="ctl" style="margin-top:16px">
<input type="search" id="q" placeholder="Search…">
<label>Sort <select id="sort"></select></label>
<label>Density <input type="range" id="density" min="2" max="6" step="1"></label>
<span id="count" style="color:var(--mut);font-size:13px;font-weight:600"></span>
</div>
<div id="view" style="padding:8px 0 60px"></div>
</div>
<div id="brief-modal" role="dialog" aria-modal="true" aria-label="Action brief">
<div class="backdrop" onclick="closeBrief()"></div>
<div class="sheet">
<div id="brief-body"></div>
<div class="brief-actions">
<button class="btn" onclick="window.print()">🖨️ Print</button>
<button class="btn" onclick="copyBrief()">📋 Copy</button>
<button class="btn" onclick="closeBrief()">Close</button>
</div>
</div>
</div>
<script>
const $=s=>document.querySelector(s);
function fmtDate(iso){ if(!iso) return '—'; const d=new Date(iso); if(isNaN(d)) return '—';
return d.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); }
function when(iso){ return `<div class="when" title="created ${iso||''}">🕓 ${fmtDate(iso)}</div>`; }
const rating=r=>r?`<span class="rate">★ ${r}</span>`:'';
const enc=s=>String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
let DENS=localStorage.getItem('pcwadmin.density')||'3';
$('#density').value=DENS; setDensity(DENS);
function setDensity(v){v=Number(v);document.documentElement.style.setProperty('--cols',v);
document.documentElement.style.setProperty('--fs',Math.max(.85,Math.min(1.2,1+(3-v)*0.05)).toFixed(3));}
$('#density').oninput=e=>{setDensity(e.target.value);localStorage.setItem('pcwadmin.density',e.target.value)};
let t;$('#q').oninput=()=>{clearTimeout(t);t=setTimeout(render,200)};
$('#sort').onchange=e=>{localStorage.setItem('pcwadmin.sort.'+CUR,e.target.value);render()};
// ---- tab definitions -------------------------------------------------------
const TABS=[
{id:'overview',label:'📊 Overview'},
{id:'promos',label:'🎬 Promos'},
{id:'compose',label:'✨ Compose & Post'},
{id:'competitors',label:'🥊 Competitors',api:'/api/admin/competitors',search:['name','city','notes','format'],
card:c=>cardWrap(`<div class="row"><div class="cat">${enc(c.city)} · ${enc(c.format)}</div>${rating(c.rating)}</div>
<div class="ttl">${enc(c.name)}</div>
<div class="blurb">${enc(c.notes)}</div>
<div class="kv"><span>Reviews</span><b>${c.reviews||'—'}</b></div>
<div class="kv"><span>Pricing</span><span>${enc(c.price_signal)}</span></div>
<div class="tags">${(c.ad_platforms||[]).map(p=>`<span class="tag">📣 ${enc(p)}</span>`).join('')||'<span class="tag b-mut">no ad signals scanned</span>'}</div>
${c.website?`<a class="tag" href="${enc(c.website)}" target="_blank" style="margin-top:6px;display:inline-block">site ↗</a>`:''}
${when(c.created_at)}`)},
{id:'best-post',label:'⏰ Best Times to Post'},
{id:'best-market',label:'💸 Best Times to Market'},
{id:'holidays',label:'🎉 Holiday-Driven',api:'/api/admin/holidays',search:['name','campaign'],
card:h=>cardWrap(`<div class="cat">${enc(h.date)} · lead ${h.lead_days}d</div>
<div class="ttl">${enc(h.name)}</div><div class="blurb">${enc(h.campaign)}</div>
<div class="tags">${(h.channels||[]).map(c=>`<span class="tag">${enc(c)}</span>`).join('')}</div>${when(h.created_at)}`)},
{id:'services',label:'🚿 Services',api:'/api/admin/services',search:['name','category','blurb'],
card:s=>cardWrap(`<div class="row"><div class="cat">${enc(s.category)} · ${s.duration_min}min</div>${s.featured?'<span class="badge b-good">featured</span>':''}</div>
<div class="ttl">${enc(s.name)}</div><div class="blurb">${enc(s.blurb)}</div>
<div class="row"><span class="price">${enc(s.price_display||('$'+s.price))}</span>
<span class="badge b-mut">${s.media_clip?'media set':'no media'}</span></div>${when(s.created_at)}`)},
{id:'suggestions',label:'💡 Grow the Business',api:'/api/admin/suggestions',search:['title','rationale','category'],
card:s=>cardWrap(`<div class="row"><div class="cat">${enc(s.category)}</div>
<span class="badge ${s.impact>=5?'b-good':s.impact>=3?'b-warn':'b-mut'}">impact ${s.impact} · effort ${s.effort}</span></div>
<div class="ttl">${enc(s.title)}</div><div class="blurb">${enc(s.rationale)}</div>${analysisPanel(s)}
<button class="brief-btn" data-id="${enc(s.id)}" onclick="openBrief(this.dataset.id)">📋 Action Brief</button>${when(s.created_at)}`)},
{id:'ads',label:'📣 Online Ads',api:'/api/admin/ads',search:['platform','type','headline'],
card:a=>cardWrap(`<div class="row"><div class="cat">${enc(a.platform)} · ${enc(a.type)}</div><span class="badge b-warn">${enc(a.status)}</span></div>
<div class="ttl">${enc(a.headline)}</div><div class="blurb">${enc(a.body)}</div>
<div class="kv"><span>Budget/mo</span><b>$${a.budget_month}</b></div>
<div class="kv"><span>Targeting</span><span>${enc(a.targeting)}</span></div>
<div class="tags">${(a.keywords||[]).map(k=>`<span class="tag">${enc(k)}</span>`).join('')}</div>${when(a.created_at)}`)},
{id:'directories',label:'📍 Groups · Nextdoor · Directories',api:'/api/admin/directories',search:['name','notes','type'],
card:d=>cardWrap(`<div class="row"><div class="cat">${enc(d.type)} · ${enc(d.priority)}</div>
<span class="badge ${d.status==='live'?'b-good':d.status==='submitted'?'b-warn':'b-mut'}">${enc(d.status)}</span></div>
<div class="ttl">${enc(d.name)}</div><div class="blurb">${enc(d.notes)}</div>
${d.url?`<a class="tag" href="${enc(d.url)}" target="_blank" style="margin-top:6px;display:inline-block">open ↗</a>`:''}${when(d.created_at)}`)},
{id:'google',label:'🟢 Google Business'},
{id:'leads',label:'📨 Leads',api:'/api/admin/leads',search:['name','service','message','status'],
card:l=>cardWrap(`<div class="row"><div class="cat">${enc(l.service||'general')}</div>
<span>${(l.status||'new')==='new'&&isCold(l.created_at)?'<span class="badge b-bad">⏳ cold</span> ':''}<span class="badge ${leadBadge(l.status)}">${enc(l.status||'new')}</span></span></div>
<div class="ttl">${enc(l.name)}</div>
<div class="kv"><span>Phone</span><span>${l.phone?(telDigits(l.phone)?`<a href="tel:${enc(telDigits(l.phone))}" style="color:var(--brand);font-weight:700">📞 ${enc(l.phone)}</a>`:enc(l.phone)):'—'}</span></div>
<div class="kv"><span>Email</span><span>${l.email?`<a href="mailto:${enc(l.email)}" style="color:var(--brand);font-weight:700">✉️ ${enc(l.email)}</a>`:'—'}</span></div>
<div class="kv"><span>Vehicle</span><span>${enc(l.vehicle)}</span></div>
<div class="blurb">${enc(l.message)}</div>
<label class="lead-pipe">Pipeline <select data-id="${enc(l.id||l.created_at)}" onchange="setLeadStatus(this)">
${['new','contacted','booked','won','lost'].map(s=>`<option value="${s}"${(l.status||'new')===s?' selected':''}>${s}</option>`).join('')}
</select></label>${when(l.created_at)}`)},
{id:'credentials',label:'🔑 Credentials'}
];
let CUR='overview';
let CMP={sel:null,plats:new Set(),media:[],tpl:{platforms:[],hashtag_sets:{},templates:[]},bt:{},services:[]};
$('#tabs').innerHTML=TABS.map(t=>`<button data-id="${t.id}" class="${t.id===CUR?'on':''}" onclick="go('${t.id}')">${t.label}</button>`).join('');
window.go=id=>{closeBrief();CUR=id;document.querySelectorAll('#tabs button').forEach(b=>b.classList.toggle('on',b.dataset.id===id));
$('#q').value='';$('#sort').dataset.tab='';render();};
function cardWrap(inner){return `<div class="card"><div class="body">${inner}</div></div>`;}
// Lead pipeline (dtd:C 2026-07-26): badge colour + status advance. Re-render on save so
// the card re-sorts/re-filters immediately (server re-reads status from disk).
function leadBadge(s){return s==='won'||s==='booked'?'b-good':s==='contacted'?'b-warn':s==='lost'?'b-bad':'b-mut';}
// One-tap call: normalize a stored phone to a tel: value. Returns '' for lengths that aren't
// a real US (10, or 11 w/ leading 1) or E.164 international (11–15) number, so a garbage/partial
// phone renders as plain text rather than a tappable dead link.
function telDigits(p){let d=String(p||'').replace(/\D/g,'');if(d.length===11&&d[0]==='1')return '+1'+d.slice(1);if(d.length===10)return '+1'+d;if(d.length>=11&&d.length<=15)return '+'+d;return '';}
// Worklist priority: bucket (uncontacted+cold, then uncontacted, contacted, done) and WITHIN a
// bucket the oldest-waiting lead first (encode created_at into the key; 1e13 > any ms timestamp
// so buckets never overlap). Ascending sort => longest-waiting cold lead sits at the very top.
function leadPriority(l){const s=l.status||'new';const p=(s==='new')?(isCold(l.created_at)?0:1):(s==='contacted'?2:3);const t=l.created_at?new Date(l.created_at).getTime():0;return p*1e13+t;}
window.setLeadStatus=function(sel){
const id=sel.dataset.id, status=sel.value; sel.disabled=true;
const row=(ROWCACHE.leads||[]).find(x=>(x.id||x.created_at)===id);
const prev=row?row.status:'new';
fetch('/api/admin/lead-status',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id,status})})
.then(r=>r.json()).then(j=>{
sel.disabled=false;
if(j&&j.ok){
if(row) row.status=status;
// update the badge IN PLACE — no full re-render, so scroll position + the owner's
// spot in the list are preserved (contrarian gate, 2026-07-26).
const card=sel.closest('.card'), badge=card&&card.querySelector('.badge');
if(badge){ badge.textContent=status; badge.className='badge '+leadBadge(status); }
} else { sel.value=prev; } // revert so the control never lies about what saved
})
.catch(()=>{ sel.disabled=false; sel.value=prev; });
};
// Expandable SWOT · costs · payback panel for an idea card. Renders only the
// sub-objects that exist, so ideas without analysis are unaffected (additive).
function swotCol(title,arr,cls){
if(!Array.isArray(arr)||!arr.length) return '';
return `<div class="swot-col ${cls}"><h5>${title}</h5><ul>${arr.map(x=>`<li>${enc(x)}</li>`).join('')}</ul></div>`;
}
function costRows(obj){
return Object.entries(obj).filter(([k,v])=>k!=='recommended'&&k!=='note'&&typeof v!=='object')
.map(([k,v])=>`<div class="kv"><span>${enc(k.replace(/_/g,' '))}</span><span>${enc(v)}</span></div>`).join('');
}
function analysisPanel(s){
const w=s.swot, c=s.costs; if(!w && !c) return '';
const hasPricing = c && Object.entries(c).some(([k,v])=>k!=='payback_model'&&v&&typeof v==='object'&&'recommended' in v);
const hasPayback = c && c.payback_model && typeof c.payback_model==='object';
let html='';
if(w){
html+=`<div class="swot-grid">${swotCol('💪 Strengths',w.strengths,'s-str')}${swotCol('⚠️ Weaknesses',w.weaknesses,'s-wk')}${swotCol('🚀 Opportunities',w.opportunities,'s-op')}${swotCol('🛑 Threats',w.threats,'s-th')}</div>`;
}
if(c){
if(c.note) html+=`<div class="blurb" style="font-style:italic;margin:4px 0">${enc(c.note)}</div>`;
for(const [key,val] of Object.entries(c)){
if(key==='note'||!val||typeof val!=='object') continue;
// payback_model is a MODELLED OUTCOME, not a purchasing option — label it so
// a client never misreads it as a third price. (contrarian gate, 2026-07-26)
if(key==='payback_model'){
html+=`<div class="cost-card model"><h5>📈 Payback model <span class="badge b-mut">modelled · not a price</span></h5>${costRows(val)}</div>`;
continue;
}
const rec = val.recommended===true?' <span class="badge b-good">recommended</span>'
: val.recommended===false?' <span class="badge b-mut">fallback</span>':'';
html+=`<div class="cost-card"><h5>${enc(key.replace(/_/g,' '))}${rec}</h5>${costRows(val)}</div>`;
}
}
// Honest summary — only name sub-sections that are actually populated.
const parts=[]; if(w) parts.push('SWOT'); if(hasPricing) parts.push('Costs'); if(hasPayback) parts.push('Payback');
const summary = parts.join(' · ') || 'Analysis';
return `<details class="analysis"><summary>${summary}</summary><div class="analysis-body">${html}</div></details>`;
}
const gridWrap=cards=>`<div class="grid">${cards}</div>`;
// --- tab-aware sort (dtd:A, 2026-07-26) — every api grid gets Newest/Oldest +
// domain-specific keys + Title A–Z; selection persists per-tab in localStorage.
const SORTKEYS={
suggestions:[{label:'Impact ↓',field:'impact',numeric:true,dir:-1},{label:'Effort ↑',field:'effort',numeric:true,dir:1},{label:'Priority ↓',calc:r=>(+r.impact||0)*2-(+r.effort||0),numeric:true,dir:-1}],
competitors:[{label:'Rating ↓',field:'rating',numeric:true,dir:-1},{label:'Reviews ↓',field:'reviews',numeric:true,dir:-1}],
services:[{label:'Price ↑',field:'price',numeric:true,dir:1},{label:'Price ↓',field:'price',numeric:true,dir:-1}],
ads:[{label:'Budget ↓',field:'budget_month',numeric:true,dir:-1}],
holidays:[{label:'Date ↑',field:'date',dir:1}],
directories:[{label:'Priority',field:'priority',dir:1}],
leads:[{label:'Status',field:'status',dir:1}]
};
const TITLEFIELD={suggestions:'title',competitors:'name',services:'name',ads:'headline',holidays:'name',directories:'name',leads:'name'};
function sortOptsFor(id){
const opts=[{label:'Newest',field:'created_at',dir:-1},{label:'Oldest',field:'created_at',dir:1},...(SORTKEYS[id]||[])];
if(TITLEFIELD[id]) opts.push({label:'Title A–Z',field:TITLEFIELD[id],dir:1});
// Leads default to a WORKLIST order: uncontacted + cold surface first so the owner works
// the follow-ups that convert before browsing the rest.
if(id==='leads') opts.unshift({label:'Needs follow-up',calc:leadPriority,numeric:true,dir:1});
return opts;
}
function cmpBy(opt){
const get=opt.calc?opt.calc:(r=>r[opt.field]);
return (a,b)=>{
let x=get(a),y=get(b);
if(opt.numeric){x=Number(x)||0;y=Number(y)||0;return (x-y)*opt.dir;}
x=String(x==null?'':x).toLowerCase();y=String(y==null?'':y).toLowerCase();
return (x<y?-1:x>y?1:0)*opt.dir;
};
}
// --- Action Brief (dtd:A, 2026-07-26): one-page printable/copyable idea brief ---
let ROWCACHE={}; // last-fetched rows per tab, so the brief can look an idea up
let BRIEFCUR=null;
function briefHTML(s){
const pri=(+s.impact||0)*2-(+s.effort||0);
let h=`<h2>${enc(s.title)}</h2><div class="meta">${enc(s.category||'')} · Impact ${enc(s.impact)} · Effort ${enc(s.effort)} · Priority ${pri}${s.created_at?' · '+fmtDate(s.created_at):''}</div>`;
h+=`<div class="sec"><h4>Rationale</h4><p>${enc(s.rationale||'')}</p></div>`;
const w=s.swot;
if(w){const col=(t,a)=>Array.isArray(a)&&a.length?`<div class="sec"><h4>${t}</h4><ul>${a.map(x=>`<li>${enc(x)}</li>`).join('')}</ul></div>`:'';
h+=col('Strengths',w.strengths)+col('Weaknesses',w.weaknesses)+col('Opportunities',w.opportunities)+col('Threats',w.threats);}
const c=s.costs;
if(c){
if(c.note) h+=`<div class="sec"><h4>Costs — note</h4><p>${enc(c.note)}</p></div>`;
for(const [k,v] of Object.entries(c)){
if(k==='note'||!v||typeof v!=='object') continue;
const rows=Object.entries(v).filter(([kk,vv])=>kk!=='recommended'&&typeof vv!=='object').map(([kk,vv])=>`<li><b>${enc(kk.replace(/_/g,' '))}:</b> ${enc(vv)}</li>`).join('');
h+=`<div class="sec"><h4>${enc(k.replace(/_/g,' '))}</h4><ul>${rows}</ul></div>`;
}
}
return h;
}
function briefText(s){
const pri=(+s.impact||0)*2-(+s.effort||0);
let t=`${s.title}\n${s.category||''} · Impact ${s.impact} · Effort ${s.effort} · Priority ${pri}\n\nRATIONALE\n${s.rationale||''}\n`;
const w=s.swot;
if(w){const sec=(n,a)=>Array.isArray(a)&&a.length?`\n${n.toUpperCase()}\n`+a.map(x=>'- '+x).join('\n')+'\n':'';
t+=sec('Strengths',w.strengths)+sec('Weaknesses',w.weaknesses)+sec('Opportunities',w.opportunities)+sec('Threats',w.threats);}
const c=s.costs;
if(c){for(const [k,v] of Object.entries(c)){if(k==='note'||!v||typeof v!=='object')continue;
t+=`\n${k.replace(/_/g,' ').toUpperCase()}\n`+Object.entries(v).filter(([kk,vv])=>kk!=='recommended'&&typeof vv!=='object').map(([kk,vv])=>`- ${kk.replace(/_/g,' ')}: ${vv}`).join('\n')+'\n';}}
return t.trim();
}
window.openBrief=function(id){
const s=(ROWCACHE.suggestions||[]).find(x=>x.id===id); if(!s) return;
BRIEFCUR=s; document.getElementById('brief-body').innerHTML=briefHTML(s);
document.getElementById('brief-modal').classList.add('open');
};
window.closeBrief=function(){document.getElementById('brief-modal').classList.remove('open');};
function briefToast(){const b=document.querySelector('.brief-actions button:nth-child(2)'); if(b){const o=b.textContent;b.textContent='✓ Copied';setTimeout(()=>{b.textContent=o;},1500);}}
function fallbackCopy(txt){const ta=document.createElement('textarea');ta.value=txt;ta.style.position='fixed';ta.style.opacity='0';document.body.appendChild(ta);ta.select();try{document.execCommand('copy');briefToast();}catch(e){}document.body.removeChild(ta);}
window.copyBrief=function(){if(!BRIEFCUR)return;const txt=briefText(BRIEFCUR);
if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(txt).then(briefToast).catch(()=>fallbackCopy(txt));}else fallbackCopy(txt);};
document.addEventListener('keydown',e=>{if(e.key==='Escape')closeBrief();});
function render(){
const tab=TABS.find(t=>t.id===CUR);
const showCtl=!!tab.api; $('#ctl').style.display=showCtl?'':'none';
if(tab.id==='overview') return renderOverview();
if(tab.id==='promos') return renderPromos();
if(tab.id==='compose') return renderCompose();
if(tab.id==='best-post') return renderBestPost();
if(tab.id==='best-market') return renderBestMarket();
if(tab.id==='google') return renderGoogle();
if(tab.id==='credentials') return renderCreds();
// generic data bucket
fetch(tab.api).then(r=>r.json()).then(rows=>{
ROWCACHE[tab.id]=rows;
const q=$('#q').value.trim().toLowerCase();
let list=rows;
if(q&&tab.search) list=rows.filter(r=>tab.search.some(f=>String(r[f]||'').toLowerCase().includes(q)));
// tab-aware sort: (re)build the sort select on tab change, then apply the chosen key
const opts=sortOptsFor(tab.id), sortEl=$('#sort');
if(sortEl.dataset.tab!==tab.id){
sortEl.innerHTML=opts.map((o,i)=>`<option value="${i}">${enc(o.label)}</option>`).join('');
const saved=localStorage.getItem('pcwadmin.sort.'+tab.id);
sortEl.value=(saved!=null&&Number(saved)<opts.length)?saved:'0';
sortEl.dataset.tab=tab.id;
}
const opt=opts[Number(sortEl.value)||0]||opts[0];
list=[...list].sort(cmpBy(opt));
$('#count').textContent=list.length+' item'+(list.length!==1?'s':'');
const exportBar=tab.id==='leads'?`<div style="margin-bottom:12px"><a href="/api/admin/leads.csv" style="display:inline-block;background:var(--brand);color:#001018;font-weight:700;padding:8px 14px;border-radius:8px;text-decoration:none">⬇ Export leads CSV</a></div>`:'';
$('#view').innerHTML=exportBar+(list.length?gridWrap(list.map(tab.card).join('')):'<p style="color:var(--mut)">Nothing here yet.</p>');
});
}
// Lead funnel roll-up (dtd:D 2026-07-26) — computed from /api/admin/leads (which carries
// each lead's pipeline status), so the owner sees funnel health where they already look.
// A "new" lead older than 48h is going cold — flag it so the owner works it first.
function isCold(iso){ return !!iso && (Date.now()-new Date(iso).getTime())>48*36e5; }
function leadFunnel(leads){
const st={new:0,contacted:0,booked:0,won:0,lost:0}; let week=0,cold=0; const wk=Date.now()-7*864e5;
(leads||[]).forEach(l=>{ const s=l.status||'new'; st[s]=(st[s]||0)+1; const t=l.created_at?new Date(l.created_at).getTime():0; if(t>=wk) week++; if(s==='new'&&isCold(l.created_at)) cold++; });
const total=(leads||[]).length;
return {st,total,week,cold,conv:total?Math.round((st.won/total)*100):0};
}
function funnelCard(f){
const cells=[['new','new · unworked'],['contacted','contacted'],['booked','booked'],['won','won'],['lost','lost']];
return `<div class="card"><div class="body">
<div class="ttl">📨 Lead funnel</div>
<div class="stat">${cells.map(([k,lab])=>`<div class="s"><b>${f.st[k]}</b><span>${lab}</span></div>`).join('')}</div>
<div class="kv"><span>New this week</span><b>${f.week}</b></div>
<div class="kv"><span>Conversion (won / total)</span><b>${f.conv}%</b></div>
${f.st.new>0?`<div class="blurb" style="color:var(--warn)">⚠️ ${f.st.new} new lead${f.st.new!==1?'s':''} waiting${f.cold>0?` — <b style="color:var(--bad)">${f.cold} going cold (>48h)</b>`:''} → <a href="#" onclick="go('leads');return false" style="color:var(--brand)">open Leads</a></div>`:'<div class="blurb" style="color:var(--mut)">No unworked leads — all caught up.</div>'}
</div></div>`;
}
// Self-hosted analytics-lite (2026-07-27): first-party aggregate counts → honest conversion.
function analyticsCard(a){
a=a||{}; const t7=a.last7||{}, tot=a.total||{}, td=a.today||{}; const n=v=>v||0;
return `<div class="card"><div class="body">
<div class="ttl">📊 Site analytics <span class="badge b-mut" style="font-weight:600">first-party · no cookies</span></div>
<div class="stat">
<div class="s"><b>${n(td.pageview)}</b><span>views today</span></div>
<div class="s"><b>${n(t7.pageview)}</b><span>views · 7d</span></div>
<div class="s"><b>${n(t7.form_start)}</b><span>form starts · 7d</span></div>
<div class="s"><b>${n(t7.form_submit)}</b><span>bookings · 7d</span></div>
<div class="s"><b>~${a.conversion7!=null?a.conversion7:0}%</b><span>view→book · 7d (est.)</span></div>
</div>
<div class="kv"><span>All-time views / bookings</span><b>${n(tot.pageview)} / ${n(tot.form_submit)}</b></div>
<div class="blurb" style="color:var(--mut)">Aggregate first-party counts — no cookies, no personal data. Visitors who block JS or bots that don't run it aren't counted, so treat these as directional.</div>
</div></div>`;
}
function renderOverview(){
Promise.all([
fetch('/api/health').then(r=>r.json()),
fetch('/api/admin/credentials').then(r=>r.json()).catch(()=>[]),
fetch('/api/admin/leads').then(r=>r.json()).catch(()=>[]),
fetch('/api/admin/analytics').then(r=>r.json()).catch(()=>({}))
]).then(([h,creds,leads,an])=>{
const c=h.counts;
const places=(creds||[]).find(k=>k.key==='GOOGLE_PLACES_API_KEY');
const placesTag=places&&places.present
? `<span class="tag b-good">Google Places: live ${enc(places.hint)}</span>`
: `<span class="tag b-mut">Google Places: key not set (mock)</span>`;
$('#view').innerHTML=`
<div class="stat">
${Object.entries(c).map(([k,v])=>`<div class="s"><b>${v}</b><span>${k}</span></div>`).join('')}
</div>
${funnelCard(leadFunnel(leads))}
${analyticsCard(an)}
<div class="card"><div class="body">
<div class="ttl">Growth command center</div>
<div class="blurb">Every bucket below is seeded from live SFV market research. Buckets with a search box are data-driven; the rest (best-times, Google, credentials) are tools. Start in <b>✨ Compose & Post</b> to build a social post from your real photos/videos and open each network's composer in one click. Cards show their created date + time. All write/spend/post actions are gated.</div>
<div class="tags">
<span class="tag">Market: San Fernando Valley</span>
<span class="tag">IG: @prestigecwash</span>
<span class="tag">Media: Nano Banana + SeeDance</span>
${placesTag}
</div>
</div></div>`;
$('#count').textContent='';
});
}
function renderBestPost(){
fetch('/api/admin/best-times').then(r=>r.json()).then(bt=>{
const pt=bt.post_times||{};
$('#view').innerHTML=`
<p class="sub" style="color:var(--mut)">${enc(pt.note||'')}</p>
<div class="grid">${Object.entries(pt).filter(([k])=>k!=='note').map(([plat,times])=>cardWrap(
`<div class="ttl">${enc(plat)}</div><div class="tags">${(times||[]).map(x=>`<span class="tag">${enc(x)}</span>`).join('')}</div>`
)).join('')}</div>`;
});
}
function renderBestMarket(){
fetch('/api/admin/best-times').then(r=>r.json()).then(bt=>{
const tr=bt.traffic||{},mk=bt.market_times||{};
$('#view').innerHTML=`
<div class="card"><div class="body"><div class="ttl">Weekly demand</div>
<p class="blurb">${enc(tr.note||'')}</p>
<div class="heat">${(tr.by_day||[]).map(d=>`<div class="d" style="border-color:${d.demand>=5?'var(--good)':d.demand>=4?'var(--warn)':'var(--line)'}">
<b>${enc(d.day)}</b>${'▮'.repeat(d.demand)}<div style="font-size:10px;color:var(--mut);margin-top:4px">${enc(d.note)}</div></div>`).join('')}</div>
</div></div>
<div class="card"><div class="body"><div class="ttl">Spend triggers</div>
<p class="blurb">${enc(mk.note||'')}</p>
${(mk.triggers||[]).map(x=>`<div class="kv"><span>⚡ ${enc(x)}</span></div>`).join('')}</div></div>`;
});
}
function renderGoogle(){
fetch('/api/admin/places').then(r=>r.json()).then(p=>{
const mock=p._mock;
$('#view').innerHTML=`
${mock?'<div class="card"><div class="body"><span class="badge b-warn">mock data</span><div class="blurb" style="margin-top:8px">Live data needs <code>GOOGLE_PLACES_API_KEY</code> + <code>PCW_PLACE_ID</code> (Steve-gated setup). Then run <code>npm run read:places</code>.</div></div></div>':''}
<div class="card"><div class="body">
<div class="ttl">${enc(p.name)}</div>
<div class="blurb">${enc(p.address)} ${p.rating?'· '+rating(p.rating)+' ('+(p.reviews_count||0)+')':''}</div>
<div class="ttl" style="font-size:14px;margin-top:10px">Hours</div>
${(p.hours||[]).map(h=>`<div class="kv"><span>${enc(h)}</span></div>`).join('')}
${(p.videos&&p.videos.length)?`<div class="ttl" style="font-size:14px;margin-top:14px">Videos (${p.videos.length})</div>
<div class="grid" style="--cols:3;gap:8px;margin-top:8px">${p.videos.map(v=>`<video src="${v.src}" ${v.poster?`poster="${v.poster}"`:''} muted loop autoplay playsinline title="${enc(v.label||'')}" style="width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:10px;border:1px solid var(--line)"></video>`).join('')}</div>`:''}
${(p.photo_urls&&p.photo_urls.length)?`<div class="ttl" style="font-size:14px;margin-top:14px">Photos (${p.photo_urls.length})</div>
<div class="grid" style="--cols:3;gap:8px;margin-top:8px">${p.photo_urls.map(u=>`<img src="${u}${u.startsWith('/api/')?'?w=500':''}" loading="lazy" style="width:100%;aspect-ratio:4/3;object-fit:cover;border-radius:10px;border:1px solid var(--line)">`).join('')}</div>`:''}
${(p.reviews&&p.reviews.length)?`<div class="ttl" style="font-size:14px;margin-top:14px">Recent reviews</div>
${p.reviews.slice(0,4).map(r=>`<div class="kv" style="flex-direction:column;align-items:flex-start;gap:2px"><span class="rate">★ ${r.rating} — ${enc(r.author)}</span><span style="color:var(--mut);font-size:12px">${enc((r.text||'').slice(0,180))}${(r.text||'').length>180?'…':''}</span></div>`).join('')}`:''}
<div style="margin-top:14px;display:flex;gap:8px;flex-wrap:wrap">
<button class="btn ghost" onclick="draftUpdate('hours')">✎ Update hours in GBP</button>
<button class="btn ghost" onclick="draftUpdate('photos')">✎ Add photos in GBP</button>
<button class="btn ghost" onclick="draftUpdate('services')">✎ Update services in GBP</button>
</div>
<div class="when" id="draftMsg"></div>
</div></div>`;
});
}
window.draftUpdate=field=>{
fetch('/api/admin/place/draft-update',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({field})})
.then(r=>r.json()).then(j=>{$('#draftMsg').textContent=j.message;if(j.url&&j.url.length>28)window.open(j.url,'_blank');});
};
function renderCreds(){
fetch('/api/admin/credentials').then(r=>r.json()).then(list=>{
$('#view').innerHTML=`<div class="card"><div class="body">
<div class="ttl">Credentials</div>
<p class="blurb">Presence + last-4 only — full secret values are never shown here (managed via the <code>/secrets</code> skill).</p>
${list.map(k=>`<div class="kv"><span><b>${enc(k.label)}</b><br><span style="color:var(--mut);font-size:12px">${enc(k.purpose)}</span></span>
<span>${k.present?`<span class="badge b-good">set ${enc(k.hint)}</span>`:'<span class="badge b-bad">not set</span>'}</span></div>`).join('')}
</div></div>`;
$('#count').textContent='';
});
}
// ---- Promos: HyperFrames-generated brand films ----------------------------
const KIND_LABEL={'brand-promo':'brand promo','hyperframes-motion':'motion cut','internal-pitch':'internal · pitch'};
function renderPromos(){
$('#count').textContent='';
$('#view').innerHTML='<p style="color:var(--mut)">Loading promos…</p>';
fetch('/api/admin/promos').then(r=>r.json()).then(list=>{
$('#count').textContent=list.length+' film'+(list.length!==1?'s':'');
if(!list.length){ $('#view').innerHTML='<p style="color:var(--mut)">No promos yet. Drop HyperFrames renders into <code>media/promos/</code> and register them in <code>data/promos.json</code>.</p>'; return; }
const card=p=>{
const kindClass='k-'+enc(p.kind||'brand-promo');
const media=p.ready
? `<div class="pv"><video src="${enc(p.src)}" ${p.poster?`poster="${enc(p.poster)}"`:''} controls preload="none" playsinline></video>${p.duration_label?`<span class="dur">${enc(p.duration_label)}</span>`:''}</div>`
: `<div class="pv" style="aspect-ratio:16/9;display:grid;place-items:center;color:var(--bad);font-size:13px">⚠ file missing on disk</div>`;
const best=(p.best_for||[]).map(b=>`<span class="tag">${enc(b)}</span>`).join('');
return `<div class="promo">
${media}
<div class="pb">
<div class="row"><span class="badge ${kindClass}">${enc(KIND_LABEL[p.kind]||p.kind||'promo')}</span>${p.ready?'':'<span class="badge b-bad">unavailable</span>'}</div>
<div class="pttl">${enc(p.title)}</div>
<div class="blurb">${enc(p.blurb)}</div>
${best?`<div class="tags" style="margin-top:8px">${best}</div>`:''}
<div class="pbtns">
${p.ready?`<a class="btn ghost" href="${enc(p.src)}" download="${enc(p.id)}.mp4">⬇ Download</a>
<button class="btn" data-promo="${enc(JSON.stringify({url:p.src,poster:p.poster,name:p.title}))}" onclick='promoUse(JSON.parse(this.dataset.promo))'>✨ Use in a post</button>`:''}
</div>
${when(p.created_at)}
</div>
</div>`;
};
$('#view').innerHTML=`
<div class="hint" style="margin:0 0 14px">HyperFrames-generated brand films, staged for the growth center. Each also appears as an attachable asset in <b>✨ Compose & Post</b>. Files are large — the player streams on demand (nothing preloads).</div>
<div class="promos">${list.map(card).join('')}</div>`;
}).catch(()=>{ $('#view').innerHTML='<p style="color:var(--bad)">Could not load promos.</p>'; });
}
// Hand a promo to the composer as the selected asset, then jump to Compose.
window.promoUse=asset=>{ CMP.sel={url:asset.url,kind:'video',name:asset.name,poster:asset.poster||''}; go('compose'); };
// ---- Compose & Post: create socials + post from ONE place -----------------
function renderCompose(){
$('#count').textContent='';
$('#view').innerHTML='<p style="color:var(--mut)">Loading assets…</p>';
Promise.all([
fetch('/api/admin/media').then(r=>r.json()).catch(()=>[]),
fetch('/api/admin/social/templates').then(r=>r.json()).catch(()=>({platforms:[],hashtag_sets:{},templates:[]})),
fetch('/api/admin/best-times').then(r=>r.json()).catch(()=>({})),
fetch('/api/admin/services').then(r=>r.json()).catch(()=>[])
]).then(([media,tpl,bt,services])=>{
CMP.media=media; CMP.tpl=tpl; CMP.bt=bt; CMP.services=services;
paintCompose();
loadDrafts();
});
}
function paintCompose(){
const t=CMP.tpl, plats=t.platforms||[], sets=t.hashtag_sets||{}, tpls=t.templates||[];
const assetCard=m=>`<div class="asset ${CMP.sel&&CMP.sel.url===m.url?'on':''}" title="${enc(m.name)}"
onclick='cmpPick(${JSON.stringify({url:m.url,kind:m.kind,name:m.name,poster:m.poster||''})})'>
${m.kind==='video'
? `<video src="${enc(m.url)}" ${m.poster?`poster="${enc(m.poster)}"`:''} muted loop playsinline></video><span class="k">▶ video</span>`
: `<img src="${enc(m.url)}" loading="lazy" alt="${enc(m.name)}"><span class="k">photo</span>`}
</div>`;
$('#view').innerHTML=`
<div class="cmp">
<div>
<div class="lab">1 · Start from a template <span style="font-weight:600;text-transform:none;color:var(--mut)">(optional — fills the caption)</span></div>
<div class="chips" id="cmpTpls">${tpls.map(x=>`<span class="chip" onclick="cmpTpl('${x.id}')">${enc(x.name)} · <span style="color:var(--gold)">${enc(x.theme)}</span></span>`).join('')||'<span class="hint">No templates yet.</span>'}</div>
<div class="chips" style="margin-top:8px"><label style="font-size:12px;color:var(--mut)">Fill {service} with
<select id="cmpSvc" style="background:var(--panel);border:1px solid var(--line);color:inherit;border-radius:8px;padding:4px 8px;font:inherit">
<option value="">— pick a service —</option>
${(CMP.services||[]).map(s=>`<option value="${enc(s.id)}">${enc(s.name)}</option>`).join('')}
</select></label></div>
<div class="lab">2 · Caption</div>
<textarea id="cmpCap" placeholder="Write your post… or pick a template above."></textarea>
<div style="font-size:11px;color:var(--mut);margin-top:4px"><span id="cmpLen">0</span> chars</div>
<div class="lab">Add hashtags</div>
<div class="chips">${Object.keys(sets).map(k=>`<span class="chip" onclick="cmpTags('${k}')">+ ${enc(k)} <span style="color:var(--mut)">(${sets[k].length})</span></span>`).join('')}</div>
<div class="lab">3 · Post to</div>
<div class="chips" id="cmpPlats">${plats.map(p=>`<span class="chip ${CMP.plats.has(p.id)?'on':''}" data-p="${p.id}" onclick="cmpPlat('${p.id}')">${p.emoji||'•'} ${enc(p.label)}</span>`).join('')}</div>
<div class="hint" id="cmpHint"></div>
<div class="cmpbtns">
<button class="btn" onclick="cmpCopy()">📋 Copy caption</button>
<button class="btn ghost" onclick="cmpDownload()">⬇ Download asset</button>
<button class="btn ghost" onclick="cmpSave()">💾 Save draft</button>
</div>
<div class="cmpbtns" id="cmpOpen"></div>
<div class="when" id="cmpMsg"></div>
</div>
<div>
<div class="lab" style="margin-top:0">Assets <span style="font-weight:600;text-transform:none;color:var(--mut)">(click to attach)</span></div>
<div class="assets">${CMP.media.map(assetCard).join('')||'<span class="hint">No media found in /media.</span>'}</div>
<div class="lab">Selected</div>
<div class="hint" id="cmpSel">${CMP.sel?enc(CMP.sel.name):'None — the post will be text-only.'}</div>
</div>
</div>
<div class="lab" style="margin-top:26px">📅 Saved drafts</div>
<div id="cmpDrafts"><span class="hint">Loading…</span></div>`;
const ta=$('#cmpCap'); ta.oninput=()=>{$('#cmpLen').textContent=ta.value.length;};
cmpSyncHint();
}
window.cmpPick=a=>{ CMP.sel=(CMP.sel&&CMP.sel.url===a.url)?null:a;
document.querySelectorAll('.asset').forEach(el=>el.classList.remove('on'));
if(CMP.sel){[...document.querySelectorAll('.asset')].find(el=>el.querySelector('[src="'+CSS.escape(CMP.sel.url)+'"]'))?.classList.add('on');}
$('#cmpSel').textContent=CMP.sel?CMP.sel.name:'None — the post will be text-only.'; };
window.cmpTpl=id=>{ const x=(CMP.tpl.templates||[]).find(t=>t.id===id); if(!x)return;
const svc=(CMP.services||[]).find(s=>s.id===$('#cmpSvc').value)||{};
let cap=x.caption.replace(/\{service\}/g,svc.name||'our detailing').replace(/\{blurb\}/g,svc.blurb||'done right, by hand');
const tags=(x.hashtags||[]).flatMap(k=>CMP.tpl.hashtag_sets[k]||[]);
const ta=$('#cmpCap'); ta.value=cap+(tags.length?'\n\n'+tags.join(' '):''); $('#cmpLen').textContent=ta.value.length; };
window.cmpTags=k=>{ const tags=(CMP.tpl.hashtag_sets[k]||[]); const ta=$('#cmpCap');
const have=ta.value; const add=tags.filter(t=>!have.includes(t)); if(!add.length)return;
ta.value=(have.trim()+' '+add.join(' ')).trim(); $('#cmpLen').textContent=ta.value.length; };
window.cmpPlat=id=>{ CMP.plats.has(id)?CMP.plats.delete(id):CMP.plats.add(id);
document.querySelector(`#cmpPlats [data-p="${id}"]`)?.classList.toggle('on'); cmpSyncHint(); };
function cmpSyncHint(){
const pt=(CMP.bt.post_times)||{}; const open=$('#cmpOpen'); const hint=$('#cmpHint');
const sel=[...CMP.plats]; const plats=CMP.tpl.platforms||[];
if(!sel.length){ hint.innerHTML='Pick one or more platforms — you\'ll get a one-click link into each native composer.'; open.innerHTML=''; return; }
const rows=sel.map(id=>{ const p=plats.find(x=>x.id===id)||{}; const key=Object.keys(pt).find(k=>k.toLowerCase().includes(id));
const times=key?pt[key]:null;
return `<div style="margin:4px 0">${p.emoji||'•'} <b>${enc(p.label||id)}</b>${times?` · ⏰ best: ${times.map(enc).join(', ')}`:''}<br><span style="color:var(--mut)">${enc(p.note||'')}</span></div>`; });
hint.innerHTML=rows.join('');
open.innerHTML=sel.map(id=>{ const p=plats.find(x=>x.id===id)||{}; return `<button class="btn ghost" onclick="cmpOpen('${enc(p.composeUrl||'')}')">↗ Open ${enc(p.label||id)}</button>`; }).join('');
}
window.cmpOpen=url=>{ if(url)window.open(url,'_blank','noopener'); const cap=$('#cmpCap').value;
if(cap&&navigator.clipboard){navigator.clipboard.writeText(cap).then(()=>{$('#cmpMsg').textContent='Caption copied — paste it into the composer that just opened.';});} };
window.cmpCopy=()=>{ const cap=$('#cmpCap').value; if(!cap)return; navigator.clipboard?.writeText(cap)
.then(()=>$('#cmpMsg').textContent='📋 Caption copied to clipboard.'); };
window.cmpDownload=()=>{ if(!CMP.sel){$('#cmpMsg').textContent='Pick an asset first.';return;}
const a=document.createElement('a'); a.href=CMP.sel.url; a.download=CMP.sel.name; document.body.appendChild(a); a.click(); a.remove();
$('#cmpMsg').textContent='⬇ Downloading '+CMP.sel.name+' — attach it in the composer.'; };
window.cmpSave=()=>{ const caption=$('#cmpCap').value.trim();
if(!caption&&!CMP.sel){$('#cmpMsg').textContent='Add a caption or pick an asset first.';return;}
fetch('/api/admin/social/draft',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({caption,platforms:[...CMP.plats],asset:CMP.sel})})
.then(r=>r.json()).then(j=>{ $('#cmpMsg').textContent=j.ok?'💾 Draft saved.':(j.error||'Save failed'); if(j.ok)loadDrafts(); }); };
function loadDrafts(){
fetch('/api/admin/social/drafts').then(r=>r.json()).then(list=>{
const el=$('#cmpDrafts'); if(!el)return;
el.innerHTML=list.length?`<div class="grid">${list.map(d=>cardWrap(
`<div class="row"><div class="cat">${(d.platforms||[]).map(enc).join(' · ')||'no platform'}</div><span class="badge b-warn">${enc(d.status)}</span></div>
${d.asset?`<div class="tags"><span class="tag">📎 ${enc(d.asset.name)}</span></div>`:''}
<div class="blurb" style="white-space:pre-wrap">${enc((d.caption||'').slice(0,240))}${(d.caption||'').length>240?'…':''}</div>${when(d.created_at)}`
)).join('')}</div>`:'<span class="hint">No drafts yet — compose one above and hit Save draft.</span>';
});
}
render();
// Admin ↔ live-view toggle. Set the browser flag so live pages can show a "back to admin"
// pill (invisible to customers), and add a persistent "Live view" pill here so the admin can
// always flip to the public site and back.
(function(){
try{ localStorage.setItem('pcw.admin','1'); }catch(e){}
var a=document.createElement('a');
a.href='/'; a.textContent='👁 Live view'; a.title='Switch to the live customer site';
a.setAttribute('aria-label','Switch to the live customer site');
a.style.cssText='position:fixed;left:12px;bottom:12px;z-index:95;background:var(--brand,#28c3ff);color:#001018;font:700 11px/1 system-ui,-apple-system,sans-serif;letter-spacing:.1em;text-transform:uppercase;padding:9px 13px;border-radius:6px;text-decoration:none;box-shadow:0 6px 18px rgba(0,0,0,.45)';
document.body.appendChild(a);
})();
</script>
</body>
</html>