← back to Prestige Car Wash
prestige Phase 1: server-side social-post queue (GET/POST/DELETE /api/admin/schedule) + inert publish-due.js worker + API-backed composer — deployed snapshot
723f0ac150f622b9faae267ab8fdbcb526db5661 · 2026-07-20 11:52:10 -0700 · Steve Abrams
Files touched
A reports/deployed-20260720/admin-index.htmlA reports/deployed-20260720/publish-due.jsA reports/deployed-20260720/schedule.jsonA reports/deployed-20260720/server.js
Diff
commit 723f0ac150f622b9faae267ab8fdbcb526db5661
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Jul 20 11:52:10 2026 -0700
prestige Phase 1: server-side social-post queue (GET/POST/DELETE /api/admin/schedule) + inert publish-due.js worker + API-backed composer — deployed snapshot
---
reports/deployed-20260720/admin-index.html | 544 +++++++++++++++++++++++++++++
reports/deployed-20260720/publish-due.js | 70 ++++
reports/deployed-20260720/schedule.json | 3 +
reports/deployed-20260720/server.js | 507 +++++++++++++++++++++++++++
4 files changed, 1124 insertions(+)
diff --git a/reports/deployed-20260720/admin-index.html b/reports/deployed-20260720/admin-index.html
new file mode 100644
index 0000000..83fab67
--- /dev/null
+++ b/reports/deployed-20260720/admin-index.html
@@ -0,0 +1,544 @@
+<!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="icon" type="image/jpeg" href="/assets/logo-ig.jpg">
+<link rel="apple-touch-icon" href="/assets/logo-ig.jpg">
+<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;
+ cursor:pointer;text-align:left;font-family:inherit;color:inherit;transition:border-color .15s,transform .15s,box-shadow .15s}
+ .stat .s:hover,.stat .s:focus-visible{border-color:var(--brand);transform:translateY(-2px);box-shadow:0 6px 18px rgba(40,195,255,.12);outline:none}
+ .stat .s:active{transform:translateY(0)}
+ .stat .s b{font-size:24px;color:var(--brand);display:block}
+ .stat .s span{font-size:12px;color:var(--mut)}
+ .stat .s .goarrow{float:right;color:var(--mut);font-size:12px;opacity:0;transition:opacity .15s}
+ .stat .s:hover .goarrow,.stat .s:focus-visible .goarrow{opacity:1}
+ a.tag{cursor:pointer}
+ a.tag:hover{border-color:var(--brand);color:var(--ink)}
+ .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}
+</style>
+</head>
+<body>
+<nav class="nav"><div class="wrap row">
+ <div class="brand"><span class="mk"><img src="/assets/logo-ig.jpg" alt="Prestige Car Wash" style="width:100%;height:100%;display:block;object-fit:cover"></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>
+
+<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>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>
+
+<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,'<');
+
+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)};
+
+// ---- tab definitions -------------------------------------------------------
+const TABS=[
+ {id:'overview',label:'📊 Overview'},
+ {id:'marketing',label:'🎨 Marketing'},
+ {id:'tips',label:'💵 Tips & Employees'},
+ {id:'specials',label:'📅 Daily Specials'},
+ {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:'partners',label:'🤝 Unlikely Partners',api:'/api/admin/partners',search:['name','type','idea','why'],
+ card:p=>cardWrap(`<div class="row"><div class="cat">${enc(p.type)}</div>
+ <span class="badge ${p.impact>=5?'b-good':p.impact>=4?'b-warn':'b-mut'}">impact ${p.impact} · effort ${p.effort}</span></div>
+ <div class="ttl">${enc(p.name)}</div>
+ <div class="blurb"><b>Play:</b> ${enc(p.idea)}</div>
+ <div class="blurb" style="opacity:.85"><b>Why:</b> ${enc(p.why)}</div>
+ <div class="tags"><span class="tag">${enc(p.address)}</span><span class="tag b-mut">${enc(p.status)}</span></div>${when(p.created_at)}`)},
+ {id:'reviews',label:'⭐ Reviews Intel'},
+ {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>${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'],
+ card:l=>cardWrap(`<div class="cat">${enc(l.service||'general')}</div>
+ <div class="ttl">${enc(l.name)}</div>
+ <div class="kv"><span>Phone</span><span>${enc(l.phone)}</span></div>
+ <div class="kv"><span>Email</span><span>${enc(l.email)}</span></div>
+ <div class="kv"><span>Vehicle</span><span>${enc(l.vehicle)}</span></div>
+ <div class="blurb">${enc(l.message)}</div>${when(l.created_at)}`)},
+ {id:'credentials',label:'🔑 Credentials'}
+];
+// deep-linkable tabs: #services, #competitors, … — back/forward + shareable URLs
+const hashTab=()=>{const h=location.hash.replace('#','');return TABS.some(t=>t.id===h)?h:'overview';};
+let CUR=hashTab();
+
+$('#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=>{if(!TABS.some(t=>t.id===id))id='overview';
+ CUR=id;document.querySelectorAll('#tabs button').forEach(b=>b.classList.toggle('on',b.dataset.id===id));
+ if(location.hash!=='#'+id)history.pushState(null,'','#'+id);
+ $('#q').value='';render();
+ document.querySelector(`#tabs button[data-id="${id}"]`)?.scrollIntoView({inline:'nearest',block:'nearest'});};
+window.addEventListener('popstate',()=>{const id=hashTab();if(id!==CUR){CUR=id;
+ document.querySelectorAll('#tabs button').forEach(b=>b.classList.toggle('on',b.dataset.id===id));
+ $('#q').value='';render();}});
+
+function cardWrap(inner){return `<div class="card"><div class="body">${inner}</div></div>`;}
+const gridWrap=cards=>`<div class="grid">${cards}</div>`;
+
+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==='marketing') return renderMarketing();
+ if(tab.id==='tips') return renderTips();
+ if(tab.id==='specials') return renderSpecials();
+ 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();
+ if(tab.id==='reviews') return renderReviews();
+ // generic data bucket
+ fetch(tab.api).then(r=>r.json()).then(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)));
+ $('#count').textContent=list.length+' item'+(list.length!==1?'s':'');
+ $('#view').innerHTML=list.length?gridWrap(list.map(tab.card).join('')):'<p style="color:var(--mut)">Nothing here yet.</p>';
+ });
+}
+
+function renderOverview(){
+ fetch('/api/health').then(r=>r.json()).then(h=>{
+ const c=h.counts;
+ $('#view').innerHTML=`
+ <div class="stat">
+ ${Object.entries(c).map(([k,v])=>`<button class="s" onclick="go('${k}')" title="Open ${k}" aria-label="Open ${k} (${v})">
+ <span class="goarrow">open →</span><b>${v}</b><span>${k}</span></button>`).join('')}
+ </div>
+ <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. Cards show their created date + time. All write/spend/post actions are gated — see the plan. <b>Tap any count above to jump into that bucket.</b></div>
+ <div class="tags">
+ <span class="tag">Market: San Fernando Valley</span>
+ <a class="tag" href="https://www.instagram.com/prestigecwash/" target="_blank" rel="noopener noreferrer">IG: @prestigecwash ↗</a>
+ <span class="tag">Media: Nano Banana + SeeDance</span>
+ <a class="tag b-mut" href="#credentials" onclick="go('credentials');return false">Google Places: key not set (mock) →</a>
+ </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('')}
+ <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 renderReviews(){
+ Promise.all([fetch('/api/admin/places').then(r=>r.json()),fetch('/api/admin/reviews-raw').then(r=>r.json())]).then(([p,raw])=>{
+ $('#view').innerHTML=`
+ <div class="stat">${(raw.platform_stats||[]).map(s=>`<div class="s"><b>${s.rating??'—'}</b><span>${enc(s.source)} (${s.count??'?'} reviews)</span></div>`).join('')}</div>
+ <div class="card"><div class="body">
+ <div class="ttl">⚠ Negatives — ops intel (never shown on the public site)</div>
+ ${(raw.negatives_for_ops||[]).map(r=>`<div style="border-top:1px solid var(--line);padding:10px 0">
+ <div class="row"><b>${enc(r.author||'anon')}</b><span class="badge b-bad">${r.rating??'?'}★ · ${enc(r.date||'')}</span></div>
+ <div class="blurb">“${enc(r.text)}”</div>
+ <div class="tags"><span class="tag b-warn">🔧 ${enc(r.ops_flag)}</span></div></div>`).join('')}
+ </div></div>
+ <div class="card" style="margin-top:14px"><div class="body">
+ <div class="ttl">Published on the site (curated positives)</div>
+ ${(p.reviews||[]).map(r=>`<div class="kv"><span>${enc(r.author)} · ${enc(r.source)} ${enc(r.date||'')}</span><span class="rate">${'★'.repeat(r.rating||5)}</span></div>`).join('')}
+ <div class="tags" style="margin-top:10px">
+ <span class="tag">Staff praised by name: ${(raw.staff_named_in_reviews||[]).map(enc).join(', ')||'—'}</span>
+ <span class="tag">Real price signals: ${(raw.real_price_signals||[]).map(enc).join(' · ')||'—'}</span>
+ </div>
+ </div></div>`;
+ $('#count').textContent='';
+ });
+}
+function renderCreds(){
+ Promise.all([fetch('/api/admin/credentials').then(r=>r.json()),
+ fetch('/api/admin/social').then(r=>r.json()),
+ fetch('/api/admin/employees').then(r=>r.json())]).then(([list,soc,emp])=>{
+ const sbadge=s=>s==='have'?'<span class="badge b-good">have it</span>'
+ :s==='claim'?'<span class="badge b-warn">claim listing</span>':'<span class="badge b-bad">create account</span>';
+ $('#view').innerHTML=`
+ <div class="card"><div class="body">
+ <div class="ttl">📱 Social media accounts</div>
+ <p class="blurb">The account registry — handles, URLs, status. Passwords never live here; they go in the secrets manager and show below as present/last-4 only.</p>
+ ${(soc.social||[]).map(a=>`<div class="kv" style="align-items:start"><span style="max-width:70%"><b>${enc(a.platform)}</b> ${a.handle?`<code>${enc(a.handle)}</code>`:''}
+ <br><span style="color:var(--mut);font-size:12px">${enc(a.notes)}</span>
+ ${a.url?`<br><a href="${enc(a.url)}" target="_blank" style="font-size:12px">open ↗</a>`:''}</span>
+ ${sbadge(a.status)}</div>`).join('')}
+ </div></div>
+ <div class="card" style="margin-top:14px"><div class="body">
+ <div class="ttl">💳 Employee Stripe accounts (tips payout)</div>
+ <p class="blurb">${enc((soc.payments||{}).provider||'Stripe Connect')} — ${enc((soc.payments||{}).platform_account_status||'')}</p>
+ ${(emp.employees||[]).map(e=>`<div class="kv"><span><b>${enc(e.name)}</b> <code>${enc(e.code)}</code></span>
+ ${e.stripe_account_id?`<span class="badge b-good">connected ${enc(e.stripe_account_id.slice(-4))}</span>`:'<span class="badge b-bad">not onboarded</span>'}</div>`).join('')}
+ </div></div>
+ <div class="card" style="margin-top:14px"><div class="body">
+ <div class="ttl">🔑 API keys & secrets</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='';
+ });
+}
+
+// ---- Marketing: Hootsuite-style social composer (left) + collapsed asset library (right) ----
+// Caption templates seed the composer from the attached asset.
+const CAPTIONS={
+ Instagram:a=>`✨ ${a&&a.name&&a.name.includes('foam')?'That foam cannon hits different.':'Fresh off the line at Prestige.'} 100% hand wash, Reseda & Saticoy. 📍7601 Reseda Blvd #handcarwash #sanfernandovalley #reseda #detailing`,
+ TikTok:a=>`POV: your car gets the Prestige treatment 🚗💦 #carwash #satisfying #detailing #losangeles #fyp`,
+ Facebook:a=>`The price on the sign is the price you pay — no upsells, every seat touched, guaranteed. Come see us at 7601 Reseda Blvd (Reseda & Saticoy). Open 7 days.`,
+ YouTube:a=>`${a&&a.kind==='video'?'Watch the full hand-wash process':'Prestige Car Wash'} — Reseda's family-owned hand car wash. Flat pricing, full detail, ceramic coating. 7601 Reseda Blvd, Reseda CA.`
+};
+// map any social platform label -> the closest caption template
+function tmplKey(platform){const p=(platform||'').toLowerCase();
+ if(p.includes('insta'))return 'Instagram'; if(p.includes('tiktok'))return 'TikTok';
+ if(p.includes('you'))return 'YouTube'; return 'Facebook';}
+
+const SKEY='pcwadmin.schedule'; // localStorage fallback only (offline / old backend)
+let MKT_ASSET=null, MKT_SOCIALS=[], MKT_SEL=new Set(), MKT_CAP='', MKT_WHEN='', MKT_QUEUE=[];
+const lsLoad=()=>{try{return JSON.parse(localStorage.getItem(SKEY)||'[]')}catch(e){return []}};
+const lsSave=q=>localStorage.setItem(SKEY,JSON.stringify(q));
+// Server-side queue (data/schedule.json). Falls back to localStorage if the API
+// isn't reachable, so the composer keeps working either way.
+function refreshQueue(){
+ return fetch('/api/admin/schedule').then(r=>r.ok?r.json():Promise.reject())
+ .then(d=>{MKT_QUEUE=(d&&d.posts)||[];renderQueue();})
+ .catch(()=>{MKT_QUEUE=lsLoad();renderQueue();});
+}
+
+function renderMarketing(){
+ Promise.all([fetch('/api/admin/assets').then(r=>r.json()),
+ fetch('/api/admin/social').then(r=>r.json())]).then(([assets,soc])=>{
+ MKT_SOCIALS=soc.social||[];
+ const buckets={};assets.forEach(a=>{(buckets[a.bucket]=buckets[a.bucket]||[]).push(a);});
+ $('#view').innerHTML=`
+ <style>
+ .mkt-layout{display:grid;grid-template-columns:1fr 320px;gap:20px;align-items:start}
+ @media(max-width:820px){.mkt-layout{grid-template-columns:1fr}}
+ .mkt-lib{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:14px;max-height:82vh;overflow:auto;position:sticky;top:76px}
+ .mkt-lib h4{margin:0 0 10px;font-size:13px;text-transform:uppercase;letter-spacing:1px;color:var(--mut)}
+ .composer-in{width:100%;background:var(--panel2);border:1px solid var(--line);color:var(--ink);border-radius:10px;padding:10px 12px;font-size:14px;font-family:inherit}
+ .soc-pick{display:flex;flex-wrap:wrap;gap:8px;margin:6px 0 4px}
+ .soc-chip{display:flex;align-items:center;gap:7px;background:var(--panel2);border:1px solid var(--line);border-radius:999px;padding:6px 12px;cursor:pointer;font-size:13px;font-weight:600;color:var(--mut);user-select:none}
+ .soc-chip.on{background:linear-gradient(135deg,var(--brand),var(--brand2));color:#001018;border-color:transparent}
+ .soc-chip .st{font-size:10px;opacity:.85}
+ .qitem{border-top:1px solid var(--line);padding:12px 0;display:flex;gap:12px;align-items:flex-start}
+ .qthumb{width:54px;height:54px;border-radius:8px;object-fit:cover;background:var(--panel2);flex:none}
+ </style>
+ <div class="mkt-layout">
+ <div id="composer"></div>
+ <div class="mkt-lib">
+ <h4>Media library (${assets.length})</h4>
+ ${Object.entries(buckets).map(([b,list])=>`
+ <details class="facet"><summary>${enc(b)} · ${list.length}</summary>
+ ${list.map(a=>`<div class="opt ${MKT_ASSET&&MKT_ASSET.path===a.path?'on':''}" onclick='pickAsset(${JSON.stringify(a).replace(/'/g,"'")})'>
+ <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${a.kind==='video'?'🎬':'🖼'} ${enc(a.name)}</span>
+ <span class="n">${a.size_kb>1024?(a.size_kb/1024).toFixed(1)+'M':a.size_kb+'K'}</span></div>`).join('')}
+ </details>`).join('')}
+ </div>
+ </div>`;
+ renderComposer();
+ refreshQueue();
+ $('#count').textContent='';
+ });
+}
+window.pickAsset=a=>{MKT_ASSET=a;
+ if(!MKT_CAP.trim()){const k=MKT_SEL.size?tmplKey([...MKT_SEL][0]):'Instagram';MKT_CAP=CAPTIONS[k](a);}
+ renderMarketing();};
+window.toggleSocial=p=>{if(MKT_SEL.has(p))MKT_SEL.delete(p);else MKT_SEL.add(p);renderComposer();};
+window.seedCap=k=>{MKT_CAP=CAPTIONS[k](MKT_ASSET||{});const ta=$('#capBox');if(ta)ta.value=MKT_CAP;};
+
+function renderComposer(){
+ const a=MKT_ASSET;
+ if(!MKT_WHEN){const d=new Date(Date.now()+3600000);d.setSeconds(0,0);
+ MKT_WHEN=new Date(d.getTime()-d.getTimezoneOffset()*60000).toISOString().slice(0,16);}
+ $('#composer').innerHTML=`
+ <div class="card"><div class="body">
+ <div class="ttl">🗓 Schedule a post</div>
+ <p class="blurb" style="margin-top:2px">Pick one or more accounts, attach media from the library on the right, write a caption, and queue it for a date & time — Hootsuite-style. Publishing stays manual/gated: at go-time you copy the caption and post to each platform.</p>
+
+ <div class="ttl" style="font-size:13px;margin-top:12px">Publish to</div>
+ <div class="soc-pick">
+ ${MKT_SOCIALS.map(s=>`<span class="soc-chip ${MKT_SEL.has(s.platform)?'on':''}" onclick="toggleSocial('${enc(s.platform).replace(/'/g,"'")}')" title="${enc(s.notes||'')}">
+ ${enc(s.platform)} <span class="st">${s.status==='have'?'✓ live':s.status==='claim'?'claim':'setup'}</span></span>`).join('')}
+ </div>
+
+ <div class="row" style="align-items:flex-start;gap:12px;margin-top:12px">
+ <div style="flex:1;min-width:0">
+ <div class="ttl" style="font-size:13px">Caption</div>
+ <textarea id="capBox" class="composer-in" rows="5" oninput="MKT_CAP=this.value" placeholder="Write your caption… or click an asset to auto-fill from a template.">${enc(MKT_CAP)}</textarea>
+ <div class="tags" style="margin-top:6px">
+ <span class="tag" style="cursor:pointer" onclick="seedCap('Instagram')">+ IG caption</span>
+ <span class="tag" style="cursor:pointer" onclick="seedCap('TikTok')">+ TikTok</span>
+ <span class="tag" style="cursor:pointer" onclick="seedCap('Facebook')">+ Facebook</span>
+ <span class="tag" style="cursor:pointer" onclick="seedCap('YouTube')">+ YouTube</span>
+ </div>
+ </div>
+ <div style="width:150px;flex:none">
+ <div class="ttl" style="font-size:13px">Media</div>
+ ${a?`<div style="border:1px solid var(--line);border-radius:10px;overflow:hidden">
+ ${a.kind==='video'?`<video src="/${a.path}" muted playsinline style="width:100%;display:block"></video>`:`<img src="/${a.path}" style="width:100%;display:block">`}
+ </div><div class="when" style="margin-top:4px" title="${enc(a.name)}">${enc(a.name)}</div>`
+ :`<div style="border:1px dashed var(--line);border-radius:10px;padding:18px 8px;text-align:center;color:var(--mut);font-size:12px">Pick an asset →<br>from the library</div>`}
+ </div>
+ </div>
+
+ <div class="row" style="margin-top:14px;gap:10px;flex-wrap:wrap;justify-content:flex-start">
+ <label style="font-size:13px;color:var(--mut)">When <input id="schWhen" type="datetime-local" class="composer-in" style="width:auto;display:inline-block" value="${enc(MKT_WHEN)}" onchange="MKT_WHEN=this.value"></label>
+ <button class="btn" onclick="schedulePost()">+ Add to queue</button>
+ <button class="btn ghost" onclick="postNow()">Copy & open now</button>
+ <span class="when" id="schMsg"></span>
+ </div>
+ </div></div>
+
+ <div class="card" style="margin-top:14px"><div class="body" id="queueBox"></div></div>`;
+ renderQueue();
+}
+
+function renderQueue(){
+ const q=MKT_QUEUE.slice().sort((a,b)=>(a.when||'').localeCompare(b.when||''));
+ const box=$('#queueBox'); if(!box)return;
+ const now=Date.now();
+ box.innerHTML=`<div class="ttl">📋 Scheduled queue (${q.length})</div>
+ ${q.length?q.map(p=>{const due=p.when&&new Date(p.when).getTime()<=now;
+ return `<div class="qitem">
+ ${p.media?`<img class="qthumb" src="/${enc(p.media)}" alt="">`:`<div class="qthumb" style="display:grid;place-items:center;color:var(--mut);font-size:20px">📝</div>`}
+ <div style="flex:1;min-width:0">
+ <div class="row"><div class="tags">${(p.socials||[]).map(s=>`<span class="tag">${enc(s)}</span>`).join('')}</div>
+ <span class="badge ${due?'b-good':'b-warn'}">${due?'ready to post':'scheduled'}</span></div>
+ <div class="blurb" style="margin:6px 0;white-space:pre-wrap">${enc((p.caption||'').slice(0,240))}${(p.caption||'').length>240?'…':''}</div>
+ <div class="row"><span class="when">🕓 ${fmtDate(p.when)}</span>
+ <span style="display:flex;gap:8px">
+ <a class="tag" style="cursor:pointer" onclick="fireQueued('${p.id}')">copy & open</a>
+ <a class="tag b-mut" style="cursor:pointer" onclick="delQueued('${p.id}')">delete</a>
+ </span></div>
+ </div></div>`;}).join('')
+ :'<p class="blurb">Nothing queued yet. Build a post above and hit “Add to queue”.</p>'}`;
+}
+window.schedulePost=()=>{
+ const socials=[...MKT_SEL]; const caption=MKT_CAP.trim(); const when=MKT_WHEN; const msg=$('#schMsg');
+ if(!socials.length){if(msg)msg.textContent='Pick at least one account';return;}
+ if(!caption){if(msg)msg.textContent='Write a caption';return;}
+ if(!when){if(msg)msg.textContent='Pick a date & time';return;}
+ const body={socials,caption,when,media:MKT_ASSET?MKT_ASSET.path:''};
+ const done=note=>{MKT_SEL=new Set();MKT_ASSET=null;MKT_CAP='';MKT_WHEN='';renderMarketing();
+ setTimeout(()=>{const m=$('#schMsg');if(m)m.textContent=note;},60);};
+ fetch('/api/admin/schedule',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})
+ .then(r=>r.ok?r.json():Promise.reject()).then(()=>done('Queued ✓'))
+ .catch(()=>{const q=lsLoad();q.push({id:'p'+Date.now().toString(36),...body,status:'scheduled',created_at:new Date().toISOString()});lsSave(q);done('Queued (offline) ✓');});
+};
+window.delQueued=id=>{
+ fetch('/api/admin/schedule/'+encodeURIComponent(id),{method:'DELETE'})
+ .then(r=>r.ok?r.json():Promise.reject()).then(()=>refreshQueue())
+ .catch(()=>{lsSave(lsLoad().filter(p=>p.id!==id));MKT_QUEUE=lsLoad();renderQueue();});
+};
+window.fireQueued=id=>{const p=MKT_QUEUE.find(x=>x.id===id);if(!p)return;
+ navigator.clipboard.writeText(p.caption||'');
+ (p.socials||[]).forEach(name=>{const s=MKT_SOCIALS.find(x=>x.platform===name);const u=s&&s.url;if(u&&u.length>8)window.open(u,'_blank','noopener');});
+};
+window.postNow=()=>{
+ const socials=[...MKT_SEL]; const caption=MKT_CAP.trim(); const msg=$('#schMsg');
+ if(!socials.length||!caption){if(msg)msg.textContent='Pick account(s) + write a caption first';return;}
+ navigator.clipboard.writeText(caption);
+ socials.forEach(name=>{const s=MKT_SOCIALS.find(x=>x.platform===name);const u=s&&s.url;if(u&&u.length>8)window.open(u,'_blank','noopener');});
+ if(msg)msg.textContent='Caption copied ✓ — opened the accounts that have a live URL';
+};
+
+// ---- Tips & Employees: roster, QR cards, ledger ----------------------------
+function renderTips(){
+ Promise.all([fetch('/api/admin/employees').then(r=>r.json()),fetch('/api/admin/tips').then(r=>r.json())]).then(([doc,ledger])=>{
+ const emps=doc.employees||[];
+ const totals={};ledger.filter(t=>t.status!=='error').forEach(t=>t.codes.forEach(c=>{
+ totals[c]=(totals[c]||0)+Math.round(t.amount_cents/t.codes.length);}));
+ $('#view').innerHTML=`
+ <div class="card"><div class="body">
+ <div class="ttl">Team roster — 3-digit tip codes</div>
+ <p class="blurb">Each person's code goes on their badge + printable QR card. Customers hit <code>/tip</code>, punch the code (or scan), and tip by card. Two-person jobs split 50/50.</p>
+ <div class="grid" style="--cols:3">
+ ${emps.map(e=>`<div class="card"><div class="body" style="text-align:center">
+ <div style="font-size:30px;font-weight:900;letter-spacing:6px;color:var(--brand)">${enc(e.code)}</div>
+ <div class="ttl">${enc(e.name)} ${e.active?'':'<span class="badge b-bad">inactive</span>'}</div>
+ <div class="blurb">${enc(e.role||'')}</div>
+ <img src="/api/qr/${enc(e.code)}" alt="QR for ${enc(e.name)}" style="width:140px;margin:8px auto;border-radius:8px;background:#fff;padding:6px">
+ <div class="kv"><span>Tips recorded</span><b>$${((totals[e.code]||0)/100).toFixed(2)}</b></div>
+ <div class="kv"><span>Stripe</span>${e.stripe_account_id?'<span class="badge b-good">connected</span>':'<span class="badge b-bad">not onboarded</span>'}</div>
+ <button class="btn ghost" style="margin-top:8px" onclick="printQR('${enc(e.code)}','${enc(e.name)}')">🖨 Print QR card</button>
+ </div></div>`).join('')}
+ </div>
+ </div></div>
+ <div class="card" style="margin-top:14px"><div class="body">
+ <div class="ttl">➕ Add / update team member</div>
+ <div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:10px">
+ <input id="eCode" placeholder="Code (e.g. 103)" maxlength="3" style="width:110px" class="admin-in">
+ <input id="eName" placeholder="Name" class="admin-in">
+ <input id="eRole" placeholder="Role (optional)" class="admin-in">
+ <button class="btn" onclick="saveEmp()">Save</button>
+ </div><div class="when" id="empMsg"></div>
+ </div></div>
+ <div class="card" style="margin-top:14px"><div class="body">
+ <div class="ttl">📒 Tip ledger (${ledger.length})</div>
+ ${ledger.length?ledger.slice(0,50).map(t=>`<div class="kv"><span>${t.names.map(enc).join(' + ')} ${t.split?'<span class="badge b-mut">split</span>':''}
+ <span class="badge ${t.status==='checkout-created'?'b-good':t.status==='offline-intent'?'b-warn':'b-bad'}">${enc(t.status)}</span></span>
+ <span><b>$${(t.amount_cents/100).toFixed(2)}</b> <span style="color:var(--mut);font-size:11px">${fmtDate(t.created_at)}</span></span></div>`).join('')
+ :'<p class="blurb">No tips yet — Stripe key not set, so the page runs in "cash for now" mode. Set <code>STRIPE_SECRET_KEY</code> to go live (gated).</p>'}
+ </div></div>
+ <style>.admin-in{background:var(--panel2);border:1px solid var(--line);color:var(--ink);border-radius:10px;padding:9px 12px;font-size:14px}</style>`;
+ $('#count').textContent='';
+ });
+}
+window.saveEmp=()=>{
+ fetch('/api/admin/employees',{method:'POST',headers:{'Content-Type':'application/json'},
+ body:JSON.stringify({code:$('#eCode').value,name:$('#eName').value,role:$('#eRole').value})})
+ .then(r=>r.json()).then(j=>{ if(j.ok)renderTips(); else $('#empMsg').textContent=j.error; });
+};
+window.printQR=(code,name)=>{
+ const w=window.open('','_blank','width=420,height=600');
+ w.document.write(`<html><head><title>Tip ${name}</title></head>
+ <body style="font-family:Inter,system-ui;text-align:center;padding:30px">
+ <h2 style="margin:0">Enjoyed the wash?</h2>
+ <p style="font-size:18px;margin:6px 0 16px">Tip <b>${name}</b> — no cash needed</p>
+ <img src="/api/qr/${code}" style="width:280px"><div style="font-size:34px;font-weight:900;letter-spacing:8px;margin-top:10px">${code}</div>
+ <p style="color:#555">Scan, or go to prestigecarwash.agentabrams.com/tip and enter the code</p>
+ <script>setTimeout(()=>print(),400)<\/script></body></html>`);
+};
+
+// ---- Daily specials editor --------------------------------------------------
+const DAYS=['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
+function renderSpecials(){
+ fetch('/api/admin/specials').then(r=>r.json()).then(sp=>{
+ $('#view').innerHTML=`
+ <div class="card"><div class="body">
+ <div class="ttl">Banner override (beats the weekday rotation)</div>
+ <p class="blurb">Whatever you type here shows in the gold banner on every public page until you clear it. Leave empty to use the weekday rotation below.</p>
+ <div style="display:flex;gap:10px;flex-wrap:wrap">
+ <input id="ovr" value="${enc(sp.override||'')}" placeholder="e.g. 🎆 4th of July weekend: $5 off every full service!"
+ style="flex:1;min-width:260px;background:var(--panel2);border:1px solid var(--line);color:var(--ink);border-radius:10px;padding:10px 12px;font-size:14px">
+ <button class="btn" onclick="saveSpecials()">Save</button>
+ </div>
+ </div></div>
+ <div class="card" style="margin-top:14px"><div class="body">
+ <div class="ttl">Weekday rotation</div>
+ ${DAYS.map((d,i)=>`<div class="kv" style="gap:14px"><span style="min-width:90px"><b>${d}</b></span>
+ <input data-day="${i}" value="${enc((sp.by_weekday||{})[String(i)]||'')}"
+ style="flex:1;background:var(--panel2);border:1px solid var(--line);color:var(--ink);border-radius:10px;padding:8px 12px;font-size:13px"></div>`).join('')}
+ <div style="margin-top:12px;display:flex;gap:10px;align-items:center"><button class="btn" onclick="saveSpecials()">Save all</button><span class="when" id="spMsg"></span></div>
+ </div></div>`;
+ $('#count').textContent='';
+ });
+}
+window.saveSpecials=()=>{
+ const by={};document.querySelectorAll('[data-day]').forEach(i=>by[i.dataset.day]=i.value);
+ fetch('/api/admin/specials',{method:'POST',headers:{'Content-Type':'application/json'},
+ body:JSON.stringify({override:$('#ovr').value,by_weekday:by})})
+ .then(r=>r.json()).then(()=>{const m=$('#spMsg');if(m)m.textContent='Saved ✓ — live on the site now';});
+};
+render();
+</script>
+</body>
+</html>
diff --git a/reports/deployed-20260720/publish-due.js b/reports/deployed-20260720/publish-due.js
new file mode 100644
index 0000000..0b3da11
--- /dev/null
+++ b/reports/deployed-20260720/publish-due.js
@@ -0,0 +1,70 @@
+'use strict';
+/**
+ * Prestige social scheduler worker — Phase 1 (DARK / INERT).
+ *
+ * Reads data/schedule.json, finds posts that are DUE (when <= now) and not yet
+ * sent, and hands each to its platform adapter. Adapters are "inert until keyed":
+ * with no token in .env they return {skipped:'needs-connection'} and the post
+ * STAYS queued — i.e. exactly today's manual copy&open behavior. Every attempt is
+ * appended to reports/social-posts.jsonl.
+ *
+ * Nothing is auto-posted in Phase 1. Real posting only happens once a platform
+ * adapter is IMPLEMENTED, keyed, AND its go-live is approved
+ * (see reports/SCOPE-social-autopost-*.md). Yelp has no posting API — manual forever.
+ *
+ * Not yet installed on any timer — run manually: `node scripts/publish-due.js`.
+ */
+require('dotenv').config();
+const fs = require('fs');
+const path = require('path');
+const DATA = path.join(__dirname, '..', 'data');
+const REPORTS = path.join(__dirname, '..', 'reports');
+const FILE = path.join(DATA, 'schedule.json');
+
+// One adapter per platform, each gated behind an env token (Stripe-style inert).
+function needsKey(env) {
+ return async (_post) => {
+ if (!process.env[env]) return { ok: false, skipped: 'needs-connection', env };
+ // Adapter body intentionally NOT implemented in Phase 1 — go-live is gated.
+ return { ok: false, skipped: 'adapter-not-enabled', env };
+ };
+}
+const adapters = {
+ 'Instagram': needsKey('PCW_IG_ACCESS_TOKEN'),
+ 'Facebook Page': needsKey('PCW_FB_PAGE_TOKEN'),
+ 'TikTok': needsKey('PCW_TIKTOK_ACCESS_TOKEN'),
+ 'YouTube': needsKey('PCW_YT_REFRESH_TOKEN'),
+ 'Google Business Profile': needsKey('PCW_GBP_ACCESS_TOKEN')
+ // 'Yelp Business': no posting API — always manual, no adapter.
+};
+
+function log(entry) {
+ try { fs.mkdirSync(REPORTS, { recursive: true }); } catch (e) {}
+ fs.appendFileSync(path.join(REPORTS, 'social-posts.jsonl'),
+ JSON.stringify({ ...entry, at: new Date().toISOString() }) + '\n');
+}
+
+async function main() {
+ let doc;
+ try { doc = JSON.parse(fs.readFileSync(FILE, 'utf8')); }
+ catch (e) { console.log('[publish-due] no schedule.json — nothing to do'); return; }
+ const posts = Array.isArray(doc.posts) ? doc.posts : [];
+ const now = Date.now();
+ let due = 0, attempted = 0;
+ for (const p of posts) {
+ if (p.status === 'sent') continue;
+ if (!p.when || new Date(p.when).getTime() > now) continue;
+ due++;
+ for (const plat of (p.socials || [])) {
+ const fn = adapters[plat];
+ const r = fn ? await fn(p) : { ok: false, skipped: 'no-adapter (manual only)' };
+ attempted++;
+ log({ post_id: p.id, platform: plat, result: r });
+ }
+ // Phase 1: nothing actually posts, so status is NOT flipped to 'sent' — the
+ // post stays visible in the admin as "ready to post" (manual copy&open) until
+ // an adapter is enabled + approved.
+ }
+ console.log(`[publish-due] ${posts.length} queued · ${due} due · ${attempted} adapter-calls · 0 auto-posted (Phase 1 dark)`);
+}
+main();
diff --git a/reports/deployed-20260720/schedule.json b/reports/deployed-20260720/schedule.json
new file mode 100644
index 0000000..646ea8b
--- /dev/null
+++ b/reports/deployed-20260720/schedule.json
@@ -0,0 +1,3 @@
+{
+ "posts": []
+}
diff --git a/reports/deployed-20260720/server.js b/reports/deployed-20260720/server.js
new file mode 100644
index 0000000..32a467a
--- /dev/null
+++ b/reports/deployed-20260720/server.js
@@ -0,0 +1,507 @@
+'use strict';
+/**
+ * Prestige Car Wash (PCW) — one Express app serving:
+ * - the public marketing site (/, /services, /contact)
+ * - the Basic-Auth growth admin (/admin) + its bucket APIs (/api/admin/*)
+ *
+ * Data source: data/*.json snapshots (Postgres is an optional future upgrade; the
+ * schema in scripts/db-init.sql mirrors these files). Everything the front-end needs
+ * comes through /api/* so the same catalog powers both the public page and the admin.
+ */
+require('dotenv').config();
+const fs = require('fs');
+const path = require('path');
+const express = require('express');
+const helmet = require('helmet');
+
+const app = express();
+const PORT = process.env.PORT || 9808;
+const ADMIN_USER = process.env.ADMIN_USER || 'admin';
+const ADMIN_PASS = process.env.ADMIN_PASS || 'DW2024!';
+const DATA = path.join(__dirname, 'data');
+
+// ---- helpers ---------------------------------------------------------------
+const readJSON = (f, fallback) => {
+ try { return JSON.parse(fs.readFileSync(path.join(DATA, f), 'utf8')); }
+ catch { return fallback; }
+};
+// Re-read on each request so admin edits / script runs show up without a restart.
+const services = () => readJSON('services.json', []);
+const competitors = () => readJSON('competitors.json', []);
+const suggestions = () => readJSON('suggestions.json', []);
+const holidays = () => readJSON('holidays.json', []);
+const directories = () => readJSON('directories.json', []);
+const ads = () => readJSON('ads.json', []);
+const bestTimes = () => readJSON('best-times.json', {});
+const places = () => readJSON('places.json', {});
+const coupons = () => readJSON('coupons.json', []);
+const plans = () => readJSON('plans.json', []);
+const area = () => readJSON('area.json', {});
+const partners = () => readJSON('partners.json', []);
+
+// Mask a secret to a "present (…last4)" descriptor — never returns the value.
+const maskEnv = (key) => {
+ const v = process.env[key];
+ if (!v) return { key, present: false, hint: '' };
+ return { key, present: true, hint: '…' + String(v).slice(-4) };
+};
+
+// ---- middleware ------------------------------------------------------------
+app.use(helmet({
+ contentSecurityPolicy: {
+ directives: {
+ defaultSrc: ["'self'"],
+ scriptSrc: ["'self'", "'unsafe-inline'"],
+ // inline event-handler attributes (onclick=, onerror=) are a separate directive;
+ // Helmet defaults it to 'none'. The site/admin use inline handlers (house style,
+ // Basic-Auth admin, no user-generated content) so allow them explicitly.
+ scriptSrcAttr: ["'unsafe-inline'"],
+ styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
+ fontSrc: ["'self'", 'https://fonts.gstatic.com', 'data:'],
+ imgSrc: ["'self'", 'data:', 'https:'],
+ mediaSrc: ["'self'", 'data:', 'blob:'],
+ connectSrc: ["'self'"],
+ // keyless Google Maps embed on /visit
+ frameSrc: ["'self'", 'https://www.google.com', 'https://maps.google.com']
+ }
+ }
+}));
+app.use(express.json());
+
+// Basic Auth gate for /admin and /api/admin/*
+function requireAdmin(req, res, next) {
+ const hdr = req.headers.authorization || '';
+ const [scheme, encoded] = hdr.split(' ');
+ if (scheme === 'Basic' && encoded) {
+ const [u, p] = Buffer.from(encoded, 'base64').toString().split(':');
+ if (u === ADMIN_USER && p === ADMIN_PASS) return next();
+ }
+ res.set('WWW-Authenticate', 'Basic realm="Prestige Admin"').status(401).send('Auth required');
+}
+app.use('/admin', requireAdmin);
+app.use('/api/admin', requireAdmin);
+
+// ---- sort (shared by public services grid) --------------------------------
+function sortServices(list, mode) {
+ const arr = [...list];
+ const byTitle = (a, b) => String(a.name).localeCompare(String(b.name));
+ switch (mode) {
+ case 'title': return arr.sort(byTitle);
+ case 'price-asc': return arr.sort((a, b) => (a.price || 0) - (b.price || 0) || byTitle(a, b));
+ case 'price-desc': return arr.sort((a, b) => (b.price || 0) - (a.price || 0) || byTitle(a, b));
+ case 'duration': return arr.sort((a, b) => (a.duration_min || 0) - (b.duration_min || 0) || byTitle(a, b));
+ case 'featured':
+ default: return arr.sort((a, b) => (b.featured - a.featured) || (a.sort_order || 99) - (b.sort_order || 99));
+ }
+}
+
+// Price bands for the left filter panel.
+const priceBand = (p) => p <= 25 ? 'Under $25' : p <= 60 ? '$25–$60' : p <= 150 ? '$60–$150' : '$150+';
+
+// ---- public API ------------------------------------------------------------
+app.get('/api/health', (req, res) => {
+ res.json({
+ ok: true, service: 'prestige-car-wash', port: PORT,
+ counts: {
+ services: services().length, competitors: competitors().length,
+ suggestions: suggestions().length, holidays: holidays().length,
+ directories: directories().length, ads: ads().length
+ },
+ ts: new Date().toISOString()
+ });
+});
+
+app.get('/api/services', (req, res) => {
+ let list = services().map(s => ({ ...s, price_band: priceBand(s.price || 0) }));
+ const { category, band, q } = req.query;
+ if (category) list = list.filter(s => s.category === category);
+ if (band) list = list.filter(s => s.price_band === band);
+ if (q) {
+ const needle = String(q).toLowerCase();
+ list = list.filter(s => [s.name, s.blurb, s.category].join(' ').toLowerCase().includes(needle));
+ }
+ res.json(sortServices(list, req.query.sort));
+});
+
+// Facet counts for the left panel — each dimension counted over the OTHER active filters.
+app.get('/api/facets', (req, res) => {
+ const base = services().map(s => ({ ...s, price_band: priceBand(s.price || 0) }));
+ const tally = (rows, key) => rows.reduce((m, r) => (m[r[key]] = (m[r[key]] || 0) + 1, m), {});
+ const applyExcept = (except) => base.filter(s =>
+ (except === 'category' || !req.query.category || s.category === req.query.category) &&
+ (except === 'band' || !req.query.band || s.price_band === req.query.band)
+ );
+ res.json({
+ category: tally(applyExcept('category'), 'category'),
+ band: tally(applyExcept('band'), 'price_band')
+ });
+});
+
+// ---- universal search (nav 🔍 overlay on every public page) ----------------
+const SEARCH_PAGES = [
+ { title: 'Home', url: '/', kw: 'prestige car wash reseda san fernando valley hand wash home welcome' },
+ { title: 'Services & Pricing',url: '/services', kw: 'services pricing wash detail detailing ceramic coating wax interior exterior engine price list' },
+ { title: 'Membership', url: '/membership', kw: 'membership unlimited monthly plan subscribe signup member' },
+ { title: 'Coupons', url: '/coupons', kw: 'coupons coupon deals discount savings offer promo code' },
+ { title: 'Visit Us', url: '/visit', kw: 'visit hours location address directions parking webcam camera weather best times busy open closed map' },
+ { title: 'About', url: '/about', kw: 'about story team family owned armenian history' },
+ { title: "Contact", url: "/contact", kw: "contact phone call email message quote question appointment walk in first come first served no appointment" },
+ { title: 'Tip the Crew', url: '/tip', kw: 'tip tips crew gratuity employee code qr thank' },
+];
+
+app.get('/api/search', (req, res) => {
+ const q = String(req.query.q || '').trim().toLowerCase();
+ if (q.length < 2) return res.json({ q, results: [] });
+ const terms = q.split(/\s+/).filter(Boolean);
+ const results = [];
+ // AND semantics over title+detail+extra; hits whose TITLE alone matches rank first.
+ const add = (type, title, detail, url, extra) => {
+ const t = String(title || ''), d = String(detail || '');
+ const hay = (t + ' ' + d + ' ' + String(extra || '')).toLowerCase();
+ if (!terms.every(w => hay.includes(w))) return;
+ const inTitle = terms.every(w => t.toLowerCase().includes(w));
+ results.push({ type, title: t, detail: d.slice(0, 140), url,
+ _s: (inTitle ? 10 : 0) + (t.toLowerCase().startsWith(terms[0]) ? 3 : 0) });
+ };
+
+ services().forEach(s => add('Service', s.name, `${s.price_display || ''} · ${s.blurb || ''}`,
+ '/services?q=' + encodeURIComponent(s.name),
+ [s.category, (s.highlights || []).join(' ')].join(' ')));
+ plans().forEach(p => add('Membership', p.name, `${p.price_display || ''} · ${p.tagline || ''}`,
+ '/membership', [p.for_who, (p.includes || []).join(' ')].join(' ')));
+ coupons().forEach(c => add('Coupon', c.title, `${c.value || ''} · ${c.detail || ''}`,
+ '/coupons', [c.code, c.fine_print].join(' ')));
+
+ const sp = readJSON('specials.json', {});
+ const todays = sp.override || (sp.by_weekday || {})[String(new Date().getDay())];
+ if (todays) add('Special', "Today's Special", todays, '/contact', 'special deal today daily');
+
+ const pl = places();
+ if (pl && pl.name) add('Visit', 'Hours, Location & Phone',
+ [pl.address, pl.phone_display || pl.phone].filter(Boolean).join(' · '),
+ '/visit', 'hours location address directions phone open close ' + JSON.stringify(pl.hours || ''));
+
+ SEARCH_PAGES.forEach(p => add('Page', p.title, '', p.url, p.kw));
+
+ results.sort((a, b) => b._s - a._s);
+ res.json({ q, results: results.slice(0, 20).map(({ _s, ...r }) => r) });
+});
+
+app.get('/api/best-times', (req, res) => res.json(bestTimes()));
+app.get('/api/places', (req, res) => res.json(places()));
+app.get('/api/coupons', (req, res) => res.json(coupons()));
+app.get('/api/plans', (req, res) => res.json(plans()));
+app.get('/api/area', (req, res) => res.json(area()));
+
+// ---- admin bucket APIs -----------------------------------------------------
+app.get('/api/admin/competitors', (req, res) => res.json(competitors()));
+app.get('/api/admin/suggestions', (req, res) => res.json(suggestions()));
+app.get('/api/admin/holidays', (req, res) => res.json(holidays()));
+app.get('/api/admin/directories', (req, res) => res.json(directories()));
+app.get('/api/admin/ads', (req, res) => res.json(ads()));
+app.get('/api/admin/services', (req, res) => res.json(services()));
+app.get('/api/admin/best-times', (req, res) => res.json(bestTimes()));
+app.get('/api/admin/places', (req, res) => res.json(places()));
+app.get('/api/admin/partners', (req, res) => res.json(partners()));
+app.get('/api/admin/reviews-raw', (req, res) => res.json(readJSON('reviews-raw.json', {})));
+app.get('/api/admin/coupons', (req, res) => res.json(coupons()));
+app.get('/api/admin/plans', (req, res) => res.json(plans()));
+
+// Credentials tab — presence + last-4 only. NEVER returns secret values.
+app.get('/api/admin/credentials', (req, res) => {
+ const keys = [
+ { key: 'GEMINI_API_KEY', label: 'Nano Banana (Gemini image)', purpose: 'Generate service stills' },
+ { key: 'REPLICATE_API_TOKEN', label: 'SeeDance (Replicate)', purpose: 'Generate wash/wax video clips' },
+ { key: 'GOOGLE_PLACES_API_KEY', label: 'Google Places (read)', purpose: 'Live hours/reviews/photos — gated setup' },
+ { key: 'PCW_PLACE_ID', label: 'Google Place ID', purpose: 'Which listing to read' },
+ { key: 'DATABASE_URL', label: 'Postgres', purpose: 'Optional data backend' },
+ { key: 'STRIPE_SECRET_KEY', label: 'Stripe (tips)', purpose: 'Employee tip checkout — GATED until owner go' },
+ { key: 'WEBCAM_EMBED_URL', label: 'Live cam stream', purpose: 'Outdoor camera embed on /visit — needs camera install' },
+ { key: 'NOTIFY_WEBHOOK', label: 'Lead notifications', purpose: 'Slack/Discord/Zapier ping on new leads' }
+ ];
+ res.json(keys.map(k => ({ ...maskEnv(k.key), label: k.label, purpose: k.purpose })));
+});
+
+// "Update Google Place" — draft-write: returns a prefilled Google Business Profile URL.
+// No direct API write (owner OAuth + API approval deferred). Front-end opens this in a new tab.
+app.post('/api/admin/place/draft-update', (req, res) => {
+ const { field } = req.body || {};
+ res.json({
+ ok: true, mode: 'draft',
+ message: `Draft update for "${field || 'listing'}" — opens Google Business Profile prefilled.`,
+ url: 'https://business.google.com/edit/l/' + (process.env.PCW_PLACE_ID || '')
+ });
+});
+
+// Simple per-IP rate limiter for the public form (prevents spam / unbounded lead file).
+const _hits = new Map();
+function rateLimit(max, windowMs) {
+ return (req, res, next) => {
+ const ip = req.ip || req.connection.remoteAddress || 'unknown';
+ const now = Date.now();
+ const arr = (_hits.get(ip) || []).filter(t => now - t < windowMs);
+ if (arr.length >= max) return res.status(429).json({ ok: false, error: 'Too many requests — try again shortly.' });
+ arr.push(now); _hits.set(ip, arr);
+ next();
+ };
+}
+const cap = (s, n) => String(s || '').slice(0, n);
+
+// Contact / booking lead — saved locally + optional webhook notify (env-gated).
+// NOTIFY_WEBHOOK (Slack/Discord/Zapier URL) gives leads a real notification path;
+// without it the lead is captured in the admin Leads tab. No mass email (that stays gated).
+app.post('/api/contact', rateLimit(5, 10 * 60 * 1000), (req, res) => {
+ const b = req.body || {};
+ if (!b.name || !(b.phone || b.email)) return res.status(400).json({ ok: false, error: 'name + phone/email required' });
+ const lead = {
+ name: cap(b.name, 120), phone: cap(b.phone, 40), email: cap(b.email, 160), vehicle: cap(b.vehicle, 120),
+ service: cap(b.service, 120), preferred: cap(b.preferred, 120), message: cap(b.message, 1000),
+ created_at: new Date().toISOString(), source: 'web-form'
+ };
+ try {
+ const dir = path.join(__dirname, 'reports');
+ fs.mkdirSync(dir, { recursive: true });
+ fs.appendFileSync(path.join(dir, 'leads.jsonl'), JSON.stringify(lead) + '\n');
+ } catch (e) { return res.status(500).json({ ok: false, error: 'could not save lead' }); }
+ // Fire-and-forget notification if a webhook is configured.
+ if (process.env.NOTIFY_WEBHOOK) {
+ fetch(process.env.NOTIFY_WEBHOOK, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ text: `🚗 New Prestige lead: ${lead.name} (${lead.phone || lead.email}) — ${lead.service || 'general'}${lead.message ? ' · ' + lead.message : ''}` })
+ }).catch(() => {});
+ }
+ res.json({ ok: true, message: "Thanks! We'll get back to you shortly. And remember — no appointment needed: we're first come, first served, so you can just drive in." });
+});
+
+// Admin: view captured leads.
+app.get('/api/admin/leads', (req, res) => {
+ try {
+ const raw = fs.readFileSync(path.join(__dirname, 'reports', 'leads.jsonl'), 'utf8').trim();
+ res.json(raw ? raw.split('\n').map(l => JSON.parse(l)).reverse() : []);
+ } catch { res.json([]); }
+});
+
+// ---- daily specials (full-width banner on every public page) ---------------
+app.get('/api/specials', (req, res) => {
+ const sp = readJSON('specials.json', {});
+ const today = sp.override && sp.override.trim()
+ ? sp.override
+ : (sp.by_weekday || {})[String(new Date().getDay())] || '';
+ res.json({ text: today, day: new Date().getDay() });
+});
+app.get('/api/admin/specials', (req, res) => res.json(readJSON('specials.json', {})));
+app.post('/api/admin/specials', (req, res) => {
+ const cur = readJSON('specials.json', {});
+ const b = req.body || {};
+ if (typeof b.override === 'string') cur.override = cap(b.override, 200);
+ if (b.by_weekday && typeof b.by_weekday === 'object')
+ for (const k of Object.keys(b.by_weekday))
+ if (/^[0-6]$/.test(k)) cur.by_weekday[k] = cap(b.by_weekday[k], 200);
+ cur.updated_at = new Date().toISOString();
+ fs.writeFileSync(path.join(DATA, 'specials.json'), JSON.stringify(cur, null, 2));
+ res.json({ ok: true });
+});
+
+// ---- weather (Open-Meteo — free, keyless; cached 15 min) --------------------
+// 7601 Reseda Blvd sits at ~34.208, -118.536. Server-side proxy keeps CSP tight.
+let _wx = { at: 0, data: null };
+app.get('/api/weather', async (req, res) => {
+ if (Date.now() - _wx.at < 15 * 60 * 1000 && _wx.data) return res.json(_wx.data);
+ try {
+ const u = 'https://api.open-meteo.com/v1/forecast?latitude=34.208&longitude=-118.536'
+ + '¤t=temperature_2m,weather_code,wind_speed_10m'
+ + '&daily=precipitation_probability_max,temperature_2m_max&forecast_days=2'
+ + '&temperature_unit=fahrenheit&wind_speed_unit=mph&timezone=America%2FLos_Angeles';
+ const j = await (await fetch(u)).json();
+ const code = j.current?.weather_code ?? 0;
+ const rainToday = j.daily?.precipitation_probability_max?.[0] ?? 0;
+ const rainTomorrow = j.daily?.precipitation_probability_max?.[1] ?? 0;
+ const desc = code === 0 ? 'Clear' : code <= 3 ? 'Partly cloudy' : code <= 48 ? 'Foggy'
+ : code <= 67 ? 'Rainy' : code <= 77 ? 'Snow' : code <= 82 ? 'Showers' : 'Stormy';
+ // The pitch: low rain chance = great day to wash; we already offer a 48h rain check.
+ const washMsg = rainToday <= 20 && rainTomorrow <= 30
+ ? 'Perfect wash weather — no rain in sight.'
+ : rainToday <= 20
+ ? 'Great day to wash — and if it rains tomorrow, your 48-hour rain check has you covered.'
+ : 'Rain possible — every wash comes with a 48-hour rain check, so you’re covered either way.';
+ _wx = { at: Date.now(), data: { ok: true, temp: Math.round(j.current?.temperature_2m ?? 0), desc, wind: Math.round(j.current?.wind_speed_10m ?? 0), rain_today: rainToday, rain_tomorrow: rainTomorrow, wash_msg: washMsg } };
+ res.json(_wx.data);
+ } catch { res.json({ ok: false }); }
+});
+
+// ---- live cam (env-configured stream; graceful "coming soon" without it) ----
+app.get('/api/webcam', (req, res) => {
+ res.json({
+ embed_url: process.env.WEBCAM_EMBED_URL || '',
+ note: process.env.WEBCAM_EMBED_URL ? '' : 'Camera install pending — check back soon to see the line live.'
+ });
+});
+
+// ---- tips: employees + QR + Stripe-ready checkout ---------------------------
+// Roster lives in data/employees.json; 3-digit codes are the public tip IDs.
+const employees = () => (readJSON('employees.json', { employees: [] }).employees || []);
+const findEmp = (code) => employees().find(e => e.active && String(e.code) === String(code).padStart(3, '0'));
+
+// Public lookup: first name + role only (never expose stripe ids or the roster).
+app.get('/api/tip/employee/:code', (req, res) => {
+ if (!/^\d{3}$/.test(req.params.code)) return res.status(400).json({ ok: false });
+ const e = findEmp(req.params.code);
+ if (!e) return res.status(404).json({ ok: false, error: 'No team member with that code.' });
+ res.json({ ok: true, code: e.code, name: e.name.split(' ')[0], role: e.role || '' });
+});
+
+// QR code per employee → points at /tip?e=CODE. SVG, printable at any size.
+const QRCode = require('qrcode');
+app.get('/api/qr/:code', async (req, res) => {
+ if (!/^\d{3}$/.test(req.params.code)) return res.status(400).send('bad code');
+ const base = process.env.PUBLIC_URL || 'https://prestigecarwash.agentabrams.com';
+ const svg = await QRCode.toString(`${base}/tip?e=${req.params.code}`,
+ { type: 'svg', margin: 1, width: 512, color: { dark: '#0b0f16', light: '#ffffff' } });
+ res.type('image/svg+xml').send(svg);
+});
+
+// Tip checkout. With STRIPE_SECRET_KEY set this creates a real Stripe Checkout
+// session (metadata carries the codes; Connect transfers happen on webhook once
+// employees have Express accounts). Without a key it's inert by design — money
+// stays OFF until Steve/owner flips it. Every intent is journaled either way.
+const tipLedger = (entry) => {
+ const dir = path.join(__dirname, 'reports');
+ fs.mkdirSync(dir, { recursive: true });
+ fs.appendFileSync(path.join(dir, 'tips.jsonl'), JSON.stringify(entry) + '\n');
+};
+app.post('/api/tip/checkout', rateLimit(10, 10 * 60 * 1000), async (req, res) => {
+ const b = req.body || {};
+ const codes = [...new Set((Array.isArray(b.codes) ? b.codes : [b.codes]).filter(c => /^\d{3}$/.test(String(c))))].slice(0, 2);
+ const emps = codes.map(findEmp).filter(Boolean);
+ const amount = Math.round(Number(b.amount) * 100);
+ if (!emps.length) return res.status(400).json({ ok: false, error: 'Enter a valid team code.' });
+ if (!(amount >= 100 && amount <= 20000)) return res.status(400).json({ ok: false, error: 'Tip must be between $1 and $200.' });
+ const entry = {
+ codes: emps.map(e => e.code), names: emps.map(e => e.name), amount_cents: amount,
+ split: emps.length > 1, note: cap(b.note, 200), created_at: new Date().toISOString()
+ };
+ if (!process.env.STRIPE_SECRET_KEY) {
+ tipLedger({ ...entry, status: 'offline-intent' });
+ return res.json({ ok: true, mode: 'offline', message: `Card tips are launching soon! For now you can tip ${emps.map(e => e.name.split(' ')[0]).join(' & ')} in cash at the register — thank you!` });
+ }
+ try {
+ const form = new URLSearchParams({
+ mode: 'payment',
+ 'line_items[0][quantity]': '1',
+ 'line_items[0][price_data][currency]': 'usd',
+ 'line_items[0][price_data][unit_amount]': String(amount),
+ 'line_items[0][price_data][product_data][name]': `Tip for ${emps.map(e => e.name.split(' ')[0]).join(' & ')} — Prestige Car Wash`,
+ success_url: (process.env.PUBLIC_URL || 'https://prestigecarwash.agentabrams.com') + '/tip?thanks=1',
+ cancel_url: (process.env.PUBLIC_URL || 'https://prestigecarwash.agentabrams.com') + '/tip',
+ 'metadata[tip_codes]': entry.codes.join('+'),
+ 'metadata[split]': String(entry.split)
+ });
+ const s = await (await fetch('https://api.stripe.com/v1/checkout/sessions', {
+ method: 'POST',
+ headers: { Authorization: 'Bearer ' + process.env.STRIPE_SECRET_KEY, 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: form
+ })).json();
+ if (!s.url) throw new Error(s.error?.message || 'no checkout url');
+ tipLedger({ ...entry, status: 'checkout-created', stripe_session: s.id });
+ res.json({ ok: true, mode: 'stripe', url: s.url });
+ } catch (e) {
+ tipLedger({ ...entry, status: 'error', error: String(e.message) });
+ res.status(502).json({ ok: false, error: 'Payment system hiccup — please tip in cash today.' });
+ }
+});
+
+// Admin: roster read/write + ledger.
+app.get('/api/admin/employees', (req, res) => res.json(readJSON('employees.json', { employees: [] })));
+app.post('/api/admin/employees', (req, res) => {
+ const b = req.body || {};
+ if (!/^\d{3}$/.test(String(b.code)) || !b.name) return res.status(400).json({ ok: false, error: '3-digit code + name required' });
+ const doc = readJSON('employees.json', { employees: [] });
+ const i = doc.employees.findIndex(e => String(e.code) === String(b.code));
+ const row = {
+ code: String(b.code), name: cap(b.name, 80), role: cap(b.role, 120),
+ active: b.active !== false, stripe_account_id: cap(b.stripe_account_id, 60),
+ added_at: i >= 0 ? doc.employees[i].added_at : new Date().toISOString()
+ };
+ if (i >= 0) doc.employees[i] = row; else doc.employees.push(row);
+ fs.writeFileSync(path.join(DATA, 'employees.json'), JSON.stringify(doc, null, 2));
+ res.json({ ok: true, employee: row });
+});
+app.get('/api/admin/tips', (req, res) => {
+ try {
+ const raw = fs.readFileSync(path.join(__dirname, 'reports', 'tips.jsonl'), 'utf8').trim();
+ res.json(raw ? raw.split('\n').map(l => JSON.parse(l)).reverse() : []);
+ } catch { res.json([]); }
+});
+
+// ---- marketing assets (left panel of the admin Marketing tab) ---------------
+app.get('/api/admin/assets', (req, res) => {
+ const root = path.join(__dirname, 'media');
+ const out = [];
+ (function walk(dir, rel) {
+ for (const f of fs.readdirSync(dir, { withFileTypes: true })) {
+ if (f.isDirectory()) { walk(path.join(dir, f.name), rel + f.name + '/'); continue; }
+ const ext = f.name.split('.').pop().toLowerCase();
+ if (!['jpg', 'jpeg', 'png', 'webp', 'mp4', 'mov', 'svg'].includes(ext)) continue;
+ const st = fs.statSync(path.join(dir, f.name));
+ out.push({
+ path: 'media/' + rel + f.name, name: f.name,
+ kind: ['mp4', 'mov'].includes(ext) ? 'video' : 'image',
+ bucket: rel ? rel.replace(/\/$/, '') : 'generated',
+ size_kb: Math.round(st.size / 1024), mtime: st.mtime.toISOString()
+ });
+ }
+ })(root, '');
+ out.sort((a, b) => b.mtime.localeCompare(a.mtime));
+ res.json(out);
+});
+
+// Social-account registry (no secrets — handles/URLs/status only).
+app.get('/api/admin/social', (req, res) => res.json(readJSON('credentials.json', {})));
+
+// ---- social scheduler: server-side queue (Phase 1 — DARK/INERT, no auto-post) ----
+// The Hootsuite-style composer queue lives in data/schedule.json so it persists
+// across devices/browsers (vs. per-browser localStorage). Publishing stays MANUAL
+// until a platform adapter is keyed + its go-live is approved — see
+// scripts/publish-due.js and reports/SCOPE-social-autopost-*.md.
+const SCHEDULE_FILE = 'schedule.json';
+app.get('/api/admin/schedule', (req, res) => res.json(readJSON(SCHEDULE_FILE, { posts: [] })));
+app.post('/api/admin/schedule', (req, res) => {
+ const b = req.body || {};
+ const socials = Array.isArray(b.socials) ? b.socials.map(s => String(s).slice(0, 60)).slice(0, 12) : [];
+ const caption = typeof b.caption === 'string' ? b.caption.slice(0, 2200) : '';
+ const when = typeof b.when === 'string' ? b.when.slice(0, 40) : '';
+ const media = typeof b.media === 'string' ? b.media.slice(0, 300) : '';
+ if (!socials.length || !caption.trim() || !when)
+ return res.status(400).json({ ok: false, error: 'socials, caption and when are required' });
+ const doc = readJSON(SCHEDULE_FILE, { posts: [] });
+ if (!Array.isArray(doc.posts)) doc.posts = [];
+ const post = {
+ id: 'p' + Date.now().toString(36) + Math.floor(Math.random() * 1e4).toString(36),
+ socials, caption, when, media, status: 'scheduled', created_at: new Date().toISOString()
+ };
+ doc.posts.push(post);
+ fs.writeFileSync(path.join(DATA, SCHEDULE_FILE), JSON.stringify(doc, null, 2));
+ res.json({ ok: true, post });
+});
+app.delete('/api/admin/schedule/:id', (req, res) => {
+ const doc = readJSON(SCHEDULE_FILE, { posts: [] });
+ if (!Array.isArray(doc.posts)) doc.posts = [];
+ const before = doc.posts.length;
+ doc.posts = doc.posts.filter(p => p.id !== req.params.id);
+ fs.writeFileSync(path.join(DATA, SCHEDULE_FILE), JSON.stringify(doc, null, 2));
+ res.json({ ok: true, removed: before - doc.posts.length });
+});
+
+// ---- static + clean URLs ---------------------------------------------------
+app.get('/favicon.svg', (req, res) => res.sendFile(path.join(__dirname, 'public', 'assets', 'logo.svg')));
+app.get('/favicon.ico', (req, res) => res.redirect(302, '/favicon.svg'));
+app.use('/media', express.static(path.join(__dirname, 'media')));
+app.get(/^\/(.+)\.html$/, (req, res) => res.redirect(301, '/' + req.params[0]));
+app.use(express.static(path.join(__dirname, 'public'), { extensions: ['html'] }));
+
+app.listen(PORT, () => {
+ console.log(`[prestige-car-wash] http://localhost:${PORT} (admin: /admin)`);
+ if (process.env.NODE_ENV === 'production' && !process.env.ADMIN_PASS) {
+ console.warn('⚠ SECURITY: running in production with the DEFAULT admin password. Set ADMIN_PASS in .env before exposing this host.');
+ }
+});
← 5683565 scope: real social auto-posting scheduler for prestige marke
·
back to Prestige Car Wash
·
chore: v0.2.0 (Compose & Post feature — session close) d9afc41 →