openvibers/openvibe.live Commits

Delegate Live wallet and cosmetic management to Shared (#717)

OP OpenVibers committed 4 days ago · a89478adca · 14 files, +76 −413

* Use the shared wallet and expose the owner money switch

* Live: keep the rebase green with the shared Wallet

After rebasing on main, two newer decisions meet the wallet change:

- main retired the local messenger (messages use the shared Dock), so Live keeps Shared's
  default Messages action and does not load the retired module.
- main's #vibes deep link opened the removed Buy Vibes modal. Old links now open the shared
  Wallet (the bar's chips do it too), still dropping the hash so a reload does not reopen it.

loadBalance() is gone with Live's own chips; the shared sheet owns balances.

* Live: the shared wallet asks Network's relays, so drop its CSP hosts

ov-wallet.js no longer calls billing./inventory.openvibe.network from the browser: every
request goes to Network's /api/frame/wallet/… relays, which every site's connect-src already
allows. Live's connect-src keeps neither host, and the wallet test asserts it.

* Merge remote-tracking branch 'origin/main' into wallet/live-1108

# Conflicts:
#	package-lock.json
#	package.json

Changes

ddiff --git a/docs/universal-wallet.md b/docs/universal-wallet.md
nnew file mode 100644
iindex 00000000..0dd4225b
---- /dev/null
++++ b/docs/universal-wallet.md
@@@ -0,0 +1,9 @@
++# Shared wallet
++
++Live enables the Shared wallet and delegates balance display, buying Vibes, top-ups and cosmetic ownership/equipment to it. The local purchase modal, balance loader and cosmetic editor are removed; Live still renders Inventory cosmetics and keeps its stream-specific tipping/subscription actions.
++
++The review branch pins Shared's wallet branch (PR #365). Replace the branch pin with its reviewed release tag before shipping. The sheet asks Network's `/api/frame/wallet/…` relays (Network PR), so Live's `connect-src` keeps neither `billing.openvibe.network` nor `inventory.openvibe.network`. Billing #228 and Inventory #219 must be deployed with the wallet release; reviewed store listings and configured checkout providers are separate rollout prerequisites.
++
++GET /api/payments/wallet-state exposes only the owner money_writes_frozen boolean with no-store. Database errors return 503. Billing reads this alongside its own ledger freeze and fails closed if it is unavailable. No cookies, user data or new permission to change the switch are exposed.
++
++The Shared PR contains phone/desktop screenshots and Chrome sheet checks. Live's focused tests cover navbar delegation, removed local UI, retained cosmetic renderers and the owner switch including database failure. No production writes or deployment are performed by this PR.
ddiff --git a/public/features.json b/public/features.json
iindex 14140cf1..a76feba0 100644
---- a/public/features.json
++++ b/public/features.json
@@@ -313,14 +313,6 @@
"/js/recap.js"
]
},
-- "cosmetics": {
-- "js": [
-- "/js/cosmetics-ui.js"
-- ],
-- "stubs": [
-- "openCosmeticsModal"
-- ]
-- },
"updates": {
"fragment": "updates"
},
ddiff --git a/public/index.html b/public/index.html
iindex 8203ced5..c2ba10d0 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -393,38 +393,6 @@
row come from the shared module so every OpenVibe property stays in step. -->
<footer id="ov-footer" class="ovf"></footer>
--<!-- ═══════════════════════════════════════════════════════════ -->
--<!-- COSMETICS INVENTORY MODAL -->
--<!-- ═══════════════════════════════════════════════════════════ -->
--<div class="cosmetics-modal-overlay" id="cosmetics-modal">
-- <div class="cosmetics-modal">
-- <div class="cosmetics-header">
-- <h2><i class="fa-solid fa-coins" style="margin-right:6px"></i> Inventory</h2>
-- <button class="cosmetics-close" onclick="closeCosmeticsModal()">&times;</button>
-- </div>
-- <div class="cosmetics-tabs">
-- <button class="cosmetics-tab active" data-cat="name_effect" onclick="switchCosmeticTab('name_effect', this)">
-- <i class="fa-solid fa-wand-magic-sparkles"></i> Name FX
-- </button>
-- <button class="cosmetics-tab" data-cat="particle" onclick="switchCosmeticTab('particle', this)">
-- <i class="fa-solid fa-sparkles"></i> Particles
-- </button>
-- <button class="cosmetics-tab" data-cat="hat" onclick="switchCosmeticTab('hat', this)">
-- <i class="fa-solid fa-hat-wizard"></i> Hats
-- </button>
-- <button class="cosmetics-tab" data-cat="voice" onclick="switchCosmeticTab('voice', this)">
-- <i class="fa-solid fa-microphone"></i> Voices
-- </button>
-- </div>
-- <div class="cosmetics-body" id="cosmetics-body">
-- <div class="cosmetics-category active" data-cat="name_effect" id="cosmetics-cat-name_effect"></div>
-- <div class="cosmetics-category" data-cat="particle" id="cosmetics-cat-particle"></div>
-- <div class="cosmetics-category" data-cat="hat" id="cosmetics-cat-hat"></div>
-- <div class="cosmetics-category" data-cat="voice" id="cosmetics-cat-voice"></div>
-- </div>
-- </div>
--</div>
--
<!-- Scripts -->
<script src="/live-notify.js" defer></script>
<script src="/js/ov-skeleton.js" defer></script>
ddiff --git a/public/js/app.js b/public/js/app.js
iindex c95ffd90..5fa047bd 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -530,7 +530,7 @@ async function loadOpenVibeAppMeta(force = false) {
}
/* ── Modal ────────────────────────────────────────────────────── */
--const MONEY_MODALS = new Set(['donate', 'buy-funds', 'cashout', 'add-reward']);
++const MONEY_MODALS = new Set(['donate', 'cashout', 'add-reward']);
function showModal(id) {
// Vibes, tips and channel rewards live in features.json → money, which most pages never load: fetch it first.
if (MONEY_MODALS.has(id) && typeof openvibeBucksDonateModal !== 'function' && window.ov && ov.load) {
@@@ -555,7 +555,6 @@ function showModal(id) {
</a>
<p style="text-align:center;margin-top:12px;font-size:12px;color:var(--text-muted)">Registration is handled on openvibe.network</p>`,
donate: () => openvibeBucksDonateModal(),
-- 'buy-funds': () => openvibeBucksBuyModal(),
cashout: () => openvibeBucksCashoutModal(),
'stream-key': streamKeyModal(),
'add-goal': addGoalModal(),
@@@ -565,7 +564,6 @@ function showModal(id) {
};
content.innerHTML = typeof templates[id] === 'function' ? templates[id]() : (templates[id] || `<p>Unknown modal: ${id}</p>`);
overlay.classList.add('show');
-- if (id === 'buy-funds' && typeof _initBuyBucks === 'function') _initBuyBucks();
if (id === 'donate' && typeof _loadDonateGoals === 'function') _loadDonateGoals();
// Balance-first donate flow + the streamer's direct-PowerChat option.
if (id === 'donate' && typeof _initDonateModal === 'function') _initDonateModal();
@@@ -783,7 +781,7 @@ function onAuthChange() {
if (currentUser) _enableHandoff(); else if (window.OpenVibeSSO) { try { window.OpenVibeSSO.handoffLinks({ signedIn: false }); } catch { /* */ } }
// The navbar is the network's shared component; tell it who is here (js/ov-navbar-live.js).
if (window.LiveNav) window.LiveNav.sync(currentUser || null);
-- if (currentUser) loadBalance();
++ if (currentUser) window.OpenVibeNavbar.refreshWallet();
// Older notification links point at a Live DM route. Keep the reader here in the Dock.
if (currentUser && new URLSearchParams(location.search).has('dm')) {
const params = new URLSearchParams(location.search);
@@@ -802,29 +800,6 @@ function onAuthChange() {
} catch {}
}
--async function loadBalance() {
-- if (!currentUser) return;
-- try {
-- const data = await api('/funds/balance');
-- const bal = Math.round(data.balance || 0);
-- const balEl = document.getElementById('nav-balance-amount');
-- if (balEl) balEl.textContent = bal.toLocaleString();
-- if (window.LiveNav) window.LiveNav.setChip('vibes', bal.toLocaleString());
-- const udV = document.getElementById('ud-vibes');
-- if (udV) udV.textContent = bal.toLocaleString();
-- } catch { /* silent */ }
-- // Navbar OpenCoins = the GLOBAL currency (game / cosmetics / media wallet).
-- try {
-- const coinData = await api('/coins/balance');
-- // null: the Network wallet did not answer. Say so ("—") rather than show 0 as the balance.
-- const coins = coinData.balance == null ? '—' : Number(coinData.balance).toLocaleString();
-- const coinEl = document.getElementById('nav-coins-amount');
-- if (coinEl) coinEl.textContent = coins;
-- if (window.LiveNav) window.LiveNav.setChip('coins', coins);
-- const udC = document.getElementById('ud-coins');
-- if (udC) udC.textContent = coins;
-- } catch { /* silent */ }
--}
// The viewer's per-streamer CHANNEL POINTS (shown in the in-chat button + rewards
// panel), for the streamer they're currently watching. Not the navbar (that's the
ddiff --git a/public/js/cosmetics-ui.js b/public/js/cosmetics-ui.js
ddeleted file mode 100644
iindex cf333973..00000000
---- a/public/js/cosmetics-ui.js
++++ /dev/null
@@@ -1,208 +0,0 @@
--/**
-- * OpenVibe.Live — Cosmetics Inventory UI
-- * Opens from the OpenCoins badge in the navbar.
-- * Allows equipping/unequipping name effects, particles, hats, and voices. Items live in OpenVibe.Inventory; a locked
-- * item says where it is earned (a quest on OpenVibe.Quest) or that it cannot be earned yet.
-- */
--
--/* ── State ────────────────────────────────────────────────────── */
--let cosmeticsData = null; // { categories: { name_effect: [], particle: [], hat: [], voice: [] }, equipped: {} }
--let cosmeticsCatalog = null; // Full catalog from server
--let cosmeticsActiveTab = 'name_effect';
--
--/* ── Open / Close ─────────────────────────────────────────────── */
--function openCosmeticsModal() {
-- if (!currentUser) {
-- toast('Log in to view your inventory', 'warning');
-- return;
-- }
-- document.getElementById('cosmetics-modal').classList.add('active');
-- loadCosmeticsInventory();
--}
--
--function closeCosmeticsModal() {
-- document.getElementById('cosmetics-modal').classList.remove('active');
--}
--
--// Close on overlay click
--document.getElementById('cosmetics-modal')?.addEventListener('click', (e) => {
-- if (e.target.classList.contains('cosmetics-modal-overlay')) closeCosmeticsModal();
--});
--
--/* ── Tab Switching ────────────────────────────────────────────── */
--function switchCosmeticTab(cat, btn) {
-- cosmeticsActiveTab = cat;
-- // Update tab buttons
-- document.querySelectorAll('.cosmetics-tab').forEach(t => t.classList.remove('active'));
-- btn.classList.add('active');
-- // Show correct category
-- document.querySelectorAll('.cosmetics-category').forEach(c => c.classList.remove('active'));
-- const target = document.getElementById(`cosmetics-cat-${cat}`);
-- if (target) target.classList.add('active');
--}
--
--/* ── Load Data ────────────────────────────────────────────────── */
--async function loadCosmeticsInventory() {
-- try {
-- const hdrs = authHeaders();
-- const [invRes, catRes] = await Promise.all([
-- fetch('/api/cosmetics/inventory', { headers: hdrs }),
-- cosmeticsCatalog ? Promise.resolve(null) : fetch('/api/cosmetics/catalog'),
-- ]);
--
-- if (!invRes.ok) {
-- const err = await invRes.json().catch(() => ({}));
-- toast(err.error || 'Failed to load inventory', 'error');
-- return;
-- }
--
-- cosmeticsData = await invRes.json();
--
-- if (catRes) {
-- const catData = await catRes.json();
-- cosmeticsCatalog = catData.catalog;
-- }
--
-- renderCosmeticsUI();
-- } catch (err) {
-- console.error('[Cosmetics] Load error:', err);
-- toast('Failed to load inventory', 'error');
-- }
--}
--
--/* ── Render ───────────────────────────────────────────────────── */
--function renderCosmeticsUI() {
-- if (!cosmeticsData || !cosmeticsCatalog) return;
--
-- const { categories: owned, equipped } = cosmeticsData;
-- const ownedMap = {};
-- for (const cat of Object.keys(owned)) {
-- for (const item of owned[cat]) {
-- ownedMap[item.itemId] = item;
-- }
-- }
--
-- // Render each category from the full catalog
-- for (const [cat, items] of Object.entries(cosmeticsCatalog)) {
-- const container = document.getElementById(`cosmetics-cat-${cat}`);
-- if (!container) continue;
--
-- if (!items.length) {
-- container.innerHTML = `<div class="cosmetics-empty"><i class="fa-solid fa-box-open"></i><br>No ${cat.replace('_', ' ')} cosmetics available yet</div>`;
-- continue;
-- }
--
-- const grid = document.createElement('div');
-- grid.className = 'cosmetics-grid';
--
-- for (const item of items) {
-- const isOwned = !!ownedMap[item.itemId];
-- const isEquipped = equipped[cat === 'name_effect' ? 'name_effect' : cat] === item.itemId;
--
-- const card = document.createElement('div');
-- const userIsAdminCard = !!(currentUser?.capabilities?.admin_panel);
-- card.className = `cosmetic-card${isEquipped ? ' equipped' : ''}${!isOwned && !userIsAdminCard ? ' locked' : ''}`;
--
-- const userIsAdmin = !!(currentUser?.capabilities?.admin_panel);
-- let actionsHtml = '';
-- if (isOwned) {
-- if (isEquipped) {
-- actionsHtml = `
-- <button class="cosmetic-btn cosmetic-btn-unequip" onclick="cosmeticUnequip('${cat === 'name_effect' ? 'name_effect' : cat}')">Unequip</button>
-- `;
-- } else {
-- actionsHtml = `
-- <button class="cosmetic-btn cosmetic-btn-equip" onclick="cosmeticEquip('${item.itemId}')">Equip</button>
-- `;
-- }
-- } else if (userIsAdmin) {
-- // Admin bypass: can equip any cosmetic without owning it
-- if (isEquipped) {
-- actionsHtml = `
-- <button class="cosmetic-btn cosmetic-btn-unequip" onclick="cosmeticUnequip('${cat === 'name_effect' ? 'name_effect' : cat}')">Unequip</button>
-- `;
-- } else {
-- actionsHtml = `
-- <button class="cosmetic-btn cosmetic-btn-equip" onclick="cosmeticEquip('${item.itemId}')">Equip</button>
-- <span class="cosmetic-desc" style="font-style:italic;opacity:0.5;font-size:0.75rem">🔓 Admin bypass</span>
-- `;
-- }
-- } else {
-- actionsHtml = item.earn === 'quest'
-- ? `<span class="cosmetic-desc" style="opacity:0.85">🏅 Earn it with a quest on <a href="https://openvibe.quest/" target="_blank" rel="noopener">OpenVibe.Quest</a></span>`
-- : `<span class="cosmetic-desc" style="font-style:italic;opacity:0.7">Not available to earn yet</span>`;
-- }
--
-- const badgeHtml = isEquipped
-- ? `<span class="cosmetic-badge equipped-badge">EQUIPPED</span>`
-- : `<span class="cosmetic-badge tier-badge">T${item.tier}</span>`;
--
-- // Preview class for name effects
-- const previewClass = (cat === 'name_effect' && item.cssClass) ? ` ${item.cssClass}` : '';
--
-- card.innerHTML = `
-- <span class="cosmetic-emoji">${item.emoji}</span>
-- <div class="cosmetic-name${previewClass}">${esc(item.name)}</div>
-- <div class="cosmetic-desc">${esc(item.desc)}</div>
-- ${badgeHtml}
-- <div class="cosmetic-actions">${actionsHtml}</div>
-- `;
--
-- grid.appendChild(card);
-- }
--
-- container.innerHTML = '';
-- container.appendChild(grid);
-- }
--
-- // Where the rest lives: the whole inventory on OpenVibe.Inventory, and the quests that give items.
-- const body = document.getElementById('cosmetics-body');
-- if (body && !document.getElementById('cosmetics-more')) {
-- const more = document.createElement('p');
-- more.id = 'cosmetics-more';
-- more.className = 'cosmetic-desc';
-- more.style.cssText = 'text-align:center;margin:12px 0 4px';
-- more.innerHTML = '<a href="https://inventory.openvibe.network/me" target="_blank" rel="noopener">Your whole inventory on OpenVibe.Inventory</a> · <a href="https://openvibe.quest/" target="_blank" rel="noopener">Earn items on OpenVibe.Quest</a>';
-- body.after(more);
-- }
--}
--
--/* ── API Actions ──────────────────────────────────────────────── */
--async function cosmeticEquip(itemId) {
-- try {
-- const res = await fetch('/api/cosmetics/equip', {
-- method: 'POST',
-- headers: { 'Content-Type': 'application/json', ...authHeaders() },
-- body: JSON.stringify({ itemId }),
-- });
-- const data = await res.json();
-- if (data.error) { toast(data.error, 'error'); return; }
-- toast(`Equipped ${data.item?.name || itemId}!`, 'success');
-- loadCosmeticsInventory();
-- } catch (err) {
-- toast('Failed to equip', 'error');
-- }
--}
--
--async function cosmeticUnequip(slot) {
-- try {
-- const res = await fetch('/api/cosmetics/unequip', {
-- method: 'POST',
-- headers: { 'Content-Type': 'application/json', ...authHeaders() },
-- body: JSON.stringify({ slot }),
-- });
-- const data = await res.json();
-- if (data.error) { toast(data.error, 'error'); return; }
-- toast(`Unequipped ${slot.replace('_', ' ')}`, 'info');
-- loadCosmeticsInventory();
-- } catch (err) {
-- toast('Failed to unequip', 'error');
-- }
--}
--
--/* ── Escape to close ──────────────────────────────────────────── */
--document.addEventListener('keydown', (e) => {
-- if (e.key === 'Escape' && document.getElementById('cosmetics-modal')?.classList.contains('active')) {
-- closeCosmeticsModal();
-- }
--});
ddiff --git a/public/js/opencoins.js b/public/js/opencoins.js
iindex ff0a01ae..55c400e5 100644
---- a/public/js/opencoins.js
++++ b/public/js/opencoins.js
@@@ -219,9 +219,7 @@ function handleCoinEarned(msg) {
if (msg.total === undefined) return;
// "gold" events change the GLOBAL OpenCoins wallet → update the navbar.
if (msg.currency === 'gold') {
-- const navCoins = document.getElementById('nav-coins-amount');
-- if (navCoins) navCoins.textContent = msg.total.toLocaleString();
-- if (window.LiveNav) window.LiveNav.setChip('coins', msg.total.toLocaleString());
++ window.OpenVibeNavbar.refreshWallet();
return;
}
// Otherwise it's CHANNEL POINTS for a streamer → update the in-chat balance,
ddiff --git a/public/js/ov-navbar-live.js b/public/js/ov-navbar-live.js
iindex 52dcc1bc..b0549288 100644
---- a/public/js/ov-navbar-live.js
++++ b/public/js/ov-navbar-live.js
@@@ -7,7 +7,6 @@
*
* LiveNav.sync(user) after sign-in, sign-out or a profile change
* LiveNav.setActive(page) the router calls this on every page change
-- * LiveNav.setChip(id, text) 'coins' | 'vibes'
* LiveNav.setLive(isLive) the red dot on "Go Live"
*/
(function () {
@@@ -57,7 +56,7 @@
{ id: 'live', label: 'Live', items: [
{ id: 'dash', label: 'Dashboard', icon: 'fa-gauge-high', href: '/dashboard' },
{ id: 'setup', label: 'Streamer setup', icon: 'fa-list-check', onClick: act('openSetupHub') },
-- { id: 'cosmetics', label: 'Cosmetics', icon: 'fa-shirt', onClick: act('openCosmeticsModal') },
++ { id: 'wallet', label: 'Wallet & items', icon: 'fa-wallet', onClick: () => window.OpenVibeNavbar.openWallet() },
{ id: 'shot', label: 'Screenshot this page', icon: 'fa-camera', onClick: act('captureScreenshot') },
{ id: 'docs', label: 'Help & docs', icon: 'fa-book', href: '/documentation' },
{ id: 'guest', label: 'Browse as guest', icon: 'fa-user-secret', onClick: act('browseAsGuest'), hidden: !user },
@@@ -77,11 +76,7 @@
links: links(user), menu: menu(user), appBar: appBar(), adminLink: false, // Admin Panel is in the account menu
// The magnifier (Shared 4.21): Live's streams, recordings and clips first, Live's own search page, then OpenVibe.
search: { owner: 'live', label: 'OpenVibe.Live', page: '/search' },
-- chips: [
-- // On a phone the two fold into one chip (Shared 4.16.0); its panel lists them by label.
-- { id: 'coins', icon: 'fa-coins', tone: 'gold', value: '0', valueId: 'nav-coins-amount', label: 'OpenCoins', title: 'OpenCoins: click to open your inventory', onClick: () => call('openCosmeticsModal') },
-- { id: 'vibes', icon: 'fa-wallet', tone: 'green', value: '0', valueId: 'nav-balance-amount', label: 'Vibes', title: 'Vibes balance', onClick: () => call('showModal', 'buy-funds') },
-- ],
++ wallet: true,
// Same-origin links are routes of this single-page app.
onNavigate: (href, e) => (typeof handleLinkClick === 'function' ? handleLinkClick(e, href) : true),
// No speculation rules: the router answers same-site links, so a prefetched page was never used, and the
@@@ -95,11 +90,10 @@
};
}
-- let last = { coins: '0', vibes: '0', live: false, page: null };
++ let last = { live: false, page: null };
function sync(user) {
const N = nav(); if (!N) return;
booted = true; N.init(config(user)); // init() merges the new config and re-renders
-- N.setChip('coins', last.coins); N.setChip('vibes', last.vibes);
if (last.page) N.setActive(last.page);
if (last.live) N.updateLink('broadcast', { dot: true });
}
@@@ -107,7 +101,6 @@
window.LiveNav = {
sync,
setActive(page) { last.page = page; const N = nav(); if (N && booted) N.setActive(page); },
-- setChip(id, text) { last[id] = String(text); const N = nav(); if (N && booted) N.setChip(id, String(text)); },
setLive(isLive) { last.live = !!isLive; const N = nav(); if (N && booted) N.updateLink('broadcast', { dot: !!isLive, label: isLive ? 'LIVE' : 'Go Live' }); },
};
ddiff --git a/public/js/vibes.js b/public/js/vibes.js
iindex bd027ffd..14d481c7 100644
---- a/public/js/vibes.js
++++ b/public/js/vibes.js
@@@ -3,113 +3,6 @@
Bit-style: integer bucks. 100 Vibes = $1.00 streamer cashout.
═══════════════════════════════════════════════════════════════ */
--// Client mirror of the server volume-discount tiers (server is the source of truth;
--// this is just for the live custom-amount price preview). Keep in sync with vibes.js.
--const _BUCKS_TIERS = [[25000, 0.0110], [10000, 0.0115], [5000, 0.0120], [2500, 0.0124], [1000, 0.0130], [500, 0.0140], [0, 0.0150]];
--function _priceForBucks(b) {
-- b = Math.max(0, Math.round(Number(b) || 0));
-- const t = _BUCKS_TIERS.find(x => b >= x[0]) || _BUCKS_TIERS[_BUCKS_TIERS.length - 1];
-- return Math.round(b * t[1] * 100) / 100;
--}
--
--/**
-- * Generate Buy Vibes modal HTML.
-- */
--function openvibeBucksBuyModal() {
-- return `
-- <h3><i class="fa-solid fa-coins"></i> Buy Vibes</h3>
-- <p class="muted" style="margin-bottom:16px">Tip streamers with Vibes — bigger packs cost less per buck. 100 Bucks = $1 to the streamer.</p>
--
-- <div class="form-group">
-- <label>Choose a pack</label>
-- <div id="buy-packages" style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-bottom:12px">
-- <p class="muted" style="grid-column:1/-1;text-align:center;font-size:0.85rem">Loading…</p>
-- </div>
-- </div>
--
-- <div class="form-group">
-- <label>Custom amount (Vibes)</label>
-- <input type="number" id="modal-buy-amount" class="form-input" placeholder="e.g. 500" min="100" step="100" oninput="updateBuyPrice()">
-- </div>
-- <div class="form-group" style="text-align:center;padding:8px;background:var(--bg-primary);border-radius:var(--radius)">
-- <span id="modal-buy-price" style="font-size:1.2rem;color:var(--accent);font-weight:700">$0.00</span>
-- <span class="muted" id="modal-buy-bucks" style="display:block;font-size:0.8rem"></span>
-- </div>
--
-- <div id="buy-providers" style="margin-top:8px">
-- <p class="muted" style="font-size:0.85rem;text-align:center"><i class="fa-solid fa-spinner fa-spin"></i> Loading payment options…</p>
-- </div>`;
--}
--
--/** Populate the buy modal with packages + the enabled payment providers. */
--async function _initBuyBucks() {
-- const box = document.getElementById('buy-providers');
-- if (!box) return;
-- let cfg;
-- try { cfg = await api('/payments/config'); } catch { cfg = null; }
-- // PowerChat purchases have their own enablement — the buy modal stays usable on
-- // every channel even while the card/PayPal rails (payments master switch) are off.
-- if (!cfg || (!cfg.enabled && !(cfg.providers && cfg.providers.powerchat))) {
-- box.innerHTML = '<p class="muted" style="font-size:0.85rem;text-align:center">Purchases aren’t available right now.</p>';
-- const pkgBox = document.getElementById('buy-packages'); if (pkgBox) pkgBox.innerHTML = '';
-- return;
-- }
-- // Package buttons.
-- const packages = (cfg.packages && cfg.packages.length) ? cfg.packages : [100, 500, 1000, 2500, 5000, 10000].map(bucks => ({ bucks, usd: _priceForBucks(bucks) }));
-- const pkgBox = document.getElementById('buy-packages');
-- if (pkgBox) pkgBox.innerHTML = packages.map(p =>
-- `<button class="btn btn-outline" onclick="setBuyAmount(${p.bucks})">${p.bucks.toLocaleString()} Vibes<br><small>$${Number(p.usd).toFixed(2)}</small></button>`
-- ).join('');
--
-- const min = cfg.minPurchaseBucks || 100;
-- const amtInput = document.getElementById('modal-buy-amount');
-- if (amtInput) { amtInput.min = min; if (!amtInput.value) amtInput.value = packages[0]?.bucks || min; updateBuyPrice(); }
--
-- const P = cfg.providers || {};
-- const btn = (prov, label, icon, color) => P[prov]
-- ? `<button class="btn btn-lg" style="width:100%;margin-top:8px;justify-content:center;background:${color};color:#fff;border:none" onclick="doPurchase('${prov}')"><i class="${icon}"></i> Pay with ${label}</button>`
-- : '';
-- const buttons = [
-- btn('stripe', 'Card (Stripe)', 'fa-solid fa-credit-card', '#635bff'),
-- btn('paypal', 'PayPal', 'fa-brands fa-paypal', '#003087'),
-- btn('ccbill', 'Card (CCBill)', 'fa-solid fa-credit-card', '#2a6'),
-- btn('crypto', 'Crypto', 'fa-brands fa-bitcoin', '#f7931a'),
-- btn('powerchat', 'PowerChat tip', 'fa-solid fa-bolt', '#8b5cf6'),
-- ].filter(Boolean).join('');
-- box.innerHTML = buttons || '<p class="muted" style="font-size:0.85rem;text-align:center">No payment methods are enabled.</p>';
--}
--
--function setBuyAmount(bucks) {
-- const el = document.getElementById('modal-buy-amount');
-- if (el) { el.value = bucks; updateBuyPrice(); }
--}
--function updateBuyPrice() {
-- const bucks = Math.round(parseFloat(document.getElementById('modal-buy-amount')?.value) || 0);
-- const price = document.getElementById('modal-buy-price');
-- const sub = document.getElementById('modal-buy-bucks');
-- if (price) price.textContent = `$${_priceForBucks(bucks).toFixed(2)}`;
-- if (sub) sub.textContent = bucks > 0 ? `${bucks.toLocaleString()} Vibes` : '';
--}
--
--async function doPurchase(provider) {
-- if (!currentUser) return showModal('login');
-- const bucks = Math.round(parseFloat(document.getElementById('modal-buy-amount').value));
-- if (!bucks || bucks < 100) return toast('Enter at least 100 Vibes', 'error');
-- try {
-- const data = await api('/payments/bucks/checkout', { method: 'POST', body: { provider, bucks } });
-- if (data.powerchat && data.url) {
-- // PowerChat: the tip page opens in a new tab; Vibes are credited by webhook
-- // once the tip confirms, so the site stays open underneath.
-- window.open(data.url, '_blank', 'noopener');
-- toast(data.note || 'Complete your tip on PowerChat — Vibes are credited automatically once it confirms.', 'success');
-- closeModal();
-- return;
-- }
-- if (data.url) { window.location.href = data.url; return; }
-- toast('Could not start checkout', 'error');
-- } catch (e) { toast(e.message || 'Purchase failed', 'error'); }
--}
--
/* ── Channel subscriptions ─────────────────────────────────── */
function openvibeSubscribeModal(username) {
return `
@@@ -173,7 +66,7 @@ async function doSubscribe(username, provider) {
return;
}
if (data.url) { window.location.href = data.url; return; }
-- if (data.ok) { toast('Subscribed! 🎉', 'success'); if (typeof loadBalance === 'function') loadBalance(); closeModal(); }
++ if (data.ok) { toast('Subscribed! 🎉', 'success'); window.OpenVibeNavbar.refreshWallet(); closeModal(); }
} catch (e) { toast(e.message || 'Subscription failed', 'error'); }
}
@@@ -230,7 +123,7 @@ function openvibeBucksDonateModal() {
<button class="btn btn-primary btn-lg" id="modal-donate-send" onclick="doDonate()" style="width:100%;margin-top:4px">
<i class="fa-solid fa-coins"></i> Donate Vibes
</button>
-- <button class="btn btn-outline" onclick="showModal('buy-funds')" style="width:100%;margin-top:8px">
++ <button class="btn btn-outline" onclick="window.OpenVibeNavbar.openWallet()" style="width:100%;margin-top:8px">
<i class="fa-solid fa-cart-plus"></i> Buy Vibes
</button>
<!-- Only shown when THIS streamer has their own PowerChat (money goes to them
@@@ -263,10 +156,10 @@ async function _initDonateModal() {
if (typeof _appendSupport === 'function') _appendSupport(document.getElementById('donate-elsewhere'), _channelUsername()); // channel-support.js
const line = document.getElementById('donate-balance-line');
if (bal === null) return; // not logged in / balance unavailable — leave the form as-is
-- if (line) line.innerHTML = `Your balance: <strong>${bal.toLocaleString()}</strong> Vibes${bal < 1 ? ' — <a href="#" onclick="showModal(\'buy-funds\');return false">buy some</a> to donate Vibes' : ''}`;
++ if (line) line.innerHTML = `Your balance: <strong>${bal.toLocaleString()}</strong> Vibes${bal < 1 ? ' — <a href="#" onclick="window.OpenVibeNavbar.openWallet();return false">buy some</a> to donate Vibes' : ''}`;
if (bal < 1 && !directAvailable) {
toast('You don’t have any Vibes yet — grab some first, then come back to donate! 🛒', 'info');
-- showModal('buy-funds');
++ window.OpenVibeNavbar.openWallet();
} else if (bal < 1 && directAvailable) {
toast('No Vibes yet — tip this channel directly via PowerChat below, or buy Vibes first. ⚡', 'info');
}
@@@ -298,7 +191,7 @@ try {
if (!e.data || e.data.status !== 'completed') return;
toast('PowerChat payment received — updating your balance… ⚡', 'success');
// The webhook usually lands within a couple of seconds of the redirect.
-- setTimeout(() => { if (typeof loadBalance === 'function') loadBalance(); }, 2500);
++ setTimeout(() => { window.OpenVibeNavbar.refreshWallet(); }, 2500);
};
} catch { /* BroadcastChannel unsupported — balance updates on next reload */ }
@@@ -363,7 +256,7 @@ async function doDonate() {
await api('/funds/donate', { method: 'POST', headers: { 'Idempotency-Key': _donateAttempt.key }, body });
_donateAttempt = null;
toast(`Donated ${Math.round(amount).toLocaleString()} Vibes!`, 'success');
-- loadBalance();
++ window.OpenVibeNavbar.refreshWallet();
closeModal();
} catch (e) {
// A refusal (no Vibes, bad amount) ends this attempt; no answer, or a 5xx, may have sent it: keep the key.
@@@ -421,7 +314,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (!purchase && !sub) return;
if (purchase === 'success' || sub === 'success') {
toast(sub === 'success' ? 'Subscription active! 🎉' : 'Purchase complete — your Vibes are on the way!', 'success');
-- if (typeof loadBalance === 'function') setTimeout(loadBalance, 1500);
++ setTimeout(() => window.OpenVibeNavbar.refreshWallet(), 1500);
} else if (purchase === 'cancel' || sub === 'cancel') {
toast('Checkout canceled.', 'info');
} else if (purchase === 'error') {
@@@ -445,18 +338,19 @@ async function doCashout() {
body: { amount: Math.round(amount), paypalEmail }
});
toast('Cashout requested! Awaiting admin approval.', 'success');
-- loadBalance();
++ window.OpenVibeNavbar.refreshWallet();
closeModal();
} catch (e) { toast(e.message || 'Cashout failed', 'error'); }
}
--// Every other OpenVibe site's account menu links its Vibes chip here (openvibe-shared 4.3: openvibe.live/#vibes): open
--// the Vibes window once, then drop the hash so a reload or a shared link does not open it again.
++// Old account-menu and bookmark links to openvibe.live/#vibes open the shared Wallet now: the hash goes, so a reload
++// does not open it again. Pages that never load this file still reach the Wallet from the bar's chip.
(function openVibesFromLink() {
if (location.hash !== '#vibes') return;
const open = () => {
try { history.replaceState(history.state, '', location.pathname + location.search); } catch { /* keep the hash */ }
-- showModal('buy-funds');
++ if (window.OpenVibeNavbar && typeof window.OpenVibeNavbar.openWallet === 'function') window.OpenVibeNavbar.openWallet();
++ else location.assign('https://openvibe.network/wallet');
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', open, { once: true });
else setTimeout(open, 0);
ddiff --git a/server/monetization/payments-routes.js b/server/monetization/payments-routes.js
iindex 7aac51e6..7af7888f 100644
---- a/server/monetization/payments-routes.js
++++ b/server/monetization/payments-routes.js
@@@ -20,6 +20,13 @@ const BILLING_WEBHOOK = { stripe: 'stripe', paypal: 'paypal', ccbill: 'ccbill',
const router = express.Router();
// ── Public config (which providers are live + pricing) ───────
++router.get('/wallet-state', async (_req, res) => {
++ try {
++ const value = await db.getSetting('money_writes_frozen');
++ return res.set('Cache-Control', 'no-store').json({ money_writes_frozen: value === true || value === 'true' || value === 1 || value === '1' });
++ } catch { return res.status(503).json({ error: 'Money switch unavailable' }); }
++});
++
router.get('/config', async (req, res) => {
res.json(await pay.publicConfig());
});
ddiff --git a/test/money-freeze.test.js b/test/money-freeze.test.js
iindex cdff9809..b243ad48 100644
---- a/test/money-freeze.test.js
++++ b/test/money-freeze.test.js
@@@ -89,6 +89,7 @@ async function check(name, fn) {
assert.strictEqual((await call('POST', '/api/admin/money/freeze', 2, { on: true })).status, 403);
assert.strictEqual((await call('PUT', '/api/admin/settings/money_writes_frozen', 4, { value: 'true' })).status, 403, 'not through the generic settings either');
assert.strictEqual(await money.isFrozen(), false);
++ assert.strictEqual((await call('GET', '/api/payments/wallet-state')).json.money_writes_frozen, false);
const r = await call('POST', '/api/admin/money/freeze', 1, { on: true, reason: 'maintenance' });
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.json.frozen, true);
@@@ -96,6 +97,12 @@ async function check(name, fn) {
assert.deepStrictEqual([seen.json.authority, seen.json.frozen, seen.json.reason, seen.json.by], ['billing', true, 'maintenance', 'owner']);
assert.strictEqual((await call('GET', '/api/admin/money', 2)).status, 403, 'not for non-admins');
assert.strictEqual((await call('GET', '/api/payments/config')).json.moneyFrozen, true);
++ assert.strictEqual((await call('GET', '/api/payments/wallet-state')).json.money_writes_frozen, true);
++ const getSetting = db.getSetting;
++ try {
++ db.getSetting = async () => { throw new Error('offline'); };
++ assert.strictEqual((await call('GET', '/api/payments/wallet-state')).status, 503);
++ } finally { db.getSetting = getSetting; }
});
await check('frozen: every money write is refused before Billing is ever called', async () => {
ddiff --git a/test/money-on-demand.test.js b/test/money-on-demand.test.js
iindex bef65743..7a7a6d5a 100644
---- a/test/money-on-demand.test.js
++++ b/test/money-on-demand.test.js
@@@ -31,7 +31,7 @@ for (const fn of ['openSubscribeForCurrentStream', 'toggleRewardsPanel', 'saveDa
}
// showModal() builds money templates lazily and fetches money first; the sign-in modal never needs it.
--assert.ok(/donate: \(\) => openvibeBucksDonateModal\(\),/.test(app) && /'buy-funds': \(\) => openvibeBucksBuyModal\(\),/.test(app)
++assert.ok(/donate: \(\) => openvibeBucksDonateModal\(\),/.test(app)
&& /cashout: \(\) => openvibeBucksCashoutModal\(\),/.test(app) && /'add-reward': \(\) => addRewardModal\(\),/.test(app), 'money templates are built only when asked for');
assert.ok(/if \(MONEY_MODALS\.has\(id\) && typeof openvibeBucksDonateModal !== 'function' && window\.ov && ov\.load\) \{\n\s*ov\.load\('money'\)\.then\(\(\) => showModal\(id\)\)/.test(app), 'a money modal fetches money first');
assert.ok(/function openChannelRewards\(\) \{[\s\S]{0,200}ov\.load\('money'\)/.test(app), 'the rewards button fetches money');
ddiff --git a/test/navbar-menu-live.test.js b/test/navbar-menu-live.test.js
iindex 83cf2e09..be3df4d9 100644
---- a/test/navbar-menu-live.test.js
++++ b/test/navbar-menu-live.test.js
@@@ -22,7 +22,7 @@ assert.ok(!/defaults:\s*false/.test(menu), 'the network\'s own rows (You, Displa
});
const sections = menu.match(/\{ id: '[a-z]+', label: '[^']+', items: \[/g) || [];
assert.deepStrictEqual(sections, ["{ id: 'live', label: 'Live', items: ["], 'one section, Live\'s own');
--['Dashboard', 'Streamer setup', 'Cosmetics', 'Screenshot this page', 'Help & docs', 'Browse as guest'].forEach((row) => {
++['Dashboard', 'Streamer setup', 'Wallet & items', 'Screenshot this page', 'Help & docs', 'Browse as guest'].forEach((row) => {
assert.ok(menu.includes(`label: '${row}'`), `Live's "${row}" row`);
});
assert.ok(!/\bafter:/.test(menu), 'no rows after the network\'s');
ddiff --git a/test/shared-wallet.test.js b/test/shared-wallet.test.js
nnew file mode 100644
iindex 00000000..ad76b6d0
---- /dev/null
++++ b/test/shared-wallet.test.js
@@@ -0,0 +1,28 @@
++'use strict';
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++let config, opens = 0, refreshes = 0;
++const context = { window: { OpenVibeNavbar: {
++ init(c) { config = c; }, setActive() {}, updateLink() {},
++ openWallet() { opens++; }, refreshWallet() { refreshes++; },
++} }, document: { readyState: 'loading', addEventListener() {} }, currentUser: null };
++vm.createContext(context);
++vm.runInContext(fs.readFileSync(path.join(__dirname, '../public/js/ov-navbar-live.js'), 'utf8'), context);
++context.window.LiveNav.sync({ id: 1, username: 'alice' });
++assert.strictEqual(config.wallet, true);
++assert.strictEqual(config.chips, undefined, 'Live does not override the shared balances');
++const wallet = config.menu.sections.flatMap((s) => s.items).find((i) => i.id === 'wallet');
++wallet.onClick(); assert.strictEqual(opens, 1);
++const root = path.join(__dirname, '..');
++const html = fs.readFileSync(path.join(root, 'public/index.html'), 'utf8');
++assert(!html.includes('id="cosmetics-modal"'));
++assert(!fs.existsSync(path.join(root, 'public/js/cosmetics-ui.js')));
++const vibes = fs.readFileSync(path.join(root, 'public/js/vibes.js'), 'utf8');
++assert(!vibes.includes('openvibeBucksBuyModal') && !vibes.includes('/payments/bucks/checkout'));
++assert(vibes.includes('window.OpenVibeNavbar.openWallet()'));
++const security = fs.readFileSync(path.join(root, 'server/index.js'), 'utf8');
++assert(!security.includes('"https://billing.openvibe.network"') && !security.includes('"https://inventory.openvibe.network"'),
++ 'the shared wallet asks Network\'s relays, so connect-src needs neither host');
++console.log('Live delegates wallet and store to Shared, retaining cosmetic renderers and channel actions');
ddiff --git a/test/vibes-hash.test.js b/test/vibes-hash.test.js
iindex 08e42559..49482ae8 100644
---- a/test/vibes-hash.test.js
++++ b/test/vibes-hash.test.js
@@@ -1,6 +1,6 @@
/**
-- * openvibe-shared 4.3's account menu shows a person's Vibes on every OpenVibe site and links them to openvibe.live/#vibes.
-- * Live opens its Vibes window for that link, once: the hash goes, so a reload does not open it again.
++ * openvibe-shared's account menu shows a person's Vibes on every OpenVibe site and links them to openvibe.live/#vibes.
++ * Live opens the shared Wallet for that link, once: the hash goes, so a reload does not open it again.
*
* node test/vibes-hash.test.js
*/
@@@ -16,18 +16,18 @@ function run(hash) {
const opened = [];
const replaced = [];
const env = {
-- location: { hash, pathname: '/@alex', search: '?x=1' },
++ location: { hash, pathname: '/@alex', search: '?x=1', assign: (url) => replaced.push(url) },
history: { state: null, replaceState: (s, t, url) => replaced.push(url) },
document: { readyState: 'complete', addEventListener() {} },
setTimeout: (fn) => fn(),
-- showModal: (id) => opened.push(id),
++ window: { OpenVibeNavbar: { openWallet: () => opened.push('wallet') } },
};
// eslint-disable-next-line no-new-func
new Function(...Object.keys(env), block)(...Object.values(env));
return { opened, replaced };
}
--assert.deepStrictEqual(run('#vibes'), { opened: ['buy-funds'], replaced: ['/@alex?x=1'] }, 'the link opens the Vibes window and drops the hash');
++assert.deepStrictEqual(run('#vibes'), { opened: ['wallet'], replaced: ['/@alex?x=1'] }, 'the link opens the shared Wallet and drops the hash');
assert.deepStrictEqual(run(''), { opened: [], replaced: [] }, 'nothing otherwise');
assert.deepStrictEqual(run('#chat'), { opened: [], replaced: [] });
console.log('vibes hash: all checks passed');