← back to AbramsOS
views/reviews.ejs
112 lines
<%- include('partials/header', { title: 'Reviews' }) %>
<section class="page-head">
<h1>Reviews & Thank-yous</h1>
<div class="grid-controls">
<button id="genBtn" class="btn">Generate drafts</button>
<label>Show
<select id="filterSel">
<option value="needs">Needs approval</option>
<option value="approved">Approved</option>
<option value="all" selected>All</option>
</select>
</label>
<label>Density
<input id="densityRange" type="range" min="180" max="480" step="10" value="320">
</label>
</div>
</section>
<section class="reviews-bar glass" style="display:flex;gap:.75rem;align-items:center;flex-wrap:wrap;padding:.6rem .9rem;margin-bottom:.8rem;">
<label style="display:flex;gap:.35rem;align-items:center;"><input type="checkbox" id="selAll"> Select all</label>
<button id="approveBtn" class="btn">Approve selected</button>
<button id="postBtn" class="btn" title="Posts per-item, one at a time">Post approved</button>
<span class="subtle">
<% const t = summary.byTarget || {}; %>
amazon_review: <%= (t.amazon_review||{}).draft||0 %> draft / <%= (t.amazon_review||{}).approved||0 %> appr ·
seller_email: <%= (t.seller_email||{}).draft||0 %> / <%= (t.seller_email||{}).approved||0 %> ·
<strong>posted: <%= summary.posted||0 %></strong>
</span>
<span class="confidence" title="Live executor switch">
executors: <%= executorsLive ? 'ARMED (per-item still gated)' : 'OFF (drafts only)' %>
</span>
</section>
<% if (!items.length) { %>
<section class="empty glass"><p>No purchases yet. <a href="/connectors">Sync a connector</a> to ingest receipts, then Generate drafts.</p></section>
<% } else { %>
<section id="reviewGrid" class="review-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:.8rem;">
<% items.forEach(p => { %>
<article class="review-card glass" style="padding:.8rem;display:flex;flex-direction:column;gap:.5rem;">
<header style="display:flex;justify-content:space-between;gap:.5rem;">
<h3 style="margin:0;font-size:1rem;"><%= p.product || p.merchant_name %></h3>
<span class="subtle" style="font-size:.72rem;"><%= p.merchant_name %></span>
</header>
<div class="when" title="<%= new Date(p.purchase_date).toISOString() %>" style="font-size:.72rem;opacity:.7;">
🕓 <%= new Date(p.purchase_date).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}) %>
<% if (p.total_amount) { %> · <%= p.currency||'USD' %> <%= Number(p.total_amount).toFixed(2) %><% } %>
</div>
<% if (!p.drafts.length) { %>
<p class="subtle" style="font-size:.8rem;">No drafts yet — click <em>Generate drafts</em>.</p>
<% } %>
<% p.drafts.forEach(d => { %>
<div class="draft" data-id="<%= d.id %>" data-status="<%= d.status %>" data-target="<%= d.target %>"
style="border:1px solid var(--hair,#3333);border-radius:8px;padding:.5rem;display:flex;flex-direction:column;gap:.35rem;">
<div style="display:flex;justify-content:space-between;align-items:center;gap:.4rem;">
<label style="display:flex;gap:.4rem;align-items:center;font-size:.72rem;text-transform:uppercase;letter-spacing:.04em;">
<input type="checkbox" class="draftChk" value="<%= d.id %>" <%= d.status==='posted'?'disabled':'' %>>
<%= d.target.replace('_',' ') %>
</label>
<span class="pill pill-<%= d.status %>" style="font-size:.68rem;padding:.1rem .45rem;border-radius:999px;border:1px solid currentColor;opacity:.85;"><%= d.status %></span>
</div>
<% if (d.draft_title) { %><strong style="font-size:.85rem;"><%= d.draft_title %></strong><% } %>
<div style="font-size:.82rem;line-height:1.35;white-space:pre-wrap;"><%= d.draft_text %></div>
<% if (d.target === 'seller_email') { %>
<div style="display:flex;gap:.35rem;align-items:center;font-size:.72rem;">
to: <input class="sellerTo" data-id="<%= d.id %>" value="<%= d.seller_email_to || '' %>" placeholder="seller/brand email (unresolved)" style="flex:1;font-size:.72rem;padding:.2rem;">
<button class="btn saveTo" data-id="<%= d.id %>" style="font-size:.7rem;">save</button>
</div>
<% } %>
<div style="display:flex;gap:.4rem;">
<button class="btn skipBtn" data-id="<%= d.id %>" style="font-size:.7rem;">skip</button>
<% if (d.status==='approved') { %><button class="btn unapproveBtn" data-id="<%= d.id %>" style="font-size:.7rem;">unapprove</button><% } %>
</div>
</div>
<% }) %>
</article>
<% }) %>
</section>
<% } %>
<script>
(function(){
const $ = (s,r=document)=>r.querySelector(s);
const $$ = (s,r=document)=>[...r.querySelectorAll(s)];
async function api(path, body){
const r = await fetch(path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body||{})});
return r.json();
}
const checked = ()=>$$('.draftChk:checked').map(c=>c.value);
$('#genBtn').onclick = async ()=>{ $('#genBtn').disabled=true; const o=await api('/api/reviews/generate',{}); alert(`Generated: ${o.created||0} new, ${o.refreshed||0} refreshed, ${o.skippedLocked||0} locked (across ${o.purchases||0} purchases).`); location.reload(); };
$('#selAll').onchange = e=>$$('.draftChk:not(:disabled)').forEach(c=>c.checked=e.target.checked);
$('#approveBtn').onclick = async ()=>{ const ids=checked(); if(!ids.length) return alert('Select at least one draft.'); const o=await api('/api/reviews/approve-batch',{ids}); alert(`Approved ${o.approved||0}.`); location.reload(); };
$('#postBtn').onclick = async ()=>{
const ids=checked(); if(!ids.length) return alert('Select approved drafts to post.');
if(!confirm(`Post ${ids.length} item(s), one at a time? Reviews open in Chrome for you to submit; only seller emails with an address send. Nothing fires unless executors are ARMED.`)) return;
const o=await api('/api/reviews/post-approved',{ids, live:true});
alert(o.live===false ? 'Executors are OFF — nothing sent. Arm REVIEWS_EXECUTORS_LIVE first.' : 'Results:\n'+o.results.map(r=>`${r.id}: ${r.status}${r.reason?' — '+r.reason:''}`).join('\n'));
location.reload();
};
$$('.skipBtn').forEach(b=>b.onclick=async()=>{ await api('/api/reviews/skip',{id:b.dataset.id}); location.reload(); });
$$('.unapproveBtn').forEach(b=>b.onclick=async()=>{ await api('/api/reviews/unapprove',{id:b.dataset.id}); location.reload(); });
$$('.saveTo').forEach(b=>b.onclick=async()=>{ const to=$(`.sellerTo[data-id="${b.dataset.id}"]`).value; await api('/api/reviews/seller-email-to',{id:b.dataset.id,to}); location.reload(); });
// filter
$('#filterSel').onchange = e=>{ const v=e.target.value; $$('.draft').forEach(d=>{ const st=d.dataset.status; d.style.display = v==='all'||(v==='needs'&&st==='draft')||(v==='approved'&&st==='approved') ? '' : 'none'; }); };
// density
const dr=$('#densityRange'); if(dr) dr.oninput=e=>{ $('#reviewGrid').style.gridTemplateColumns=`repeat(auto-fill,minmax(${e.target.value}px,1fr))`; };
})();
</script>
<%- include('partials/footer', { }) %>