verae-staff-ui/public/review.html
George Lambert 76240f1702 feat: 2026 brand staff UI kit + hash lookup panel
- verae-ui-kit and brand tokens on review.html
- Support hash lookup plane polish
2026-10-13 21:11:32 -04:00

256 lines
14 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="staff-iam" content="" />
<link rel="stylesheet" href="brand/tokens.css" />
<link rel="stylesheet" href="verae-ui-kit.css" />
<title>{{TITLE}}</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%23531B59'/%3E%3Ctext x='16' y='22' text-anchor='middle' font-size='14' font-family='system-ui' font-weight='700' fill='white'%3EV%3C/text%3E%3C/svg%3E"/>
<style>
/* Staff CS review — kit shell + plum empty states (no customer token storage) */
body.p-web { margin: 0; background: var(--bg); color: var(--ink); }
.skip { position: absolute; left: -999px; }
.skip:focus { left: 1rem; top: 1rem; z-index: 20; background: #fff; color: var(--plum); padding: 0.5rem 0.9rem; border-radius: 8px; }
aside .spacer { flex: 1; }
.card { margin-bottom: 1rem; padding: 1.1rem 1.25rem; }
.card h3 { margin: 0 0 0.75rem; font-size: 0.95rem; }
label {
display: block; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.04em;
text-transform: uppercase; color: var(--muted); margin: 0.7rem 0 0.25rem;
}
input {
width: 100%; max-width: 22rem; padding: 0.5rem 0.65rem;
border: 1px solid var(--line); border-radius: 8px; font-size: 0.95rem; background: #fff; color: var(--ink);
}
input:focus-visible, button:focus-visible, a:focus-visible { outline: 2px solid var(--plum); outline-offset: 2px; }
.row { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: end; }
.row > div { min-width: 10rem; }
button.btn, a.btn { cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; }
.money { font-variant-numeric: tabular-nums; }
.muted { color: var(--muted); }
.empty { text-align: center; padding: 1.5rem 1rem; color: var(--muted); }
.empty svg { display: block; margin: 0 auto 0.6rem; }
.empty h3, .empty .who { margin: 0 0 0.25rem; color: var(--ink); font-size: 1rem; font-weight: 750; }
.err { color: var(--err, #B42318); }
.staff-note {
font-size: 0.78rem; color: var(--muted); margin: 0.5rem 0 0;
}
</style>
</head>
<body class="p-web">
<a class="skip" href="#main">Skip to content</a>
<div class="app-shell" id="shell">
<aside>
<div class="app-brand">
<span class="mark">V</span>
<div><b>Verae</b><div class="xs muted">Staff</div></div>
</div>
<nav class="app-nav">
<button type="button" class="active" data-panel="review">Account review</button>
<button type="button" data-panel="hash">Hash lookup</button>
<button type="button" data-panel="credit">Apply credit</button>
</nav>
<div class="spacer"></div>
<p class="staff-note" style="padding:0 8px 12px">Staff session via IAM cookie. Never stores customer xVerae- keys.</p>
</aside>
<main id="main">
<div class="kicker" style="letter-spacing:.12em;text-transform:uppercase;font:700 10px system-ui;color:var(--muted);margin-bottom:4px">{{KICKER}}</div>
<div class="h1">{{TITLE}}</div>
<p class="lede">{{LEDE}}</p>
<div class="card" id="panel-review">
<div class="row">
<div>
<label for="id">Customer (email / name / id)</label>
<input id="id" list="cust-list" placeholder="Type a name, email, or id" autocomplete="off" />
<datalist id="cust-list"></datalist>
</div>
<button type="button" class="btn primary" onclick="review()">Review account</button>
</div>
<p class="staff-note">ZP-B2-style support lookup. Staff session via IAM cookie — never stores customer xVerae- keys.</p>
</div>
<div class="card" id="panel-hash" hidden>
<h3>Look up by hash</h3>
<div class="row">
<div style="flex:1;min-width:16rem">
<label for="hash">SHA-256</label>
<input id="hash" class="mono" style="font-family:var(--font-mono);max-width:none;width:100%" placeholder="64-char hex or sha256:…" spellcheck="false" autocomplete="off" />
</div>
<button type="button" class="btn primary" onclick="lookupHash()">Look up</button>
</div>
<p class="staff-note">Calls host <code>/lookup/hash/:sha</code> or admin <code>/admin/api/support/hash/:sha</code> when available. Evidence is receipt/CID only — jobId is not proof.</p>
<div id="hash-out"></div>
</div>
<div class="card" id="panel-credit">
<h3>Apply credit</h3>
<div class="row">
<div>
<label for="dollars">Amount (USD)</label>
<input id="dollars" type="number" min="0.01" step="0.01" value="5.00" />
</div>
<div>
<label for="reason">Reason</label>
<input id="reason" value="goodwill" />
</div>
<div>
<label for="agent">Agent name</label>
<input id="agent" value="cs" />
</div>
<button type="button" class="btn primary" onclick="credit()">Apply credit</button>
</div>
<p class="staff-note">Credits write to account-balance. The customer sees dollars, not cents. Requires cs.credit (or owner).</p>
</div>
<div id="out"></div>
</main>
</div>
<script>
const fmt = (c) => (c < 0 ? '-$' : '$') + (Math.abs(c || 0) / 100).toFixed(2);
const when = (v) => (v ? new Date(v).toLocaleString() : '—');
/* Plum empty-state (brand kit) */
const EMPTY_SVG =
'<svg width="80" height="64" viewBox="0 0 80 64" fill="none" aria-hidden="true"><rect x="16" y="8" width="48" height="48" rx="8" fill="#F7EFF8"/><rect x="24" y="20" width="32" height="4" rx="2" fill="#531B59" opacity=".35"/><rect x="24" y="30" width="24" height="4" rx="2" fill="#531B59" opacity=".2"/><rect x="24" y="40" width="28" height="4" rx="2" fill="#531B59" opacity=".2"/></svg>';
const empty = (title, hint) =>
`<div class="empty">${EMPTY_SVG}<div class="who">${title}</div><p>${hint}</p></div>`;
function moneyTable(list, cols, emptyTitle) {
if (!list || !list.length) return empty(emptyTitle, 'Nothing recorded for this customer yet.');
const head = cols.map((c) => `<th>${c.label}</th>`).join('');
const body = list.map((r) => '<tr>' + cols.map((c) => {
const v = c.key === 'cents' ? fmt(r.cents) : c.key === 'at' ? when(r.at) : (r[c.key] ?? '—');
return `<td class="${c.key === 'cents' ? 'money' : ''}">${v}</td>`;
}).join('') + '</tr>').join('');
return `<table class="t"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
}
function paint(st, id) {
const name = st.name || st.customerName || id;
document.getElementById('out').innerHTML = `
<div class="kpi-grid" style="grid-template-columns:repeat(auto-fit,minmax(150px,1fr))">
<div class="kpi"><div class="k">Customer</div><div class="v" style="font-size:1.05rem">${name}</div></div>
<div class="kpi"><div class="k">Prepaid balance</div><div class="v">${fmt(st.prepaidCents)}</div></div>
<div class="kpi"><div class="k">Credits</div><div class="v">${(st.credits||[]).length}</div></div>
<div class="kpi"><div class="k">Source</div><div class="v" style="font-size:1rem">${st.source || 'unknown'}</div></div>
</div>
<div class="card"><h3>Credits</h3>${moneyTable(st.credits, [{key:'cents',label:'Amount'},{key:'reason',label:'Reason'},{key:'agent',label:'Agent'},{key:'at',label:'When'}], 'No credits yet')}</div>
<div class="card"><h3>Usage</h3>${moneyTable(st.usage, [{key:'endpointId',label:'Endpoint'},{key:'cents',label:'Amount'},{key:'at',label:'When'}], 'No usage yet')}</div>
<div class="card"><h3>Payments</h3>${moneyTable(st.payments, [{key:'cents',label:'Amount'},{key:'kind',label:'Kind'},{key:'reason',label:'Reason'},{key:'at',label:'When'}], 'No payments yet')}</div>`;
}
async function loadCustomers() {
try {
const { customers } = await (await fetch('/customers')).json();
document.getElementById('cust-list').innerHTML = (customers || [])
.map((c) => `<option value="${c.name}">${c.id}${c.email ? ' · ' + c.email : ''}</option>`)
.join('');
if (customers && customers[0] && !document.getElementById('id').value) {
document.getElementById('id').value = customers[0].name;
}
} catch { /* edge optional */ }
}
loadCustomers();
async function review() {
const id = document.getElementById('id').value.trim();
try {
const r = await fetch('/review/' + encodeURIComponent(id));
const st = await r.json();
if (!r.ok) throw new Error(st.error || ('HTTP ' + r.status));
paint(st, id);
} catch (err) {
document.getElementById('out').innerHTML = `<div class="card err">${err.message}</div>`;
}
}
function receiptBadge(rec) {
const fin = String(rec.finality || rec.status || '').toLowerCase();
const block = rec.blockNumber ?? rec.merkle;
if (fin === 'final' || fin === 'completed' || (rec.receipt_id && block != null)) {
const label = block != null
? 'Final: Substrate block #' + (Number(block).toLocaleString ? Number(block).toLocaleString('en-US') : block) + ', finalized'
: 'Final';
return '<span class="badge b-ok sm" role="status">' + label + '</span>';
}
if (fin.includes('pend')) return '<span class="badge b-pend sm" role="status">Pending receipt</span>';
if (rec.exists === false) return '<span class="badge b-off sm" role="status">No record</span>';
return '<span class="badge b-info sm" role="status">' + (rec.status || 'Found') + '</span>';
}
async function lookupHash() {
const raw = document.getElementById('hash').value.trim().toLowerCase().replace(/^sha256:/, '').replace(/\s+/g, '');
const box = document.getElementById('hash-out');
if (!raw) {
box.innerHTML = empty('Enter a hash', '64-character hex SHA-256.');
return;
}
const paths = [
'/lookup/hash/' + encodeURIComponent(raw),
'/admin/api/support/hash/' + encodeURIComponent(raw),
'/support/hash/' + encodeURIComponent(raw),
];
box.innerHTML = '<p class="muted">Looking up…</p>';
let lastErr = 'Hash lookup API not available on this host.';
for (const path of paths) {
try {
const r = await fetch(path, { credentials: 'same-origin' });
const st = await r.json().catch(() => ({}));
if (r.status === 404 && (st.error === 'Hash not found' || st.exists === false)) {
box.innerHTML = empty('No record', 'No receipt for this hash. Confirm the digest.');
return;
}
if (r.status === 404 || r.status === 501) {
lastErr = st.error || lastErr;
continue;
}
if (!r.ok) throw new Error(st.error || ('HTTP ' + r.status));
const when = st.timestamp ? new Date(st.timestamp).toLocaleString() : '—';
box.innerHTML = `
<div class="kpi-grid" style="grid-template-columns:repeat(auto-fit,minmax(140px,1fr));margin-top:0.75rem">
<div class="kpi"><div class="k">Receipt</div><div class="v" style="font-size:0.95rem">${receiptBadge(st)}</div></div>
<div class="kpi"><div class="k">Submitted</div><div class="v" style="font-size:0.95rem">${when}</div></div>
<div class="kpi"><div class="k">Source</div><div class="v" style="font-size:0.95rem">${st.source || '—'}</div></div>
</div>
<p class="staff-note" style="margin-top:0.6rem">CID/receipt: <code>${st.receipt_id || st.certificate || '—'}</code> · jobId is not proof.</p>`;
return;
} catch (err) {
lastErr = err.message || lastErr;
}
}
box.innerHTML = empty('Lookup unavailable', lastErr);
}
async function credit() {
const dollars = Number(document.getElementById('dollars').value);
const body = {
customerId: document.getElementById('id').value.trim(),
cents: Math.round(dollars * 100),
reason: document.getElementById('reason').value,
agent: document.getElementById('agent').value,
};
const r = await fetch('/credits', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
if (!r.ok) {
const st = await r.json().catch(() => ({}));
document.getElementById('out').innerHTML = `<div class="card err">${st.error || r.status}</div>`;
return;
}
await review();
}
document.querySelectorAll('.app-nav button').forEach((btn) => {
btn.addEventListener('click', () => {
document.querySelectorAll('.app-nav button').forEach((b) => b.classList.remove('active'));
btn.classList.add('active');
const panel = btn.dataset.panel;
const rev = document.getElementById('panel-review');
const hash = document.getElementById('panel-hash');
const cred = document.getElementById('panel-credit');
rev.hidden = panel === 'hash';
hash.hidden = panel !== 'hash';
cred.hidden = panel === 'hash';
rev.style.outline = panel === 'review' ? '2px solid var(--plum-100)' : '';
hash.style.outline = panel === 'hash' ? '2px solid var(--plum-100)' : '';
cred.style.outline = panel === 'credit' ? '2px solid var(--plum-100)' : '';
const el = document.getElementById(panel === 'credit' ? 'dollars' : panel === 'hash' ? 'hash' : 'id');
if (el) el.focus();
});
});
</script>
<script src="role-switcher.js"></script>
</body>
</html>