/* The universal Wallet. Loaded by the navbar on first tap; bearer requests never send cookies. */
(function (root) {
'use strict';
let active = null;
const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
function amount(b) {
const value = Number(b.amount) / Math.pow(10, b.scale || 0);
return b.currency === 'USD' ? new Intl.NumberFormat(undefined, { style: 'currency', currency: 'USD' }).format(value) : value.toLocaleString();
}
function close() { if (active) active.close(); }
// 4.221.0: the sheet's marks are the network's glyphs (openvibe-shared/glyphs, openvibe.design/icons);
// test/wallet-glyphs.test.js keeps them equal to the registry.
const GLYPHS = {
wallet: ' ',
history: ' ',
store: ' ',
gift: ' ',
list: ' ',
trophy: ' ',
check: ' ',
chevron: ' ',
};
const glyph = (name, cls) => { const t = el('span', cls || 'ov-wallet-glyph'); t.setAttribute('aria-hidden', 'true'); t.innerHTML = `${GLYPHS[name] || ''} `; return t; };
/** "3 min ago", "2 h ago", "yesterday", or the date. */
function ago(at) {
const t = Date.parse(at || ''); if (!Number.isFinite(t)) return '';
const m = Math.max(0, Math.round((Date.now() - t) / 60000));
if (m < 1) return 'just now'; if (m < 60) return `${m} min ago`;
const h = Math.round(m / 60); if (h < 24) return `${h} h ago`;
return h < 48 ? 'yesterday' : new Date(t).toLocaleDateString(undefined, { day: 'numeric', month: 'short' });
}
/**
* A currency's recent moves, grouped so they read as a list, not a paragraph: every "for the quest …" is one group
* (Quests done ×10, each quest named in the fold); any other reason is its own row, counted when it repeats.
*/
function groupMoves(moves) {
const groups = new Map();
for (const m of moves) {
const label = String((m && m.label) || '').replace(/\s+/g, ' ').trim(); if (!label) continue;
const quest = /^for the quest\s+(.+)$/i.exec(label);
const key = quest ? 'quest' : label.toLowerCase();
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: '' };
const d = Number(m.delta); if (Number.isFinite(d)) g.delta += d;
if (quest) g.items.push({ text: quest[1], delta: Number.isFinite(d) ? d : null });
g.n += 1; if (String(m.at || '') > g.at) g.at = String(m.at || '');
groups.set(key, g);
}
return [...groups.values()].sort((a, b) => b.at.localeCompare(a.at));
}
/**
* A hosted checkout is followed only to a host Billing names for the chosen provider (checkout.providers:
* [{ name, hosts }]) or to Billing itself. Anything else — http, a URL with a username, a foreign host — is refused.
*/
const maker = (issuer) => { const n = String(issuer || '').replace(/^(service|app):/, '').replace(/[-_]+/g, ' ').trim(); return n ? n.charAt(0).toUpperCase() + n.slice(1) : 'OpenVibe'; };
function checkoutAllowed(value, providers) {
let url;
try { url = new URL(String(value)); } catch { return false; }
if (url.protocol !== 'https:' || url.username || url.password) return false;
const hosts = new Set(['billing.openvibe.network']);
for (const provider of providers || []) for (const host of (provider && provider.hosts) || []) {
if (typeof host === 'string' && host) hosts.add(host.toLowerCase());
}
return hosts.has(url.hostname.toLowerCase());
}
// What the sheet last showed in this tab (the wallet's own answer: the person's balances and recent lines, kept only
// in their tab): the next opening starts from it,
// so the cards are right from the first frame. Forgotten when the account changes.
const KEEP = 'ov-wallet-last';
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; } };
const keep = (d) => { try { root.sessionStorage.setItem(KEEP, JSON.stringify(d)); } catch { /* a closed store: it opens with placeholders */ } };
if (typeof document !== 'undefined' && document.addEventListener) document.addEventListener('openvibe-auth-changed', () => { try { root.sessionStorage.removeItem(KEEP); } catch { /* */ } });
// 4.202.0 (asks 304 and 318): the wallet sits in the one window (ov-window.js, loaded from beside this file): the
// same motion, keys, phone sheet and window style as every other window; its own where that is missing.
const WALLET_REV = { 'ov-window.js': '55f98e14ef27' };
const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
function needWindow() {
return new Promise((ok) => {
if (root.OVWindow) return ok(root.OVWindow);
const m = /^(https?:\/\/[^?#]*\/)ov-wallet(?:\.[0-9a-f]{6,})?\.js(?:[?#]|$)/.exec(selfSrc);
if (!m) return ok(null);
const sc = document.createElement('script');
sc.src = m[1] + 'ov-window.js' + (WALLET_REV['ov-window.js'] ? '?v=' + WALLET_REV['ov-window.js'] : '');
sc.onload = () => ok(root.OVWindow || null); sc.onerror = () => ok(null);
document.head.appendChild(sc);
});
}
async function open(options) {
close();
const token = (options.getToken ? options.getToken() : options.token) || null;
// cookieSession: no token for the page (httpOnlyAccess sites): ask with the person's Network cookie instead.
const cookie = !token && options.cookieSession === true;
if (!token && !cookie) throw new Error('Sign in to open your wallet');
// Every site's connect-src allows openvibe.network and refuses billing./inventory.openvibe.network, so the
// sheet asks Network's relays (/api/frame/wallet/…); Network forwards the bearer and never a cookie.
const walletApi = `${(options.frameBase || 'https://openvibe.network/api/frame').replace(/\/+$/, '')}/wallet`;
const controller = new AbortController();
const W = await needWindow();
let win = null;
const dialog = W ? el('div', 'ov-wallet ov-wallet-in') : el('dialog', 'ov-wallet');
dialog.setAttribute('aria-labelledby', 'ov-wallet-title');
const header = el('header', 'ov-wallet-head'), heading = el('div', 'ov-wallet-name');
const title = el('h2', '', 'Wallet'); title.id = 'ov-wallet-title';
heading.append(title, el('small', '', 'Yours on every OpenVibe site'));
header.append(glyph('wallet', 'ov-wallet-mark'));
const exit = el('button', 'ov-wallet-close', '×'); exit.type = 'button'; exit.setAttribute('aria-label', 'Close wallet');
header.append(heading, exit);
const body = el('div', 'ov-wallet-body is-first'), status = el('p', 'ov-wallet-status', '');
status.setAttribute('role', 'status'); body.append(status); dialog.append(header, body); if (W) exit.remove(); // the window's own × closes it
const previousFocus = document.activeElement, previousOverflow = document.documentElement.style.overflow;
// A site's surfaces.js locks the page for any modal dialog and restores it on close; locking here as well would
// make its saved state "hidden". Only a page without it needs the wallet's own lock.
const ownLock = !!W || !root.OVSurfaces; // in the window there is no for surfaces.js to see
let disposed = false, data = null, allItems = null, equipped = {}, kinds = [], storeItems = [], changeData = null;
const here = { store: false, items: false }; // which parts have answered: until then a placeholder of their size stands in
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; };
const shown = {}; // what each balance last showed, so a change counts from it
const pending = new Map();
const session = { close() {
if (disposed) return; disposed = true; controller.abort();
if (win) { const w = win; win = null; w.close(); } else if (dialog.close) dialog.close();
dialog.remove();
if (ownLock) document.documentElement.style.overflow = previousOverflow;
document.removeEventListener('openvibe-auth-changed', session.close);
if (active === session) active = null;
if (previousFocus && previousFocus.isConnected) previousFocus.focus();
} };
active = session;
const identity = () => {
if (disposed || (options.getToken && (options.getToken() || null) !== token)) { session.close(); throw new Error('Your account changed. Open Wallet again.'); }
};
async function request(path, method = 'GET', payload, publicRead = false) {
identity();
const r = await fetch(walletApi + path, { method, credentials: cookie && !publicRead ? 'include' : 'omit', signal: controller.signal,
headers: { ...(publicRead || !token ? {} : { Authorization: `Bearer ${token}` }), ...(payload ? { 'Content-Type': 'application/json' } : {}) },
...(payload ? { body: JSON.stringify(payload) } : {}) });
const value = await r.json(); identity();
if (!r.ok) throw new Error(value.detail || 'This is temporarily unavailable. Please try again.');
return value;
}
// What went wrong, in a person's words: a program's own error (a TypeError from a read that came back in another
// shape, a failed fetch) is never shown as it is.
function said(error) {
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.';
if (!error || (error.name && error.name !== 'Error') || !error.message) return 'Part of your wallet could not be loaded. Close Wallet and open it again.';
return error.message;
}
function message(error) { if (!disposed) { status.textContent = said(error); status.classList.add('is-error'); status.scrollIntoView({ block: 'nearest' }); } }
function button(label, action, cls) {
const b = el('button', cls || 'ov-wallet-button', label); b.type = 'button';
b.addEventListener('click', async () => {
if (b.disabled) return; b.disabled = true;
try { identity(); await action(); } catch (e) { message(e); } finally { if (b.isConnected) b.disabled = false; }
}); return b;
}
function section(title, description, icon, into) {
const s = el('section', 'ov-wallet-section'), head = el('div', 'ov-wallet-sh'), words = el('div');
words.append(el('h3', '', title)); if (description) words.append(el('p', 'ov-wallet-muted', description));
if (icon) head.append(glyph(icon)); head.append(words); s.append(head); (into || body).append(s); return s;
}
/** Nothing here yet, said as a small card with its mark (never two grey lines under a heading). */
function empty(icon, line, sub) {
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;
}
function preview(art, name) {
const p = el('div', 'ov-wallet-preview');
if (art && /^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(art.media_id || '')) {
const img = el('img'); img.src = `https://openvibe.media/o/${art.media_id}`; img.alt = name; img.loading = 'lazy'; p.append(img);
} else if (art && art.emoji) p.textContent = String(art.emoji).slice(0, 12);
else { p.append(el('span', 'ov-wallet-sample', name)); if (art && /rainbow|neon|galaxy/.test(art.token || '')) p.classList.add('is-rainbow'); }
return p;
}
async function refreshWallet() {
data = await request('/api/v1/me/wallet');
if (options.onWallet) options.onWallet(data);
}
async function refreshItems() {
[allItems, equipped] = await Promise.all([
request('/api/v1/me/items?limit=100'),
request(`/api/v1/people/${encodeURIComponent(data.subject.id)}/equipped`, 'GET', null, true),
]);
}
function checkout(kind) {
const old = body.querySelector('.ov-wallet-checkout'); if (old) old.remove();
const form = el('form', 'ov-wallet-checkout');
form.append(el('h3', '', kind === 'vibes' ? 'Buy Vibes' : 'Top up Balance'));
const offers = data.checkout || {}, topup = offers.topup || {};
// Billing names each provider and the hosts its hosted checkout may use; an older answer (plain names)
// still lists the provider but allows no checkout host, so the sheet fails closed rather than guess.
const providers = (offers.providers || []).map((p) => (typeof p === 'string' ? { name: p, hosts: [] } : p)).filter((p) => p && p.name);
if (!providers.length || (kind === 'topup' && !topup.enabled)) {
form.append(el('p', '', 'Checkout is not available right now. Please try again later.')); body.prepend(form); return;
}
const providerLabel = el('label', '', 'Pay with'), provider = el('select');
for (const p of providers) { const option = el('option', '', p.name === 'stripe' ? 'Card' : p.name); option.value = p.name; provider.append(option); }
providerLabel.append(provider);
const amountLabel = el('label', '', kind === 'vibes' ? 'Vibes' : 'Amount to add');
const input = el(kind === 'vibes' ? 'input' : 'select');
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; }
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); }
amountLabel.append(input);
const cost = el('p', 'ov-wallet-muted');
function quote() {
const quantity = Number(input.value);
const tier = kind === 'vibes' && [...offers.vibes.price_tiers].sort((a, b) => b.min - a.min).find((t) => quantity >= t.min);
const cents = kind === 'vibes' ? (tier ? Math.round(quantity * tier.usd_per_bit * 100) : null) : quantity + Math.ceil(quantity * topup.fee_bps / 10000);
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.`;
}
input.addEventListener('input', quote); quote();
const submit = el('button', 'ov-wallet-button is-primary', 'Continue to checkout'); submit.type = 'submit';
form.append(amountLabel, providerLabel, cost, submit, button('Cancel', () => form.remove(), 'ov-wallet-button'));
let attempt = null;
form.addEventListener('submit', async (event) => {
event.preventDefault(); if (submit.disabled || !form.reportValidity()) return; submit.disabled = true;
try {
if (!attempt) attempt = { idempotency_key: root.crypto.randomUUID(), provider: provider.value,
...(kind === 'vibes' ? { vibes: Number(input.value) } : { amount_cents: Number(input.value) }) };
input.disabled = true; provider.disabled = true;
const result = await request(kind === 'vibes' ? '/api/v1/me/vibes/buy' : '/api/v1/me/wallet/topups', 'POST', attempt);
if (!checkoutAllowed(result.checkout_url, providers)) throw new Error('Checkout is unavailable.');
root.location.assign(result.checkout_url);
} catch (e) { message(e); submit.disabled = false; }
});
body.prepend(form); form.scrollIntoView({ block: 'start' }); input.focus();
}
// Before anything is known (the first opening in a tab): three cards' worth of room, so nothing moves when they come.
function hold() { body.replaceChildren(status, wait(3, 'is-card'), wait(2, 'is-row')); status.textContent = 'Opening your wallet…'; }
function render() {
body.replaceChildren(status); status.textContent = data.money_writes_frozen ? 'Purchases and top-ups are paused. Your balances and items are still here.' : '';
status.classList.remove('is-error');
// The bar keeps the last window in this tab after marking it read. A direct sheet read supplies it when
// the bar has not yet fetched changes. Reasons are the API's plain labels, never ledger keys.
const balances = el('div', 'ov-wallet-balances'); body.append(balances);
const pair = el('div', 'ov-wallet-pair');
const changeWindow = (root.OpenVibePurse && root.OpenVibePurse.recentChanges()) || changeData;
const changes = [];
if (changeWindow) {
for (const [key, id, name] of [['opencoins', 'coins', 'OpenCoins'], ['vibes', 'vibes', 'Vibes'], ['channel_points', 'channel-points', 'Channel points']]) {
const part = changeWindow[key];
if (part && Number.isSafeInteger(Number(part.net)) && Number(part.net) !== 0) changes.push({ id, name, part });
}
changes.sort((a, b) => String((b.part.moves || []).at(-1)?.at || '').localeCompare(String((a.part.moves || []).at(-1)?.at || '')));
}
if (changes.length) {
const sec = section('What changed', 'Since you last looked.', 'history', pair);
sec.classList.add('ov-wallet-changes');
for (const { id, name, part } of changes) {
const row = el('div', 'ov-wallet-change'), info = el('div', 'ov-wallet-change-t');
const purse = root.OpenVibePurse || null;
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'));
mark.setAttribute('aria-hidden', 'true');
const moves = Array.isArray(part.moves) ? [...part.moves].sort((a, b) => String(b.at || '').localeCompare(String(a.at || ''))) : [];
const groups = groupMoves(moves);
info.append(el('strong', '', name));
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` : '');
const when = ago(moves[0] && moves[0].at);
if (line || when) info.append(el('small', '', [line, when].filter(Boolean).join(' · ')));
const n = Number(part.net), full = `${n > 0 ? '+' : '−'}${Math.abs(n).toLocaleString().replace(/,/g, '\u2009')}`;
const compact = Math.abs(n) > 10000 ? `${n > 0 ? '+' : '−'}${Math.round(Math.abs(n) / 1000)}k` : full;
const value = el('span', `ov-wallet-change-n ${n > 0 ? 'is-gain' : 'is-loss'}`, compact);
value.style.fontVariantNumeric = 'tabular-nums'; value.title = full; value.setAttribute('aria-label', full);
row.append(mark, info, value);
// Each move, in a fold: the quests by name, the rest as they were said.
if (moves.length > 1 || groups.some((g) => g.items.length)) {
const more = el('details', 'ov-wallet-change-more'), sum = el('summary');
sum.append(el('span', '', `See all ${moves.length}`), glyph('chevron', 'ov-wallet-chev'));
const list = el('ul');
const signed = (v) => (v == null || !v ? null : el('b', v > 0 ? 'is-gain' : 'is-loss', `${v > 0 ? '+' : '−'}${Math.abs(v).toLocaleString()}`));
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); };
for (const g of groups) {
if (g.items.length) for (const it of g.items) item(g.icon || 'list', it.text, it.delta);
else item('list', g.n > 1 ? `${g.title} ×${g.n}` : g.title, g.delta);
}
more.append(sum, list); row.append(more);
}
sec.append(row);
}
}
body.append(pair);
for (const id of ['coins', 'vibes', 'balance']) {
const b = data.balances[id], card = el('article', `ov-wallet-balance is-${id}`);
const numeric = Number(b.amount) / Math.pow(10, b.scale || 0);
const words = (n) => amount({ ...b, amount: String(Math.round(n * Math.pow(10, b.scale || 0))) });
// 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.
const purse = root.OpenVibePurse || null;
const symbol = el('span', 'ov-wallet-symbol', purse ? null : { coins: 'O', vibes: 'V', balance: '$' }[id]);
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); }
const title = el('div', 'ov-wallet-balance-title'); title.append(symbol, el('h3', '', b.label));
const value = el('p', 'ov-wallet-value', amount(b));
card.append(title, value, el('p', 'ov-wallet-purpose', b.purpose));
if (id === 'coins') { const link = el('a', 'ov-wallet-button', b.action); link.href = 'https://openvibe.network/my#coins'; card.append(link); }
else { const action = button(b.action, () => checkout(id === 'vibes' ? 'vibes' : 'topup')); action.disabled = data.money_writes_frozen; card.append(action); }
balances.append(card);
// The number counts in as the sheet opens; after a purchase it counts to the new amount and the currency's
// pieces leave (or reach) its card. The full amount is what a screen reader hears, at once.
if (purse && isFinite(numeric)) {
const was = shown[id]; shown[id] = numeric;
value.setAttribute('aria-label', amount(b));
if (was == null) purse.count(value, numeric * .82, numeric, words);
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); }
}
}
const store = section('Store', 'A little more you. Items travel with your OpenVibe account.', 'store');
const grid = el('div', 'ov-wallet-grid'); store.append(grid);
if (!here.store) grid.append(wait(2, 'is-tile'));
else if (!storeItems.length) grid.append(empty('store', 'New items are on their way', 'They appear here once they pass review.'));
for (const item of storeItems) {
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)}`));
const owned = item.owned || (allItems && allItems.instances.some((i) => i.definition_id === item.id && i.state === 'owned'));
const prices = el('div', 'ov-wallet-prices');
if (owned) { const o = el('span', 'ov-wallet-owned', 'Owned'); o.prepend(glyph('check', 'ov-wallet-tick')); prices.append(o); }
else for (const [currency, price] of Object.entries(item.prices)) {
const word = currency === 'vibes' ? 'Vibes' : 'OpenCoins';
const buy = button(`Buy · ${price.toLocaleString()} ${word}`, async () => {
let attempt = pending.get(item.id);
if (!attempt) { attempt = { currency, idempotency_key: root.crypto.randomUUID() }; pending.set(item.id, attempt); }
if (attempt.currency !== currency) throw new Error('Retry this item in the currency you chose first.');
await request(`/api/v1/store/${encodeURIComponent(item.id)}/buy`, 'POST', attempt);
await Promise.all([refreshWallet(), refreshItems()]); render();
}, 'ov-wallet-price'); buy.disabled = data.money_writes_frozen || !item.available;
buy.setAttribute('aria-label', `Buy ${item.name} for ${price.toLocaleString()} ${word}`); buy.title = `Buy for ${price.toLocaleString()} ${word}`;
const purse = root.OpenVibePurse; buy.textContent = '';
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';
buy.append(ic, el('span', '', price.toLocaleString())); prices.append(buy);
}
card.append(prices);
grid.append(card);
}
const mine = section('Your items', 'Wear them wherever their kind is supported.', 'gift');
const owned = allItems ? allItems.instances.filter((i) => i.state === 'owned') : [];
if (!here.items) mine.append(wait(2, 'is-row'));
else if (!owned.length) mine.append(empty('gift', 'Your collection starts here', 'What you get or earn shows up here, ready to wear.'));
for (const item of owned) {
const def = allItems.definitions[item.definition_id]; if (!def) continue;
const kind = kinds.find((k) => k.id === def.kind), row = el('article', 'ov-wallet-owned-row'), copy = el('div');
row.append(preview(def.art, def.name)); copy.append(el('h4', '', def.name));
copy.append(el('p', 'ov-wallet-muted', `Across OpenVibe · ${(kind && kind.surfaces || []).map((s) => s.surface).join(', ') || 'supported sites'}`)); row.append(copy);
for (const slot of kind && kind.slots || []) {
const worn = equipped.slots && equipped.slots[`${def.kind}:${slot}`];
const isWorn = worn && worn.instance_id === item.id;
row.append(button(isWorn ? 'Unequip' : 'Equip', async () => {
equipped = await request('/api/v1/me/equipped', 'PUT', { kind: def.kind, slot, instance_id: isWorn ? null : item.id });
document.dispatchEvent(new CustomEvent('openvibe-items-changed')); render();
}));
}
mine.append(row);
}
if (allItems && allItems.next_cursor) mine.append(button('More items', async () => {
const page = await request('/api/v1/me/items?limit=100&cursor=' + encodeURIComponent(allItems.next_cursor));
allItems = { ...page, instances: allItems.instances.concat(page.instances), definitions: { ...allItems.definitions, ...page.definitions } }; render();
}));
const recent = section('Recent activity', 'Your last five money events.', 'list', pair);
if (!data.recent_activity.length) recent.append(empty('list', 'No activity yet', 'Purchases, top-ups and tips show up here.'));
for (const event of data.recent_activity) {
// The reason is a sentence made by billing.money-reason@1 (Billing or Network); when it names
// something with a page, the sentence is the link to it. The raw ledger key is never drawn.
const row = el('div', 'ov-wallet-event ov-wallet-act'), info = el('div', 'ov-wallet-act-t');
{ 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); }
const url = event.subject && event.subject.url;
if (url && /^https:\/\//.test(url)) {
// The link is the reason and its line together: one target of a thumb's size, no underline across the row.
const a = el('a'); a.append(el('strong', '', event.label), el('small', '', `${event.site} · ${new Date(event.at).toLocaleDateString()}`));
a.href = url; a.target = '_blank'; a.rel = 'noopener';
info.append(a);
} else info.append(el('strong', '', event.label), el('small', '', `${event.site} · ${new Date(event.at).toLocaleDateString()}`));
const units = event.kind === 'balance' ? { currency: 'USD', scale: 9 } : { currency: event.kind, scale: 0 };
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);
}
}
exit.addEventListener('click', session.close);
if (!W) dialog.addEventListener('cancel', (e) => { e.preventDefault(); session.close(); });
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(); });
document.addEventListener('openvibe-auth-changed', session.close);
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(); } });
else { document.body.append(dialog); dialog.showModal(); exit.focus(); }
if (ownLock) document.documentElement.style.overflow = 'hidden';
// There at once: what this tab last showed, or room held for it. Then everything is asked side by side (the
// store and the kinds need nothing; the items need to know whose they are, which a kept wallet already says).
const seed = kept();
if (seed) { data = seed; render(); } else hold();
setTimeout(() => body.classList.remove('is-first'), 600);
const settled = (pr) => pr.then((value) => ({ status: 'fulfilled', value }), (reason) => ({ status: 'rejected', reason }));
const storeAsked = settled(request('/api/v1/store')), kindsAsked = settled(request('/api/v1/kinds', 'GET', null, true));
const changesAsked = settled(request('/changes'));
let itemsAsked = seed ? settled(refreshItems()) : null;
try {
await refreshWallet();
identity(); keep(data); render();
changesAsked.then((result) => { if (!disposed && result.status === 'fulfilled') { changeData = result.value; render(); } });
if (!itemsAsked || (seed && seed.subject.id !== data.subject.id)) itemsAsked = settled(refreshItems());
const results = await Promise.all([storeAsked, itemsAsked, kindsAsked]);
identity(); storeItems = results[0].status === 'fulfilled' ? results[0].value.items : [];
kinds = results[2].status === 'fulfilled' ? results[2].value.kinds : [];
here.store = here.items = true;
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.'));
} catch (error) { if (seed && !disposed) { here.store = here.items = true; } message(error); }
return session;
}
const api = { open, close, amount, checkoutAllowed, GLYPHS, groupMoves };
root.OpenVibeWallet = api;
if (typeof module === 'object' && module.exports) module.exports = api;
})(typeof window !== 'undefined' ? window : globalThis);