- verae-ui-kit and brand tokens on review.html - Support hash lookup plane polish
256 lines
14 KiB
HTML
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>
|