openvibers/openvibe.live Commits

Remove the RobotStreamer switch-bonus campaign entirely (takeover, ticker, bounty/VIP/scoreboard cards, stream alerts, claim modal, cashout ledger, config)

OP OpenVibers committed 11 Sept 2026 · a4c8aea751 · 9 files, +0 −2009

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

Changes

ddiff --git a/.env.example b/.env.example
iindex 45c1c54f..f70c24e3 100644
---- a/.env.example
++++ b/.env.example
@@@ -6,17 +6,6 @@
ADMIN_USERNAME=admin
ADMIN_PASSWORD=changeme123
--# RobotStreamer switch bonus promo (site-wide takeover + ticker + hero card + claim modal)
--# RS_PROMO_ENABLED=true
--# RS_PROMO_AMOUNT=50 # top of the $25–$50 range
--# RS_PROMO_AMOUNT_MIN=25
--# RS_PROMO_REFERRAL=10 # paid to an existing user who brings a RobotStreamer streamer over
--# RS_PROMO_VIP_USER=patrick # the VIP recruiter spotlighted on the home page (username)
--# RS_PROMO_REFERRAL_VIP=20 # the VIP recruiter's per-conversion rate
--# RS_PROMO_GITHUB=https://github.com/OpenVibers/OpenVibe.Live
--# RS_PROMO_OWNER=alex # in-site username claimants DM (defaults to ADMIN_USERNAME)
--# RS_PROMO_DISCORD=https://discord.gg/M6MuRUaeJj
--
# Home-page "Star of OpenVibe" spotlight — a username. The `star_streamer` site setting
# (admin settings API) overrides this. Non-English streamers get their bio auto-translated.
# STAR_STREAMER=JapaneseOldGuy
ddiff --git a/public/css/rs-promo.css b/public/css/rs-promo.css
ddeleted file mode 100644
iindex cd5586d7..00000000
---- a/public/css/rs-promo.css
++++ /dev/null
@@@ -1,830 +0,0 @@
--/* ═══════════════════════════════════════════════════════════════
-- RS-PROMO — RobotStreamer switch bonus ($50 Zelle / PayPal, amount is server-driven)
-- Site-wide takeover, marquee ticker, home hero bounty card, claim
-- modal, confetti. Mounted by /js/rs-promo.js; toggled server-side
-- via RS_PROMO_ENABLED (see /api/promo/robotstreamer).
-- ═══════════════════════════════════════════════════════════════ */
--
--:root {
-- --rs-ticker-h: 36px;
-- --rs-blue: #7dd3fc; /* RobotStreamer relay colour used across chat */
-- --rs-blue-deep: #0ea5e9;
-- --rs-gold: #fbbf24;
-- --rs-gold-deep: #d97706;
-- --rs-green: #22c55e;
-- --rs-green-deep: #15803d;
-- --rs-ink: #06060a;
--}
--
--/* ── Shared money text: gold gradient with a travelling shine ── */
--.rs-money {
-- display: inline-block;
-- font-weight: 900;
-- letter-spacing: -0.02em;
-- background: linear-gradient(100deg, #fde68a 0%, var(--rs-gold) 30%, #fff7d6 50%, var(--rs-gold) 70%, var(--rs-gold-deep) 100%);
-- background-size: 240% 100%;
-- -webkit-background-clip: text; background-clip: text;
-- -webkit-text-fill-color: transparent; color: var(--rs-gold);
-- animation: rsShine 3.2s ease-in-out infinite;
-- filter: drop-shadow(0 2px 0 rgba(0,0,0,0.25)) drop-shadow(0 0 14px rgba(251,191,36,0.35));
--}
--@keyframes rsShine { 0% { background-position: 120% 0; } 100% { background-position: -120% 0; } }
--
--/* ═══════════════════════════════════════════════════════════════
-- TICKER — sits directly under the fixed navbar on every page
-- ═══════════════════════════════════════════════════════════════ */
--.rs-ticker {
-- position: fixed; top: var(--navbar-h); left: 0; right: 0;
-- height: var(--rs-ticker-h); z-index: 999;
-- display: flex; align-items: center;
-- color: #fff; font-size: 0.86rem; font-weight: 600;
-- background: linear-gradient(90deg, #4c1d95, #1d4ed8, #0e7490, #7c3aed, #4c1d95);
-- background-size: 400% 100%;
-- animation: rsTickerBg 14s linear infinite, rsTickerDrop 0.6s cubic-bezier(.2,1.2,.3,1) both;
-- box-shadow: 0 8px 24px rgba(0,0,0,0.35), inset 0 -1px 0 rgba(255,255,255,0.08);
-- overflow: hidden; cursor: pointer;
-- user-select: none; -webkit-user-select: none;
--}
--.rs-ticker::before { /* moving diagonal sheen */
-- content: ''; position: absolute; inset: 0; pointer-events: none;
-- background: linear-gradient(115deg, transparent 40%, rgba(255,255,255,0.14) 50%, transparent 60%);
-- background-size: 220% 100%;
-- animation: rsShine 4.5s linear infinite;
--}
--.rs-ticker:hover { filter: brightness(1.12); }
--@keyframes rsTickerBg { 0% { background-position: 0 0; } 100% { background-position: 400% 0; } }
--@keyframes rsTickerDrop { from { transform: translateY(-100%); opacity: 0; } to { transform: none; opacity: 1; } }
--
--.rs-ticker-track {
-- display: flex; align-items: center; width: max-content;
-- animation: rsMarquee 32s linear infinite;
-- will-change: transform;
--}
--.rs-ticker:hover .rs-ticker-track { animation-play-state: paused; }
--@keyframes rsMarquee { to { transform: translateX(-50%); } }
--.rs-ticker-item {
-- display: inline-flex; align-items: center; gap: 8px;
-- padding: 0 26px; white-space: nowrap;
--}
--.rs-ticker-item .rs-money { font-size: 1.05rem; }
--.rs-ticker-item i.fa-robot { color: var(--rs-blue); animation: rsBob 1.6s ease-in-out infinite; }
--.rs-ticker-item .rs-ticker-sep { opacity: 0.45; }
--.rs-ticker-item .rs-ticker-cta {
-- padding: 2px 10px; border-radius: 999px;
-- background: rgba(255,255,255,0.16); border: 1px solid rgba(255,255,255,0.35);
-- font-size: 0.78rem; letter-spacing: 0.04em; text-transform: uppercase;
-- animation: rsPulseRing 2.2s ease-out infinite;
--}
--@keyframes rsBob { 0%,100% { transform: translateY(0) rotate(-6deg); } 50% { transform: translateY(-2px) rotate(6deg); } }
--@keyframes rsPulseRing {
-- 0% { box-shadow: 0 0 0 0 rgba(255,255,255,0.55); }
-- 70% { box-shadow: 0 0 0 9px rgba(255,255,255,0); }
-- 100% { box-shadow: 0 0 0 0 rgba(255,255,255,0); }
--}
--.rs-ticker-close {
-- position: absolute; right: 0; top: 0; bottom: 0;
-- width: 48px; border: none; cursor: pointer; color: #fff;
-- background: linear-gradient(90deg, transparent, rgba(20,10,50,0.85) 40%);
-- font-size: 1rem; display: flex; align-items: center; justify-content: center;
--}
--.rs-ticker-close:hover { color: var(--rs-gold); }
--
--/* Push page content down while the ticker is showing */
--body.rs-ticker-on .page { padding-top: calc(var(--navbar-h) + var(--rs-ticker-h)); }
--body.rs-ticker-on #page-home .hero { padding-top: calc(var(--navbar-h) + var(--rs-ticker-h) + clamp(20px, 4vw, 44px)); }
--body.rs-ticker-on .rs-modal, body.rs-ticker-on .rs-takeover { --rs-ticker-h: 0px; }
--
--/* ═══════════════════════════════════════════════════════════════
-- TAKEOVER — one-time full-screen splash
-- ═══════════════════════════════════════════════════════════════ */
--.rs-takeover {
-- position: fixed; inset: 0; z-index: 20000;
-- display: flex; align-items: center; justify-content: center;
-- padding: 24px 16px; overflow: hidden;
-- background: radial-gradient(120% 90% at 50% 110%, #1e1b4b 0%, var(--rs-ink) 60%);
-- color: #fff;
-- animation: rsFadeIn 0.5s ease both;
-- perspective: 900px;
--}
--.rs-takeover.leaving { animation: rsTakeoverOut 0.45s ease forwards; pointer-events: none; }
--@keyframes rsFadeIn { from { opacity: 0; } to { opacity: 1; } }
--@keyframes rsTakeoverOut { to { opacity: 0; transform: scale(1.06); filter: blur(10px); } }
--
--/* WebGL aurora + scanlines + shake */
--.rs-tk-gl { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; z-index: 0; filter: saturate(1.2); }
--.rs-tk-burst { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; z-index: 3; }
--.rs-tk-scan { position: absolute; inset: 0; pointer-events: none; z-index: 1; background: repeating-linear-gradient(0deg, rgba(255,255,255,0.025) 0 1px, transparent 1px 4px); mix-blend-mode: overlay; animation: rsScan 8s linear infinite; }
--@keyframes rsScan { to { background-position: 0 200px; } }
--.rs-takeover.rs-shake { animation: rsShakeIt 0.55s cubic-bezier(.36,.07,.19,.97) both; }
--@keyframes rsShakeIt { 10%,90% { transform: translate(-2px, 1px); } 20%,80% { transform: translate(4px, -2px); } 30%,50%,70% { transform: translate(-8px, 3px); } 40%,60% { transform: translate(8px, -3px); } }
--.rs-tk-content { transition: transform 0.12s ease-out; }
--.rs-tk-amount { transform-style: preserve-3d; }
--.rs-tk-amount .rs-money { display: inline-block; transform: var(--tilt, none); transition: transform 0.12s ease-out; }
--/* Glitch-in title */
--.rs-glitch { position: relative; }
--.rs-glitch::before, .rs-glitch::after { content: attr(data-text); position: absolute; left: 0; top: 0; width: 100%; color: #fff; opacity: 0; pointer-events: none; clip-path: inset(0 0 0 0); }
--.rs-glitch::before { text-shadow: -2px 0 #22d3ee; animation: rsGlitchA 3.2s steps(1) 0.6s 2; }
--.rs-glitch::after { text-shadow: 2px 0 #f472b6; animation: rsGlitchB 3.2s steps(1) 0.6s 2; }
--@keyframes rsGlitchA { 0%,100% { opacity: 0; } 6% { opacity: 1; clip-path: inset(10% 0 60% 0); transform: translate(-4px, 0); } 10% { opacity: 1; clip-path: inset(50% 0 20% 0); transform: translate(4px, 0); } 14% { opacity: 0; } 42% { opacity: 1; clip-path: inset(30% 0 40% 0); transform: translate(-3px, 0); } 46% { opacity: 0; } }
--@keyframes rsGlitchB { 0%,100% { opacity: 0; } 8% { opacity: 1; clip-path: inset(60% 0 5% 0); transform: translate(5px, 0); } 12% { opacity: 1; clip-path: inset(0 0 75% 0); transform: translate(-5px, 0); } 16% { opacity: 0; } 44% { opacity: 1; clip-path: inset(20% 0 55% 0); transform: translate(3px, 0); } 48% { opacity: 0; } }
--/* Typewriter plea */
--.rs-tk-type { min-height: 1.6em; margin: 4px 0 2px; font-size: clamp(1rem, 2.4vw, 1.35rem); font-weight: 800; color: #fde68a; font-family: var(--font-mono); letter-spacing: 0.02em; }
--.rs-tk-caret { animation: rsCaret 0.9s steps(1) infinite; color: #fbbf24; }
--@keyframes rsCaret { 50% { opacity: 0; } }
--/* Odometers */
--.rs-odos { display: flex; flex-wrap: wrap; justify-content: center; gap: 10px; margin: 0 0 20px; }
--.rs-odo { display: grid; justify-items: center; min-width: 120px; padding: 8px 14px; border-radius: 12px; background: rgba(0,0,0,0.35); border: 1px solid rgba(255,255,255,0.16); }
--.rs-odo b { font-size: 1.6rem; font-weight: 900; color: #fde68a; font-variant-numeric: tabular-nums; text-shadow: 0 0 16px rgba(251,191,36,0.5); transition: transform 0.2s; }
--.rs-odo b.is-done { animation: rsAmountPop 0.5s cubic-bezier(.2,1.6,.3,1); }
--.rs-odo small { font-size: 0.66rem; letter-spacing: 0.1em; text-transform: uppercase; color: #cbd5e1; }
--
--/* Aurora blobs drifting behind everything */
--.rs-tk-aurora { position: absolute; inset: -20%; pointer-events: none; filter: blur(60px) saturate(1.3); opacity: 0.85; }
--.rs-tk-aurora span {
-- position: absolute; width: 55vmax; height: 55vmax; border-radius: 50%;
-- mix-blend-mode: screen; animation: rsAurora 18s ease-in-out infinite alternate;
--}
--.rs-tk-aurora span:nth-child(1) { left: -10%; top: -10%; background: radial-gradient(circle, rgba(139,92,246,0.9), transparent 62%); }
--.rs-tk-aurora span:nth-child(2) { right: -15%; top: 5%; background: radial-gradient(circle, rgba(14,165,233,0.85), transparent 62%); animation-delay: -6s; animation-duration: 22s; }
--.rs-tk-aurora span:nth-child(3) { left: 20%; bottom: -25%; background: radial-gradient(circle, rgba(34,197,94,0.75), transparent 62%); animation-delay: -12s; animation-duration: 26s; }
--.rs-tk-aurora span:nth-child(4) { right: 15%; bottom: -15%; background: radial-gradient(circle, rgba(251,191,36,0.7), transparent 62%); animation-delay: -3s; animation-duration: 20s; }
--@keyframes rsAurora {
-- 0% { transform: translate(0,0) scale(1); }
-- 50% { transform: translate(8vw,-6vh) scale(1.18); }
-- 100% { transform: translate(-6vw,8vh) scale(0.95); }
--}
--
--/* Synthwave floor grid */
--.rs-tk-grid {
-- position: absolute; left: -50%; right: -50%; bottom: -10%; height: 60%;
-- pointer-events: none; transform-origin: 50% 100%;
-- transform: perspective(600px) rotateX(64deg);
-- background:
-- linear-gradient(to top, rgba(139,92,246,0.55), transparent 70%),
-- repeating-linear-gradient(90deg, rgba(167,139,250,0.35) 0 2px, transparent 2px 80px),
-- repeating-linear-gradient(0deg, rgba(125,211,252,0.35) 0 2px, transparent 2px 80px);
-- background-size: 100% 100%, 100% 100%, 100% 80px;
-- animation: rsGridScroll 1.6s linear infinite;
-- mask-image: linear-gradient(to top, #000 30%, transparent);
-- -webkit-mask-image: linear-gradient(to top, #000 30%, transparent);
--}
--@keyframes rsGridScroll { to { background-position: 0 0, 0 0, 0 80px; } }
--
--.rs-tk-canvas { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
--
--.rs-tk-close {
-- position: absolute; top: 14px; right: 16px; z-index: 3;
-- width: 42px; height: 42px; border-radius: 50%;
-- border: 1px solid rgba(255,255,255,0.25); background: rgba(0,0,0,0.35);
-- color: #fff; font-size: 1.2rem; cursor: pointer; backdrop-filter: blur(6px);
-- transition: transform 0.2s, background 0.2s;
--}
--.rs-tk-close:hover { transform: rotate(90deg) scale(1.08); background: rgba(255,255,255,0.15); }
--
--.rs-tk-content {
-- position: relative; z-index: 2; text-align: center;
-- max-width: 780px; width: 100%;
-- animation: rsPopIn 0.8s cubic-bezier(.2,1.4,.3,1) 0.15s both;
-- transform-style: preserve-3d;
--}
--@keyframes rsPopIn { from { opacity: 0; transform: scale(0.72) translateY(40px); } to { opacity: 1; transform: none; } }
--
--.rs-tk-eyebrow {
-- display: inline-flex; align-items: center; gap: 8px;
-- padding: 6px 16px; border-radius: 999px; margin-bottom: 18px;
-- background: rgba(125,211,252,0.12); border: 1px solid rgba(125,211,252,0.45);
-- color: var(--rs-blue); font-weight: 700; font-size: 0.85rem; letter-spacing: 0.08em; text-transform: uppercase;
-- animation: rsGlowBlue 2.4s ease-in-out infinite;
--}
--@keyframes rsGlowBlue { 0%,100% { box-shadow: 0 0 0 rgba(125,211,252,0); } 50% { box-shadow: 0 0 26px rgba(125,211,252,0.45); } }
--.rs-tk-eyebrow i { animation: rsBob 1.6s ease-in-out infinite; }
--
--.rs-tk-title {
-- font-size: clamp(1.7rem, 5.2vw, 3.2rem); line-height: 1.08; font-weight: 900;
-- margin: 0 0 8px; letter-spacing: -0.02em;
-- text-shadow: 0 6px 30px rgba(0,0,0,0.5);
--}
--.rs-tk-title .rs-w { display: inline-block; opacity: 0; animation: rsWordIn 0.6s cubic-bezier(.2,1.3,.3,1) forwards; }
--@keyframes rsWordIn { from { opacity: 0; transform: translateY(26px) rotate(-4deg) scale(0.8); } to { opacity: 1; transform: none; } }
--
--.rs-tk-amount {
-- font-size: clamp(5.5rem, 22vw, 11rem); line-height: 0.95; margin: 6px 0 4px;
-- animation: rsAmountFloat 4s ease-in-out infinite;
--}
--.rs-tk-amount .rs-money { filter: drop-shadow(0 8px 0 rgba(120,53,15,0.6)) drop-shadow(0 0 40px rgba(251,191,36,0.55)); }
--.rs-tk-amount.pop .rs-money { animation: rsShine 3.2s ease-in-out infinite, rsAmountPop 0.7s cubic-bezier(.2,1.6,.3,1); }
--@keyframes rsAmountFloat { 0%,100% { transform: translateY(0) rotate(-1deg); } 50% { transform: translateY(-10px) rotate(1deg); } }
--@keyframes rsAmountPop { 0% { transform: scale(0.6); } 100% { transform: scale(1); } }
--
--.rs-tk-sub { font-size: clamp(1rem, 2.4vw, 1.3rem); color: #e2e8f0; margin: 0 0 20px; font-weight: 500; }
--.rs-tk-sub b { color: #fff; }
--
--/* Payment chips */
--.rs-chips { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; margin: 0 0 24px; }
--.rs-chip {
-- display: inline-flex; align-items: center; gap: 8px;
-- padding: 8px 16px; border-radius: 999px; font-weight: 800; font-size: 0.95rem;
-- border: 1px solid rgba(255,255,255,0.22); background: rgba(255,255,255,0.08);
-- backdrop-filter: blur(8px); transition: transform 0.2s;
-- animation: rsChipIn 0.6s cubic-bezier(.2,1.4,.3,1) both;
--}
--.rs-chip:hover { transform: translateY(-3px) scale(1.04); }
--.rs-chip:nth-child(2) { animation-delay: 0.1s; }
--.rs-chip:nth-child(3) { animation-delay: 0.2s; }
--@keyframes rsChipIn { from { opacity: 0; transform: translateY(14px) scale(0.8); } to { opacity: 1; transform: none; } }
--.rs-chip-zelle { border-color: rgba(109,40,217,0.8); background: linear-gradient(135deg, rgba(109,40,217,0.5), rgba(139,92,246,0.25)); }
--.rs-chip-paypal { border-color: rgba(0,112,186,0.9); background: linear-gradient(135deg, rgba(0,48,135,0.6), rgba(0,156,222,0.3)); }
--.rs-chip-paypal i { color: #7cc4ff; }
--.rs-chip-crypto { border-color: rgba(247,147,26,0.9); background: linear-gradient(135deg, rgba(247,147,26,0.45), rgba(251,191,36,0.2)); }
--.rs-chip-crypto i { color: #ffd28a; }
--.rs-toggle-3 { grid-template-columns: 1fr 1fr 1fr; }
--.rs-toggle-3 .rs-toggle-thumb { width: calc(33.333% - 4px); }
--.rs-toggle-3[data-method="paypal"] .rs-toggle-thumb { transform: translateX(100%); }
--.rs-toggle-3[data-method="crypto"] .rs-toggle-thumb { transform: translateX(200%); background: linear-gradient(135deg, #b45309, #f7931a); box-shadow: 0 6px 18px rgba(247,147,26,0.5); }
--.rs-chip-cash { border-color: rgba(34,197,94,0.8); background: linear-gradient(135deg, rgba(21,128,61,0.55), rgba(34,197,94,0.25)); }
--.rs-chip-build { border-color: rgba(251,191,36,0.8); background: linear-gradient(135deg, rgba(180,83,9,0.5), rgba(251,191,36,0.22)); }
--.rs-chip-build i { color: #fde68a; }
--.rs-chip-oss { border-color: rgba(167,139,250,0.8); background: linear-gradient(135deg, rgba(76,29,149,0.55), rgba(139,92,246,0.25)); }
--.rs-chip-oss i { color: #fff; }
--.rs-tk-trust { max-width: 640px; margin: -6px auto 22px; padding: 10px 14px; border-radius: 12px; font-size: 0.86rem; line-height: 1.5; color: #e2e8f0;
-- background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.14); text-align: left; }
--.rs-tk-trust a, .rs-card-sub a, .rs-sa-sub a { color: #c4b5fd; font-weight: 700; }
--.rs-tk-trust i { color: #c4b5fd; margin-right: 4px; }
--.rs-trust { display: grid; gap: 8px; margin: 0 0 16px; }
--.rs-trust-row { display: grid; grid-template-columns: 22px 1fr; gap: 10px; align-items: start; padding: 10px 12px; border-radius: 12px; font-size: 0.84rem; line-height: 1.45; color: #e2e8f0;
-- background: rgba(139,92,246,0.08); border: 1px solid rgba(139,92,246,0.3); }
--.rs-trust-row i { color: #c4b5fd; font-size: 1rem; margin-top: 2px; }
--.rs-trust-row a { color: #c4b5fd; font-weight: 700; }
--.rs-zelle-mark {
-- display: inline-grid; place-items: center; width: 20px; height: 20px; border-radius: 5px;
-- background: #6d1ed4; color: #fff; font-weight: 900; font-size: 0.8rem; font-family: var(--font);
--}
--
--/* RS → OpenVibe migration flow */
--.rs-flow { display: flex; align-items: center; justify-content: center; gap: 14px; margin: 0 auto 26px; flex-wrap: wrap; }
--.rs-flow-node {
-- display: inline-flex; align-items: center; gap: 8px;
-- padding: 10px 18px; border-radius: 14px; font-weight: 800;
-- background: rgba(255,255,255,0.07); border: 1px solid rgba(255,255,255,0.18);
-- backdrop-filter: blur(8px);
--}
--.rs-flow-node.rs-from { color: var(--rs-blue); }
--.rs-flow-node.rs-to { color: #fff; border-color: rgba(167,139,250,0.7); background: linear-gradient(135deg, rgba(139,92,246,0.45), rgba(109,40,217,0.2)); animation: rsGlowViolet 2s ease-in-out infinite; }
--@keyframes rsGlowViolet { 0%,100% { box-shadow: 0 0 0 rgba(139,92,246,0); } 50% { box-shadow: 0 0 30px rgba(139,92,246,0.55); } }
--.rs-flow-arrow {
-- position: relative; width: clamp(80px, 18vw, 160px); height: 26px;
-- background: repeating-linear-gradient(90deg, rgba(255,255,255,0.55) 0 10px, transparent 10px 20px) 0 50% / 100% 3px no-repeat;
-- animation: rsDash 0.7s linear infinite;
--}
--.rs-flow-arrow::after { content: '▶'; position: absolute; right: -6px; top: 50%; transform: translateY(-50%); color: #fff; font-size: 0.9rem; }
--@keyframes rsDash { to { background-position: 20px 50%; } }
--.rs-walker {
-- position: absolute; top: -18px; left: 0; font-size: 1.5rem; line-height: 1;
-- animation: rsWalk 2.6s ease-in-out infinite;
--}
--@keyframes rsWalk {
-- 0% { left: -4%; transform: translateY(0) rotate(0); }
-- 25% { transform: translateY(-6px) rotate(8deg); }
-- 50% { transform: translateY(0) rotate(0); }
-- 75% { transform: translateY(-6px) rotate(-8deg); }
-- 100% { left: 92%; transform: translateY(0) rotate(0); }
--}
--
--.rs-tk-actions { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; }
--.rs-btn {
-- display: inline-flex; align-items: center; justify-content: center; gap: 10px;
-- padding: 14px 28px; border-radius: 14px; border: 1px solid transparent;
-- font-size: 1.05rem; font-weight: 800; cursor: pointer; text-decoration: none;
-- transition: transform 0.2s cubic-bezier(.2,1.4,.3,1), box-shadow 0.2s, filter 0.2s;
-- color: #fff; font-family: var(--font);
--}
--.rs-btn:hover { transform: translateY(-3px) scale(1.03); }
--.rs-btn:active { transform: translateY(0) scale(0.98); }
--.rs-btn-gold {
-- color: #1c1002;
-- background: linear-gradient(135deg, #fde68a, var(--rs-gold) 45%, #f59e0b);
-- box-shadow: 0 10px 30px rgba(245,158,11,0.45), inset 0 -3px 0 rgba(0,0,0,0.15);
-- animation: rsGoldGlow 2s ease-in-out infinite;
-- position: relative; overflow: hidden;
--}
--.rs-btn-gold::after {
-- content: ''; position: absolute; inset: 0;
-- background: linear-gradient(115deg, transparent 35%, rgba(255,255,255,0.7) 50%, transparent 65%);
-- background-size: 250% 100%; animation: rsShine 2.4s linear infinite; pointer-events: none;
--}
--@keyframes rsGoldGlow { 0%,100% { box-shadow: 0 10px 30px rgba(245,158,11,0.35); } 50% { box-shadow: 0 10px 44px rgba(245,158,11,0.75); } }
--.rs-btn-ghost { background: rgba(255,255,255,0.08); border-color: rgba(255,255,255,0.3); backdrop-filter: blur(6px); }
--.rs-btn-ghost:hover { background: rgba(255,255,255,0.16); }
--.rs-btn-violet { background: linear-gradient(135deg, var(--accent), var(--accent-dark)); box-shadow: 0 10px 30px rgba(139,92,246,0.4); }
--
--.rs-tk-foot { margin-top: 18px; font-size: 0.82rem; color: #94a3b8; }
--.rs-tk-foot i { color: var(--rs-gold); }
--
--/* ═══════════════════════════════════════════════════════════════
-- HERO BOUNTY CARD — lives in the home hero, under the CTA row
-- ═══════════════════════════════════════════════════════════════ */
--.rs-hero-mount { margin: 22px auto 0; max-width: 820px; position: relative; }
--.rs-bills { position: absolute; inset: -30px -10px; pointer-events: none; z-index: 0; }
--.rs-bills span {
-- position: absolute; font-size: 1.4rem; opacity: 0.55;
-- animation: rsBillFloat 6s ease-in-out infinite;
-- filter: drop-shadow(0 4px 8px rgba(0,0,0,0.4));
--}
--.rs-bills span:nth-child(1) { left: -2%; top: 10%; animation-delay: 0s; }
--.rs-bills span:nth-child(2) { left: 12%; top: 80%; animation-delay: -1.5s; font-size: 1.1rem; }
--.rs-bills span:nth-child(3) { right: 8%; top: 0%; animation-delay: -3s; }
--.rs-bills span:nth-child(4) { right: -3%; top: 70%; animation-delay: -4.5s; font-size: 1.7rem; }
--.rs-bills span:nth-child(5) { left: 48%; top: -12%; animation-delay: -2.2s; font-size: 1rem; }
--@keyframes rsBillFloat {
-- 0%,100% { transform: translateY(0) rotate(-12deg); }
-- 50% { transform: translateY(-14px) rotate(12deg); }
--}
--
--.rs-card-border {
-- position: relative; z-index: 1; border-radius: 22px; padding: 2px; overflow: hidden;
-- isolation: isolate;
-- animation: rsCardIn 0.9s cubic-bezier(.2,1.3,.3,1) 0.3s both;
--}
--.rs-card-border::before {
-- content: ''; position: absolute; inset: -60%; z-index: -1;
-- background: conic-gradient(from 0deg, var(--rs-gold), var(--accent), var(--rs-blue), var(--rs-green), var(--rs-gold));
-- animation: rsSpin 5s linear infinite;
--}
--@keyframes rsSpin { to { transform: rotate(360deg); } }
--@keyframes rsCardIn { from { opacity: 0; transform: translateY(30px) scale(0.94); } to { opacity: 1; transform: none; } }
--
--.rs-hero-card {
-- position: relative; border-radius: 20px; overflow: hidden;
-- display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 20px;
-- padding: 18px 22px; text-align: left;
-- background: linear-gradient(135deg, rgba(20,16,40,0.94), rgba(10,10,16,0.96));
-- color: #fff;
-- transform: rotateX(var(--rs-rx, 0deg)) rotateY(var(--rs-ry, 0deg));
-- transition: transform 0.12s ease-out;
-- transform-style: preserve-3d;
-- box-shadow: 0 30px 60px rgba(0,0,0,0.5);
--}
--.rs-card-border { perspective: 900px; }
--.rs-card-glare {
-- position: absolute; inset: 0; pointer-events: none; z-index: 0;
-- background: radial-gradient(circle at var(--rs-mx, 50%) var(--rs-my, 50%), rgba(255,255,255,0.16), transparent 42%);
-- opacity: var(--rs-glare, 0); transition: opacity 0.3s;
--}
--.rs-hero-card > :not(.rs-card-glare) { position: relative; z-index: 1; }
--
--/* 3D spinning coin */
--.rs-coin-wrap { width: 84px; height: 84px; perspective: 500px; flex: none; }
--.rs-coin {
-- position: relative; width: 100%; height: 100%; transform-style: preserve-3d;
-- animation: rsCoinSpin 4s linear infinite;
--}
--.rs-coin-face {
-- position: absolute; inset: 0; border-radius: 50%;
-- display: grid; place-items: center;
-- font-weight: 900; font-size: 2rem; color: #7c2d12;
-- background: radial-gradient(circle at 35% 30%, #fff3c4, var(--rs-gold) 45%, #b45309 100%);
-- box-shadow: inset 0 0 0 5px rgba(180,83,9,0.55), inset 0 0 0 7px rgba(255,243,196,0.5), 0 0 24px rgba(251,191,36,0.55);
-- backface-visibility: hidden;
--}
--.rs-coin-face.back { transform: rotateY(180deg); }
--.rs-coin-face.back i { font-size: 1.7rem; }
--.rs-coin-edge { position: absolute; inset: 0; border-radius: 50%; transform: translateZ(-3px); background: #b45309; }
--@keyframes rsCoinSpin { to { transform: rotateY(360deg); } }
--
--.rs-card-body { min-width: 0; }
--.rs-card-kicker {
-- display: inline-flex; align-items: center; gap: 7px;
-- color: var(--rs-blue); font-weight: 800; font-size: 0.72rem; letter-spacing: 0.12em; text-transform: uppercase; margin-bottom: 4px;
--}
--.rs-card-kicker i { animation: rsBob 1.6s ease-in-out infinite; }
--.rs-card-title { font-size: clamp(1.05rem, 2.2vw, 1.4rem); font-weight: 800; line-height: 1.2; margin: 0 0 6px; }
--.rs-card-title .rs-money { font-size: 1.5em; vertical-align: -0.08em; }
--.rs-card-sub { color: #cbd5e1; font-size: 0.86rem; margin: 0 0 10px; }
--.rs-card-chips { display: flex; gap: 8px; flex-wrap: wrap; }
--.rs-card-chips .rs-chip { padding: 4px 10px; font-size: 0.78rem; animation: none; }
--
--.rs-card-action { display: flex; flex-direction: column; align-items: center; gap: 8px; flex: none; }
--.rs-card-action .rs-btn { padding: 12px 20px; font-size: 0.95rem; white-space: nowrap; }
--.rs-card-replay { background: none; border: none; color: #94a3b8; font-size: 0.72rem; cursor: pointer; text-decoration: underline dotted; }
--.rs-card-replay:hover { color: #fff; }
--
--/* ═══════════════════════════════════════════════════════════════
-- SCOREBOARD + RECEIPTS — home page, under the VIP card
-- ═══════════════════════════════════════════════════════════════ */
--.rs-board { position: relative; z-index: 1; margin-top: 14px; padding: 16px 18px 14px; border-radius: 20px; text-align: left; color: #fff;
-- background: linear-gradient(160deg, rgba(20,16,40,0.96), rgba(10,10,16,0.98)); border: 1px solid rgba(255,255,255,0.14); box-shadow: 0 20px 50px rgba(0,0,0,0.45); overflow: hidden; }
--.rs-board::before { content: ''; position: absolute; inset: 0 0 auto 0; height: 3px; background: linear-gradient(90deg, var(--rs-gold), var(--accent), var(--rs-blue), var(--rs-gold)); background-size: 300% 100%; animation: rsTickerBg 6s linear infinite; }
--.rs-board-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; font-weight: 900; font-size: 1.05rem; margin-bottom: 10px; }
--.rs-board-head i { color: #fde68a; } .rs-board-head small { color: #94a3b8; font-weight: 500; font-size: 0.78rem; }
--.rs-board-cols, .rs-board-row { display: grid; grid-template-columns: 1fr 150px 150px; gap: 8px; align-items: center; }
--.rs-board-cols { padding: 4px 0 8px; border-bottom: 1px solid rgba(255,255,255,0.12); margin-bottom: 4px; }
--.rs-board-col { font-weight: 900; font-size: 0.82rem; letter-spacing: 0.06em; text-transform: uppercase; text-align: center; }
--.rs-board-us { color: #c4b5fd; } .rs-board-them { color: #94a3b8; }
--.rs-board-row { padding: 7px 0; border-bottom: 1px dashed rgba(255,255,255,0.07); opacity: 0; transform: translateX(-16px); transition: opacity 0.5s ease calc(var(--i, 0) * 90ms), transform 0.6s cubic-bezier(.2,1.3,.3,1) calc(var(--i, 0) * 90ms); }
--.rs-board.is-in .rs-board-row { opacity: 1; transform: none; }
--.rs-board-feat { font-size: 0.88rem; color: #e2e8f0; }
--.rs-yes, .rs-no, .rs-meh { display: inline-flex; align-items: center; justify-content: center; gap: 6px; margin: 0 auto; min-width: 34px; height: 30px; padding: 0 10px; border-radius: 999px; font-weight: 800; font-size: 0.8rem; }
--.rs-yes { color: #052e16; background: linear-gradient(135deg, #86efac, #22c55e); box-shadow: 0 0 14px rgba(34,197,94,0.45); transform: scale(0.6); }
--.rs-board.is-in .rs-yes { animation: rsYesPop 0.5s cubic-bezier(.2,1.7,.3,1) calc(var(--i, 0) * 90ms + 200ms) forwards; }
--@keyframes rsYesPop { to { transform: scale(1); } }
--.rs-yes-text { font-size: 0.72rem; white-space: nowrap; }
--.rs-no { color: #fecaca; background: rgba(239,68,68,0.2); border: 1px solid rgba(239,68,68,0.6); }
--.rs-board.is-in .rs-no { animation: rsNoShake 0.5s ease calc(var(--i, 0) * 90ms + 300ms); }
--@keyframes rsNoShake { 0%,100% { transform: translateX(0); } 25% { transform: translateX(-4px) rotate(-6deg); } 75% { transform: translateX(4px) rotate(6deg); } }
--.rs-meh { color: #94a3b8; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.15); cursor: help; }
--.rs-odos-home { margin: 14px 0 6px; }
--.rs-receipts-head { font-weight: 800; font-size: 0.9rem; margin: 8px 0 6px; } .rs-receipts-head i { color: #fde68a; } .rs-receipts-head small { color: #94a3b8; font-weight: 500; font-size: 0.74rem; margin-left: 6px; }
--.rs-receipts-row { display: flex; flex-wrap: wrap; gap: 6px; }
--.rs-receipt-chip { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px 4px 5px; border-radius: 999px; font-size: 0.78rem; background: rgba(34,197,94,0.12); border: 1px solid rgba(34,197,94,0.45); animation: rsFeatPop 0.5s cubic-bezier(.2,1.5,.3,1) both; animation-delay: calc(var(--i, 0) * 60ms); }
--.rs-receipt-chip img { width: 20px; height: 20px; border-radius: 50%; object-fit: cover; }
--.rs-receipt-chip .rs-money { font-size: 0.86rem; } .rs-receipt-chip small { color: #94a3b8; }
--@media (max-width: 720px) { .rs-board-cols, .rs-board-row { grid-template-columns: 1fr 74px 74px; } .rs-yes-text { font-size: 0.62rem; white-space: normal; text-align: center; } .rs-board-col { font-size: 0.66rem; } }
--
--/* Holographic foil on the bounty card (moves with the pointer via --rs-mx/--rs-my) */
--.rs-card-foil { position: absolute; inset: 0; pointer-events: none; z-index: 0; opacity: var(--rs-glare, 0); transition: opacity 0.3s; mix-blend-mode: color-dodge;
-- background: conic-gradient(from calc(var(--rs-mx, 50%) * 3.6deg) at var(--rs-mx, 50%) var(--rs-my, 50%), rgba(255,0,128,0.35), rgba(255,215,0,0.35), rgba(0,255,200,0.35), rgba(80,120,255,0.35), rgba(255,0,128,0.35)); filter: blur(18px) saturate(1.4); }
--
--/* ═══════════════════════════════════════════════════════════════
-- VIP RECRUITER — the starfish bounty board under the hero card
-- ═══════════════════════════════════════════════════════════════ */
--.rs-vip {
-- position: relative; z-index: 1; margin-top: 14px; padding: 16px 20px; border-radius: 20px; overflow: hidden; text-align: left;
-- display: grid; grid-template-columns: 140px 1fr; gap: 18px; align-items: center; color: #fff;
-- background: linear-gradient(120deg, #0b3a5c, #0e5c7a 55%, #1a2f6b);
-- border: 2px solid rgba(255,143,190,0.6); box-shadow: 0 20px 50px rgba(0,0,0,0.45), 0 0 30px rgba(255,95,162,0.25);
-- animation: rsCardIn 0.9s cubic-bezier(.2,1.3,.3,1) 0.5s both;
--}
--.rs-vip::before { content: ''; position: absolute; inset: 0; background: repeating-linear-gradient(0deg, transparent 0 22px, rgba(255,255,255,0.03) 22px 23px); pointer-events: none; }
--.rs-vip-bubbles { position: absolute; inset: 0; pointer-events: none; }
--.rs-vip-bubbles span { position: absolute; bottom: -20px; width: 10px; height: 10px; border-radius: 50%; border: 2px solid rgba(255,255,255,0.55); background: rgba(255,255,255,0.08); animation: rsBubble 6s ease-in infinite; }
--.rs-vip-bubbles span:nth-child(1) { left: 8%; animation-delay: 0s; } .rs-vip-bubbles span:nth-child(2) { left: 22%; width: 6px; height: 6px; animation-delay: -2.2s; }
--.rs-vip-bubbles span:nth-child(3) { left: 47%; animation-delay: -4s; } .rs-vip-bubbles span:nth-child(4) { left: 63%; width: 14px; height: 14px; animation-delay: -1.3s; }
--.rs-vip-bubbles span:nth-child(5) { left: 80%; animation-delay: -3.1s; } .rs-vip-bubbles span:nth-child(6) { left: 93%; width: 7px; height: 7px; animation-delay: -5s; }
--@keyframes rsBubble { 0% { transform: translate(0, 0) scale(0.6); opacity: 0; } 10% { opacity: 0.9; } 100% { transform: translate(-14px, -170px) scale(1.2); opacity: 0; } }
--.rs-vip-star { position: relative; width: 120px; height: 120px; margin: 0 auto; transform-origin: 50% 80%; animation: rsWobble 2.6s ease-in-out infinite; filter: drop-shadow(0 8px 14px rgba(0,0,0,0.45)); }
--.rs-vip-star svg { width: 100%; height: 100%; display: block; }
--.rs-vip-pupil { animation: rsGoogly 3.2s ease-in-out infinite; transform-origin: center; }
--@keyframes rsWobble { 0%,100% { transform: rotate(-7deg) scale(1, 1); } 25% { transform: rotate(6deg) scale(1.04, 0.94); } 50% { transform: rotate(-4deg) scale(0.96, 1.06); } 75% { transform: rotate(7deg) scale(1.03, 0.97); } }
--@keyframes rsGoogly { 0%,100% { transform: translate(0, 0); } 30% { transform: translate(2px, 1px); } 60% { transform: translate(-2px, 2px); } 80% { transform: translate(1px, -1px); } }
--.rs-vip-sparkle { position: absolute; color: #fde68a; font-size: 1.1rem; animation: rsTwinkle 1.6s ease-in-out infinite; text-shadow: 0 0 10px #fbbf24; }
--.rs-vip-sparkle:nth-child(2) { left: -6px; top: 10px; } .rs-vip-sparkle:nth-child(3) { right: -8px; top: 24px; animation-delay: -0.5s; } .rs-vip-sparkle:nth-child(4) { left: 50%; bottom: -8px; animation-delay: -1s; }
--@keyframes rsTwinkle { 0%,100% { transform: scale(0.6) rotate(0); opacity: 0.4; } 50% { transform: scale(1.3) rotate(45deg); opacity: 1; } }
--.rs-vip-kicker { display: inline-flex; align-items: center; gap: 6px; color: #ffb3d1; font-weight: 800; font-size: 0.72rem; letter-spacing: 0.12em; text-transform: uppercase; margin-bottom: 4px; }
--.rs-vip-title { margin: 0 0 6px; font-size: clamp(1.05rem, 2.2vw, 1.4rem); font-weight: 900; line-height: 1.2; }
--.rs-vip-title .rs-money { font-size: 1.4em; vertical-align: -0.08em; }
--.rs-vip-sub { margin: 0 0 10px; color: #dbeafe; font-size: 0.86rem; line-height: 1.45; }
--.rs-vip-chips { display: flex; flex-wrap: wrap; gap: 8px; }
--.rs-vip-chips .rs-chip { padding: 4px 10px; font-size: 0.78rem; animation: none; }
--@media (max-width: 720px) { .rs-vip { grid-template-columns: 1fr; text-align: center; } .rs-vip-chips { justify-content: center; } }
--
--/* ═══════════════════════════════════════════════════════════════
-- RESTREAM TOUR — cam/robot → OpenVibe hub → every platform, with packets flowing
-- ═══════════════════════════════════════════════════════════════ */
--.rs-flow2 { display: grid; gap: 6px; justify-items: center; margin: 12px 0 4px; }
--.rs-flow2-src, .rs-flow2-out { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; }
--.rs-flow2-node {
-- display: inline-flex; align-items: center; gap: 6px; padding: 5px 12px; border-radius: 999px; font-weight: 700; font-size: 0.8rem;
-- color: #fff; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.22); backdrop-filter: blur(6px);
-- animation: rsChipIn 0.6s cubic-bezier(.2,1.4,.3,1) both;
--}
--.rs-flow2-out .rs-flow2-node:nth-child(1) { animation-delay: 0.05s; } .rs-flow2-out .rs-flow2-node:nth-child(2) { animation-delay: 0.12s; }
--.rs-flow2-out .rs-flow2-node:nth-child(3) { animation-delay: 0.19s; } .rs-flow2-out .rs-flow2-node:nth-child(4) { animation-delay: 0.26s; } .rs-flow2-out .rs-flow2-node:nth-child(5) { animation-delay: 0.33s; }
--.rs-flow2-node.rs-twitch { border-color: #9146ff; background: rgba(145,70,255,0.25); }
--.rs-flow2-node.rs-yt { border-color: #ff0000; background: rgba(255,0,0,0.2); }
--.rs-flow2-node.rs-kick { border-color: #53fc18; background: rgba(83,252,24,0.16); color: #d9ffcc; }
--.rs-flow2-node.rs-rs { border-color: var(--rs-blue); background: rgba(125,211,252,0.16); }
--.rs-flow2-hub {
-- display: grid; justify-items: center; gap: 2px; padding: 10px 18px; border-radius: 14px; text-align: center;
-- background: linear-gradient(135deg, rgba(139,92,246,0.45), rgba(109,40,217,0.25)); border: 1px solid rgba(167,139,250,0.75);
-- animation: rsGlowViolet 2s ease-in-out infinite;
--}
--.rs-flow2-hub b { font-size: 1rem; color: #fff; }
--.rs-flow2-hub small { color: #e9d5ff; font-size: 0.74rem; font-weight: 600; }
--.rs-flow2-wire { position: relative; width: 4px; height: 26px; border-radius: 2px; background: rgba(255,255,255,0.18); overflow: visible; }
--.rs-flow2-fan { width: min(70%, 420px); height: 26px; background: none; }
--.rs-flow2-fan::before { content: ''; position: absolute; left: 0; right: 0; top: 50%; height: 3px; background: repeating-linear-gradient(90deg, rgba(255,255,255,0.45) 0 8px, transparent 8px 16px); animation: rsDash 0.6s linear infinite; }
--.rs-flow2-fan::after { content: ''; position: absolute; left: 50%; top: 0; width: 4px; height: 50%; background: rgba(255,255,255,0.18); transform: translateX(-50%); }
--.rs-packet { position: absolute; left: 50%; top: -6px; width: 10px; height: 10px; margin-left: -5px; border-radius: 50%; background: #fde68a; box-shadow: 0 0 12px #fbbf24, 0 0 24px rgba(251,191,36,0.6); animation: rsPacketDown 1.2s linear infinite; }
--.rs-packet:nth-child(2) { animation-delay: 0.6s; }
--.rs-flow2-fan .rs-packet { top: 50%; margin-top: -5px; left: 50%; animation: rsPacketFan 1.4s ease-in-out infinite; }
--.rs-flow2-fan .rs-packet:nth-child(2) { animation-name: rsPacketFanL; animation-delay: 0.45s; }
--.rs-flow2-fan .rs-packet:nth-child(3) { animation-name: rsPacketFanR; animation-delay: 0.9s; }
--@keyframes rsPacketDown { 0% { transform: translateY(0); opacity: 0; } 15% { opacity: 1; } 100% { transform: translateY(26px); opacity: 0; } }
--@keyframes rsPacketFan { 0% { transform: translate(0, -14px) scale(0.6); opacity: 0; } 30% { opacity: 1; } 100% { transform: translate(0, 12px) scale(1); opacity: 0; } }
--@keyframes rsPacketFanL { 0% { transform: translate(0, -14px) scale(0.6); opacity: 0; } 30% { opacity: 1; } 100% { transform: translate(-160px, 12px) scale(1); opacity: 0; } }
--@keyframes rsPacketFanR { 0% { transform: translate(0, -14px) scale(0.6); opacity: 0; } 30% { opacity: 1; } 100% { transform: translate(160px, 12px) scale(1); opacity: 0; } }
--.rs-features { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; margin-top: 10px; }
--.rs-feat {
-- display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 999px; font-size: 0.76rem; font-weight: 700; color: #fff;
-- background: rgba(34,197,94,0.14); border: 1px solid rgba(34,197,94,0.5);
-- animation: rsFeatPop 0.5s cubic-bezier(.2,1.5,.3,1) both; animation-delay: calc(0.4s + var(--i, 0) * 0.08s);
--}
--.rs-feat i { color: #86efac; }
--@keyframes rsFeatPop { from { opacity: 0; transform: translateY(8px) scale(0.85); } to { opacity: 1; transform: none; } }
--.rs-flow2.is-compact { margin: 8px 0 0; gap: 4px; }
--.rs-flow2.is-compact .rs-flow2-node { padding: 3px 9px; font-size: 0.72rem; }
--.rs-flow2.is-compact .rs-flow2-hub { padding: 6px 12px; }
--.rs-flow2.is-compact .rs-flow2-hub b { font-size: 0.86rem; } .rs-flow2.is-compact .rs-flow2-hub small { font-size: 0.68rem; }
--.rs-flow2.is-compact .rs-flow2-wire { height: 16px; } .rs-flow2.is-compact .rs-flow2-fan { height: 16px; }
--.rs-tk-tour { max-width: 720px; margin: 0 auto 18px; padding: 12px 14px 14px; border-radius: 16px; background: rgba(0,0,0,0.28); border: 1px solid rgba(255,255,255,0.14); }
--.rs-tk-tour-head { font-weight: 900; font-size: 1rem; color: #fff; margin-bottom: 4px; }
--.rs-tk-tour-head i { color: var(--rs-blue); }
--.rs-step .rs-flow2 { margin-top: 8px; }
--@media (max-width: 720px) { .rs-flow2-fan { width: 90%; } }
--
--/* ═══════════════════════════════════════════════════════════════
-- STREAM-PAGE ALERT — under the player on every channel page
-- ═══════════════════════════════════════════════════════════════ */
--.rs-stream-alert-mount { margin: 10px 0 0; }
--.rs-stream-alert {
-- position: relative; border-radius: 16px; overflow: hidden; color: #fff;
-- background: linear-gradient(120deg, #2e1065, #1e1b4b 40%, #0c4a6e);
-- border: 2px solid transparent;
-- background-clip: padding-box;
-- box-shadow: 0 16px 40px rgba(0,0,0,0.45), 0 0 0 2px rgba(251,191,36,0.55), 0 0 32px rgba(251,191,36,0.25);
-- animation: rsAlertGlow 2.4s ease-in-out infinite, rsCardIn 0.7s cubic-bezier(.2,1.3,.3,1) both;
--}
--@keyframes rsAlertGlow {
-- 0%,100% { box-shadow: 0 16px 40px rgba(0,0,0,0.45), 0 0 0 2px rgba(251,191,36,0.55), 0 0 22px rgba(251,191,36,0.2); }
-- 50% { box-shadow: 0 16px 40px rgba(0,0,0,0.45), 0 0 0 3px rgba(251,191,36,0.95), 0 0 48px rgba(251,191,36,0.5); }
--}
--.rs-sa-stripe {
-- position: absolute; inset: 0 0 auto 0; height: 6px;
-- background: repeating-linear-gradient(135deg, #fbbf24 0 14px, #0b0d10 14px 28px);
-- background-size: 40px 6px; animation: rsStripe 0.8s linear infinite;
--}
--@keyframes rsStripe { to { background-position: 40px 0; } }
--.rs-sa-body { display: grid; grid-template-columns: auto 1fr auto; gap: 18px; align-items: center; padding: 20px 20px 16px; }
--.rs-sa-siren {
-- width: 64px; height: 64px; border-radius: 50%; display: grid; place-items: center; font-size: 1.8rem; color: var(--rs-blue);
-- background: rgba(125,211,252,0.12); border: 2px solid rgba(125,211,252,0.5);
-- animation: rsSiren 1.2s ease-in-out infinite;
--}
--@keyframes rsSiren {
-- 0%,100% { box-shadow: 0 0 0 0 rgba(231,76,60,0.6), 0 0 18px rgba(125,211,252,0.4); transform: rotate(-4deg); }
-- 50% { box-shadow: 0 0 0 14px rgba(231,76,60,0), 0 0 30px rgba(125,211,252,0.7); transform: rotate(4deg); }
--}
--.rs-sa-text { min-width: 0; }
--.rs-sa-head { font-size: clamp(1.05rem, 2vw, 1.45rem); font-weight: 900; line-height: 1.2; letter-spacing: -0.01em; }
--.rs-sa-head .rs-money { font-size: 1.35em; vertical-align: -0.06em; }
--.rs-sa-sub { color: #e2e8f0; font-size: 0.9rem; margin: 6px 0 10px; line-height: 1.45; }
--.rs-sa-sub b { color: #fff; }
--.rs-sa-chips { display: flex; flex-wrap: wrap; gap: 6px; }
--.rs-sa-chips .rs-chip { padding: 4px 10px; font-size: 0.76rem; animation: none; }
--.rs-sa-actions { display: flex; flex-direction: column; gap: 8px; flex: none; }
--.rs-sa-actions .rs-btn { padding: 12px 18px; font-size: 0.95rem; white-space: nowrap; }
--.rs-sa-actions .rs-btn-ghost { padding: 9px 14px; font-size: 0.85rem; }
--.rs-sa-mini {
-- display: none; align-items: center; gap: 10px; padding: 9px 46px 9px 16px; font-weight: 700; font-size: 0.86rem; cursor: pointer;
--}
--.rs-sa-mini i.fa-robot { color: var(--rs-blue); animation: rsBob 1.6s ease-in-out infinite; }
--.rs-sa-mini .rs-money { font-size: 1.05rem; }
--.rs-sa-mini .rs-ticker-cta { margin-left: auto; padding: 2px 10px; border-radius: 999px; background: rgba(255,255,255,0.16); border: 1px solid rgba(255,255,255,0.35); font-size: 0.74rem; text-transform: uppercase; }
--.rs-sa-toggle {
-- position: absolute; top: 12px; right: 10px; width: 30px; height: 30px; border-radius: 50%;
-- background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.2); color: #fff; cursor: pointer; transition: transform 0.25s;
--}
--.rs-sa-toggle:hover { background: rgba(255,255,255,0.18); }
--.rs-stream-alert.minimized .rs-sa-body, .rs-stream-alert.minimized .rs-sa-stripe { display: none; }
--.rs-stream-alert.minimized .rs-sa-mini { display: flex; }
--.rs-stream-alert.minimized { animation: none; box-shadow: 0 6px 20px rgba(0,0,0,0.35), 0 0 0 1px rgba(251,191,36,0.5); }
--.rs-stream-alert.minimized .rs-sa-toggle { top: 50%; transform: translateY(-50%) rotate(180deg); }
--@media (max-width: 720px) {
-- .rs-sa-body { grid-template-columns: 1fr; text-align: center; gap: 12px; padding: 18px 14px 14px; }
-- .rs-sa-siren { margin: 0 auto; width: 54px; height: 54px; font-size: 1.5rem; }
-- .rs-sa-chips { justify-content: center; }
-- .rs-sa-actions { width: 100%; }
-- .rs-sa-actions .rs-btn { width: 100%; }
-- .rs-sa-mini { font-size: 0.78rem; padding-right: 40px; }
-- .rs-sa-mini span:first-of-type { white-space: normal; }
--}
--
--/* ═══════════════════════════════════════════════════════════════
-- CLAIM MODAL
-- ═══════════════════════════════════════════════════════════════ */
--.rs-modal {
-- position: fixed; inset: 0; z-index: 20100;
-- display: flex; align-items: center; justify-content: center; padding: 16px;
-- background: rgba(4,4,10,0.72); backdrop-filter: blur(10px);
-- animation: rsFadeIn 0.3s ease both;
--}
--.rs-modal.leaving { animation: rsFadeOut 0.25s ease forwards; pointer-events: none; }
--@keyframes rsFadeOut { to { opacity: 0; } }
--.rs-modal-card {
-- position: relative; z-index: 1; width: 100%; max-width: 560px; max-height: calc(100vh - 32px); overflow-y: auto;
-- border-radius: 22px; padding: 26px 26px 22px;
-- background: linear-gradient(160deg, #17132b, #0c0c12 60%);
-- border: 1px solid rgba(255,255,255,0.12);
-- box-shadow: 0 40px 90px rgba(0,0,0,0.65), 0 0 0 1px rgba(139,92,246,0.25), 0 0 60px rgba(139,92,246,0.25);
-- color: #fff;
-- animation: rsPopIn 0.55s cubic-bezier(.2,1.4,.3,1) both;
--}
--.rs-modal-card::before {
-- content: ''; position: absolute; inset: 0 0 auto 0; height: 4px;
-- background: linear-gradient(90deg, var(--rs-gold), var(--accent), var(--rs-blue), var(--rs-green), var(--rs-gold));
-- background-size: 300% 100%; animation: rsTickerBg 6s linear infinite;
--}
--.rs-modal-close {
-- position: absolute; top: 12px; right: 14px; width: 36px; height: 36px; border-radius: 50%;
-- background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.15); color: #fff; cursor: pointer; font-size: 1.1rem;
-- transition: transform 0.2s;
--}
--.rs-modal-close:hover { transform: rotate(90deg); }
--
--.rs-modal-head { display: flex; align-items: center; gap: 16px; margin-bottom: 16px; }
--.rs-modal-head .rs-coin-wrap { width: 64px; height: 64px; }
--.rs-modal-head .rs-coin-face { font-size: 1.5rem; }
--.rs-modal-head h3 { margin: 0; font-size: 1.35rem; font-weight: 900; line-height: 1.15; }
--.rs-modal-head h3 .rs-money { font-size: 1.35em; vertical-align: -0.1em; }
--.rs-modal-head p { margin: 4px 0 0; color: #cbd5e1; font-size: 0.86rem; }
--
--/* Zelle / PayPal toggle */
--.rs-toggle {
-- position: relative; display: grid; grid-template-columns: 1fr 1fr;
-- background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12);
-- border-radius: 14px; padding: 4px; margin-bottom: 18px;
--}
--.rs-toggle-thumb {
-- position: absolute; top: 4px; bottom: 4px; left: 4px; width: calc(50% - 4px);
-- border-radius: 11px; background: linear-gradient(135deg, #6d1ed4, #8b5cf6);
-- transition: transform 0.35s cubic-bezier(.2,1.4,.3,1), background 0.35s;
-- box-shadow: 0 6px 18px rgba(109,30,212,0.5);
--}
--.rs-toggle[data-method="paypal"] .rs-toggle-thumb { transform: translateX(100%); background: linear-gradient(135deg, #003087, #009cde); box-shadow: 0 6px 18px rgba(0,156,222,0.45); }
--.rs-toggle button {
-- position: relative; z-index: 1; background: none; border: none; color: #cbd5e1; cursor: pointer;
-- padding: 10px 8px; font-weight: 800; font-size: 0.95rem; font-family: var(--font);
-- display: inline-flex; align-items: center; justify-content: center; gap: 8px;
-- transition: color 0.2s;
--}
--.rs-toggle button[aria-pressed="true"] { color: #fff; }
--
--.rs-steps { list-style: none; margin: 0 0 16px; padding: 0; display: grid; gap: 8px; counter-reset: rs; }
--.rs-step {
-- display: grid; grid-template-columns: 34px 1fr; gap: 12px; align-items: start;
-- padding: 10px 12px; border-radius: 12px;
-- background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.08);
-- animation: rsStepIn 0.5s cubic-bezier(.2,1.3,.3,1) both;
--}
--.rs-step:nth-child(1) { animation-delay: 0.05s; } .rs-step:nth-child(2) { animation-delay: 0.15s; }
--.rs-step:nth-child(3) { animation-delay: 0.25s; } .rs-step:nth-child(4) { animation-delay: 0.35s; }
--@keyframes rsStepIn { from { opacity: 0; transform: translateX(-18px); } to { opacity: 1; transform: none; } }
--.rs-step-num {
-- counter-increment: rs; width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center;
-- font-weight: 900; color: #1c1002;
-- background: linear-gradient(135deg, #fde68a, var(--rs-gold));
-- box-shadow: 0 4px 14px rgba(251,191,36,0.35);
--}
--.rs-step-num::before { content: counter(rs); }
--.rs-step.done .rs-step-num { background: linear-gradient(135deg, #86efac, var(--rs-green)); }
--.rs-step.done .rs-step-num::before { content: '✓'; }
--.rs-step div > strong { display: block; font-size: 0.95rem; }
--.rs-step div > span { display: block; color: #cbd5e1; font-size: 0.82rem; line-height: 1.4; }
--.rs-step a { color: var(--rs-blue); }
--
--.rs-msg {
-- position: relative; margin: 0 0 16px; padding: 12px 44px 12px 14px; border-radius: 12px;
-- background: rgba(125,211,252,0.08); border: 1px dashed rgba(125,211,252,0.45);
-- color: #e2e8f0; font-size: 0.86rem; line-height: 1.45; font-family: var(--font-mono);
--}
--.rs-msg-copy {
-- position: absolute; top: 8px; right: 8px; width: 30px; height: 30px; border-radius: 8px;
-- background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.15); color: #fff; cursor: pointer;
--}
--.rs-msg-copy:hover { background: rgba(255,255,255,0.18); }
--.rs-msg-copy.copied { background: var(--rs-green); border-color: var(--rs-green); color: #052e16; }
--
--.rs-modal-actions { display: grid; gap: 10px; }
--.rs-modal-actions .rs-btn { width: 100%; padding: 13px 18px; font-size: 1rem; }
--.rs-modal-actions .rs-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
--.rs-modal-actions .rs-btn-ghost { font-size: 0.92rem; padding: 11px 14px; }
--.rs-modal-fine { margin: 14px 0 0; color: #94a3b8; font-size: 0.76rem; text-align: center; line-height: 1.5; }
--.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%; }
--
--/* Toast-y "copied" wiggle */
--@keyframes rsWiggle { 0%,100% { transform: rotate(0); } 25% { transform: rotate(-6deg); } 75% { transform: rotate(6deg); } }
--.rs-wiggle { animation: rsWiggle 0.4s ease; }
--
--/* ═══════════════════════════════════════════════════════════════
-- RESPONSIVE
-- ═══════════════════════════════════════════════════════════════ */
--@media (max-width: 720px) {
-- :root { --rs-ticker-h: 32px; }
-- .rs-ticker { font-size: 0.78rem; }
-- .rs-ticker-item { padding: 0 16px; }
-- .rs-hero-card { grid-template-columns: 1fr; text-align: center; gap: 12px; padding: 18px 16px; }
-- .rs-coin-wrap { margin: 0 auto; width: 70px; height: 70px; }
-- .rs-card-chips { justify-content: center; }
-- .rs-card-action { width: 100%; }
-- .rs-card-action .rs-btn { width: 100%; }
-- .rs-tk-content { padding: 0 4px; }
-- .rs-flow-arrow { width: 60px; }
-- .rs-tk-actions .rs-btn { width: 100%; }
-- .rs-modal-card { padding: 22px 18px 18px; }
-- .rs-modal-actions .rs-row { grid-template-columns: 1fr; }
-- .rs-tk-grid { height: 45%; }
-- .rs-takeover { align-items: flex-start; overflow-y: auto; padding-top: 58px; }
-- .rs-tk-close { top: 10px; right: 10px; width: 38px; height: 38px; }
--}
--@media (max-height: 640px) {
-- .rs-tk-amount { font-size: clamp(3.5rem, 16vh, 7rem); }
-- .rs-tk-title { font-size: clamp(1.3rem, 4vh, 2.2rem); }
-- .rs-flow { margin-bottom: 14px; }
--}
--
--/* ═══════════════════════════════════════════════════════════════
-- REDUCED MOTION — everything static, nothing lost
-- ═══════════════════════════════════════════════════════════════ */
--@media (prefers-reduced-motion: reduce) {
-- .rs-money, .rs-ticker, .rs-ticker::before, .rs-ticker-track, .rs-ticker-item i.fa-robot, .rs-ticker-item .rs-ticker-cta,
-- .rs-takeover, .rs-tk-aurora span, .rs-tk-grid, .rs-tk-content, .rs-tk-eyebrow, .rs-tk-eyebrow i, .rs-tk-title .rs-w,
-- .rs-tk-amount, .rs-tk-amount.pop .rs-money, .rs-chip, .rs-flow-node.rs-to, .rs-flow-arrow, .rs-walker,
-- .rs-btn-gold, .rs-btn-gold::after, .rs-bills span, .rs-card-border, .rs-card-border::before, .rs-coin, .rs-card-kicker i,
-- .rs-modal, .rs-modal-card, .rs-modal-card::before, .rs-step, .rs-modal-fine i, .rs-wiggle,
-- .rs-stream-alert, .rs-sa-stripe, .rs-sa-siren, .rs-sa-mini i.fa-robot,
-- .rs-flow2-node, .rs-flow2-hub, .rs-flow2-fan::before, .rs-packet, .rs-feat,
-- .rs-vip, .rs-vip-bubbles span, .rs-vip-star, .rs-vip-pupil, .rs-vip-sparkle,
-- .rs-tk-scan, .rs-glitch::before, .rs-glitch::after, .rs-tk-caret, .rs-odo b, .rs-board-row, .rs-yes, .rs-no, .rs-receipt-chip, .rs-board::before {
-- animation: none !important;
-- }
-- .rs-tk-title .rs-w, .rs-tk-content, .rs-card-border, .rs-step, .rs-chip { opacity: 1; transform: none; }
-- .rs-ticker-track { width: 100%; justify-content: center; overflow: hidden; }
-- .rs-ticker-track .rs-ticker-copy:nth-child(2) { display: none; }
-- .rs-hero-card { transform: none !important; }
-- .rs-card-glare { display: none; }
-- .rs-tk-canvas, .rs-confetti, .rs-tk-gl, .rs-tk-burst, .rs-tk-scan { display: none; }
-- .rs-board-row { opacity: 1; transform: none; } .rs-yes { transform: none; }
-- .rs-money { background: none; -webkit-text-fill-color: var(--rs-gold); color: var(--rs-gold); }
--}
--
--/* ═══════════════════════════════════════════════════════════════
-- LITE MODE — html.rs-lite (phones / weak GPUs / Data Saver) and any screen ≤ 820px.
-- No blur filters, blend modes, backdrop-filter, canvases or animated shadows: those are what
-- make low-end Android Chrome flicker. Transform/opacity animations stay.
-- ═══════════════════════════════════════════════════════════════ */
--.rs-lite .rs-tk-aurora, .rs-lite .rs-tk-scan, .rs-lite .rs-tk-gl, .rs-lite .rs-tk-canvas, .rs-lite .rs-tk-burst, .rs-lite .rs-card-foil, .rs-lite .rs-card-glare, .rs-lite .rs-bills, .rs-lite .rs-vip-bubbles, .rs-lite .rs-ticker::before, .rs-lite .rs-btn-gold::after, .rs-lite .rs-glitch::before, .rs-lite .rs-glitch::after { display: none !important; }
--.rs-lite .rs-takeover { background: radial-gradient(120% 80% at 50% 0%, #2e1065 0%, #1e1b4b 40%, #06060a 80%); }
--.rs-lite .rs-tk-grid { animation: none; transform: perspective(600px) rotateX(64deg); opacity: 0.5; }
--.rs-lite .rs-tk-content, .rs-lite .rs-tk-amount, .rs-lite .rs-tk-amount .rs-money { transform: none !important; animation: none !important; }
--.rs-lite .rs-money { animation: none; filter: none; background: none; -webkit-text-fill-color: var(--rs-gold); color: var(--rs-gold); text-shadow: 0 2px 0 rgba(0,0,0,0.35); }
--.rs-lite .rs-tk-amount .rs-money { filter: none; }
--.rs-lite .rs-ticker { animation: rsTickerDrop 0.5s ease both; background: linear-gradient(90deg, #4c1d95, #1d4ed8, #7c3aed); }
--.rs-lite .rs-ticker-item .rs-ticker-cta, .rs-lite .rs-tk-eyebrow, .rs-lite .rs-flow-node.rs-to, .rs-lite .rs-btn-gold, .rs-lite .rs-stream-alert, .rs-lite .rs-sa-siren, .rs-lite .rs-flow2-hub, .rs-lite .rs-chip, .rs-lite .rs-flow2-node, .rs-lite .rs-odo b, .rs-lite .rs-yes, .rs-lite .rs-star-avatar-glow { animation: none !important; box-shadow: none !important; }
--.rs-lite .rs-chip, .rs-lite .rs-flow-node, .rs-lite .rs-flow2-node, .rs-lite .rs-btn-ghost, .rs-lite .rs-tk-close, .rs-lite .rs-modal, .rs-lite .rs-tk-tour { backdrop-filter: none !important; -webkit-backdrop-filter: none !important; }
--.rs-lite .rs-modal { background: rgba(4,4,10,0.9); }
--.rs-lite .rs-card-border::before { animation: none; background: linear-gradient(135deg, var(--rs-gold), var(--accent), var(--rs-blue)); inset: 0; }
--.rs-lite .rs-hero-card { transform: none !important; transition: none; box-shadow: 0 10px 30px rgba(0,0,0,0.4); }
--.rs-lite .rs-coin { animation: none; transform: rotateY(0); } .rs-lite .rs-coin-face { box-shadow: inset 0 0 0 5px rgba(180,83,9,0.55); }
--.rs-lite .rs-vip-star { filter: none; } .rs-lite .rs-vip { box-shadow: 0 10px 30px rgba(0,0,0,0.4); }
--.rs-lite .rs-vip-sparkle { animation: none; opacity: 0.8; }
--.rs-lite .rs-stream-alert { box-shadow: 0 0 0 2px rgba(251,191,36,0.6); }
--.rs-lite .rs-sa-stripe { animation: none; }
--.rs-lite .rs-packet { box-shadow: none; }
--.rs-lite .rs-board::before { animation: none; }
--.rs-lite .rs-tk-title { text-shadow: none; }
--@media (max-width: 820px) {
-- /* CSS-only safety net for the most expensive effects even before JS decides on lite mode. */
-- .rs-tk-aurora { filter: none; opacity: 0.55; } .rs-tk-aurora span { mix-blend-mode: normal; animation: none; }
-- .rs-tk-scan, .rs-card-foil { display: none; }
-- .rs-tk-amount .rs-money { filter: drop-shadow(0 4px 0 rgba(120,53,15,0.6)); }
-- .rs-stream-alert, .rs-sa-siren, .rs-btn-gold { animation: none; }
--}
ddiff --git a/public/index.html b/public/index.html
iindex ae0dbed2..d678cba4 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -67,7 +67,6 @@
<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=9">
<link rel="stylesheet" href="/css/home-fx.css?v=4">
<link rel="stylesheet" href="/css/i18n-star.css?v=3">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
@@@ -249,8 +248,6 @@
<i class="fa-solid fa-wand-magic-sparkles"></i> Vibe Coders
</a>
</div>
-- <!-- RobotStreamer switch bonus — bounty card mounted by /js/rs-promo.js (server-driven amount) -->
-- <div class="rs-hero-mount" id="rs-hero-mount"></div>
<!-- Newest shipped commit — built in the open, receipts included. -->
<a class="hero-latest" id="hero-latest" href="/updates" onclick="return handleLinkClick(event, '/updates')" style="display:none"></a>
<div class="hero-philosophy-links">
@@@ -803,8 +800,6 @@
<div class="controls-grid" id="controls-grid"></div>
</div>
</div>
-- <!-- RobotStreamer switch bonus — big alert on every stream page (filled by /js/rs-promo.js) -->
-- <div class="rs-stream-alert-mount" id="rs-stream-alert"></div>
<!-- Live translation captions for non-English streamers (filled by _startCaptions in app.js) -->
<div class="ch-captions" id="ch-captions" style="display:none"></div>
<!-- Streamer Info Bar (below video, Kick/Twitch style) -->
@@@ -938,8 +933,6 @@
<div class="stream-layout">
<div class="stream-main">
<!-- Offline header with streamer info -->
-- <!-- RobotStreamer switch bonus — the same big alert on OFFLINE channel pages (filled by /js/rs-promo.js) -->
-- <div class="rs-stream-alert-mount" id="rs-stream-alert-offline"></div>
<div class="ch-offline-header" id="ch-offline-header">
<div class="ch-avatar ch-avatar-lg" id="ch-avatar-offline">?</div>
<div class="ch-offline-header-info">
@@@ -5126,7 +5119,6 @@ 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=12" defer></script>
<script src="/js/home-fx.js?v=2" defer></script>
<!-- ── OpenVibe shared libs (notifications, account sync, themes) ── -->
ddiff --git a/public/js/rs-promo.js b/public/js/rs-promo.js
ddeleted file mode 100644
iindex 2b976219..00000000
---- a/public/js/rs-promo.js
++++ /dev/null
@@@ -1,847 +0,0 @@
--/* ═══════════════════════════════════════════════════════════════
-- RS-PROMO — RobotStreamer switch bonus (Zelle / PayPal)
-- ───────────────────────────────────────────────────────────────
-- Every RobotStreamer user who moves to OpenVibe.Live gets a cash
-- bonus, paid personally by the site owner. This module mounts the
-- four promo surfaces and all of their effects:
--
-- 1. Ticker — animated marquee under the navbar on every page
-- 2. Takeover — one-time full-screen splash (money rain, aurora,
-- synthwave grid, count-up amount)
-- 3. Hero card — 3D-tilt bounty card in the home hero
-- 4. Claim modal — Zelle/PayPal toggle, steps, copyable message,
-- sign-in / DM-the-owner / Discord actions + confetti
--
-- Amount, owner username, Discord link and the on/off switch come
-- from GET /api/promo/robotstreamer (env RS_PROMO_*), so the promo
-- can be retuned or paused without a frontend deploy. Everything
-- here is static copy + server-provided strings (escaped) — no user
-- content is rendered.
-- ═══════════════════════════════════════════════════════════════ */
--(function () {
-- 'use strict';
--
-- const PROMO_VERSION = 'rs-desperate-v8'; // bump to re-show the takeover to everyone
-- const SEEN_KEY = 'ov_rs_promo_seen';
-- const TICKER_KEY = 'ov_rs_ticker_hidden'; // sessionStorage — comes back next visit
-- const REDUCED = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
-- const COARSE = window.matchMedia && window.matchMedia('(pointer: coarse)').matches;
-- // Lite mode: phones and weak devices get the copy and the cheap transform animations, but no
-- // canvases, shaders, blur filters, blend modes or animated shadows (they flicker and stutter on
-- // low-end Android GPUs). Also honours Data Saver.
-- const LITE = (() => {
-- try {
-- const narrow = window.innerWidth <= 820;
-- const weak = (navigator.hardwareConcurrency && navigator.hardwareConcurrency <= 4) || (navigator.deviceMemory && navigator.deviceMemory <= 4);
-- const saver = navigator.connection && navigator.connection.saveData;
-- const ua = /Android|iPhone|iPad|Mobile/i.test(navigator.userAgent);
-- return !!(saver || (COARSE && (narrow || ua)) || (narrow && weak) || (ua && weak));
-- } catch { return COARSE; }
-- })();
-- if (LITE) document.documentElement.classList.add('rs-lite');
--
-- const cfg = { enabled: true, amount: 50, amountMin: 25, referral: 10, vip: null, totals: null, github: 'https://github.com/OpenVibers/OpenVibe.Live', owner: 'admin', discord: 'https://discord.gg/M6MuRUaeJj' };
-- const STREAM_ALERT_KEY = 'ov_rs_stream_alert_min'; // sessionStorage — collapsed (never hidden) state
-- let modalEl = null, takeoverEl = null, tickerEl = null;
-- let rainStop = null, glStop = null;
-- const PLEAS = ['I am begging you.', 'I will do anything. Name it.', 'Out of my own pocket. No investors.', 'Rick will never offer you this.', 'Restream everywhere. From here. Paid.', 'Every line of this site is public code.', 'I genuinely care that you stream here.'];
--
-- const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
-- const money = () => (cfg.amountMin && cfg.amountMin < cfg.amount ? `$${cfg.amountMin}–$${cfg.amount}` : `$${cfg.amount}`);
-- const moneyMax = () => `$${cfg.amount}`;
-- const referral = () => `$${cfg.referral}`;
-- const ghLink = (label) => cfg.github ? `<a href="${esc(cfg.github)}" target="_blank" rel="noopener noreferrer">${label}</a>` : label;
-- const store = {
-- get(k, ss) { try { return (ss ? sessionStorage : localStorage).getItem(k); } catch { return null; } },
-- set(k, v, ss) { try { (ss ? sessionStorage : localStorage).setItem(k, v); } catch { /* private mode */ } },
-- };
-- 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;
-- if (/^\/(popout|obs|whip|embed|kiosk)/.test(p)) return false;
-- if (/^\/(broadcast|dashboard)/.test(p)) return false;
-- return true;
-- }
-- function promoAllowedHere() {
-- return !/^\/(popout|obs|whip|embed|kiosk)/.test(location.pathname);
-- }
--
-- /* ─────────────────────────────────────────────────────────────
-- Shared fragments
-- ───────────────────────────────────────────────────────────── */
-- const chipsHtml = () => `
-- <span class="rs-chip rs-chip-zelle"><span class="rs-zelle-mark">Z</span> Zelle</span>
-- <span class="rs-chip rs-chip-paypal"><i class="fa-brands fa-paypal"></i> PayPal</span>
-- <span class="rs-chip rs-chip-crypto"><i class="fa-brands fa-bitcoin"></i> Crypto</span>
-- <span class="rs-chip rs-chip-cash"><i class="fa-solid fa-money-bill-wave"></i> Real cash, no coins</span>
-- <span class="rs-chip rs-chip-build"><i class="fa-solid fa-hammer"></i> Features built on request</span>
-- <span class="rs-chip rs-chip-oss" title="Every line of this site is public — trust what you can read"><i class="fa-brands fa-github"></i> 100% open source</span>`;
--
-- // "One stream, everywhere": stream from here, mirror anywhere — with the toys that only exist here.
-- const restreamHtml = (compact = false) => `
-- <div class="rs-flow2${compact ? ' is-compact' : ''}" aria-label="Stream from OpenVibe.Live and restream everywhere">
-- <div class="rs-flow2-src"><span class="rs-flow2-node"><i class="fa-solid fa-video"></i> your cam</span><span class="rs-flow2-node"><i class="fa-solid fa-robot"></i> your robot</span></div>
-- <div class="rs-flow2-wire" aria-hidden="true"><span class="rs-packet"></span><span class="rs-packet"></span></div>
-- <div class="rs-flow2-hub"><b><i class="fa-solid fa-circle-nodes"></i> OpenVibe.Live</b><small>WebRTC &lt;1s latency · emotes · sound commands · robot controls · VODs · clips · AI moments</small></div>
-- <div class="rs-flow2-wire rs-flow2-fan" aria-hidden="true"><span class="rs-packet"></span><span class="rs-packet"></span><span class="rs-packet"></span></div>
-- <div class="rs-flow2-out">
-- <span class="rs-flow2-node rs-twitch"><i class="fa-brands fa-twitch"></i> Twitch</span>
-- <span class="rs-flow2-node rs-yt"><i class="fa-brands fa-youtube"></i> YouTube</span>
-- <span class="rs-flow2-node rs-kick"><i class="fa-solid fa-bolt"></i> Kick</span>
-- <span class="rs-flow2-node rs-rs"><i class="fa-solid fa-robot"></i> RobotStreamer</span>
-- <span class="rs-flow2-node"><i class="fa-solid fa-tower-broadcast"></i> any RTMP</span>
-- </div>
-- ${compact ? '' : `<div class="rs-features">
-- <span class="rs-feat" style="--i:0"><i class="fa-solid fa-gauge-high"></i> Sub-second latency</span>
-- <span class="rs-feat" style="--i:1"><i class="fa-solid fa-face-grin-squint-tears"></i> 7TV / BTTV / FFZ + custom emotes</span>
-- <span class="rs-feat" style="--i:2"><i class="fa-solid fa-volume-high"></i> Sound commands &amp; soundboard</span>
-- <span class="rs-feat" style="--i:3"><i class="fa-solid fa-gamepad"></i> Robot &amp; hardware controls</span>
-- <span class="rs-feat" style="--i:4"><i class="fa-solid fa-satellite-dish"></i> Free restream, all at once</span>
-- <span class="rs-feat" style="--i:5"><i class="fa-solid fa-scissors"></i> VODs, clips, AI moments</span>
-- <span class="rs-feat" style="--i:6"><i class="fa-solid fa-comments"></i> One chat from every platform</span>
-- </div>`}
-- </div>`;
--
-- const coinHtml = () => `
-- <div class="rs-coin-wrap" aria-hidden="true">
-- <div class="rs-coin">
-- <div class="rs-coin-face front">$</div>
-- <div class="rs-coin-face back"><i class="fa-solid fa-robot"></i></div>
-- <div class="rs-coin-edge"></div>
-- </div>
-- </div>`;
--
-- /* ─────────────────────────────────────────────────────────────
-- 1. TICKER
-- ───────────────────────────────────────────────────────────── */
-- function mountTicker() {
-- if (tickerEl || store.get(TICKER_KEY, true) === '1') return;
-- const copy = `
-- <span class="rs-ticker-item"><i class="fa-solid fa-robot"></i> RobotStreamer streamers: I AM BEGGING. Leave Rick's trash site for good, stream ONLY here, get <span class="rs-money">${money()}</span> <span class="rs-ticker-sep">•</span> Zelle, PayPal or crypto <span class="rs-ticker-sep">•</span> <span class="rs-ticker-cta">Claim yours</span></span>
-- <span class="rs-ticker-item"><i class="fa-solid fa-heart" style="color:#f472b6"></i> I am extremely desperate and I will do ANYTHING to get you here — name the feature, I build it <span class="rs-ticker-sep">•</span> OUT OF POCKET. No investors. Just a wallet and feelings.</span>
-- <span class="rs-ticker-item"><i class="fa-solid fa-face-sad-cry" style="color:#fde68a"></i> I genuinely care about you streaming on my site. That is the whole business plan. Please. <span class="rs-ticker-sep">•</span> <span class="rs-ticker-cta">Take my money</span></span>
-- <span class="rs-ticker-item"><i class="fa-solid fa-satellite-dish" style="color:#7dd3fc"></i> Stream here, restream to Twitch, YouTube, Kick, RobotStreamer &amp; any RTMP at once <span class="rs-ticker-sep">•</span> emotes, sound commands, robot controls, &lt;1s latency</span>
-- <span class="rs-ticker-item"><i class="fa-brands fa-github"></i> Open source beats closed source: every line of this site is on GitHub, theirs isn't <span class="rs-ticker-sep">•</span> trust what you can read</span>
-- <span class="rs-ticker-item"><i class="fa-solid fa-people-arrows" style="color:#86efac"></i> Already here? Bring a RobotStreamer streamer over and you get <span class="rs-money">${referral()}</span> too <span class="rs-ticker-sep">•</span> <span class="rs-ticker-cta">How it works</span></span>`;
-- tickerEl = document.createElement('div');
-- tickerEl.className = 'rs-ticker';
-- tickerEl.setAttribute('role', 'region');
-- tickerEl.setAttribute('aria-label', `RobotStreamer switch bonus: ${money()} via Zelle or PayPal`);
-- tickerEl.innerHTML = `
-- <div class="rs-ticker-track">
-- <span class="rs-ticker-copy" style="display:contents">${copy}</span>
-- <span class="rs-ticker-copy" style="display:contents" aria-hidden="true">${copy}</span>
-- </div>
-- <button class="rs-ticker-close" type="button" title="Hide for this visit" aria-label="Hide promo bar"><i class="fa-solid fa-xmark"></i></button>`;
-- tickerEl.addEventListener('click', (e) => {
-- if (e.target.closest('.rs-ticker-close')) { hideTicker(); return; }
-- openClaim();
-- });
-- document.body.appendChild(tickerEl);
-- document.body.classList.add('rs-ticker-on');
-- }
-- function hideTicker() {
-- if (!tickerEl) return;
-- store.set(TICKER_KEY, '1', true);
-- tickerEl.style.transition = 'transform 0.35s ease, opacity 0.35s ease';
-- tickerEl.style.transform = 'translateY(-100%)';
-- tickerEl.style.opacity = '0';
-- document.body.classList.remove('rs-ticker-on');
-- setTimeout(() => { tickerEl?.remove(); tickerEl = null; }, 400);
-- }
--
-- /* ─────────────────────────────────────────────────────────────
-- 2. TAKEOVER (money rain canvas + count-up)
-- ───────────────────────────────────────────────────────────── */
-- function buildTakeover() {
-- const titleWords = ['I', 'am', 'BEGGING', 'you.', 'Leave', "Rick's", 'trash', 'site.'];
-- const el = document.createElement('div');
-- el.className = 'rs-takeover';
-- el.setAttribute('role', 'dialog');
-- el.setAttribute('aria-modal', 'true');
-- el.setAttribute('aria-label', `RobotStreamer switch bonus: ${money()}`);
-- el.innerHTML = `
-- <canvas class="rs-tk-gl" aria-hidden="true"></canvas>
-- <div class="rs-tk-aurora" aria-hidden="true"><span></span><span></span><span></span><span></span></div>
-- <div class="rs-tk-grid" aria-hidden="true"></div>
-- <canvas class="rs-tk-canvas" aria-hidden="true"></canvas>
-- <canvas class="rs-tk-burst" aria-hidden="true"></canvas>
-- <div class="rs-tk-scan" aria-hidden="true"></div>
-- <button class="rs-tk-close" type="button" aria-label="Close"><i class="fa-solid fa-xmark"></i></button>
-- <div class="rs-tk-content">
-- <div class="rs-tk-eyebrow"><i class="fa-solid fa-robot"></i> robotstreamer.com users</div>
-- <h1 class="rs-tk-title rs-glitch" data-text="${esc(titleWords.join(' '))}">${titleWords.map((w, i) => `<span class="rs-w" style="animation-delay:${0.25 + i * 0.09}s">${esc(w)}</span>`).join(' ')}</h1>
-- <div class="rs-tk-type" aria-live="polite"><span class="rs-tk-type-text"></span><span class="rs-tk-caret">▌</span></div>
-- <div class="rs-tk-amount" aria-label="${money()}"><span class="rs-money" data-countup="${cfg.amount}">${cfg.amountMin < cfg.amount ? `$${cfg.amountMin}–$0` : '$0'}</span></div>
-- <p class="rs-tk-sub">I am <b>extremely desperate</b>. I will do <b>anything</b> to get you off RobotStreamer and onto <b>OpenVibe.Live</b>. Never stream there again, stream <b>only</b> here, and I personally send you <b>${money()}</b> — Zelle, PayPal or crypto, your pick. This is <b>out of my own pocket</b>. There are no investors. There is a wallet, and there are feelings.<br>Want a feature? <b>Name it. I build it.</b> Want me to grovel? This is the groveling. I genuinely care about you streaming here — that is the entire business plan. Not a corporation — one unhinged guy who wants you here more than Rick ever will.</p>
-- <div class="rs-tk-tour"><div class="rs-tk-tour-head"><i class="fa-solid fa-satellite-dish"></i> Stream here. Be everywhere. Keep the toys.</div>${restreamHtml(false)}</div>
-- <p class="rs-tk-trust"><i class="fa-brands fa-github"></i> <b>Trust what you can read.</b> Every line of this site is ${ghLink('public on GitHub')}. Theirs is closed source — you'll never see what it does. An open alternative is the only kind worth trusting.<br><i class="fa-solid fa-people-arrows"></i> <b>Already one of us?</b> Bring a RobotStreamer streamer over and you get <b>${referral()}</b> too.</p>
-- <div class="rs-chips">${chipsHtml()}</div>
-- <div class="rs-odos" aria-label="live totals">
-- <div class="rs-odo"><b data-odo="${Number(cfg.totals?.converted || 0)}">0</b><small>streamers converted</small></div>
-- <div class="rs-odo"><b data-odo="${Number(cfg.totals?.paid || 0)}" data-prefix="$">$0</b><small>paid out so far</small></div>
-- <div class="rs-odo"><b data-odo="${Number(cfg.totals?.claims || 0)}">0</b><small>claims filed</small></div>
-- </div>
-- <div class="rs-flow" aria-hidden="true">
-- <span class="rs-flow-node rs-from"><i class="fa-solid fa-robot"></i> RobotStreamer</span>
-- <span class="rs-flow-arrow"><span class="rs-walker">🤖</span></span>
-- <span class="rs-flow-node rs-to"><i class="fa-solid fa-circle-nodes"></i> OpenVibe.Live</span>
-- </div>
-- <div class="rs-tk-actions">
-- <button class="rs-btn rs-btn-gold" type="button" data-act="claim"><i class="fa-solid fa-sack-dollar"></i> Fine, I'll take the ${money()}</button>
-- <button class="rs-btn rs-btn-ghost" type="button" data-act="later"><i class="fa-solid fa-compass"></i> Let me look around first</button>
-- </div>
-- <p class="rs-tk-foot"><i class="fa-solid fa-star"></i> You'll only see this splash once — the bar at the top brings you back here anytime.</p>
-- </div>`;
-- el.addEventListener('click', (e) => {
-- const act = e.target.closest('[data-act]')?.dataset.act;
-- if (e.target.closest('.rs-tk-close') || act === 'later') { closeTakeover(); return; }
-- if (act === 'claim') { closeTakeover(); openClaim(); }
-- });
-- return el;
-- }
-- function showTakeover(force) {
-- if (takeoverEl) return;
-- if (!force && (store.get(SEEN_KEY) === PROMO_VERSION || !takeoverAllowedHere())) return;
-- takeoverEl = buildTakeover();
-- document.body.appendChild(takeoverEl);
-- document.body.style.overflow = 'hidden';
-- store.set(SEEN_KEY, PROMO_VERSION);
-- if (!REDUCED) {
-- if (!LITE) {
-- rainStop = startMoneyRain(takeoverEl.querySelector('.rs-tk-canvas'));
-- glStop = startShader(takeoverEl.querySelector('.rs-tk-gl'));
-- attachParallax(takeoverEl);
-- } else {
-- takeoverEl.querySelectorAll('.rs-tk-canvas, .rs-tk-gl, .rs-tk-burst, .rs-tk-scan').forEach(c => c.remove());
-- }
-- typewriter(takeoverEl.querySelector('.rs-tk-type-text'), PLEAS);
-- countUp(takeoverEl.querySelector('[data-countup]'), cfg.amount, 1500, 500, () => { if (!LITE) { shake(takeoverEl); coinBurst(takeoverEl.querySelector('.rs-tk-burst')); } });
-- takeoverEl.querySelectorAll('[data-odo]').forEach(el => odometer(el, 900));
-- } else {
-- takeoverEl.querySelector('[data-countup]').textContent = money();
-- takeoverEl.querySelectorAll('[data-odo]').forEach(el => { el.textContent = (el.dataset.prefix || '') + el.dataset.odo; });
-- takeoverEl.querySelector('.rs-tk-type-text').textContent = PLEAS[0];
-- }
-- document.addEventListener('keydown', onTakeoverKey);
-- setTimeout(() => takeoverEl?.querySelector('[data-act="claim"]')?.focus({ preventScroll: true }), 900);
-- }
-- function onTakeoverKey(e) { if (e.key === 'Escape') closeTakeover(); }
-- function closeTakeover() {
-- if (!takeoverEl) return;
-- const el = takeoverEl; takeoverEl = null;
-- document.removeEventListener('keydown', onTakeoverKey);
-- document.body.style.overflow = '';
-- if (rainStop) { rainStop(); rainStop = null; }
-- if (glStop) { glStop(); glStop = null; }
-- el.classList.add('leaving');
-- setTimeout(() => el.remove(), REDUCED ? 0 : 480);
-- }
--
-- function countUp(el, target, duration, delay, onDone) {
-- if (!el) return;
-- const t0 = performance.now() + (delay || 0);
-- const ease = (t) => 1 - Math.pow(2, -10 * t); // easeOutExpo
-- function frame(now) {
-- const t = Math.min(1, Math.max(0, (now - t0) / duration));
-- const fmt = (n) => (cfg.amountMin < cfg.amount ? `$${cfg.amountMin}–$${n}` : `$${n}`);
-- el.textContent = fmt(Math.round(ease(t) * target));
-- if (t < 1) requestAnimationFrame(frame);
-- else { el.textContent = fmt(target); el.parentElement?.classList.add('pop'); if (onDone) onDone(); }
-- }
-- requestAnimationFrame(frame);
-- }
--
-- // ── WebGL aurora: a tiny fragment shader (no library). Falls back to the CSS aurora silently. ──
-- function startShader(canvas) {
-- if (!canvas) return () => {};
-- let gl = null;
-- try { gl = canvas.getContext('webgl', { alpha: true, antialias: false, powerPreference: 'low-power' }); } catch { gl = null; }
-- if (!gl) { canvas.remove(); return () => {}; }
-- const vs = 'attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}';
-- const fs = `precision mediump float;uniform vec2 r;uniform float t;uniform vec2 m;
-- float n(vec2 p){return sin(p.x)*cos(p.y);}
-- void main(){vec2 uv=(gl_FragCoord.xy/r.xy);vec2 q=uv*2.-1.;q.x*=r.x/r.y;
-- float a=0.;vec2 mm=(m-.5)*.6;
-- for(int i=1;i<5;i++){float f=float(i);a+=n(q*f*1.3+vec2(t*.13*f+mm.x*f,t*.09*f+mm.y*f))/f;}
-- a=smoothstep(-.6,.9,a);
-- vec3 c1=vec3(.55,.36,.97);vec3 c2=vec3(.06,.65,.91);vec3 c3=vec3(.98,.75,.14);
-- vec3 col=mix(c1,c2,a);col=mix(col,c3,pow(max(0.,a-.55),2.)*1.8);
-- float v=smoothstep(1.35,.25,length(q*vec2(.8,1.)));
-- gl_FragColor=vec4(col*a*v*.85,a*v*.9);}`;
-- const sh = (type, src) => { const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); return s; };
-- const prog = gl.createProgram(); gl.attachShader(prog, sh(gl.VERTEX_SHADER, vs)); gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, fs)); gl.linkProgram(prog);
-- if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { canvas.remove(); return () => {}; }
-- gl.useProgram(prog);
-- const buf = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buf); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
-- const loc = gl.getAttribLocation(prog, 'p'); gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
-- const uR = gl.getUniformLocation(prog, 'r'), uT = gl.getUniformLocation(prog, 't'), uM = gl.getUniformLocation(prog, 'm');
-- gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
-- let raf = 0, running = true, mx = 0.5, my = 0.5;
-- const resize = () => { const s = Math.min(1, 1200 / Math.max(1, canvas.clientWidth)); canvas.width = Math.max(1, Math.round(canvas.clientWidth * s * 0.6)); canvas.height = Math.max(1, Math.round(canvas.clientHeight * s * 0.6)); gl.viewport(0, 0, canvas.width, canvas.height); };
-- const onMove = (e) => { mx = e.clientX / window.innerWidth; my = 1 - e.clientY / window.innerHeight; };
-- const t0 = performance.now();
-- const frame = (now) => { if (!running) return; gl.uniform2f(uR, canvas.width, canvas.height); gl.uniform1f(uT, (now - t0) / 1000); gl.uniform2f(uM, mx, my); gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); raf = requestAnimationFrame(frame); };
-- const onVis = () => { if (document.hidden) { running = false; cancelAnimationFrame(raf); } else if (!running) { running = true; raf = requestAnimationFrame(frame); } };
-- resize(); window.addEventListener('resize', resize); window.addEventListener('pointermove', onMove); document.addEventListener('visibilitychange', onVis);
-- raf = requestAnimationFrame(frame);
-- return () => { running = false; cancelAnimationFrame(raf); window.removeEventListener('resize', resize); window.removeEventListener('pointermove', onMove); document.removeEventListener('visibilitychange', onVis); };
-- }
-- // Mouse parallax: the content and the grid drift against the pointer.
-- function attachParallax(root) {
-- if (COARSE) return;
-- const content = root.querySelector('.rs-tk-content'), grid = root.querySelector('.rs-tk-grid'), amount = root.querySelector('.rs-tk-amount');
-- let raf = 0;
-- root.addEventListener('pointermove', (e) => {
-- const px = e.clientX / window.innerWidth - 0.5, py = e.clientY / window.innerHeight - 0.5;
-- cancelAnimationFrame(raf);
-- raf = requestAnimationFrame(() => {
-- if (content) content.style.transform = `translate(${(-px * 14).toFixed(1)}px, ${(-py * 10).toFixed(1)}px)`;
-- if (grid) grid.style.transform = `perspective(600px) rotateX(64deg) translateX(${(px * 40).toFixed(1)}px)`;
-- if (amount) amount.style.setProperty('--tilt', `rotateY(${(px * 18).toFixed(1)}deg) rotateX(${(-py * 12).toFixed(1)}deg)`);
-- });
-- });
-- }
-- // Typewriter cycling the pleas.
-- function typewriter(el, lines) {
-- if (!el) return;
-- let li = 0, ci = 0, del = false, t = null;
-- const step = () => {
-- if (!el.isConnected) return;
-- const line = lines[li % lines.length];
-- if (!del) { ci++; el.textContent = line.slice(0, ci); if (ci >= line.length) { del = true; t = setTimeout(step, 1400); return; } t = setTimeout(step, 34 + Math.random() * 40); }
-- else { ci--; el.textContent = line.slice(0, ci); if (ci <= 0) { del = false; li++; t = setTimeout(step, 260); return; } t = setTimeout(step, 16); }
-- };
-- t = setTimeout(step, 1200);
-- }
-- // Screen shake on the amount landing.
-- function shake(el) { if (!el) return; el.classList.remove('rs-shake'); void el.offsetWidth; el.classList.add('rs-shake'); setTimeout(() => el.classList.remove('rs-shake'), 600); }
-- // Coin burst from the centre.
-- function coinBurst(canvas) {
-- if (!canvas) return;
-- const ctx = canvas.getContext('2d');
-- const dpr = Math.min(2, window.devicePixelRatio || 1);
-- const w = canvas.clientWidth, h = canvas.clientHeight;
-- canvas.width = w * dpr; canvas.height = h * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
-- const parts = [];
-- for (let i = 0; i < 70; i++) { const a = Math.random() * Math.PI * 2, sp = 260 + Math.random() * 520; parts.push({ x: w / 2, y: h * 0.42, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 200, r: Math.random() * Math.PI, vr: (Math.random() - 0.5) * 14, s: 14 + Math.random() * 18, g: ['🪙', '💸', '💵', '💰'][(Math.random() * 4) | 0], t: 0, life: 1.3 + Math.random() * 0.6 }); }
-- let last = 0, raf = 0;
-- const tick = (now) => {
-- const dt = Math.min(0.04, (now - (last || now)) / 1000); last = now;
-- ctx.clearRect(0, 0, w, h); let alive = 0;
-- ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
-- for (const p of parts) { p.t += dt; if (p.t > p.life) continue; alive++; p.vy += 900 * dt; p.x += p.vx * dt; p.y += p.vy * dt; p.r += p.vr * dt; ctx.save(); ctx.globalAlpha = Math.max(0, 1 - p.t / p.life); ctx.translate(p.x, p.y); ctx.rotate(p.r); ctx.font = `${p.s}px "Segoe UI Emoji","Apple Color Emoji","Noto Color Emoji",sans-serif`; ctx.fillText(p.g, 0, 0); ctx.restore(); }
-- if (alive) raf = requestAnimationFrame(tick); else ctx.clearRect(0, 0, w, h);
-- };
-- raf = requestAnimationFrame(tick);
-- setTimeout(() => cancelAnimationFrame(raf), 2600);
-- }
-- // Odometer: numbers roll up with easing and a bump.
-- function odometer(el, delay) {
-- const target = Number(el.dataset.odo) || 0, prefix = el.dataset.prefix || '';
-- if (!target) { el.textContent = prefix + '0'; return; }
-- const t0 = performance.now() + (delay || 0), dur = 1400;
-- const ease = (t) => 1 - Math.pow(1 - t, 4);
-- (function frame(now) { const t = Math.min(1, Math.max(0, (now - t0) / dur)); const v = ease(t) * target; el.textContent = prefix + (Number.isInteger(target) ? Math.round(v) : v.toFixed(2)); if (t < 1) requestAnimationFrame(frame); else { el.textContent = prefix + target; el.classList.add('is-done'); } })(t0);
-- }
--
-- // Emoji particle rain: bills, coins, robots, hearts. Cheap (fillText), DPR-aware, pauses when hidden.
-- function startMoneyRain(canvas) {
-- if (!canvas) return () => {};
-- const ctx = canvas.getContext('2d');
-- const GLYPHS = ['💸', '💵', '🪙', '🤖', '💜', '💰', '🤖', '💸'];
-- let w = 0, h = 0, dpr = 1, raf = 0, running = true, last = 0;
-- const parts = [];
-- function resize() {
-- dpr = Math.min(2, window.devicePixelRatio || 1);
-- w = canvas.clientWidth; h = canvas.clientHeight;
-- canvas.width = w * dpr; canvas.height = h * dpr;
-- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
-- }
-- function spawn(initial) {
-- return {
-- g: GLYPHS[(Math.random() * GLYPHS.length) | 0],
-- x: Math.random() * w,
-- y: initial ? Math.random() * h : -40,
-- s: 18 + Math.random() * 26,
-- vy: 40 + Math.random() * 90,
-- vx: (Math.random() - 0.5) * 30,
-- r: Math.random() * Math.PI * 2,
-- vr: (Math.random() - 0.5) * 2.2,
-- sw: Math.random() * Math.PI * 2,
-- a: 0.55 + Math.random() * 0.45,
-- };
-- }
-- resize();
-- const N = Math.min(90, Math.max(35, Math.round((w * h) / 16000)));
-- for (let i = 0; i < N; i++) parts.push(spawn(true));
-- function tick(now) {
-- if (!running) return;
-- const dt = Math.min(0.05, (now - (last || now)) / 1000); last = now;
-- ctx.clearRect(0, 0, w, h);
-- ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
-- for (let i = 0; i < parts.length; i++) {
-- const p = parts[i];
-- p.sw += dt * 1.6;
-- p.x += (p.vx + Math.sin(p.sw) * 22) * dt;
-- p.y += p.vy * dt;
-- p.r += p.vr * dt;
-- if (p.y > h + 50 || p.x < -60 || p.x > w + 60) parts[i] = spawn(false);
-- ctx.save();
-- ctx.globalAlpha = p.a;
-- ctx.translate(p.x, p.y); ctx.rotate(p.r);
-- ctx.font = `${p.s}px "Segoe UI Emoji","Apple Color Emoji","Noto Color Emoji",sans-serif`;
-- ctx.fillText(p.g, 0, 0);
-- ctx.restore();
-- }
-- 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);
-- return () => { running = false; cancelAnimationFrame(raf); window.removeEventListener('resize', resize); document.removeEventListener('visibilitychange', onVis); };
-- }
--
-- /* ─────────────────────────────────────────────────────────────
-- 3. HERO BOUNTY CARD (home page)
-- ───────────────────────────────────────────────────────────── */
-- function mountHeroCard() {
-- const mount = document.getElementById('rs-hero-mount');
-- if (!mount || mount.dataset.mounted) return;
-- mount.dataset.mounted = '1';
-- mount.innerHTML = `
-- <div class="rs-bills" aria-hidden="true"><span>💸</span><span>🪙</span><span>💵</span><span>💸</span><span>🤖</span></div>
-- <div class="rs-card-border">
-- <div class="rs-hero-card" id="rs-hero-card">
-- <div class="rs-card-glare" aria-hidden="true"></div>
-- <div class="rs-card-foil" aria-hidden="true"></div>
-- ${coinHtml()}
-- <div class="rs-card-body">
-- <div class="rs-card-kicker"><i class="fa-solid fa-robot"></i> RobotStreamer switch bonus</div>
-- <h3 class="rs-card-title">RobotStreamer people: I am begging. Leave Rick's site, stream only here, get <span class="rs-money">${money()}</span></h3>
-- <p class="rs-card-sub">Never stream on RobotStreamer again, stream only on OpenVibe.Live. Zelle, PayPal or crypto, out of my own pocket, because I genuinely care about you streaming here (that is the whole business plan). Every converter gets catered to: ask for a feature, I build it. Every line of this site is ${ghLink('open source on GitHub')}; theirs is closed. Already here? Bring a RobotStreamer streamer over and you get <b>${referral()}</b>.</p>
-- <div class="rs-card-chips">${chipsHtml()}</div>
-- ${restreamHtml(true)}
-- </div>
-- <div class="rs-card-action">
-- <button class="rs-btn rs-btn-gold" type="button" onclick="rsPromoOpenClaim()"><i class="fa-solid fa-sack-dollar"></i> Claim ${money()}</button>
-- <button class="rs-card-replay" type="button" onclick="rsPromoReplay()">replay the splash ✨</button>
-- ${isLoggedIn() ? `<button class="rs-card-replay" type="button" onclick="rsPromoOpenClaim()"><i class="fa-solid fa-wallet"></i> my payouts &amp; referral link</button>` : ''}
-- </div>
-- </div>
-- </div>`;
-- if (!REDUCED && !COARSE) attachTilt(mount.querySelector('.rs-card-border'), mount.querySelector('#rs-hero-card'));
-- }
-- // ── VIP recruiter card (home page, under the bounty card): the starfish bounty board ──
-- function mountVipCard() {
-- const mount = document.getElementById('rs-hero-mount');
-- if (!mount || !cfg.vip || mount.querySelector('.rs-vip')) return;
-- const v = cfg.vip;
-- const me = window.currentUser && String(window.currentUser.username || '').toLowerCase() === String(v.username).toLowerCase();
-- const name = esc(v.username);
-- const el = document.createElement('div');
-- el.className = 'rs-vip';
-- el.innerHTML = `
-- <div class="rs-vip-bubbles" aria-hidden="true"><span></span><span></span><span></span><span></span><span></span><span></span></div>
-- <div class="rs-vip-star" aria-hidden="true">
-- <svg viewBox="0 0 120 120" width="120" height="120">
-- <defs><radialGradient id="rsVipG" cx="40%" cy="35%" r="70%"><stop offset="0%" stop-color="#ffb3d1"/><stop offset="100%" stop-color="#ff5fa2"/></radialGradient></defs>
-- <path class="rs-vip-body" d="M60 6 L74 42 L112 44 L82 68 L93 106 L60 84 L27 106 L38 68 L8 44 L46 42 Z" fill="url(#rsVipG)" stroke="#c2185b" stroke-width="3" stroke-linejoin="round"/>
-- <circle cx="48" cy="52" r="7" fill="#fff"/><circle cx="72" cy="52" r="7" fill="#fff"/>
-- <circle class="rs-vip-pupil" cx="50" cy="53" r="3" fill="#222"/><circle class="rs-vip-pupil" cx="74" cy="53" r="3" fill="#222"/>
-- <path d="M46 68 Q60 82 74 68" stroke="#7a1140" stroke-width="3.5" fill="none" stroke-linecap="round"/>
-- <circle cx="36" cy="64" r="3" fill="#ff8ac0" opacity="0.8"/><circle cx="84" cy="64" r="3" fill="#ff8ac0" opacity="0.8"/>
-- </svg>
-- <span class="rs-vip-sparkle">✦</span><span class="rs-vip-sparkle">✦</span><span class="rs-vip-sparkle">✦</span>
-- </div>
-- <div class="rs-vip-body-text">
-- <div class="rs-vip-kicker"><i class="fa-solid fa-star"></i> ${name}'s bounty board</div>
-- <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);
-- }
--
-- // ── Home: the scoreboard (OpenVibe vs RobotStreamer) + the receipts wall (who got paid) ──
-- async function mountScoreboard() {
-- const mount = document.getElementById('rs-hero-mount');
-- if (!mount || mount.querySelector('.rs-board')) return;
-- const rows = [
-- ['Every line of code public on GitHub', 'yes', 'no'],
-- ['Pays YOU to stream here', `${money()} + referrals`, 'no'],
-- ['Restream to Twitch, YouTube, Kick, RTMP — and RobotStreamer', 'yes, from one stream', '?'],
-- ['Sub-second WebRTC latency', 'yes', '?'],
-- ['7TV / BTTV / FFZ + custom emotes', 'yes', '?'],
-- ['Sound commands & soundboard', 'yes', '?'],
-- ['Robot & hardware controls', 'yes', 'yes'],
-- ['Features built because you asked', 'yes, by me, this week', '?'],
-- ['Owner personally begging you to stream', 'yes (see above)', 'no'],
-- ];
-- const cell = (v, side) => v === 'yes' ? `<span class="rs-yes"><i class="fa-solid fa-check"></i></span>` : v === 'no' ? `<span class="rs-no"><i class="fa-solid fa-xmark"></i></span>` : v === '?' ? `<span class="rs-meh" title="couldn't tell you — it's closed source">?</span>` : `<span class="rs-yes rs-yes-text"><i class="fa-solid fa-check"></i> ${esc(v)}</span>`;
-- const el = document.createElement('div');
-- el.className = 'rs-board';
-- el.innerHTML = `
-- <div class="rs-board-head"><i class="fa-solid fa-scale-unbalanced"></i> The scoreboard <small>why streamers are leaving Rick's site</small></div>
-- <div class="rs-board-cols"><span></span><span class="rs-board-col rs-board-us"><i class="fa-solid fa-circle-nodes"></i> OpenVibe.Live</span><span class="rs-board-col rs-board-them"><i class="fa-solid fa-robot"></i> RobotStreamer</span></div>
-- ${rows.map((r, i) => `<div class="rs-board-row" style="--i:${i}"><span class="rs-board-feat">${esc(r[0])}</span>${cell(r[1])}${cell(r[2])}</div>`).join('')}
-- <div class="rs-odos rs-odos-home">
-- <div class="rs-odo"><b data-odo="${Number(cfg.totals?.converted || 0)}">0</b><small>converted</small></div>
-- <div class="rs-odo"><b data-odo="${Number(cfg.totals?.paid || 0)}" data-prefix="$">$0</b><small>paid out</small></div>
-- <div class="rs-odo"><b data-odo="${Number(cfg.totals?.claims || 0)}">0</b><small>claims</small></div>
-- </div>
-- <div class="rs-receipts" id="rs-receipts"></div>`;
-- mount.appendChild(el);
-- const startOdo = () => el.querySelectorAll('[data-odo]').forEach(o => { if (REDUCED) o.textContent = (o.dataset.prefix || '') + o.dataset.odo; else odometer(o, 200); });
-- if ('IntersectionObserver' in window) { const io = new IntersectionObserver((en) => { if (en.some(x => x.isIntersecting)) { el.classList.add('is-in'); startOdo(); io.disconnect(); } }, { threshold: 0.2 }); io.observe(el); } else { el.classList.add('is-in'); startOdo(); }
-- try {
-- const r = await fetch('/api/promo/receipts', { credentials: 'same-origin' }); const j = r.ok ? await r.json() : null;
-- const box = el.querySelector('#rs-receipts');
-- if (j && j.receipts && j.receipts.length) {
-- box.innerHTML = `<div class="rs-receipts-head"><i class="fa-solid fa-receipt"></i> Receipts <small>real people, real money, out of my pocket</small></div><div class="rs-receipts-row">${j.receipts.map((x, i) => `<span class="rs-receipt-chip" style="--i:${i}">${x.avatar_url ? `<img src="${esc(x.avatar_url)}" alt="">` : '<i class="fa-solid fa-user"></i>'} <b>${esc(x.display_name || x.username)}</b> <span class="rs-money">$${esc(String(x.amount))}</span> <small>${x.kind === 'referral' ? 'referral' : 'switched'}</small></span>`).join('')}</div>`;
-- } else {
-- box.innerHTML = `<div class="rs-receipts-head"><i class="fa-solid fa-receipt"></i> Receipts <small>nobody has claimed yet — be the first name on this wall</small></div>`;
-- }
-- } catch { /* */ }
-- }
--
-- function attachTilt(zone, card) {
-- if (!zone || !card) return;
-- let raf = 0;
-- zone.addEventListener('pointermove', (e) => {
-- const r = card.getBoundingClientRect();
-- const px = (e.clientX - r.left) / r.width, py = (e.clientY - r.top) / r.height;
-- cancelAnimationFrame(raf);
-- raf = requestAnimationFrame(() => {
-- card.style.setProperty('--rs-ry', `${((px - 0.5) * 14).toFixed(2)}deg`);
-- card.style.setProperty('--rs-rx', `${((0.5 - py) * 10).toFixed(2)}deg`);
-- card.style.setProperty('--rs-mx', `${(px * 100).toFixed(1)}%`);
-- card.style.setProperty('--rs-my', `${(py * 100).toFixed(1)}%`);
-- card.style.setProperty('--rs-glare', '1');
-- });
-- });
-- zone.addEventListener('pointerleave', () => {
-- cancelAnimationFrame(raf);
-- card.style.transition = 'transform 0.5s cubic-bezier(.2,1.4,.3,1)';
-- card.style.setProperty('--rs-rx', '0deg'); card.style.setProperty('--rs-ry', '0deg'); card.style.setProperty('--rs-glare', '0');
-- setTimeout(() => { card.style.transition = ''; }, 500);
-- });
-- }
--
-- /* ─────────────────────────────────────────────────────────────
-- 3b. STREAM-PAGE ALERT — on every channel page, under the player.
-- Never hidden: it collapses to a slim bar (remembered per session).
-- ───────────────────────────────────────────────────────────── */
-- function mountStreamAlert() {
-- // One mount inside the live layout, one inside the offline layout — whichever is showing.
-- // The offline one is (re)created on demand in case the channel page rebuilt its offline area.
-- const offHead = document.getElementById('ch-offline-header');
-- if (offHead && !document.getElementById('rs-stream-alert-offline')) {
-- const m = document.createElement('div'); m.className = 'rs-stream-alert-mount'; m.id = 'rs-stream-alert-offline';
-- offHead.parentNode.insertBefore(m, offHead);
-- }
-- document.querySelectorAll('.rs-stream-alert-mount').forEach(mountStreamAlertInto);
-- }
-- // The SPA swaps pages without reloading — re-check the channel page each time it becomes active.
-- function watchChannelPage() {
-- const page = document.getElementById('page-channel');
-- if (!page || !('MutationObserver' in window)) return;
-- new MutationObserver(() => { if (page.classList.contains('active')) mountStreamAlert(); }).observe(page, { attributes: true, attributeFilter: ['class'] });
-- }
-- function mountStreamAlertInto(mount) {
-- if (!mount || mount.dataset.mounted) return;
-- mount.dataset.mounted = '1';
-- const minimized = store.get(STREAM_ALERT_KEY, true) === '1';
-- mount.innerHTML = `
-- <div class="rs-stream-alert${minimized ? ' minimized' : ''}" role="region" aria-label="RobotStreamer switch bonus">
-- <div class="rs-sa-stripe" aria-hidden="true"></div>
-- <div class="rs-sa-body">
-- <div class="rs-sa-siren" aria-hidden="true"><i class="fa-solid fa-robot"></i></div>
-- <div class="rs-sa-text">
-- <div class="rs-sa-head">🚨 RobotStreamer streamers: I am DESPERATE. <span class="rs-money">${money()}</span> to leave Rick's trash site for good</div>
-- <div class="rs-sa-sub">Never stream on RobotStreamer again, stream <b>only</b> on OpenVibe.Live, and I pay you <b>${money()}</b> by Zelle, PayPal or crypto — out of my own pocket, because I actually care about you streaming here. Converters get catered to: <b>ask for a feature and I build it.</b> Every line of this site is ${ghLink('open source')} — theirs is closed. Bring a RobotStreamer streamer over and <b>you</b> get ${referral()} too.</div>
-- <div class="rs-sa-chips">${chipsHtml()}</div>
-- ${restreamHtml(true)}
-- </div>
-- <div class="rs-sa-actions">
-- <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-ghost" type="button" onclick="rsPromoReplay()"><i class="fa-solid fa-circle-info"></i> How it works</button>
-- </div>
-- </div>
-- <div class="rs-sa-mini" onclick="rsPromoToggleStreamAlert()">
-- <i class="fa-solid fa-robot"></i> <span>RobotStreamer streamers: I'm begging — <span class="rs-money">${money()}</span> (Zelle / PayPal / crypto) to leave Rick's site for good · restream everywhere from here</span>
-- <span class="rs-ticker-cta" onclick="event.stopPropagation(); rsPromoOpenClaim()">Claim</span>
-- </div>
-- <button class="rs-sa-toggle" type="button" onclick="rsPromoToggleStreamAlert()" title="Collapse / expand" aria-label="Collapse or expand the offer"><i class="fa-solid fa-chevron-up"></i></button>
-- </div>`;
-- }
-- function toggleStreamAlert() {
-- const els = document.querySelectorAll('.rs-stream-alert-mount .rs-stream-alert');
-- if (!els.length) return;
-- const min = !els[0].classList.contains('minimized');
-- els.forEach(el => el.classList.toggle('minimized', min));
-- store.set(STREAM_ALERT_KEY, min ? '1' : '0', true);
-- }
--
-- /* ─────────────────────────────────────────────────────────────
-- 4. CLAIM MODAL
-- ───────────────────────────────────────────────────────────── */
-- const METHOD_NAME = { zelle: 'Zelle', paypal: 'PayPal', crypto: 'Crypto' };
-- function claimMessage(method) {
-- const via = METHOD_NAME[method] || 'Zelle';
-- const detail = method === 'crypto' ? 'My wallet (coin + network + address): ______' : `My ${via} is: ______`;
-- return `Hey! I'm coming over from RobotStreamer (RS username: ______). I'm done streaming on RS for good — OpenVibe.Live only from now on. I'd like to claim the ${money()} switch bonus via ${via}. ${detail} · Referred by (OpenVibe user, if any): ______ 🤖💸`;
-- }
-- function openClaim() {
-- if (modalEl) return;
-- const loggedIn = isLoggedIn();
-- const owner = esc(cfg.owner);
-- const discord = cfg.discord ? esc(cfg.discord) : '';
-- modalEl = document.createElement('div');
-- modalEl.className = 'rs-modal';
-- modalEl.setAttribute('role', 'dialog');
-- modalEl.setAttribute('aria-modal', 'true');
-- modalEl.setAttribute('aria-label', `Claim your ${money()} RobotStreamer switch bonus`);
-- modalEl.innerHTML = `
-- <div class="rs-modal-card">
-- <button class="rs-modal-close" type="button" aria-label="Close"><i class="fa-solid fa-xmark"></i></button>
-- <div class="rs-modal-head">
-- ${coinHtml()}
-- <div>
-- <h3>Claim your <span class="rs-money">${money()}</span></h3>
-- <p>I am desperate and not hiding it. The deal: you never stream on Rick's site again and stream only here. I pay you personally — Zelle, PayPal or crypto, out of my own pocket — I build whatever you ask for, and you keep restreaming everywhere you like, from here. I genuinely care about you streaming on my site. That is the plan. That is the whole plan.</p>
-- </div>
-- </div>
-- <div class="rs-toggle rs-toggle-3" data-method="zelle" role="group" aria-label="How do you want to get paid?">
-- <div class="rs-toggle-thumb" aria-hidden="true"></div>
-- <button type="button" data-method="zelle" aria-pressed="true"><span class="rs-zelle-mark">Z</span> Zelle</button>
-- <button type="button" data-method="paypal" aria-pressed="false"><i class="fa-brands fa-paypal"></i> PayPal</button>
-- <button type="button" data-method="crypto" aria-pressed="false"><i class="fa-brands fa-bitcoin"></i> Crypto</button>
-- </div>
-- <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>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>
-- <div class="rs-trust">
-- <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 ? '' : `<a class="rs-btn rs-btn-gold" href="/api/auth/sso/login"><i class="fa-solid fa-network-wired"></i> Sign in &amp; file your claim</a>`}
-- <div class="rs-row">
-- ${discord ? `<a class="rs-btn rs-btn-ghost" href="${discord}" target="_blank" rel="noopener noreferrer"><i class="fa-brands fa-discord"></i> 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>
-- <p class="rs-modal-fine">One bonus per RobotStreamer account · Zelle, PayPal or crypto · the deal is exclusivity: no more streaming on RobotStreamer, ever. Built and paid for out of pocket by one person who cares way too much — thanks for giving it a shot <i class="fa-solid fa-heart"></i></p>
-- </div>`;
-- 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 && !LITE) 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 && !LITE) 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; }
-- const tb = e.target.closest('.rs-toggle button');
-- 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 === 'golive') {
-- closeClaim();
-- }
-- }
-- function setMethod(method) {
-- const tg = modalEl?.querySelector('.rs-toggle'); if (!tg) return;
-- 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>';
-- }
-- async function copyMessage(btn) {
-- const text = modalEl?.querySelector('.rs-msg-text')?.textContent || '';
-- try { await navigator.clipboard.writeText(text); }
-- catch {
-- const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
-- document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); } catch { /* ignore */ } ta.remove();
-- }
-- btn.classList.add('copied', 'rs-wiggle'); btn.innerHTML = '<i class="fa-solid fa-check"></i>';
-- setTimeout(() => btn.classList.remove('rs-wiggle'), 450);
-- say('Claim message copied — paste it in your DM', 'success');
-- }
-- function closeClaim() {
-- if (!modalEl) return;
-- const el = modalEl; modalEl = null;
-- document.removeEventListener('keydown', onModalKey);
-- if (!takeoverEl) document.body.style.overflow = '';
-- el.classList.add('leaving');
-- setTimeout(() => el.remove(), REDUCED ? 0 : 260);
-- }
--
-- // Confetti burst from the bottom corners — gold, violet, RS blue, green.
-- function confettiBurst() {
-- const canvas = document.createElement('canvas');
-- canvas.className = 'rs-confetti';
-- (modalEl || document.body).appendChild(canvas);
-- const ctx = canvas.getContext('2d');
-- const dpr = Math.min(2, window.devicePixelRatio || 1);
-- const w = window.innerWidth, h = window.innerHeight;
-- canvas.width = w * dpr; canvas.height = h * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
-- const COLORS = ['#fbbf24', '#fde68a', '#8b5cf6', '#a78bfa', '#7dd3fc', '#22c55e', '#f472b6', '#ffffff'];
-- const parts = [];
-- const emit = (x, y, dir) => {
-- for (let i = 0; i < 90; i++) {
-- const ang = dir + (Math.random() - 0.5) * 1.1, sp = 520 + Math.random() * 520;
-- parts.push({ x, y, vx: Math.cos(ang) * sp, vy: Math.sin(ang) * sp, g: 900, r: Math.random() * Math.PI, vr: (Math.random() - 0.5) * 12,
-- w: 6 + Math.random() * 8, hgt: 4 + Math.random() * 6, c: COLORS[(Math.random() * COLORS.length) | 0], life: 1.6 + Math.random() * 0.8, t: 0, round: Math.random() < 0.3 });
-- }
-- };
-- emit(0, h, -Math.PI / 3.2); emit(w, h, -Math.PI + Math.PI / 3.2);
-- let last = 0, raf = 0;
-- function tick(now) {
-- const dt = Math.min(0.04, (now - (last || now)) / 1000); last = now;
-- ctx.clearRect(0, 0, w, h);
-- let alive = 0;
-- for (const p of parts) {
-- p.t += dt; if (p.t > p.life) continue; alive++;
-- p.vy += p.g * dt; p.vx *= 0.985; p.x += p.vx * dt; p.y += p.vy * dt; p.r += p.vr * dt;
-- ctx.save(); ctx.globalAlpha = Math.max(0, 1 - p.t / p.life); ctx.translate(p.x, p.y); ctx.rotate(p.r); ctx.fillStyle = p.c;
-- if (p.round) { ctx.beginPath(); ctx.arc(0, 0, p.hgt / 1.5, 0, Math.PI * 2); ctx.fill(); }
-- else ctx.fillRect(-p.w / 2, -p.hgt / 2, p.w, p.hgt);
-- ctx.restore();
-- }
-- if (alive) raf = requestAnimationFrame(tick); else canvas.remove();
-- }
-- raf = requestAnimationFrame(tick);
-- setTimeout(() => { cancelAnimationFrame(raf); canvas.remove(); }, 3200);
-- }
--
-- /* ─────────────────────────────────────────────────────────────
-- Boot
-- ───────────────────────────────────────────────────────────── */
-- async function loadConfig() {
-- try {
-- const r = await fetch('/api/promo/robotstreamer', { credentials: 'same-origin' });
-- if (!r.ok) return;
-- const j = await r.json();
-- if (typeof j.enabled === 'boolean') cfg.enabled = j.enabled;
-- if (Number.isFinite(j.amount) && j.amount > 0) cfg.amount = j.amount;
-- if (Number.isFinite(j.amount_min) && j.amount_min > 0) cfg.amountMin = j.amount_min;
-- if (Number.isFinite(j.referral) && j.referral > 0) cfg.referral = j.referral;
-- if (j.vip && j.vip.username) cfg.vip = j.vip;
-- if (j.totals) cfg.totals = j.totals;
-- if (j.github !== undefined) cfg.github = j.github || '';
-- if (j.owner) cfg.owner = String(j.owner);
-- if (j.discord !== undefined) cfg.discord = j.discord || '';
-- } catch { /* offline / dev without the route — defaults stand */ }
-- }
-- async function boot() {
-- if (!promoAllowedHere()) return;
-- await loadConfig();
-- if (!cfg.enabled) return;
-- mountTicker();
-- mountHeroCard();
-- mountVipCard();
-- mountScoreboard().catch(() => {});
-- mountStreamAlert();
-- watchChannelPage();
-- // Let the page settle (fonts, hero paint) before the splash slams in.
-- setTimeout(() => showTakeover(false), 700);
-- }
-- if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
-- else boot();
--
-- // Public hooks (hero card buttons, console, other modules)
-- window.rsPromoOpenClaim = openClaim;
-- window.rsPromoReplay = () => showTakeover(true);
-- window.rsPromoHideTicker = hideTicker;
-- window.rsPromoToggleStreamAlert = toggleStreamAlert;
--})();
ddiff --git a/server/config.js b/server/config.js
iindex 7532da76..6446adce 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -217,25 +217,6 @@ function buildConfig(registryValues) {
},
adminUsername: process.env.ADMIN_USERNAME || 'admin',
adminPassword: process.env.ADMIN_PASSWORD || 'changeme123',
-- // RobotStreamer switch bonus — the site-wide "$100 for converting" promo (public/js/rs-promo.js).
-- // RS_PROMO_ENABLED=false turns every promo surface off without a deploy of the frontend.
-- // Home-page "Star of OpenVibe" spotlight (username). The site setting `star_streamer`
-- // (admin settings API) overrides this env default.
-- starStreamer: process.env.STAR_STREAMER || '',
-- rsPromo: {
-- enabled: process.env.RS_PROMO_ENABLED !== 'false',
-- amount: parseInt(process.env.RS_PROMO_AMOUNT, 10) || 50, // top of the range
-- amountMin: parseInt(process.env.RS_PROMO_AMOUNT_MIN, 10) || 25, // bottom of the range
-- // Referral: an existing OpenVibe user who brings a RobotStreamer streamer over gets paid too.
-- referral: parseInt(process.env.RS_PROMO_REFERRAL, 10) || 10,
-- // VIP recruiter: one user with a higher per-conversion rate, spotlighted on the home page.
-- vipUser: process.env.RS_PROMO_VIP_USER || 'patrick',
-- vipReferral: parseInt(process.env.RS_PROMO_REFERRAL_VIP, 10) || 20,
-- github: process.env.RS_PROMO_GITHUB || 'https://github.com/OpenVibers/OpenVibe.Live',
-- // Who the claimant DMs in-site; defaults to the admin account.
-- owner: process.env.RS_PROMO_OWNER || process.env.ADMIN_USERNAME || 'admin',
-- discord: process.env.RS_PROMO_DISCORD || 'https://discord.gg/M6MuRUaeJj',
-- },
jwt: {
secret: process.env.JWT_SECRET || 'openvibelive-dev-secret-change-me',
expiresIn: '7d',
ddiff --git a/server/index.js b/server/index.js
iindex 0427cce9..ae46c48c 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -608,11 +608,6 @@ app.get('/api/health', (req, res) => {
});
});
--// ── 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');
const REPO_DIR = path.resolve(__dirname, '..');
ddiff --git a/server/promo/claims.js b/server/promo/claims.js
ddeleted file mode 100644
iindex 59887bb0..00000000
---- a/server/promo/claims.js
++++ /dev/null
@@@ -1,160 +0,0 @@
--/**
-- * promo/claims.js — the cashout ledger behind the RobotStreamer switch bonus.
-- *
-- * Every payout the promo promises is a ROW here instead of a DM:
-- * switch → a converted RobotStreamer streamer claims the $25–$50 (one per account)
-- * referral → created automatically for the referrer when a switch claim is approved
-- * (VIP recruiter rate when the referrer is RS_PROMO_VIP_USER)
-- *
-- * Lifecycle: pending → approved → paid (or rejected). The user sees their balance
-- * (pending / approved / paid) and requests a cashout; the owner approves and marks paid
-- * from the admin panel. Payout rails today are manual (Zelle / PayPal / crypto details on
-- * the claim); a Stripe rail plugs in on the same rows (method 'stripe', paid via Connect).
-- */
--'use strict';
--
--const db = require('../db/database');
--const config = require('../config');
--
--const METHODS = new Set(['zelle', 'paypal', 'crypto', 'stripe']);
--const STATUSES = new Set(['pending', 'approved', 'paid', 'rejected', 'cashout_requested']);
--
--let _ready = false;
--function ensureTables() {
-- if (_ready) return;
-- db.run(`CREATE TABLE IF NOT EXISTS promo_claims (
-- id INTEGER PRIMARY KEY AUTOINCREMENT,
-- user_id INTEGER NOT NULL,
-- kind TEXT NOT NULL DEFAULT 'switch',
-- rs_username TEXT,
-- method TEXT NOT NULL DEFAULT 'zelle',
-- payout_detail TEXT,
-- referrer_user_id INTEGER,
-- referrer_username TEXT,
-- source_claim_id INTEGER,
-- amount_cents INTEGER NOT NULL DEFAULT 0,
-- status TEXT NOT NULL DEFAULT 'pending',
-- note TEXT,
-- admin_note TEXT,
-- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-- paid_at DATETIME
-- )`);
-- db.run('CREATE INDEX IF NOT EXISTS idx_promo_claims_user ON promo_claims (user_id, id)');
-- db.run('CREATE INDEX IF NOT EXISTS idx_promo_claims_status ON promo_claims (status, id)');
-- _ready = true;
--}
--
--function promo() { return config.rsPromo || {}; }
--function clean(s, n) { return String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n); }
--function isVip(username) { const v = String(promo().vipUser || '').toLowerCase(); return !!v && String(username || '').toLowerCase() === v; }
--
--function view(row) {
-- if (!row) return null;
-- const u = db.getUserById(row.user_id);
-- return {
-- id: row.id, kind: row.kind, status: row.status, method: row.method, rs_username: row.rs_username || null,
-- payout_detail: row.payout_detail || null, referrer_username: row.referrer_username || null, source_claim_id: row.source_claim_id || null,
-- amount_cents: row.amount_cents || 0, amount: Number(((row.amount_cents || 0) / 100).toFixed(2)),
-- note: row.note || null, admin_note: row.admin_note || null,
-- user: u ? { id: u.id, username: u.username, display_name: u.display_name || u.username, avatar_url: u.avatar_url || null } : { id: row.user_id, username: `user${row.user_id}` },
-- created_at: row.created_at, updated_at: row.updated_at, paid_at: row.paid_at || null,
-- };
--}
--
--/** A converted streamer files their switch claim. One per account. */
--function submitSwitch(userId, { rs_username, method, payout_detail, referrer, note }) {
-- ensureTables();
-- const p = promo();
-- if (p.enabled === false) throw new Error('The promo is paused right now');
-- const rs = clean(rs_username, 60);
-- if (!rs) throw new Error('Your RobotStreamer username is required');
-- const m = String(method || 'zelle').toLowerCase();
-- if (!METHODS.has(m)) throw new Error('Pick Zelle, PayPal or crypto');
-- const detail = clean(payout_detail, 200);
-- if (m !== 'stripe' && detail.length < 3) throw new Error(`Add where to send it (${m === 'crypto' ? 'coin + network + address' : m === 'paypal' ? 'PayPal email' : 'Zelle phone or email'})`);
-- const existing = db.get(`SELECT * FROM promo_claims WHERE user_id = ? AND kind = 'switch' AND status != 'rejected'`, [userId]);
-- if (existing) throw new Error(`You already have a switch claim (#${existing.id}, ${existing.status})`);
-- let refUser = null;
-- const refName = clean(referrer, 60).replace(/^@/, '');
-- if (refName) {
-- refUser = db.getUserByUsername(refName);
-- if (!refUser) throw new Error(`No OpenVibe user called "${refName}" — check the referrer's username`);
-- if (refUser.id === userId) throw new Error("You can't refer yourself. Nice try.");
-- }
-- // The amount is settled at approval (the range is $25–$50); default to the top of the range.
-- const r = db.run(`INSERT INTO promo_claims (user_id, kind, rs_username, method, payout_detail, referrer_user_id, referrer_username, amount_cents, note)
-- VALUES (?, 'switch', ?, ?, ?, ?, ?, ?, ?)`, [userId, rs, m, detail || null, refUser ? refUser.id : null, refUser ? refUser.username : null, (p.amount || 50) * 100, clean(note, 300) || null]);
-- const claim = db.get('SELECT * FROM promo_claims WHERE id = ?', [Number(r.lastInsertRowid)]);
-- console.log(`[Promo] switch claim #${claim.id} by user ${userId} (RS ${rs}, ${m}${refUser ? `, referred by ${refUser.username}` : ''})`);
-- try { require('../utils/notify').pushNotification({ user_id: userId, type: 'PROMO_CLAIM', title: 'Switch bonus claim received', message: `Claim #${claim.id} is in. You'll get a notification when it's approved and paid.`, icon: '💸', url: `${String(config.baseUrl || '').replace(/\/$/, '')}/` }); } catch { /* */ }
-- return view(claim);
--}
--
--/** Owner decision. Approving a switch claim mints the referrer's referral claim. */
--function setStatus(id, { status, amount, admin_note }, adminId) {
-- ensureTables();
-- const claim = db.get('SELECT * FROM promo_claims WHERE id = ?', [id]);
-- if (!claim) throw new Error('No such claim');
-- const st = String(status || '').toLowerCase();
-- if (!STATUSES.has(st)) throw new Error('Bad status');
-- const cents = amount != null && amount !== '' ? Math.round(Number(amount) * 100) : claim.amount_cents;
-- if (!Number.isFinite(cents) || cents < 0) throw new Error('Bad amount');
-- db.run(`UPDATE promo_claims SET status = ?, amount_cents = ?, admin_note = COALESCE(?, admin_note), updated_at = CURRENT_TIMESTAMP, paid_at = CASE WHEN ? = 'paid' THEN CURRENT_TIMESTAMP ELSE paid_at END WHERE id = ?`,
-- [st, cents, admin_note != null ? clean(admin_note, 300) : null, st, id]);
-- if (st === 'approved' && claim.kind === 'switch' && claim.referrer_user_id && claim.status !== 'approved' && claim.status !== 'paid') {
-- const already = db.get(`SELECT id FROM promo_claims WHERE kind = 'referral' AND source_claim_id = ?`, [id]);
-- if (!already) {
-- const p = promo();
-- const refAmount = isVip(claim.referrer_username) ? (p.vipReferral || 20) : (p.referral || 10);
-- db.run(`INSERT INTO promo_claims (user_id, kind, rs_username, method, payout_detail, source_claim_id, amount_cents, status, note)
-- VALUES (?, 'referral', ?, 'zelle', NULL, ?, ?, 'approved', ?)`, [claim.referrer_user_id, claim.rs_username, id, refAmount * 100, `Referral for switch claim #${id}`]);
-- try { require('../utils/notify').pushNotification({ user_id: claim.referrer_user_id, type: 'PROMO_REFERRAL', title: `Referral approved: $${refAmount}`, message: `${claim.rs_username} came over from RobotStreamer and named you. Add your payout details to cash out.`, icon: '💰', url: `${String(config.baseUrl || '').replace(/\/$/, '')}/` }); } catch { /* */ }
-- }
-- }
-- if (st === 'approved' || st === 'paid' || st === 'rejected') {
-- try { require('../utils/notify').pushNotification({ user_id: claim.user_id, type: 'PROMO_CLAIM', title: st === 'paid' ? `Paid: $${(cents / 100).toFixed(2)}` : st === 'approved' ? `Approved: $${(cents / 100).toFixed(2)}` : 'Claim not approved', message: st === 'paid' ? 'Sent. Thanks for switching.' : st === 'approved' ? 'Your switch bonus is approved — payout is on its way.' : (admin_note ? clean(admin_note, 200) : 'Reach out on Discord if you think this is wrong.'), icon: st === 'rejected' ? '😬' : '💸', url: `${String(config.baseUrl || '').replace(/\/$/, '')}/` }); } catch { /* */ }
-- }
-- return view(db.get('SELECT * FROM promo_claims WHERE id = ?', [id]));
--}
--
--/** The user updates payout details on their own claim (e.g. a referral row minted without any). */
--function setPayout(id, userId, { method, payout_detail }) {
-- ensureTables();
-- const claim = db.get('SELECT * FROM promo_claims WHERE id = ? AND user_id = ?', [id, userId]);
-- if (!claim) throw new Error('No such claim');
-- if (claim.status === 'paid' || claim.status === 'rejected') throw new Error('That claim is closed');
-- const m = String(method || claim.method).toLowerCase();
-- if (!METHODS.has(m)) throw new Error('Pick Zelle, PayPal or crypto');
-- const detail = clean(payout_detail, 200);
-- if (detail.length < 3) throw new Error('Add where to send it');
-- db.run('UPDATE promo_claims SET method = ?, payout_detail = ?, status = CASE WHEN status = ? THEN ? ELSE status END, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [m, detail, 'approved', 'cashout_requested', id]);
-- return view(db.get('SELECT * FROM promo_claims WHERE id = ?', [id]));
--}
--
--function mine(userId) {
-- ensureTables();
-- const rows = db.all('SELECT * FROM promo_claims WHERE user_id = ? ORDER BY id DESC', [userId]).map(view);
-- const sum = (f) => Number((rows.filter(f).reduce((n, r) => n + r.amount_cents, 0) / 100).toFixed(2));
-- return {
-- claims: rows,
-- balance: { pending: sum(r => r.status === 'pending'), approved: sum(r => r.status === 'approved' || r.status === 'cashout_requested'), paid: sum(r => r.status === 'paid') },
-- has_switch_claim: rows.some(r => r.kind === 'switch' && r.status !== 'rejected'),
-- referral_rate: isVip(db.getUserById(userId)?.username) ? (promo().vipReferral || 20) : (promo().referral || 10),
-- };
--}
--
--function listAll({ status = null, limit = 200 } = {}) {
-- ensureTables();
-- const rows = status ? db.all('SELECT * FROM promo_claims WHERE status = ? ORDER BY id DESC LIMIT ?', [status, limit]) : db.all('SELECT * FROM promo_claims ORDER BY CASE status WHEN ? THEN 0 WHEN ? THEN 1 WHEN ? THEN 2 ELSE 3 END, id DESC LIMIT ?', ['pending', 'cashout_requested', 'approved', limit]);
-- const totals = db.get(`SELECT COALESCE(SUM(CASE WHEN status = 'pending' THEN amount_cents END), 0) AS pending, COALESCE(SUM(CASE WHEN status IN ('approved', 'cashout_requested') THEN amount_cents END), 0) AS approved, COALESCE(SUM(CASE WHEN status = 'paid' THEN amount_cents END), 0) AS paid, COUNT(*) AS n FROM promo_claims`) || {};
-- return { claims: rows.map(view), totals: { pending: (totals.pending || 0) / 100, approved: (totals.approved || 0) / 100, paid: (totals.paid || 0) / 100, count: totals.n || 0 } };
--}
--
--function stats() {
-- ensureTables();
-- const t = db.get(`SELECT COUNT(*) AS claims, COALESCE(SUM(kind = 'switch' AND status IN ('approved', 'paid')), 0) AS converted, COALESCE(SUM(CASE WHEN status = 'paid' THEN amount_cents END), 0) AS paid_cents FROM promo_claims`) || {};
-- return { claims: t.claims || 0, converted: t.converted || 0, paid: (t.paid_cents || 0) / 100 };
--}
--
--module.exports = { ensureTables, submitSwitch, setStatus, setPayout, mine, listAll, stats, view, METHODS, STATUSES };
ddiff --git a/server/promo/routes.js b/server/promo/routes.js
ddeleted file mode 100644
iindex dc4e1d35..00000000
---- a/server/promo/routes.js
++++ /dev/null
@@@ -1,56 +0,0 @@
--/**
-- * 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 });
--});
--
--// Public social proof: who got paid (username + amount + when). Only PAID rows, no payout details.
--router.get('/receipts', (req, res) => {
-- try {
-- res.set('Cache-Control', 'public, max-age=60');
-- const rows = claims.listAll({ status: 'paid', limit: 30 }).claims.map(c => ({ username: c.user.username, display_name: c.user.display_name, avatar_url: c.user.avatar_url, amount: c.amount, kind: c.kind, paid_at: c.paid_at }));
-- res.json({ receipts: rows, totals: claims.stats() });
-- } catch (err) { res.status(500).json({ error: err.message }); }
--});
--
--router.get('/claims/mine', requireAuth, (req, res) => {
-- try { res.set('Cache-Control', 'no-store'); res.json(claims.mine(req.user.id)); } catch (err) { res.status(500).json({ error: err.message }); }
--});
--router.post('/claims', requireAuth, (req, res) => {
-- try { res.json({ ok: true, claim: claims.submitSwitch(req.user.id, req.body || {}), mine: claims.mine(req.user.id) }); } catch (err) { res.status(400).json({ error: err.message }); }
--});
--router.patch('/claims/:id/payout', requireAuth, (req, res) => {
-- try { res.json({ ok: true, claim: claims.setPayout(Number(req.params.id), req.user.id, req.body || {}), mine: claims.mine(req.user.id) }); } catch (err) { res.status(400).json({ error: err.message }); }
--});
--router.get('/claims', requireAuth, permissions.requireAdmin, (req, res) => {
-- try { res.set('Cache-Control', 'no-store'); res.json(claims.listAll({ status: req.query.status ? String(req.query.status) : null })); } catch (err) { res.status(500).json({ error: err.message }); }
--});
--router.patch('/claims/:id', requireAuth, permissions.requireAdmin, (req, res) => {
-- try { res.json({ ok: true, claim: claims.setStatus(Number(req.params.id), req.body || {}, req.user.id) }); } catch (err) { res.status(400).json({ error: err.message }); }
--});
--
--module.exports = router;
ddiff --git a/test/promo-claims.test.js b/test/promo-claims.test.js
ddeleted file mode 100644
iindex 9eb61394..00000000
---- a/test/promo-claims.test.js
++++ /dev/null
@@@ -1,73 +0,0 @@
--'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);