openvibers/openvibe.live Commits

Promo cashout ledger (claims, referrals, owner console), home page effects layer

OP OpenVibers committed 11 Sept 2026 · 94aa60bf8d · 9 files, +607 −23

- server/promo/claims.js + routes: switch claims (one per account, Zelle/PayPal/crypto/stripe
  rail), referral rows minted on approval (VIP rate for RS_PROMO_VIP_USER), payout details →
  cashout requested, owner approve/paid/reject with amounts, balances + public totals;
  /api/promo/robotstreamer now served by the promo router
- claim modal: in-site claim form + "Your payouts" balance/ledger + referral link (?ref=);
  owner console inside the modal for admins; DM flow removed
- public/js/home-fx.js + css: hero sparks canvas, cursor spotlight, magnetic CTAs, headline
  sheen, scroll reveal, stat count-up, stream-card tilt (reduced-motion safe)
- test/promo-claims.test.js

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

Changes

ddiff --git a/public/css/home-fx.css b/public/css/home-fx.css
nnew file mode 100644
iindex 00000000..cb2a1375
---- /dev/null
++++ b/public/css/home-fx.css
@@@ -0,0 +1,45 @@
++/* ═══════════════════════════════════════════════════════════════
++ HOME FX — decorative effects layer for the home page (public/js/home-fx.js)
++ Additive only; everything degrades to the plain page without JS or under
++ prefers-reduced-motion.
++ ═══════════════════════════════════════════════════════════════ */
++#page-home .hero { position: relative; }
++.hfx-sparks { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; z-index: 0; mix-blend-mode: screen; opacity: 0.9; }
++.hfx-spot {
++ position: absolute; inset: 0; pointer-events: none; z-index: 0; opacity: 0; transition: opacity 0.4s;
++ background: radial-gradient(360px circle at var(--x, 50%) var(--y, 30%), color-mix(in srgb, var(--accent) 22%, transparent), transparent 70%);
++}
++#page-home .hero-content { position: relative; z-index: 2; }
++#page-home .hero-cta .btn { transition: transform 0.18s cubic-bezier(.2,1.4,.3,1), background 0.2s, box-shadow 0.2s; will-change: transform; }
++#page-home .hero-cta .btn-primary { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 50%, transparent); animation: hfxPulse 2.6s ease-out infinite; }
++@keyframes hfxPulse { 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent); } 70% { box-shadow: 0 0 0 14px color-mix(in srgb, var(--accent) 0%, transparent); } 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent); } }
++
++/* Headline: slow sheen sweeping across the gradient text */
++.hfx-shimmer { position: relative; }
++.hfx-shimmer::after {
++ content: ''; position: absolute; inset: -6px -20px; pointer-events: none;
++ background: linear-gradient(115deg, transparent 40%, rgba(255,255,255,0.22) 50%, transparent 60%);
++ background-size: 260% 100%; animation: hfxSheen 5s ease-in-out infinite;
++ mix-blend-mode: screen; -webkit-mask: linear-gradient(#000, #000); mask: linear-gradient(#000, #000);
++}
++@keyframes hfxSheen { 0% { background-position: 130% 0; } 60%, 100% { background-position: -130% 0; } }
++.hfx-glow { text-shadow: 0 0 22px color-mix(in srgb, var(--accent) 55%, transparent), 0 0 44px color-mix(in srgb, var(--accent) 25%, transparent); animation: hfxGlow 3s ease-in-out infinite; }
++@keyframes hfxGlow { 0%,100% { filter: brightness(1); } 50% { filter: brightness(1.18); } }
++
++/* Scroll reveal */
++.hfx-reveal { opacity: 0; transform: translateY(18px) scale(0.985); transition: opacity 0.6s ease var(--d, 0ms), transform 0.7s cubic-bezier(.2,1.2,.3,1) var(--d, 0ms); }
++.hfx-reveal.is-in { opacity: 1; transform: none; }
++
++/* Stream card tilt */
++#page-home .stream-card { transition: transform 0.15s ease-out, box-shadow 0.25s; }
++#page-home .stream-card.hfx-tilting { box-shadow: 0 24px 50px rgba(0,0,0,0.45), 0 0 0 1px color-mix(in srgb, var(--accent) 40%, transparent); z-index: 2; }
++
++/* Live section header dot + Star section shimmer accent */
++#page-home .section-header h2 .live-dot { animation: hfxLive 1.4s ease-in-out infinite; }
++@keyframes hfxLive { 0%,100% { transform: scale(1); filter: drop-shadow(0 0 0 rgba(231,76,60,0)); } 50% { transform: scale(1.25); filter: drop-shadow(0 0 8px rgba(231,76,60,0.9)); } }
++
++@media (prefers-reduced-motion: reduce) {
++ .hfx-sparks, .hfx-spot { display: none; }
++ .hfx-shimmer::after, .hfx-glow, #page-home .hero-cta .btn-primary, #page-home .section-header h2 .live-dot { animation: none !important; }
++ .hfx-reveal { opacity: 1; transform: none; transition: none; }
++}
ddiff --git a/public/css/rs-promo.css b/public/css/rs-promo.css
iindex 3adc46be..2fdd948c 100644
---- a/public/css/rs-promo.css
++++ b/public/css/rs-promo.css
@@@ -638,6 +638,41 @@ body.rs-ticker-on .rs-modal, body.rs-ticker-on .rs-takeover { --rs-ticker-h: 0px
.rs-modal-fine i { color: #f472b6; animation: rsHeart 1.4s ease-in-out infinite; }
@keyframes rsHeart { 0%,100% { transform: scale(1); } 30% { transform: scale(1.3); } }
++/* ── Cashout ledger inside the claim modal ── */
++.rs-cashout { margin: 0 0 16px; padding: 12px; border-radius: 14px; background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.12); }
++.rs-cashout-loading { color: #94a3b8; font-size: 0.85rem; }
++.rs-cashout-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; font-weight: 900; font-size: 0.95rem; margin-bottom: 8px; }
++.rs-cashout-head small { color: #94a3b8; font-weight: 500; font-size: 0.74rem; }
++.rs-cashout-head i { color: #fde68a; }
++.rs-admin-head { margin-top: 14px; padding-top: 10px; border-top: 1px dashed rgba(255,255,255,0.18); }
++.rs-balance { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 10px; }
++.rs-balance span { display: grid; justify-items: center; padding: 8px 6px; border-radius: 10px; background: rgba(0,0,0,0.25); border: 1px solid rgba(255,255,255,0.08); }
++.rs-balance b { font-size: 1.15rem; color: #fde68a; }
++.rs-balance small { font-size: 0.68rem; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.08em; }
++.rs-claims { display: grid; gap: 8px; margin-bottom: 10px; }
++.rs-claim { display: grid; grid-template-columns: 1fr auto; gap: 6px 10px; align-items: center; padding: 9px 10px; border-radius: 10px; background: rgba(0,0,0,0.22); border: 1px solid rgba(255,255,255,0.1); border-left: 3px solid #94a3b8; }
++.rs-claim.is-approved, .rs-claim.is-cashout_requested { border-left-color: #a78bfa; } .rs-claim.is-paid { border-left-color: #22c55e; } .rs-claim.is-rejected { border-left-color: #ef4444; opacity: 0.7; } .rs-claim.is-pending { border-left-color: #fbbf24; }
++.rs-claim-main b { display: block; font-size: 0.86rem; } .rs-claim-main small { display: block; color: #94a3b8; font-size: 0.72rem; line-height: 1.4; }
++.rs-claim-side { display: grid; justify-items: end; gap: 3px; }
++.rs-claim-side .rs-money { font-size: 1rem; }
++.rs-status { font-size: 0.64rem; font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase; padding: 2px 7px; border-radius: 999px; background: rgba(255,255,255,0.08); color: #e2e8f0; }
++.rs-status-paid { background: rgba(34,197,94,0.25); color: #bbf7d0; } .rs-status-approved, .rs-status-cashout_requested { background: rgba(139,92,246,0.3); color: #ddd6fe; } .rs-status-pending { background: rgba(251,191,36,0.2); color: #fde68a; } .rs-status-rejected { background: rgba(239,68,68,0.25); color: #fecaca; }
++.rs-payout-form, .rs-admin-actions { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 6px; }
++.rs-payout-form select, .rs-payout-form input, .rs-claim-form input, .rs-admin-amount { padding: 8px 10px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.18); background: rgba(0,0,0,0.35); color: #fff; font: inherit; font-size: 0.86rem; }
++.rs-payout-form input { flex: 1; min-width: 160px; }
++.rs-payout-form .rs-btn, .rs-admin-actions .rs-btn { padding: 8px 12px; font-size: 0.82rem; }
++.rs-admin-amount { width: 76px; text-align: right; }
++.rs-claim-form { display: grid; gap: 8px; margin-bottom: 10px; }
++.rs-claim-form-head { font-weight: 800; font-size: 0.9rem; } .rs-claim-form-head small { color: #94a3b8; font-weight: 500; font-size: 0.72rem; margin-left: 6px; } .rs-claim-form-head i { color: #fde68a; }
++.rs-claim-form label { display: grid; gap: 3px; font-size: 0.76rem; color: #cbd5e1; }
++.rs-claim-form label span b { color: #fff; }
++.rs-claim-form .rs-btn { width: 100%; }
++.rs-share { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 0.78rem; color: #cbd5e1; padding: 8px 10px; border-radius: 10px; background: rgba(139,92,246,0.1); border: 1px dashed rgba(139,92,246,0.4); margin-top: 8px; }
++.rs-share code { color: #fff; background: rgba(0,0,0,0.35); padding: 2px 6px; border-radius: 6px; font-size: 0.74rem; word-break: break-all; }
++.rs-share-copy { position: static; }
++.rs-share .rs-btn { padding: 6px 10px; font-size: 0.78rem; }
++.rs-vip .rs-share { color: #dbeafe; background: rgba(255,255,255,0.08); border-color: rgba(255,179,209,0.5); }
++
/* Confetti canvas (burst on claim open) */
.rs-confetti { position: absolute; inset: 0; z-index: 0; pointer-events: none; width: 100%; height: 100%; }
ddiff --git a/public/index.html b/public/index.html
iindex dde9070e..82ea8af6 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -67,7 +67,8 @@
<link rel="stylesheet" href="/css/broadcast.css?v=11">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
<link rel="stylesheet" href="/css/messenger.css?v=1">
-- <link rel="stylesheet" href="/css/rs-promo.css?v=6">
++ <link rel="stylesheet" href="/css/rs-promo.css?v=7">
++ <link rel="stylesheet" href="/css/home-fx.css?v=1">
<link rel="stylesheet" href="/css/i18n-star.css?v=2">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
@@@ -5125,7 +5126,8 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/pastes.js?v=15" defer></script>
<script src="/js/draggable-fab.js?v=2" defer></script>
<script src="/js/messenger.js?v=2" defer></script>
--<script src="/js/rs-promo.js?v=8" defer></script>
++<script src="/js/rs-promo.js?v=9" defer></script>
++<script src="/js/home-fx.js?v=1" defer></script>
<!-- ── OpenVibe shared libs (notifications, account sync, themes) ── -->
<script src="/shared/theme-loader.js" defer></script>
ddiff --git a/public/js/home-fx.js b/public/js/home-fx.js
nnew file mode 100644
iindex 00000000..1cff4811
---- /dev/null
++++ b/public/js/home-fx.js
@@@ -0,0 +1,155 @@
++/**
++ * home-fx.js — home page effects layer.
++ *
++ * Purely decorative and fully additive: nothing here is required for the page to work.
++ * - hero sparks: a lightweight canvas of drifting embers / orbs behind the hero
++ * - cursor spotlight: a soft radial glow that follows the pointer over the hero
++ * - magnetic buttons: the hero CTA buttons lean toward the cursor
++ * - scroll reveal: section headers, cards and grids slide/fade in as they enter the viewport
++ * - count-up: hero stat numbers tick up from 0 when first painted
++ * - card tilt: stream cards tilt slightly toward the pointer
++ * Everything is disabled under prefers-reduced-motion, paused when the tab is hidden, and
++ * skips the canvas on coarse-pointer / narrow screens to keep phones cool.
++ */
++(function () {
++ 'use strict';
++ const REDUCED = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
++ const COARSE = window.matchMedia && window.matchMedia('(pointer: coarse)').matches;
++ if (REDUCED) return;
++
++ const onHome = () => { const p = document.getElementById('page-home'); return !!(p && p.classList.contains('active')); };
++
++ // ── Hero sparks canvas ─────────────────────────────────────
++ let sparksStop = null;
++ function startSparks() {
++ if (sparksStop || COARSE || window.innerWidth < 720) return;
++ const hero = document.querySelector('#page-home .hero');
++ if (!hero) return;
++ const canvas = document.createElement('canvas');
++ canvas.className = 'hfx-sparks';
++ canvas.setAttribute('aria-hidden', 'true');
++ hero.insertBefore(canvas, hero.firstChild);
++ const ctx = canvas.getContext('2d');
++ let w = 0, h = 0, raf = 0, running = true, last = 0;
++ const parts = [];
++ const COLORS = ['139,92,246', '167,139,250', '125,211,252', '251,191,36', '244,114,182'];
++ function resize() { const r = hero.getBoundingClientRect(); w = canvas.width = Math.max(1, Math.round(r.width)); h = canvas.height = Math.max(1, Math.round(r.height)); }
++ function spawn() { return { x: Math.random() * w, y: h + 10 + Math.random() * 40, r: 1 + Math.random() * 2.4, vy: 12 + Math.random() * 26, vx: (Math.random() - 0.5) * 10, sw: Math.random() * Math.PI * 2, c: COLORS[(Math.random() * COLORS.length) | 0], a: 0.25 + Math.random() * 0.5, life: 0 }; }
++ resize();
++ const N = Math.min(70, Math.max(30, Math.round(w / 18)));
++ for (let i = 0; i < N; i++) { const p = spawn(); p.y = Math.random() * h; parts.push(p); }
++ function tick(now) {
++ if (!running) return;
++ const dt = Math.min(0.05, (now - (last || now)) / 1000); last = now;
++ ctx.clearRect(0, 0, w, h);
++ for (let i = 0; i < parts.length; i++) {
++ const p = parts[i];
++ p.sw += dt * 1.2; p.x += (p.vx + Math.sin(p.sw) * 8) * dt; p.y -= p.vy * dt;
++ if (p.y < -10 || p.x < -10 || p.x > w + 10) parts[i] = spawn();
++ const fade = Math.min(1, p.y / 80) * Math.min(1, (h - p.y) / 120);
++ ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
++ ctx.fillStyle = `rgba(${p.c},${(p.a * Math.max(0, fade)).toFixed(3)})`;
++ ctx.shadowBlur = 8; ctx.shadowColor = `rgba(${p.c},0.8)`;
++ ctx.fill();
++ }
++ ctx.shadowBlur = 0;
++ raf = requestAnimationFrame(tick);
++ }
++ const onVis = () => { if (document.hidden) { running = false; cancelAnimationFrame(raf); } else if (!running) { running = true; last = 0; raf = requestAnimationFrame(tick); } };
++ window.addEventListener('resize', resize);
++ document.addEventListener('visibilitychange', onVis);
++ raf = requestAnimationFrame(tick);
++ sparksStop = () => { running = false; cancelAnimationFrame(raf); window.removeEventListener('resize', resize); document.removeEventListener('visibilitychange', onVis); canvas.remove(); sparksStop = null; };
++ }
++
++ // ── Cursor spotlight + magnetic buttons ─────────────────────
++ function attachHeroPointer() {
++ const hero = document.querySelector('#page-home .hero');
++ if (!hero || hero.dataset.hfx) return;
++ hero.dataset.hfx = '1';
++ const spot = document.createElement('div'); spot.className = 'hfx-spot'; spot.setAttribute('aria-hidden', 'true'); hero.appendChild(spot);
++ let raf = 0;
++ hero.addEventListener('pointermove', (e) => {
++ if (COARSE) return;
++ const r = hero.getBoundingClientRect();
++ const x = e.clientX - r.left, y = e.clientY - r.top;
++ cancelAnimationFrame(raf);
++ raf = requestAnimationFrame(() => {
++ spot.style.setProperty('--x', `${x}px`); spot.style.setProperty('--y', `${y}px`); spot.style.opacity = '1';
++ hero.querySelectorAll('.hero-cta .btn').forEach(btn => {
++ const b = btn.getBoundingClientRect();
++ const dx = e.clientX - (b.left + b.width / 2), dy = e.clientY - (b.top + b.height / 2);
++ const dist = Math.hypot(dx, dy);
++ if (dist < 140) { const k = (1 - dist / 140) * 10; btn.style.transform = `translate(${(dx / dist) * k}px, ${(dy / dist) * k}px)`; }
++ else btn.style.transform = '';
++ });
++ });
++ });
++ hero.addEventListener('pointerleave', () => { spot.style.opacity = '0'; hero.querySelectorAll('.hero-cta .btn').forEach(btn => { btn.style.transform = ''; }); });
++ }
++
++ // ── Scroll reveal ──────────────────────────────────────────
++ let io = null;
++ function attachReveal() {
++ if (!('IntersectionObserver' in window)) return;
++ if (!io) io = new IntersectionObserver((entries) => { for (const en of entries) if (en.isIntersecting) { en.target.classList.add('is-in'); io.unobserve(en.target); } }, { rootMargin: '0px 0px -8% 0px', threshold: 0.08 });
++ const sel = '#page-home .section-header, #page-home .stream-card, #page-home .home-cta-banner, #page-home .home-star-section, #page-home .rs-hero-mount, #page-home .pulse-grid > *, #page-home .moments-row > *, #page-home .home-digest';
++ document.querySelectorAll(sel).forEach((el, i) => { if (el.classList.contains('hfx-reveal')) return; el.classList.add('hfx-reveal'); el.style.setProperty('--d', `${(i % 6) * 60}ms`); io.observe(el); });
++ }
++
++ // ── Count-up on the hero stats ─────────────────────────────
++ function countUp(el) {
++ const raw = String(el.textContent || '').trim();
++ const m = raw.match(/^([\d,]+)(\.\d+)?([kKmM]?)$/);
++ if (!m || el.dataset.hfxCounted) return;
++ el.dataset.hfxCounted = '1';
++ const target = parseFloat(m[1].replace(/,/g, '') + (m[2] || ''));
++ if (!Number.isFinite(target) || target === 0) return;
++ const suffix = m[3] || '', decimals = m[2] ? m[2].length - 1 : 0, hasComma = m[1].includes(',');
++ const t0 = performance.now(), dur = 900 + Math.min(900, target);
++ const ease = (t) => 1 - Math.pow(1 - t, 3);
++ const fmt = (n) => { const s = n.toFixed(decimals); return (hasComma ? Number(s).toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals }) : s) + suffix; };
++ (function frame(now) { const t = Math.min(1, (now - t0) / dur); el.textContent = fmt(ease(t) * target); if (t < 1) requestAnimationFrame(frame); else el.textContent = raw; })(t0);
++ }
++ function attachCountUp() {
++ const stats = document.getElementById('hero-stats');
++ if (!stats) return;
++ const run = () => stats.querySelectorAll('b, strong, .hero-stat-value, .hero-stat-num').forEach(countUp);
++ run();
++ new MutationObserver(() => run()).observe(stats, { childList: true, subtree: true });
++ }
++
++ // ── Stream card tilt ───────────────────────────────────────
++ function attachTilt() {
++ if (COARSE) return;
++ document.addEventListener('pointermove', (e) => {
++ const card = e.target.closest && e.target.closest('#page-home .stream-card');
++ if (!card) return;
++ const r = card.getBoundingClientRect();
++ const px = (e.clientX - r.left) / r.width - 0.5, py = (e.clientY - r.top) / r.height - 0.5;
++ card.style.transform = `perspective(700px) rotateY(${(px * 6).toFixed(2)}deg) rotateX(${(-py * 6).toFixed(2)}deg) translateY(-3px)`;
++ card.classList.add('hfx-tilting');
++ });
++ document.addEventListener('pointerout', (e) => { const card = e.target.closest && e.target.closest('#page-home .stream-card'); if (card && !card.contains(e.relatedTarget)) { card.style.transform = ''; card.classList.remove('hfx-tilting'); } });
++ }
++
++ // ── Headline shimmer ───────────────────────────────────────
++ function attachHeadline() {
++ const h1 = document.querySelector('#page-home .hero h1');
++ if (h1) h1.classList.add('hfx-shimmer');
++ const rot = document.getElementById('hero-rotate');
++ if (rot) rot.classList.add('hfx-glow');
++ }
++
++ function boot() {
++ attachHeroPointer(); attachHeadline(); attachCountUp(); attachTilt();
++ const apply = () => { if (onHome()) { startSparks(); attachReveal(); } else if (sparksStop) sparksStop(); };
++ apply();
++ const page = document.getElementById('page-home');
++ if (page && 'MutationObserver' in window) new MutationObserver(apply).observe(page, { attributes: true, attributeFilter: ['class'] });
++ // New cards arrive as sections load — reveal them too.
++ const container = page && page.querySelector('.container');
++ if (container) new MutationObserver(() => { if (onHome()) attachReveal(); }).observe(container, { childList: true, subtree: true });
++ }
++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();
++})();
ddiff --git a/public/js/rs-promo.js b/public/js/rs-promo.js
iindex 5bb721ae..11b8f82f 100644
---- a/public/js/rs-promo.js
++++ b/public/js/rs-promo.js
@@@ -21,7 +21,7 @@
(function () {
'use strict';
-- const PROMO_VERSION = 'rs-desperate-v5'; // bump to re-show the takeover to everyone
++ const PROMO_VERSION = 'rs-desperate-v6'; // bump to re-show the takeover to everyone
const SEEN_KEY = 'ov_rs_promo_seen';
const TICKER_KEY = 'ov_rs_ticker_hidden'; // sessionStorage — comes back next visit
const REDUCED = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
@@@ -44,6 +44,13 @@
const isLoggedIn = () => !!(window.currentUser);
const say = (msg, type) => { if (typeof window.toast === 'function') window.toast(msg, type || 'info'); };
++ // ?ref=<username> on any URL = a referral link. Remembered so the claim form is prefilled.
++ (function captureRef() {
++ try { const r = new URLSearchParams(location.search).get('ref'); if (r && /^[A-Za-z0-9_.-]{2,40}$/.test(r)) localStorage.setItem('ov_rs_ref', r); } catch { /* */ }
++ })();
++ const savedRef = () => { try { return localStorage.getItem('ov_rs_ref') || ''; } catch { return ''; } };
++ const refLink = (username) => `${location.origin}/?ref=${encodeURIComponent(username)}`;
++
// Pages where a full-screen splash would be hostile (mid-broadcast, overlays, popouts).
function takeoverAllowedHere() {
const p = location.pathname;
@@@ -302,6 +309,7 @@
<div class="rs-card-action">
<button class="rs-btn rs-btn-gold" type="button" onclick="rsPromoOpenClaim()"><i class="fa-solid fa-sack-dollar"></i> Claim ${money()}</button>
<button class="rs-card-replay" type="button" onclick="rsPromoReplay()">replay the splash ✨</button>
++ ${isLoggedIn() ? `<button class="rs-card-replay" type="button" onclick="rsPromoOpenClaim()"><i class="fa-solid fa-wallet"></i> my payouts &amp; referral link</button>` : ''}
</div>
</div>
</div>`;
@@@ -334,6 +342,7 @@
<h3 class="rs-vip-title">${me ? `That's you, ${name}. Go get 'em.` : `${name} gets <span class="rs-money">$${Number(v.referral)}</span> per RobotStreamer streamer he brings over`}</h3>
<p class="rs-vip-sub">${me ? `<span class="rs-money">$${Number(v.referral)}</span> every time a RobotStreamer streamer you brought in claims the switch bonus and names you. Everyone else gets $${cfg.referral}. You're the recruiter. The rock is yours.` : `He's going to be godlike at this, so his rate is double everyone else's ($${cfg.referral} for the rest of us). Converts name him in the claim message, he gets paid. Simple.`}</p>
<div class="rs-vip-chips"><span class="rs-chip rs-chip-cash"><i class="fa-solid fa-money-bill-wave"></i> $${Number(v.referral)} per conversion</span><span class="rs-chip rs-chip-build"><i class="fa-solid fa-people-arrows"></i> everyone else: $${cfg.referral}</span>${me ? '<span class="rs-chip rs-chip-oss"><i class="fa-solid fa-crown"></i> VIP recruiter</span>' : ''}</div>
++ ${me ? `<div class="rs-share"><i class="fa-solid fa-link"></i> Your link: <code>${esc(refLink(v.username))}</code> <button class="rs-btn rs-btn-gold" type="button" onclick="rsPromoOpenClaim()"><i class="fa-solid fa-wallet"></i> my payouts</button></div>` : ''}
</div>`;
mount.appendChild(el);
}
@@@ -454,7 +463,7 @@
<ol class="rs-steps">
<li class="rs-step ${loggedIn ? 'done' : ''}"><span class="rs-step-num"></span><div><strong>Sign in — it's free</strong><span>One OpenVibe account works across the whole network. ${loggedIn ? 'You\'re already in. ✔' : 'Takes about ten seconds.'}</span></div></li>
<li class="rs-step"><span class="rs-step-num"></span><div><strong>Go exclusive — your home is here now</strong><span>Set up on <a href="/broadcast" onclick="return handleLinkClick(event, '/broadcast')">Go Live</a>: browser (WebRTC), OBS/RTMP, or your robot's existing pipeline. Then flip on <b>restream</b> and mirror your OpenVibe stream to Twitch, YouTube, Kick, any RTMP — even RobotStreamer — while your chat, emotes, sound commands and robot controls all live here.</span>${restreamHtml(true)}</div></li>
-- <li class="rs-step"><span class="rs-step-num"></span><div><strong>Message me</strong><span>Send your RobotStreamer username plus your <b class="rs-method-word">Zelle</b> info — in-site DM to <b>@${owner}</b> or on Discord. Copy the message below to make it painless.</span></div></li>
++ <li class="rs-step"><span class="rs-step-num"></span><div><strong>File the claim right here</strong><span>Your RobotStreamer username, where to send the money (<b class="rs-method-word">Zelle</b>, PayPal or crypto), and who referred you if anyone. It lands in the cashout ledger — no DMs, no chasing. Prefer to talk? <b>@${owner}</b> is on Discord.</span></div></li>
<li class="rs-step"><span class="rs-step-num"></span><div><strong>Get paid — and get catered to</strong><span>I send the <b>${money()}</b> by hand. Then tell me what your stream needs: converters get features built on request.</span></div></li>
</ol>
<div class="rs-msg"><span class="rs-msg-text">${esc(claimMessage('zelle'))}</span><button class="rs-msg-copy" type="button" title="Copy message" aria-label="Copy message"><i class="fa-regular fa-copy"></i></button></div>
@@@ -462,12 +471,11 @@
<div class="rs-trust-row"><i class="fa-brands fa-github"></i><div><b>Why trust this over RobotStreamer?</b> Because you can read it. Every line of OpenVibe.Live is ${ghLink('public on GitHub')} — what it does with your stream, your chat and your data is in the open. RobotStreamer is closed source; you are trusting a black box. An open alternative is the only kind that earns trust.</div></div>
<div class="rs-trust-row"><i class="fa-solid fa-people-arrows"></i><div><b>Already on OpenVibe?</b> Bring a RobotStreamer streamer over — when they claim, name you in the message and <b>you get ${referral()}</b> as well${cfg.vip ? ` (${esc(cfg.vip.display_name || cfg.vip.username)} gets $${Number(cfg.vip.referral)} — he's the VIP recruiter)` : ''}. Yes, I'm paying our own users to phase that site out.</div></div>
</div>
++ <div class="rs-cashout" id="rs-cashout">${loggedIn ? _spinner('Loading your payouts…') : ''}</div>
<div class="rs-modal-actions">
-- ${loggedIn
-- ? `<button class="rs-btn rs-btn-gold" type="button" data-act="dm"><i class="fa-solid fa-paper-plane"></i> DM @${owner} to claim</button>`
-- : `<a class="rs-btn rs-btn-gold" href="/api/auth/sso/login"><i class="fa-solid fa-network-wired"></i> Sign in &amp; claim</a>`}
++ ${loggedIn ? '' : `<a class="rs-btn rs-btn-gold" href="/api/auth/sso/login"><i class="fa-solid fa-network-wired"></i> Sign in &amp; file your claim</a>`}
<div class="rs-row">
-- ${discord ? `<a class="rs-btn rs-btn-ghost" href="${discord}" target="_blank" rel="noopener noreferrer"><i class="fa-brands fa-discord"></i> Claim on Discord</a>` : ''}
++ ${discord ? `<a class="rs-btn rs-btn-ghost" href="${discord}" target="_blank" rel="noopener noreferrer"><i class="fa-brands fa-discord"></i> Questions? Discord</a>` : ''}
<a class="rs-btn rs-btn-ghost" href="/broadcast" onclick="return handleLinkClick(event, '/broadcast')" data-act="golive"><i class="fa-solid fa-tower-broadcast"></i> Set up my stream</a>
</div>
</div>
@@@ -476,10 +484,77 @@
modalEl.addEventListener('click', onModalClick);
document.addEventListener('keydown', onModalKey);
document.body.appendChild(modalEl);
++ if (loggedIn) loadCashout().catch(() => { const c = document.getElementById('rs-cashout'); if (c) c.innerHTML = '<p class="rs-modal-fine">Could not load your payouts right now.</p>'; });
document.body.style.overflow = 'hidden';
if (!REDUCED) confettiBurst();
setTimeout(() => modalEl?.querySelector('.rs-modal-actions .rs-btn')?.focus({ preventScroll: true }), 400);
}
++ const _spinner = (t) => `<div class="rs-cashout-loading"><i class="fa-solid fa-circle-notch fa-spin"></i> ${esc(t)}</div>`;
++ const STATUS_LABEL = { pending: 'pending review', approved: 'approved — add payout details', cashout_requested: 'cashout requested', paid: 'paid', rejected: 'not approved' };
++ async function loadCashout() {
++ const box = document.getElementById('rs-cashout');
++ if (!box) return;
++ const mine = await api('/promo/claims/mine');
++ const me = window.currentUser;
++ const isAdmin = me && (me.role === 'admin');
++ const method = modalEl?.querySelector('.rs-toggle')?.dataset.method || 'zelle';
++ const claimsHtml = mine.claims.length ? `<div class="rs-claims">${mine.claims.map(c => `
++ <div class="rs-claim is-${esc(c.status)}">
++ <div class="rs-claim-main"><b>${c.kind === 'referral' ? `Referral · ${esc(c.rs_username || '')}` : `Switch bonus · RS ${esc(c.rs_username || '')}`}</b><small>#${c.id} · ${esc(c.method)}${c.payout_detail ? ` · ${esc(c.payout_detail)}` : ' · <i>no payout details yet</i>'}${c.referrer_username ? ` · referred by ${esc(c.referrer_username)}` : ''}${c.admin_note ? ` · ${esc(c.admin_note)}` : ''}</small></div>
++ <div class="rs-claim-side"><span class="rs-money">$${c.amount}</span><span class="rs-status rs-status-${esc(c.status)}">${esc(STATUS_LABEL[c.status] || c.status)}</span></div>
++ ${(!c.payout_detail || c.status === 'approved') && c.status !== 'paid' && c.status !== 'rejected' ? `<form class="rs-payout-form" data-claim="${c.id}"><select name="method">${['zelle', 'paypal', 'crypto'].map(m => `<option value="${m}" ${m === c.method ? 'selected' : ''}>${m === 'zelle' ? 'Zelle' : m === 'paypal' ? 'PayPal' : 'Crypto'}</option>`).join('')}</select><input name="payout_detail" placeholder="where to send it" value="${esc(c.payout_detail || '')}" required><button class="rs-btn rs-btn-violet" type="submit"><i class="fa-solid fa-sack-dollar"></i> Cash out</button></form>` : ''}
++ </div>`).join('')}</div>` : '';
++ const formHtml = mine.has_switch_claim ? '' : `
++ <form class="rs-claim-form" id="rs-claim-form">
++ <div class="rs-claim-form-head"><i class="fa-solid fa-file-invoice-dollar"></i> File your switch claim <small>one per account · reviewed by a human (me)</small></div>
++ <label><span>RobotStreamer username</span><input name="rs_username" placeholder="your RS name" required maxlength="60"></label>
++ <label><span>Send my <b class="rs-method-word-2">${esc(method === 'paypal' ? 'PayPal' : method === 'crypto' ? 'Crypto' : 'Zelle')}</b> to</span><input name="payout_detail" placeholder="${method === 'crypto' ? 'coin + network + address' : method === 'paypal' ? 'PayPal email' : 'Zelle phone or email'}" required maxlength="200"></label>
++ <label><span>Referred by (OpenVibe username, optional)</span><input name="referrer" placeholder="who sent you?" value="${esc(savedRef())}" maxlength="60"></label>
++ <label><span>Anything else (optional)</span><input name="note" placeholder="the feature you want built, your robot, whatever" maxlength="300"></label>
++ <button class="rs-btn rs-btn-gold" type="submit"><i class="fa-solid fa-sack-dollar"></i> File claim for ${money()}</button>
++ </form>`;
++ const balance = `<div class="rs-balance"><span><b>$${mine.balance.pending}</b><small>pending</small></span><span><b>$${mine.balance.approved}</b><small>approved</small></span><span><b>$${mine.balance.paid}</b><small>paid out</small></span></div>`;
++ const share = me ? `<div class="rs-share"><i class="fa-solid fa-link"></i> Your referral link (<b>$${mine.referral_rate}</b> per RobotStreamer streamer who claims through it): <code>${esc(refLink(me.username))}</code> <button class="rs-msg-copy rs-share-copy" type="button" title="Copy link"><i class="fa-regular fa-copy"></i></button></div>` : '';
++ box.innerHTML = `<div class="rs-cashout-head"><i class="fa-solid fa-wallet"></i> Your payouts <small>the cashout ledger — every dollar promised is a row here</small></div>${balance}${claimsHtml}${formHtml}${share}${isAdmin ? '<div id="rs-admin-claims"></div>' : ''}`;
++ box.querySelector('#rs-claim-form')?.addEventListener('submit', async (e) => {
++ e.preventDefault();
++ const f = e.currentTarget, btn = f.querySelector('button[type=submit]');
++ const body = Object.fromEntries(new FormData(f).entries());
++ body.method = modalEl?.querySelector('.rs-toggle')?.dataset.method || 'zelle';
++ btn.disabled = true; btn.innerHTML = '<i class="fa-solid fa-circle-notch fa-spin"></i> Filing…';
++ try { const r = await api('/promo/claims', { method: 'POST', body }); say(`Claim #${r.claim.id} filed — you'll get a notification when it's approved`, 'success'); if (!REDUCED) confettiBurst(); await loadCashout(); }
++ catch (err) { say(err?.message || 'Could not file the claim', 'error'); btn.disabled = false; btn.innerHTML = `<i class="fa-solid fa-sack-dollar"></i> File claim for ${money()}`; }
++ });
++ box.querySelectorAll('.rs-payout-form').forEach(f => f.addEventListener('submit', async (e) => {
++ e.preventDefault();
++ const body = Object.fromEntries(new FormData(f).entries());
++ try { await api(`/promo/claims/${f.dataset.claim}/payout`, { method: 'PATCH', body }); say('Cashout requested — I send it by hand and mark it paid', 'success'); await loadCashout(); }
++ catch (err) { say(err?.message || 'Could not save payout details', 'error'); }
++ }));
++ box.querySelector('.rs-share-copy')?.addEventListener('click', async (e) => { const b = e.currentTarget; try { await navigator.clipboard.writeText(refLink(me.username)); } catch { /* */ } b.classList.add('copied'); b.innerHTML = '<i class="fa-solid fa-check"></i>'; say('Referral link copied', 'success'); });
++ if (isAdmin) loadAdminClaims().catch(() => {});
++ }
++ async function loadAdminClaims() {
++ const box = document.getElementById('rs-admin-claims');
++ if (!box) return;
++ const all = await api('/promo/claims');
++ box.innerHTML = `<div class="rs-cashout-head rs-admin-head"><i class="fa-solid fa-shield-halved"></i> Owner console <small>${all.totals.count} claims · $${all.totals.pending} pending · $${all.totals.approved} approved · $${all.totals.paid} paid</small></div>
++ ${all.claims.length ? `<div class="rs-claims rs-claims-admin">${all.claims.map(c => `
++ <div class="rs-claim is-${esc(c.status)}" data-claim="${c.id}">
++ <div class="rs-claim-main"><b>${esc(c.user.display_name)} <small>@${esc(c.user.username)}</small> · ${c.kind === 'referral' ? 'referral' : `switch · RS ${esc(c.rs_username || '')}`}</b><small>#${c.id} · ${esc(c.method)} · ${esc(c.payout_detail || 'no payout details')}${c.referrer_username ? ` · ref ${esc(c.referrer_username)}` : ''}${c.note ? ` · “${esc(c.note)}”` : ''} · ${esc(String(c.created_at).slice(0, 16))}</small></div>
++ <div class="rs-claim-side"><input class="rs-admin-amount" type="number" min="0" step="1" value="${c.amount}" title="amount $"><span class="rs-status rs-status-${esc(c.status)}">${esc(c.status)}</span></div>
++ <div class="rs-admin-actions">
++ ${c.status !== 'approved' && c.status !== 'paid' ? `<button class="rs-btn rs-btn-violet" data-set="approved"><i class="fa-solid fa-check"></i> Approve</button>` : ''}
++ ${c.status !== 'paid' ? `<button class="rs-btn rs-btn-gold" data-set="paid"><i class="fa-solid fa-sack-dollar"></i> Mark paid</button>` : ''}
++ ${c.status !== 'rejected' && c.status !== 'paid' ? `<button class="rs-btn rs-btn-ghost" data-set="rejected"><i class="fa-solid fa-xmark"></i> Reject</button>` : ''}
++ </div>
++ </div>`).join('')}</div>` : '<p class="rs-modal-fine">No claims yet.</p>'}`;
++ box.querySelectorAll('[data-set]').forEach(b => b.addEventListener('click', async () => {
++ const row = b.closest('.rs-claim'); const id = row.dataset.claim; const amount = row.querySelector('.rs-admin-amount').value;
++ try { await api(`/promo/claims/${id}`, { method: 'PATCH', body: { status: b.dataset.set, amount } }); say(`Claim #${id} → ${b.dataset.set}`, 'success'); await loadAdminClaims(); }
++ catch (err) { say(err?.message || 'Failed', 'error'); }
++ }));
++ }
function onModalKey(e) { if (e.key === 'Escape') closeClaim(); }
function onModalClick(e) {
if (e.target === modalEl || e.target.closest('.rs-modal-close')) { closeClaim(); return; }
@@@ -487,11 +562,7 @@
if (tb) { setMethod(tb.dataset.method); return; }
if (e.target.closest('.rs-msg-copy')) { copyMessage(e.target.closest('.rs-msg-copy')); return; }
const act = e.target.closest('[data-act]')?.dataset.act;
-- if (act === 'dm') {
-- closeClaim();
-- if (typeof window.openMessengerDm === 'function') window.openMessengerDm(cfg.owner);
-- else say(`DM @${cfg.owner} to claim your ${money()}`, 'info');
-- } else if (act === 'golive') {
++ if (act === 'golive') {
closeClaim();
}
}
@@@ -500,6 +571,8 @@
tg.dataset.method = method;
tg.querySelectorAll('button').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.method === method)));
modalEl.querySelector('.rs-method-word').textContent = METHOD_NAME[method] || 'Zelle';
++ const w2 = modalEl.querySelector('.rs-method-word-2'); if (w2) w2.textContent = METHOD_NAME[method] || 'Zelle';
++ const pd = modalEl.querySelector('#rs-claim-form [name=payout_detail]'); if (pd) pd.placeholder = method === 'crypto' ? 'coin + network + address' : method === 'paypal' ? 'PayPal email' : 'Zelle phone or email';
modalEl.querySelector('.rs-msg-text').textContent = claimMessage(method);
const copyBtn = modalEl.querySelector('.rs-msg-copy'); copyBtn.classList.remove('copied'); copyBtn.innerHTML = '<i class="fa-regular fa-copy"></i>';
}
ddiff --git a/server/index.js b/server/index.js
iindex d4264508..0427cce9 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -608,16 +608,10 @@ app.get('/api/health', (req, res) => {
});
});
--// ── RobotStreamer switch-bonus promo (public) ────────────────
--// Read by public/js/rs-promo.js on every page load so the promo can be paused or retuned
--// from the env (RS_PROMO_*) without touching the frontend.
--app.get('/api/promo/robotstreamer', (req, res) => {
-- const p = config.rsPromo || {};
-- res.set('Cache-Control', 'public, max-age=300');
-- let vip = null;
-- try { const u = p.vipUser ? db.getUserByUsername(p.vipUser) : null; vip = { username: u ? u.username : p.vipUser, display_name: u ? (u.display_name || u.username) : p.vipUser, avatar_url: u ? u.avatar_url : null, referral: p.vipReferral || 20, exists: !!u }; } catch { vip = { username: p.vipUser, display_name: p.vipUser, referral: p.vipReferral || 20, exists: false }; }
-- res.json({ enabled: p.enabled !== false, amount: p.amount || 50, amount_min: p.amountMin || 25, referral: p.referral || 10, vip, github: p.github || null, owner: p.owner || 'admin', discord: p.discord || null });
--});
++// ── RobotStreamer switch-bonus promo: public config + the cashout ledger ─────────
++// (public/js/rs-promo.js reads /api/promo/robotstreamer on every page load; claims, referrals
++// and payouts are rows in promo_claims — see server/promo/claims.js.)
++app.use('/api/promo', require('./promo/routes'));
// ── Updates / Changelog ──────────────────────────────────────
const { execSync } = require('child_process');
ddiff --git a/server/promo/claims.js b/server/promo/claims.js
nnew file mode 100644
iindex 00000000..59887bb0
---- /dev/null
++++ b/server/promo/claims.js
@@@ -0,0 +1,160 @@
++/**
++ * promo/claims.js — the cashout ledger behind the RobotStreamer switch bonus.
++ *
++ * Every payout the promo promises is a ROW here instead of a DM:
++ * switch → a converted RobotStreamer streamer claims the $25–$50 (one per account)
++ * referral → created automatically for the referrer when a switch claim is approved
++ * (VIP recruiter rate when the referrer is RS_PROMO_VIP_USER)
++ *
++ * Lifecycle: pending → approved → paid (or rejected). The user sees their balance
++ * (pending / approved / paid) and requests a cashout; the owner approves and marks paid
++ * from the admin panel. Payout rails today are manual (Zelle / PayPal / crypto details on
++ * the claim); a Stripe rail plugs in on the same rows (method 'stripe', paid via Connect).
++ */
++'use strict';
++
++const db = require('../db/database');
++const config = require('../config');
++
++const METHODS = new Set(['zelle', 'paypal', 'crypto', 'stripe']);
++const STATUSES = new Set(['pending', 'approved', 'paid', 'rejected', 'cashout_requested']);
++
++let _ready = false;
++function ensureTables() {
++ if (_ready) return;
++ db.run(`CREATE TABLE IF NOT EXISTS promo_claims (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ user_id INTEGER NOT NULL,
++ kind TEXT NOT NULL DEFAULT 'switch',
++ rs_username TEXT,
++ method TEXT NOT NULL DEFAULT 'zelle',
++ payout_detail TEXT,
++ referrer_user_id INTEGER,
++ referrer_username TEXT,
++ source_claim_id INTEGER,
++ amount_cents INTEGER NOT NULL DEFAULT 0,
++ status TEXT NOT NULL DEFAULT 'pending',
++ note TEXT,
++ admin_note TEXT,
++ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ paid_at DATETIME
++ )`);
++ db.run('CREATE INDEX IF NOT EXISTS idx_promo_claims_user ON promo_claims (user_id, id)');
++ db.run('CREATE INDEX IF NOT EXISTS idx_promo_claims_status ON promo_claims (status, id)');
++ _ready = true;
++}
++
++function promo() { return config.rsPromo || {}; }
++function clean(s, n) { return String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n); }
++function isVip(username) { const v = String(promo().vipUser || '').toLowerCase(); return !!v && String(username || '').toLowerCase() === v; }
++
++function view(row) {
++ if (!row) return null;
++ const u = db.getUserById(row.user_id);
++ return {
++ id: row.id, kind: row.kind, status: row.status, method: row.method, rs_username: row.rs_username || null,
++ payout_detail: row.payout_detail || null, referrer_username: row.referrer_username || null, source_claim_id: row.source_claim_id || null,
++ amount_cents: row.amount_cents || 0, amount: Number(((row.amount_cents || 0) / 100).toFixed(2)),
++ note: row.note || null, admin_note: row.admin_note || null,
++ user: u ? { id: u.id, username: u.username, display_name: u.display_name || u.username, avatar_url: u.avatar_url || null } : { id: row.user_id, username: `user${row.user_id}` },
++ created_at: row.created_at, updated_at: row.updated_at, paid_at: row.paid_at || null,
++ };
++}
++
++/** A converted streamer files their switch claim. One per account. */
++function submitSwitch(userId, { rs_username, method, payout_detail, referrer, note }) {
++ ensureTables();
++ const p = promo();
++ if (p.enabled === false) throw new Error('The promo is paused right now');
++ const rs = clean(rs_username, 60);
++ if (!rs) throw new Error('Your RobotStreamer username is required');
++ const m = String(method || 'zelle').toLowerCase();
++ if (!METHODS.has(m)) throw new Error('Pick Zelle, PayPal or crypto');
++ const detail = clean(payout_detail, 200);
++ if (m !== 'stripe' && detail.length < 3) throw new Error(`Add where to send it (${m === 'crypto' ? 'coin + network + address' : m === 'paypal' ? 'PayPal email' : 'Zelle phone or email'})`);
++ const existing = db.get(`SELECT * FROM promo_claims WHERE user_id = ? AND kind = 'switch' AND status != 'rejected'`, [userId]);
++ if (existing) throw new Error(`You already have a switch claim (#${existing.id}, ${existing.status})`);
++ let refUser = null;
++ const refName = clean(referrer, 60).replace(/^@/, '');
++ if (refName) {
++ refUser = db.getUserByUsername(refName);
++ if (!refUser) throw new Error(`No OpenVibe user called "${refName}" — check the referrer's username`);
++ if (refUser.id === userId) throw new Error("You can't refer yourself. Nice try.");
++ }
++ // The amount is settled at approval (the range is $25–$50); default to the top of the range.
++ const r = db.run(`INSERT INTO promo_claims (user_id, kind, rs_username, method, payout_detail, referrer_user_id, referrer_username, amount_cents, note)
++ VALUES (?, 'switch', ?, ?, ?, ?, ?, ?, ?)`, [userId, rs, m, detail || null, refUser ? refUser.id : null, refUser ? refUser.username : null, (p.amount || 50) * 100, clean(note, 300) || null]);
++ const claim = db.get('SELECT * FROM promo_claims WHERE id = ?', [Number(r.lastInsertRowid)]);
++ console.log(`[Promo] switch claim #${claim.id} by user ${userId} (RS ${rs}, ${m}${refUser ? `, referred by ${refUser.username}` : ''})`);
++ try { require('../utils/notify').pushNotification({ user_id: userId, type: 'PROMO_CLAIM', title: 'Switch bonus claim received', message: `Claim #${claim.id} is in. You'll get a notification when it's approved and paid.`, icon: '💸', url: `${String(config.baseUrl || '').replace(/\/$/, '')}/` }); } catch { /* */ }
++ return view(claim);
++}
++
++/** Owner decision. Approving a switch claim mints the referrer's referral claim. */
++function setStatus(id, { status, amount, admin_note }, adminId) {
++ ensureTables();
++ const claim = db.get('SELECT * FROM promo_claims WHERE id = ?', [id]);
++ if (!claim) throw new Error('No such claim');
++ const st = String(status || '').toLowerCase();
++ if (!STATUSES.has(st)) throw new Error('Bad status');
++ const cents = amount != null && amount !== '' ? Math.round(Number(amount) * 100) : claim.amount_cents;
++ if (!Number.isFinite(cents) || cents < 0) throw new Error('Bad amount');
++ db.run(`UPDATE promo_claims SET status = ?, amount_cents = ?, admin_note = COALESCE(?, admin_note), updated_at = CURRENT_TIMESTAMP, paid_at = CASE WHEN ? = 'paid' THEN CURRENT_TIMESTAMP ELSE paid_at END WHERE id = ?`,
++ [st, cents, admin_note != null ? clean(admin_note, 300) : null, st, id]);
++ if (st === 'approved' && claim.kind === 'switch' && claim.referrer_user_id && claim.status !== 'approved' && claim.status !== 'paid') {
++ const already = db.get(`SELECT id FROM promo_claims WHERE kind = 'referral' AND source_claim_id = ?`, [id]);
++ if (!already) {
++ const p = promo();
++ const refAmount = isVip(claim.referrer_username) ? (p.vipReferral || 20) : (p.referral || 10);
++ db.run(`INSERT INTO promo_claims (user_id, kind, rs_username, method, payout_detail, source_claim_id, amount_cents, status, note)
++ VALUES (?, 'referral', ?, 'zelle', NULL, ?, ?, 'approved', ?)`, [claim.referrer_user_id, claim.rs_username, id, refAmount * 100, `Referral for switch claim #${id}`]);
++ try { require('../utils/notify').pushNotification({ user_id: claim.referrer_user_id, type: 'PROMO_REFERRAL', title: `Referral approved: $${refAmount}`, message: `${claim.rs_username} came over from RobotStreamer and named you. Add your payout details to cash out.`, icon: '💰', url: `${String(config.baseUrl || '').replace(/\/$/, '')}/` }); } catch { /* */ }
++ }
++ }
++ if (st === 'approved' || st === 'paid' || st === 'rejected') {
++ try { require('../utils/notify').pushNotification({ user_id: claim.user_id, type: 'PROMO_CLAIM', title: st === 'paid' ? `Paid: $${(cents / 100).toFixed(2)}` : st === 'approved' ? `Approved: $${(cents / 100).toFixed(2)}` : 'Claim not approved', message: st === 'paid' ? 'Sent. Thanks for switching.' : st === 'approved' ? 'Your switch bonus is approved — payout is on its way.' : (admin_note ? clean(admin_note, 200) : 'Reach out on Discord if you think this is wrong.'), icon: st === 'rejected' ? '😬' : '💸', url: `${String(config.baseUrl || '').replace(/\/$/, '')}/` }); } catch { /* */ }
++ }
++ return view(db.get('SELECT * FROM promo_claims WHERE id = ?', [id]));
++}
++
++/** The user updates payout details on their own claim (e.g. a referral row minted without any). */
++function setPayout(id, userId, { method, payout_detail }) {
++ ensureTables();
++ const claim = db.get('SELECT * FROM promo_claims WHERE id = ? AND user_id = ?', [id, userId]);
++ if (!claim) throw new Error('No such claim');
++ if (claim.status === 'paid' || claim.status === 'rejected') throw new Error('That claim is closed');
++ const m = String(method || claim.method).toLowerCase();
++ if (!METHODS.has(m)) throw new Error('Pick Zelle, PayPal or crypto');
++ const detail = clean(payout_detail, 200);
++ if (detail.length < 3) throw new Error('Add where to send it');
++ db.run('UPDATE promo_claims SET method = ?, payout_detail = ?, status = CASE WHEN status = ? THEN ? ELSE status END, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [m, detail, 'approved', 'cashout_requested', id]);
++ return view(db.get('SELECT * FROM promo_claims WHERE id = ?', [id]));
++}
++
++function mine(userId) {
++ ensureTables();
++ const rows = db.all('SELECT * FROM promo_claims WHERE user_id = ? ORDER BY id DESC', [userId]).map(view);
++ const sum = (f) => Number((rows.filter(f).reduce((n, r) => n + r.amount_cents, 0) / 100).toFixed(2));
++ return {
++ claims: rows,
++ balance: { pending: sum(r => r.status === 'pending'), approved: sum(r => r.status === 'approved' || r.status === 'cashout_requested'), paid: sum(r => r.status === 'paid') },
++ has_switch_claim: rows.some(r => r.kind === 'switch' && r.status !== 'rejected'),
++ referral_rate: isVip(db.getUserById(userId)?.username) ? (promo().vipReferral || 20) : (promo().referral || 10),
++ };
++}
++
++function listAll({ status = null, limit = 200 } = {}) {
++ ensureTables();
++ const rows = status ? db.all('SELECT * FROM promo_claims WHERE status = ? ORDER BY id DESC LIMIT ?', [status, limit]) : db.all('SELECT * FROM promo_claims ORDER BY CASE status WHEN ? THEN 0 WHEN ? THEN 1 WHEN ? THEN 2 ELSE 3 END, id DESC LIMIT ?', ['pending', 'cashout_requested', 'approved', limit]);
++ const totals = db.get(`SELECT COALESCE(SUM(CASE WHEN status = 'pending' THEN amount_cents END), 0) AS pending, COALESCE(SUM(CASE WHEN status IN ('approved', 'cashout_requested') THEN amount_cents END), 0) AS approved, COALESCE(SUM(CASE WHEN status = 'paid' THEN amount_cents END), 0) AS paid, COUNT(*) AS n FROM promo_claims`) || {};
++ return { claims: rows.map(view), totals: { pending: (totals.pending || 0) / 100, approved: (totals.approved || 0) / 100, paid: (totals.paid || 0) / 100, count: totals.n || 0 } };
++}
++
++function stats() {
++ ensureTables();
++ const t = db.get(`SELECT COUNT(*) AS claims, COALESCE(SUM(kind = 'switch' AND status IN ('approved', 'paid')), 0) AS converted, COALESCE(SUM(CASE WHEN status = 'paid' THEN amount_cents END), 0) AS paid_cents FROM promo_claims`) || {};
++ return { claims: t.claims || 0, converted: t.converted || 0, paid: (t.paid_cents || 0) / 100 };
++}
++
++module.exports = { ensureTables, submitSwitch, setStatus, setPayout, mine, listAll, stats, view, METHODS, STATUSES };
ddiff --git a/server/promo/routes.js b/server/promo/routes.js
nnew file mode 100644
iindex 00000000..38713799
---- /dev/null
++++ b/server/promo/routes.js
@@@ -0,0 +1,47 @@
++/**
++ * promo/routes.js — the RobotStreamer switch bonus (mounted at /api/promo)
++ *
++ * GET /robotstreamer public promo config (amounts, VIP recruiter, owner, links) + public totals
++ * GET /claims/mine signed in: my claims + balance (pending / approved / paid)
++ * POST /claims signed in: file the switch claim { rs_username, method, payout_detail, referrer, note }
++ * PATCH /claims/:id/payout signed in: set payout details on my claim (referral rows start without any)
++ * GET /claims admin: every claim + totals (?status=pending)
++ * PATCH /claims/:id admin: { status: approved|paid|rejected|pending, amount, admin_note }
++ */
++'use strict';
++
++const express = require('express');
++const { requireAuth } = require('../auth/auth');
++const permissions = require('../auth/permissions');
++const db = require('../db/database');
++const config = require('../config');
++const claims = require('./claims');
++
++const router = express.Router();
++
++router.get('/robotstreamer', (req, res) => {
++ const p = config.rsPromo || {};
++ let vip = null;
++ try { const u = p.vipUser ? db.getUserByUsername(p.vipUser) : null; vip = { username: u ? u.username : p.vipUser, display_name: u ? (u.display_name || u.username) : p.vipUser, avatar_url: u ? u.avatar_url : null, referral: p.vipReferral || 20, exists: !!u }; } catch { vip = { username: p.vipUser, display_name: p.vipUser, referral: p.vipReferral || 20, exists: false }; }
++ let totals = null; try { totals = claims.stats(); } catch { totals = null; }
++ res.set('Cache-Control', 'public, max-age=60');
++ res.json({ enabled: p.enabled !== false, amount: p.amount || 50, amount_min: p.amountMin || 25, referral: p.referral || 10, vip, github: p.github || null, owner: p.owner || 'admin', discord: p.discord || null, totals, cashout: true });
++});
++
++router.get('/claims/mine', requireAuth, (req, res) => {
++ try { res.set('Cache-Control', 'no-store'); res.json(claims.mine(req.user.id)); } catch (err) { res.status(500).json({ error: err.message }); }
++});
++router.post('/claims', requireAuth, (req, res) => {
++ try { res.json({ ok: true, claim: claims.submitSwitch(req.user.id, req.body || {}), mine: claims.mine(req.user.id) }); } catch (err) { res.status(400).json({ error: err.message }); }
++});
++router.patch('/claims/:id/payout', requireAuth, (req, res) => {
++ try { res.json({ ok: true, claim: claims.setPayout(Number(req.params.id), req.user.id, req.body || {}), mine: claims.mine(req.user.id) }); } catch (err) { res.status(400).json({ error: err.message }); }
++});
++router.get('/claims', requireAuth, permissions.requireAdmin, (req, res) => {
++ try { res.set('Cache-Control', 'no-store'); res.json(claims.listAll({ status: req.query.status ? String(req.query.status) : null })); } catch (err) { res.status(500).json({ error: err.message }); }
++});
++router.patch('/claims/:id', requireAuth, permissions.requireAdmin, (req, res) => {
++ try { res.json({ ok: true, claim: claims.setStatus(Number(req.params.id), req.body || {}, req.user.id) }); } catch (err) { res.status(400).json({ error: err.message }); }
++});
++
++module.exports = router;
ddiff --git a/test/promo-claims.test.js b/test/promo-claims.test.js
nnew file mode 100644
iindex 00000000..9eb61394
---- /dev/null
++++ b/test/promo-claims.test.js
@@@ -0,0 +1,73 @@
++'use strict';
++
++// The cashout ledger behind the RobotStreamer switch bonus: one switch claim per account,
++// referral rows minted on approval (VIP rate for the VIP recruiter), payout details + cashout
++// requests, owner decisions, balances. Temp DB, no network (notifications are best-effort).
++
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-claims-'));
++process.env.DB_PATH = path.join(tmp, 'live.db');
++process.env.RS_PROMO_VIP_USER = 'Patrick';
++process.env.RS_PROMO_REFERRAL_VIP = '20';
++process.env.RS_PROMO_REFERRAL = '10';
++process.env.RS_PROMO_AMOUNT = '50';
++
++const db = require('../server/db/database');
++db.initDb();
++const claims = require('../server/promo/claims');
++
++const mk = (username) => Number(db.createUser({ username, email: `${username}@x`, password_hash: 'x', display_name: username, stream_key: username.padEnd(32, '0') }).lastInsertRowid);
++const convert = mk('rsguy'), patrick = mk('Patrick'), normie = mk('normie'), convert2 = mk('rsguy2');
++
++// Validation
++assert.throws(() => claims.submitSwitch(convert, { rs_username: '', method: 'zelle', payout_detail: '555' }), /RobotStreamer username/);
++assert.throws(() => claims.submitSwitch(convert, { rs_username: 'rsguy', method: 'venmo', payout_detail: '555' }), /Zelle, PayPal or crypto/);
++assert.throws(() => claims.submitSwitch(convert, { rs_username: 'rsguy', method: 'paypal', payout_detail: '' }), /PayPal email/);
++assert.throws(() => claims.submitSwitch(convert, { rs_username: 'rsguy', method: 'zelle', payout_detail: '555-0100', referrer: 'nobody_here' }), /No OpenVibe user/);
++assert.throws(() => claims.submitSwitch(convert, { rs_username: 'rsguy', method: 'zelle', payout_detail: '555-0100', referrer: 'rsguy' }), /refer yourself/);
++
++// A convert files a claim naming Patrick (the VIP recruiter)
++const c1 = claims.submitSwitch(convert, { rs_username: 'RSGuy', method: 'crypto', payout_detail: 'USDC / Solana / 9xy…', referrer: '@Patrick', note: 'build me a cozmo bridge' });
++assert.strictEqual(c1.status, 'pending'); assert.strictEqual(c1.amount, 50); assert.strictEqual(c1.referrer_username, 'Patrick'); assert.strictEqual(c1.method, 'crypto');
++assert.throws(() => claims.submitSwitch(convert, { rs_username: 'RSGuy', method: 'zelle', payout_detail: '555' }), /already have a switch claim/);
++let mine = claims.mine(convert);
++assert.deepStrictEqual(mine.balance, { pending: 50, approved: 0, paid: 0 }); assert.strictEqual(mine.has_switch_claim, true); assert.strictEqual(mine.referral_rate, 10);
++assert.strictEqual(claims.mine(patrick).referral_rate, 20, 'the VIP recruiter sees his rate');
++console.log('✅ switch claim filed with validation');
++
++// Owner approves at $40 → Patrick gets a $20 referral row (approved, no payout details yet)
++const a1 = claims.setStatus(c1.id, { status: 'approved', amount: 40, admin_note: 'welcome' }, 1);
++assert.strictEqual(a1.status, 'approved'); assert.strictEqual(a1.amount, 40); assert.strictEqual(a1.admin_note, 'welcome');
++let pm = claims.mine(patrick);
++assert.strictEqual(pm.claims.length, 1); assert.strictEqual(pm.claims[0].kind, 'referral'); assert.strictEqual(pm.claims[0].amount, 20); assert.strictEqual(pm.claims[0].status, 'approved'); assert.strictEqual(pm.claims[0].source_claim_id, c1.id);
++assert.deepStrictEqual(pm.balance, { pending: 0, approved: 20, paid: 0 });
++claims.setStatus(c1.id, { status: 'approved' }, 1);
++assert.strictEqual(claims.mine(patrick).claims.length, 1, 're-approving never mints a second referral');
++// Patrick adds payout details → cashout requested
++const pr = claims.setPayout(pm.claims[0].id, patrick, { method: 'paypal', payout_detail: '[email protected]' });
++assert.strictEqual(pr.status, 'cashout_requested'); assert.strictEqual(pr.method, 'paypal');
++assert.throws(() => claims.setPayout(pm.claims[0].id, normie, { method: 'paypal', payout_detail: '[email protected]' }), /No such claim/, 'only the owner of a claim can edit it');
++console.log('✅ approval mints the VIP referral; payout details → cashout requested');
++
++// A normal referrer gets the normal rate; rejection pays nothing
++const c2 = claims.submitSwitch(convert2, { rs_username: 'rsguy2', method: 'zelle', payout_detail: '555-0199', referrer: 'normie' });
++claims.setStatus(c2.id, { status: 'approved' }, 1);
++assert.strictEqual(claims.mine(normie).claims[0].amount, 10);
++claims.setStatus(c2.id, { status: 'paid' }, 1);
++const paid = claims.mine(convert2);
++assert.strictEqual(paid.claims[0].status, 'paid'); assert.ok(paid.claims[0].paid_at); assert.deepStrictEqual(paid.balance, { pending: 0, approved: 0, paid: 50 });
++assert.throws(() => claims.setPayout(paid.claims[0].id, convert2, { method: 'zelle', payout_detail: '555' }), /closed/);
++assert.throws(() => claims.setStatus(999, { status: 'paid' }, 1), /No such claim/);
++assert.throws(() => claims.setStatus(c2.id, { status: 'nope' }, 1), /Bad status/);
++const all = claims.listAll();
++assert.strictEqual(all.totals.count, 4); assert.strictEqual(all.totals.paid, 50); assert.strictEqual(all.totals.approved, 70);
++assert.strictEqual(all.claims[0].status, 'cashout_requested', 'owner list: actionable rows first');
++assert.deepStrictEqual(claims.stats(), { claims: 4, converted: 2, paid: 50 });
++console.log('✅ normal referral rate, paid, rejected/closed rules, owner totals');
++
++console.log('\n✅ All promo cashout ledger tests passed');
++process.exit(0);