openvibers/openvibe.shared ov-wallet.js

ov-wallet.js

main 33.7 KB · 403 lines · javascript

1/* The universal Wallet. Loaded by the navbar on first tap; bearer requests never send cookies. */
2(function (root) {
3 'use strict';
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