The web half of Mischief Makers M3. A signed-in buyer picks a mark off the anonymous roster board and pays for a monster to find them; gogobee does the real work and hands back a verdict Pete files against the order. - mischief_orders: intent in, verdict out, idempotent on a guid that is the end-to-end key gogobee passes to DebitIdem and stamps on the contract - user_euro + mischief_tiers: advisory balance and the live price list, pushed on the roster tick so the storefront never hardcodes a number that can drift - OIDC-gated buy API (target + tier + signed), bearer-authed poll/claim wire - roster board grows a 'send trouble' button, a tier picker, and a status panel Pete never touches money and never runs a game rule. It records what a buyer wants and what gogobee said happened.
311 lines
15 KiB
HTML
311 lines
15 KiB
HTML
{{define "title"}}{{.Channel.Title}} — {{.SiteTitle}}{{end}}
|
||
|
||
{{define "main"}}
|
||
<section class="mt-2 mb-8">
|
||
<div class="rounded-3xl bg-theme-{{.Channel.Theme}} text-white p-6 sm:p-10 shadow-pete relative overflow-hidden">
|
||
<div class="absolute -top-6 -right-6 text-[12rem] opacity-20 select-none" aria-hidden="true">{{.Channel.Emoji}}</div>
|
||
<div class="relative">
|
||
<p class="text-sm uppercase tracking-[0.2em] opacity-80">section</p>
|
||
<h1 class="font-display text-4xl sm:text-5xl font-bold mt-1">{{.Channel.Title}}</h1>
|
||
<p class="mt-2 max-w-xl text-white/85">{{.Channel.Blurb}}</p>
|
||
<p class="mt-4 text-xs uppercase tracking-wider opacity-75">{{.Total}} stories archived</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{{if .ShowRoster}}
|
||
<section class="mb-10" id="roster" data-stale="{{.RosterStale}}">
|
||
<div class="flex items-baseline justify-between mb-3">
|
||
<h2 class="font-display text-2xl font-bold">Out there right now</h2>
|
||
<p class="text-xs uppercase tracking-wider text-[color:var(--ink)]/50" id="roster-status">
|
||
{{if .RosterStale}}last known — the wire's gone quiet{{else}}live{{end}}
|
||
</p>
|
||
</div>
|
||
|
||
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete overflow-hidden {{if .RosterStale}}opacity-60{{end}}" id="roster-card">
|
||
<ul class="divide-y divide-[color:var(--ink)]/10" id="roster-list">
|
||
{{range .Roster}}
|
||
<li class="flex items-center gap-4 px-5 py-3" data-token="{{.Token}}" data-name="{{.Name}}">
|
||
<span class="text-lg" aria-hidden="true">{{if .OnRun}}⚔{{else}}🏠{{end}}</span>
|
||
<span class="font-semibold">{{.Name}}</span>
|
||
<span class="text-sm text-[color:var(--ink)]/60">lv {{.Level}} {{.ClassRace}}</span>
|
||
<span class="ml-auto text-sm {{if .OnRun}}font-semibold{{else}}text-[color:var(--ink)]/60{{end}}">
|
||
{{.Where}}{{if .Idle}} <span class="text-[color:var(--ink)]/45">· {{.Idle}}</span>{{end}}
|
||
</span>
|
||
{{if and $.User .OnRun}}
|
||
<button type="button" class="mischief-send shrink-0 rounded-full bg-[color:var(--ink)]/5 hover:bg-red-500 hover:text-white border border-[color:var(--ink)]/15 px-3 py-1 text-xs font-semibold transition" data-token="{{.Token}}" data-name="{{.Name}}">send trouble</button>
|
||
{{end}}
|
||
</li>
|
||
{{else}}
|
||
<li class="px-5 py-8 text-center text-[color:var(--ink)]/60">Nobody's out at the moment. Quiet week in the realm.</li>
|
||
{{end}}
|
||
</ul>
|
||
</div>
|
||
|
||
{{if not .User}}
|
||
<p class="mt-3 text-xs text-[color:var(--ink)]/50">
|
||
<a href="/auth/login?next={{.Path}}" class="underline hover:text-red-500">Sign in</a> to send something unpleasant their way.
|
||
</p>
|
||
{{end}}
|
||
|
||
{{if .User}}
|
||
<!-- Your open contracts. Rendered from filed facts, never from live game state:
|
||
Pete only knows what gogobee told it happened. -->
|
||
<div id="mischief-orders" class="mt-4 hidden">
|
||
<h3 class="text-xs uppercase tracking-wider text-[color:var(--ink)]/50 mb-2">Trouble you've paid for</h3>
|
||
<ul id="mischief-orders-list" class="space-y-2 text-sm"></ul>
|
||
</div>
|
||
|
||
<!-- The buy dialog. Prices are gogobee's, pulled live from /api/mischief/catalog;
|
||
nothing here asserts a number of its own. -->
|
||
<div id="mischief-modal" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/50 p-4">
|
||
<div class="w-full max-w-md rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete overflow-hidden">
|
||
<div class="flex items-center justify-between px-5 py-4 border-b border-[color:var(--ink)]/10">
|
||
<h3 class="font-display text-xl font-bold">Send trouble to <span id="mischief-target">—</span></h3>
|
||
<button type="button" id="mischief-close" class="text-2xl leading-none text-[color:var(--ink)]/50 hover:text-[color:var(--ink)]" aria-label="close">×</button>
|
||
</div>
|
||
<div class="px-5 py-4 space-y-3">
|
||
<p class="text-sm text-[color:var(--ink)]/60">Pick how bad. They won't know who sent it — unless you sign it, or they live to read the receipt.</p>
|
||
<div id="mischief-tiers" class="space-y-2"></div>
|
||
<label class="flex items-center gap-2 text-sm">
|
||
<input type="checkbox" id="mischief-signed" class="rounded border-[color:var(--ink)]/30">
|
||
<span>Sign my name to it <span class="text-[color:var(--ink)]/45">(+25%, and they'll know)</span></span>
|
||
</label>
|
||
<p id="mischief-balance" class="text-xs text-[color:var(--ink)]/50"></p>
|
||
<p id="mischief-result" class="text-sm hidden"></p>
|
||
</div>
|
||
<div class="flex items-center justify-end gap-2 px-5 py-4 border-t border-[color:var(--ink)]/10">
|
||
<button type="button" id="mischief-cancel" class="rounded-full px-4 py-2 text-sm font-semibold text-[color:var(--ink)]/60 hover:text-[color:var(--ink)]">Never mind</button>
|
||
<button type="button" id="mischief-confirm" class="rounded-full bg-red-500 text-white px-5 py-2 text-sm font-semibold shadow-pete hover:-translate-y-0.5 transition disabled:opacity-40 disabled:translate-y-0" disabled>Put the word out</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{{end}}
|
||
</section>
|
||
|
||
<script>
|
||
// The board is state, not a story: an open tab should keep telling the truth
|
||
// without a reload. Cheap poll — a handful of rows, no auth, no cache.
|
||
(function () {
|
||
var list = document.getElementById('roster-list');
|
||
var status = document.getElementById('roster-status');
|
||
var card = document.getElementById('roster-card');
|
||
if (!list) return;
|
||
|
||
var signedIn = {{if .User}}true{{else}}false{{end}};
|
||
|
||
function esc(s) {
|
||
var d = document.createElement('div');
|
||
d.textContent = s == null ? '' : String(s);
|
||
return d.innerHTML;
|
||
}
|
||
|
||
function row(a) {
|
||
var idle = a.Idle ? ' <span class="text-[color:var(--ink)]/45">· ' + esc(a.Idle) + '</span>' : '';
|
||
var button = (signedIn && a.OnRun)
|
||
? '<button type="button" class="mischief-send shrink-0 rounded-full bg-[color:var(--ink)]/5 hover:bg-red-500 hover:text-white border border-[color:var(--ink)]/15 px-3 py-1 text-xs font-semibold transition" data-token="' + esc(a.Token) + '" data-name="' + esc(a.Name) + '">send trouble</button>'
|
||
: '';
|
||
return '<li class="flex items-center gap-4 px-5 py-3" data-token="' + esc(a.Token) + '" data-name="' + esc(a.Name) + '">' +
|
||
'<span class="text-lg" aria-hidden="true">' + (a.OnRun ? '⚔' : '🏠') + '</span>' +
|
||
'<span class="font-semibold">' + esc(a.Name) + '</span>' +
|
||
'<span class="text-sm text-[color:var(--ink)]/60">lv ' + esc(a.Level) + ' ' + esc(a.ClassRace) + '</span>' +
|
||
'<span class="ml-auto text-sm ' + (a.OnRun ? 'font-semibold' : 'text-[color:var(--ink)]/60') + '">' +
|
||
esc(a.Where) + idle + '</span>' + button + '</li>';
|
||
}
|
||
|
||
function refresh() {
|
||
fetch('/api/roster', { headers: { 'Accept': 'application/json' } })
|
||
.then(function (r) { return r.ok ? r.json() : null; })
|
||
.then(function (data) {
|
||
if (!data) return;
|
||
var rows = data.adventurers || [];
|
||
list.innerHTML = rows.length
|
||
? rows.map(row).join('')
|
||
: '<li class="px-5 py-8 text-center text-[color:var(--ink)]/60">Nobody\'s out at the moment. Quiet week in the realm.</li>';
|
||
status.textContent = data.stale ? "last known — the wire's gone quiet" : 'live';
|
||
card.classList.toggle('opacity-60', !!data.stale);
|
||
})
|
||
.catch(function () { /* transient — the next tick will do */ });
|
||
}
|
||
|
||
setInterval(refresh, 60000);
|
||
document.addEventListener('visibilitychange', function () {
|
||
if (!document.hidden) refresh();
|
||
});
|
||
|
||
if (signedIn) initMischief(list, esc);
|
||
})();
|
||
|
||
// The storefront: pick a mark, pick how bad, pay. The only thing this code is
|
||
// authoritative about is the buyer's intent — every price, every yes/no, and the
|
||
// buyer's actual balance are gogobee's, reached through the API.
|
||
function initMischief(list, esc) {
|
||
var modal = document.getElementById('mischief-modal');
|
||
if (!modal) return;
|
||
var targetEl = document.getElementById('mischief-target');
|
||
var tiersEl = document.getElementById('mischief-tiers');
|
||
var signedEl = document.getElementById('mischief-signed');
|
||
var balanceEl = document.getElementById('mischief-balance');
|
||
var resultEl = document.getElementById('mischief-result');
|
||
var confirmEl = document.getElementById('mischief-confirm');
|
||
var ordersWrap = document.getElementById('mischief-orders');
|
||
var ordersList = document.getElementById('mischief-orders-list');
|
||
|
||
var catalog = null; // {tiers, euro, has_balance}
|
||
var chosen = null; // selected tier key
|
||
var current = null; // {token, name}
|
||
|
||
function euros(n) { return '€' + Number(n).toLocaleString(); }
|
||
|
||
function loadCatalog() {
|
||
return fetch('/api/mischief/catalog', { headers: { 'Accept': 'application/json' } })
|
||
.then(function (r) { return r.ok ? r.json() : null; })
|
||
.then(function (c) { catalog = c; return c; });
|
||
}
|
||
|
||
function drawTiers() {
|
||
if (!catalog || !catalog.tiers || !catalog.tiers.length) {
|
||
tiersEl.innerHTML = '<p class="text-sm text-[color:var(--ink)]/50">The catalogue is between snapshots. Try again in a moment.</p>';
|
||
return;
|
||
}
|
||
var signed = signedEl.checked;
|
||
var known = catalog.has_balance;
|
||
var euro = catalog.euro;
|
||
tiersEl.innerHTML = catalog.tiers.map(function (t) {
|
||
var price = signed ? t.signed_fee : t.fee;
|
||
var tooDear = known && euro < price;
|
||
return '<button type="button" class="mischief-tier w-full text-left rounded-2xl border-2 px-4 py-2 transition ' +
|
||
(chosen === t.key ? 'border-red-500 bg-red-500/10' : 'border-[color:var(--ink)]/10 hover:border-[color:var(--ink)]/25') +
|
||
(tooDear ? ' opacity-40 cursor-not-allowed' : '') + '" data-key="' + esc(t.key) + '"' + (tooDear ? ' disabled' : '') + '>' +
|
||
'<span class="flex items-baseline justify-between"><span class="font-semibold">' + esc(t.display) + '</span>' +
|
||
'<span class="font-semibold">' + euros(price) + '</span></span>' +
|
||
(t.blurb ? '<span class="block text-xs text-[color:var(--ink)]/55 mt-0.5">' + esc(t.blurb) + '</span>' : '') +
|
||
'</button>';
|
||
}).join('');
|
||
balanceEl.textContent = known ? 'You have about ' + euros(euro) + ' to spend.' : '';
|
||
Array.prototype.forEach.call(tiersEl.querySelectorAll('.mischief-tier'), function (b) {
|
||
b.addEventListener('click', function () {
|
||
chosen = b.getAttribute('data-key');
|
||
confirmEl.disabled = false;
|
||
drawTiers();
|
||
});
|
||
});
|
||
}
|
||
|
||
function open(token, name) {
|
||
current = { token: token, name: name };
|
||
chosen = null;
|
||
confirmEl.disabled = true;
|
||
resultEl.classList.add('hidden');
|
||
resultEl.textContent = '';
|
||
signedEl.checked = false;
|
||
targetEl.textContent = name;
|
||
modal.classList.remove('hidden');
|
||
modal.classList.add('flex');
|
||
(catalog ? Promise.resolve() : loadCatalog()).then(drawTiers);
|
||
}
|
||
|
||
function close() {
|
||
modal.classList.add('hidden');
|
||
modal.classList.remove('flex');
|
||
}
|
||
|
||
function badge(statusText) {
|
||
var map = {
|
||
pending: ['on its way', 'bg-amber-500/15 text-amber-700'],
|
||
placed: ['out for delivery', 'bg-red-500/15 text-red-600'],
|
||
bounced_funds: ["couldn't afford it", 'bg-[color:var(--ink)]/10 text-[color:var(--ink)]/60'],
|
||
bounced_ineligible: ['no longer a mark', 'bg-[color:var(--ink)]/10 text-[color:var(--ink)]/60']
|
||
};
|
||
var m = map[statusText] || [statusText, 'bg-[color:var(--ink)]/10 text-[color:var(--ink)]/60'];
|
||
return '<span class="rounded-full px-2 py-0.5 text-xs font-semibold ' + m[1] + '">' + esc(m[0]) + '</span>';
|
||
}
|
||
|
||
function loadOrders() {
|
||
fetch('/api/mischief/orders', { headers: { 'Accept': 'application/json' } })
|
||
.then(function (r) { return r.ok ? r.json() : null; })
|
||
.then(function (orders) {
|
||
if (!orders || !orders.length) { ordersWrap.classList.add('hidden'); return; }
|
||
ordersWrap.classList.remove('hidden');
|
||
ordersList.innerHTML = orders.map(function (o) {
|
||
var detail = o.detail ? ' <span class="text-[color:var(--ink)]/45">· ' + esc(o.detail) + '</span>' : '';
|
||
return '<li class="flex items-center gap-3">' +
|
||
'<span class="font-semibold">' + esc(o.target_name) + '</span>' +
|
||
'<span class="text-[color:var(--ink)]/55">' + esc(o.tier) + (o.signed ? ', signed' : '') + '</span>' +
|
||
'<span class="ml-auto">' + badge(o.status) + '</span>' + detail + '</li>';
|
||
}).join('');
|
||
})
|
||
.catch(function () {});
|
||
}
|
||
|
||
list.addEventListener('click', function (e) {
|
||
var b = e.target.closest('.mischief-send');
|
||
if (!b) return;
|
||
open(b.getAttribute('data-token'), b.getAttribute('data-name'));
|
||
});
|
||
signedEl.addEventListener('change', drawTiers);
|
||
document.getElementById('mischief-close').addEventListener('click', close);
|
||
document.getElementById('mischief-cancel').addEventListener('click', close);
|
||
modal.addEventListener('click', function (e) { if (e.target === modal) close(); });
|
||
|
||
confirmEl.addEventListener('click', function () {
|
||
if (!current || !chosen) return;
|
||
confirmEl.disabled = true;
|
||
resultEl.classList.add('hidden');
|
||
fetch('/api/mischief/order', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ target_token: current.token, tier: chosen, signed: signedEl.checked })
|
||
})
|
||
.then(function (r) { return r.json().then(function (b) { return { ok: r.ok, body: b }; }); })
|
||
.then(function (res) {
|
||
resultEl.classList.remove('hidden');
|
||
if (res.ok) {
|
||
resultEl.className = 'text-sm text-red-600 font-semibold';
|
||
resultEl.textContent = 'The word is out. Watch the board.';
|
||
catalog = null; // balance moved; refetch next open
|
||
loadOrders();
|
||
setTimeout(close, 1200);
|
||
} else {
|
||
resultEl.className = 'text-sm text-[color:var(--ink)]/70';
|
||
resultEl.textContent = (res.body && res.body.error) || 'That didn\'t take. Try again.';
|
||
confirmEl.disabled = false;
|
||
}
|
||
})
|
||
.catch(function () {
|
||
resultEl.classList.remove('hidden');
|
||
resultEl.className = 'text-sm text-[color:var(--ink)]/70';
|
||
resultEl.textContent = 'The line dropped. Try again.';
|
||
confirmEl.disabled = false;
|
||
});
|
||
});
|
||
|
||
loadOrders();
|
||
setInterval(loadOrders, 60000);
|
||
}
|
||
</script>
|
||
{{end}}
|
||
|
||
{{if .Stories}}
|
||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||
{{range .Stories}}
|
||
{{template "card" dict "Story" . "Theme" $.Channel.Theme}}
|
||
{{end}}
|
||
</div>
|
||
|
||
<nav class="mt-10 flex items-center justify-between">
|
||
{{if .HasPrev}}
|
||
<a href="{{.PrevURL}}" class="rounded-full bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 px-5 py-2 font-semibold shadow-pete hover:-translate-y-0.5 transition">← newer</a>
|
||
{{else}}<span></span>{{end}}
|
||
<span class="text-sm text-[color:var(--ink)]/60">page {{.Page}}</span>
|
||
{{if .HasNext}}
|
||
<a href="{{.NextURL}}" class="rounded-full bg-theme-{{.Channel.Theme}} text-white px-5 py-2 font-semibold shadow-pete hover:-translate-y-0.5 transition">older →</a>
|
||
{{else}}<span></span>{{end}}
|
||
</nav>
|
||
{{else}}
|
||
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-dashed border-[color:var(--ink)]/15 p-10 text-center text-[color:var(--ink)]/60">
|
||
Pete hasn't catalogued anything for {{.Channel.Title}} yet.
|
||
</div>
|
||
{{end}}
|
||
{{end}}
|