openvibers/openvibe.live Commits

Channel: VIP tiers and Tips' other ways to support in the Subscribe and Donate windows (#181)

OP OpenVibers committed 27 days ago · f3e4de1291 · 7 files, +223 −2

* Channel: the Subscribe window lists the creator's higher tiers on OpenVibe.VIP, the Donate window their other ways to support (OpenVibe.Tips and the links Tips built); public, cached a minute

* Channel support helpers load with the channel feature (channel-support.js), not on every page: the home page's script budget stays within 262 KB

Changes

ddiff --git a/public/css/features/channel.css b/public/css/features/channel.css
iindex bae471f5..d3286a42 100644
---- a/public/css/features/channel.css
++++ b/public/css/features/channel.css
@@@ -1517,3 +1517,10 @@
body:has(#page-channel.active #ch-live-area .stream-layout > .chat-sidebar) .mobile-chat-toggle,
body:has(#page-channel.active #ch-live-area .stream-layout > .chat-sidebar) #messenger-toggle { display: none !important; }
}
++
++/* The donate and subscribe modals: the channel's VIP tiers and other ways to support it (vibes.js _elsewhereBlock). */
++.elsewhere { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--border); }
++.elsewhere-title { font-weight: 600; font-size: 0.9rem; margin-bottom: 8px; }
++.elsewhere-list { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }
++.elsewhere-list .btn { font-size: 0.85rem; }
++.elsewhere-note { font-size: 0.75rem; margin-top: 6px; }
ddiff --git a/public/features.json b/public/features.json
iindex a7d1582a..32daf970 100644
---- a/public/features.json
++++ b/public/features.json
@@@ -108,6 +108,7 @@
"js": [
"/js/channel-socials.js",
"/js/channel-chat-resize.js",
++ "/js/channel-support.js",
"/js/app-channel.js"
],
"stubs": [
ddiff --git a/public/js/channel-support.js b/public/js/channel-support.js
nnew file mode 100644
iindex 00000000..19776820
---- /dev/null
++++ b/public/js/channel-support.js
@@@ -0,0 +1,61 @@
++/**
++ * The channel's support on OpenVibe.VIP and OpenVibe.Tips, in the Subscribe and Donate windows (vibes.js calls these
++ * when they exist): a creator's higher membership tiers, and their other ways to support. Loaded with the channel
++ * feature (features.json), not on every page, so the home page's script budget does not carry it.
++ */
++// Memberships (VIP tiers) and other ways to support (Tips), from /api/payments/channel/:username/elsewhere. Built as
++// DOM nodes: the names and links come from other services.
++const _elsewhereCache = new Map();
++function _elsewhere(username) {
++ const key = String(username || '').toLowerCase();
++ if (!key) return Promise.resolve(null);
++ if (!_elsewhereCache.has(key)) _elsewhereCache.set(key, api(`/payments/channel/${encodeURIComponent(key)}/elsewhere`).catch(() => null));
++ return _elsewhereCache.get(key);
++}
++function _channelUsername() {
++ if (typeof currentStreamData !== 'undefined' && currentStreamData && currentStreamData.username) return currentStreamData.username;
++ const m = /^\/@([^/?#]+)/.exec(location.pathname);
++ return m ? decodeURIComponent(m[1]) : null;
++}
++function _elsewhereBlock(title, items, note) {
++ const wrap = document.createElement('div');
++ wrap.className = 'elsewhere';
++ const h = document.createElement('div');
++ h.className = 'elsewhere-title';
++ h.textContent = title;
++ const ul = document.createElement('ul');
++ ul.className = 'elsewhere-list';
++ for (const it of items) {
++ const li = document.createElement('li');
++ const a = document.createElement('a');
++ a.href = it.url;
++ a.target = '_blank';
++ a.rel = it.rel || 'noopener';
++ a.className = 'btn btn-outline';
++ a.textContent = it.label;
++ li.appendChild(a);
++ ul.appendChild(li);
++ }
++ wrap.append(h, ul);
++ if (note) { const p = document.createElement('div'); p.className = 'muted elsewhere-note'; p.textContent = note; wrap.appendChild(p); }
++ return wrap;
++}
++// Live's Subscribe sells tier 1; a creator's higher tiers are joined on openvibe.vip with the same account.
++async function _appendTiers(box, username) {
++ const d = await _elsewhere(username);
++ const plans = d && d.memberships ? d.memberships.plans.filter((p) => p.tier > 1) : [];
++ if (!plans.length || !box || !box.isConnected) return;
++ box.appendChild(_elsewhereBlock('More tiers on OpenVibe.VIP', plans.map((p) => ({
++ url: p.join_url, label: p.price_cents ? `${p.name} · $${(p.price_cents / 100).toFixed(2)}/mo` : p.name,
++ })), 'Each tier has its own perks. You join on openvibe.vip with this account; one tier at a time.'));
++}
++async function _appendSupport(box, username) {
++ const d = await _elsewhere(username);
++ if (!d || !box || !box.isConnected) return;
++ const items = [];
++ if (d.tips_url) items.push({ url: d.tips_url, label: 'OpenVibe.Tips: paid messages, TTS and sounds' });
++ for (const l of d.support_links || []) items.push({ url: l.url, label: l.label, rel: 'nofollow noopener ugc' });
++ if (!items.length) return;
++ box.appendChild(_elsewhereBlock('Other ways to support', items, d.support_links ? 'Ko-fi, PayPal and the like are paid outside OpenVibe, not in Vibes.' : ''));
++}
++
ddiff --git a/public/js/vibes.js b/public/js/vibes.js
iindex 795585d9..3a5b7725 100644
---- a/public/js/vibes.js
++++ b/public/js/vibes.js
@@@ -129,7 +129,7 @@ async function _initSubscribe() {
catch { box.innerHTML = '<p class="muted">Subscriptions unavailable.</p>'; return; }
const priceLine = document.getElementById('sub-price-line');
if (priceLine) priceLine.textContent = `$${(info.priceUsd || cfg.subPriceUsd || 4.99).toFixed(2)}/month · ${info.subscriberCount || 0} subscriber${info.subscriberCount === 1 ? '' : 's'}`;
-- if (info.subscribed) { box.innerHTML = '<p style="text-align:center;color:#53fc18"><i class="fa-solid fa-circle-check"></i> You’re subscribed!</p>'; return; }
++ if (info.subscribed) { box.innerHTML = '<p style="text-align:center;color:#53fc18"><i class="fa-solid fa-circle-check"></i> You’re subscribed!</p>'; if (typeof _appendTiers === 'function') _appendTiers(box, username); return; }
const pc = info.powerchat || {};
const price = Number(info.priceUsd || cfg.subPriceUsd || 4.99);
const anyPowerchat = !!(pc.direct || pc.site);
@@@ -158,6 +158,7 @@ async function _initSubscribe() {
Auto-renew monthly from my Vibes balance (Vibes/PowerChat)
</label>`;
box.innerHTML = html;
++ if (typeof _appendTiers === 'function') _appendTiers(box, username); // channel-support.js (channel pages)
}
async function doSubscribe(username, provider) {
@@@ -239,7 +240,8 @@ function openvibeBucksDonateModal() {
<button class="btn btn-lg" onclick="doPowerchatTip()" style="width:100%;background:#8b5cf6;color:#fff;border:none">
<i class="fa-solid fa-bolt"></i> Tip real money via PowerChat
</button>
-- </div>`;
++ </div>
++ <div id="donate-elsewhere"></div>`;
}
// Donate modal boot: balance-first, but NEVER at the cost of the direct route.
@@@ -258,6 +260,7 @@ async function _initDonateModal() {
]);
const box = document.getElementById('donate-powerchat-direct');
if (box && directAvailable) box.style.display = '';
++ 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' : ''}`;
ddiff --git a/server/monetization/elsewhere.js b/server/monetization/elsewhere.js
nnew file mode 100644
iindex 00000000..04a12f5d
---- /dev/null
++++ b/server/monetization/elsewhere.js
@@@ -0,0 +1,72 @@
++'use strict';
++/**
++ * A channel's support on OpenVibe.VIP and OpenVibe.Tips, shown on Live's channel page (owner 2026-10-08: "openvibe.vip
++ * and openvibe.tips … should be directly integrated to openvibe.live"):
++ *
++ * memberships the creator's VIP plans (one per tier, each with Billing's price) from VIP's public card
++ * (GET /embed/:username/card.json): Live's own Subscribe sells tier 1, the rest are joined on VIP
++ * support_links other ways to support the creator, paid outside OpenVibe, from Tips' public profile
++ * (GET /api/v1/profiles/:handle): Tips builds every link from a known service and a handle
++ *
++ * Both are public reads on loopback, cached per channel for a minute (a miss or an error is cached too, briefly), and
++ * never block the page: whatever does not answer within the timeout is left out.
++ */
++const TTL_MS = 60_000;
++const MISS_TTL_MS = 15_000;
++const TIMEOUT_MS = 1500;
++const cache = new Map(); // username → { at, ttl, value }
++
++const vipUrl = () => String(process.env.OV_VIP_INTERNAL_URL || 'http://127.0.0.1:4620').replace(/\/+$/, '');
++const tipsUrl = () => String(process.env.TIPS_URL || 'http://127.0.0.1:4610').replace(/\/+$/, '');
++const HANDLE_RE = /^[A-Za-z0-9_.-]{1,40}$/;
++const https = (u) => (typeof u === 'string' && /^https:\/\/[^\s"'<>]+$/.test(u) ? u : null);
++
++async function getJson(url, fetchImpl) {
++ const res = await fetchImpl(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(TIMEOUT_MS) });
++ if (!res.ok) return null;
++ return await res.json().catch(() => null);
++}
++
++/** VIP's card → the plans a viewer can join (name, tier, price, link), or null. */
++function memberships(card) {
++ if (!card || !Array.isArray(card.plans)) return null;
++ const plans = card.plans.filter((p) => p && p.purchasable && https(p.join_url)).map((p) => ({
++ name: String(p.name || '').slice(0, 80), tier: Number(p.tier) || 1,
++ price_cents: Number.isSafeInteger(p.price_cents) && p.price_cents > 0 ? p.price_cents : null, join_url: p.join_url,
++ }));
++ if (!plans.length) return null;
++ return { url: https(card.join_url) || https(card.creator && card.creator.url), plans: plans.slice(0, 10) };
++}
++
++/** Tips' public profile → the creator's other ways to support (label and link), or null. */
++function supportLinks(profile) {
++ const list = profile && Array.isArray(profile.support_links) ? profile.support_links : [];
++ const out = list.map((l) => ({ provider: String(l.provider || ''), label: String(l.label || '').slice(0, 40), url: https(l.url) })).filter((l) => l.url && l.label);
++ return out.length ? out.slice(0, 8) : null;
++}
++
++async function forChannel(username, { fetchImpl = globalThis.fetch, now = Date.now() } = {}) {
++ const name = String(username || '');
++ if (!HANDLE_RE.test(name)) return { memberships: null, support_links: null, tips_url: null };
++ const key = name.toLowerCase();
++ const hit = cache.get(key);
++ if (hit && now - hit.at < hit.ttl) return hit.value;
++ const [card, tips] = await Promise.all([
++ getJson(`${vipUrl()}/embed/${encodeURIComponent(key)}/card.json`, fetchImpl).catch(() => null),
++ getJson(`${tipsUrl()}/api/v1/profiles/${encodeURIComponent(key)}`, fetchImpl).catch(() => null),
++ ]);
++ const profile = tips && tips.profile ? tips.profile : null;
++ const value = {
++ memberships: memberships(card && card.card ? card.card : card),
++ support_links: supportLinks(profile),
++ tips_url: profile && profile.page_enabled && profile.accepting ? https(profile.url) : null,
++ };
++ const empty = !value.memberships && !value.support_links && !value.tips_url;
++ cache.set(key, { at: now, ttl: empty ? MISS_TTL_MS : TTL_MS, value });
++ if (cache.size > 2000) cache.delete(cache.keys().next().value);
++ return value;
++}
++
++function _reset() { cache.clear(); }
++
++module.exports = { forChannel, memberships, supportLinks, _reset };
ddiff --git a/server/monetization/payments-routes.js b/server/monetization/payments-routes.js
iindex cc4994ac..9632c6d5 100644
---- a/server/monetization/payments-routes.js
++++ b/server/monetization/payments-routes.js
@@@ -239,6 +239,13 @@ router.get('/subscriptions/mine', requireAuth, async (req, res) => {
res.json({ subscriptions: await db.getSubscriptionsBySubscriber(req.user.id) });
});
++// The channel's memberships on OpenVibe.VIP (tiers) and other ways to support it (OpenVibe.Tips): public, the same
++// for everyone, cached a minute (elsewhere.js).
++router.get('/channel/:username/elsewhere', async (req, res) => {
++ const out = await require('./elsewhere').forChannel(req.params.username);
++ res.set('Cache-Control', 'public, max-age=60').json(out);
++});
++
// Am I subscribed to this channel? + subscriber count
router.get('/channel/:username', optionalAuth, async (req, res) => {
const streamer = await db.getUserByUsername(req.params.username);
ddiff --git a/test/channel-elsewhere.test.js b/test/channel-elsewhere.test.js
nnew file mode 100644
iindex 00000000..2a164c2b
---- /dev/null
++++ b/test/channel-elsewhere.test.js
@@@ -0,0 +1,70 @@
++'use strict';
++/**
++ * A channel's support on OpenVibe.VIP and OpenVibe.Tips for Live's donate and subscribe modals (server/monetization/
++ * elsewhere.js): VIP's joinable plans with their tier and Billing's price, Tips' other ways to support (links Tips
++ * built) and the tip page while it takes tips. Only https links pass; whatever does not answer is left out; a minute's
++ * cache per channel; a name that is not a handle asks nobody.
++ *
++ * node test/channel-elsewhere.test.js
++ */
++const assert = require('assert');
++const elsewhere = require('../server/monetization/elsewhere');
++
++const json = (status, body) => ({ ok: status >= 200 && status < 300, status, json: async () => body });
++const card = {
++ creator: { username: 'tierra', url: 'https://openvibe.vip/tierra' }, join_url: 'https://openvibe.vip/tierra',
++ plans: [
++ { name: 'Supporter', tier: 1, price_cents: 300, purchasable: true, join_url: 'https://openvibe.vip/tierra#plan-supporter' },
++ { name: 'Superfan', tier: 2, price_cents: 1500, purchasable: true, join_url: 'https://openvibe.vip/tierra#plan-superfan' },
++ { name: 'Patron', tier: 3, price_cents: null, purchasable: false, join_url: 'https://openvibe.vip/tierra#plan-patron' },
++ { name: 'Sneaky', tier: 4, price_cents: 100, purchasable: true, join_url: 'javascript:alert(1)' },
++ ],
++};
++const profile = {
++ url: 'https://openvibe.tips/tierra', page_enabled: true, accepting: true,
++ support_links: [
++ { provider: 'kofi', label: 'Ko-fi', handle: 'tierra', url: 'https://ko-fi.com/tierra' },
++ { provider: 'paypal', label: 'PayPal.me', handle: 'x', url: 'http://paypal.me/x' },
++ ],
++};
++
++(async () => {
++ const calls = [];
++ const fetchImpl = async (url) => {
++ calls.push(url);
++ if (url.endsWith('/embed/tierra/card.json')) return json(200, card);
++ if (url.endsWith('/api/v1/profiles/tierra')) return json(200, { profile });
++ if (url.includes('/embed/quiet/')) throw new Error('timeout');
++ return json(404, { code: 'not_found' });
++ };
++
++ const out = await elsewhere.forChannel('Tierra', { fetchImpl, now: 1000 });
++ assert.deepStrictEqual(out.memberships, {
++ url: 'https://openvibe.vip/tierra',
++ plans: [
++ { name: 'Supporter', tier: 1, price_cents: 300, join_url: 'https://openvibe.vip/tierra#plan-supporter' },
++ { name: 'Superfan', tier: 2, price_cents: 1500, join_url: 'https://openvibe.vip/tierra#plan-superfan' },
++ ],
++ }, 'joinable plans only, and only https links');
++ assert.deepStrictEqual(out.support_links, [{ provider: 'kofi', label: 'Ko-fi', url: 'https://ko-fi.com/tierra' }], 'an http link is dropped');
++ assert.strictEqual(out.tips_url, 'https://openvibe.tips/tierra');
++ assert.ok(calls.every((u) => u.startsWith('http://127.0.0.1:')), 'loopback reads');
++
++ const n = calls.length;
++ await elsewhere.forChannel('tierra', { fetchImpl, now: 30_000 });
++ assert.strictEqual(calls.length, n, 'cached for a minute');
++ await elsewhere.forChannel('tierra', { fetchImpl, now: 62_000 });
++ assert.strictEqual(calls.length, n + 2, 'read again after the minute');
++
++ const quiet = await elsewhere.forChannel('quiet', { fetchImpl, now: 1000 });
++ assert.deepStrictEqual(quiet, { memberships: null, support_links: null, tips_url: null }, 'nothing answers: nothing shown');
++
++ profile.accepting = false;
++ elsewhere._reset();
++ assert.strictEqual((await elsewhere.forChannel('tierra', { fetchImpl, now: 1000 })).tips_url, null, 'no tip page link while it takes no tips');
++
++ const before = calls.length;
++ assert.deepStrictEqual(await elsewhere.forChannel('../admin', { fetchImpl }), { memberships: null, support_links: null, tips_url: null });
++ assert.strictEqual(calls.length, before, 'a name that is not a handle asks nobody');
++ console.log('channel elsewhere: all checks passed');
++})().catch((e) => { console.error(e); process.exit(1); });