brimbloom / funpass.js
CyberMax-tools's picture
Deploy brimbloom
cc6eefe verified
Raw History Blame Contribute Delete
15.4 kB
// CyberMax games checkout: the per-game "full game" unlock + the Fun Pass (every game), copied into each game's web/ folder.
// Model (games lane GM, 9 Oct 2026; evidence docs/ceo/games-monetization-2026-10-09.md):
// - Free demo: every game's daily challenge stays free for everyone, plus the first levels (OFFER.demo once the per-game
// unlock is live; levels 1-20 until then). A level the player has opened is never locked again.
// - Full game: ONE payment per game (OFFER.price, Stripe pack revenue/packs/stripe/<game>-full) unlocks every level,
// mode, the daily archive and themes in that game, for good. Shown only when OFFER.live is true (owner-approved).
// - Fun Pass = the all-games bundle: Monthly $2.99 · Yearly $19 · Lifetime $29.99 (pack revenue/packs/stripe/fun-pass).
// - Portal builds (scripts/games/portals.py) never show a checkout; where the portal SDK offers rewarded ads, the player
// may watch one to open the full game for an hour (shared/portal-adapter/cmx-portal.js CMXPortal.rewarded()).
// Buy: counted redirect https://data.cybermaxtools.com/buy/st-<pack>-<plan>?s=game-<id> -> Stripe checkout.
// After checkout the data Worker (/unlock/fun-pass or /unlock/<game>-full) checks the Checkout Session and returns with
// #cmx-pro=<code> (the full-game return adds ?unlock=full). The code on the receipt page unlocks any other device.
// No account, no cookies, nothing personal stored. (c) 2026 CyberMax. All rights reserved.
import { count, store, toast } from './cmx.js';
import { OFFER } from './offer.js'; // written by scripts/games/sync_kit.py from products/fun-pass/games.json
export { OFFER };
const DATA = 'https://data.cybermaxtools.com';
const KEY = 'cmx-pro:fun-pass';
const SID = /^(cs_(live|test)_[A-Za-z0-9]{10,200}|[A-Z0-9]{8}-[A-Z0-9]{8}-[A-Z0-9]{8}-[A-Z0-9]{8})$/i; // Stripe order code or Gumroad licence key
const RECHECK = 3 * 864e5; // subscriptions are re-checked every 3 days; a cancelled pass turns off by itself
export const PLANS = [
{ id: 'monthly', label: 'Monthly', price: '$2.99', per: '/month', note: 'Cancel any time' },
{ id: 'yearly', label: 'Yearly', price: '$19', per: '/year', note: 'Save 47%', best: true },
{ id: 'unlock', label: 'Lifetime', price: '$29.99', per: ' once', note: 'Pay once, keep forever' },
];
export const COMMON_PERKS = ['Every level and mode in every CyberMax game', 'The full archive of past dailies', 'Custom colour themes', 'New games included as they launch'];
const FULL_KEY = `cmx-full:${OFFER.game}`;
const REWARD_KEY = `cmx-reward:${OFFER.game}`;
const REWARD_MS = 60 * 60 * 1000; // one rewarded ad on a portal build opens the full game for an hour
let state = store(KEY);
let full = store(FULL_KEY);
const listeners = [];
let cfg = { game: 'games', name: 'CyberMax games', perks: [] };
const passOn = () => !!(state && state.code);
export const ownsGame = () => !!(full && full.code);
const rewardOn = () => { const r = store(REWARD_KEY); return !!(r && Date.now() - r.at < REWARD_MS); };
/** True when this player may use everything in this game: Fun Pass, this game's full unlock, or a portal reward hour. */
export const isPro = () => passOn() || ownsGame() || rewardOn();
/** Free levels in this game's demo: OFFER.demo once the per-game unlock is live, else the game's current number. */
export const freeLevels = (current = 20) => (OFFER.live && OFFER.demo ? Math.min(current, OFFER.demo) : current);
const portal = () => (typeof window !== 'undefined' && window.CMXPortal && window.CMXPortal.portal) || '';
export function onChange(fn) { listeners.push(fn); fn(isPro()); }
function emit() {
document.documentElement.classList.toggle('cmx-pro', isPro());
listeners.forEach((fn) => { try { fn(isPro()); } catch { /* game hook */ } });
}
async function check(code, what = 'fun-pass') {
try {
const r = await fetch(`${DATA}/unlock/${what}?json=1&session_id=${encodeURIComponent(code)}`);
const j = await r.json();
return j && typeof j.pro === 'boolean' ? j.pro : null;
} catch { return null; }
}
/** Turn on a full-game code for this game (receipt code of the <game>-full Payment Link). */
export async function unlockGame(code) {
const ok = await check(code, `${OFFER.game}-full`);
if (ok) {
full = store(FULL_KEY, { code, at: new Date().toISOString() });
count(cfg.game, 'pro', 'full-unlocked');
toast(`${OFFER.name}: the full game is on. Thank you for supporting CyberMax games!`, 3500);
emit(); render();
} else if (ok === false) toast('That code is not a full-game order for this game.', 3500);
else toast('Could not reach the unlock check. Try again in a minute.', 3500);
return ok;
}
/** Turn on a pasted or returned code: a Fun Pass code, or (where this game sells its own unlock) a full-game code. */
export async function unlock(code) {
const ok = await check(code);
if (ok) {
state = store(KEY, { code, at: new Date().toISOString(), checked: Date.now() });
count(cfg.game, 'pro', 'unlocked');
toast('Fun Pass is on. Thank you for supporting CyberMax games!', 3500);
emit();
render();
return ok;
}
if (sellsGame() && (await check(code, `${OFFER.game}-full`))) return unlockGame(code);
if (ok === false) toast('That code is not an active Fun Pass.', 3500);
else toast('Could not reach the pass check. Try again in a minute.', 3500);
return ok;
}
async function recheck() {
if (!isPro() || Date.now() - (state.checked || 0) < RECHECK) return;
const ok = await check(state.code);
if (ok === false) { state = null; try { localStorage.removeItem(KEY); } catch { /* */ } emit(); render(); toast('Your Fun Pass has ended. Everything free is still here.', 4000); }
else if (ok) state = store(KEY, { ...state, checked: Date.now() });
}
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const buyUrl = (plan) => `${DATA}/buy/st-fun-pass-${plan}?s=game-${cfg.game}`;
const gameUrl = (from = '') => `${DATA}/buy/st-${OFFER.game}-full-unlock?s=game-${cfg.game}${from ? `-${from}` : ''}`;
const sellsGame = () => !!(OFFER.live && OFFER.price && OFFER.game !== 'games');
function dialog() {
let d = document.getElementById('funpass');
if (!d) {
d = document.createElement('dialog');
d.id = 'funpass';
d.className = 'funpass';
document.body.appendChild(d);
d.addEventListener('click', (e) => { if (e.target === d || e.target.closest('[data-close]')) d.close(); });
}
return d;
}
const planHtml = () => PLANS.map((p) => `<a class="fp-plan${p.best ? ' best' : ''}" href="${buyUrl(p.id)}" rel="nofollow" data-plan="${p.id}">${p.best ? '<i>Best value</i>' : ''}<b>${p.label}</b><span class="fp-price">${p.price}<small>${p.per}</small></span><span class="fp-note">${p.note}</span></a>`).join('');
const codeForm = `<details><summary>Already bought?</summary><form class="fp-form"><input aria-label="Order code" autocomplete="off" placeholder="Receipt code (cs_live_…) or Gumroad licence key"><button class="btn sm" type="submit">Turn on</button></form></details>`;
function render() {
const d = dialog();
const perks = [...(cfg.perks || []), ...COMMON_PERKS.filter((p) => !(cfg.perks || []).some((q) => q.split(' ')[0] === p.split(' ')[0]))].slice(0, 6);
if (isPro()) {
const what = passOn() ? 'The Fun Pass is on in this browser for every CyberMax game' : ownsGame() ? `You own the full ${esc(OFFER.name)}` : 'The full game is open for the next hour (rewarded ad)';
const code = passOn() ? state.code : ownsGame() ? full.code : '';
d.innerHTML = `<div class="fp"><button class="fp-x" data-close aria-label="Close">×</button><p class="fp-kick">${passOn() ? 'CyberMax Fun Pass' : esc(OFFER.name)}</p><h2>Everything is open. Thank you!</h2>
<p>${what}: ${perks.map(esc).join(' · ')}.</p>
${code ? `<p class="fp-small">Your code (paste it on another device to turn it on there):</p><code class="fp-code">${esc(code)}</code>` : ''}
${passOn() ? '<p class="fp-small">Manage or cancel a subscription any time from the link in your Stripe receipt email.</p>' : ''}</div>`;
return;
}
if (portal()) { // portal build: no outside checkout; a rewarded ad (when the portal offers one) opens the game for an hour
d.innerHTML = `<div class="fp"><button class="fp-x" data-close aria-label="Close">×</button><p class="fp-kick">${esc(OFFER.name || 'Full game')}</p>
<h2>Open the full game for an hour</h2><ul class="fp-perks">${perks.slice(0, 4).map((p) => `<li>${esc(p)}</li>`).join('')}</ul>
<p><button class="btn" type="button" data-reward>▶ Watch a short ad</button></p>
<p class="fp-small">Optional. The daily challenge and the free levels stay free without ads.</p></div>`;
d.querySelector('[data-reward]').addEventListener('click', async () => {
count(cfg.game, 'cta', 'reward-ad');
const ok = await window.CMXPortal.rewarded().catch(() => false);
if (ok) { store(REWARD_KEY, { at: Date.now() }); count(cfg.game, 'pro', 'reward-hour'); toast('Everything is open for the next hour. Have fun!', 3000); emit(); d.close(); }
else toast('No ad is available right now. Try again later.', 3000);
});
return;
}
const allGames = `${OFFER.games || 'every'} CyberMax games`;
const own = cfg.perks || [];
const gamePerks = [...own, ...(own.some((p) => /archive/i.test(p)) ? [] : ['The full archive of past dailies']), 'Every bonus mode in this game'].slice(0, 6);
d.innerHTML = sellsGame()
? `<div class="fp"><button class="fp-x" data-close aria-label="Close">×</button><p class="fp-kick">${esc(OFFER.name)} · full game</p>
<h2>Unlock the full ${esc(OFFER.name)}</h2>
<ul class="fp-perks">${gamePerks.map((p) => `<li>${esc(p)}</li>`).join('')}</ul>
<a class="fp-plan fp-game" href="${gameUrl()}" rel="nofollow" data-game><i>This game</i><b>Full ${esc(OFFER.name)}</b><span class="fp-price">${esc(OFFER.price)}<small> once</small></span><span class="fp-note">Pay once, keep it forever. No subscription.</span></a>
<p class="fp-or"><b>Or get all ${esc(allGames)}</b> with the Fun Pass:</p>
<div class="fp-plans">${planHtml()}</div>
<p class="fp-small">Secure Stripe checkout · turns on as soon as you pay · your code works on any device · 14-day promise: if it doesn't work as described, we fix it or refund you. The daily challenge and the first ${OFFER.demo || ''} levels stay free for everyone.</p>
${codeForm}</div>`
: `<div class="fp"><button class="fp-x" data-close aria-label="Close">×</button><p class="fp-kick">CyberMax Fun Pass</p>
<h2>One pass. Every game. More to play.</h2>
<ul class="fp-perks">${perks.map((p) => `<li>${esc(p)}</li>`).join('')}</ul>
<div class="fp-plans">${planHtml()}</div>
<p class="fp-small">Secure Stripe checkout. Works in every CyberMax game. The daily challenges and the free levels stay free for everyone.</p>
${codeForm}</div>`;
d.querySelector('.fp-form').addEventListener('submit', (e) => {
e.preventDefault();
const c = e.target.querySelector('input').value.trim();
if (SID.test(c)) unlock(c); else toast('Paste the code from your receipt page (cs_live_…) or your Gumroad licence key.', 3500);
});
d.querySelectorAll('[data-plan]').forEach((a) => a.addEventListener('click', () => count(cfg.game, 'cta', `pass-${a.dataset.plan}`)));
d.querySelector('[data-game]')?.addEventListener('click', () => count(cfg.game, 'cta', 'full-game'));
}
/** Open the pass sheet; `from` names the button that opened it (counted as cta). */
export function open(from = 'button') {
render();
const d = dialog();
if (d.showModal) d.showModal(); else d.setAttribute('open', '');
count(cfg.game, 'cta', `pass-open-${from}`);
}
/** After a finished round (the value moment): one small inline card under the result, never a pop-up. Shows the
* pass price, the honest trust line and an optional "email me" weekly reminder (double opt-in).
* `host` is the result panel; the card is added once and removed for pass holders. */
export function afterWin(host, from = 'win') {
if (!host) return;
if (isPro() || portal()) { host.querySelector('.fp-after')?.remove(); return; }
let el = host.querySelector('.fp-after');
if (!el) { el = document.createElement('div'); el.className = 'fp-after'; host.appendChild(el); }
const best = PLANS.find((p) => p.best) || PLANS[0];
const game = sellsGame();
const href = game ? gameUrl('after') : `${buyUrl(best.id)}-after`;
const label = game ? `Get the full ${OFFER.name}: ${OFFER.price} once` : `Get the Fun Pass: ${best.price}${best.per}`;
const line = game ? (cfg.extra || `The full game opens every level, mode, the daily archive and themes in ${OFFER.name}.`)
: (cfg.extra || 'The Fun Pass opens every level, mode, the full archive and themes in every CyberMax game.');
el.innerHTML = `<p><b>Want more today?</b> ${esc(line)}</p>
<p class="fp-row"><a class="btn sm" href="${href}" rel="nofollow" data-after>${esc(label)}</a> <button class="btn sm ghost" type="button" data-plans>${game ? 'All games pass' : 'See all plans'}</button></p>
<p class="fp-small">Secure Stripe checkout · turns on as soon as you pay · ${game ? 'one payment, no subscription' : 'cancel any time from your receipt email'} · 14-day promise: if it doesn't work as described, we fix it or refund you (<a href="https://cybermaxtools.com/terms#payments" target="_blank" rel="noopener">terms</a>). Today's daily challenge stays free.</p>
<div class="fp-cap"></div>`;
el.querySelector('[data-after]').addEventListener('click', () => { count(cfg.game, 'cta', game ? 'full-game-after' : `pass-${best.id}-after`); kit((K) => K.event('upgrade-click', cfg.game)); });
el.querySelector('[data-plans]').addEventListener('click', () => open(`after-${from}`));
kit((K) => {
K.event('result', cfg.game); K.event('upgrade-view', cfg.game);
K.capture(el.querySelector('.fp-cap'), { list: `weekly-${cfg.game}`, src: `game-${cfg.game}`, label: cfg.remind || 'Email me a weekly reminder with new puzzles (free)', button: 'Remind me' });
});
}
// Convert kit (funnel beacons + email capture) from the CyberMax site; optional, the game works without it.
let kitQ = null;
function kit(fn) {
if (window.CMXConvert) return fn(window.CMXConvert);
if (kitQ) return kitQ.push(fn);
kitQ = [fn];
const sc = document.createElement('script');
sc.src = 'https://cybermaxtools.com/store/convert.js'; sc.async = true;
sc.onload = () => kitQ.splice(0).forEach((f) => { try { f(window.CMXConvert); } catch { /* optional */ } });
document.head.appendChild(sc);
}
/** Call once per page: game id, display name and the game's own perks (listed first). Reads #cmx-pro=<code> after
* checkout (?unlock=full marks this game's full-game return). Skin-pack codes are taken first by skins.js capture(). */
export function init(options) {
cfg = { ...cfg, ...options };
const m = /[#&]cmx-pro=([^&]+)/.exec(location.hash || '');
if (m && SID.test(decodeURIComponent(m[1]))) {
const q = new URLSearchParams(location.search);
const fullReturn = q.get('unlock') === 'full';
q.delete('unlock');
history.replaceState(null, '', location.pathname + (q.toString() ? `?${q}` : ''));
if (fullReturn) unlockGame(decodeURIComponent(m[1])); else unlock(decodeURIComponent(m[1]));
} else recheck();
emit();
}