openvibers/openvibe.live Commits

PowerChat: mint fixed-price checkout intents for Vibes packs + subs; Home hero: unified stat board with fuller Economy row

OP OpenVibers committed 27 Aug 2026 · cc4d7c34d8 · 7 files, +172 −57

- powerchat-checkout: pinned checkouts (pcorder / pcsub) now call
  GET /streamers/:u/tip-checkout-link (amount_cents, purpose, ref,
  redirect_uri, item_name) to mint a single-use server-held intent, so the
  amount is enforced by PowerChat rather than a query param; canonical
  app_amount_cents link stays as the fallback when the host account has no
  token / the running PowerChat lacks intents (retry once without item_name
  on a 400). Site-account minting works when the tips username is also
  connected here.
- Home stats: pointsSpent, redemptions, supporters, vibesBought,
  goalsActive/goalsReached + rolling deltas.
- Hero: one full-width stat board (kicker column + shared chip grid per row)
  replacing the mismatched centered islands; Right Now always shows Live/
  Watching/Active/New Visitors + wider sparkline; Economy grows to 8 chips.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017AyeSFu7qdLdR64Zv99QNm

Changes

ddiff --git a/public/css/style.css b/public/css/style.css
iindex a2ff7fc4..fbc0e956 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -2184,22 +2184,38 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
/* Stats bar — themed clusters (Right now / Community / Economy / Archive) instead of
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 {
-- 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; }
}
ddiff --git a/public/index.html b/public/index.html
iindex eecae864..c3e43da8 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -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>
ddiff --git a/public/js/app.js b/public/js/app.js
iindex f4b28e37..478ffe13 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -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));
ddiff --git a/public/popout-chat.html b/public/popout-chat.html
iindex 7a192f83..1f024fb7 100644
---- a/public/popout-chat.html
++++ b/public/popout-chat.html
@@@ -7,7 +7,7 @@
<!-- Keep these in step with index.html — the popout renders the same chat
components (cosmetics.css positions particle effects; without it they sit
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/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>
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 3b9ccbd5..f4fddb0e 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -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'),
},
};
ddiff --git a/server/integrations/powerchat-checkout.js b/server/integrations/powerchat-checkout.js
iindex 38e3defc..9e8f5faf 100644
---- a/server/integrations/powerchat-checkout.js
++++ b/server/integrations/powerchat-checkout.js
@@@ -78,18 +78,78 @@ function tipLinkFor(pcUsername, ref, { purpose = null, amountCents = null, retur
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 ────────────────────────────────────────────
// 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 none
const 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-handle
if (order.status === 'credited') return true; // at-least-once redelivery
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 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,
};
ddiff --git a/server/monetization/payments-routes.js b/server/monetization/payments-routes.js
iindex 99d60cdc..19822d4a 100644
---- a/server/monetization/payments-routes.js
++++ b/server/monetization/payments-routes.js
@@@ -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({