openvibers/openvibe.live Commits
Vibes/PowerChat money flows redesigned: balance-first donate, buy anywhere
Changes
@@@ -4995,7 +4995,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig<!-- Scripts --><script src="/js/theme-engine.js?v=4"></script><script src="/live-notify.js?v=3" defer></script>--<script src="/js/app.js?v=125" defer></script>++<script src="/js/app.js?v=126" defer></script><script src="/js/stream-player.js?v=21" defer></script><script src="/js/stream-pip.js?v=1" defer></script><script src="/js/chat-shared.js?v=3" defer></script>@@@ -5051,7 +5051,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig</div></div>--<script src="/js/vibes.js?v=8" defer></script>++<script src="/js/vibes.js?v=9" defer></script><script src="/js/opencoins.js?v=6" defer></script><script src="/js/cosmetics-ui.js?v=2" defer></script><!-- Broadcast modules — load order matters: state → devices → vods → workspace → core -->@@@ -654,6 +654,8 @@ function showModal(id) {overlay.classList.add('show');if (id === 'buy-funds' && typeof _initBuyBucks === 'function') _initBuyBucks();if (id === 'donate' && typeof _loadDonateGoals === 'function') _loadDonateGoals();++ // Balance-first donate flow + the streamer's direct-PowerChat option.++ if (id === 'donate' && typeof _initDonateModal === 'function') _initDonateModal();}function closeModal() {@@@ -47,7 +47,9 @@ async function _initBuyBucks() {if (!box) return;let cfg;try { cfg = await api('/payments/config'); } catch { cfg = null; }-- if (!cfg || !cfg.enabled) {++ // PowerChat purchases have their own enablement — the buy modal stays usable on++ // every channel even while the card/PayPal rails (payments master switch) are off.++ if (!cfg || (!cfg.enabled && !(cfg.providers && cfg.providers.powerchat))) {box.innerHTML = '<p class="muted" style="font-size:0.85rem;text-align:center">Purchases aren’t available right now.</p>';const pkgBox = document.getElementById('buy-packages'); if (pkgBox) pkgBox.innerHTML = '';return;@@@ -208,19 +210,54 @@ function openvibeBucksDonateModal() {<div class="form-group" id="donate-goal-wrap" style="display:none"><label>Put it toward a goal</label><select id="modal-donate-goal" class="form-input"></select>++ <div class="muted" style="font-size:0.75rem;margin-top:3px">Applies to Vibes donations AND PowerChat tips.</div></div>-- <button class="btn btn-primary btn-lg" onclick="doDonate()" style="width:100%;margin-top:8px">-- <i class="fa-solid fa-coins"></i> Donate++ <div class="muted" id="donate-balance-line" style="font-size:0.82rem;margin:2px 0 6px"></div>++ <button class="btn btn-primary btn-lg" onclick="doDonate()" style="width:100%;margin-top:4px">++ <i class="fa-solid fa-coins"></i> Donate Vibes</button>-- <button class="btn btn-lg" onclick="doPowerchatTip()" style="width:100%;margin-top:8px;background:#8b5cf6;color:#fff;border:none">-- <i class="fa-solid fa-bolt"></i> Tip real money via PowerChat-- </button>`;++ <button class="btn btn-outline" onclick="showModal('buy-funds')" style="width:100%;margin-top:8px">++ <i class="fa-solid fa-cart-plus"></i> Buy Vibes++ </button>++ <!-- Only shown when THIS streamer has their own PowerChat (money goes to them++ directly, no Vibes involved). Populated by _initDonatePowerchat. -->++ <div id="donate-powerchat-direct" style="display:none">++ <div class="muted" style="text-align:center;font-size:0.75rem;margin:10px 0 4px">— or skip Vibes entirely —</div>++ <button class="btn btn-lg" onclick="doPowerchatTip()" style="width:100%;background:#8b5cf6;color:#fff;border:none">++ <i class="fa-solid fa-bolt"></i> Tip real money via PowerChat++ </button>++ </div>`;++}++++// Donate modal boot: balance-first. No Vibes → send the viewer straight to Buy Vibes++// (works on every channel; includes the PowerChat purchase rail); with a balance,++// show it. Separately, streamers with their OWN PowerChat get the direct-tip option.++async function _initDonateModal() {++ try {++ const data = await api('/funds/balance');++ const bal = Math.round(data.balance || 0);++ const line = document.getElementById('donate-balance-line');++ if (line) line.innerHTML = `Your balance: <strong>${bal.toLocaleString()}</strong> Vibes`;++ if (bal < 1) {++ toast('You don’t have any Vibes yet — grab some first, then come back to donate! 🛒', 'info');++ showModal('buy-funds');++ return;++ }++ } catch { /* not logged in / balance unavailable — leave the form as-is */ }++ _initDonatePowerchat();++}++async function _initDonatePowerchat() {++ const box = document.getElementById('donate-powerchat-direct');++ const streamerId = _donateStreamerId();++ if (!box || !streamerId) return;++ try {++ const d = await api(`/powerchat/donate-link?streamer_id=${streamerId}`);++ if (d && d.mode === 'direct') box.style.display = '';++ } catch { /* streamer has no PowerChat — keep hidden */ }}--// Real-money tip through PowerChat: the streamer's own tip page when they have--// PowerChat connected, otherwise the site's PowerChat account (the streamer is then--// credited the full amount as cashout-able Vibes once the tip confirms).++// DIRECT real-money tip — only offered when the streamer has their own PowerChat.async function doPowerchatTip() {const streamerId = _donateStreamerId();if (!streamerId) return toast('No streamer selected', 'error');@@@ -226,11 +226,11 @@<!-- Chat feature scripts — mirror index.html so the popout has full parity.Order: dependencies (app/emotes/gif/uploads/coins/bucks/cosmetics) before chat.js. -->-- <script src="/js/app.js?v=125"></script>++ <script src="/js/app.js?v=126"></script><script src="/js/emotes.js?v=8"></script><script src="/js/gif-picker.js?v=1"></script><script src="/js/channel-uploads.js?v=12"></script>-- <script src="/js/vibes.js?v=8"></script>++ <script src="/js/vibes.js?v=9"></script><script src="/js/opencoins.js?v=6"></script><script src="/js/cosmetics-ui.js?v=2"></script><script src="/js/chat.js?v=75"></script>@@@ -123,11 +123,21 @@ function buildDonateLink(streamerUserId, donorUserId, { goalId = null } = {}) {// checkout too — without app_redirect_uri PowerChat only offers a plain// "Return to app" link and viewers just sat on the tip page.const opts = { purpose, returnTo: true };++ // Direct ONLY: money through PowerChat goes to a streamer exclusively via THEIR++ // OWN connected account. Streamers without PowerChat don't get site-routed tips++ // any more — the viewer buys Vibes through the site account instead (pcorder;++ // see /donate-link) and donates Vibes from their balance. The pcdon webhook++ // handler stays only to honor links already in flight.++ // ALWAYS send an app_ref: attribution fields (appPurpose included — that's how++ // the goal pick travels) only echo back for tips through our checkout link, and a++ // ref-less link may not count as one. "dontip:" refs fall through to the normal++ // donation pipeline on the webhook side.const direct = _checkoutConn(streamerUserId);-- if (direct) return { url: tipLinkFor(direct.powerchat_username, '', opts), mode: 'direct' };-- const site = getSiteAccount();-- if (!site) return null;-- return { url: tipLinkFor(site.username, `pcdon:${streamerUserId}:${donorUserId || 0}`, opts), mode: 'site' };++ if (direct) {++ const ref = `dontip:${streamerUserId}:${donorUserId || 0}`;++ return { url: tipLinkFor(direct.powerchat_username, ref, opts), mode: 'direct' };++ }++ return null;}// ── Webhook fulfillment ──────────────────────────────────────@@@ -215,27 +215,30 @@ router.get('/tip-link', requireAuth, (req, res) => {}});--// ── GET /donate-link — PowerChat tip link for ANY streamer (viewer-facing) ────--// ?streamer=<username>. Streamers with PowerChat get their own tip page (they keep--// the money; the webhook fires their celebration). Streamers without PowerChat get--// the site-wide account's page with a routing ref — the webhook then credits them--// the full amount as cashout-able Vibes and fires the same celebration.++// ── GET /donate-link — DIRECT PowerChat tip link (viewer-facing) ──────────────++// ?streamer=<username> or ?streamer_id=<id>, optional &goal_id. Only streamers with++// their OWN PowerChat connected get a link — money goes to them directly, their++// webhook fires the celebration, the site never mints anything. Streamers without++// PowerChat 404 here: viewers buy Vibes instead (Buy Vibes modal, any channel) and++// donate from their balance.router.get('/donate-link', optionalAuth, (req, res) => {try {const streamer = req.query.streamer_id? db.getUserById(parseInt(req.query.streamer_id, 10)): db.getUserByUsername(String(req.query.streamer || ''));if (!streamer) return res.status(404).json({ error: 'Streamer not found' });-- // Optional goal pick — rides in app_purpose ("goal:<id>") so the webhook-- // credits that exact goal. Only the streamer's own ACTIVE goals qualify.++++ // Optional goal pick — rides in app_purpose ("goal:<id>") so the streamer's++ // webhook credits that exact goal.let goalId = null;if (req.query.goal_id) {const g = db.getDonationGoalById(parseInt(req.query.goal_id, 10));if (g && Number(g.user_id) === Number(streamer.id) && g.is_active) goalId = g.id;}-- const link = require('./powerchat-checkout').buildDonateLink(streamer.id, req.user ? req.user.id : null, { goalId });-- if (!link) return res.status(404).json({ error: 'PowerChat tips are not available for this channel' });-- res.json({ url: link.url, mode: link.mode, goal_id: goalId });++++ const direct = require('./powerchat-checkout').buildDonateLink(streamer.id, req.user ? req.user.id : null, { goalId });++ if (!direct) return res.status(404).json({ error: 'This channel does not take PowerChat tips directly', direct: false });++ res.json({ url: direct.url, mode: 'direct', goal_id: goalId });} catch (err) {res.status(500).json({ error: 'Failed to build tip link' });}@@@ -22,8 +22,10 @@ router.get('/config', (req, res) => {// ── Buy Vibes ───────────────────────────────────────────router.post('/bucks/checkout', requireAuth, async (req, res) => {-- if (!pay.isEnabled()) return res.status(403).json({ error: 'Payments are not enabled' });const provider = String(req.body.provider || '').toLowerCase();++ // PowerChat purchases run on their own enablement (powerchat_enabled + site tips++ // account) — the master payments switch only gates the card/PayPal/crypto rails.++ if (!pay.isEnabled() && provider !== 'powerchat') return res.status(403).json({ error: 'Payments are not enabled' });// The client picks a Vibes amount (bit-style); the USD price is derived from the// volume-discount tiers so bigger buys are cheaper per buck and the platform keeps the spread.let bucks;@@@ -48,10 +48,11 @@ function publicConfig() {stripe: isEnabled() && b('stripe_enabled') && !!s('stripe_secret_key'),ccbill: isEnabled() && b('ccbill_enabled') && !!s('ccbill_flexform_id'),crypto: isEnabled() && b('crypto_enabled') && !!s('crypto_api_key'),-- // PowerChat tips as a money entry point — available once the site-wide-- // PowerChat account (powerchat_site_user_id) is connected with checkout-- // attribution. Lazy require: the checkout module requires this one back.-- powerchat: isEnabled() && (() => { try { return require('../integrations/powerchat-checkout').isAvailable(); } catch { return false; } })(),++ // PowerChat tips as a money entry point. Deliberately INDEPENDENT of the++ // payments master switch — it has its own enablement (powerchat_enabled +++ // the site tips account), so Vibes can be bought via PowerChat even while++ // the card/PayPal rails are off. Lazy require: circular with this module.++ powerchat: (() => { try { return require('../integrations/powerchat-checkout').isAvailable(); } catch { return false; } })(),},stripePublishableKey: s('stripe_publishable_key'),};
@@ -4995,7 +4995,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -4995,7 +4995,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<!-- Scripts -->
<!-- Scripts -->
<script src="/js/theme-engine.js?v=4"></script>
<script src="/js/theme-engine.js?v=4"></script>
<script src="/live-notify.js?v=3" defer></script>
<script src="/live-notify.js?v=3" defer></script>
-<script src="/js/app.js?v=125" defer></script>
+<script src="/js/app.js?v=126" defer></script>
<script src="/js/stream-player.js?v=21" defer></script>
<script src="/js/stream-player.js?v=21" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
@@ -5051,7 +5051,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -5051,7 +5051,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
</div>
</div>
</div>
</div>
-<script src="/js/vibes.js?v=8" defer></script>
+<script src="/js/vibes.js?v=9" defer></script>
<script src="/js/opencoins.js?v=6" defer></script>
<script src="/js/opencoins.js?v=6" defer></script>
<script src="/js/cosmetics-ui.js?v=2" defer></script>
<script src="/js/cosmetics-ui.js?v=2" defer></script>
<!-- Broadcast modules — load order matters: state → devices → vods → workspace → core -->
<!-- Broadcast modules — load order matters: state → devices → vods → workspace → core -->
@@ -654,6 +654,8 @@ function showModal(id) {
@@ -654,6 +654,8 @@ function showModal(id) {
overlay.classList.add('show');
overlay.classList.add('show');
if (id === 'buy-funds' && typeof _initBuyBucks === 'function') _initBuyBucks();
if (id === 'buy-funds' && typeof _initBuyBucks === 'function') _initBuyBucks();
if (id === 'donate' && typeof _loadDonateGoals === 'function') _loadDonateGoals();
if (id === 'donate' && typeof _loadDonateGoals === 'function') _loadDonateGoals();
+ // Balance-first donate flow + the streamer's direct-PowerChat option.
+ if (id === 'donate' && typeof _initDonateModal === 'function') _initDonateModal();
}
}
function closeModal() {
function closeModal() {
@@ -47,7 +47,9 @@ async function _initBuyBucks() {
@@ -47,7 +47,9 @@ async function _initBuyBucks() {
if (!box) return;
if (!box) return;
let cfg;
let cfg;
try { cfg = await api('/payments/config'); } catch { cfg = null; }
try { cfg = await api('/payments/config'); } catch { cfg = null; }
- if (!cfg || !cfg.enabled) {
+ // PowerChat purchases have their own enablement — the buy modal stays usable on
+ // every channel even while the card/PayPal rails (payments master switch) are off.
+ if (!cfg || (!cfg.enabled && !(cfg.providers && cfg.providers.powerchat))) {
box.innerHTML = '<p class="muted" style="font-size:0.85rem;text-align:center">Purchases aren’t available right now.</p>';
box.innerHTML = '<p class="muted" style="font-size:0.85rem;text-align:center">Purchases aren’t available right now.</p>';
const pkgBox = document.getElementById('buy-packages'); if (pkgBox) pkgBox.innerHTML = '';
const pkgBox = document.getElementById('buy-packages'); if (pkgBox) pkgBox.innerHTML = '';
return;
return;
@@ -208,19 +210,54 @@ function openvibeBucksDonateModal() {
@@ -208,19 +210,54 @@ function openvibeBucksDonateModal() {
<div class="form-group" id="donate-goal-wrap" style="display:none">
<div class="form-group" id="donate-goal-wrap" style="display:none">
<label>Put it toward a goal</label>
<label>Put it toward a goal</label>
<select id="modal-donate-goal" class="form-input"></select>
<select id="modal-donate-goal" class="form-input"></select>
+ <div class="muted" style="font-size:0.75rem;margin-top:3px">Applies to Vibes donations AND PowerChat tips.</div>
</div>
</div>
- <button class="btn btn-primary btn-lg" onclick="doDonate()" style="width:100%;margin-top:8px">
- <i class="fa-solid fa-coins"></i> Donate
+ <div class="muted" id="donate-balance-line" style="font-size:0.82rem;margin:2px 0 6px"></div>
+ <button class="btn btn-primary btn-lg" onclick="doDonate()" style="width:100%;margin-top:4px">
+ <i class="fa-solid fa-coins"></i> Donate Vibes
</button>
</button>
- <button class="btn btn-lg" onclick="doPowerchatTip()" style="width:100%;margin-top:8px;background:#8b5cf6;color:#fff;border:none">
- <i class="fa-solid fa-bolt"></i> Tip real money via PowerChat
- </button>`;
+ <button class="btn btn-outline" onclick="showModal('buy-funds')" style="width:100%;margin-top:8px">
+ <i class="fa-solid fa-cart-plus"></i> Buy Vibes
+ </button>
+ <!-- Only shown when THIS streamer has their own PowerChat (money goes to them
+ directly, no Vibes involved). Populated by _initDonatePowerchat. -->
+ <div id="donate-powerchat-direct" style="display:none">
+ <div class="muted" style="text-align:center;font-size:0.75rem;margin:10px 0 4px">— or skip Vibes entirely —</div>
+ <button class="btn btn-lg" onclick="doPowerchatTip()" style="width:100%;background:#8b5cf6;color:#fff;border:none">
+ <i class="fa-solid fa-bolt"></i> Tip real money via PowerChat
+ </button>
+ </div>`;
+}
+
+// Donate modal boot: balance-first. No Vibes → send the viewer straight to Buy Vibes
+// (works on every channel; includes the PowerChat purchase rail); with a balance,
+// show it. Separately, streamers with their OWN PowerChat get the direct-tip option.
+async function _initDonateModal() {
+ try {
+ const data = await api('/funds/balance');
+ const bal = Math.round(data.balance || 0);
+ const line = document.getElementById('donate-balance-line');
+ if (line) line.innerHTML = `Your balance: <strong>${bal.toLocaleString()}</strong> Vibes`;
+ if (bal < 1) {
+ toast('You don’t have any Vibes yet — grab some first, then come back to donate! 🛒', 'info');
+ showModal('buy-funds');
+ return;
+ }
+ } catch { /* not logged in / balance unavailable — leave the form as-is */ }
+ _initDonatePowerchat();
+}
+async function _initDonatePowerchat() {
+ const box = document.getElementById('donate-powerchat-direct');
+ const streamerId = _donateStreamerId();
+ if (!box || !streamerId) return;
+ try {
+ const d = await api(`/powerchat/donate-link?streamer_id=${streamerId}`);
+ if (d && d.mode === 'direct') box.style.display = '';
+ } catch { /* streamer has no PowerChat — keep hidden */ }
}
}
-// Real-money tip through PowerChat: the streamer's own tip page when they have
-// PowerChat connected, otherwise the site's PowerChat account (the streamer is then
-// credited the full amount as cashout-able Vibes once the tip confirms).
+// DIRECT real-money tip — only offered when the streamer has their own PowerChat.
async function doPowerchatTip() {
async function doPowerchatTip() {
const streamerId = _donateStreamerId();
const streamerId = _donateStreamerId();
if (!streamerId) return toast('No streamer selected', 'error');
if (!streamerId) return toast('No streamer selected', 'error');
@@ -226,11 +226,11 @@
@@ -226,11 +226,11 @@
<!-- Chat feature scripts — mirror index.html so the popout has full parity.
<!-- Chat feature scripts — mirror index.html so the popout has full parity.
Order: dependencies (app/emotes/gif/uploads/coins/bucks/cosmetics) before chat.js. -->
Order: dependencies (app/emotes/gif/uploads/coins/bucks/cosmetics) before chat.js. -->
- <script src="/js/app.js?v=125"></script>
+ <script src="/js/app.js?v=126"></script>
<script src="/js/emotes.js?v=8"></script>
<script src="/js/emotes.js?v=8"></script>
<script src="/js/gif-picker.js?v=1"></script>
<script src="/js/gif-picker.js?v=1"></script>
<script src="/js/channel-uploads.js?v=12"></script>
<script src="/js/channel-uploads.js?v=12"></script>
- <script src="/js/vibes.js?v=8"></script>
+ <script src="/js/vibes.js?v=9"></script>
<script src="/js/opencoins.js?v=6"></script>
<script src="/js/opencoins.js?v=6"></script>
<script src="/js/cosmetics-ui.js?v=2"></script>
<script src="/js/cosmetics-ui.js?v=2"></script>
<script src="/js/chat.js?v=75"></script>
<script src="/js/chat.js?v=75"></script>
@@ -123,11 +123,21 @@ function buildDonateLink(streamerUserId, donorUserId, { goalId = null } = {}) {
@@ -123,11 +123,21 @@ function buildDonateLink(streamerUserId, donorUserId, { goalId = null } = {}) {
// checkout too — without app_redirect_uri PowerChat only offers a plain
// checkout too — without app_redirect_uri PowerChat only offers a plain
// "Return to app" link and viewers just sat on the tip page.
// "Return to app" link and viewers just sat on the tip page.
const opts = { purpose, returnTo: true };
const opts = { purpose, returnTo: true };
+ // Direct ONLY: money through PowerChat goes to a streamer exclusively via THEIR
+ // OWN connected account. Streamers without PowerChat don't get site-routed tips
+ // any more — the viewer buys Vibes through the site account instead (pcorder;
+ // see /donate-link) and donates Vibes from their balance. The pcdon webhook
+ // handler stays only to honor links already in flight.
+ // ALWAYS send an app_ref: attribution fields (appPurpose included — that's how
+ // the goal pick travels) only echo back for tips through our checkout link, and a
+ // ref-less link may not count as one. "dontip:" refs fall through to the normal
+ // donation pipeline on the webhook side.
const direct = _checkoutConn(streamerUserId);
const direct = _checkoutConn(streamerUserId);
- if (direct) return { url: tipLinkFor(direct.powerchat_username, '', opts), mode: 'direct' };
- const site = getSiteAccount();
- if (!site) return null;
- return { url: tipLinkFor(site.username, `pcdon:${streamerUserId}:${donorUserId || 0}`, opts), mode: 'site' };
+ if (direct) {
+ const ref = `dontip:${streamerUserId}:${donorUserId || 0}`;
+ return { url: tipLinkFor(direct.powerchat_username, ref, opts), mode: 'direct' };
+ }
+ return null;
}
}
// ── Webhook fulfillment ──────────────────────────────────────
// ── Webhook fulfillment ──────────────────────────────────────
@@ -215,27 +215,30 @@ router.get('/tip-link', requireAuth, (req, res) => {
@@ -215,27 +215,30 @@ router.get('/tip-link', requireAuth, (req, res) => {
}
}
});
});
-// ── GET /donate-link — PowerChat tip link for ANY streamer (viewer-facing) ────
-// ?streamer=<username>. Streamers with PowerChat get their own tip page (they keep
-// the money; the webhook fires their celebration). Streamers without PowerChat get
-// the site-wide account's page with a routing ref — the webhook then credits them
-// the full amount as cashout-able Vibes and fires the same celebration.
+// ── GET /donate-link — DIRECT PowerChat tip link (viewer-facing) ──────────────
+// ?streamer=<username> or ?streamer_id=<id>, optional &goal_id. Only streamers with
+// their OWN PowerChat connected get a link — money goes to them directly, their
+// webhook fires the celebration, the site never mints anything. Streamers without
+// PowerChat 404 here: viewers buy Vibes instead (Buy Vibes modal, any channel) and
+// donate from their balance.
router.get('/donate-link', optionalAuth, (req, res) => {
router.get('/donate-link', optionalAuth, (req, res) => {
try {
try {
const streamer = req.query.streamer_id
const streamer = req.query.streamer_id
? db.getUserById(parseInt(req.query.streamer_id, 10))
? db.getUserById(parseInt(req.query.streamer_id, 10))
: db.getUserByUsername(String(req.query.streamer || ''));
: db.getUserByUsername(String(req.query.streamer || ''));
if (!streamer) return res.status(404).json({ error: 'Streamer not found' });
if (!streamer) return res.status(404).json({ error: 'Streamer not found' });
- // Optional goal pick — rides in app_purpose ("goal:<id>") so the webhook
- // credits that exact goal. Only the streamer's own ACTIVE goals qualify.
+
+ // Optional goal pick — rides in app_purpose ("goal:<id>") so the streamer's
+ // webhook credits that exact goal.
let goalId = null;
let goalId = null;
if (req.query.goal_id) {
if (req.query.goal_id) {
const g = db.getDonationGoalById(parseInt(req.query.goal_id, 10));
const g = db.getDonationGoalById(parseInt(req.query.goal_id, 10));
if (g && Number(g.user_id) === Number(streamer.id) && g.is_active) goalId = g.id;
if (g && Number(g.user_id) === Number(streamer.id) && g.is_active) goalId = g.id;
}
}
- const link = require('./powerchat-checkout').buildDonateLink(streamer.id, req.user ? req.user.id : null, { goalId });
- if (!link) return res.status(404).json({ error: 'PowerChat tips are not available for this channel' });
- res.json({ url: link.url, mode: link.mode, goal_id: goalId });
+
+ const direct = require('./powerchat-checkout').buildDonateLink(streamer.id, req.user ? req.user.id : null, { goalId });
+ if (!direct) return res.status(404).json({ error: 'This channel does not take PowerChat tips directly', direct: false });
+ res.json({ url: direct.url, mode: 'direct', goal_id: goalId });
} catch (err) {
} catch (err) {
res.status(500).json({ error: 'Failed to build tip link' });
res.status(500).json({ error: 'Failed to build tip link' });
}
}
@@ -22,8 +22,10 @@ router.get('/config', (req, res) => {
@@ -22,8 +22,10 @@ router.get('/config', (req, res) => {
// ── Buy Vibes ───────────────────────────────────────────
// ── Buy Vibes ───────────────────────────────────────────
router.post('/bucks/checkout', requireAuth, async (req, res) => {
router.post('/bucks/checkout', requireAuth, async (req, res) => {
- if (!pay.isEnabled()) return res.status(403).json({ error: 'Payments are not enabled' });
const provider = String(req.body.provider || '').toLowerCase();
const provider = String(req.body.provider || '').toLowerCase();
+ // PowerChat purchases run on their own enablement (powerchat_enabled + site tips
+ // account) — the master payments switch only gates the card/PayPal/crypto rails.
+ if (!pay.isEnabled() && provider !== 'powerchat') return res.status(403).json({ error: 'Payments are not enabled' });
// The client picks a Vibes amount (bit-style); the USD price is derived from the
// The client picks a Vibes amount (bit-style); the USD price is derived from the
// volume-discount tiers so bigger buys are cheaper per buck and the platform keeps the spread.
// volume-discount tiers so bigger buys are cheaper per buck and the platform keeps the spread.
let bucks;
let bucks;
@@ -48,10 +48,11 @@ function publicConfig() {
@@ -48,10 +48,11 @@ function publicConfig() {
stripe: isEnabled() && b('stripe_enabled') && !!s('stripe_secret_key'),
stripe: isEnabled() && b('stripe_enabled') && !!s('stripe_secret_key'),
ccbill: isEnabled() && b('ccbill_enabled') && !!s('ccbill_flexform_id'),
ccbill: isEnabled() && b('ccbill_enabled') && !!s('ccbill_flexform_id'),
crypto: isEnabled() && b('crypto_enabled') && !!s('crypto_api_key'),
crypto: isEnabled() && b('crypto_enabled') && !!s('crypto_api_key'),
- // PowerChat tips as a money entry point — available once the site-wide
- // PowerChat account (powerchat_site_user_id) is connected with checkout
- // attribution. Lazy require: the checkout module requires this one back.
- powerchat: isEnabled() && (() => { try { return require('../integrations/powerchat-checkout').isAvailable(); } catch { return false; } })(),
+ // PowerChat tips as a money entry point. Deliberately INDEPENDENT of the
+ // payments master switch — it has its own enablement (powerchat_enabled +
+ // the site tips account), so Vibes can be bought via PowerChat even while
+ // the card/PayPal rails are off. Lazy require: circular with this module.
+ powerchat: (() => { try { return require('../integrations/powerchat-checkout').isAvailable(); } catch { return false; } })(),
},
},
stripePublishableKey: s('stripe_publishable_key'),
stripePublishableKey: s('stripe_publishable_key'),
};
};