openvibers/openvibe.live Commits
Promo cashout ledger (claims, referrals, owner console), home page effects layer
Changes
nnew file mode 100644@@@ -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; }++}@@@ -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%; }@@@ -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>nnew file mode 100644@@@ -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();++})();@@@ -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 everyoneconst SEEN_KEY = 'ov_rs_promo_seen';const TICKER_KEY = 'ov_rs_ticker_hidden'; // sessionStorage — comes back next visitconst 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 & 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 & 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 & 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>';}@@@ -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');nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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;nnew file mode 100644@@@ -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);
new file mode 100644
new file mode 100644
@@ -0,0 +1,45 @@
@@ -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; }
+}
@@ -638,6 +638,41 @@ body.rs-ticker-on .rs-modal, body.rs-ticker-on .rs-takeover { --rs-ticker-h: 0px
@@ -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; }
.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); } }
@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) */
/* Confetti canvas (burst on claim open) */
.rs-confetti { position: absolute; inset: 0; z-index: 0; pointer-events: none; width: 100%; height: 100%; }
.rs-confetti { position: absolute; inset: 0; z-index: 0; pointer-events: none; width: 100%; height: 100%; }
@@ -67,7 +67,8 @@
@@ -67,7 +67,8 @@
<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">
<link rel="stylesheet" href="/css/messenger.css?v=1">
<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="/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">
<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
@@ -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/pastes.js?v=15" defer></script>
<script src="/js/draggable-fab.js?v=2" defer></script>
<script src="/js/draggable-fab.js?v=2" defer></script>
<script src="/js/messenger.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) ── -->
<!-- ── OpenVibe shared libs (notifications, account sync, themes) ── -->
<script src="/shared/theme-loader.js" defer></script>
<script src="/shared/theme-loader.js" defer></script>
new file mode 100644
new file mode 100644
@@ -0,0 +1,155 @@
@@ -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();
+})();
@@ -21,7 +21,7 @@
@@ -21,7 +21,7 @@
(function () {
(function () {
'use strict';
'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 SEEN_KEY = 'ov_rs_promo_seen';
const TICKER_KEY = 'ov_rs_ticker_hidden'; // sessionStorage — comes back next visit
const TICKER_KEY = 'ov_rs_ticker_hidden'; // sessionStorage — comes back next visit
const REDUCED = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const REDUCED = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
@@ -44,6 +44,13 @@
@@ -44,6 +44,13 @@
const isLoggedIn = () => !!(window.currentUser);
const isLoggedIn = () => !!(window.currentUser);
const say = (msg, type) => { if (typeof window.toast === 'function') window.toast(msg, type || 'info'); };
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).
// Pages where a full-screen splash would be hostile (mid-broadcast, overlays, popouts).
function takeoverAllowedHere() {
function takeoverAllowedHere() {
const p = location.pathname;
const p = location.pathname;
@@ -302,6 +309,7 @@
@@ -302,6 +309,7 @@
<div class="rs-card-action">
<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-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>
<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 & referral link</button>` : ''}
</div>
</div>
</div>
</div>
</div>`;
</div>`;
@@ -334,6 +342,7 @@
@@ -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>
<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>
<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>
<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>`;
</div>`;
mount.appendChild(el);
mount.appendChild(el);
}
}
@@ -454,7 +463,7 @@
@@ -454,7 +463,7 @@
<ol class="rs-steps">
<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 ${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>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>
<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>
</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>
<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 @@
@@ -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-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 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>
+ <div class="rs-cashout" id="rs-cashout">${loggedIn ? _spinner('Loading your payouts…') : ''}</div>
<div class="rs-modal-actions">
<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 & 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 & file your claim</a>`}
<div class="rs-row">
<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>
<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>
</div>
</div>
@@ -476,10 +484,77 @@
@@ -476,10 +484,77 @@
modalEl.addEventListener('click', onModalClick);
modalEl.addEventListener('click', onModalClick);
document.addEventListener('keydown', onModalKey);
document.addEventListener('keydown', onModalKey);
document.body.appendChild(modalEl);
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';
document.body.style.overflow = 'hidden';
if (!REDUCED) confettiBurst();
if (!REDUCED) confettiBurst();
setTimeout(() => modalEl?.querySelector('.rs-modal-actions .rs-btn')?.focus({ preventScroll: true }), 400);
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 onModalKey(e) { if (e.key === 'Escape') closeClaim(); }
function onModalClick(e) {
function onModalClick(e) {
if (e.target === modalEl || e.target.closest('.rs-modal-close')) { closeClaim(); return; }
if (e.target === modalEl || e.target.closest('.rs-modal-close')) { closeClaim(); return; }
@@ -487,11 +562,7 @@
@@ -487,11 +562,7 @@
if (tb) { setMethod(tb.dataset.method); return; }
if (tb) { setMethod(tb.dataset.method); return; }
if (e.target.closest('.rs-msg-copy')) { copyMessage(e.target.closest('.rs-msg-copy')); 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;
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();
closeClaim();
}
}
}
}
@@ -500,6 +571,8 @@
@@ -500,6 +571,8 @@
tg.dataset.method = method;
tg.dataset.method = method;
tg.querySelectorAll('button').forEach(b => b.setAttribute('aria-pressed', String(b.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';
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);
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>';
const copyBtn = modalEl.querySelector('.rs-msg-copy'); copyBtn.classList.remove('copied'); copyBtn.innerHTML = '<i class="fa-regular fa-copy"></i>';
}
}
@@ -608,16 +608,10 @@ app.get('/api/health', (req, res) => {
@@ -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 ──────────────────────────────────────
// ── Updates / Changelog ──────────────────────────────────────
const { execSync } = require('child_process');
const { execSync } = require('child_process');
new file mode 100644
new file mode 100644
@@ -0,0 +1,160 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,47 @@
@@ -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;
new file mode 100644
new file mode 100644
@@ -0,0 +1,73 @@
@@ -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);