import { checkoutPath, openSub3, readSub3Result } from './sub3.js'; const $ = (id) => document.getElementById(id); let token = sessionStorage.getItem('session'); let config; let account; let busy = false; async function api(path, body, headers = {}) { const response = await fetch(path, { method: body ? 'POST' : 'GET', headers: { ...(token && { Authorization: `Bearer ${token}` }), ...(body && { 'Content-Type': 'application/json' }), ...headers }, body: body && JSON.stringify(body) }); const data = await response.json(); if (response.status === 401) signOut(); // only a 401 signs out; a 503 keeps the session if (!response.ok) throw new Error(data.error); return data; } // The popup or redirect result is only a hint: the backend looks the order up and decides. async function receive(result) { if (result.token) { token = result.token; sessionStorage.setItem('session', token); account = await api('/api/refresh', {}); return; } const order = { payer: result.payer, orderId: result.orderId }; for (let attempt = 0; attempt < 20; attempt++) { account = await api('/api/refresh', { order }); if (!account.pending) return; $('notice').textContent = 'Payment received, waiting for confirmation…'; render(); await new Promise((done) => setTimeout(done, 3000)); } $('notice').textContent = 'Still confirming. Refresh in a minute; the payment will not be lost.'; } function signOut() { token = null; account = null; sessionStorage.removeItem('session'); } // What a tier button does, given the current membership (the checkout page enforces the same rules). function tierAction(item) { if (!account?.tier) return 'Subscribe'; if (account.tier === item.key) return 'Renew'; const level = config.items.find((i) => i.key === account.tier)?.level ?? account.level; if (item.level > level) return 'Upgrade'; return `After ${new Date(account.end * 1000).toLocaleDateString()}`; } const options = () => ({ site: config.site, redirect: $('redirect').checked }); // Sign in from a click. merchant=acme lets the page show your verified logo and @acme. async function signIn() { const handle = config.product.split('/')[0]; await receive(await openSub3(`/login?merchant=${handle}`, options())); } // Buy from a click. The checkout page chooses purchase, renewal or upgrade from the chain. async function buy(item, units) { const path = checkoutPath(config.product, item.key, { units, user: account.user }); await receive(await openSub3(path, options())); } function button(label, units, item, { enabled = true, primary = false } = {}) { const b = document.createElement('button'); b.textContent = label; if (primary) b.className = 'primary'; b.disabled = busy || !account || !enabled; b.onclick = () => run(async () => { await buy(item, units); log( 'Checkout', `Paid ${item.name} on sub3 /checkout. The result is only a hint: your backend checked the order and re-read sub3.` ); logAccess('After the payment'); }); return b; } // A small element helper: el('p', 'detail', 'text', child…) function el(tag, className, ...children) { const node = document.createElement(tag); if (className) node.className = className; node.append(...children.filter((c) => c !== null && c !== undefined && c !== '')); return node; } const day = (seconds) => new Date(seconds * 1000).toLocaleDateString(); function stat(label, value, note) { return el( 'div', 'stat', el('span', '', label), el('strong', '', value), note ? el('small', '', note) : '' ); } function itemCard(item) { if (item.kind === 'tier') { const action = tierAction(item); const allowed = ['Subscribe', 'Renew', 'Upgrade'].includes(action); const current = account?.tier === item.key; const card = el( 'div', current ? 'item current' : 'item', el('span', 'kind', `Tier · level ${item.level}`), current ? el('span', 'tag', 'Your plan') : '', el('h3', '', item.name), el('div', 'price', `$${item.price}`, el('small', '', ' / month')), el('div', 'detail', `or $${(item.price * 10).toFixed(2)} / year — 2 months free`) ); const actions = allowed ? [ button(`${action} · 1 month`, 1, item), button(`${action} · 12 months`, 12, item, { primary: true }) ] : [button(action, 1, item, { enabled: false })]; card.append(el('div', 'actions', ...actions)); if (action === 'Upgrade') card.append( el('div', 'note', 'Starts now at full price; the rest of your current tier ends.') ); return card; } if (item.kind === 'lifetime') { const owned = account?.lifetime.includes(item.key); return el( 'div', owned ? 'item current' : 'item', el('span', 'kind', 'Lifetime'), owned ? el('span', 'tag', 'Owned') : '', el('h3', '', item.name), el('div', 'price', `$${item.price}`, el('small', '', ' once')), el('div', 'detail', 'Yours for good, whatever your tier.'), el( 'div', 'actions', button(owned ? 'Owned' : 'Buy', 1, item, { enabled: !owned, primary: true }) ) ); } return el( 'div', 'item', el('span', 'kind', 'Credit pack'), el('h3', '', item.name), el('div', 'price', `$${item.price}`, el('small', '', ` · ${item.credits} credits`)), el('div', 'detail', 'Used after this cycle’s allowance. Buy as many as you like.'), el('div', 'actions', button('Buy 1', 1, item, { primary: true }), button('Buy 5', 5, item)) ); } // Why "left this cycle" shows what it shows: the allowance per tier is this app's rule (config.mjs), not sub3's. function allowanceNote() { if (!account.tier) return 'no tier, so no allowance'; const per = config.rules?.allowances?.[account.tier]; if (per === undefined) return `"${account.tier}" has no allowance in config.mjs`; return `of ${per} per 30-day cycle (config.mjs)`; } // The developer view: what just happened, which piece did it, and sub3's raw answer. function log(step, detail, kind = '') { const time = new Date().toLocaleTimeString(); $('log').prepend( el('li', kind, el('strong', '', step), el('span', '', ` ${detail}`), el('time', '', time)) ); } function logAccess(why) { const a = account?.sub3; if (!a) return; const m = a.membership; log( 'GET /v1/access', `${why}: ${m?.active ? `${m.tier} (level ${m.level}) until ${day(m.end)}, cycle ${m.cycle?.index ?? '–'}` : 'no active tier'}${a.lifetime.length ? `, owns ${a.lifetime.join(', ')}` : ''}. Credits ${account.credits} come from your ledger.` ); } function render() { if (!config) return; $('welcome').hidden = !!token; $('signout').hidden = $('refresh').hidden = !token; $('who').textContent = account ? `${account.user.slice(0, 6)}…${account.user.slice(-4)}` : ''; $('run').disabled = busy || !account; $('account').replaceChildren( account ? el( 'div', 'stats', stat( 'Plan', account.tier ? (config.items.find((i) => i.key === account.tier)?.name ?? account.tier) : 'None', account.tier ? `until ${day(account.end)}` : 'Pick a tier below' ), stat('Left this cycle', account.allowance, allowanceNote()), stat( 'Credits', account.credits, account.waiting ? `${account.waiting} payment(s) confirming` : 'from packs' ), stat('Lifetime', account.lifetime.length ? account.lifetime.join(', ') : 'None') ) : token ? el('p', 'hint', 'Loading your account…') : '' ); $('items').replaceChildren(...config.items.map(itemCard)); $('raw').textContent = account?.sub3 ? JSON.stringify(account.sub3, null, 2) : 'Sign in to see GET /v1/access for your wallet.'; } async function run(task) { if (busy) return; busy = true; $('notice').textContent = ''; render(); try { await task(); } catch (error) { $('notice').textContent = error.message; log('Error', error.message, 'error'); } finally { busy = false; render(); } } $('redirect').checked = localStorage.getItem('redirect') === 'true'; $('redirect').onchange = () => localStorage.setItem('redirect', String($('redirect').checked)); $('signin').onclick = () => run(async () => { await signIn(); log( 'Signed in', 'sub3 /login returned a session token; your backend verified it (sub3.mjs verifySession).' ); logAccess('Your backend asked sub3 what this wallet has'); }); $('signout').onclick = () => { signOut(); render(); }; $('refresh').onclick = () => run(async () => { account = await api('/api/refresh', {}); logAccess('Refreshed (packs paid since are credited once, from GET /v1/orders)'); }); $('run').onclick = () => run(async () => { const body = { text: $('text').value, premium: $('premium').checked, fail: $('fail').checked }; const result = await api('/api/run', body, { 'Idempotency-Key': crypto.randomUUID() }); $('result').textContent = result.text; const from = result.charged?.allowance !== '0' ? "this cycle's allowance" : 'your credits'; log( 'Ran it', `charged ${config.rules?.actionCost ?? 1} from ${from} in your ledger (ledger.mjs reserve).` ); account = await api('/api/account'); }); await run(async () => { config = await api('/api/config'); $('environment').textContent = config.environment; if (config.premium) $('premium-rule').textContent = `(tier level ${config.premium.level}+ or ${config.premium.lifetime})`; log( 'GET /api/config', `${config.product}: ${config.items.length} items read from sub3 (GET /v1/products).` ); const result = readSub3Result(); // back from a redirect if (result) await receive(result); else if (token) account = await api('/api/refresh', {}); logAccess('Your backend asked sub3 what this wallet has'); });