PageSourceSearch

https://webcade.fun/nav.js?v=1025436e

js webcade.fun collected 2026-10-08 08:26:52 UTC 20,656 bytes, 372 lines download raw bytes

1// The site bar: brand, a dropdown menu, and the wallet corner.
2//
3// Built here rather than copied into seventeen pages, because the last few
4// header changes had to be applied seventeen times and a page was always one
5// edit behind. Every page keeps only an empty <header> and this fills it.
6//
7// app.js binds to #connect-btn, #wallet-addr and #wallet-area, and injects the
8// sound toggle and coin chip into that container. So this must run BEFORE
9// app.js and must produce those exact elements — the wallet corner is this
10// bar's markup but still app.js's behaviour.
11(() => {
12  const header = document.querySelector('header');
13  if (!header) return;
14
15  const here = location.pathname.replace(/\/$/, '') || '/';
16
17  const LINKS = [
18    { href: '/arcade', label: 'Arcade' },
19    { href: '/duel', label: 'Duels' },
20    { href: '/crews', label: 'Crews' },
21    { href: '/leaderboard', label: 'Leaderboard' },
22    // CLAIMS IS GONE FROM THE BAR, because the desk it pointed at is shut.
23    // The entry read `{ href: '/prizeroom', label: 'Claims' }` and was called
24    // Claims rather than Prizes on the reasoning that there are no prizes,
25    // only a desk for tickets that already existed — and that desk closed at
26    // 00:00 UTC on 20 Sep 2026, the date /terms section 5 and prizeroom.html
27    // both carry. server.js now enforces it by date, so the link had become a
28    // nav item leading to a 410 on every page of the site.
29    //
30    // THE ROUTE IS NOT REMOVED AND MUST NOT BE. It still answers — with 410
31    // and one factual line — because holders were handed that URL and some
32    // will arrive late. Unlinking it from the furniture is not the same as
33    // deleting it, and deleting it would turn a withdrawn page into a dead end.
34    // See the claim desk block in server.js.
35    // { href: '/store', label: 'Store' },   <- paired with STORE_LIVE
36  ];
37
38  // HOW TO PLAY came out of the bar and went into the footer below. Six items
39  // is where a nav starts being scanned rather than read, and of the six this
40  // is the one nobody clicks twice: it is read once, early, and then never
41  // again. It is NOT dropped — the rules of a game that pays out have to stay
42  // reachable from every page — so the footer link row exists to hold it, and
43  // /howtoplay is still on the Telegram bot's menu and in the landing footer.
44  const FOOT = [
45    { href: '/howtoplay', label: 'How to play' },
46    // TERMS and PRIVACY are absent from the footer, which no longer means what
47    // it used to. They were unlinked everywhere while the wording was being
48    // settled; the arcade floor now puts a gate in front of them that has to be
49    // agreed to before the floor is usable (public/tos.js), so both documents
50    // ARE linked, prominently, from the one page that matters. What is still
51    // missing is a link on every OTHER page, which is a smaller question than
52    // it was. Restore these two and the pair in landing.html's own footer row
53    // at the same time.
54  ];
55
56  // THE STORE LINK IS COMMENTED OUT ABOVE, and putting it back is half of
57  // opening the store. The other half is STORE_LIVE=1 in the environment,
58  // which is what actually makes /store answer instead of 404ing.
59  //
60  // Two places rather than one because this file is the only script on every
61  // page, and having it fetch a flag on every page load — to decide a single
62  // nav link — costs a request everywhere to save an edit here. server.js says
63  // which half is missing in its boot log, so neither can be forgotten
64  // quietly: STORE_LIVE without this line is a store nobody can find, and this
65  // line without STORE_LIVE is a link to a 404.
66
67  const esc = window.esc || (v => String(v == null ? '' : v)
68    .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
69    .replace(/"/g, '&quot;').replace(/'/g, '&#39;'));
70
71  // The header is drawn before anything knows whether a wallet is connected —
72  // wallet.js reconnects asynchronously at the bottom of the page. Rendering
73  // CONNECT WALLET and then swapping it for an address is the flash. So the
74  // last known session is remembered and used to draw the right header
75  // immediately; wallet.js corrects it if the reconnect fails.
76  let remembered = null;
77  try { remembered = JSON.parse(localStorage.getItem('webcade-wallet') || 'null'); } catch (_) {}
78  const pk = remembered && remembered.pk;
79  // A Telegram player has no wallet, so every route into /player was hidden
80  // from them: this menu item, the tab bar's profile tab, and the header link
81  // all key on `pk`. The page itself has always worked for a tg id — only the
82  // way in was missing — which meant anything living on a profile was
83  // invisible to every Telegram player, referral link included.
84  //
85  // tg.js runs before this file and publishes the id it read from initData.
86  // Display only, as it says: the server re-derives identity from the signed
87  // payload and never trusts this.
88  const tgId = (window.ARCADE_TG && window.ARCADE_TG.id) || null;
89  const profileFor = pk || tgId;
90  const shortPk = pk ? pk.slice(0, 4) + '..' + pk.slice(-4) : '';
91
92  const items = LINKS.map(l => {
93    const on = here === l.href.replace(/\/$/, '') ? ' class="on"' : '';
94    return `<a role="menuitem"${on} href="${l.href}">${esc(l.label)}</a>`;
95  }).join('');
96
97  // Inside a Telegram Mini App the player is already identified, by a signed
98  // id rather than a wallet. Offering CONNECT WALLET there would be the exact
99  // dead end the Mini App exists to remove — so the header shows who they are
100  // instead. Defaults to "not in Telegram" on every page that never loads the
101  // bridge, which is all of them but /duel.
102  const TG = window.ARCADE_TG || { inTelegram: false, name: null };
103
104  // Desktop shows the links inline; the hamburger menu carries them on a
105  // phone, plus the account items that never earn a place in the main row.
106  //
107  // PRIZE WALLET is Telegram-only and it is here because a player told us it
108  // was nowhere. The panel that says where their winnings get paid lived inside
109  // duel.js, so their name in the corner opened it on /duel and opened this
110  // menu — with nothing about a wallet in it — on every other page. The panel
111  // is on the player page now; this is the way in from everywhere else. A
112  // browser player needs no such link: their wallet is the chip itself.
113  //
114  // NO TELEGRAM GROUP AND NO BUY $TOKEN LINK. Both were menu items here and
115  // both were removed deliberately, so putting either back is a decision and
116  // not a tidy-up. The buy link pointed at the pump.fun page for the token:
117  // an account menu inside the game is the last place that should be routing
118  // players to a market, and shipping it alongside a prize board is the
119  // "play to earn" framing the site is being taken out of. The Telegram link
120  // went with it because the group is where the token gets talked about, and
121  // it is not linked from the footer row below either any more — see the note
122  // there.
123  header.innerHTML = `
124    <div class="nav-inner">
125      <a class="brand" href="/arcade" aria-label="Webcade home">WEB<i>CADE</i></a>
126      <nav class="nav-links" aria-label="Site">${items}</nav>
127
128      <div id="wallet-area">
129        <button id="connect-btn" class="pixel-btn${TG.inTelegram || pk ? ' hidden' : ''}">Connect Wallet</button>
130        <span id="wallet-addr"${TG.inTelegram || pk ? '' : ' class="hidden"'}>${esc(TG.inTelegram ? (TG.name || 'player') : shortPk)}</span>
131        ${pk && remembered.free
132          ? `<span id="coin-balance" title="Free plays left today">${esc(remembered.free.left + (remembered.free.bonus || 0))}/${esc(remembered.free.allowance)}<i>plays</i></span>`
133          : pk && remembered.coins != null
134          ? `<span id="coin-balance">${esc(remembered.coins)}<i> coins</i></span>` : ''}
135      </div>
136
137      <button id="nav-toggle" aria-haspopup="true" aria-expanded="false"
138              aria-controls="nav-menu">&#9776;</button>
139      <nav id="nav-menu" role="menu" hidden>
140        <div class="menu-links">${items}</div>
141        <a role="menuitem" id="nav-profile" class="${profileFor ? '' : 'hidden'}" href="${profileFor ? '/player?w=' + encodeURIComponent(profileFor) : '/'}">My profile</a>
142        ${tgId ? `<a role="menuitem" id="nav-prizewallet" href="/player?w=${encodeURIComponent(tgId)}#pl-wallet">Prize wallet</a>` : ''}
143        <button role="menuitem" id="nav-signin" class="hidden">Sign in</button>
144        <button role="menuitem" id="nav-disconnect" class="hidden">Disconnect wallet</button>
145      </nav>
146    </div>`;
147
148  // Shown only when a wallet is attached — including inside a Mini App, where
149  // a wallet from an earlier visit is invisible in the header but still stored
150  // on the device. That was the case with no way out at all.
151  //
152  // nav.js runs in the middle of the page and wallet.js at the foot, so
153  // ARCADE_WALLET does not exist yet when this file executes. Wiring it up
154  // immediately silently did nothing: the button stayed hidden and no listener
155  // was ever registered. So it wires on DOMContentLoaded, by which point every
156  // script at the end of the body has run.
157  const disc = document.getElementById('nav-disconnect');
158  // Is there a browser wallet to let go of? A live connection (publicKey) OR one
159  // remembered on this device. The second half is why this now works inside
160  // Telegram: a Mini App proves its wallet into ARCADE_TG, never into
161  // publicKey, so gating on publicKey alone hid the control for the exact
162  // players who most need a way to swap the wallet their prizes go to. Reads
163  // localStorage live so the item disappears the moment a disconnect clears it.
164  const rememberedPk = () => {
165    try { return (JSON.parse(localStorage.getItem('webcade-wallet') || 'null') || {}).pk || null; }
166    catch (_) { return null; }
167  };
168  function wireDisconnect() {
169    if (!disc) return;
170    const w = window.ARCADE_WALLET;
171    const has = () => !!((w && w.publicKey) || rememberedPk());
172    const paintDisc = () => disc.classList.toggle('hidden', !has());
173    paintDisc();
174    if (w && w.onChange) w.onChange(paintDisc);
175    disc.addEventListener('click', async () => {
176      if (!has()) return;
177      const inTg = !!(window.ARCADE_TG && window.ARCADE_TG.inTelegram);
178      const msg = inTg
179        ? 'Forget the wallet stored on this device? Your Telegram account and prizes are not affected.'
180        : 'Disconnect this wallet?';
181      // ARCADE_ASK, not window.confirm: inside the dApp Store build's WebView
182      // the native dialog is never drawn and answers false, so this guard was
183      // an unconditional return and the control did nothing. See ask.js.
184      if (!await window.ARCADE_ASK.confirm(msg, { ok: inTg ? 'FORGET' : 'DISCONNECT' })) return;
185      // Prefer the wallet's own teardown (clears its session token too); fall
186      // back to clearing the remembered record when there is no live wallet.
187      if (w && w.disconnect) { try { await w.disconnect(); } catch (_) {} }
188      else { try { localStorage.removeItem('webcade-wallet'); } catch (_) {} }
189      paintDisc();
190      open(false);
191    });
192  }
193  // THE WAY BACK IN, and the site did not have one until this existed.
194  //
195  // A session token expires and nothing refreshes it without a signature. The
196  // page used to sign in by itself on load, which is what made the dApp Store
197  // build unopenable on a Seeker — there, a signature is an app handoff that
198  // takes the arcade's task with it (see wallet.js token()). So page load is
199  // silent now, and silence needs somewhere for the player to say "yes, ask me".
200  //
201  // Without this the arcade opens fine and then quietly withholds everything a
202  // token buys — streak, rank, the daily reward — with no control anywhere that
203  // offers to fix it. A connected player with an aged-out session was stranded
204  // unless they happened to run out of free plays, because spending a coin was
205  // the only remaining path that still signed.
206  //
207  // Shown only when there is a live wallet AND no usable token: a Telegram
208  // player proves themselves with initData and never needs this, and a player
209  // holding a good token has nothing to do here.
210  const signin = document.getElementById('nav-signin');
211
212  // NEVER CAPTURE ARCADE_WALLET HERE. This file is deferred and runs BEFORE
213  // wallet.js, and a deferred script executes with document.readyState already
214  // 'interactive' — so the guard at the foot of this file takes its `else`
215  // branch and wires everything at module time, when window.ARCADE_WALLET does
216  // not exist yet. Holding `const w = window.ARCADE_WALLET` there pins undefined
217  // forever: the predicate is false for good and onChange is never registered.
218  //
219  // That is exactly how this shipped broken. The item stayed hidden on a Seeker
220  // that needed it, with a live wallet and no token, and nothing said why —
221  // announce() swallows listener errors and there was no listener to fail.
222  // Disconnect sits two lines away and survives the same race only because its
223  // has() falls back to localStorage rather than trusting the object.
224  //
225  // So: look the wallet up at CALL time, and repaint when the menu opens, which
226  // is the only moment the answer is actually looked at.
227  const wallet = () => window.ARCADE_WALLET;
228  let repaintSignIn = () => {};
229  function wireSignIn() {
230    if (!signin) return;
231    const inTg = () => !!(window.ARCADE_TG && window.ARCADE_TG.inTelegram);
232    const needs = () => { const w = wallet(); return !!(w && w.publicKey && !inTg() && !w.heldToken()); };
233    const paintSignIn = () => signin.classList.toggle('hidden', !needs());
234    repaintSignIn = paintSignIn;
235    paintSignIn();
236    // Register as soon as there is something to register with. At module time
237    // there is not, so try again once the deferred scripts have all run.
238    const listen = () => { const w = wallet(); if (w && w.onChange) { w.onChange(paintSignIn); return true; } return false; };
239    if (!listen()) {
240      document.addEventListener('DOMContentLoaded', () => { listen(); paintSignIn(); }, { once: true });
241      window.addEventListener('load', () => { listen(); paintSignIn(); }, { once: true });
242    }
243    signin.addEventListener('click', async () => {
244      const w = wallet();
245      if (!w || !needs()) return;
246      signin.disabled = true;
247      const was = signin.textContent;
248      signin.textContent = 'Check your wallet\u2026';
249      try {
250        await w.token();
251        // Reload rather than invent a repaint. Everything personalised is
252        // fetched once on load — the dashboard, the rival strip, the daily
253        // reward — and each gave up quietly when there was no token. Asking
254        // them all to re-run is exactly what a load does, and a token that has
255        // just been signed for is worth one.
256        location.reload();
257      } catch (_) {
258        // Refused, or the wallet never came back. Not an error worth a dialog:
259        // put the control back and let them try again.
260        signin.disabled = false;
261        signin.textContent = was;
262        paintSignIn();
263      }
264    });
265  }
266
267  function wireAccount() { wireDisconnect(); wireSignIn(); }
268  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wireAccount);
269  else wireAccount();
270
271  const toggle = document.getElementById('nav-toggle');
272  const menu = document.getElementById('nav-menu');
273
274  function open(state) {
275    // The menu is the only place the sign-in item is ever read, and its answer
276    // depends on a token that can expire between one open and the next.
277    if (state) { try { repaintSignIn(); } catch (_) {} }
278    menu.hidden = !state;
279    toggle.setAttribute('aria-expanded', String(!!state));
280    header.classList.toggle('menu-open', !!state);
281  }
282  toggle.addEventListener('click', e => { e.stopPropagation(); open(menu.hidden); });
283
284  // The wallet corner opens the same menu, at every width. On a phone the
285  // hamburger already carries it; on a wider screen — Telegram Desktop, a
286  // tablet, any WebView past the 860px breakpoint — the hamburger is hidden and
287  // this is the only way in. Tapping your own name/address to reach My profile
288  // and Disconnect is also where people look for them. The account items
289  // (profile, buy, disconnect) live only in this menu, so without a trigger
290  // that survives every width they were unreachable — the bug this fixes.
291  const walletAddr = document.getElementById('wallet-addr');
292  if (walletAddr) {
293    walletAddr.setAttribute('role', 'button');
294    walletAddr.setAttribute('aria-haspopup', 'true');
295    walletAddr.classList.add('wallet-menu-trigger');
296    walletAddr.addEventListener('click', e => {
297      // A page may claim the chip for its own richer account menu — duel.js does,
298      // for the Telegram prize-wallet menu (#acct-menu), marking the chip with
299      // data-acct. Defer to it there rather than opening a second menu on top.
300      if (walletAddr.dataset.acct) return;
301      e.stopPropagation();
302      open(menu.hidden);
303    });
304  }
305
306  // a menu that only closes via its own button is a menu people get stuck in
307  document.addEventListener('click', e => { if (!menu.contains(e.target)) open(false); });
308  document.addEventListener('keydown', e => { if (e.key === 'Escape') open(false); });
309
310  // ---- the footer link row ----
311  //
312  // The footer was a copyright line drawn in CSS with a ::after and nothing
313  // else, which was fine while HOW TO PLAY was in the bar. It is not fine now:
314  // taking a link out of a nav without putting it anywhere is deleting a page
315  // that still exists. Built here rather than pasted into thirty files, and it
316  // skips a footer that already has its own row — landing.html writes one,
317  // because the front door is static and never loads this file.
318  //
319  // THE TELEGRAM GROUP LINK WAS HERE and is gone on purpose, along with the
320  // copy of it in landing.html's own row and the two items in the account
321  // menu above. The group is where the token is discussed, and a link to it
322  // on every page of the arcade makes the arcade part of that. Nothing else
323  // referenced the invite hash, so the constant went with the last use of it:
324  // putting the link back means putting the hash back too, which is enough
325  // friction to make it a decision.
326  const foot = document.querySelector('footer');
327  if (foot && !foot.querySelector('.foot-links') && !foot.querySelector('a')) {
328    const row = document.createElement('p');
329    row.className = 'foot-links';
330    row.innerHTML = FOOT.map(l => `<a href="${l.href}">${esc(l.label)}</a>`).join('');
331    foot.prepend(row);
332  }
333
334  // ---- the UI switch ----
335  //
336  // Yesterday's colours and wording, on a toggle. It goes in the footer link
337  // row rather than the header, and that is a decision worth stating: the bar
338  // has no spare room at 390px, and the header MENU — the obvious home for a
339  // preference — is only reachable past 860px by tapping the wallet corner, so
340  // a signed-out player on a desktop could not open it at all. The footer row
341  // is on every page, at every width, wallet or no wallet.
342  //
343  // Built here for the same reason the row above is: one control in one file
344  // rather than a copy in thirty-five documents. It attaches to whichever row
345  // the page has — the one just built, or the front door's own hand-written
346  // one, since landing.html has no <footer> and never gets the block above.
347  //
348  // role="switch" with aria-checked rather than a checkbox drawn to look like
349  // one: it is what makes a screen reader say "on" and "off" about it.
350  const footRow = document.querySelector('.foot-links') || document.querySelector('.land-foot');
351  const UI = window.WEBCADE_UI;
352  if (footRow && UI) {
353    const sw = document.createElement('button');
354    sw.type = 'button';
355    sw.id = 'ui-switch';
356    sw.className = 'ui-switch';
357    sw.setAttribute('role', 'switch');
358    sw.innerHTML = '<span class="ui-switch-label">Retro</span>'
359      + '<span class="ui-switch-track" aria-hidden="true"></span>';
360    footRow.appendChild(sw);
361    // Subscribed rather than read once, so a page switched from somewhere else
362    // — the ?ui= query string, another tab — still shows the right state here.
363    // onChange calls back immediately, which is what sets it on load.
364    UI.onChange(on => {
365      sw.setAttribute('aria-checked', String(on));
366      sw.title = on
367        ? 'Retro is on: the old colours and wording. Nothing has moved.'
368        : 'Switch to the old colours and wording. Nothing moves.';
369    });
370    sw.addEventListener('click', () => UI.toggle());
371  }
372})();

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.