mischief: a storefront where money buys a stranger some trouble
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.
This commit is contained in:
@@ -25,19 +25,62 @@
|
||||
<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">
|
||||
<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>
|
||||
@@ -49,6 +92,8 @@
|
||||
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);
|
||||
@@ -57,12 +102,15 @@
|
||||
|
||||
function row(a) {
|
||||
var idle = a.Idle ? ' <span class="text-[color:var(--ink)]/45">· ' + esc(a.Idle) + '</span>' : '';
|
||||
return '<li class="flex items-center gap-4 px-5 py-3">' +
|
||||
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></li>';
|
||||
esc(a.Where) + idle + '</span>' + button + '</li>';
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
@@ -84,7 +132,157 @@
|
||||
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}}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user