openvibers/openvibe.shared ov-wallet.js
ov-wallet.js
| 1 | /* The universal Wallet. Loaded by the navbar on first tap; bearer requests never send cookies. */ |
| 2 | (function (root) { |
| 3 | ; |
| 4 | let active = null; |
| 5 | const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; }; |
| 6 | function amount(b) { |
| 7 | const value = Number(b.amount) / Math.pow(10, b.scale || 0); |
| 8 | return b.currency === 'USD' ? new Intl.NumberFormat(undefined, { style: 'currency', currency: 'USD' }).format(value) : value.toLocaleString(); |
| 9 | } |
| 10 | function close() { if (active) active.close(); } |
| 11 | // 4.221.0: the sheet's marks are the network's glyphs (openvibe-shared/glyphs, openvibe.design/icons); |
| 12 | // test/wallet-glyphs.test.js keeps them equal to the registry. |
| 13 | const GLYPHS = { |
| 14 | wallet: '<path d="M3.5 6.8A2.7 2.7 0 0 1 6.2 4.2H18.4v4.2M3.5 6.8V18a2.1 2.1 0 0 0 2.1 2.1h14.9V8.4H6.2A2.7 2.7 0 0 1 3.5 6.8Z"/><path d="M16.2 14.2h0"/>', |
| 15 | history: '<path d="M3.8 12.1a8.3 8.3 0 1 0 2.5-5.9L3.8 8.5M3.8 4.2v4.2h4.2"/><path d="M12 7.9v4.3l3.1 1.9"/>', |
| 16 | store: '<path d="M3.7 8.8L5.3 3.7h13.4L20.3 8.8M3.7 8.8h16.5v1.6a2.8 2.8 0 0 1-5.4 0a2.8 2.8 0 0 1-5.6 0A2.8 2.8 0 0 1 3.7 10.4ZM4.8 12.9v7.2h14.5v-7.2M9.9 20.1v-4.1h4.1v4.1"/>', |
| 17 | gift: '<rect x="4.4" y="8.8" width="15.1" height="10.4" rx="0.9"/><path d="M3.5 8.8h17M12 8.8v10.4M12 8.8c-1.4-2.8-4.7-3.8-4.7-1.4S12 8.8 12 8.8s4.7-0.9 4.7-3.3S13.4 6 12 8.8"/>', |
| 18 | list: '<path d="M8.2 5.5h11.9M8.2 12h11.9M8.2 18.5h11.9M3.3 5.5h0M3.3 12h0M3.3 18.5h0"/>', |
| 19 | trophy: '<path d="M7.9 4h8.4v5.2a4.1 4.1 0 0 1-8.4 0ZM7.9 6.1H4.7a3.2 3.2 0 0 0 3.2 4.1M16.2 6.1h3.2a3.2 3.2 0 0 1-3.2 4.1M12 13.4v4.1M7.9 20.8h8.4"/>', |
| 20 | check: '<path d="M3.7 12.1l5.4 5.4L20.4 6.1"/>', |
| 21 | chevron: '<path d="M7.9 3.6l8.4 8.4l-8.4 8.4"/>', |
| 22 | }; |
| 23 | const glyph = (name, cls) => { const t = el('span', cls || 'ov-wallet-glyph'); t.setAttribute('aria-hidden', 'true'); t.innerHTML = `<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${GLYPHS[name] || ''}</svg>`; return t; }; |
| 24 | /** "3 min ago", "2 h ago", "yesterday", or the date. */ |
| 25 | function ago(at) { |
| 26 | const t = Date.parse(at || ''); if (!Number.isFinite(t)) return ''; |
| 27 | const m = Math.max(0, Math.round((Date.now() - t) / 60000)); |
| 28 | if (m < 1) return 'just now'; if (m < 60) return `${m} min ago`; |
| 29 | const h = Math.round(m / 60); if (h < 24) return `${h} h ago`; |
| 30 | return h < 48 ? 'yesterday' : new Date(t).toLocaleDateString(undefined, { day: 'numeric', month: 'short' }); |
| 31 | } |
| 32 | /** |
| 33 | * A currency's recent moves, grouped so they read as a list, not a paragraph: every "for the quest …" is one group |
| 34 | * (Quests done ×10, each quest named in the fold); any other reason is its own row, counted when it repeats. |
| 35 | */ |
| 36 | function groupMoves(moves) { |
| 37 | const groups = new Map(); |
| 38 | for (const m of moves) { |
| 39 | const label = String((m && m.label) || '').replace(/\s+/g, ' ').trim(); if (!label) continue; |
| 40 | const quest = /^for the quest\s+(.+)$/i.exec(label); |
| 41 | const key = quest ? 'quest' : label.toLowerCase(); |
| 42 | const g = groups.get(key) || { key, title: quest ? 'Quests done' : label.charAt(0).toUpperCase() + label.slice(1), icon: quest ? 'trophy' : null, items: [], n: 0, delta: 0, at: '' }; |
| 43 | const d = Number(m.delta); if (Number.isFinite(d)) g.delta += d; |
| 44 | if (quest) g.items.push({ text: quest[1], delta: Number.isFinite(d) ? d : null }); |
| 45 | g.n += 1; if (String(m.at || '') > g.at) g.at = String(m.at || ''); |
| 46 | groups.set(key, g); |
| 47 | } |
| 48 | return [...groups.values()].sort((a, b) => b.at.localeCompare(a.at)); |
| 49 | } |
| 50 | /** |
| 51 | * A hosted checkout is followed only to a host Billing names for the chosen provider (checkout.providers: |
| 52 | * [{ name, hosts }]) or to Billing itself. Anything else — http, a URL with a username, a foreign host — is refused. |
| 53 | */ |
| 54 | const maker = (issuer) => { const n = String(issuer || '').replace(/^(service|app):/, '').replace(/[-_]+/g, ' ').trim(); return n ? n.charAt(0).toUpperCase() + n.slice(1) : 'OpenVibe'; }; |
| 55 | function checkoutAllowed(value, providers) { |
| 56 | let url; |
| 57 | try { url = new URL(String(value)); } catch { return false; } |
| 58 | if (url.protocol !== 'https:' || url.username || url.password) return false; |
| 59 | const hosts = new Set(['billing.openvibe.network']); |
| 60 | for (const provider of providers || []) for (const host of (provider && provider.hosts) || []) { |
| 61 | if (typeof host === 'string' && host) hosts.add(host.toLowerCase()); |
| 62 | } |
| 63 | return hosts.has(url.hostname.toLowerCase()); |
| 64 | } |
| 65 | // What the sheet last showed in this tab (the wallet's own answer: the person's balances and recent lines, kept only |
| 66 | // in their tab): the next opening starts from it, |
| 67 | // so the cards are right from the first frame. Forgotten when the account changes. |
| 68 | const KEEP = 'ov-wallet-last'; |
| 69 | const kept = () => { try { const j = JSON.parse(root.sessionStorage.getItem(KEEP) || 'null'); return j && j.balances && j.subject && j.subject.id ? j : null; } catch { return null; } }; |
| 70 | const keep = (d) => { try { root.sessionStorage.setItem(KEEP, JSON.stringify(d)); } catch { /* a closed store: it opens with placeholders */ } }; |
| 71 | if (typeof document !== 'undefined' && document.addEventListener) document.addEventListener('openvibe-auth-changed', () => { try { root.sessionStorage.removeItem(KEEP); } catch { /* */ } }); |
| 72 | |
| 73 | // 4.202.0 (asks 304 and 318): the wallet sits in the one window (ov-window.js, loaded from beside this file): the |
| 74 | // same motion, keys, phone sheet and window style as every other window; its own <dialog> where that is missing. |
| 75 | const WALLET_REV = { 'ov-window.js': '55f98e14ef27' }; |
| 76 | const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || ''; |
| 77 | function needWindow() { |
| 78 | return new Promise((ok) => { |
| 79 | if (root.OVWindow) return ok(root.OVWindow); |
| 80 | const m = /^(https?:\/\/[^?#]*\/)ov-wallet(?:\.[0-9a-f]{6,})?\.js(?:[?#]|$)/.exec(selfSrc); |
| 81 | if (!m) return ok(null); |
| 82 | const sc = document.createElement('script'); |
| 83 | sc.src = m[1] + 'ov-window.js' + (WALLET_REV['ov-window.js'] ? '?v=' + WALLET_REV['ov-window.js'] : ''); |
| 84 | sc.onload = () => ok(root.OVWindow || null); sc.onerror = () => ok(null); |
| 85 | document.head.appendChild(sc); |
| 86 | }); |
| 87 | } |
| 88 | async function open(options) { |
| 89 | close(); |
| 90 | const token = (options.getToken ? options.getToken() : options.token) || null; |
| 91 | // cookieSession: no token for the page (httpOnlyAccess sites): ask with the person's Network cookie instead. |
| 92 | const cookie = !token && options.cookieSession === true; |
| 93 | if (!token && !cookie) throw new Error('Sign in to open your wallet'); |
| 94 | // Every site's connect-src allows openvibe.network and refuses billing./inventory.openvibe.network, so the |
| 95 | // sheet asks Network's relays (/api/frame/wallet/…); Network forwards the bearer and never a cookie. |
| 96 | const walletApi = `${(options.frameBase || 'https://openvibe.network/api/frame').replace(/\/+$/, '')}/wallet`; |
| 97 | const controller = new AbortController(); |
| 98 | const W = await needWindow(); |
| 99 | let win = null; |
| 100 | const dialog = W ? el('div', 'ov-wallet ov-wallet-in') : el('dialog', 'ov-wallet'); |
| 101 | dialog.setAttribute('aria-labelledby', 'ov-wallet-title'); |
| 102 | const header = el('header', 'ov-wallet-head'), heading = el('div', 'ov-wallet-name'); |
| 103 | const title = el('h2', '', 'Wallet'); title.id = 'ov-wallet-title'; |
| 104 | heading.append(title, el('small', '', 'Yours on every OpenVibe site')); |
| 105 | header.append(glyph('wallet', 'ov-wallet-mark')); |
| 106 | const exit = el('button', 'ov-wallet-close', '×'); exit.type = 'button'; exit.setAttribute('aria-label', 'Close wallet'); |
| 107 | header.append(heading, exit); |
| 108 | const body = el('div', 'ov-wallet-body is-first'), status = el('p', 'ov-wallet-status', ''); |
| 109 | status.setAttribute('role', 'status'); body.append(status); dialog.append(header, body); if (W) exit.remove(); // the window's own × closes it |
| 110 | const previousFocus = document.activeElement, previousOverflow = document.documentElement.style.overflow; |
| 111 | // A site's surfaces.js locks the page for any modal dialog and restores it on close; locking here as well would |
| 112 | // make its saved state "hidden". Only a page without it needs the wallet's own lock. |
| 113 | const ownLock = !!W || !root.OVSurfaces; // in the window there is no <dialog> for surfaces.js to see |
| 114 | let disposed = false, data = null, allItems = null, equipped = {}, kinds = [], storeItems = [], changeData = null; |
| 115 | const here = { store: false, items: false }; // which parts have answered: until then a placeholder of their size stands in |
| 116 | const wait = (n, cls) => { const w = el('div', 'ov-wallet-wait-set'); w.setAttribute('aria-hidden', 'true'); for (let i = 0; i < n; i += 1) w.append(el('div', `ov-wallet-wait ${cls || ''}`)); return w; }; |
| 117 | const shown = {}; // what each balance last showed, so a change counts from it |
| 118 | const pending = new Map(); |
| 119 | const session = { close() { |
| 120 | if (disposed) return; disposed = true; controller.abort(); |
| 121 | if (win) { const w = win; win = null; w.close(); } else if (dialog.close) dialog.close(); |
| 122 | dialog.remove(); |
| 123 | if (ownLock) document.documentElement.style.overflow = previousOverflow; |
| 124 | document.removeEventListener('openvibe-auth-changed', session.close); |
| 125 | if (active === session) active = null; |
| 126 | if (previousFocus && previousFocus.isConnected) previousFocus.focus(); |
| 127 | } }; |
| 128 | active = session; |
| 129 | const identity = () => { |
| 130 | if (disposed || (options.getToken && (options.getToken() || null) !== token)) { session.close(); throw new Error('Your account changed. Open Wallet again.'); } |
| 131 | }; |
| 132 | async function request(path, method = 'GET', payload, publicRead = false) { |
| 133 | identity(); |
| 134 | const r = await fetch(walletApi + path, { method, credentials: cookie && !publicRead ? 'include' : 'omit', signal: controller.signal, |
| 135 | headers: { ...(publicRead || !token ? {} : { Authorization: `Bearer ${token}` }), ...(payload ? { 'Content-Type': 'application/json' } : {}) }, |
| 136 | ...(payload ? { body: JSON.stringify(payload) } : {}) }); |
| 137 | const value = await r.json(); identity(); |
| 138 | if (!r.ok) throw new Error(value.detail || 'This is temporarily unavailable. Please try again.'); |
| 139 | return value; |
| 140 | } |
| 141 | // What went wrong, in a person's words: a program's own error (a TypeError from a read that came back in another |
| 142 | // shape, a failed fetch) is never shown as it is. |
| 143 | function said(error) { |
| 144 | if (error && error.name === 'TypeError' && /fetch|network|load/i.test(error.message || '')) return 'Your wallet could not be reached. Check your connection and try again.'; |
| 145 | if (!error || (error.name && error.name !== 'Error') || !error.message) return 'Part of your wallet could not be loaded. Close Wallet and open it again.'; |
| 146 | return error.message; |
| 147 | } |
| 148 | function message(error) { if (!disposed) { status.textContent = said(error); status.classList.add('is-error'); status.scrollIntoView({ block: 'nearest' }); } } |
| 149 | function button(label, action, cls) { |
| 150 | const b = el('button', cls || 'ov-wallet-button', label); b.type = 'button'; |
| 151 | b.addEventListener('click', async () => { |
| 152 | if (b.disabled) return; b.disabled = true; |
| 153 | try { identity(); await action(); } catch (e) { message(e); } finally { if (b.isConnected) b.disabled = false; } |
| 154 | }); return b; |
| 155 | } |
| 156 | function section(title, description, icon, into) { |
| 157 | const s = el('section', 'ov-wallet-section'), head = el('div', 'ov-wallet-sh'), words = el('div'); |
| 158 | words.append(el('h3', '', title)); if (description) words.append(el('p', 'ov-wallet-muted', description)); |
| 159 | if (icon) head.append(glyph(icon)); head.append(words); s.append(head); (into || body).append(s); return s; |
| 160 | } |
| 161 | /** Nothing here yet, said as a small card with its mark (never two grey lines under a heading). */ |
| 162 | function empty(icon, line, sub) { |
| 163 | const e = el('div', 'ov-wallet-empty'); e.append(glyph(icon)); const w = el('div'); w.append(el('strong', '', line)); if (sub) w.append(el('span', '', sub)); e.append(w); return e; |
| 164 | } |
| 165 | function preview(art, name) { |
| 166 | const p = el('div', 'ov-wallet-preview'); |
| 167 | if (art && /^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(art.media_id || '')) { |
| 168 | const img = el('img'); img.src = `https://openvibe.media/o/${art.media_id}`; img.alt = name; img.loading = 'lazy'; p.append(img); |
| 169 | } else if (art && art.emoji) p.textContent = String(art.emoji).slice(0, 12); |
| 170 | else { p.append(el('span', 'ov-wallet-sample', name)); if (art && /rainbow|neon|galaxy/.test(art.token || '')) p.classList.add('is-rainbow'); } |
| 171 | return p; |
| 172 | } |
| 173 | async function refreshWallet() { |
| 174 | data = await request('/api/v1/me/wallet'); |
| 175 | if (options.onWallet) options.onWallet(data); |
| 176 | } |
| 177 | async function refreshItems() { |
| 178 | [allItems, equipped] = await Promise.all([ |
| 179 | request('/api/v1/me/items?limit=100'), |
| 180 | request(`/api/v1/people/${encodeURIComponent(data.subject.id)}/equipped`, 'GET', null, true), |
| 181 | ]); |
| 182 | } |
| 183 | function checkout(kind) { |
| 184 | const old = body.querySelector('.ov-wallet-checkout'); if (old) old.remove(); |
| 185 | const form = el('form', 'ov-wallet-checkout'); |
| 186 | form.append(el('h3', '', kind === 'vibes' ? 'Buy Vibes' : 'Top up Balance')); |
| 187 | const offers = data.checkout || {}, topup = offers.topup || {}; |
| 188 | // Billing names each provider and the hosts its hosted checkout may use; an older answer (plain names) |
| 189 | // still lists the provider but allows no checkout host, so the sheet fails closed rather than guess. |
| 190 | const providers = (offers.providers || []).map((p) => (typeof p === 'string' ? { name: p, hosts: [] } : p)).filter((p) => p && p.name); |
| 191 | if (!providers.length || (kind === 'topup' && !topup.enabled)) { |
| 192 | form.append(el('p', '', 'Checkout is not available right now. Please try again later.')); body.prepend(form); return; |
| 193 | } |
| 194 | const providerLabel = el('label', '', 'Pay with'), provider = el('select'); |
| 195 | for (const p of providers) { const option = el('option', '', p.name === 'stripe' ? 'Card' : p.name); option.value = p.name; provider.append(option); } |
| 196 | providerLabel.append(provider); |
| 197 | const amountLabel = el('label', '', kind === 'vibes' ? 'Vibes' : 'Amount to add'); |
| 198 | const input = el(kind === 'vibes' ? 'input' : 'select'); |
| 199 | if (kind === 'vibes') { input.type = 'number'; input.min = offers.vibes.minimum; input.max = offers.vibes.maximum; input.step = '1'; input.value = offers.vibes.minimum; input.required = true; } |
| 200 | else for (const cents of topup.amounts_cents || []) { const opt = el('option', '', amount({ amount: cents, scale: 2, currency: 'USD' })); opt.value = cents; input.append(opt); } |
| 201 | amountLabel.append(input); |
| 202 | const cost = el('p', 'ov-wallet-muted'); |
| 203 | function quote() { |
| 204 | const quantity = Number(input.value); |
| 205 | const tier = kind === 'vibes' && [...offers.vibes.price_tiers].sort((a, b) => b.min - a.min).find((t) => quantity >= t.min); |
| 206 | const cents = kind === 'vibes' ? (tier ? Math.round(quantity * tier.usd_per_bit * 100) : null) : quantity + Math.ceil(quantity * topup.fee_bps / 10000); |
| 207 | cost.textContent = cents == null ? 'Choose a valid amount.' : `Total ${amount({ amount: cents, scale: 2, currency: 'USD' })}${kind === 'topup' && topup.fee_bps ? ' including the top-up fee' : ''}. Confirm the final total at checkout.`; |
| 208 | } |
| 209 | input.addEventListener('input', quote); quote(); |
| 210 | const submit = el('button', 'ov-wallet-button is-primary', 'Continue to checkout'); submit.type = 'submit'; |
| 211 | form.append(amountLabel, providerLabel, cost, submit, button('Cancel', () => form.remove(), 'ov-wallet-button')); |
| 212 | let attempt = null; |
| 213 | form.addEventListener('submit', async (event) => { |
| 214 | event.preventDefault(); if (submit.disabled || !form.reportValidity()) return; submit.disabled = true; |
| 215 | try { |
| 216 | if (!attempt) attempt = { idempotency_key: root.crypto.randomUUID(), provider: provider.value, |
| 217 | ...(kind === 'vibes' ? { vibes: Number(input.value) } : { amount_cents: Number(input.value) }) }; |
| 218 | input.disabled = true; provider.disabled = true; |
| 219 | const result = await request(kind === 'vibes' ? '/api/v1/me/vibes/buy' : '/api/v1/me/wallet/topups', 'POST', attempt); |
| 220 | if (!checkoutAllowed(result.checkout_url, providers)) throw new Error('Checkout is unavailable.'); |
| 221 | root.location.assign(result.checkout_url); |
| 222 | } catch (e) { message(e); submit.disabled = false; } |
| 223 | }); |
| 224 | body.prepend(form); form.scrollIntoView({ block: 'start' }); input.focus(); |
| 225 | } |
| 226 | // Before anything is known (the first opening in a tab): three cards' worth of room, so nothing moves when they come. |
| 227 | function hold() { body.replaceChildren(status, wait(3, 'is-card'), wait(2, 'is-row')); status.textContent = 'Opening your wallet…'; } |
| 228 | function render() { |
| 229 | body.replaceChildren(status); status.textContent = data.money_writes_frozen ? 'Purchases and top-ups are paused. Your balances and items are still here.' : ''; |
| 230 | status.classList.remove('is-error'); |
| 231 | // The bar keeps the last window in this tab after marking it read. A direct sheet read supplies it when |
| 232 | // the bar has not yet fetched changes. Reasons are the API's plain labels, never ledger keys. |
| 233 | const balances = el('div', 'ov-wallet-balances'); body.append(balances); |
| 234 | const pair = el('div', 'ov-wallet-pair'); |
| 235 | const changeWindow = (root.OpenVibePurse && root.OpenVibePurse.recentChanges()) || changeData; |
| 236 | const changes = []; |
| 237 | if (changeWindow) { |
| 238 | for (const [key, id, name] of [['opencoins', 'coins', 'OpenCoins'], ['vibes', 'vibes', 'Vibes'], ['channel_points', 'channel-points', 'Channel points']]) { |
| 239 | const part = changeWindow[key]; |
| 240 | if (part && Number.isSafeInteger(Number(part.net)) && Number(part.net) !== 0) changes.push({ id, name, part }); |
| 241 | } |
| 242 | changes.sort((a, b) => String((b.part.moves || []).at(-1)?.at || '').localeCompare(String((a.part.moves || []).at(-1)?.at || ''))); |
| 243 | } |
| 244 | if (changes.length) { |
| 245 | const sec = section('What changed', 'Since you last looked.', 'history', pair); |
| 246 | sec.classList.add('ov-wallet-changes'); |
| 247 | for (const { id, name, part } of changes) { |
| 248 | const row = el('div', 'ov-wallet-change'), info = el('div', 'ov-wallet-change-t'); |
| 249 | const purse = root.OpenVibePurse || null; |
| 250 | const mark = el('span', 'ov-wallet-change-ic'); if (purse && purse.icon) { mark.innerHTML = purse.icon(id === 'channel-points' ? 'coins' : id, 22); mark.setAttribute('data-purse', id === 'channel-points' ? 'coins' : id); } else mark.append(glyph('wallet')); |
| 251 | mark.setAttribute('aria-hidden', 'true'); |
| 252 | const moves = Array.isArray(part.moves) ? [...part.moves].sort((a, b) => String(b.at || '').localeCompare(String(a.at || ''))) : []; |
| 253 | const groups = groupMoves(moves); |
| 254 | info.append(el('strong', '', name)); |
| 255 | const line = groups.slice(0, 2).map((g) => (g.n > 1 ? `${g.title} ×${g.n}` : g.title)).join(' · ') + (groups.length > 2 ? ` · ${groups.length - 2} more` : ''); |
| 256 | const when = ago(moves[0] && moves[0].at); |
| 257 | if (line || when) info.append(el('small', '', [line, when].filter(Boolean).join(' · '))); |
| 258 | const n = Number(part.net), full = `${n > 0 ? '+' : '−'}${Math.abs(n).toLocaleString().replace(/,/g, '\u2009')}`; |
| 259 | const compact = Math.abs(n) > 10000 ? `${n > 0 ? '+' : '−'}${Math.round(Math.abs(n) / 1000)}k` : full; |
| 260 | const value = el('span', `ov-wallet-change-n ${n > 0 ? 'is-gain' : 'is-loss'}`, compact); |
| 261 | value.style.fontVariantNumeric = 'tabular-nums'; value.title = full; value.setAttribute('aria-label', full); |
| 262 | row.append(mark, info, value); |
| 263 | // Each move, in a fold: the quests by name, the rest as they were said. |
| 264 | if (moves.length > 1 || groups.some((g) => g.items.length)) { |
| 265 | const more = el('details', 'ov-wallet-change-more'), sum = el('summary'); |
| 266 | sum.append(el('span', '', `See all ${moves.length}`), glyph('chevron', 'ov-wallet-chev')); |
| 267 | const list = el('ul'); |
| 268 | const signed = (v) => (v == null || !v ? null : el('b', v > 0 ? 'is-gain' : 'is-loss', `${v > 0 ? '+' : '−'}${Math.abs(v).toLocaleString()}`)); |
| 269 | const item = (icon, text, v) => { const li = el('li'); li.append(glyph(icon), el('span', '', text)); const b = signed(v); if (b) li.append(b); list.append(li); }; |
| 270 | for (const g of groups) { |
| 271 | if (g.items.length) for (const it of g.items) item(g.icon || 'list', it.text, it.delta); |
| 272 | else item('list', g.n > 1 ? `${g.title} ×${g.n}` : g.title, g.delta); |
| 273 | } |
| 274 | more.append(sum, list); row.append(more); |
| 275 | } |
| 276 | sec.append(row); |
| 277 | } |
| 278 | } |
| 279 | body.append(pair); |
| 280 | for (const id of ['coins', 'vibes', 'balance']) { |
| 281 | const b = data.balances[id], card = el('article', `ov-wallet-balance is-${id}`); |
| 282 | const numeric = Number(b.amount) / Math.pow(10, b.scale || 0); |
| 283 | const words = (n) => amount({ ...b, amount: String(Math.round(n * Math.pow(10, b.scale || 0))) }); |
| 284 | // The currency's own icon and colour (purse.js, the one the bar's pill has); the letter where that file is not on the page. |
| 285 | const purse = root.OpenVibePurse || null; |
| 286 | const symbol = el('span', 'ov-wallet-symbol', purse ? null : { coins: 'O', vibes: 'V', balance: '$' }[id]); |
| 287 | if (purse) { symbol.innerHTML = purse.icon(id, 30); symbol.classList.add('is-drawn', 'ovp-play'); symbol.setAttribute('aria-hidden', 'true'); card.setAttribute('data-purse', id); } |
| 288 | const title = el('div', 'ov-wallet-balance-title'); title.append(symbol, el('h3', '', b.label)); |
| 289 | const value = el('p', 'ov-wallet-value', amount(b)); |
| 290 | card.append(title, value, el('p', 'ov-wallet-purpose', b.purpose)); |
| 291 | if (id === 'coins') { const link = el('a', 'ov-wallet-button', b.action); link.href = 'https://openvibe.network/my#coins'; card.append(link); } |
| 292 | else { const action = button(b.action, () => checkout(id === 'vibes' ? 'vibes' : 'topup')); action.disabled = data.money_writes_frozen; card.append(action); } |
| 293 | balances.append(card); |
| 294 | // The number counts in as the sheet opens; after a purchase it counts to the new amount and the currency's |
| 295 | // pieces leave (or reach) its card. The full amount is what a screen reader hears, at once. |
| 296 | if (purse && isFinite(numeric)) { |
| 297 | const was = shown[id]; shown[id] = numeric; |
| 298 | value.setAttribute('aria-label', amount(b)); |
| 299 | if (was == null) purse.count(value, numeric * .82, numeric, words); |
| 300 | else if (was !== numeric) { purse.count(value, was, numeric, words); card.classList.add(numeric > was ? 'is-gain' : 'is-loss'); setTimeout(() => { if (card.isConnected) purse.burst(card, id, numeric - was); }, 60); } |
| 301 | } |
| 302 | } |
| 303 | const store = section('Store', 'A little more you. Items travel with your OpenVibe account.', 'store'); |
| 304 | const grid = el('div', 'ov-wallet-grid'); store.append(grid); |
| 305 | if (!here.store) grid.append(wait(2, 'is-tile')); |
| 306 | else if (!storeItems.length) grid.append(empty('store', 'New items are on their way', 'They appear here once they pass review.')); |
| 307 | for (const item of storeItems) { |
| 308 | const card = el('article', 'ov-wallet-item'); card.append(preview(item.preview, item.name), el('h4', '', item.name), el('p', 'ov-wallet-muted', `By ${maker(item.issuer)}`)); |
| 309 | const owned = item.owned || (allItems && allItems.instances.some((i) => i.definition_id === item.id && i.state === 'owned')); |
| 310 | const prices = el('div', 'ov-wallet-prices'); |
| 311 | if (owned) { const o = el('span', 'ov-wallet-owned', 'Owned'); o.prepend(glyph('check', 'ov-wallet-tick')); prices.append(o); } |
| 312 | else for (const [currency, price] of Object.entries(item.prices)) { |
| 313 | const word = currency === 'vibes' ? 'Vibes' : 'OpenCoins'; |
| 314 | const buy = button(`Buy · ${price.toLocaleString()} ${word}`, async () => { |
| 315 | let attempt = pending.get(item.id); |
| 316 | if (!attempt) { attempt = { currency, idempotency_key: root.crypto.randomUUID() }; pending.set(item.id, attempt); } |
| 317 | if (attempt.currency !== currency) throw new Error('Retry this item in the currency you chose first.'); |
| 318 | await request(`/api/v1/store/${encodeURIComponent(item.id)}/buy`, 'POST', attempt); |
| 319 | await Promise.all([refreshWallet(), refreshItems()]); render(); |
| 320 | }, 'ov-wallet-price'); buy.disabled = data.money_writes_frozen || !item.available; |
| 321 | buy.setAttribute('aria-label', `Buy ${item.name} for ${price.toLocaleString()} ${word}`); buy.title = `Buy for ${price.toLocaleString()} ${word}`; |
| 322 | const purse = root.OpenVibePurse; buy.textContent = ''; |
| 323 | const ic = el('span', 'ov-wallet-price-ic'); ic.setAttribute('aria-hidden', 'true'); if (purse && purse.icon) { ic.innerHTML = purse.icon(currency === 'vibes' ? 'vibes' : 'coins', 16); ic.setAttribute('data-purse', currency === 'vibes' ? 'vibes' : 'coins'); } else ic.textContent = currency === 'vibes' ? 'V' : 'O'; |
| 324 | buy.append(ic, el('span', '', price.toLocaleString())); prices.append(buy); |
| 325 | } |
| 326 | card.append(prices); |
| 327 | grid.append(card); |
| 328 | } |
| 329 | const mine = section('Your items', 'Wear them wherever their kind is supported.', 'gift'); |
| 330 | const owned = allItems ? allItems.instances.filter((i) => i.state === 'owned') : []; |
| 331 | if (!here.items) mine.append(wait(2, 'is-row')); |
| 332 | else if (!owned.length) mine.append(empty('gift', 'Your collection starts here', 'What you get or earn shows up here, ready to wear.')); |
| 333 | for (const item of owned) { |
| 334 | const def = allItems.definitions[item.definition_id]; if (!def) continue; |
| 335 | const kind = kinds.find((k) => k.id === def.kind), row = el('article', 'ov-wallet-owned-row'), copy = el('div'); |
| 336 | row.append(preview(def.art, def.name)); copy.append(el('h4', '', def.name)); |
| 337 | copy.append(el('p', 'ov-wallet-muted', `Across OpenVibe · ${(kind && kind.surfaces || []).map((s) => s.surface).join(', ') || 'supported sites'}`)); row.append(copy); |
| 338 | for (const slot of kind && kind.slots || []) { |
| 339 | const worn = equipped.slots && equipped.slots[`${def.kind}:${slot}`]; |
| 340 | const isWorn = worn && worn.instance_id === item.id; |
| 341 | row.append(button(isWorn ? 'Unequip' : 'Equip', async () => { |
| 342 | equipped = await request('/api/v1/me/equipped', 'PUT', { kind: def.kind, slot, instance_id: isWorn ? null : item.id }); |
| 343 | document.dispatchEvent(new CustomEvent('openvibe-items-changed')); render(); |
| 344 | })); |
| 345 | } |
| 346 | mine.append(row); |
| 347 | } |
| 348 | if (allItems && allItems.next_cursor) mine.append(button('More items', async () => { |
| 349 | const page = await request('/api/v1/me/items?limit=100&cursor=' + encodeURIComponent(allItems.next_cursor)); |
| 350 | allItems = { ...page, instances: allItems.instances.concat(page.instances), definitions: { ...allItems.definitions, ...page.definitions } }; render(); |
| 351 | })); |
| 352 | const recent = section('Recent activity', 'Your last five money events.', 'list', pair); |
| 353 | if (!data.recent_activity.length) recent.append(empty('list', 'No activity yet', 'Purchases, top-ups and tips show up here.')); |
| 354 | for (const event of data.recent_activity) { |
| 355 | // The reason is a sentence made by billing.money-reason@1 (Billing or Network); when it names |
| 356 | // something with a page, the sentence is the link to it. The raw ledger key is never drawn. |
| 357 | const row = el('div', 'ov-wallet-event ov-wallet-act'), info = el('div', 'ov-wallet-act-t'); |
| 358 | { const mk = el('span', 'ov-wallet-change-ic'); mk.setAttribute('aria-hidden', 'true'); const id = event.kind === 'balance' ? 'balance' : event.kind === 'vibes' ? 'vibes' : 'coins'; const purse = root.OpenVibePurse; if (purse && purse.icon) { mk.innerHTML = purse.icon(id, 20); mk.setAttribute('data-purse', id); } else mk.append(glyph('list')); row.append(mk); } |
| 359 | const url = event.subject && event.subject.url; |
| 360 | if (url && /^https:\/\//.test(url)) { |
| 361 | // The link is the reason and its line together: one target of a thumb's size, no underline across the row. |
| 362 | const a = el('a'); a.append(el('strong', '', event.label), el('small', '', `${event.site} · ${new Date(event.at).toLocaleDateString()}`)); |
| 363 | a.href = url; a.target = '_blank'; a.rel = 'noopener'; |
| 364 | info.append(a); |
| 365 | } else info.append(el('strong', '', event.label), el('small', '', `${event.site} · ${new Date(event.at).toLocaleDateString()}`)); |
| 366 | const units = event.kind === 'balance' ? { currency: 'USD', scale: 9 } : { currency: event.kind, scale: 0 }; |
| 367 | row.append(info, el('span', `ov-wallet-act-n ${Number(event.amount) > 0 ? 'is-gain' : 'is-loss'}`, `${Number(event.amount) > 0 ? '+' : ''}${amount({ ...units, amount: event.amount })}${event.kind === 'balance' ? '' : event.kind === 'vibes' ? ' Vibes' : ' OpenCoins'}`)); recent.append(row); |
| 368 | } |
| 369 | } |
| 370 | exit.addEventListener('click', session.close); |
| 371 | if (!W) dialog.addEventListener('cancel', (e) => { e.preventDefault(); session.close(); }); |
| 372 | if (!W) dialog.addEventListener('click', (e) => { if (e.target !== dialog) return; const r = dialog.getBoundingClientRect(); if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) session.close(); }); |
| 373 | document.addEventListener('openvibe-auth-changed', session.close); |
| 374 | if (W) win = W.open({ id: 'ov-wallet', title: 'Wallet', bare: true, body: dialog, kind: 'dialog', size: 'lg', width: 860, from: previousFocus, onClose: () => { win = null; session.close(); } }); |
| 375 | else { document.body.append(dialog); dialog.showModal(); exit.focus(); } |
| 376 | if (ownLock) document.documentElement.style.overflow = 'hidden'; |
| 377 | // There at once: what this tab last showed, or room held for it. Then everything is asked side by side (the |
| 378 | // store and the kinds need nothing; the items need to know whose they are, which a kept wallet already says). |
| 379 | const seed = kept(); |
| 380 | if (seed) { data = seed; render(); } else hold(); |
| 381 | setTimeout(() => body.classList.remove('is-first'), 600); |
| 382 | const settled = (pr) => pr.then((value) => ({ status: 'fulfilled', value }), (reason) => ({ status: 'rejected', reason })); |
| 383 | const storeAsked = settled(request('/api/v1/store')), kindsAsked = settled(request('/api/v1/kinds', 'GET', null, true)); |
| 384 | const changesAsked = settled(request('/changes')); |
| 385 | let itemsAsked = seed ? settled(refreshItems()) : null; |
| 386 | try { |
| 387 | await refreshWallet(); |
| 388 | identity(); keep(data); render(); |
| 389 | changesAsked.then((result) => { if (!disposed && result.status === 'fulfilled') { changeData = result.value; render(); } }); |
| 390 | if (!itemsAsked || (seed && seed.subject.id !== data.subject.id)) itemsAsked = settled(refreshItems()); |
| 391 | const results = await Promise.all([storeAsked, itemsAsked, kindsAsked]); |
| 392 | identity(); storeItems = results[0].status === 'fulfilled' ? results[0].value.items : []; |
| 393 | kinds = results[2].status === 'fulfilled' ? results[2].value.kinds : []; |
| 394 | here.store = here.items = true; |
| 395 | render(); if (results.some((r) => r.status === 'rejected')) message(new Error('Your balances are ready. The store or your items could not load. Close and reopen Wallet to retry.')); |
| 396 | } catch (error) { if (seed && !disposed) { here.store = here.items = true; } message(error); } |
| 397 | return session; |
| 398 | } |
| 399 | const api = { open, close, amount, checkoutAllowed, GLYPHS, groupMoves }; |
| 400 | root.OpenVibeWallet = api; |
| 401 | if (typeof module === 'object' && module.exports) module.exports = api; |
| 402 | })(typeof window !== 'undefined' ? window : globalThis); |
| 403 |