mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-18 14:51:26 +02:00
501 lines
24 KiB
JavaScript
501 lines
24 KiB
JavaScript
/**
|
||
* Payment UI Manager
|
||
* Handles the game mode selection and payment modal interactions
|
||
*/
|
||
const PaymentUI = {
|
||
config: null,
|
||
userCredits: 0,
|
||
hasPayoutAddress: false,
|
||
selectedPackageId: null,
|
||
|
||
init: function() {
|
||
console.log("Initializing PaymentUI...");
|
||
|
||
// Bind close button
|
||
$('#close-payment').on('click', function() {
|
||
$('#payment-ui').hide();
|
||
});
|
||
|
||
// Bind mode options (use delegation for dynamic content)
|
||
$('#payment-ui').on('click', '.mode-option', function() {
|
||
// The "Buy Selected Package" button ALSO carries .mode-option (for styling) but has its
|
||
// own handler below; without this guard it double-fired here with NO data attrs, defaulting
|
||
// to single_game — so buying a 10-credit pack ALSO created a spurious 1-credit invoice.
|
||
if (this.id === 'buy-credits-btn') return;
|
||
const mode = $(this).data('mode');
|
||
const action = $(this).data('action');
|
||
const packageId = $(this).data('package-id');
|
||
if (!mode && !action) return; // stray click with no intent
|
||
PaymentUI.handleModeSelection(mode, action, packageId);
|
||
});
|
||
|
||
// Bind package radio buttons
|
||
$('#payment-ui').on('change', 'input[name="credit-package"]', function() {
|
||
PaymentUI.selectedPackageId = $(this).val();
|
||
// Enable buy button
|
||
$('#buy-credits-btn').prop('disabled', false);
|
||
});
|
||
|
||
// Bind buy credits button
|
||
$('#payment-ui').on('click', '#buy-credits-btn', function() {
|
||
if (PaymentUI.selectedPackageId) {
|
||
PaymentUI.handleModeSelection('PAID_CREDITS', 'buy_credits', PaymentUI.selectedPackageId);
|
||
}
|
||
});
|
||
|
||
// Bind check payment button
|
||
$('#check-payment').on('click', function() {
|
||
const paymentId = $(this).data('payment-id');
|
||
if (paymentId && window.socket) {
|
||
window.socket.emit('check_payment_status', { paymentId });
|
||
$(this).text('Checking...');
|
||
setTimeout(() => $('#check-payment').text('🔄 Check Payment'), 1000);
|
||
}
|
||
});
|
||
|
||
// Bind copy button
|
||
$('#copy-address').on('click', function() {
|
||
const addr = $('#payment-address').val();
|
||
if(addr) {
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
navigator.clipboard.writeText(addr).then(() => {
|
||
$('#copy-address').text('Copied!');
|
||
setTimeout(() => $('#copy-address').html('📋 Copy Address'), 2000);
|
||
});
|
||
} else {
|
||
const $temp = $("<input>");
|
||
$("body").append($temp);
|
||
$temp.val(addr).select();
|
||
document.execCommand("copy");
|
||
$temp.remove();
|
||
$('#copy-address').text('Copied!');
|
||
setTimeout(() => $('#copy-address').html('📋 Copy Address'), 2000);
|
||
}
|
||
}
|
||
});
|
||
|
||
// Bind Shop Button
|
||
$('#shopButton').on('click', function() {
|
||
PaymentUI.show();
|
||
});
|
||
|
||
// Listen for credits updates
|
||
if (window.socket) {
|
||
window.socket.on('credits_update', function(data) {
|
||
PaymentUI.updateCredits(data.balance);
|
||
});
|
||
}
|
||
},
|
||
|
||
updateConfig: function(config) {
|
||
console.log("PaymentUI config updated:", config);
|
||
this.config = config;
|
||
this.render();
|
||
},
|
||
|
||
updateCredits: function(balance) {
|
||
this.userCredits = balance || 0;
|
||
this.render();
|
||
$('#user-credits-display').text(this.userCredits);
|
||
},
|
||
|
||
formatPrice: function(atomicAmount) {
|
||
const currency = this.config?.cryptoType || 'WOW';
|
||
const decimals = currency === 'XMR' ? 12 : 11;
|
||
const divisor = Math.pow(10, decimals);
|
||
return (atomicAmount / divisor).toFixed(decimals === 12 ? 4 : 2);
|
||
},
|
||
|
||
escapeHtml: function(value) {
|
||
return String(value == null ? '' : value).replace(/[&<>"']/g, function(ch) {
|
||
return ({
|
||
'&': '&',
|
||
'<': '<',
|
||
'>': '>',
|
||
'"': '"',
|
||
"'": '''
|
||
})[ch];
|
||
});
|
||
},
|
||
|
||
grantLabel: function(packId) {
|
||
const pack = window.RK && window.RK.PACKS ? window.RK.PACKS[packId] : null;
|
||
const labels = {
|
||
'generated-skins': 'Premium skins',
|
||
'iso-dungeon': 'Isometric dungeon',
|
||
'kenney-3d-characters': '3D characters',
|
||
'roguelike-interior': 'Roguelike interior'
|
||
};
|
||
return (pack && pack.label) || labels[packId] || packId;
|
||
},
|
||
|
||
grantSummaryHtml: function(grants, options = {}) {
|
||
if (!grants) return '';
|
||
const parts = [];
|
||
if (options.includeCredits && Number(grants.credits || 0) > 0) {
|
||
parts.push(`${Number(grants.credits)} credits`);
|
||
}
|
||
(grants.packs || []).forEach(packId => parts.push(this.grantLabel(packId)));
|
||
if (grants.premiumLevel) {
|
||
parts.push(`${grants.premiumLevel} tier`);
|
||
}
|
||
if (parts.length === 0) return '';
|
||
return `<div style="font-size:0.78em;color:#fbbf24;margin-top:4px;margin-left:${options.indent || 0}px;">Includes: ${this.escapeHtml(parts.join(' • '))}</div>`;
|
||
},
|
||
|
||
payoutSummaryHtml: function(mode, currency) {
|
||
const outcomes = this.config?.payoutOutcomes?.[mode];
|
||
if (!outcomes?.escape || !outcomes?.escapeWithTreasure) {
|
||
return '<span style="color:#f87171;">Exact payout terms are unavailable. Reload before entering paid play.</span>';
|
||
}
|
||
const describe = (label, outcome) => {
|
||
if (!outcome.payable) {
|
||
const why = outcome.suppressedReason === 'below_minimum' ? ' (below configured minimum)' : '';
|
||
return `${label}: no payout${why}`;
|
||
}
|
||
const amount = `${this.escapeHtml(outcome.amountFormatted)} ${this.escapeHtml(currency)}`;
|
||
if (outcome.capApplied) return `${label}: ${amount} (configured cap applied)`;
|
||
return `${label}: ${this.escapeHtml(outcome.multiplier)}× = ${amount}`;
|
||
};
|
||
return `<span style="color:#4ade80;">💰 ${describe('Escape', outcomes.escape)} • ${describe('Treasure', outcomes.escapeWithTreasure)}</span>`;
|
||
},
|
||
|
||
render: function() {
|
||
if (!this.config) return;
|
||
|
||
// Display label (sXMR on stagenet) — distinct from cryptoType, which formatPrice
|
||
// still uses for decimals.
|
||
const currency = this.config.currencyLabel || this.config.cryptoType || 'WOW';
|
||
const hasCredits = this.userCredits >= (this.config.creditsPerGame || 1);
|
||
const creditPackages = this.config.creditPackages || [];
|
||
const cosmeticProducts = this.config.cosmeticProducts || [];
|
||
|
||
const $container = $('.game-modes');
|
||
$container.empty();
|
||
|
||
// Reset selected package
|
||
this.selectedPackageId = null;
|
||
|
||
// Explain the two leaderboards up front when both paid and free are available.
|
||
if (this.config.paymentsEnabled && this.config.freePlayEnabled) {
|
||
const paidEntryKinds = [];
|
||
if (this.config.creditsModeEnabled) paidEntryKinds.push('credits');
|
||
if (this.config.directModeEnabled) paidEntryKinds.push('single entry');
|
||
const paidEntryCopy = paidEntryKinds.join(' or ') || 'a paid entry';
|
||
$container.append(
|
||
'<div style="margin-bottom:14px;padding:10px;background:rgba(245,158,11,0.08);border:1px solid #92400e;border-radius:6px;font-size:0.85em;line-height:1.5;">' +
|
||
'🏅 <strong style="color:#fbbf24;">Pay</strong> (' + this.escapeHtml(paidEntryCopy) + ') to put your high scores in the <strong style="color:#fbbf24;">Hall of Champions</strong>.<br>' +
|
||
'🪙 <strong style="color:#8ab4f8;">Play free</strong> — high scores go to the <strong style="color:#8ab4f8;">Pleb leaderboard</strong> instead.' +
|
||
'</div>'
|
||
);
|
||
}
|
||
|
||
// === SECTION 1: Use existing credits (if available) ===
|
||
if (hasCredits && this.config.creditsModeEnabled) {
|
||
const creditsPayoutsEnabled = this.config.creditsPayoutsEnabled;
|
||
|
||
let payoutNote = '';
|
||
if (creditsPayoutsEnabled) {
|
||
payoutNote = `${this.payoutSummaryHtml('credits', currency)}<br><span style="color:#fbbf24;">🏅 High scores enter the Hall of Champions</span>`;
|
||
} else {
|
||
payoutNote = '<span style="color:#fbbf24;">🏅 High scores enter the Hall of Champions</span>';
|
||
}
|
||
|
||
$container.append(`
|
||
<div class="payment-section" style="margin-bottom:15px;padding:12px;background:#0d3320;border:2px solid #4ade80;border-radius:6px;">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
|
||
<strong style="color:#4ade80;font-size:1.1em;">🎮 You have ${this.userCredits} credits</strong>
|
||
</div>
|
||
<div style="font-size:0.85em;margin-bottom:10px;">
|
||
${payoutNote}
|
||
</div>
|
||
<button class="mode-option recommended" data-mode="PAID_CREDITS" data-action="use_credit"
|
||
style="width:100%;padding:10px;background:#166534;border:none;color:#fff;cursor:pointer;border-radius:4px;">
|
||
<strong>▶ PLAY NOW</strong> (use 1 credit)
|
||
</button>
|
||
</div>
|
||
`);
|
||
}
|
||
|
||
// === SECTION 2: Direct payment (pay per game) ===
|
||
if (this.config.directModeEnabled) {
|
||
const price = this.config.singleGamePriceFormatted || this.formatPrice(this.config.singleGamePrice);
|
||
// Payout-honest: only show multipliers when payouts are actually on (default off, not on).
|
||
const directPayoutsEnabled = this.config.directPayoutsEnabled != null
|
||
? !!this.config.directPayoutsEnabled
|
||
: !!this.config.payoutsEnabled;
|
||
|
||
let payoutInfo = '';
|
||
if (directPayoutsEnabled) {
|
||
payoutInfo = `${this.payoutSummaryHtml('direct', currency)}<br><span style="color:#fbbf24;">🏅 High scores enter the Hall of Champions</span>`;
|
||
} else {
|
||
payoutInfo = '<span style="color:#fbbf24;">🏅 High scores enter the Hall of Champions</span>';
|
||
}
|
||
|
||
$container.append(`
|
||
<div class="payment-section" style="margin-bottom:15px;padding:12px;background:#1a1a2e;border:1px solid #444;border-radius:6px;">
|
||
<div style="margin-bottom:8px;">
|
||
<strong style="color:#f0f0f0;">⚡ Single-game entry</strong>
|
||
<span style="float:right;color:#fbbf24;font-weight:bold;">${price} ${currency}</span>
|
||
</div>
|
||
<div style="font-size:0.85em;color:#aaa;margin-bottom:6px;">
|
||
${payoutInfo}
|
||
</div>
|
||
<div style="font-size:0.78em;color:#888;margin-bottom:10px;">Adds one purchase-progress point for unlock thresholds; no spendable credit is added.</div>
|
||
<button class="mode-option" data-mode="PAID_SINGLE" data-action="pay_direct"
|
||
style="width:100%;padding:8px;background:#3730a3;border:none;color:#fff;cursor:pointer;border-radius:4px;">
|
||
💰 Pay ${price} ${currency} to Play
|
||
</button>
|
||
</div>
|
||
`);
|
||
}
|
||
|
||
// === SECTION 3: Buy credits (bulk discount) ===
|
||
if (this.config.creditsModeEnabled && creditPackages.length > 0) {
|
||
const singlePrice = this.config.singleGamePrice;
|
||
const creditsPayoutsEnabled = this.config.creditsPayoutsEnabled;
|
||
|
||
// Build payout info for credits mode
|
||
let creditsPayoutInfo = '';
|
||
if (creditsPayoutsEnabled) {
|
||
creditsPayoutInfo = `<div style="font-size:0.85em;margin-bottom:10px;">${this.payoutSummaryHtml('credits', currency)}</div>`;
|
||
} else {
|
||
creditsPayoutInfo = `<div style="font-size:0.85em;color:#fbbf24;margin-bottom:10px;">🏅 High scores enter the Hall of Champions • bulk discount</div>`;
|
||
}
|
||
|
||
let packagesHtml = '';
|
||
creditPackages.forEach((pkg, index) => {
|
||
const totalCredits = pkg.credits + (pkg.bonus || 0);
|
||
const pricePerCredit = pkg.price / totalCredits;
|
||
const priceFormatted = pkg.priceFormatted || this.formatPrice(pkg.price);
|
||
const perGamePrice = this.formatPrice(pricePerCredit);
|
||
|
||
// Calculate discount vs direct play
|
||
let discountBadge = '';
|
||
if (singlePrice > 0) {
|
||
const directCostForSameGames = singlePrice * totalCredits;
|
||
const savings = ((directCostForSameGames - pkg.price) / directCostForSameGames * 100).toFixed(0);
|
||
if (savings > 0) {
|
||
discountBadge = `<span style="background:#166534;color:#4ade80;padding:2px 6px;border-radius:3px;font-size:0.75em;margin-left:8px;">Save ${savings}%</span>`;
|
||
}
|
||
}
|
||
|
||
const bonusText = pkg.bonus > 0
|
||
? `<span style="color:#4ade80;"> +${pkg.bonus} bonus</span>`
|
||
: '';
|
||
const grantSummary = this.grantSummaryHtml(pkg.grants, { indent: 22 });
|
||
|
||
const checked = index === 0 ? 'checked' : '';
|
||
if (index === 0) this.selectedPackageId = pkg.id;
|
||
|
||
packagesHtml += `
|
||
<label style="display:block;padding:10px;margin:5px 0;background:#252540;border:1px solid #444;border-radius:4px;cursor:pointer;"
|
||
class="package-option" data-package-id="${pkg.id}">
|
||
<input type="radio" name="credit-package" value="${this.escapeHtml(pkg.id)}" ${checked} style="margin-right:10px;">
|
||
<strong>${pkg.credits} credits${bonusText}</strong>${discountBadge}
|
||
<span style="float:right;color:#fbbf24;font-weight:bold;">${priceFormatted} ${currency}</span>
|
||
<div style="font-size:0.8em;color:#888;margin-top:4px;margin-left:22px;">
|
||
${perGamePrice} ${currency}/game for ${totalCredits} games
|
||
</div>
|
||
${grantSummary}
|
||
</label>
|
||
`;
|
||
});
|
||
|
||
$container.append(`
|
||
<div class="payment-section" style="margin-bottom:15px;padding:12px;background:#1a1a2e;border:1px solid #444;border-radius:6px;">
|
||
<div style="margin-bottom:10px;">
|
||
<strong style="color:#f0f0f0;">🎫 Buy Credits</strong>
|
||
<span style="font-size:0.85em;color:#888;margin-left:10px;">Bulk discount • tied to this browser session</span>
|
||
</div>
|
||
${creditsPayoutInfo}
|
||
<div class="packages-list">
|
||
${packagesHtml}
|
||
</div>
|
||
<button id="buy-credits-btn" class="mode-option"
|
||
style="width:100%;padding:10px;margin-top:10px;background:#7c3aed;border:none;color:#fff;cursor:pointer;border-radius:4px;font-weight:bold;">
|
||
🛒 Buy Selected Package
|
||
</button>
|
||
</div>
|
||
`);
|
||
}
|
||
|
||
// === SECTION 4: Standalone cosmetic/render products ===
|
||
if (this.config.paymentsEnabled && cosmeticProducts.length > 0) {
|
||
let productsHtml = '';
|
||
cosmeticProducts.forEach(product => {
|
||
const priceFormatted = product.priceFormatted || this.formatPrice(product.price);
|
||
const label = this.escapeHtml(product.label || product.id);
|
||
const grantSummary = this.grantSummaryHtml(product.grants, { includeCredits: true });
|
||
productsHtml += `
|
||
<div style="padding:10px;margin:5px 0;background:#252540;border:1px solid #444;border-radius:4px;">
|
||
<div style="display:flex;gap:10px;justify-content:space-between;align-items:center;">
|
||
<strong style="color:#f0f0f0;">${label}</strong>
|
||
<span style="color:#fbbf24;font-weight:bold;">${priceFormatted} ${currency}</span>
|
||
</div>
|
||
${grantSummary}
|
||
<button class="mode-option" data-mode="SHOP" data-action="buy_product" data-package-id="${this.escapeHtml(product.id)}"
|
||
style="width:100%;padding:8px;margin-top:8px;background:#334155;border:none;color:#fff;cursor:pointer;border-radius:4px;">
|
||
Unlock Pack
|
||
</button>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
$container.append(`
|
||
<div class="payment-section" style="margin-bottom:15px;padding:12px;background:#1a1a2e;border:1px solid #444;border-radius:6px;">
|
||
<div style="margin-bottom:10px;">
|
||
<strong style="color:#f0f0f0;">Premium Packs</strong>
|
||
<span style="font-size:0.85em;color:#888;margin-left:10px;">Cosmetics and render modes</span>
|
||
</div>
|
||
${productsHtml}
|
||
</div>
|
||
`);
|
||
}
|
||
|
||
// === Credits balance display (if has some but not enough) ===
|
||
if (this.config.creditsModeEnabled && this.userCredits > 0 && !hasCredits) {
|
||
$container.prepend(`
|
||
<div style="text-align:center;margin-bottom:10px;padding:8px;background:#1a1a2e;border:1px solid #f59e0b;border-radius:4px;">
|
||
<span style="color:#f59e0b;">⚠️ You have ${this.userCredits} credits (need ${this.config.creditsPerGame || 1} to play)</span>
|
||
</div>
|
||
`);
|
||
}
|
||
|
||
// === FREE play === shown when the instance is free-only OR offers free as a choice
|
||
// alongside paid (freePlayEnabled). Free games go to the Pleb leaderboard.
|
||
if (this.config.freePlayEnabled) {
|
||
const note = this.config.paymentsEnabled
|
||
? '🪙 Free — high scores go to the Pleb leaderboard'
|
||
: 'No payments required';
|
||
$container.append(`
|
||
<button class="mode-option" data-mode="FREE" data-action="free"
|
||
style="width:100%;padding:12px;background:#166534;border:none;color:#fff;cursor:pointer;border-radius:4px;">
|
||
<strong>🆓 Play Free</strong><br>
|
||
<span style="font-size:0.8em;color:#aaa;">${note}</span>
|
||
</button>
|
||
`);
|
||
}
|
||
},
|
||
|
||
handleModeSelection: function(mode, action, packageId, acknowledgementReady, legalAcknowledgement) {
|
||
console.log("Mode selected:", mode, "Action:", action, "Package:", packageId);
|
||
|
||
if (mode === 'FREE') {
|
||
$('#payment-ui').hide();
|
||
if (window.socket && typeof SocketHandlers !== 'undefined' && SocketHandlers.emitFairGameStart) {
|
||
// play_free records the game as FREE (Pleb leaderboard, no payout) even on
|
||
// an instance that also sells credits/entry.
|
||
SocketHandlers.emitFairGameStart('play_free', { free: true });
|
||
}
|
||
return;
|
||
}
|
||
|
||
// A checkbox in the browser is not a legal/identity verification, but requiring a fresh
|
||
// per-session statement prevents the UI from silently opening a value-bearing action.
|
||
if (!acknowledgementReady) {
|
||
if (typeof CommerceConsent === 'undefined' || !CommerceConsent.require) {
|
||
$('#payment-status').text('Paid-play disclosures are unavailable. No payment was requested.');
|
||
return;
|
||
}
|
||
CommerceConsent.require(function(acknowledgement) {
|
||
PaymentUI.handleModeSelection(mode, action, packageId, true, acknowledgement);
|
||
});
|
||
return;
|
||
}
|
||
|
||
// If using existing credits, just start the game
|
||
if (action === 'use_credit' && this.userCredits >= 1) {
|
||
$('#payment-ui').hide();
|
||
if (window.socket && typeof SocketHandlers !== 'undefined' && SocketHandlers.emitFairGameStart) {
|
||
SocketHandlers.emitFairGameStart('auto_start', {
|
||
legalAcknowledgement: legalAcknowledgement
|
||
}, true);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Request payment with type and optional packageId
|
||
let paymentType = 'single_game';
|
||
if (action === 'buy_credits') {
|
||
paymentType = 'credits_package';
|
||
} else if (action === 'buy_product') {
|
||
paymentType = 'cosmetic_pack';
|
||
}
|
||
|
||
const requestData = { type: paymentType };
|
||
if (legalAcknowledgement) requestData.legalAcknowledgement = legalAcknowledgement;
|
||
if (packageId) {
|
||
if (paymentType === 'cosmetic_pack') {
|
||
requestData.productId = packageId;
|
||
} else {
|
||
requestData.packageId = packageId;
|
||
}
|
||
}
|
||
|
||
if (window.socket) {
|
||
if (paymentType === 'single_game' && typeof SocketHandlers !== 'undefined' && SocketHandlers.fairnessAttempt) {
|
||
var fairRequest = SocketHandlers.fairnessAttempt(requestData);
|
||
if (!fairRequest) return;
|
||
window.socket.emit('request_payment', fairRequest);
|
||
} else {
|
||
window.socket.emit('request_payment', requestData);
|
||
}
|
||
|
||
// Show loading state
|
||
$('.game-modes').hide();
|
||
$('#payment-details').show();
|
||
$('#payment-status').text('Requesting payment address...');
|
||
$('#payment-amount').text('...');
|
||
$('#payment-address').val('Loading...');
|
||
}
|
||
},
|
||
|
||
showPaymentRequest: function(data) {
|
||
$('#payment-ui').show();
|
||
$('#payment-details').show();
|
||
$('.game-modes').hide();
|
||
|
||
const isCreditsPurchase = data.paymentType === 'credits_package';
|
||
const isProductPurchase = data.paymentType === 'cosmetic_pack';
|
||
let headerText = '💳 Payment Required';
|
||
if (isCreditsPurchase && data.package) {
|
||
const bonus = data.package.bonus > 0 ? ` +${data.package.bonus} bonus` : '';
|
||
headerText = `🎫 Buy ${data.package.credits}${bonus} Credits`;
|
||
} else if (isProductPurchase && data.package) {
|
||
headerText = `Buy ${data.package.label || data.package.id || 'Pack'}`;
|
||
}
|
||
$('.payment-header strong').text(headerText);
|
||
|
||
const displayAmount = data.humanAmount || data.amountFormatted || data.amount;
|
||
$('#payment-amount').text(displayAmount + ' ' + (data.currency || ''));
|
||
$('#payment-address').val(data.address);
|
||
$('#check-payment').data('payment-id', data.paymentId || data.id);
|
||
|
||
if (data.reused) {
|
||
$('#payment-status').html('<span style="color:#ff0">♻️ Using existing pending payment</span>');
|
||
} else {
|
||
const statusText = isCreditsPurchase
|
||
? 'Send payment to receive credits...'
|
||
: (isProductPurchase ? 'Send payment to unlock this pack...' : 'Waiting for payment...');
|
||
$('#payment-status').text(statusText);
|
||
}
|
||
},
|
||
|
||
show: function() {
|
||
this.render(); // Ensure options are up-to-date with current config
|
||
$('#payment-ui').show();
|
||
$('.game-modes').show();
|
||
$('#payment-details').hide();
|
||
$('.payment-header strong').text('🎮 Choose How to Play');
|
||
}
|
||
};
|
||
|
||
// Expose globally
|
||
window.PaymentUI = PaymentUI;
|
||
|
||
// Initialize when DOM is ready
|
||
$(document).ready(function() {
|
||
PaymentUI.init();
|
||
});
|