1/* iChallenge Arena v5 client. No framework. All user/game text is inserted with textContent. */ 2(function () { 3 "use strict"; 4 var bootEl = document.getElementById("boot"); 5 if (!bootEl) return; 6 var BOOT = JSON.parse(bootEl.textContent); 7 var REDUCED = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches; 8 var LS = { 9 get: function (k) { try { return localStorage.getItem(k); } catch (e) { return null; } }, 10 set: function (k, v) { try { localStorage.setItem(k, v); } catch (e) {} }, 11 del: function (k) { try { localStorage.removeItem(k); } catch (e) {} }, 12 }; 13 var TOKEN_KEY = BOOT.preview ? "ic_t_preview" : "ic_t"; 14 var state = { player: null, token: LS.get(TOKEN_KEY) }; 15 16 // ---------- helpers ---------- 17 function $(s, r) { return (r || document).querySelector(s); } 18 function $$(s, r) { return Array.prototype.slice.call((r || document).querySelectorAll(s)); } 19 function h(tag, attrs) { 20 var el = document.createElement(tag); 21 if (attrs) for (var k in attrs) { 22 if (attrs[k] == null) continue; 23 if (k === "class") el.className = attrs[k]; 24 else if (k === "text") el.textContent = attrs[k]; 25 else if (k.slice(0, 2) === "on") el.addEventListener(k.slice(2), attrs[k]); 26 else el.setAttribute(k, attrs[k]); 27 } 28 for (var i = 2; i < arguments.length; i++) { 29 var c = arguments[i]; 30 if (c == null || c === false) continue; 31 if (Array.isArray(c)) { c.forEach(function (x) { if (x) el.appendChild(x); }); continue; } 32 el.appendChild(typeof c === "string" || typeof c === "number" ? document.createTextNode(String(c)) : c); 33 } 34 return el; 35 } 36 function svgEl(markup) { 37 var t = document.createElement("template"); 38 t.innerHTML = markup; // static, code-owned markup only 39 return t.content.firstChild; 40 } 41 function fmt(n) { return Number(n || 0).toLocaleString("en-US"); } 42 function withArena(path) { 43 if (!BOOT.preview) return path; 44 var hash = ""; 45 var i = path.indexOf("#"); 46 if (i > -1) { hash = path.slice(i); path = path.slice(0, i); } 47 return path + (path.indexOf("?") > -1 ? "&" : "?") + "arena=" + encodeURIComponent(BOOT.host) + hash; 48 } 49 50 function api(path, body, method) { 51 var headers = { "content-type": "application/json" }; 52 if (state.token) headers.authorization = "Bearer " + state.token; 53 if (BOOT.preview) headers["x-arena-host"] = BOOT.host; 54 return fetch(path, { method: method || (body ? "POST" : "GET"), headers: headers, body: body ? JSON.stringify(body) : undefined, credentials: "omit" }) 55 .then(function (r) { 56 return r.json().catch(function () { return {}; }).then(function (j) { j._status = r.status; return j; }); 57 }) 58 .catch(function () { return { error: "You're offline. Check your connection and try again.", _status: 0 }; }); 59 } 60 61 function toast(title, sub, badgeId) { 62 var box = $("#toasts"); 63 if (!box) return; 64 var t = h("div", { class: "toast" }); 65 if (badgeId) t.appendChild(svgEl(badgeSvg(badgeId))); 66 t.appendChild(h("div", null, h("b", { text: title }), sub ? h("span", { text: sub }) : null)); 67 box.appendChild(t); 68 setTimeout(function () { t.remove(); }, 4200); 69 } 70 71 var GLYPHS = { 72 first_round: "M8 4h8M12 4v16M8 20h8", flawless: "M5 12.5l4.5 4.5L19 7.5", lightning: "M13 3L5 13.5h6L10 21l9-11h-6z", 73 on_a_roll: "M12 3c1 3.5 5 5.5 5 10a5 5 0 01-10 0c0-2.5 1.5-4 2.5-5.5.5 2 1.5 3 2.5 3 0-2.5-1-5 0-7.5z", 74 week_warrior: "M4 6h16v14H4zM4 10h16M8 3v5M16 3v5", unbreakable: "M12 3l7 3v6c0 4.5-3 7.5-7 9-4-1.5-7-4.5-7-9V6z", 75 explorer: "M12 21a9 9 0 110-18 9 9 0 010 18zM15.5 8.5l-2 5-5 2 2-5z", 76 globetrotter: "M12 21a9 9 0 110-18 9 9 0 010 18zM3 12h18M12 3c2.5 2.5 3.5 5.5 3.5 9s-1 6.5-3.5 9c-2.5-2.5-3.5-5.5-3.5-9s1-6.5 3.5-9z", 77 polymath: "M4 5.5C6.5 4 9.5 4 12 5.5 14.5 4 17.5 4 20 5.5V19c-2.5-1.5-5.5-1.5-8 0-2.5-1.5-5.5-1.5-8 0zM12 5.5V19", 78 in_the_arena: "M5 21V4M5 4h11l-2 4 2 4H5", finisher: "M12 15a6 6 0 100-12 6 6 0 000 12zM8.5 14L7 21l5-2.5L17 21l-1.5-7", 79 recruiter: "M9 11a3.5 3.5 0 100-7 3.5 3.5 0 000 7zM3 20c0-3.5 2.5-6 6-6s6 2.5 6 6M18 8v6M15 11h6", 80 captain: "M4 18h16M5 18L4 8l4.5 4L12 5l3.5 7L20 8l-1 10", podium: "M4 20V14h5v6M9 20V9h6v11M15 20v-8h5v8", 81 challenger: "M3 20l6.5-11 4 6 2.5-4 5 9z", saved: "M4 6h16v12H4zM4 7l8 6 8-6", 82 }; 83 function badgeSvg(id) { 84 return '<svg class="medal-svg" width="44" height="44" viewBox="0 0 48 48" aria-hidden="true"><circle cx="24" cy="24" r="22" class="medal-ring"/><circle cx="24" cy="24" r="17" class="medal-core"/><g transform="translate(12 12)">
84<path d="' + (GLYPHS[id] || GLYPHS.first_round) + '" class="medal-glyph"/></g></svg>'; 85 } 86 87 // ---------- player ---------- 88 function ensurePlayer() { 89 if (state.token && state.player) return Promise.resolve(state.player); 90 if (state.token) return loadMe().then(function (p) { return p || createPlayer(); }); 91 return createPlayer(); 92 } 93 function createPlayer() { 94 return api("/api/player", { ref: LS.get("ic_ref") || undefined, hp_field: "" }).then(function (r) { 95 if (!r.token) { toast("Couldn't start your profile", r.error || "Try again in a moment."); return null; } 96 state.token = r.token; LS.set(TOKEN_KEY, r.token); state.player = r.player; renderPlayer(); 97 return r.player; 98 }); 99 } 100 function loadMe() { 101 if (!state.token) { renderPlayer(); return Promise.resolve(null); } 102 return api("/api/me").then(function (r) { 103 if (r._status === 0) return state.player; 104 if (!r.player) { state.token = null; LS.del(TOKEN_KEY); } 105 state.player = r.player || null; 106 renderPlayer(); 107 return state.player; 108 }); 109 } 110 111 function setRing(el, frac) { 112 var c = 2 * Math.PI * 15; 113 var f = Math.max(0, Math.min(1, frac)); 114 el.style.strokeDasharray = (f * c).toFixed(1) + " " + c.toFixed(1); 115 } 116 117 function renderPlayer() { 118 var p = state.player; 119 var span = p ? Math.max(1, p.level_next - p.level_floor) : 1; 120 var frac = p ? (p.xp - p.level_floor) / span : 0; 121 $$("[data-lvl]").forEach(function (e) { e.textContent = p ? p.level : 1; }); 122 $$("[data-lvl-text]").forEach(function (e) { e.textContent = p ? p.level : 1; }); 123 $$("[data-title]").forEach(function (e) { e.textContent = p ? p.title : "Rookie"; }); 124 $$("[data-handle]").forEach(function (e) { e.textContent = p ? p.handle : "Guest"; }); 125 $$("[data-ring-fill]").forEach(function (e) { setRing(e, frac); }); 126 $$("[data-xp-bar]").forEach(function (e) { e.style.width = (frac * 100).toFixed(1) + "%"; }); 127 $$("[data-xp-text]").forEach(function (e) { e.textContent = p && p.xp ? fmt(p.xp - p.level_floor) + " / " + fmt(span) + " XP to level " + (p.level + 1) : "Play a round to earn XP"; }); 128 var streak = p && p.streak_alive ? p.streak : 0; 129 $$("[data-streak]").forEach(function (e) { e.textContent = streak; }); 130 $$("[data-streak-text]").forEach(function (e) { e.textContent = streak; }); 131 $$("[data-streak-sub]").forEach(function (e) { e.textContent = !p ? "days" : p.played_today ? "days · safe today" : streak ? "days · play today" : "days"; }); 132 $$("[data-streak-chip]").forEach(function (e) { e.classList.toggle("is-hot", !!(p && p.played_today)); }); 133 $$("[data-league]").forEach(function (e) { var l = p ? p.league : "bronze"; e.textContent = l.charAt(0).toUpperCase() + l.slice(1); e.setAttribute("data-l", l); }); 134 var owned = {}; 135 if (p && p.badges) p.badges.forEach(function (b) { owned[b.id] = true; }); 136 $$("[data-badge]").forEach(function (e) { e.classList.toggle("is-locked", !owned[e.getAttribute("data-badge")]); }); 137 if (p && p.email_saved) { 138 $$(".save").forEach(function (s) { s.classList.add("is-saved"); }); 139 var note = $("[data-save-note]"); 140 if (note && !note.textContent) note.textContent = p.email_verified ? "Your profile is saved." : "Email saved."; 141 } 142 var profile = $("[data-profile]"); 143 if (profile && p) { 144 var set = function (sel, v) { var e = $(sel, profile); if (e) e.textContent = v; }; 145 set("[data-xp-total]", fmt(p.xp)); set("[data-best-streak]", p.best_streak); set("[data-rounds]", p.rounds_played); 146 set("[data-perfect]", p.perfect_rounds); set("[data-freezes]", p.freezes); 147 var link = $("[data-invite-link]", profile); 148 if (link) link.textContent = location.origin + "/?ref=" + p.ref_code; 149 } 150 } 151 152 // ---------- round engine ---------- 153 var round = null; 154 var timerRAF = 0; 155 var roundEl = $("[data-round]"); 156 157 function showPane(name) { 158 if (!roundEl) return; 159 ["idle", "live", "done"].forEach(function (n) { $("[data-round-" + n + "]", roundEl).hidden = n !== name; }); 160 } 161 162 function refreshToday() { 163 if (!roundEl) return; 164 api("/api/round/today").then(function (t) { 165 if (t._status !== 200) return; 166 var pt = $("[data-players-today]", roundEl); if (pt) pt.textContent = fmt(t.players_today); 167 var daily = $("[data-start='daily']", roundEl), practice = $("[data-start='practice']", roundEl), note = $("[data-round-note]", roundEl); 168 if (t.mine && t.mine.status === "done") { 169 daily.textContent = "See today's result"; daily.dataset.showResult = "1"; 170 practice.hidden = false;
171 note.textContent = "You scored " + fmt(t.mine.score) + " today (" + t.mine.correct + "/" + t.mine.total + "). Next round in " + hm(t.resets_in_ms) + "."; 172 state.todayMine = t.mine; 173 } else if (t.mine && t.mine.status === "open") { 174 daily.textContent = "Resume today's round"; 175 } 176 }); 177 } 178 function hm(ms) { var m = Math.max(0, Math.round(ms / 60000)); return Math.floor(m / 60) + "h " + (m % 60) + "m"; } 179 180 function start(mode) { 181 var btn = $("[data-start='" + mode + "']", roundEl); 182 if (btn && btn.dataset.showResult && state.todayMine) return showSavedResult(state.todayMine); 183 if (btn) btn.disabled = true; 184 ensurePlayer().then(function (p) { 185 if (!p) { if (btn) btn.disabled = false; return null; } 186 return api("/api/round/start", { mode: mode }).then(function (r) { 187 if (btn) btn.disabled = false; 188 if (r._status === 409 && r.today && r.today.mine) { state.todayMine = r.today.mine; return showSavedResult(r.today.mine); } 189 if (!r.round_id) { toast("Couldn't start the round", r.error || "Try again."); return; } 190 round = { id: r.round_id, mode: mode, score: r.score || 0, combo: r.combo || 0, results: [], total: r.question.total, locked: false }; 191 showPane("live"); 192 renderQuestion(r.question, r.elapsed_ms || 0); 193 roundEl.scrollIntoView({ behavior: REDUCED ? "auto" : "smooth", block: "nearest" }); 194 }); 195 }); 196 } 197 198 function renderQuestion(q, elapsed) { 199 var live = $("[data-round-live]", roundEl); 200 live.textContent = ""; 201 var steps = h("div", { class: "q-steps", "aria-hidden": "true" }); 202 for (var i = 0; i < q.total; i++) { 203 var cls = i < round.results.length ? (round.results[i] ? "is-ok" : "is-bad") : i === q.i ? "is-on" : ""; 204 steps.appendChild(h("i", { class: cls })); 205 } 206 var C = 2 * Math.PI * 20; 207 var timer = svgEl('<div class="q-timer" aria-hidden="true"><svg viewBox="0 0 46 46" width="46" height="46"><circle cx="23" cy="23" r="20" class="t-track"/><circle cx="23" cy="23" r="20" class="t-fill" style="stroke-dasharray:' + C + " " + C + '"/></svg><b>20</b></div>'); 208 live.appendChild(h("div", { class: "q-head" }, steps, timer)); 209 live.appendChild(h("div", { class: "q-meta" }, 210 h("span", { text: (round.mode === "daily" ? "Daily" : "Practice") + " · Question " + (q.i + 1) + " of " + q.total }), 211 round.combo > 0 ? h("span", { class: "q-combo", text: "Combo Ã" + [1, 1.1, 1.2, 1.35, 1.5][Math.min(round.combo, 4)] }) : h("span", { class: "q-score", text: fmt(round.score) + " pts" }) 212 )); 213 live.appendChild(h("p", { class: "q-text", id: "qText", text: q.q })); 214 var opts = h("div", { class: "opts", role: "group", "aria-labelledby": "qText" }); 215 q.opts.forEach(function (o, idx) { 216 opts.appendChild(h("button", { class: "opt", type: "button", "data-choice": idx, onclick: function () { answer(idx); } }, h("kbd", { text: String(idx + 1) }), h("span", { text: o }))); 217 }); 218 live.appendChild(opts); 219 var firstBtn = $(".opt", opts); if (firstBtn && !REDUCED) firstBtn.focus({ preventScroll: true }); 220 221 var deadline = performance.now() + Math.max(0, q.time_ms - elapsed); 222 var fill = $(".t-fill", timer), label = $("b", timer); 223 cancelAnimationFrame(timerRAF); 224 (function tick() { 225 var left = deadline - performance.now(); 226 var frac = Math.max(0, left / q.time_ms); 227 fill.style.strokeDasharray = (frac * C).toFixed(1) + " " + C; 228 label.textContent = Math.max(0, Math.ceil(left / 1000)); 229 timer.classList.toggle("is-low", left < 5000); 230 if (left <= 0) { if (!round.locked) answer(-1); return; } 231 timerRAF = requestAnimationFrame(tick); 232 })(); 233 } 234 235 function answer(choice) { 236 if (!round || round.locked) return; 237 round.locked = true; 238 cancelAnimationFrame(timerRAF); 239 var live = $("[data-round-live]", roundEl); 240 $$(".opt", live).forEach(function (b) { b.disabled = true; }); 241 api("/api/round/answer", { round_id: round.id, choice: choice }).then(function (r) { 242 if (r.error) { toast("That answer didn't go through", r.error); round.locked = false; return; } 243 round.score = r.score; round.combo = r.combo; round.results.push(r.ok); 244 $$(".opt", live).forEach(function (b) { 245 var i = +b.getAttribute("data-choice"); 246 if (i === r.correct_index) b.classList.add("is-right"); 247 else if (i === choice) { b.classList.add("is-wrong"); if (!REDUCED) b.classList.add("is-shake"); } 248 else b.classList.add("is-dim"); 249 }); 250 var steps = $$(".q-steps i", live); 251 if (steps[round.results.length - 1]) steps[round.results.length - 1].className = r.ok ? "is-ok" : "is-bad"; 252 var msg = r.ok ? (r.combo > 1 ? "Correct â " + r.combo + " in a row" : "Correct") : r.timed_out ? "Time's up" : "Not quite"; 253 var reveal = h("div", { class: "reveal" + (r.ok ? "" : " is-bad"), role: "status" }, 254 h("div", null, h("b", { text: msg }), r.why ? h("div", { text: r.why }) : null),
255 h("span", { class: "pts" + (r.ok && !REDUCED ? " is-pop" : ""), text: r.ok ? "+" + r.points : "+0" })); 256 live.appendChild(reveal); 257 var go = function () { 258 if (r.done) return renderDone(r.done); 259 nextBtn.disabled = true; 260 api("/api/round/next", { round_id: round.id }).then(function (n) { 261 if (!n.question) { nextBtn.disabled = false; return toast("Couldn't load the next question", n.error || "Try again."); } 262 round.locked = false; 263 renderQuestion(n.question, n.elapsed_ms || 0); 264 }); 265 }; 266 var nextBtn = h("button", { class: "btn btn-primary q-next", type: "button", text: r.done ? "See results" : "Next question", onclick: go }); 267 live.appendChild(nextBtn); 268 nextBtn.focus({ preventScroll: true }); 269 }); 270 } 271 272 function countUp(el, to) { 273 if (REDUCED) { el.textContent = fmt(to); return; } 274 var t0 = performance.now(), dur = 900; 275 (function step(t) { 276 var k = Math.min(1, (t - t0) / dur); 277 el.textContent = fmt(Math.round(to * (1 - Math.pow(1 - k, 3)))); 278 if (k < 1) requestAnimationFrame(step); 279 })(t0); 280 } 281 282 function renderDone(d) { 283 round = null; 284 showPane("done"); 285 var box = $("[data-round-done]", roundEl); 286 box.textContent = ""; 287 box.appendChild(h("div", { class: "round-top" }, h("span", { class: "pill pill-accent", text: d.mode === "daily" ? "Daily result" : "Practice result" }), d.rank_today ? h("span", { class: "round-reset", text: "#" + d.rank_today.rank + " of " + fmt(d.rank_today.of) + " today" }) : null)); 288 if (d.level_up) box.appendChild(h("div", { class: "levelup" + (REDUCED ? "" : " is-pop") }, h("b", { text: "Level " + d.level }), h("span", { text: "You're now a " + d.title + "." }))); 289 var scoreB = h("b", { text: "0" }); 290 box.appendChild(h("div", { class: "done-score" }, scoreB, h("span", { text: "points" }))); 291 countUp(scoreB, d.score); 292 var grid = h("div", { class: "done-grid", "aria-label": d.correct + " of " + d.total + " correct" }); 293 d.grid.forEach(function (ok) { grid.appendChild(h("i", { class: ok ? "ok" : "" })); }); 294 box.appendChild(grid); 295 var rows = h("div", { class: "done-rows" }); 296 rows.appendChild(h("div", { class: "done-row" }, h("span", { text: "Correct" }), h("b", { text: d.correct + " / " + d.total }))); 297 if (d.perfect_bonus) rows.appendChild(h("div", { class: "done-row" }, h("span", { text: "Perfect round bonus" }), h("b", { text: "+" + d.perfect_bonus }))); 298 rows.appendChild(h("div", { class: "done-row" }, h("span", { text: "XP earned" }), h("b", { text: "+" + fmt(d.xp_gained) }))); 299 if (d.mode === "daily") rows.appendChild(h("div", { class: "done-row" }, h("span", { text: d.freeze_used ? "Streak (a freeze saved it)" : "Streak" }), h("b", { text: d.streak + (d.streak === 1 ? " day" : " days") }))); 300 box.appendChild(rows); 301 var span = Math.max(1, d.level_next - d.level_floor); 302 var bar = h("i", { style: "width:0%" }); 303 box.appendChild(h("div", { class: "done-xp" }, h("div", { class: "bar bar-xp" }, bar), h("p", null, h("span", { text: "Level " + d.level }), h("span", { text: fmt(d.xp_total - d.level_floor) + " / " + fmt(span) + " XP" })))); 304 requestAnimationFrame(function () { bar.style.width = (((d.xp_total - d.level_floor) / span) * 100).toFixed(1) + "%"; }); 305 if (d.badges_new && d.badges_new.length) { 306 var bl = h("div", { class: "done-badges" }); 307 d.badges_new.forEach(function (b, i) { 308 var m = h("div", { class: "medal is-new" }); 309 m.appendChild(svgEl(badgeSvg(b.id))); 310 m.appendChild(h("div", null, h("b", { text: b.name }), h("span", { text: b.desc }))); 311 bl.appendChild(m); 312 setTimeout(function () { toast("Badge unlocked: " + b.name, b.desc, b.id); }, 500 + i * 700); 313 }); 314 box.appendChild(bl); 315 } 316 var actions = h("div", { class: "done-actions" }); 317 if (d.share) actions.appendChild(h("button", { class: "btn btn-primary", type: "button", text: "Challenge a friend", onclick: function () { share(d.share); } })); 318 actions.appendChild(h("button", { class: "btn btn-secondary", type: "button", text: "Practice round", onclick: function () { start("practice"); } })); 319 if (!state.player || !state.player.email_saved) actions.appendChild(h("a", { class: "btn btn-quiet span", href: "#save", text: "Save your progress" })); 320 else if (!BOOT.hub) actions.appendChild(h("a", { class: "btn btn-quiet span", href: withArena("/#challenges"), text: "Join a challenge" })); 321 box.appendChild(actions); 322 var tgt = beatTarget(); 323 if (d.mode === "daily" && tgt && tgt.today && (!state.player || state.player.ref_code !== tgt.code)) { 324 var diff = d.score - tgt.score; 325 box.insertBefore(h("div", { class: "h2h " + (diff > 0 ? "is-win" : diff < 0 ? "is-loss" : "") }, 326 h("b", { text: diff > 0 ? "You beat " + tgt.handle + " by " + fmt(diff) : diff < 0 ? tgt.handle + " wins by " + fmt(-diff) : "Dead heat with " + tgt.handle }), 327 h("span", { text: "You " + fmt(d.score) + " · " + tgt.handle + " " + fmt(tgt.score) })), rows); 328 if (!LS.get("ic_beat_played_" + tgt.code + tgt.day)) { LS.set("ic_beat_played_" + tgt.code + tgt.day, "1"); track("beat_play"); } 329 } 330 loadMe(); 331 loadLeaderboard(); 332 if (d.mode === "daily") { 333 state.todayMine = { status: "done", score: d.score, correct: d.correct, total: d.total, grid: d.grid, xp: d.xp_gained }; 334 var daily = $("[data-start='daily']", roundEl); 335 daily.textContent = "See today's result"; daily.dataset.showResult = "1"; 336 $("[data-start='practice']", roundEl).hidden = false;
337 } 338 } 339 340 function showSavedResult(m) { 341 showPane("done"); 342 var box = $("[data-round-done]", roundEl); 343 box.textContent = ""; 344 box.appendChild(h("div", { class: "round-top" }, h("span", { class: "pill pill-accent", text: "Today's result" }))); 345 box.appendChild(h("div", { class: "done-score" }, h("b", { text: fmt(m.score) }), h("span", { text: "points" }))); 346 var grid = h("div", { class: "done-grid" }); 347 (m.grid || []).forEach(function (ok) { grid.appendChild(h("i", { class: ok ? "ok" : "" })); }); 348 box.appendChild(grid); 349 box.appendChild(h("p", { class: "round-note", text: "You've played today's ranked round on " + BOOT.brand + ". A new one opens at midnight UTC." })); 350 var gridTxt = (m.grid || []).map(function (ok) { return ok ? "ð©" : "ð¥"; }).join(""); 351 box.appendChild(h("div", { class: "done-actions" }, 352 h("button", { class: "btn btn-primary", type: "button", text: "Challenge a friend", onclick: function () { share(BOOT.brand + " · Daily #" + (BOOT.today ? BOOT.today.number : "") + "\n" + gridTxt + " " + m.correct + "/" + m.total + " · " + m.score + " pts"); } }), 353 h("button", { class: "btn btn-secondary", type: "button", text: "Practice round", onclick: function () { start("practice"); } }))); 354 } 355 356 function track(t) { 357 try { fetch("/api/event", { method: "POST", keepalive: true, headers: { "content-type": "application/json", "x-arena-host": BOOT.host }, body: JSON.stringify({ t: t }) }); } catch (e) {} 358 } 359 function beatUrl() { 360 var day = (BOOT.today && BOOT.today.day) || new Date().toISOString().slice(0, 10); 361 var q = state.player ? "?beat=" + state.player.ref_code + "&d=" + day : ""; 362 return withArena(location.origin + "/" + q); 363 } 364 // Share sheet: native share when available, otherwise one-tap links. Every share counts once toward arena stats. 365 function share(textMsg, opts) { 366 opts = opts || {}; 367 var url = opts.url || beatUrl(); 368 var msg = textMsg + (opts.url ? "" : "\nCan you beat me?"); 369 var old = $(".sharesheet"); if (old) old.remove(); 370 var counted = false; 371 var count = function () { if (!counted) { counted = true; track("share"); } }; 372 var enc = encodeURIComponent; 373 var links = [ 374 ["X", "https://twitter.com/intent/tweet?text=" + enc(msg) + "&url=" + enc(url)], 375 ["WhatsApp", "https://wa.me/?text=" + enc(msg + "\n" + url)], 376 ["LinkedIn", "https://www.linkedin.com/sharing/share-offsite/?url=" + enc(url)], 377 ["Facebook", "https://www.facebook.com/sharer/sharer.php?u=" + enc(url)], 378 ["Email", "mailto:?subject=" + enc(opts.subject || "Can you beat my score on " + BOOT.brand + "?") + "&body=" + enc(msg + "\n\n" + url)], 379 ]; 380 var sheet = h("div", { class: "sharesheet", role: "dialog", "aria-label": "Share" }, 381 h("div", { class: "sharesheet-card" }, 382 h("div", { class: "sharesheet-top" }, h("b", { text: opts.title || "Challenge a friend" }), h("button", { class: "btn btn-quiet", type: "button", text: "Close", onclick: function () { sheet.remove(); } })), 383 h("pre", { class: "sharesheet-text", text: msg + "\n" + url }), 384 h("div", { class: "sharesheet-links" }, 385 navigator.share ? h("button", { class: "btn btn-primary", type: "button", text: "Shareâ¦", onclick: function () { count(); navigator.share({ text: msg, url: url }).catch(function () {}); } }) : null, 386 h("button", { class: "btn btn-secondary", type: "button", text: "Copy", onclick: function () { count(); if (navigator.clipboard) navigator.clipboard.writeText(msg + "\n" + url).then(function () { toast("Copied", "Paste it anywhere."); }); } }), 387 links.map(function (l) { return h("a", { class: "btn btn-quiet", href: l[1], target: "_blank", rel: "noopener", text: l[0], onclick: count }); })))); 388 sheet.addEventListener("click", function (e) { if (e.target === sheet) sheet.remove(); }); 389 document.body.appendChild(sheet); 390 } 391 392 document.addEventListener("keydown", function (e) { 393 if (!round || round.locked || e.metaKey || e.ctrlKey || e.altKey) return; 394 var tag = (document.activeElement && document.activeElement.tagName) || ""; 395 if (tag === "INPUT" || tag === "TEXTAREA") return; 396 var n = parseInt(e.key, 10); 397 if (n >= 1 && n <= 4) { e.preventDefault(); answer(n - 1); } 398 }); 399 400 // ---------- leaderboard ---------- 401 function loadLeaderboard() { 402 var lb = $("[data-lb]"); 403 if (!lb) return; 404 var period = lb.getAttribute("data-period"), scope = lb.getAttribute("data-scope"); 405 api("/api/leaderboard?period=" + period + "&scope=" + scope).then(function (r) { 406 var list = $("[data-lb-list]", lb); 407 list.textContent = ""; 408 if (!r.rows || !r.rows.length) { 409 list.appendChild(h("li", { class: "lb-empty", text: period === "day" ? "No scores yet today. Play the round and take first place." : "No scores yet. Be the first on the board." })); 410 } else r.rows.forEach(function (row) { 411 var mine = state.player && row.handle === state.player.handle; 412 list.appendChild(h("li", { class: mine ? "is-me" : "" }, 413 h("span", { class: "lb-rank", text: row.rank }), 414 h("span", { class: "lb-who" }, h("b", { text: row.handle }), h("span", { text: "Level " + row.level + " · " + row.league.charAt(0).toUpperCase() + row.league.slice(1) + (row.streak > 1 ? " · " + row.streak + "-day streak" : "") })), 415 h("span", { class: "lb-xp" }, fmt(row.xp), h("small", { text: "XP" })))); 416 }); 417 var me = $("[data-lb-me]", lb); 418 if (r.me && r.me.rank > r.rows.length) { me.hidden = false; me.textContent = "You're #" + r.me.rank + " of " + fmt(r.players) + " with " + fmt(r.me.xp) + " XP"; } 419 else me.hidden = true; 420 }); 421 } 422 $$("[data-lb-period]").forEach(function (b) { 423 b.addEventListener("click", function () { 424 $$("[data-lb-period]").forEach(function (x) { x.setAttribute("aria-selected", x === b ? "true" : "false"); }); 425 $("[data-lb]").setAttribute("data-period", b.getAttribute("data-lb-period")); 426 loadLeaderboard(); 427 }); 428 }); 429 $$("[data-lb-scope]").forEach(function (b) { 430 b.addEventListener("click", function () { 431 $$("[data-lb-scope]").forEach(function (x) { x.setAttribute("aria-selected", x === b ? "true" : "false"); }); 432 $("[data-lb]").setAttribute("data-scope", b.getAttribute("data-lb-scope")); 433 loadLeaderboard(); 434 }); 435 }); 436 437 // ---------- challenges ---------- 438 function loadChallenges() { 439 var cards = $$("[data-challenge]"); 440 if (!cards.length) return; 441 api("/api/challenges").then(function (r) { 442 if (!r.challenges) return; 443 var byId = {};
444 r.challenges.forEach(function (c) { byId[c.id] = c; }); 445 cards.forEach(function (card) { renderChallenge(card, byId[card.getAttribute("data-challenge")]); }); 446 }); 447 } 448 function renderChallenge(card, c) { 449 if (!c) return; 450 var count = $("[data-ch-count]", card); 451 count.textContent = c.players ? fmt(c.players) + (c.players === 1 ? " player" : " players") + (c.finished ? " · " + fmt(c.finished) + " finished" : "") : "Be the first to join"; 452 var actions = $("[data-ch-actions]", card); 453 actions.textContent = ""; 454 var prog = $("[data-ch-progress]", card); 455 var m = c.mine; 456 card.classList.toggle("is-done", !!(m && m.status === "completed")); 457 if (!m) { 458 prog.hidden = true; 459 actions.appendChild(h("button", { class: "btn btn-secondary", type: "button", text: "Join", onclick: function () { act("/api/challenge/join", c, card); } })); 460 return; 461 } 462 if (m.status === "completed") { prog.hidden = true; actions.appendChild(h("span", { class: "ch-done", text: "Completed" })); return; } 463 if (c.kind === "streak") { 464 prog.hidden = false; 465 $("[data-ch-bar]", card).style.width = Math.min(100, (m.checkins / c.days) * 100) + "%"; 466 $("[data-ch-label]", card).textContent = "Day " + m.checkins + " of " + c.days; 467 actions.appendChild(m.checked_in_today 468 ? h("span", { class: "ch-count", text: "Checked in today" }) 469 : h("button", { class: "btn btn-primary", type: "button", text: "Check in", onclick: function () { act("/api/challenge/checkin", c, card); } })); 470 } else { 471 prog.hidden = true; 472 if (m.status === "submitted") actions.appendChild(h("span", { class: "ch-count", text: "Entry under review" })); 473 else if (m.status === "rejected") actions.appendChild(h("a", { class: "btn btn-secondary", href: withArena("/c/" + c.slug), text: "Resubmit" })); 474 else actions.appendChild(h("a", { class: "btn btn-primary", href: withArena("/c/" + c.slug), text: "Post proof" })); 475 } 476 } 477 function act(path, c, card) { 478 ensurePlayer().then(function (p) { 479 if (!p) return; 480 api(path, { id: c.id }).then(function (r) { 481 if (r.error) return toast("Hmm", r.error); 482 if (r.badges_new) r.badges_new.forEach(function (b) { toast("Badge unlocked: " + b.name, b.desc, b.id); }); 483 if (path.indexOf("join") > -1) toast("You're in", c.kind === "streak" ? "Check in once a day for " + c.days + " days." : "Post your proof when you're done."); 484 if (path.indexOf("checkin") > -1 && r.status === "completed") toast("Challenge complete", "+" + c.xp_complete + " XP"); 485 else if (path.indexOf("checkin") > -1 && !r.already) toast("Checked in", "Day " + r.checkins + " of " + r.days); 486 loadChallenges(); loadMe(); 487 }); 488 }); 489 } 490 491 function loadGallery() { 492 var g = $("[data-gallery]"); 493 if (!g) return; 494 api("/api/challenge/gallery?id=" + encodeURIComponent(g.getAttribute("data-challenge-id"))).then(function (r) { 495 g.textContent = ""; 496 if (!r.entries || !r.entries.length) { g.appendChild(h("p", { class: "muted", text: "No entries yet. Yours could be the first." })); return; } 497 r.entries.forEach(function (e) { 498 var foot = h("div", { class: "entry-foot" }, 499 e.link ? h("a", { href: e.link, rel: "nofollow ugc noopener", target: "_blank", text: e.link_host || "View link" }) : h("span"), 500 e.status === "completed" ? h("span", { class: "ok", text: "Completed" }) : 501 h("button", { class: "btn btn-quiet", type: "button", text: "Ⲡ" + e.votes, "aria-label": "Vote for " + e.handle + "'s entry", onclick: function (ev) { 502 var btn = ev.currentTarget; 503 ensurePlayer().then(function (p) { 504 if (!p) return; 505 api("/api/challenge/vote", { entry_id: e.entry_id }).then(function (v) { 506 if (v.error) return toast("Can't vote yet", v.error); 507 btn.textContent = "Ⲡ" + v.votes; btn.disabled = true; 508 }); 509 }); 510 } })); 511 g.appendChild(h("div", { class: "entry" }, h("div", { class: "entry-top" }, h("b", { text: e.handle }), h("span", { class: "muted", text: "Level " + e.level })), e.note ? h("p", { text: e.note }) : null, foot)); 512 }); 513 }); 514 } 515 516 // ---------- forms ---------- 517 function formData(form) { 518 var o = {}; 519 $$("input,select,textarea", form).forEach(function (el) { if (el.name) o[el.name] = el.value; }); 520 return o; 521 } 522 function note(sel, msg, kind) { 523 var n = $(sel); if (!n) return; 524 n.textContent = msg; n.classList.toggle("is-bad", kind === "bad"); n.classList.toggle("is-good", kind === "good"); 525 } 526 var savedEmail = ""; 527 $$("[data-form='email']").forEach(function (form) { 528 form.addEventListener("submit", function (e) { 529 e.preventDefault(); 530 var d = formData(form); 531 if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(d.email)) return note("[data-save-note]", "Enter a valid email address.", "bad"); 532 var btn = $("button", form); btn.disabled = true; 533 ensurePlayer().then(function (p) { 534 if (!p) { btn.disabled = false; return; } 535 api("/api/player/email", d).then(function (r) { 536 btn.disabled = false;
537 if (r.error) return note("[data-save-note]", r.error, "bad"); 538 savedEmail = d.email; 539 if (r.sent) { 540 form.hidden = true; 541 var v = $("[data-form='verify']"); if (v) { v.hidden = false; $("input", v).focus(); } 542 note("[data-save-note]", "We sent a 6-digit code to " + d.email + ". Enter it to finish.", "good"); 543 } else { 544 note("[data-save-note]", r.merged_into_existing ? "That email already has a profile. We'll add sign-in codes soon so you can load it here." : "Saved. Your progress is linked to " + d.email + ".", "good"); 545 } 546 loadMe(); 547 }); 548 }); 549 }); 550 }); 551 $$("[data-form='verify']").forEach(function (form) { 552 form.addEventListener("submit", function (e) { 553 e.preventDefault(); 554 var d = formData(form); 555 api("/api/player/verify", { email: savedEmail, code: d.code }).then(function (r) { 556 if (r.error) return note("[data-save-note]", r.error, "bad"); 557 form.hidden = true; 558 note("[data-save-note]", r.merged ? "Welcome back. Your saved profile is loaded on this device." : "Your profile is saved.", "good"); 559 loadMe().then(loadLeaderboard); 560 }); 561 }); 562 }); 563 $$("[data-form='sponsor']").forEach(function (form) { 564 form.addEventListener("submit", function (e) { 565 e.preventDefault(); 566 var d = formData(form); 567 if (!d.name || !d.message || !/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(d.email)) return note("[data-sponsor-note]", "Add your name, a work email and what you'd like to sponsor.", "bad"); 568 var btn = $("button", form); btn.disabled = true; 569 api("/api/sponsor", d).then(function (r) { 570 btn.disabled = false; 571 if (r.error) return note("[data-sponsor-note]", r.error, "bad"); 572 form.reset(); 573 note("[data-sponsor-note]", "Thanks. We'll reply within two business days.", "good"); 574 }); 575 }); 576 }); 577 $$("[data-form='proof']").forEach(function (form) { 578 form.addEventListener("submit", function (e) { 579 e.preventDefault(); 580 var d = formData(form); 581 d.id = form.getAttribute("data-challenge-id"); 582 ensurePlayer().then(function (p) { 583 if (!p) return; 584 api("/api/challenge/submit", d).then(function (r) { 585 if (r.need_email) { note("[data-proof-note]", r.error, "bad"); var s = $("#saveEmail"); if (s) s.focus(); return; } 586 if (r.error) return note("[data-proof-note]", r.error, "bad"); 587 form.reset(); 588 note("[data-proof-note]", "Entry posted. It completes when approved or when it reaches 5 votes.", "good"); 589 loadGallery(); loadChallenges(); 590 }); 591 }); 592 }); 593 }); 594 $$("[data-form='partner']").forEach(function (form) { 595 form.addEventListener("submit", function (e) { 596 e.preventDefault(); 597 var d = formData(form); 598 if (!d.name || !d.message || !d.interest || !/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(d.email)) return note("[data-partner-note]", "Add your name, email, what you're interested in and a short message.", "bad"); 599 var btn = $("button", form); btn.disabled = true; 600 api("/api/partner", d).then(function (r) { 601 btn.disabled = false; 602 if (r.error) return note("[data-partner-note]", r.error, "bad"); 603 form.reset(); 604 note("[data-partner-note]", "Thanks. Your inquiry is in, and a person will reply within two business days.", "good"); 605 }); 606 }); 607 }); 608 $$("[data-share-page]").forEach(function (b) { 609 b.addEventListener("click", function () { 610 share(document.title, { url: withArena(location.origin + location.pathname), title: "Share this page", subject: document.title }); 611 }); 612 }); 613 614 // ---------- warm-up question ---------- 615 $$("[data-warmup]").forEach(function (w) { 616 var ans = +w.getAttribute("data-answer"); 617 $$("[data-wchoice]", w).forEach(function (b) { 618 b.addEventListener("click", function () { 619 if (w.classList.contains("is-answered")) return; 620 w.classList.add("is-answered"); 621 var pick = +b.getAttribute("data-wchoice");
622 $$("[data-wchoice]", w).forEach(function (x) { 623 var i = +x.getAttribute("data-wchoice"); 624 x.disabled = true; 625 x.classList.add(i === ans ? "is-right" : i === pick ? "is-wrong" : "is-dim"); 626 }); 627 var ok = pick === ans; 628 $("[data-warmup-verdict]", w).textContent = ok ? "Correct. You're warmed up." : "Not quite. The answer is " + $("[data-wchoice='" + ans + "'] span", w).textContent + "."; 629 $("[data-warmup-verdict]", w).classList.toggle("is-good", ok); 630 $("[data-warmup-after]", w).hidden = false; 631 track("warmup"); if (ok) track("warmup_ok"); 632 }); 633 }); 634 var play = $("[data-warmup-play]", w); 635 if (play) play.addEventListener("click", playFromHero); 636 }); 637 function playFromHero() { 638 if (!roundEl) { location.href = withArena("/#play"); return; } 639 roundEl.scrollIntoView({ behavior: REDUCED ? "auto" : "smooth", block: "center" }); 640 var daily = $("[data-start='daily']", roundEl); 641 if (daily && !daily.disabled && !daily.hidden) daily.click(); 642 } 643 644 // ---------- head-to-head ---------- 645 function beatTarget() { 646 if (BOOT.beat) return BOOT.beat; 647 try { var t = JSON.parse(LS.get("ic_beat") || "null"); return t && t.day === (BOOT.today && BOOT.today.day) && t.host === BOOT.host ? t : null; } catch (e) { return null; } 648 } 649 if (BOOT.beat) { 650 var bt = BOOT.beat; 651 LS.set("ic_beat", JSON.stringify({ code: bt.code, handle: bt.handle, score: bt.score, day: bt.day, today: bt.today, host: BOOT.host })); 652 if (!LS.get("ic_ref")) LS.set("ic_ref", bt.code); 653 if (!LS.get("ic_beat_seen_" + bt.code + bt.day)) { LS.set("ic_beat_seen_" + bt.code + bt.day, "1"); track("beat_view"); } 654 } 655 $$("[data-beat-play]").forEach(function (b) { b.addEventListener("click", playFromHero); }); 656 657 $$("[data-form='handle']").forEach(function (form) { 658 form.addEventListener("submit", function (e) { 659 e.preventDefault(); 660 var d = formData(form); 661 ensurePlayer().then(function (p) { 662 if (!p) return; 663 api("/api/player/handle", d).then(function (r) { 664 if (r.error) return toast("Couldn't rename", r.error); 665 form.reset(); toast("Handle updated", d.handle); loadMe(); 666 }); 667 }); 668 }); 669 }); 670 $$("[data-copy-invite]").forEach(function (b) { 671 b.addEventListener("click", function () { 672 var t = $("[data-invite-link]").textContent; 673 if (navigator.clipboard) navigator.clipboard.writeText(t).then(function () { toast("Invite link copied", "Friends who finish a round earn you +100 XP."); }); 674 }); 675 }); 676 677 function loadHistory() { 678 var list = $("[data-history]"); 679 if (!list || !state.token) return; 680 api("/api/me/history").then(function (r) { 681 if (!r.rounds || !r.rounds.length) return; 682 list.textContent = ""; 683 r.rounds.forEach(function (x) { 684 list.appendChild(h("li", null, h("span", { text: x.domain + " · " + x.day + (x.mode === "practice" ? " · practice" : "") }), h("b", { text: x.correct + "/" + x.total }), h("b", { text: fmt(x.score) + " pts" }))); 685 }); 686 }); 687 } 688 689 // ---------- network pass: carry the profile to another arena in one click ---------- 690 document.addEventListener("click", function (e) { 691 var a = e.target.closest && e.target.closest("a[data-arena]"); 692 if (!a || !state.token || BOOT.preview || e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return; 693 e.preventDefault(); 694 var href = a.href; 695 api("/api/pass", {}).then(function (r) { 696 location.href = r.pass ? href.split("#")[0] + "#icpass=" + encodeURIComponent(r.pass) : href; 697 }); 698 }); 699 function redeemPassFromHash() { 700 var m = location.hash.match(/icpass=([^&]+)/); 701 if (!m) return Promise.resolve(); 702 history.replaceState(null, "", location.pathname + location.search); 703 return api("/api/pass/redeem", { pass: decodeURIComponent(m[1]) }).then(function (r) { 704 if (r.token) { state.token = r.token; LS.set(TOKEN_KEY, r.token); state.player = r.player; toast("Welcome, " + r.player.handle, "Your level and streak came with you."); } 705 else if (r.kept_current) toast("You're already playing here", "Save your email on both arenas to combine progress."); 706 }); 707 } 708 709 // ---------- analytics beacon (JS-executing visitors only) ---------- 710 function beacon() { 711 var key = "ic_seen_" + new Date().toISOString().slice(0, 10); 712 var first = !LS.get(key); 713 LS.set(key, "1"); 714 var payload = JSON.stringify({ u: first ? 1 : 0, p: location.pathname, r: document.referrer ? document.referrer.slice(0, 300) : "" }); 715 try { 716 fetch("/api/beacon", { method: "POST", keepalive: true, headers: { "content-type": "application/json", "x-arena-host": BOOT.host }, body: payload }); 717 } catch (e) {} 718 } 719 720 // ---------- boot ---------- 721 var ref = new URLSearchParams(location.search).get("ref"); 722 if (ref && /^[A-Za-z0-9]{6,10}$/.test(ref)) LS.set("ic_ref", ref.toUpperCase()); 723 $$("[data-start]").forEach(function (b) { b.addEventListener("click", function () { start(b.getAttribute("data-start")); }); }); 724 if (BOOT.preview) $$("a[href^='/']").forEach(function (a) { if (a.getAttribute("href").indexOf("arena=") < 0 && a.getAttribute("href").indexOf("/.well-known") < 0 && a.getAttribute("href").indexOf("/sitemap") < 0) a.setAttribute("href", withArena(a.getAttribute("href"))); }); 725 redeemPassFromHash().then(loadMe).then(function () { 726 refreshToday(); loadLeaderboard(); loadChallenges(); loadGallery(); loadHistory(); 727 if (location.hash === "#play" && roundEl) roundEl.scrollIntoView({ block: "nearest" }); 728 }); 729 beacon(); 730})();
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.