openvibers/openvibe.live Commits
Delegate Live wallet and cosmetic management to Shared (#717)
Changes
nnew file mode 100644@@@ -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.@@@ -313,14 +313,6 @@"/js/recap.js"]},-- "cosmetics": {-- "js": [-- "/js/cosmetics-ui.js"-- ],-- "stubs": [-- "openCosmeticsModal"-- ]-- },"updates": {"fragment": "updates"},@@@ -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()">×</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>@@@ -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 theddeleted file mode 100644@@@ -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();-- }--});@@@ -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,@@@ -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' }); },};@@@ -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.jsconst 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);@@@ -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());});@@@ -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 () => {@@@ -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');@@@ -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');nnew file mode 100644@@@ -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');@@@ -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-funcnew 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');
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -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.
@@ -313,14 +313,6 @@
@@ -313,14 +313,6 @@
"/js/recap.js"
"/js/recap.js"
]
]
},
},
- "cosmetics": {
- "js": [
- "/js/cosmetics-ui.js"
- ],
- "stubs": [
- "openCosmeticsModal"
- ]
- },
"updates": {
"updates": {
"fragment": "updates"
"fragment": "updates"
},
},
@@ -393,38 +393,6 @@
@@ -393,38 +393,6 @@
row come from the shared module so every OpenVibe property stays in step. -->
row come from the shared module so every OpenVibe property stays in step. -->
<footer id="ov-footer" class="ovf"></footer>
<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()">×</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 -->
<!-- Scripts -->
<script src="/live-notify.js" defer></script>
<script src="/live-notify.js" defer></script>
<script src="/js/ov-skeleton.js" defer></script>
<script src="/js/ov-skeleton.js" defer></script>
@@ -530,7 +530,7 @@ async function loadOpenVibeAppMeta(force = false) {
@@ -530,7 +530,7 @@ async function loadOpenVibeAppMeta(force = false) {
}
}
/* ── Modal ────────────────────────────────────────────────────── */
/* ── Modal ────────────────────────────────────────────────────── */
-const MONEY_MODALS = new Set(['donate', 'buy-funds', 'cashout', 'add-reward']);
+const MONEY_MODALS = new Set(['donate', 'cashout', 'add-reward']);
function showModal(id) {
function showModal(id) {
// Vibes, tips and channel rewards live in features.json → money, which most pages never load: fetch it first.
// 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) {
if (MONEY_MODALS.has(id) && typeof openvibeBucksDonateModal !== 'function' && window.ov && ov.load) {
@@ -555,7 +555,6 @@ function showModal(id) {
@@ -555,7 +555,6 @@ function showModal(id) {
</a>
</a>
<p style="text-align:center;margin-top:12px;font-size:12px;color:var(--text-muted)">Registration is handled on openvibe.network</p>`,
<p style="text-align:center;margin-top:12px;font-size:12px;color:var(--text-muted)">Registration is handled on openvibe.network</p>`,
donate: () => openvibeBucksDonateModal(),
donate: () => openvibeBucksDonateModal(),
- 'buy-funds': () => openvibeBucksBuyModal(),
cashout: () => openvibeBucksCashoutModal(),
cashout: () => openvibeBucksCashoutModal(),
'stream-key': streamKeyModal(),
'stream-key': streamKeyModal(),
'add-goal': addGoalModal(),
'add-goal': addGoalModal(),
@@ -565,7 +564,6 @@ function showModal(id) {
@@ -565,7 +564,6 @@ function showModal(id) {
};
};
content.innerHTML = typeof templates[id] === 'function' ? templates[id]() : (templates[id] || `<p>Unknown modal: ${id}</p>`);
content.innerHTML = typeof templates[id] === 'function' ? templates[id]() : (templates[id] || `<p>Unknown modal: ${id}</p>`);
overlay.classList.add('show');
overlay.classList.add('show');
- if (id === 'buy-funds' && typeof _initBuyBucks === 'function') _initBuyBucks();
if (id === 'donate' && typeof _loadDonateGoals === 'function') _loadDonateGoals();
if (id === 'donate' && typeof _loadDonateGoals === 'function') _loadDonateGoals();
// Balance-first donate flow + the streamer's direct-PowerChat option.
// Balance-first donate flow + the streamer's direct-PowerChat option.
if (id === 'donate' && typeof _initDonateModal === 'function') _initDonateModal();
if (id === 'donate' && typeof _initDonateModal === 'function') _initDonateModal();
@@ -783,7 +781,7 @@ function onAuthChange() {
@@ -783,7 +781,7 @@ function onAuthChange() {
if (currentUser) _enableHandoff(); else if (window.OpenVibeSSO) { try { window.OpenVibeSSO.handoffLinks({ signedIn: false }); } catch { /* */ } }
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).
// 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 (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.
// Older notification links point at a Live DM route. Keep the reader here in the Dock.
if (currentUser && new URLSearchParams(location.search).has('dm')) {
if (currentUser && new URLSearchParams(location.search).has('dm')) {
const params = new URLSearchParams(location.search);
const params = new URLSearchParams(location.search);
@@ -802,29 +800,6 @@ function onAuthChange() {
@@ -802,29 +800,6 @@ function onAuthChange() {
} catch {}
} 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
// 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
// panel), for the streamer they're currently watching. Not the navbar (that's the
deleted file mode 100644
deleted file mode 100644
@@ -1,208 +0,0 @@
@@ -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();
- }
-});
@@ -219,9 +219,7 @@ function handleCoinEarned(msg) {
@@ -219,9 +219,7 @@ function handleCoinEarned(msg) {
if (msg.total === undefined) return;
if (msg.total === undefined) return;
// "gold" events change the GLOBAL OpenCoins wallet → update the navbar.
// "gold" events change the GLOBAL OpenCoins wallet → update the navbar.
if (msg.currency === 'gold') {
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;
return;
}
}
// Otherwise it's CHANNEL POINTS for a streamer → update the in-chat balance,
// Otherwise it's CHANNEL POINTS for a streamer → update the in-chat balance,
@@ -7,7 +7,6 @@
@@ -7,7 +7,6 @@
*
*
* LiveNav.sync(user) after sign-in, sign-out or a profile change
* LiveNav.sync(user) after sign-in, sign-out or a profile change
* LiveNav.setActive(page) the router calls this on every page 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"
* LiveNav.setLive(isLive) the red dot on "Go Live"
*/
*/
(function () {
(function () {
@@ -57,7 +56,7 @@
@@ -57,7 +56,7 @@
{ id: 'live', label: 'Live', items: [
{ id: 'live', label: 'Live', items: [
{ id: 'dash', label: 'Dashboard', icon: 'fa-gauge-high', href: '/dashboard' },
{ id: 'dash', label: 'Dashboard', icon: 'fa-gauge-high', href: '/dashboard' },
{ id: 'setup', label: 'Streamer setup', icon: 'fa-list-check', onClick: act('openSetupHub') },
{ 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: 'shot', label: 'Screenshot this page', icon: 'fa-camera', onClick: act('captureScreenshot') },
{ id: 'docs', label: 'Help & docs', icon: 'fa-book', href: '/documentation' },
{ 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 },
{ id: 'guest', label: 'Browse as guest', icon: 'fa-user-secret', onClick: act('browseAsGuest'), hidden: !user },
@@ -77,11 +76,7 @@
@@ -77,11 +76,7 @@
links: links(user), menu: menu(user), appBar: appBar(), adminLink: false, // Admin Panel is in the account menu
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.
// 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' },
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.
// Same-origin links are routes of this single-page app.
onNavigate: (href, e) => (typeof handleLinkClick === 'function' ? handleLinkClick(e, href) : true),
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
// No speculation rules: the router answers same-site links, so a prefetched page was never used, and the
@@ -95,11 +90,10 @@
@@ -95,11 +90,10 @@
};
};
}
}
- let last = { coins: '0', vibes: '0', live: false, page: null };
+ let last = { live: false, page: null };
function sync(user) {
function sync(user) {
const N = nav(); if (!N) return;
const N = nav(); if (!N) return;
booted = true; N.init(config(user)); // init() merges the new config and re-renders
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.page) N.setActive(last.page);
if (last.live) N.updateLink('broadcast', { dot: true });
if (last.live) N.updateLink('broadcast', { dot: true });
}
}
@@ -107,7 +101,6 @@
@@ -107,7 +101,6 @@
window.LiveNav = {
window.LiveNav = {
sync,
sync,
setActive(page) { last.page = page; const N = nav(); if (N && booted) N.setActive(page); },
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' }); },
setLive(isLive) { last.live = !!isLive; const N = nav(); if (N && booted) N.updateLink('broadcast', { dot: !!isLive, label: isLive ? 'LIVE' : 'Go Live' }); },
};
};
@@ -3,113 +3,6 @@
@@ -3,113 +3,6 @@
Bit-style: integer bucks. 100 Vibes = $1.00 streamer cashout.
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 ─────────────────────────────────── */
/* ── Channel subscriptions ─────────────────────────────────── */
function openvibeSubscribeModal(username) {
function openvibeSubscribeModal(username) {
return `
return `
@@ -173,7 +66,7 @@ async function doSubscribe(username, provider) {
@@ -173,7 +66,7 @@ async function doSubscribe(username, provider) {
return;
return;
}
}
if (data.url) { window.location.href = data.url; 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'); }
} catch (e) { toast(e.message || 'Subscription failed', 'error'); }
}
}
@@ -230,7 +123,7 @@ function openvibeBucksDonateModal() {
@@ -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">
<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
<i class="fa-solid fa-coins"></i> Donate Vibes
</button>
</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
<i class="fa-solid fa-cart-plus"></i> Buy Vibes
</button>
</button>
<!-- Only shown when THIS streamer has their own PowerChat (money goes to them
<!-- Only shown when THIS streamer has their own PowerChat (money goes to them
@@ -263,10 +156,10 @@ async function _initDonateModal() {
@@ -263,10 +156,10 @@ async function _initDonateModal() {
if (typeof _appendSupport === 'function') _appendSupport(document.getElementById('donate-elsewhere'), _channelUsername()); // channel-support.js
if (typeof _appendSupport === 'function') _appendSupport(document.getElementById('donate-elsewhere'), _channelUsername()); // channel-support.js
const line = document.getElementById('donate-balance-line');
const line = document.getElementById('donate-balance-line');
if (bal === null) return; // not logged in / balance unavailable — leave the form as-is
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) {
if (bal < 1 && !directAvailable) {
toast('You don’t have any Vibes yet — grab some first, then come back to donate! 🛒', 'info');
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) {
} else if (bal < 1 && directAvailable) {
toast('No Vibes yet — tip this channel directly via PowerChat below, or buy Vibes first. ⚡', 'info');
toast('No Vibes yet — tip this channel directly via PowerChat below, or buy Vibes first. ⚡', 'info');
}
}
@@ -298,7 +191,7 @@ try {
@@ -298,7 +191,7 @@ try {
if (!e.data || e.data.status !== 'completed') return;
if (!e.data || e.data.status !== 'completed') return;
toast('PowerChat payment received — updating your balance… ⚡', 'success');
toast('PowerChat payment received — updating your balance… ⚡', 'success');
// The webhook usually lands within a couple of seconds of the redirect.
// 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 */ }
} catch { /* BroadcastChannel unsupported — balance updates on next reload */ }
@@ -363,7 +256,7 @@ async function doDonate() {
@@ -363,7 +256,7 @@ async function doDonate() {
await api('/funds/donate', { method: 'POST', headers: { 'Idempotency-Key': _donateAttempt.key }, body });
await api('/funds/donate', { method: 'POST', headers: { 'Idempotency-Key': _donateAttempt.key }, body });
_donateAttempt = null;
_donateAttempt = null;
toast(`Donated ${Math.round(amount).toLocaleString()} Vibes!`, 'success');
toast(`Donated ${Math.round(amount).toLocaleString()} Vibes!`, 'success');
- loadBalance();
+ window.OpenVibeNavbar.refreshWallet();
closeModal();
closeModal();
} catch (e) {
} catch (e) {
// A refusal (no Vibes, bad amount) ends this attempt; no answer, or a 5xx, may have sent it: keep the key.
// 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', () => {
@@ -421,7 +314,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (!purchase && !sub) return;
if (!purchase && !sub) return;
if (purchase === 'success' || sub === 'success') {
if (purchase === 'success' || sub === 'success') {
toast(sub === 'success' ? 'Subscription active! 🎉' : 'Purchase complete — your Vibes are on the way!', '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') {
} else if (purchase === 'cancel' || sub === 'cancel') {
toast('Checkout canceled.', 'info');
toast('Checkout canceled.', 'info');
} else if (purchase === 'error') {
} else if (purchase === 'error') {
@@ -445,18 +338,19 @@ async function doCashout() {
@@ -445,18 +338,19 @@ async function doCashout() {
body: { amount: Math.round(amount), paypalEmail }
body: { amount: Math.round(amount), paypalEmail }
});
});
toast('Cashout requested! Awaiting admin approval.', 'success');
toast('Cashout requested! Awaiting admin approval.', 'success');
- loadBalance();
+ window.OpenVibeNavbar.refreshWallet();
closeModal();
closeModal();
} catch (e) { toast(e.message || 'Cashout failed', 'error'); }
} 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() {
(function openVibesFromLink() {
if (location.hash !== '#vibes') return;
if (location.hash !== '#vibes') return;
const open = () => {
const open = () => {
try { history.replaceState(history.state, '', location.pathname + location.search); } catch { /* keep the hash */ }
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 });
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', open, { once: true });
else setTimeout(open, 0);
else setTimeout(open, 0);
@@ -20,6 +20,13 @@ const BILLING_WEBHOOK = { stripe: 'stripe', paypal: 'paypal', ccbill: 'ccbill',
@@ -20,6 +20,13 @@ const BILLING_WEBHOOK = { stripe: 'stripe', paypal: 'paypal', ccbill: 'ccbill',
const router = express.Router();
const router = express.Router();
// ── Public config (which providers are live + pricing) ───────
// ── 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) => {
router.get('/config', async (req, res) => {
res.json(await pay.publicConfig());
res.json(await pay.publicConfig());
});
});
@@ -89,6 +89,7 @@ async function check(name, fn) {
@@ -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('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 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 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' });
const r = await call('POST', '/api/admin/money/freeze', 1, { on: true, reason: 'maintenance' });
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.json.frozen, true);
assert.strictEqual(r.json.frozen, true);
@@ -96,6 +97,12 @@ async function check(name, fn) {
@@ -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.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/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/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 () => {
await check('frozen: every money write is refused before Billing is ever called', async () => {
@@ -31,7 +31,7 @@ for (const fn of ['openSubscribeForCurrentStream', 'toggleRewardsPanel', 'saveDa
@@ -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.
// 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');
&& /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(/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');
assert.ok(/function openChannelRewards\(\) \{[\s\S]{0,200}ov\.load\('money'\)/.test(app), 'the rewards button fetches money');
@@ -22,7 +22,7 @@ assert.ok(!/defaults:\s*false/.test(menu), 'the network\'s own rows (You, Displa
@@ -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) || [];
const sections = menu.match(/\{ id: '[a-z]+', label: '[^']+', items: \[/g) || [];
assert.deepStrictEqual(sections, ["{ id: 'live', label: 'Live', items: ["], 'one section, Live\'s own');
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(menu.includes(`label: '${row}'`), `Live's "${row}" row`);
});
});
assert.ok(!/\bafter:/.test(menu), 'no rows after the network\'s');
assert.ok(!/\bafter:/.test(menu), 'no rows after the network\'s');
new file mode 100644
new file mode 100644
@@ -0,0 +1,28 @@
@@ -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');
@@ -1,6 +1,6 @@
@@ -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
* node test/vibes-hash.test.js
*/
*/
@@ -16,18 +16,18 @@ function run(hash) {
@@ -16,18 +16,18 @@ function run(hash) {
const opened = [];
const opened = [];
const replaced = [];
const replaced = [];
const env = {
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) },
history: { state: null, replaceState: (s, t, url) => replaced.push(url) },
document: { readyState: 'complete', addEventListener() {} },
document: { readyState: 'complete', addEventListener() {} },
setTimeout: (fn) => fn(),
setTimeout: (fn) => fn(),
- showModal: (id) => opened.push(id),
+ window: { OpenVibeNavbar: { openWallet: () => opened.push('wallet') } },
};
};
// eslint-disable-next-line no-new-func
// eslint-disable-next-line no-new-func
new Function(...Object.keys(env), block)(...Object.values(env));
new Function(...Object.keys(env), block)(...Object.values(env));
return { opened, replaced };
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(''), { opened: [], replaced: [] }, 'nothing otherwise');
assert.deepStrictEqual(run('#chat'), { opened: [], replaced: [] });
assert.deepStrictEqual(run('#chat'), { opened: [], replaced: [] });
console.log('vibes hash: all checks passed');
console.log('vibes hash: all checks passed');