1// Buying coins, from anywhere. 2// 3// This lived inside app.js, which only cabinet pages load. The moment the duel 4// lobby needed a stake, a player could arrive there with an empty balance, hit 5// CHALLENGE, be told "need 1 coin" and find nothing on the page that sells one. 6// A dead end on the first screen of a new feature. 7// 8// So it moves here, shared. app.js keeps what only a cabinet does â spending a 9// coin on a run â and this owns the shop. 10(() => { 11 const $ = id => document.getElementById(id); 12 const W = () => window.ARCADE_WALLET; 13 const fmt = n => Number(n).toLocaleString(undefined, { maximumFractionDigits: 2 }); 14 15 let config = null; 16 let onBought = null; 17 18 function build() { 19 if ($('coin-modal')) return true; 20 if (!config) return false; 21 const div = document.createElement('div'); 22 div.id = 'coin-modal'; 23 div.className = 'hidden'; 24 div.innerHTML = ` 25 <div id="coin-box"> 26 <p id="coin-label">BUY COINS</p> 27 <p class="muted coin-sub">${config.freeToPlay 28 ? 'A coin enters a <b>duel</b>. Cabinets are free to play — a coin buys nothing there.' 29 : '1 coin = 1 play on any cabinet'}<br> 30 Coins are granted free, as a daily allowance. They are not sold.</p> 31 <div id="pack-list"></div> 32 <p id="coin-status"></p> 33 <button id="coin-close" class="pixel-btn">CLOSE</button> 34 </div>`; 35 document.body.appendChild(div); 36 $('coin-close').addEventListener('click', () => div.classList.add('hidden')); 37 // clicking the backdrop closes it too; a modal with one way out is a trap 38 div.addEventListener('click', e => { if (e.target === div) div.classList.add('hidden'); }); 39 40 const list = $('pack-list'); 41 // No packs means the server has no price it trusts and is not selling. 42 // Saying so beats an empty shelf that looks like a broken page. 43 if (!config.packs.length) { 44 list.innerHTML = '<p class="muted">Price feed unavailable â coins are back in a moment.</p>'; 45 return true; 46 } 47 for (const pack of config.packs) { 48 const btn = document.createElement('button'); 49 btn.className = 'coin-btn pack-btn'; 50 // Worked out from the pack rather than hard-coded, so the badge cannot 51 // drift from what the server is actually charging. 52 const paidFor = Math.round(pack.sol / config.coinPriceSol); 53 const bonus = pack.coins > paidFor 54 ? ` <span class="pack-bonus">+${Math.round((pack.coins / paidFor - 1) * 100)}%</span>` : ''; 55 // A coin is priced in dollars, so that is the number players read. The 56 // SOL underneath is what they actually sign for, so it is shown too â 57 // quoting only a dollar figure for a SOL payment hides the real amount. 58 const price = pack.usd != null 59 ? `$${pack.usd} <span class="pack-sol">${pack.sol.toFixed(4)} SOL</span>` 60 : `${pack.sol} SOL`; 61 btn.innerHTML = `<span class="coin-slot"></span>${pack.coins} COIN${pack.coins > 1 ? 'S' : ''}${bonus} â ${price}`; 62 btn.addEventListener('click', () => buy(pack, btn)); 63 list.appendChild(btn); 64 } 65 return true; 66 } 67 68 async function buy(pack, btn) { 69 btn.disabled = true; 70 const player = W() && W().publicKey; 71 const provider = W() && W().provider(); 72 try { 73 if (!player || !provider) throw new Error('connect your wallet first'); 74 $('coin-status').textContent = 'Building transactionâ¦'; 75 const buildRes = await fetch('/api/coins/build', { 76 method: 'POST', headers: { 'Content-Type': 'application/json' }, 77 body: JSON.stringify({ player, pack: pack.id }), 78 }); 79 if (!buildRes.ok) throw new Error((await buildRes.json()).error); 80 const { tx: txB64 } = await buildRes.json(); 81 const tx = solanaWeb3.Transaction.from(Uint8Array.from(atob(txB64), c => c.charCodeAt(0))); 82 $('coin-status').textContent = 'Approve in your walletâ¦'; 83 const { signature } = await provider.signAndSendTransaction(tx); 84 $('coin-status').textContent = 'Confirming paymentâ¦'; 85 let done = false; 86 for (let i = 0; i < 20; i++) { 87 const r = await fetch('/api/coins/confirm', { 88 method: 'POST', headers: { 'Content-Type': 'application/json' }, 89 body: JSON.stringify({ player, signature, pack: pack.id }), 90 }); 91 if (r.ok) { done = true; break; } 92 const err = (await r.json()).error || ''; 93 if (err.includes('already used')) throw new Error(err); 94 await new Promise(res => setTimeout(res, 1500)); 95 } 96 if (!done) throw new Error('payment not confirmed â if SOL left your wallet, contact support'); 97 if (W()) await W().refreshCoins(); 98 $('coin-status').textContent = `${pack.coins}
98 coins added! You have ${fmt(W() ? W().coins : pack.coins)}.`; 99 if (onBought) { try { onBought(); } catch (_) {} } 100 } catch (e) { 101 $('coin-status').textContent = 'Error: ' + (e.message || e); 102 } 103 btn.disabled = false; 104 } 105 106 window.ARCADE_COINS = { 107 // pages that already fetched /api/config can hand it over rather than refetch 108 use(cfg) { config = cfg; }, 109 onBought(fn) { onBought = fn; }, 110 async open() { 111 if (!config) { 112 try { config = await (await fetch('/api/config')).json(); } catch (_) { return; } 113 } 114 if (!build()) return; 115 $('coin-status').textContent = ''; 116 $('coin-modal').classList.remove('hidden'); 117 }, 118 }; 119})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.