# Worldcup Tipping Game — Brain On BNB AI # A full tournament game that paid out on-chain. # # This is the complete worldcup-tipgame bundle as a single file, so it can be read in # one fetch. 45 files, 11114 lines. # Download as a zip: https://brainonbnb.com/code/worldcup-tipgame.zip # Everything else: https://brainonbnb.com/code/index.txt # # No secrets are present: they are supplied at runtime through the environment. # MIT licensed. ============================================================================== === FILE: dashboard/worldcup/app/assets/auth.js ============================================================================== // BOBAI Worldcup '26 — Auth helpers // Real-email auth: users sign up with their own email; login accepts // username OR email; password reset via email link. (function(){ const cfg = window.WC_CONFIG; const sb = window.supabase.createClient(cfg.SUPABASE_URL, cfg.SUPABASE_ANON_KEY, { auth: { persistSession: true, autoRefreshToken: true, storage: window.localStorage }, }); window.WC_SB = sb; // Username: 3–20 chars, letters/numbers/underscore (also enforced by DB CHECK) const USERNAME_RE = /^[a-zA-Z0-9_]{3,20}$/; // Basic email format (Supabase does the deep validation) const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; async function register({ username, email, password, passwordConfirm, country }){ username = (username || '').trim(); email = (email || '').trim().toLowerCase(); if (!USERNAME_RE.test(username)) { return { error: 'Username must be 3–20 chars, letters/numbers/underscore only.' }; } if (!EMAIL_RE.test(email)) { return { error: 'Please enter a valid email address.' }; } if (!password || password.length < 6) { return { error: 'Password must be at least 6 characters.' }; } if (password !== passwordConfirm) { return { error: 'Passwords do not match.' }; } // Create auth user with real email. The DB trigger wc_handle_new_user() // creates the matching wc_users profile row server-side, so we never have // to do a client-side insert (which would fail when "Confirm email" is on, // since there's no session yet). Username uniqueness is enforced by the // unique index on wc_users.username_lc → trigger raises → signUp fails. const { data: signUp, error: signUpErr } = await sb.auth.signUp({ email, password, options: { emailRedirectTo: window.location.origin + '/worldcup/app/', data: { username, country: country || null, }, }, }); if (signUpErr) { const m = (signUpErr.message || '').toLowerCase(); if (m.includes('already registered') || m.includes('already in use')) { return { error: 'Email already registered. Try signing in or use password reset.' }; } // Trigger failed on unique-username collision → Supabase returns a // generic "Database error saving new user" message. if (m.includes('database error') || m.includes('saving new user') || m.includes('duplicate') || m.includes('unique')) { return { error: 'Username already taken. Please pick another.' }; } return { error: signUpErr.message }; } // Confirm Email is on → no immediate session, user must click the email // link. Supabase's anti-enumeration may also return a null/empty user // even on successful signup; since signUpErr would have fired on a real // failure, treat "no error" as success and tell the user to check mail. const { data: s } = await sb.auth.getSession(); if (!s.session) { return { ok: true, needsConfirmation: true }; } return { ok: true, user: signUp.user }; } async function login({ identifier, password }){ identifier = (identifier || '').trim(); if (!identifier) return { error: 'Enter your username or email.' }; if (!password) return { error: 'Enter your password.' }; let email = null; if (identifier.includes('@')) { email = identifier.toLowerCase(); } else { if (!USERNAME_RE.test(identifier)) { return { error: 'Invalid username format.' }; } // Resolve username → email via RPC const { data, error } = await sb.rpc('wc_username_to_email', { p_username: identifier }); if (error || !data) { return { error: 'Wrong username or password.' }; } email = data; } const { data, error } = await sb.auth.signInWithPassword({ email, password }); if (error) { const m = (error.message || '').toLowerCase(); if (m.includes('email not confirmed')) { return { error: 'Please confirm your email first — check your inbox.' }; } return { error: 'Wrong username/email or password.' }; } // Make sure the profile row exists (for users whose sign-up insert was deferred // due to email-confirmation flow). await ensureProfile(data.user); return { ok: true, user: data.user }; } // Reset request — sends a password-reset email with a link back to reset.html. async function resetPasswordRequest(email){ email = (email || '').trim().toLowerCase(); if (!EMAIL_RE.test(email)) { return { error: 'Please enter a valid email address.' }; } const { error } = await sb.auth.resetPasswordForEmail(email, { redirectTo: window.location.origin + '/worldcup/app/reset.html', }); if (error) return { error: error.message }; return { ok: true }; } // Called on reset.html after the user clicks the email link. async function updatePassword(newPassword, confirmPassword){ if (!newPassword || newPassword.length < 6) { return { error: 'Password must be at least 6 characters.' }; } if (newPassword !== confirmPassword) { return { error: 'Passwords do not match.' }; } const { error } = await sb.auth.updateUser({ password: newPassword }); if (error) return { error: error.message }; return { ok: true }; } async function logout(){ await sb.auth.signOut(); } // Lazily create the wc_users row if it's missing (handles the email-confirm // race where signUp insert was blocked by RLS pre-session). async function ensureProfile(authUser){ if (!authUser) return null; const { data: existing } = await sb .from('wc_users') .select('id, username, avatar_country, wallet') .eq('auth_id', authUser.id) .maybeSingle(); if (existing) return existing; const meta = authUser.user_metadata || {}; const username = meta.username; if (!username || !USERNAME_RE.test(username)) return null; const { data: row, error } = await sb .from('wc_users') .insert({ auth_id: authUser.id, username, avatar_country: meta.country || null, }) .select('id, username, avatar_country, wallet') .single(); if (error) return null; return row; } async function currentProfile(){ const { data: s } = await sb.auth.getSession(); if (!s.session) return null; const { data, error } = await sb .from('wc_users') .select('id, username, avatar_country, wallet') .eq('auth_id', s.session.user.id) .maybeSingle(); if (error) return null; if (data) return data; // Profile missing → try to backfill (post email-confirmation case) return await ensureProfile(s.session.user); } window.WC_AUTH = { register, login, logout, currentProfile, resetPasswordRequest, updatePassword, }; })(); ============================================================================== === FILE: dashboard/worldcup/app/assets/avatar.js ============================================================================== // BOBAI Worldcup '26 — Shared avatar / illustration helper. // // Single source of truth for every illustration slot in the app. The // designer drops a file into the right path, and this helper picks it up // everywhere without any further code changes. // // Files the designer is expected to deliver: // /worldcup/app/illus/avatars/avatar-.webp (48 country avatars) // /worldcup/app/illus/pool-trophy.webp (pool-card flourish) // /worldcup/app/illus/bonus-hero.webp (bonus banner, optional) // /worldcup/app/illus/coin-btc.webp // /worldcup/app/illus/coin-bnb.webp // /worldcup/app/illus/coin-bobai.webp // // Two render modes: // - Production: if the file exists, the WebP is shown; otherwise a // fallback (flag emoji / unicode glyph) keeps the UI working. // - Designer (`?design=1` on any URL OR window.WC_DESIGN === true): // missing files are highlighted with a dashed magenta box that // prints the expected filename — so the designer can see at a // glance what's still missing. // // Avatar size presets: // xs = 24 (leaderboard rows) // sm = 32 // md = 56 (small profile chips) // lg = 72 (profile headers) // xl = 96 (champion picker cards) (function(){ const BASE = '/worldcup/app/illus/'; const AVATAR_BASE = BASE + 'avatars/'; // Formats we try, in priority order. The designer just drops *any* of these // into the right folder and the app picks it up. const FORMATS = ['webp', 'png', 'jpg', 'jpeg', 'svg']; // Designer mode toggle: ?design=1 OR localStorage('wc_design') OR window.WC_DESIGN const params = new URLSearchParams(window.location.search); const designOn = params.has('design') || localStorage.getItem('wc_design') === '1' || window.WC_DESIGN === true; if (params.has('design')) localStorage.setItem('wc_design', '1'); if (params.get('design') === '0') localStorage.removeItem('wc_design'); const SIZES = { xs: 24, sm: 32, md: 56, lg: 72, xl: 96 }; // Inline-onerror chain: when the current src 404s, jump to the next format. // After the last format, swap the for the fallback flag span. // Exposed on window so onerror="WC_AVATAR.imgError(this)" works. function imgError(img){ const base = img.dataset.fBase; const fmts = (img.dataset.fFmts || FORMATS.join(',')).split(','); let idx = parseInt(img.dataset.fIdx || '0', 10); idx++; if (idx < fmts.length) { img.dataset.fIdx = idx; img.src = base + '.' + fmts[idx]; return; } // All formats failed — show fallback inside the parent .wc-ava img.onerror = null; const flag = img.dataset.fFlag || '⚪'; const wrap = img.parentElement; img.remove(); if (wrap && wrap.classList.contains('wc-ava')) { // Use existing .wc-ava-fallback if present (designer mode), else add one let fb = wrap.querySelector('.wc-ava-fallback'); if (!fb) { fb = document.createElement('span'); fb.className = 'wc-ava-fallback'; const size = parseInt(wrap.style.width || '72', 10); fb.style.cssText = `display:flex;align-items:center;justify-content:center;width:100%;height:100%;font-size:${Math.round(size*0.55)}px;line-height:1`; fb.textContent = flag; wrap.appendChild(fb); } } } function findCountry(code){ if (!window.WC_COUNTRIES) return null; return window.WC_COUNTRIES.find(c => c.code === code) || null; } function avatarBase(code){ if (!code) return null; return AVATAR_BASE + 'avatar-' + code; // extension-less } function avatarUrl(code, ext){ if (!code) return null; return avatarBase(code) + '.' + (ext || FORMATS[0]); } // Inline HTML snippet for a single country avatar. // Renders an that walks through webp → png → jpg → jpeg → svg via // onerror. After the last format also 404s, it gets replaced with a flag // emoji span. In designer mode a magenta dashed wrap shows the filename. function avatarHtml(code, sizeKey = 'lg'){ const px = SIZES[sizeKey] || SIZES.lg; const c = findCountry(code); const flag = c ? c.flag : '⚪'; if (!code) { return ``; } const base = avatarBase(code); const fmts = FORMATS.join(','); const designCls = designOn ? ' wc-ava-design' : ''; const designTag = designOn ? `${flag} avatar-${code}.{webp|png|jpg}` : ''; return ` BOBAI ${c?c.name:code} ${designTag} `; } // Generic illustration slot for non-avatar art (trophy, hero, coin icons). // `filename` is the WebP filename — same fallback chain to png/jpg/etc. function illusUrl(filename){ return BASE + filename; } function illusHtml(filename, opts){ opts = opts || {}; const w = opts.w || 200; const h = opts.h || 200; const alt = opts.alt || ''; // strip extension so we can swap through the chain const base = BASE + filename.replace(/\.[a-z0-9]+$/i, ''); const fmts = FORMATS.join(','); if (designOn) { return ` ${alt} ${filename} `; } return `${alt}`; } // Hero banner helper — emits a full-width landscape illustration for the // top of a tab page. `slot` is the filename stem (e.g. 'dashboard-hero'). // If the file doesn't exist (and we're not in designer mode), the wrapper // collapses silently so the page just shows its normal heading. function heroHtml(slot){ const base = BASE + slot; const fmts = FORMATS.join(','); if (designOn) { return `
${slot}.{webp|png|jpg}
`; } return `
`; } // Fallback chain for hero images. Walks through formats, then hides the // wrapper entirely so missing art doesn't leave a broken-image gap. function heroError(img){ const base = img.dataset.fBase; const fmts = (img.dataset.fFmts || FORMATS.join(',')).split(','); let idx = parseInt(img.dataset.fIdx || '0', 10); idx++; if (idx < fmts.length) { img.dataset.fIdx = idx; img.src = base + '.' + fmts[idx]; return; } img.onerror = null; const wrap = img.parentElement; if (wrap && wrap.classList.contains('wc-hero') && !wrap.classList.contains('wc-hero-design')) { wrap.style.display = 'none'; } else { img.style.display = 'none'; } } // Same idea as heroHtml but for the small pool-card corner trophy accent. function poolTrophyHtml(){ const base = BASE + 'pool-trophy'; const fmts = FORMATS.join(','); return `
`; } // Generic card-corner accent (e.g. bonus-crystal, tips-ball). Same fallback // chain as heroHtml; positioned absolute top-right of the parent .card. function cardAccentHtml(slot){ const base = BASE + slot; const fmts = FORMATS.join(','); return `
`; } // Programmatic single-URL check — returns Promise. // We can't just trust `response.ok`: CF Pages may serve the project's 404 // HTML page with a 200 status for missing static files. So we also verify // the response is actually an image by checking Content-Type. function exists(path){ return fetch(path, { method: 'HEAD', cache: 'no-store' }) .then(r => { if (!r.ok) return false; const ct = (r.headers.get('content-type') || '').toLowerCase(); return ct.startsWith('image/'); }) .catch(() => false); } // Multi-format probe — returns the extension that exists, or null. // basePath = full URL without extension (e.g. ".../avatar-BR"). // Runs all format probes in parallel and returns the first hit (in FORMATS // priority order). This keeps for-designer.html responsive: with N slots // and the sequential version it would do up to 5×N HEAD requests serially. async function findFormat(basePath){ const probes = FORMATS.map(ext => exists(basePath + '.' + ext).then(ok => ok ? ext : null) ); const results = await Promise.all(probes); return results.find(Boolean) || null; } // Master inventory of every illustration slot. Used by for-designer.html. // basePath = URL without extension; the checker tries webp/png/jpg/jpeg/svg. function inventory(){ const items = []; // 48 country avatars (window.WC_COUNTRIES || []).forEach(c => { items.push({ kind: 'avatar', code: c.code, name: c.name, flag: c.flag, filename: 'avatars/avatar-' + c.code, basePath: AVATAR_BASE + 'avatar-' + c.code, specs: '512×512 transparent. BOBAI in the ' + c.name + ' national kit. Any format (WebP / PNG / JPG / SVG).', usedOn: 'Dashboard · Leaderboard · Public profile · Champion pick', required: true, }); }); // Page hero banners — one per tab. Landscape format, transparent. const HEROES = [ { name: 'Dashboard hero', filename: 'dashboard-hero', usedOn: 'Dashboard page (top banner)' }, { name: 'Tips hero', filename: 'tips-hero', usedOn: 'Tips page (top banner)' }, { name: 'Bonus-questions hero banner', filename: 'bonus-hero', usedOn: 'Bonus page (above questions)' }, { name: 'Crypto hero', filename: 'crypto-hero', usedOn: 'Crypto predictions page (top banner)' }, { name: 'Leaderboard hero', filename: 'leaderboard-hero',usedOn: 'Leaderboard page (top banner)' }, { name: 'Prize Pool hero', filename: 'prize-pool-hero', usedOn: 'Prize Pool page (top banner)' }, { name: 'Rules hero', filename: 'rules-hero', usedOn: 'Rules page (top banner)' }, ]; HEROES.forEach(h => items.push({ kind: 'illus', name: h.name, filename: h.filename, basePath: BASE + h.filename, specs: '~1500×1000, transparent, dark-mode compatible. BOBAI mascot themed for the section. Any format.', usedOn: h.usedOn, required: false, })); // Decorative accents items.push({ kind: 'illus', name: 'Pool-card trophy flourish', filename: 'pool-trophy', basePath: BASE + 'pool-trophy', specs: '~512×512, transparent. BOBAI lifting a small trophy, coins/sparkles. Any format.', usedOn: 'Leaderboard + Prize Pool pool-card corner accent', required: false, }); items.push({ kind: 'illus', name: 'Bonus crystal-ball accent', filename: 'bonus-crystal', basePath: BASE + 'bonus-crystal', specs: '~512×512, transparent. BOBAI peering into a glowing crystal ball. Any format.', usedOn: 'Bonus Questions card (top-right corner)', required: false, }); items.push({ kind: 'illus', name: 'Tips ball-kick accent', filename: 'tips-ball', basePath: BASE + 'tips-ball', specs: '~512×512, transparent. BOBAI kicking a small football. Any format.', usedOn: 'Tips card (top-right corner)', required: false, }); // BOBAI-style coin icons (re-skin of BTC / BNB / BOBAI marks) items.push({ kind: 'illus', name: 'BTC coin icon (BOBAI style)', filename: 'coin-btc', basePath: BASE + 'coin-btc', specs: '~512×512, transparent. BOBAI-style Bitcoin icon. Any format.', usedOn: 'Crypto predictions page · Leaderboard crypto view', required: false, }); items.push({ kind: 'illus', name: 'BNB coin icon (BOBAI style)', filename: 'coin-bnb', basePath: BASE + 'coin-bnb', specs: '~512×512, transparent. BOBAI-style BNB icon. Any format.', usedOn: 'Crypto predictions page · Leaderboard crypto view', required: false, }); items.push({ kind: 'illus', name: 'BOBAI coin icon (mark)', filename: 'coin-bobai', basePath: BASE + 'coin-bobai', specs: '~512×512, transparent. The brain icon, mark-style. Any format.', usedOn: 'Crypto predictions page · Leaderboard crypto view', required: false, }); return items; } // Click-to-zoom on any delivered avatar (wrapper has .wc-has-art once the img // actually loaded). Also catches plain .item.done .preview img on for-designer. // Lightbox markup is injected lazily on first open. function ensureLightbox(){ if (document.getElementById('wc-lightbox')) return; const lb = document.createElement('div'); lb.id = 'wc-lightbox'; lb.setAttribute('aria-hidden', 'true'); lb.innerHTML = '
'; document.body.appendChild(lb); lb.addEventListener('click', e => { if (e.target === lb || e.target.classList.contains('lb-close')) closeLightbox(); }); document.addEventListener('keydown', e => { if (e.key === 'Escape' && lb.classList.contains('open')) closeLightbox(); }); } function openLightbox(src, caption){ ensureLightbox(); const lb = document.getElementById('wc-lightbox'); lb.querySelector('img').src = src; lb.querySelector('.lb-cap').textContent = caption || ''; lb.classList.add('open'); lb.setAttribute('aria-hidden', 'false'); } function closeLightbox(){ const lb = document.getElementById('wc-lightbox'); if (!lb) return; lb.classList.remove('open'); lb.setAttribute('aria-hidden', 'true'); lb.querySelector('img').src = ''; } document.addEventListener('click', e => { const img = e.target.closest('.wc-ava.wc-has-art .wc-ava-img, .item.done .preview img, .illus-card img'); if (!img) return; e.preventDefault(); e.stopPropagation(); let caption = img.alt || ''; // Try to enrich caption with the username if inside a leaderboard row const row = img.closest('a.row, .item'); if (row) { const uname = row.querySelector('.uname-text, .uname, .name, .info .name'); if (uname && uname.textContent.trim()) caption = uname.textContent.trim(); } openLightbox(img.src, caption); }); // Auto-wire any declarative markup so each page only needs one line. //
→ renders dashboard-hero banner //
→ renders the pool-card trophy accent function autoWire(){ document.querySelectorAll('[data-hero]').forEach(el => { if (el.dataset.wired) return; el.dataset.wired = '1'; el.outerHTML = heroHtml(el.dataset.hero); }); document.querySelectorAll('[data-pool-trophy]').forEach(el => { if (el.dataset.wired) return; el.dataset.wired = '1'; el.outerHTML = poolTrophyHtml(); }); document.querySelectorAll('[data-card-accent]').forEach(el => { if (el.dataset.wired) return; el.dataset.wired = '1'; el.outerHTML = cardAccentHtml(el.dataset.cardAccent); }); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', autoWire); } else { autoWire(); } window.WC_AVATAR = { designOn, formats: FORMATS, avatarUrl, avatarHtml, illusUrl, illusHtml, heroHtml, heroError, poolTrophyHtml, cardAccentHtml, exists, findFormat, inventory, imgError, openLightbox, closeLightbox, }; })(); ============================================================================== === FILE: dashboard/worldcup/app/assets/config.js ============================================================================== // BOBAI Worldcup '26 — App Config (public-safe values only) window.WC_CONFIG = { SUPABASE_URL: 'https://aerffjhdsbxpvuulkryr.supabase.co', SUPABASE_ANON_KEY: 'sb_publishable_ne0MFzyCQb6MvFWur2X-Vw_vG9JH76_', // Auth uses the user's real email (required for password reset). // Login accepts username OR email; see auth.js for the username→email resolver. // Kickoff & registration windows (UTC) REG_OPEN_UTC: '2026-06-04T12:00:00Z', KICKOFF_UTC: '2026-06-11T19:00:00Z', // Bonus + Crypto edit deadline — one-time extension after MD1 to give late joiners a fair chance. // Locks 15 min before the first MD2 match (CZ–ZA, 2026-06-18 16:00 UTC). Crypto scoring snapshot still happens at Final kickoff. BONUS_CRYPTO_LOCK_UTC: '2026-06-18T15:45:00Z', // BOBAI token (BSC) BOBAI_TOKEN: '0x245c386dcfed896f5c346107596141e5edcbffff', // Prize wallet (Phase H) — public BSC address, receives BNB/USDT donations + 26% creator tax post-04.06 PRIZE_WALLET: '0x5E4102520A71B2AA18a1208330d4848dea4BD105', // USDT (BEP-20) contract on BSC — for donation address copy + future integration USDT_BSC: '0x55d398326f99059fF775485246999027B3197955', // Pool fill starts at kickoff (matches KICKOFF_UTC). Display gating only — actual on-chain flow handled by worker. POOL_FILL_START_UTC: '2026-06-11T19:00:00Z', }; ============================================================================== === FILE: dashboard/worldcup/app/assets/countries.js ============================================================================== // BOBAI Worldcup '26 — Qualified Teams (FIFA WC 2026) // 48 teams, alphabetical. Source: FIFA / UEFA / CONMEBOL official qualification (as of 2026-05). window.WC_COUNTRIES = [ // CAF (10) { code: 'DZ', name: 'Algeria', flag: '🇩🇿' }, // CONMEBOL (6) { code: 'AR', name: 'Argentina', flag: '🇦🇷' }, // AFC (9) { code: 'AU', name: 'Australia', flag: '🇦🇺' }, // UEFA (16) { code: 'AT', name: 'Austria', flag: '🇦🇹' }, { code: 'BE', name: 'Belgium', flag: '🇧🇪' }, { code: 'BA', name: 'Bosnia & Herz.', flag: '🇧🇦' }, { code: 'BR', name: 'Brazil', flag: '🇧🇷' }, // CONCACAF (6, incl. hosts) { code: 'CA', name: 'Canada', flag: '🇨🇦' }, { code: 'CV', name: 'Cape Verde', flag: '🇨🇻' }, { code: 'CO', name: 'Colombia', flag: '🇨🇴' }, { code: 'HR', name: 'Croatia', flag: '🇭🇷' }, { code: 'CW', name: 'Curaçao', flag: '🇨🇼' }, { code: 'CZ', name: 'Czechia', flag: '🇨🇿' }, { code: 'CD', name: 'DR Congo', flag: '🇨🇩' }, { code: 'EC', name: 'Ecuador', flag: '🇪🇨' }, { code: 'EG', name: 'Egypt', flag: '🇪🇬' }, { code: 'ENG', name: 'England', flag: '🏴󠁧󠁢󠁥󠁮󠁧󠁿' }, { code: 'FR', name: 'France', flag: '🇫🇷' }, { code: 'DE', name: 'Germany', flag: '🇩🇪' }, { code: 'GH', name: 'Ghana', flag: '🇬🇭' }, { code: 'HT', name: 'Haiti', flag: '🇭🇹' }, { code: 'IR', name: 'Iran', flag: '🇮🇷' }, { code: 'IQ', name: 'Iraq', flag: '🇮🇶' }, { code: 'CI', name: 'Ivory Coast', flag: '🇨🇮' }, { code: 'JP', name: 'Japan', flag: '🇯🇵' }, { code: 'JO', name: 'Jordan', flag: '🇯🇴' }, { code: 'MX', name: 'Mexico', flag: '🇲🇽' }, { code: 'MA', name: 'Morocco', flag: '🇲🇦' }, { code: 'NL', name: 'Netherlands', flag: '🇳🇱' }, // OFC (1) { code: 'NZ', name: 'New Zealand', flag: '🇳🇿' }, { code: 'NO', name: 'Norway', flag: '🇳🇴' }, { code: 'PA', name: 'Panama', flag: '🇵🇦' }, { code: 'PY', name: 'Paraguay', flag: '🇵🇾' }, { code: 'PT', name: 'Portugal', flag: '🇵🇹' }, { code: 'QA', name: 'Qatar', flag: '🇶🇦' }, { code: 'SA', name: 'Saudi Arabia', flag: '🇸🇦' }, { code: 'SCO', name: 'Scotland', flag: '🏴󠁧󠁢󠁳󠁣󠁴󠁿' }, { code: 'SN', name: 'Senegal', flag: '🇸🇳' }, { code: 'ZA', name: 'South Africa', flag: '🇿🇦' }, { code: 'KR', name: 'South Korea', flag: '🇰🇷' }, { code: 'ES', name: 'Spain', flag: '🇪🇸' }, { code: 'SE', name: 'Sweden', flag: '🇸🇪' }, { code: 'CH', name: 'Switzerland', flag: '🇨🇭' }, { code: 'TN', name: 'Tunisia', flag: '🇹🇳' }, { code: 'TR', name: 'Türkiye', flag: '🇹🇷' }, { code: 'UY', name: 'Uruguay', flag: '🇺🇾' }, { code: 'US', name: 'USA', flag: '🇺🇸' }, { code: 'UZ', name: 'Uzbekistan', flag: '🇺🇿' }, ]; // Sort alphabetically by display name (the section comments above are just for traceability) window.WC_COUNTRIES.sort((a,b) => a.name.localeCompare(b.name)); ============================================================================== === FILE: dashboard/worldcup/app/assets/nav.js ============================================================================== // BOBAI Worldcup '26 — Shared nav behavior // Activates the current tab, wires logout, and fills the "Hi " slot. // Pages that include this script just need the