openvibers/openvibe.live Commits
PowerChat: mint fixed-price checkout intents for Vibes packs + subs; Home hero: unified stat board with fuller Economy row
Changes
@@@ -2184,22 +2184,38 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }/* Stats bar — themed clusters (Right now / Community / Economy / Archive) instead ofone undifferentiated chip wall. Each group is a bordered island with a kicker. */--.hero-stats { display: flex; flex-wrap: wrap; gap: 14px; justify-content: center; align-items: stretch; margin: 4px 0 26px; }++/* Hero stat BOARD: one full-width panel, one row per themed group. Every row shares++ the same chip column grid so chips line up top-to-bottom and each row spans the++ full board width — no more differently-sized islands stacked down the middle. */++.hero-stats {++ display: flex; flex-direction: column; margin: 4px 0 26px;++ /* Break out of the 860px hero text column: a stat board wants dashboard width. */++ width: min(1240px, 94vw); position: relative; left: 50%; transform: translateX(-50%);++ border-radius: 18px; overflow: hidden;++ background: color-mix(in srgb, var(--bg-primary) 58%, transparent);++ border: 1px solid color-mix(in srgb, var(--accent) 18%, transparent);++ box-shadow: 0 18px 50px -30px rgba(0, 0, 0, 0.9);++ backdrop-filter: blur(10px); text-align: left;++}.hero-stat-group {-- display: flex; flex-direction: column; gap: 7px; align-items: center;-- padding: 10px 12px 12px; border-radius: 16px;-- background: color-mix(in srgb, var(--bg-primary) 34%, transparent);-- border: 1px solid color-mix(in srgb, var(--accent) 14%, transparent);-- backdrop-filter: blur(4px);++ display: grid; grid-template-columns: 118px minmax(0, 1fr); gap: 12px; align-items: center;++ padding: 11px 14px 11px 0;++ border-top: 1px solid color-mix(in srgb, var(--accent) 12%, transparent);}++.hero-stat-group:first-child { border-top: 0; }++.hero-stat-group:nth-child(even) { background: color-mix(in srgb, var(--accent) 4%, transparent); }.hero-stat-kicker {-- font-size: 0.62rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.14em;++ display: flex; align-items: center; gap: 7px; padding-left: 16px; align-self: stretch;++ border-left: 3px solid color-mix(in srgb, var(--accent) 55%, transparent);++ font-size: 0.64rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.14em; line-height: 1.15;color: color-mix(in srgb, var(--accent) 70%, var(--text-muted));-- text-shadow: 0 1px 8px rgba(0, 0, 0, 0.5);}--.hero-stat-row { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; }--.hero-stat--spark { flex-direction: column; gap: 2px; padding: 7px 12px; color: var(--accent); }--.hero-stat--spark svg { display: block; opacity: 0.9; }++.hero-stat-kicker i { color: var(--accent); font-size: 0.8rem; opacity: 0.9; }++/* auto-fit: a row with fewer chips stretches them to the board edge instead of++ leaving a ragged gap — every row reads as one full-width band. */++.hero-stat-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 8px; }++.hero-stat--spark { grid-column: span 2; flex-direction: column; align-items: stretch; gap: 3px; padding: 7px 12px 6px; color: var(--accent); }++.hero-stat--spark svg { display: block; width: 100%; height: 30px; opacity: 0.9; }/* Newest shipped commit — hero one-liner */.hero-latest {@@@ -2277,13 +2293,14 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }.sgg-title i { color: var(--accent); margin-right: 3px; }.sgg-pct { font-weight: 700; color: var(--accent); font-variant-numeric: tabular-nums; }.hero-stat {-- display: flex; align-items: center; gap: 9px; padding: 9px 15px; border-radius: 14px;++ display: flex; align-items: center; gap: 9px; padding: 9px 12px; border-radius: 13px; min-width: 0;background: color-mix(in srgb, var(--bg-secondary) 78%, transparent);border: 1px solid color-mix(in srgb, var(--accent) 24%, transparent); backdrop-filter: blur(8px);box-shadow: 0 6px 20px -12px rgba(0, 0, 0, 0.7);}.hero-stat > i { color: var(--accent); font-size: 1.05rem; }--.hero-stat-meta { display: flex; flex-direction: column; align-items: flex-start; line-height: 1.05; }++.hero-stat-meta { display: flex; flex-direction: column; align-items: flex-start; line-height: 1.05; min-width: 0; }++.hero-stat-label, .hero-stat-sub { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }.hero-stat-num { font-size: 1.22rem; font-weight: 800; color: var(--text-primary); font-variant-numeric: tabular-nums; }.hero-stat-label { font-size: 0.66rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); }.hero-stat-sub { font-size: 0.6rem; font-weight: 700; color: color-mix(in srgb, var(--accent, #8b5cf6) 85%, var(--text-muted)); letter-spacing: 0.02em; }@@@ -2301,7 +2318,11 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }@media (max-width: 640px) {.hero { min-height: clamp(480px, 82vh, 620px); }-- .hero-stat { padding: 7px 11px; gap: 7px; }++ .hero-stats { border-radius: 14px; }++ .hero-stat-group { grid-template-columns: 1fr; gap: 8px; padding: 10px 10px 10px 0; }++ .hero-stat-kicker { padding-left: 10px; border-left-width: 2px; }++ .hero-stat-row { grid-template-columns: repeat(auto-fit, minmax(104px, 1fr)); gap: 6px; margin-left: 10px; }++ .hero-stat { padding: 7px 9px; gap: 7px; }.hero-stat-num { font-size: 1.05rem; }.hero-float { border-radius: 10px; }}@@@ -62,7 +62,7 @@</script><!-- Stylesheets -->-- <link rel="stylesheet" href="/css/style.css?v=138">++ <link rel="stylesheet" href="/css/style.css?v=139"><link rel="stylesheet" href="/css/chat-shared.css?v=1"><link rel="stylesheet" href="/css/broadcast.css?v=11"><link rel="stylesheet" href="/css/cosmetics.css?v=2">@@@ -5021,7 +5021,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig<!-- Scripts --><script src="/js/theme-engine.js?v=4"></script><script src="/live-notify.js?v=3" defer></script>--<script src="/js/app.js?v=130" defer></script>++<script src="/js/app.js?v=131" defer></script><script src="/js/stream-player.js?v=21" defer></script><script src="/js/stream-pip.js?v=1" defer></script><script src="/js/chat-shared.js?v=3" defer></script>@@@ -1215,58 +1215,68 @@ function _heroCountUp(el, target) {function renderHeroStats(stats) {const wrap = document.getElementById('hero-stats');if (!wrap || !stats) return;-- // Short, uniform labels (full meaning in the title tooltip) so a long label never dwarfs-- // its number. Stats are clustered into themed groups instead of one undifferentiated-- // 15-chip wall: what's happening NOW, the people, the economy, and the archive.++ // One stat BOARD: every themed group is a full-width row (kicker on the left, chips++ // on a shared column grid), so rows line up edge to edge instead of floating as++ // differently-sized islands. Chips keep short uniform labels (full meaning in the++ // title tooltip) so a long label never dwarfs its number.const R = stats.recent || {};const groups = [];// ── Right now ────────────────────────────────────────────────-- const now = [];-- if (stats.liveNow > 0) now.push({ cls: 'hero-stat--live', icon: 'fa-circle', num: stats.liveNow, label: 'Live', title: 'Streams live right now' });-- if (stats.viewersNow > 0) now.push({ cls: 'hero-stat--live', icon: 'fa-eye', num: stats.viewersNow, label: 'Watching', title: 'Viewers watching right now' });-- now.push({ icon: 'fa-fire', num: stats.weeklyActive, label: 'Active', title: 'Active this week — chatters incl. anons & relays' + (stats.weeklyVisitors ? `, plus ${stats.weeklyVisitors} new visitors` : ''), sub: stats.weeklyVisitors ? `+${_fmtCount(stats.weeklyVisitors)} new` : '' });++ const now = [++ { cls: stats.liveNow > 0 ? 'hero-stat--live' : '', icon: stats.liveNow > 0 ? 'fa-circle' : 'fa-circle-dot', num: stats.liveNow, label: 'Live', title: stats.liveNow > 0 ? 'Streams live right now' : 'Nobody is live right now — check Recently Online below' },++ { cls: stats.viewersNow > 0 ? 'hero-stat--live' : '', icon: 'fa-eye', num: stats.viewersNow, label: 'Watching', title: 'Viewers watching right now' },++ { icon: 'fa-fire', num: stats.weeklyActive, label: 'Active', title: 'Active this week — chatters incl. anons & relays' },++ { icon: 'fa-user-plus', num: stats.weeklyVisitors, label: 'New Visitors', title: 'New unique visitors this week' },++ ];// 24h viewer sparkline (5-minute samples) — trends read better than a snapshot.const trend = Array.isArray(stats.viewerTrend) ? stats.viewerTrend : [];if (trend.length >= 2 && trend.some(t => (t.viewers || 0) > 0)) {const max = Math.max(...trend.map(t => t.viewers || 0), 1);-- const W = 110, H = 26;++ const W = 220, H = 30;const pts = trend.map((t, i) => `${(i / (trend.length - 1) * W).toFixed(1)},${(H - 2 - ((t.viewers || 0) / max) * (H - 4)).toFixed(1)}`).join(' ');now.push({html: `<div class="hero-stat hero-stat--spark" title="Viewers over the last 24h (peak ${max})">-- <svg viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" preserveAspectRatio="none" aria-hidden="true">++ <svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" aria-hidden="true"><polyline points="${pts}" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>-- </svg><span class="hero-stat-label">24h viewers</span></div>`,++ </svg><span class="hero-stat-label">24h viewers · peak ${max}</span></div>`,});}-- groups.push({ kicker: 'Right now', rows: now });++ groups.push({ kicker: 'Right now', icon: 'fa-bolt', rows: now });// ── Community ────────────────────────────────────────────────groups.push({-- kicker: 'Community', rows: [++ kicker: 'Community', icon: 'fa-people-group', rows: [{ icon: 'fa-satellite-dish', num: stats.streamers, label: 'Streamers', title: 'People who have gone live' },{ icon: 'fa-users', num: stats.users, label: 'Users', title: 'Registered users', recent: R.users },{ icon: 'fa-user-secret', num: stats.anons, label: 'Anons', title: 'Anonymous chatters ever seen', recent: R.anons },{ icon: 'fa-heart', num: stats.follows, label: 'Follows', title: 'Channel follows', recent: R.follows },{ icon: 'fa-comments', num: stats.chatMessages, label: 'Messages', title: 'Chat messages sent', recent: R.messages },-- ...(stats.hoursWatched > 0 ? [{ icon: 'fa-couch', num: stats.hoursWatched, label: 'Hrs Watched', title: 'Hours the community has spent watching streams', unit: 'h' }] : []),++ { icon: 'fa-couch', num: stats.hoursWatched, label: 'Hrs Watched', title: 'Hours the community has spent watching streams', unit: 'h' },],});// ── Economy ──────────────────────────────────────────────────-- const econ = [];-- if (stats.vibesTipped > 0) econ.push({ icon: 'fa-hand-holding-dollar', num: stats.vibesTipped, label: 'Vibes Tipped', title: 'Vibes donated between people (100 Vibes = $1)', recent: R.vibes });-- if (stats.activeSubs > 0) econ.push({ icon: 'fa-star', num: stats.activeSubs, label: 'Subs', title: 'Active channel subscriptions' });-- if (stats.pointsEarned > 0) econ.push({ icon: 'fa-coins', num: stats.pointsEarned, label: 'Points Earned', title: 'Channel points earned by viewers (watching, chatting, following)', recent: R.points });-- if (econ.length) groups.push({ kicker: 'Economy', rows: econ });++ groups.push({++ kicker: 'Economy', icon: 'fa-coins', rows: [++ { icon: 'fa-hand-holding-dollar', num: stats.vibesTipped, label: 'Vibes Tipped', title: 'Vibes donated between people (100 Vibes = $1)', recent: R.vibes },++ { icon: 'fa-hand-holding-heart', num: stats.supporters, label: 'Supporters', title: 'People who have tipped Vibes to a streamer' },++ { icon: 'fa-cart-shopping', num: stats.vibesBought, label: 'Vibes Bought', title: 'Vibes purchased with real money (PowerChat, card, PayPal, crypto)', recent: R.vibesBought },++ { icon: 'fa-star', num: stats.activeSubs, label: 'Subs', title: 'Active channel subscriptions', recent: R.subs },++ { icon: 'fa-coins', num: stats.pointsEarned, label: 'Points Earned', title: 'Channel points earned by viewers (watching, chatting, following)', recent: R.points },++ { icon: 'fa-gift', num: stats.pointsSpent, label: 'Points Spent', title: 'Channel points spent on rewards', recent: R.pointsSpent },++ { icon: 'fa-ticket', num: stats.redemptions, label: 'Rewards', title: 'Channel reward redemptions', recent: R.redemptions },++ { icon: 'fa-bullseye', num: stats.goalsActive, label: 'Goals', title: `Donation goals running now · ${stats.goalsReached || 0} reached so far`, sub: stats.goalsReached ? `${_fmtCount(stats.goalsReached)} reached` : '' },++ ],++ });// ── Archive ──────────────────────────────────────────────────groups.push({-- kicker: 'Archive', rows: [++ kicker: 'Archive', icon: 'fa-box-archive', rows: [{ icon: 'fa-tower-broadcast', num: stats.liveSessions, label: 'Sessions', title: 'Total stream sessions', recent: R.sessions },{ icon: 'fa-film', num: stats.vods, label: 'VODs', title: 'Recorded videos', recent: R.vods },{ icon: 'fa-scissors', num: stats.clips, label: 'Clips', title: 'Clips created', recent: R.clips },-- ...(stats.streamHours > 0 ? [{ icon: 'fa-clock', num: stats.streamHours, label: 'Hours', title: 'Hours of video archived', recent: R.hours, unit: 'h' }] : []),++ { icon: 'fa-clock', num: stats.streamHours, label: 'Hours', title: 'Hours of video archived', recent: R.hours, unit: 'h' },{ icon: 'fa-brain', num: stats.aiMemories, label: 'AI Moments', title: 'Moments the AI remembers across every stream', recent: R.aiMoments },{ icon: 'fa-face-grin-squint', num: stats.emotes, label: 'Emotes', title: 'Custom channel emotes uploaded' },{ icon: 'fa-paste', num: stats.pastes, label: 'Pastes', title: `${stats.pasteText || 0} text · ${stats.pasteImages || 0} image pastes`, sub: (stats.pasteText != null && stats.pasteImages != null) ? `${_fmtCount(stats.pasteText)} txt · ${_fmtCount(stats.pasteImages)} img` : '' },@@@ -1276,7 +1286,7 @@ function renderHeroStats(stats) {// For stats with rolling data: append "+d today · +w this week · +m this month" to the// hover tooltip and show the weekly delta as the small sub-line.const recTitle = (rec, u = '') => rec ? ` — +${_fmtCount(rec.d)}${u} today · +${_fmtCount(rec.w)}${u} this week · +${_fmtCount(rec.m)}${u} this month` : '';-- const recSub = (rec, u = '') => rec ? `+${_fmtCount(rec.w)}${u} wk` : '';++ const recSub = (rec, u = '') => (rec && rec.w > 0) ? `+${_fmtCount(rec.w)}${u} wk` : '';const chip = (r) => {if (r.html) return r.html; // pre-rendered chips (sparkline)const title = (r.title || '') + recTitle(r.recent, r.unit || '');@@@ -1285,7 +1295,7 @@ function renderHeroStats(stats) {};wrap.innerHTML = groups.filter(g => g.rows.length).map(g => `<div class="hero-stat-group">-- <span class="hero-stat-kicker">${g.kicker}</span>++ <span class="hero-stat-kicker"><i class="fa-solid ${g.icon}"></i>${g.kicker}</span><div class="hero-stat-row">${g.rows.map(chip).join('')}</div></div>`).join('');wrap.querySelectorAll('.hero-stat-num').forEach(el => _heroCountUp(el, parseInt(el.dataset.n, 10) || 0));@@@ -7,7 +7,7 @@<!-- Keep these in step with index.html — the popout renders the same chatcomponents (cosmetics.css positions particle effects; without it they sitinline next to the username as "broken badges" until their cleanup timer). -->-- <link rel="stylesheet" href="/css/style.css?v=138">++ <link rel="stylesheet" href="/css/style.css?v=139"><link rel="stylesheet" href="/css/chat-shared.css?v=1"><link rel="stylesheet" href="/css/cosmetics.css?v=2"><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">@@@ -226,7 +226,7 @@<!-- Chat feature scripts — mirror index.html so the popout has full parity.Order: dependencies (app/emotes/gif/uploads/coins/bucks/cosmetics) before chat.js. -->-- <script src="/js/app.js?v=130"></script>++ <script src="/js/app.js?v=131"></script><script src="/js/emotes.js?v=8"></script><script src="/js/gif-picker.js?v=1"></script><script src="/js/channel-uploads.js?v=12"></script>@@@ -3900,6 +3900,17 @@ function _computeHomeStats() {activeSubs: c(`SELECT COUNT(*) AS count FROM subscriptions WHERE status = 'active' AND (current_period_end IS NULL OR datetime(current_period_end) > CURRENT_TIMESTAMP)`),// Channel points earned by viewers across every channel (watch/chat/follow bonuses).pointsEarned: c(`SELECT COALESCE(SUM(amount), 0) AS count FROM coin_transactions WHERE amount > 0`),++ // …and spent back on channel rewards.++ pointsSpent: c(`SELECT COALESCE(-SUM(amount), 0) AS count FROM coin_transactions WHERE amount < 0`),++ // Reward redemptions that stuck (not rejected / refunded).++ redemptions: c(`SELECT COUNT(*) AS count FROM coin_redemptions WHERE status NOT IN ('rejected', 'refunded')`),++ // Distinct people who have tipped Vibes to someone.++ supporters: c(`SELECT COUNT(DISTINCT from_user_id) AS count FROM transactions WHERE type = 'donation' AND from_user_id IS NOT NULL`),++ // Vibes bought with real money (credited purchase orders, any provider).++ vibesBought: c(`SELECT COALESCE(SUM(bucks), 0) AS count FROM payment_orders WHERE kind = 'bucks' AND status = 'credited'`),++ // Donation goals: currently running + ever reached.++ goalsActive: c(`SELECT COUNT(*) AS count FROM donation_goals WHERE is_active = 1`),++ goalsReached: c(`SELECT COUNT(*) AS count FROM donation_goals WHERE reached_at IS NOT NULL OR current_amount >= target_amount`),vods: c(`SELECT COUNT(*) AS count FROM vods WHERE is_public = 1 AND COALESCE(is_recording, 0) = 0`),clips: c(`SELECT COUNT(*) AS count FROM clips WHERE COALESCE(is_public, 1) = 1`),liveSessions: c(`SELECT COUNT(*) AS count FROM streams`),@@@ -3942,6 +3953,10 @@ function _computeHomeStats() {hours: { d: hoursSince('-1 day'), w: hoursSince('-7 days'), m: hoursSince('-30 days') },vibes: winSum('transactions', 'amount', 'created_at', "type = 'donation'"),points: winSum('coin_transactions', 'amount', 'created_at', 'amount > 0'),++ pointsSpent: winSum('coin_transactions', '-amount', 'created_at', 'amount < 0'),++ redemptions: winCount('coin_redemptions', 'created_at', "status NOT IN ('rejected', 'refunded')"),++ vibesBought: winSum('payment_orders', 'bucks', 'updated_at', "kind = 'bucks' AND status = 'credited'"),++ subs: winCount('subscriptions', 'created_at'),follows: winCount('follows', 'created_at'),},};@@@ -78,18 +78,78 @@ function tipLinkFor(pcUsername, ref, { purpose = null, amountCents = null, returreturn `${c.baseUrl}/${encodeURIComponent(pcUsername)}/tip?${params.toString()}`;}++// ── Server-minted checkout intents ───────────────────────────++// PowerChat's fixed-price checkout is an INTENT, not a query string: the app calls++// GET /streamers/:username/tip-checkout-link?amount_cents&purpose&ref&redirect_uri++// (streamer token, scope checkout:attribute) and gets back a single-use, ~1h URL that++// carries only app_intent=<token>. Amount/purpose/ref live server-side on the intent,++// so the viewer can neither edit the price nor strip the correlation ref, and the++// tip page renders the amount locked. That is the mechanism the plain++// app_amount_cents query param only approximates — so every PINNED checkout (Vibes++// packs, subscriptions) is minted here first and the canonical URL is the fallback++// (PowerChat versions without intents, token trouble, transient API errors).++//++// Minting needs a token for the HOST account. Streamer-direct hosts have one (their++// own connection); the site tips account has one only when its PowerChat username is++// also connected on this site (the owner's dashboard card) — otherwise it stays on++// the canonical link. `item_name` is the viewer-facing label PowerChat is adding to++// intents ("1,000 Vibes", "goosely — 1 month sub"); if the running PowerChat rejects++// the unknown param we retry without it so the fixed amount still lands.++function _mintHostConn(pcUsername) {++ try {++ const conn = db.getPowerchatConnectionByUsername(pcUsername);++ if (!conn || !conn.access_token || !conn.refresh_token) return null;++ if (conn.scope && !String(conn.scope).split(/\s+/).includes('checkout:attribute')) return null;++ return conn;++ } catch { return null; }++}++const _mintWarned = new Map();++function _mintWarn(key, msg) {++ const now = Date.now();++ if ((now - (_mintWarned.get(key) || 0)) < 10 * 60 * 1000) return;++ _mintWarned.set(key, now);++ console.warn(`[PowerChat] checkout intent for @${key} not minted — using canonical link: ${msg}`);++}++async function mintCheckoutLink(pcUsername, ref, { purpose = null, amountCents = null, itemName = null, returnTo = true } = {}) {++ const fallback = () => ({ url: tipLinkFor(pcUsername, ref, { purpose, amountCents, returnTo }), minted: false });++ const conn = _mintHostConn(pcUsername);++ if (!conn) { _mintWarn(pcUsername, 'no connected account with checkout:attribute for that username'); return fallback(); }++ const cents = Math.round(Number(amountCents) || 0);++ const base = { ref: String(ref) };++ if (returnTo) base.redirect_uri = oauth.redirectUri();++ if (purpose) base.purpose = String(purpose).slice(0, 64);++ if (cents >= 50 && cents <= 1000000) base.amount_cents = String(cents);++ const label = itemName ? String(itemName).replace(/\s+/g, ' ').trim().slice(0, 64) : '';++ const attempt = async (withLabel) => {++ const query = withLabel && label ? { ...base, item_name: label } : base;++ const json = await oauth.apiRequest(conn.user_id, { method: 'GET', path: '/tip-checkout-link', username: pcUsername, query });++ const url = (json && json.data && json.data.url) || (json && json.url) || null;++ if (!url || !/^https?:\/\//i.test(String(url))) throw new Error('response carried no url');++ return String(url);++ };++ try {++ return { url: await attempt(true), minted: true };++ } catch (e1) {++ if (label && e1.status === 400) {++ try { return { url: await attempt(false), minted: true }; } catch (e2) { e1 = e2; }++ }++ _mintWarn(pcUsername, `${e1.status || ''} ${e1.message}`.trim());++ return fallback();++ }++}++// ── Link builders ────────────────────────────────────────────// Buy Vibes: always the site account (the site must receive the money it mints// against). The checkout is PINNED to the package price and categorized "vibes".--function buildPurchaseLink(order) {++async function buildPurchaseLink(order) {const site = getSiteAccount();if (!site) return null;-- return {-- url: tipLinkFor(site.username, `pcorder:${order.id}`, {-- purpose: 'vibes', amountCents: order.amount_cents, returnTo: true,-- }),-- mode: 'site',-- };++ const bucks = Number(order.bucks) || 0;++ const link = await mintCheckoutLink(site.username, `pcorder:${order.id}`, {++ purpose: 'vibes', amountCents: order.amount_cents, returnTo: true,++ itemName: bucks ? `${bucks.toLocaleString()} Vibes` : 'Vibes',++ });++ return { url: link.url, mode: 'site', minted: link.minted };}// Subscription: streamer-direct when possible, site fallback otherwise.@@@ -101,20 +161,27 @@ function buildPurchaseLink(order) {// and the streamer receives their normal share of the BASE price). 'auto' = direct when// the streamer has PowerChat connected, else site.// provider_ref persists the decision: "direct[:renew]" | "site[:fee=<cents>][:renew]".--function buildSubscribeLink(order, streamerUserId, { autoRenew = 0, route = 'auto', feeCents = 0 } = {}) {++async function buildSubscribeLink(order, streamerUserId, { autoRenew = 0, route = 'auto', feeCents = 0 } = {}) {const suffix = autoRenew ? ':renew' : '';-- const opts = { purpose: 'subscription', amountCents: order.amount_cents, returnTo: true };++ const streamer = db.getUserById(streamerUserId);++ const who = streamer ? (streamer.display_name || streamer.username) : 'channel';++ const opts = {++ purpose: 'subscription', amountCents: order.amount_cents, returnTo: true,++ itemName: `${who} — 1 month subscription${autoRenew ? ' (auto-renew)' : ''}`,++ };const direct = route !== 'site' ? _checkoutConn(streamerUserId) : null;if (direct) {db.updatePaymentOrder(order.id, { provider_ref: `direct${suffix}` });-- return { url: tipLinkFor(direct.powerchat_username, `pcsub:${order.id}`, opts), mode: 'direct' };++ const link = await mintCheckoutLink(direct.powerchat_username, `pcsub:${order.id}`, opts);++ return { url: link.url, mode: 'direct', minted: link.minted };}if (route === 'direct') return null; // caller asked for the streamer's page and they have noneconst site = getSiteAccount();if (!site) return null;const fee = Math.max(0, Math.round(Number(feeCents) || 0));db.updatePaymentOrder(order.id, { provider_ref: `site${fee ? `:fee=${fee}` : ''}${suffix}` });-- return { url: tipLinkFor(site.username, `pcsub:${order.id}`, opts), mode: 'site', feeCents: fee };++ const link = await mintCheckoutLink(site.username, `pcsub:${order.id}`, opts);++ return { url: link.url, mode: 'site', feeCents: fee, minted: link.minted };}/** Which PowerChat subscription routes a streamer currently supports (for the UI). */@@@ -166,7 +233,8 @@ function handleAttributedDonation(receivingUserId, data) {if (!order || order.kind !== 'bucks') return true; // ours but unusable — never double-handleif (order.status === 'credited') return true; // at-least-once redeliveryif (usdCents < 1) return true;-- // The tip page can't enforce an amount — credit what was ACTUALLY paid.++ // Credit what was ACTUALLY paid (intents pin the amount; the canonical++ // fallback link can't) — never the package price on faith.const pay = require('../monetization/payments');const bucks = pay.bucksForUsd(usdCents / 100);db.updatePaymentOrder(order.id, { amount_cents: usdCents, bucks, status: 'paid' });@@@ -181,7 +249,8 @@ function handleAttributedDonation(receivingUserId, data) {const order = db.getPaymentOrderById(Number(m[1]));if (!order || order.kind !== 'subscription' || !order.streamer_id) return true;if (order.status === 'credited') return true;-- // Underpaid (tip page can't enforce amounts): treat the tip as a plain++ // Underpaid (canonical-link fallback can't enforce amounts; minted intents++ // can): treat the tip as a plain// donation to the order's streamer rather than silently activating a// discounted sub. Direct mode = the receiving account IS the streamer, so// falling through does the right thing; site mode routes explicitly.@@@ -244,7 +313,7 @@ function _notify(userId, title, message) {}module.exports = {-- isAvailable, getSiteAccount, tipLinkFor, subscribeRoutes,++ isAvailable, getSiteAccount, tipLinkFor, mintCheckoutLink, subscribeRoutes,buildPurchaseLink, buildSubscribeLink, buildDonateLink,handleAttributedDonation,};@@@ -61,9 +61,9 @@ router.post('/bucks/checkout', requireAuth, async (req, res) => {}if (provider === 'powerchat') {// Tip the site's PowerChat account; the checkout is PINNED to the package-- // price (app_amount_cents locks the tip page's amount picker) and the++ // price (a server-minted intent locks the tip page's amount) and the// donation.completed webhook credits the buyer once it confirms.-- const link = require('../integrations/powerchat-checkout').buildPurchaseLink(order);++ const link = await require('../integrations/powerchat-checkout').buildPurchaseLink(order);if (!link) { db.updatePaymentOrder(order.id, { status: 'failed' }); return res.status(400).json({ error: 'PowerChat purchases are not available right now' }); }return res.json({url: link.url, powerchat: true, amountUsd,@@@ -148,7 +148,7 @@ router.post('/subscribe', requireAuth, async (req, res) => {user_id: req.user.id, provider: 'powerchat', kind: 'subscription',amount_cents: totalCents, streamer_id: streamer.id,});-- const link = checkout.buildSubscribeLink(order, streamer.id, { autoRenew, route: wantSite ? 'site' : 'direct', feeCents });++ const link = await checkout.buildSubscribeLink(order, streamer.id, { autoRenew, route: wantSite ? 'site' : 'direct', feeCents });if (!link) { db.updatePaymentOrder(order.id, { status: 'failed' }); return res.status(400).json({ error: 'PowerChat payments are not available right now' }); }const totalUsd = totalCents / 100;return res.json({
@@ -2184,22 +2184,38 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -2184,22 +2184,38 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
/* Stats bar — themed clusters (Right now / Community / Economy / Archive) instead of
/* Stats bar — themed clusters (Right now / Community / Economy / Archive) instead of
one undifferentiated chip wall. Each group is a bordered island with a kicker. */
one undifferentiated chip wall. Each group is a bordered island with a kicker. */
-.hero-stats { display: flex; flex-wrap: wrap; gap: 14px; justify-content: center; align-items: stretch; margin: 4px 0 26px; }
+/* Hero stat BOARD: one full-width panel, one row per themed group. Every row shares
+ the same chip column grid so chips line up top-to-bottom and each row spans the
+ full board width — no more differently-sized islands stacked down the middle. */
+.hero-stats {
+ display: flex; flex-direction: column; margin: 4px 0 26px;
+ /* Break out of the 860px hero text column: a stat board wants dashboard width. */
+ width: min(1240px, 94vw); position: relative; left: 50%; transform: translateX(-50%);
+ border-radius: 18px; overflow: hidden;
+ background: color-mix(in srgb, var(--bg-primary) 58%, transparent);
+ border: 1px solid color-mix(in srgb, var(--accent) 18%, transparent);
+ box-shadow: 0 18px 50px -30px rgba(0, 0, 0, 0.9);
+ backdrop-filter: blur(10px); text-align: left;
+}
.hero-stat-group {
.hero-stat-group {
- display: flex; flex-direction: column; gap: 7px; align-items: center;
- padding: 10px 12px 12px; border-radius: 16px;
- background: color-mix(in srgb, var(--bg-primary) 34%, transparent);
- border: 1px solid color-mix(in srgb, var(--accent) 14%, transparent);
- backdrop-filter: blur(4px);
+ display: grid; grid-template-columns: 118px minmax(0, 1fr); gap: 12px; align-items: center;
+ padding: 11px 14px 11px 0;
+ border-top: 1px solid color-mix(in srgb, var(--accent) 12%, transparent);
}
}
+.hero-stat-group:first-child { border-top: 0; }
+.hero-stat-group:nth-child(even) { background: color-mix(in srgb, var(--accent) 4%, transparent); }
.hero-stat-kicker {
.hero-stat-kicker {
- font-size: 0.62rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.14em;
+ display: flex; align-items: center; gap: 7px; padding-left: 16px; align-self: stretch;
+ border-left: 3px solid color-mix(in srgb, var(--accent) 55%, transparent);
+ font-size: 0.64rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.14em; line-height: 1.15;
color: color-mix(in srgb, var(--accent) 70%, var(--text-muted));
color: color-mix(in srgb, var(--accent) 70%, var(--text-muted));
- text-shadow: 0 1px 8px rgba(0, 0, 0, 0.5);
}
}
-.hero-stat-row { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; }
-.hero-stat--spark { flex-direction: column; gap: 2px; padding: 7px 12px; color: var(--accent); }
-.hero-stat--spark svg { display: block; opacity: 0.9; }
+.hero-stat-kicker i { color: var(--accent); font-size: 0.8rem; opacity: 0.9; }
+/* auto-fit: a row with fewer chips stretches them to the board edge instead of
+ leaving a ragged gap — every row reads as one full-width band. */
+.hero-stat-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 8px; }
+.hero-stat--spark { grid-column: span 2; flex-direction: column; align-items: stretch; gap: 3px; padding: 7px 12px 6px; color: var(--accent); }
+.hero-stat--spark svg { display: block; width: 100%; height: 30px; opacity: 0.9; }
/* Newest shipped commit — hero one-liner */
/* Newest shipped commit — hero one-liner */
.hero-latest {
.hero-latest {
@@ -2277,13 +2293,14 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
@@ -2277,13 +2293,14 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
.sgg-title i { color: var(--accent); margin-right: 3px; }
.sgg-title i { color: var(--accent); margin-right: 3px; }
.sgg-pct { font-weight: 700; color: var(--accent); font-variant-numeric: tabular-nums; }
.sgg-pct { font-weight: 700; color: var(--accent); font-variant-numeric: tabular-nums; }
.hero-stat {
.hero-stat {
- display: flex; align-items: center; gap: 9px; padding: 9px 15px; border-radius: 14px;
+ display: flex; align-items: center; gap: 9px; padding: 9px 12px; border-radius: 13px; min-width: 0;
background: color-mix(in srgb, var(--bg-secondary) 78%, transparent);
background: color-mix(in srgb, var(--bg-secondary) 78%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 24%, transparent); backdrop-filter: blur(8px);
border: 1px solid color-mix(in srgb, var(--accent) 24%, transparent); backdrop-filter: blur(8px);
box-shadow: 0 6px 20px -12px rgba(0, 0, 0, 0.7);
box-shadow: 0 6px 20px -12px rgba(0, 0, 0, 0.7);
}
}
.hero-stat > i { color: var(--accent); font-size: 1.05rem; }
.hero-stat > i { color: var(--accent); font-size: 1.05rem; }
-.hero-stat-meta { display: flex; flex-direction: column; align-items: flex-start; line-height: 1.05; }
+.hero-stat-meta { display: flex; flex-direction: column; align-items: flex-start; line-height: 1.05; min-width: 0; }
+.hero-stat-label, .hero-stat-sub { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
.hero-stat-num { font-size: 1.22rem; font-weight: 800; color: var(--text-primary); font-variant-numeric: tabular-nums; }
.hero-stat-num { font-size: 1.22rem; font-weight: 800; color: var(--text-primary); font-variant-numeric: tabular-nums; }
.hero-stat-label { font-size: 0.66rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); }
.hero-stat-label { font-size: 0.66rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); }
.hero-stat-sub { font-size: 0.6rem; font-weight: 700; color: color-mix(in srgb, var(--accent, #8b5cf6) 85%, var(--text-muted)); letter-spacing: 0.02em; }
.hero-stat-sub { font-size: 0.6rem; font-weight: 700; color: color-mix(in srgb, var(--accent, #8b5cf6) 85%, var(--text-muted)); letter-spacing: 0.02em; }
@@ -2301,7 +2318,11 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
@@ -2301,7 +2318,11 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
@media (max-width: 640px) {
@media (max-width: 640px) {
.hero { min-height: clamp(480px, 82vh, 620px); }
.hero { min-height: clamp(480px, 82vh, 620px); }
- .hero-stat { padding: 7px 11px; gap: 7px; }
+ .hero-stats { border-radius: 14px; }
+ .hero-stat-group { grid-template-columns: 1fr; gap: 8px; padding: 10px 10px 10px 0; }
+ .hero-stat-kicker { padding-left: 10px; border-left-width: 2px; }
+ .hero-stat-row { grid-template-columns: repeat(auto-fit, minmax(104px, 1fr)); gap: 6px; margin-left: 10px; }
+ .hero-stat { padding: 7px 9px; gap: 7px; }
.hero-stat-num { font-size: 1.05rem; }
.hero-stat-num { font-size: 1.05rem; }
.hero-float { border-radius: 10px; }
.hero-float { border-radius: 10px; }
}
}
@@ -62,7 +62,7 @@
@@ -62,7 +62,7 @@
</script>
</script>
<!-- Stylesheets -->
<!-- Stylesheets -->
- <link rel="stylesheet" href="/css/style.css?v=138">
+ <link rel="stylesheet" href="/css/style.css?v=139">
<link rel="stylesheet" href="/css/chat-shared.css?v=1">
<link rel="stylesheet" href="/css/chat-shared.css?v=1">
<link rel="stylesheet" href="/css/broadcast.css?v=11">
<link rel="stylesheet" href="/css/broadcast.css?v=11">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
@@ -5021,7 +5021,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -5021,7 +5021,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<!-- Scripts -->
<!-- Scripts -->
<script src="/js/theme-engine.js?v=4"></script>
<script src="/js/theme-engine.js?v=4"></script>
<script src="/live-notify.js?v=3" defer></script>
<script src="/live-notify.js?v=3" defer></script>
-<script src="/js/app.js?v=130" defer></script>
+<script src="/js/app.js?v=131" defer></script>
<script src="/js/stream-player.js?v=21" defer></script>
<script src="/js/stream-player.js?v=21" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
@@ -1215,58 +1215,68 @@ function _heroCountUp(el, target) {
@@ -1215,58 +1215,68 @@ function _heroCountUp(el, target) {
function renderHeroStats(stats) {
function renderHeroStats(stats) {
const wrap = document.getElementById('hero-stats');
const wrap = document.getElementById('hero-stats');
if (!wrap || !stats) return;
if (!wrap || !stats) return;
- // Short, uniform labels (full meaning in the title tooltip) so a long label never dwarfs
- // its number. Stats are clustered into themed groups instead of one undifferentiated
- // 15-chip wall: what's happening NOW, the people, the economy, and the archive.
+ // One stat BOARD: every themed group is a full-width row (kicker on the left, chips
+ // on a shared column grid), so rows line up edge to edge instead of floating as
+ // differently-sized islands. Chips keep short uniform labels (full meaning in the
+ // title tooltip) so a long label never dwarfs its number.
const R = stats.recent || {};
const R = stats.recent || {};
const groups = [];
const groups = [];
// ── Right now ────────────────────────────────────────────────
// ── Right now ────────────────────────────────────────────────
- const now = [];
- if (stats.liveNow > 0) now.push({ cls: 'hero-stat--live', icon: 'fa-circle', num: stats.liveNow, label: 'Live', title: 'Streams live right now' });
- if (stats.viewersNow > 0) now.push({ cls: 'hero-stat--live', icon: 'fa-eye', num: stats.viewersNow, label: 'Watching', title: 'Viewers watching right now' });
- now.push({ icon: 'fa-fire', num: stats.weeklyActive, label: 'Active', title: 'Active this week — chatters incl. anons & relays' + (stats.weeklyVisitors ? `, plus ${stats.weeklyVisitors} new visitors` : ''), sub: stats.weeklyVisitors ? `+${_fmtCount(stats.weeklyVisitors)} new` : '' });
+ const now = [
+ { cls: stats.liveNow > 0 ? 'hero-stat--live' : '', icon: stats.liveNow > 0 ? 'fa-circle' : 'fa-circle-dot', num: stats.liveNow, label: 'Live', title: stats.liveNow > 0 ? 'Streams live right now' : 'Nobody is live right now — check Recently Online below' },
+ { cls: stats.viewersNow > 0 ? 'hero-stat--live' : '', icon: 'fa-eye', num: stats.viewersNow, label: 'Watching', title: 'Viewers watching right now' },
+ { icon: 'fa-fire', num: stats.weeklyActive, label: 'Active', title: 'Active this week — chatters incl. anons & relays' },
+ { icon: 'fa-user-plus', num: stats.weeklyVisitors, label: 'New Visitors', title: 'New unique visitors this week' },
+ ];
// 24h viewer sparkline (5-minute samples) — trends read better than a snapshot.
// 24h viewer sparkline (5-minute samples) — trends read better than a snapshot.
const trend = Array.isArray(stats.viewerTrend) ? stats.viewerTrend : [];
const trend = Array.isArray(stats.viewerTrend) ? stats.viewerTrend : [];
if (trend.length >= 2 && trend.some(t => (t.viewers || 0) > 0)) {
if (trend.length >= 2 && trend.some(t => (t.viewers || 0) > 0)) {
const max = Math.max(...trend.map(t => t.viewers || 0), 1);
const max = Math.max(...trend.map(t => t.viewers || 0), 1);
- const W = 110, H = 26;
+ const W = 220, H = 30;
const pts = trend.map((t, i) => `${(i / (trend.length - 1) * W).toFixed(1)},${(H - 2 - ((t.viewers || 0) / max) * (H - 4)).toFixed(1)}`).join(' ');
const pts = trend.map((t, i) => `${(i / (trend.length - 1) * W).toFixed(1)},${(H - 2 - ((t.viewers || 0) / max) * (H - 4)).toFixed(1)}`).join(' ');
now.push({
now.push({
html: `<div class="hero-stat hero-stat--spark" title="Viewers over the last 24h (peak ${max})">
html: `<div class="hero-stat hero-stat--spark" title="Viewers over the last 24h (peak ${max})">
- <svg viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" preserveAspectRatio="none" aria-hidden="true">
+ <svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" aria-hidden="true">
<polyline points="${pts}" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
<polyline points="${pts}" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
- </svg><span class="hero-stat-label">24h viewers</span></div>`,
+ </svg><span class="hero-stat-label">24h viewers · peak ${max}</span></div>`,
});
});
}
}
- groups.push({ kicker: 'Right now', rows: now });
+ groups.push({ kicker: 'Right now', icon: 'fa-bolt', rows: now });
// ── Community ────────────────────────────────────────────────
// ── Community ────────────────────────────────────────────────
groups.push({
groups.push({
- kicker: 'Community', rows: [
+ kicker: 'Community', icon: 'fa-people-group', rows: [
{ icon: 'fa-satellite-dish', num: stats.streamers, label: 'Streamers', title: 'People who have gone live' },
{ icon: 'fa-satellite-dish', num: stats.streamers, label: 'Streamers', title: 'People who have gone live' },
{ icon: 'fa-users', num: stats.users, label: 'Users', title: 'Registered users', recent: R.users },
{ icon: 'fa-users', num: stats.users, label: 'Users', title: 'Registered users', recent: R.users },
{ icon: 'fa-user-secret', num: stats.anons, label: 'Anons', title: 'Anonymous chatters ever seen', recent: R.anons },
{ icon: 'fa-user-secret', num: stats.anons, label: 'Anons', title: 'Anonymous chatters ever seen', recent: R.anons },
{ icon: 'fa-heart', num: stats.follows, label: 'Follows', title: 'Channel follows', recent: R.follows },
{ icon: 'fa-heart', num: stats.follows, label: 'Follows', title: 'Channel follows', recent: R.follows },
{ icon: 'fa-comments', num: stats.chatMessages, label: 'Messages', title: 'Chat messages sent', recent: R.messages },
{ icon: 'fa-comments', num: stats.chatMessages, label: 'Messages', title: 'Chat messages sent', recent: R.messages },
- ...(stats.hoursWatched > 0 ? [{ icon: 'fa-couch', num: stats.hoursWatched, label: 'Hrs Watched', title: 'Hours the community has spent watching streams', unit: 'h' }] : []),
+ { icon: 'fa-couch', num: stats.hoursWatched, label: 'Hrs Watched', title: 'Hours the community has spent watching streams', unit: 'h' },
],
],
});
});
// ── Economy ──────────────────────────────────────────────────
// ── Economy ──────────────────────────────────────────────────
- const econ = [];
- if (stats.vibesTipped > 0) econ.push({ icon: 'fa-hand-holding-dollar', num: stats.vibesTipped, label: 'Vibes Tipped', title: 'Vibes donated between people (100 Vibes = $1)', recent: R.vibes });
- if (stats.activeSubs > 0) econ.push({ icon: 'fa-star', num: stats.activeSubs, label: 'Subs', title: 'Active channel subscriptions' });
- if (stats.pointsEarned > 0) econ.push({ icon: 'fa-coins', num: stats.pointsEarned, label: 'Points Earned', title: 'Channel points earned by viewers (watching, chatting, following)', recent: R.points });
- if (econ.length) groups.push({ kicker: 'Economy', rows: econ });
+ groups.push({
+ kicker: 'Economy', icon: 'fa-coins', rows: [
+ { icon: 'fa-hand-holding-dollar', num: stats.vibesTipped, label: 'Vibes Tipped', title: 'Vibes donated between people (100 Vibes = $1)', recent: R.vibes },
+ { icon: 'fa-hand-holding-heart', num: stats.supporters, label: 'Supporters', title: 'People who have tipped Vibes to a streamer' },
+ { icon: 'fa-cart-shopping', num: stats.vibesBought, label: 'Vibes Bought', title: 'Vibes purchased with real money (PowerChat, card, PayPal, crypto)', recent: R.vibesBought },
+ { icon: 'fa-star', num: stats.activeSubs, label: 'Subs', title: 'Active channel subscriptions', recent: R.subs },
+ { icon: 'fa-coins', num: stats.pointsEarned, label: 'Points Earned', title: 'Channel points earned by viewers (watching, chatting, following)', recent: R.points },
+ { icon: 'fa-gift', num: stats.pointsSpent, label: 'Points Spent', title: 'Channel points spent on rewards', recent: R.pointsSpent },
+ { icon: 'fa-ticket', num: stats.redemptions, label: 'Rewards', title: 'Channel reward redemptions', recent: R.redemptions },
+ { icon: 'fa-bullseye', num: stats.goalsActive, label: 'Goals', title: `Donation goals running now · ${stats.goalsReached || 0} reached so far`, sub: stats.goalsReached ? `${_fmtCount(stats.goalsReached)} reached` : '' },
+ ],
+ });
// ── Archive ──────────────────────────────────────────────────
// ── Archive ──────────────────────────────────────────────────
groups.push({
groups.push({
- kicker: 'Archive', rows: [
+ kicker: 'Archive', icon: 'fa-box-archive', rows: [
{ icon: 'fa-tower-broadcast', num: stats.liveSessions, label: 'Sessions', title: 'Total stream sessions', recent: R.sessions },
{ icon: 'fa-tower-broadcast', num: stats.liveSessions, label: 'Sessions', title: 'Total stream sessions', recent: R.sessions },
{ icon: 'fa-film', num: stats.vods, label: 'VODs', title: 'Recorded videos', recent: R.vods },
{ icon: 'fa-film', num: stats.vods, label: 'VODs', title: 'Recorded videos', recent: R.vods },
{ icon: 'fa-scissors', num: stats.clips, label: 'Clips', title: 'Clips created', recent: R.clips },
{ icon: 'fa-scissors', num: stats.clips, label: 'Clips', title: 'Clips created', recent: R.clips },
- ...(stats.streamHours > 0 ? [{ icon: 'fa-clock', num: stats.streamHours, label: 'Hours', title: 'Hours of video archived', recent: R.hours, unit: 'h' }] : []),
+ { icon: 'fa-clock', num: stats.streamHours, label: 'Hours', title: 'Hours of video archived', recent: R.hours, unit: 'h' },
{ icon: 'fa-brain', num: stats.aiMemories, label: 'AI Moments', title: 'Moments the AI remembers across every stream', recent: R.aiMoments },
{ icon: 'fa-brain', num: stats.aiMemories, label: 'AI Moments', title: 'Moments the AI remembers across every stream', recent: R.aiMoments },
{ icon: 'fa-face-grin-squint', num: stats.emotes, label: 'Emotes', title: 'Custom channel emotes uploaded' },
{ icon: 'fa-face-grin-squint', num: stats.emotes, label: 'Emotes', title: 'Custom channel emotes uploaded' },
{ icon: 'fa-paste', num: stats.pastes, label: 'Pastes', title: `${stats.pasteText || 0} text · ${stats.pasteImages || 0} image pastes`, sub: (stats.pasteText != null && stats.pasteImages != null) ? `${_fmtCount(stats.pasteText)} txt · ${_fmtCount(stats.pasteImages)} img` : '' },
{ icon: 'fa-paste', num: stats.pastes, label: 'Pastes', title: `${stats.pasteText || 0} text · ${stats.pasteImages || 0} image pastes`, sub: (stats.pasteText != null && stats.pasteImages != null) ? `${_fmtCount(stats.pasteText)} txt · ${_fmtCount(stats.pasteImages)} img` : '' },
@@ -1276,7 +1286,7 @@ function renderHeroStats(stats) {
@@ -1276,7 +1286,7 @@ function renderHeroStats(stats) {
// For stats with rolling data: append "+d today · +w this week · +m this month" to the
// For stats with rolling data: append "+d today · +w this week · +m this month" to the
// hover tooltip and show the weekly delta as the small sub-line.
// hover tooltip and show the weekly delta as the small sub-line.
const recTitle = (rec, u = '') => rec ? ` — +${_fmtCount(rec.d)}${u} today · +${_fmtCount(rec.w)}${u} this week · +${_fmtCount(rec.m)}${u} this month` : '';
const recTitle = (rec, u = '') => rec ? ` — +${_fmtCount(rec.d)}${u} today · +${_fmtCount(rec.w)}${u} this week · +${_fmtCount(rec.m)}${u} this month` : '';
- const recSub = (rec, u = '') => rec ? `+${_fmtCount(rec.w)}${u} wk` : '';
+ const recSub = (rec, u = '') => (rec && rec.w > 0) ? `+${_fmtCount(rec.w)}${u} wk` : '';
const chip = (r) => {
const chip = (r) => {
if (r.html) return r.html; // pre-rendered chips (sparkline)
if (r.html) return r.html; // pre-rendered chips (sparkline)
const title = (r.title || '') + recTitle(r.recent, r.unit || '');
const title = (r.title || '') + recTitle(r.recent, r.unit || '');
@@ -1285,7 +1295,7 @@ function renderHeroStats(stats) {
@@ -1285,7 +1295,7 @@ function renderHeroStats(stats) {
};
};
wrap.innerHTML = groups.filter(g => g.rows.length).map(g => `
wrap.innerHTML = groups.filter(g => g.rows.length).map(g => `
<div class="hero-stat-group">
<div class="hero-stat-group">
- <span class="hero-stat-kicker">${g.kicker}</span>
+ <span class="hero-stat-kicker"><i class="fa-solid ${g.icon}"></i>${g.kicker}</span>
<div class="hero-stat-row">${g.rows.map(chip).join('')}</div>
<div class="hero-stat-row">${g.rows.map(chip).join('')}</div>
</div>`).join('');
</div>`).join('');
wrap.querySelectorAll('.hero-stat-num').forEach(el => _heroCountUp(el, parseInt(el.dataset.n, 10) || 0));
wrap.querySelectorAll('.hero-stat-num').forEach(el => _heroCountUp(el, parseInt(el.dataset.n, 10) || 0));
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
<!-- Keep these in step with index.html — the popout renders the same chat
<!-- Keep these in step with index.html — the popout renders the same chat
components (cosmetics.css positions particle effects; without it they sit
components (cosmetics.css positions particle effects; without it they sit
inline next to the username as "broken badges" until their cleanup timer). -->
inline next to the username as "broken badges" until their cleanup timer). -->
- <link rel="stylesheet" href="/css/style.css?v=138">
+ <link rel="stylesheet" href="/css/style.css?v=139">
<link rel="stylesheet" href="/css/chat-shared.css?v=1">
<link rel="stylesheet" href="/css/chat-shared.css?v=1">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
@@ -226,7 +226,7 @@
@@ -226,7 +226,7 @@
<!-- Chat feature scripts — mirror index.html so the popout has full parity.
<!-- Chat feature scripts — mirror index.html so the popout has full parity.
Order: dependencies (app/emotes/gif/uploads/coins/bucks/cosmetics) before chat.js. -->
Order: dependencies (app/emotes/gif/uploads/coins/bucks/cosmetics) before chat.js. -->
- <script src="/js/app.js?v=130"></script>
+ <script src="/js/app.js?v=131"></script>
<script src="/js/emotes.js?v=8"></script>
<script src="/js/emotes.js?v=8"></script>
<script src="/js/gif-picker.js?v=1"></script>
<script src="/js/gif-picker.js?v=1"></script>
<script src="/js/channel-uploads.js?v=12"></script>
<script src="/js/channel-uploads.js?v=12"></script>
@@ -3900,6 +3900,17 @@ function _computeHomeStats() {
@@ -3900,6 +3900,17 @@ function _computeHomeStats() {
activeSubs: c(`SELECT COUNT(*) AS count FROM subscriptions WHERE status = 'active' AND (current_period_end IS NULL OR datetime(current_period_end) > CURRENT_TIMESTAMP)`),
activeSubs: c(`SELECT COUNT(*) AS count FROM subscriptions WHERE status = 'active' AND (current_period_end IS NULL OR datetime(current_period_end) > CURRENT_TIMESTAMP)`),
// Channel points earned by viewers across every channel (watch/chat/follow bonuses).
// Channel points earned by viewers across every channel (watch/chat/follow bonuses).
pointsEarned: c(`SELECT COALESCE(SUM(amount), 0) AS count FROM coin_transactions WHERE amount > 0`),
pointsEarned: c(`SELECT COALESCE(SUM(amount), 0) AS count FROM coin_transactions WHERE amount > 0`),
+ // …and spent back on channel rewards.
+ pointsSpent: c(`SELECT COALESCE(-SUM(amount), 0) AS count FROM coin_transactions WHERE amount < 0`),
+ // Reward redemptions that stuck (not rejected / refunded).
+ redemptions: c(`SELECT COUNT(*) AS count FROM coin_redemptions WHERE status NOT IN ('rejected', 'refunded')`),
+ // Distinct people who have tipped Vibes to someone.
+ supporters: c(`SELECT COUNT(DISTINCT from_user_id) AS count FROM transactions WHERE type = 'donation' AND from_user_id IS NOT NULL`),
+ // Vibes bought with real money (credited purchase orders, any provider).
+ vibesBought: c(`SELECT COALESCE(SUM(bucks), 0) AS count FROM payment_orders WHERE kind = 'bucks' AND status = 'credited'`),
+ // Donation goals: currently running + ever reached.
+ goalsActive: c(`SELECT COUNT(*) AS count FROM donation_goals WHERE is_active = 1`),
+ goalsReached: c(`SELECT COUNT(*) AS count FROM donation_goals WHERE reached_at IS NOT NULL OR current_amount >= target_amount`),
vods: c(`SELECT COUNT(*) AS count FROM vods WHERE is_public = 1 AND COALESCE(is_recording, 0) = 0`),
vods: c(`SELECT COUNT(*) AS count FROM vods WHERE is_public = 1 AND COALESCE(is_recording, 0) = 0`),
clips: c(`SELECT COUNT(*) AS count FROM clips WHERE COALESCE(is_public, 1) = 1`),
clips: c(`SELECT COUNT(*) AS count FROM clips WHERE COALESCE(is_public, 1) = 1`),
liveSessions: c(`SELECT COUNT(*) AS count FROM streams`),
liveSessions: c(`SELECT COUNT(*) AS count FROM streams`),
@@ -3942,6 +3953,10 @@ function _computeHomeStats() {
@@ -3942,6 +3953,10 @@ function _computeHomeStats() {
hours: { d: hoursSince('-1 day'), w: hoursSince('-7 days'), m: hoursSince('-30 days') },
hours: { d: hoursSince('-1 day'), w: hoursSince('-7 days'), m: hoursSince('-30 days') },
vibes: winSum('transactions', 'amount', 'created_at', "type = 'donation'"),
vibes: winSum('transactions', 'amount', 'created_at', "type = 'donation'"),
points: winSum('coin_transactions', 'amount', 'created_at', 'amount > 0'),
points: winSum('coin_transactions', 'amount', 'created_at', 'amount > 0'),
+ pointsSpent: winSum('coin_transactions', '-amount', 'created_at', 'amount < 0'),
+ redemptions: winCount('coin_redemptions', 'created_at', "status NOT IN ('rejected', 'refunded')"),
+ vibesBought: winSum('payment_orders', 'bucks', 'updated_at', "kind = 'bucks' AND status = 'credited'"),
+ subs: winCount('subscriptions', 'created_at'),
follows: winCount('follows', 'created_at'),
follows: winCount('follows', 'created_at'),
},
},
};
};
@@ -78,18 +78,78 @@ function tipLinkFor(pcUsername, ref, { purpose = null, amountCents = null, retur
@@ -78,18 +78,78 @@ function tipLinkFor(pcUsername, ref, { purpose = null, amountCents = null, retur
return `${c.baseUrl}/${encodeURIComponent(pcUsername)}/tip?${params.toString()}`;
return `${c.baseUrl}/${encodeURIComponent(pcUsername)}/tip?${params.toString()}`;
}
}
+// ── Server-minted checkout intents ───────────────────────────
+// PowerChat's fixed-price checkout is an INTENT, not a query string: the app calls
+// GET /streamers/:username/tip-checkout-link?amount_cents&purpose&ref&redirect_uri
+// (streamer token, scope checkout:attribute) and gets back a single-use, ~1h URL that
+// carries only app_intent=<token>. Amount/purpose/ref live server-side on the intent,
+// so the viewer can neither edit the price nor strip the correlation ref, and the
+// tip page renders the amount locked. That is the mechanism the plain
+// app_amount_cents query param only approximates — so every PINNED checkout (Vibes
+// packs, subscriptions) is minted here first and the canonical URL is the fallback
+// (PowerChat versions without intents, token trouble, transient API errors).
+//
+// Minting needs a token for the HOST account. Streamer-direct hosts have one (their
+// own connection); the site tips account has one only when its PowerChat username is
+// also connected on this site (the owner's dashboard card) — otherwise it stays on
+// the canonical link. `item_name` is the viewer-facing label PowerChat is adding to
+// intents ("1,000 Vibes", "goosely — 1 month sub"); if the running PowerChat rejects
+// the unknown param we retry without it so the fixed amount still lands.
+function _mintHostConn(pcUsername) {
+ try {
+ const conn = db.getPowerchatConnectionByUsername(pcUsername);
+ if (!conn || !conn.access_token || !conn.refresh_token) return null;
+ if (conn.scope && !String(conn.scope).split(/\s+/).includes('checkout:attribute')) return null;
+ return conn;
+ } catch { return null; }
+}
+const _mintWarned = new Map();
+function _mintWarn(key, msg) {
+ const now = Date.now();
+ if ((now - (_mintWarned.get(key) || 0)) < 10 * 60 * 1000) return;
+ _mintWarned.set(key, now);
+ console.warn(`[PowerChat] checkout intent for @${key} not minted — using canonical link: ${msg}`);
+}
+async function mintCheckoutLink(pcUsername, ref, { purpose = null, amountCents = null, itemName = null, returnTo = true } = {}) {
+ const fallback = () => ({ url: tipLinkFor(pcUsername, ref, { purpose, amountCents, returnTo }), minted: false });
+ const conn = _mintHostConn(pcUsername);
+ if (!conn) { _mintWarn(pcUsername, 'no connected account with checkout:attribute for that username'); return fallback(); }
+ const cents = Math.round(Number(amountCents) || 0);
+ const base = { ref: String(ref) };
+ if (returnTo) base.redirect_uri = oauth.redirectUri();
+ if (purpose) base.purpose = String(purpose).slice(0, 64);
+ if (cents >= 50 && cents <= 1000000) base.amount_cents = String(cents);
+ const label = itemName ? String(itemName).replace(/\s+/g, ' ').trim().slice(0, 64) : '';
+ const attempt = async (withLabel) => {
+ const query = withLabel && label ? { ...base, item_name: label } : base;
+ const json = await oauth.apiRequest(conn.user_id, { method: 'GET', path: '/tip-checkout-link', username: pcUsername, query });
+ const url = (json && json.data && json.data.url) || (json && json.url) || null;
+ if (!url || !/^https?:\/\//i.test(String(url))) throw new Error('response carried no url');
+ return String(url);
+ };
+ try {
+ return { url: await attempt(true), minted: true };
+ } catch (e1) {
+ if (label && e1.status === 400) {
+ try { return { url: await attempt(false), minted: true }; } catch (e2) { e1 = e2; }
+ }
+ _mintWarn(pcUsername, `${e1.status || ''} ${e1.message}`.trim());
+ return fallback();
+ }
+}
+
// ── Link builders ────────────────────────────────────────────
// ── Link builders ────────────────────────────────────────────
// Buy Vibes: always the site account (the site must receive the money it mints
// Buy Vibes: always the site account (the site must receive the money it mints
// against). The checkout is PINNED to the package price and categorized "vibes".
// against). The checkout is PINNED to the package price and categorized "vibes".
-function buildPurchaseLink(order) {
+async function buildPurchaseLink(order) {
const site = getSiteAccount();
const site = getSiteAccount();
if (!site) return null;
if (!site) return null;
- return {
- url: tipLinkFor(site.username, `pcorder:${order.id}`, {
- purpose: 'vibes', amountCents: order.amount_cents, returnTo: true,
- }),
- mode: 'site',
- };
+ const bucks = Number(order.bucks) || 0;
+ const link = await mintCheckoutLink(site.username, `pcorder:${order.id}`, {
+ purpose: 'vibes', amountCents: order.amount_cents, returnTo: true,
+ itemName: bucks ? `${bucks.toLocaleString()} Vibes` : 'Vibes',
+ });
+ return { url: link.url, mode: 'site', minted: link.minted };
}
}
// Subscription: streamer-direct when possible, site fallback otherwise.
// Subscription: streamer-direct when possible, site fallback otherwise.
@@ -101,20 +161,27 @@ function buildPurchaseLink(order) {
@@ -101,20 +161,27 @@ function buildPurchaseLink(order) {
// and the streamer receives their normal share of the BASE price). 'auto' = direct when
// and the streamer receives their normal share of the BASE price). 'auto' = direct when
// the streamer has PowerChat connected, else site.
// the streamer has PowerChat connected, else site.
// provider_ref persists the decision: "direct[:renew]" | "site[:fee=<cents>][:renew]".
// provider_ref persists the decision: "direct[:renew]" | "site[:fee=<cents>][:renew]".
-function buildSubscribeLink(order, streamerUserId, { autoRenew = 0, route = 'auto', feeCents = 0 } = {}) {
+async function buildSubscribeLink(order, streamerUserId, { autoRenew = 0, route = 'auto', feeCents = 0 } = {}) {
const suffix = autoRenew ? ':renew' : '';
const suffix = autoRenew ? ':renew' : '';
- const opts = { purpose: 'subscription', amountCents: order.amount_cents, returnTo: true };
+ const streamer = db.getUserById(streamerUserId);
+ const who = streamer ? (streamer.display_name || streamer.username) : 'channel';
+ const opts = {
+ purpose: 'subscription', amountCents: order.amount_cents, returnTo: true,
+ itemName: `${who} — 1 month subscription${autoRenew ? ' (auto-renew)' : ''}`,
+ };
const direct = route !== 'site' ? _checkoutConn(streamerUserId) : null;
const direct = route !== 'site' ? _checkoutConn(streamerUserId) : null;
if (direct) {
if (direct) {
db.updatePaymentOrder(order.id, { provider_ref: `direct${suffix}` });
db.updatePaymentOrder(order.id, { provider_ref: `direct${suffix}` });
- return { url: tipLinkFor(direct.powerchat_username, `pcsub:${order.id}`, opts), mode: 'direct' };
+ const link = await mintCheckoutLink(direct.powerchat_username, `pcsub:${order.id}`, opts);
+ return { url: link.url, mode: 'direct', minted: link.minted };
}
}
if (route === 'direct') return null; // caller asked for the streamer's page and they have none
if (route === 'direct') return null; // caller asked for the streamer's page and they have none
const site = getSiteAccount();
const site = getSiteAccount();
if (!site) return null;
if (!site) return null;
const fee = Math.max(0, Math.round(Number(feeCents) || 0));
const fee = Math.max(0, Math.round(Number(feeCents) || 0));
db.updatePaymentOrder(order.id, { provider_ref: `site${fee ? `:fee=${fee}` : ''}${suffix}` });
db.updatePaymentOrder(order.id, { provider_ref: `site${fee ? `:fee=${fee}` : ''}${suffix}` });
- return { url: tipLinkFor(site.username, `pcsub:${order.id}`, opts), mode: 'site', feeCents: fee };
+ const link = await mintCheckoutLink(site.username, `pcsub:${order.id}`, opts);
+ return { url: link.url, mode: 'site', feeCents: fee, minted: link.minted };
}
}
/** Which PowerChat subscription routes a streamer currently supports (for the UI). */
/** Which PowerChat subscription routes a streamer currently supports (for the UI). */
@@ -166,7 +233,8 @@ function handleAttributedDonation(receivingUserId, data) {
@@ -166,7 +233,8 @@ function handleAttributedDonation(receivingUserId, data) {
if (!order || order.kind !== 'bucks') return true; // ours but unusable — never double-handle
if (!order || order.kind !== 'bucks') return true; // ours but unusable — never double-handle
if (order.status === 'credited') return true; // at-least-once redelivery
if (order.status === 'credited') return true; // at-least-once redelivery
if (usdCents < 1) return true;
if (usdCents < 1) return true;
- // The tip page can't enforce an amount — credit what was ACTUALLY paid.
+ // Credit what was ACTUALLY paid (intents pin the amount; the canonical
+ // fallback link can't) — never the package price on faith.
const pay = require('../monetization/payments');
const pay = require('../monetization/payments');
const bucks = pay.bucksForUsd(usdCents / 100);
const bucks = pay.bucksForUsd(usdCents / 100);
db.updatePaymentOrder(order.id, { amount_cents: usdCents, bucks, status: 'paid' });
db.updatePaymentOrder(order.id, { amount_cents: usdCents, bucks, status: 'paid' });
@@ -181,7 +249,8 @@ function handleAttributedDonation(receivingUserId, data) {
@@ -181,7 +249,8 @@ function handleAttributedDonation(receivingUserId, data) {
const order = db.getPaymentOrderById(Number(m[1]));
const order = db.getPaymentOrderById(Number(m[1]));
if (!order || order.kind !== 'subscription' || !order.streamer_id) return true;
if (!order || order.kind !== 'subscription' || !order.streamer_id) return true;
if (order.status === 'credited') return true;
if (order.status === 'credited') return true;
- // Underpaid (tip page can't enforce amounts): treat the tip as a plain
+ // Underpaid (canonical-link fallback can't enforce amounts; minted intents
+ // can): treat the tip as a plain
// donation to the order's streamer rather than silently activating a
// donation to the order's streamer rather than silently activating a
// discounted sub. Direct mode = the receiving account IS the streamer, so
// discounted sub. Direct mode = the receiving account IS the streamer, so
// falling through does the right thing; site mode routes explicitly.
// falling through does the right thing; site mode routes explicitly.
@@ -244,7 +313,7 @@ function _notify(userId, title, message) {
@@ -244,7 +313,7 @@ function _notify(userId, title, message) {
}
}
module.exports = {
module.exports = {
- isAvailable, getSiteAccount, tipLinkFor, subscribeRoutes,
+ isAvailable, getSiteAccount, tipLinkFor, mintCheckoutLink, subscribeRoutes,
buildPurchaseLink, buildSubscribeLink, buildDonateLink,
buildPurchaseLink, buildSubscribeLink, buildDonateLink,
handleAttributedDonation,
handleAttributedDonation,
};
};
@@ -61,9 +61,9 @@ router.post('/bucks/checkout', requireAuth, async (req, res) => {
@@ -61,9 +61,9 @@ router.post('/bucks/checkout', requireAuth, async (req, res) => {
}
}
if (provider === 'powerchat') {
if (provider === 'powerchat') {
// Tip the site's PowerChat account; the checkout is PINNED to the package
// Tip the site's PowerChat account; the checkout is PINNED to the package
- // price (app_amount_cents locks the tip page's amount picker) and the
+ // price (a server-minted intent locks the tip page's amount) and the
// donation.completed webhook credits the buyer once it confirms.
// donation.completed webhook credits the buyer once it confirms.
- const link = require('../integrations/powerchat-checkout').buildPurchaseLink(order);
+ const link = await require('../integrations/powerchat-checkout').buildPurchaseLink(order);
if (!link) { db.updatePaymentOrder(order.id, { status: 'failed' }); return res.status(400).json({ error: 'PowerChat purchases are not available right now' }); }
if (!link) { db.updatePaymentOrder(order.id, { status: 'failed' }); return res.status(400).json({ error: 'PowerChat purchases are not available right now' }); }
return res.json({
return res.json({
url: link.url, powerchat: true, amountUsd,
url: link.url, powerchat: true, amountUsd,
@@ -148,7 +148,7 @@ router.post('/subscribe', requireAuth, async (req, res) => {
@@ -148,7 +148,7 @@ router.post('/subscribe', requireAuth, async (req, res) => {
user_id: req.user.id, provider: 'powerchat', kind: 'subscription',
user_id: req.user.id, provider: 'powerchat', kind: 'subscription',
amount_cents: totalCents, streamer_id: streamer.id,
amount_cents: totalCents, streamer_id: streamer.id,
});
});
- const link = checkout.buildSubscribeLink(order, streamer.id, { autoRenew, route: wantSite ? 'site' : 'direct', feeCents });
+ const link = await checkout.buildSubscribeLink(order, streamer.id, { autoRenew, route: wantSite ? 'site' : 'direct', feeCents });
if (!link) { db.updatePaymentOrder(order.id, { status: 'failed' }); return res.status(400).json({ error: 'PowerChat payments are not available right now' }); }
if (!link) { db.updatePaymentOrder(order.id, { status: 'failed' }); return res.status(400).json({ error: 'PowerChat payments are not available right now' }); }
const totalUsd = totalCents / 100;
const totalUsd = totalCents / 100;
return res.json({
return res.json({