/* ========================================================================= CISCO ARENA — Screens: Join, Lobby, Quiz, PhaseTransition, Triage, Wrap ========================================================================= */ const { useState: uS, useEffect: uE, useMemo: uM, useRef: uR } = React; function JoinScreen({ theme, onJoin, me, leaderboard, connected }) { const [step, setStep] = uS("code"); // "code" or "register" const [code, setCode] = uS(connected ? (Session.getGameId() || "") : ""); const [name, setName] = uS(connected ? me.name : ""); const [email, setEmail] = uS(connected ? (Session.getEmail() || "") : ""); const [busy, setBusy] = uS(false); const [error, setError] = uS(""); const [gameName, setGameName] = uS(""); // DOM data-theme is the source of truth (set in handleCodeSubmit before step transition) const themeId = document.documentElement.getAttribute("data-theme") || "cisco"; const themeLabelMap = { cisco: "Cisco Arena", cup: "World Cup", gp: "Grand Prix Series", nfl: "Gridiron League", realm: "Realm Wars", }; const themeMoodMap = { cisco: "Test your knowledge and compete with your peers.", cup: "Matchday atmosphere — trophy-gold highlights and stadium-night depth.", gp: "Race-weekend intensity — dramatic contrast and a fast-lane visual pulse.", nfl: "Prime-time kickoff energy — bold lights and a big-game presentation feel.", realm: "A dramatic fantasy arena with ceremonial glow and house-banner atmosphere.", }; const activeThemeLabel = themeLabelMap[themeId] || "Cisco Arena"; const activeThemeMood = themeMoodMap[themeId] || themeMoodMap.cisco; // If already connected, skip to registration step uE(() => { if (connected) setStep("register"); }, [connected]); // Step 1: Validate code and fetch game config const handleCodeSubmit = async (e) => { if (e) e.preventDefault(); const trimmed = code.trim().toUpperCase(); if (!trimmed) { setError(tc("Enter a session code")); return; } setBusy(true); setError(""); try { // Resolve join code → game_id let gameId = null; let gName = ""; try { const joinRes = await fetch("/api/events/join", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code: trimmed }), }); if (joinRes.ok) { const joinData = await joinRes.json(); gameId = joinData.game_id; gName = joinData.name || ""; } } catch (_) {} // Fallback: try as direct game_id if (!gameId) { const res = await fetch("/api/games/" + encodeURIComponent(trimmed)); if (res.ok) { gameId = trimmed; const d = await res.json(); gName = d.name || ""; } } if (!gameId) { setError(tc("Session not found — check your code")); setBusy(false); return; } // Fetch game config for theme try { const gRes = await fetch("/api/games/" + encodeURIComponent(gameId)); if (gRes.ok) { const game = await gRes.json(); gName = game.name || gName; // Apply theme immediately const tm = { 'f1': 'gp', 'world-cup': 'cup', 'nfl': 'nfl', 'realm': 'realm' }; const clientTheme = tm[game.theme_id] || game.theme_id || 'cisco'; document.documentElement.setAttribute("data-theme", clientTheme); } } catch (_) {} setGameName(gName); setCode(trimmed); setStep("register"); } catch (err) { setError(tc("Connection error — try again")); } setBusy(false); }; // Step 2: Register and join const handleRegister = async (e) => { if (e) e.preventDefault(); if (!name.trim()) { setError(tc("Enter your name")); return; } if (!email.trim()) { setError(tc("Enter your email")); return; } setError(""); setBusy(true); await onJoin(name, email, "", code); setBusy(false); }; // Demo mode const handleDemo = () => { onJoin("", "", "", ""); }; // ── Step 1: Code Entry ── if (step === "code") { return (

{tc("Session access")}

{tc("Enter your event code")}

{tc("Use the code shown on the main display to continue.")}

setCode(e.target.value.toUpperCase())} placeholder={tc("ENTER CODE")} autoComplete="off" autoCapitalize="characters" spellCheck="false" inputMode="text" maxLength="20" autoFocus />

{tc("Enter the code shown on the projector screen.")}

{error &&
{error}
}
🔐 {tc("Staff login")}
); } // ── Step 2: Registration ── return (
Cisco Arena {activeThemeLabel}

{tc("Theme loaded")}

{gameName || "Cisco Arena"}

{activeThemeMood}

Session {code} {activeThemeLabel}

{tc("Player registration")}

{tc("Claim your spot on the board")}

{code}

Enter your details to join {gameName || activeThemeLabel} and sync your player identity before the first round begins.

setName(e.target.value)} placeholder={tc("e.g. Alex Morgan")} autoComplete="name" autoFocus />
setEmail(e.target.value)} placeholder={tc("name@cisco.com")} autoComplete="email" />
{error &&
{error}
}
{!connected && (
)}
); } function LobbyScreen({ theme, leaderboard, me, onStart, countdownSec, draftStatus, draftTeams, draftUndrafted, isCaptain, isMyTurn, onDraftPick, connected }) { const [secs, setSecs] = uS(42); const [draftNow, setDraftNow] = uS(Date.now()); const [animatedHistoryKey, setAnimatedHistoryKey] = uS(""); const [animatedMemberKey, setAnimatedMemberKey] = uS(""); const [showConfetti, setShowConfetti] = uS(false); const [yourTurnFxSeed, setYourTurnFxSeed] = uS(0); const prevDraftActiveRef = uR(!!(draftStatus && draftStatus.active)); const prevIsMyTurnRef = uR(false); const prevLatestHistoryKeyRef = uR(""); const historyHydratedRef = uR(false); // Demo-mode only: local 42s countdown → auto-start quiz uE(() => { if (connected) return; // connected mode: server controls transitions const t = setInterval(() => setSecs(s => (s > 0 ? s - 1 : 0)), 1000); return () => clearInterval(t); }, [connected]); const draftActive = !!(draftStatus && draftStatus.active); uE(() => { if (!draftActive) return; const t = setInterval(() => setDraftNow(Date.now()), 100); return () => clearInterval(t); }, [draftActive]); const display = countdownSec !== undefined ? countdownSec : secs; const mm = String(Math.floor(display/60)).padStart(2,"0"); const ss = String(display%60).padStart(2,"0"); // Demo-mode only: auto-advance to quiz when countdown hits 0 uE(() => { if (connected) return; // connected mode: never auto-advance if (!draftActive && display === 0) { const t = setTimeout(onStart, 600); return () => clearTimeout(t); } }, [display, draftActive, connected]); const getInitials = (name) => (name || "?").split(" ").map(n => n[0]).join("").slice(0, 2).toUpperCase(); const getTeamTheme = (teamId) => theme.teams.find(t => t.id === teamId) || {}; const getTeamName = (teamId) => getTeamTheme(teamId).name || teamId || "Unassigned"; const getTeamColor = (teamId) => getTeamTheme(teamId).color || "var(--accent)"; const getCurrentCaptainInfo = () => { if (!draftStatus || !draftStatus.active) return null; const order = draftStatus.captain_order || []; const idx = draftStatus.current_pick_index || 0; const round = draftStatus.round || 1; const n = order.length; if (n === 0) return null; const captainIdx = round % 2 === 1 ? (idx % n) : ((n - 1) - (idx % n)); const email = order[captainIdx]; for (const [teamId, teamData] of Object.entries(draftTeams || {})) { const captain = teamData.captain || (teamData.members || []).find(m => m.isCaptain); if (captain && captain.email === email) { const themeTeam = theme.teams.find(t => t.id === teamId); return { ...captain, teamId, teamName: themeTeam?.name || teamId, teamColor: themeTeam?.color || 'var(--accent)' }; } } return { email, teamId: '', teamName: '', teamColor: 'var(--accent)' }; }; const currentCaptain = getCurrentCaptainInfo(); const draftCountdown = draftActive && draftStatus?.pick_deadline ? Math.max(0, draftStatus.pick_deadline - (draftNow / 1000)) : 0; const draftTotal = draftStatus?.pick_duration || 15; const draftPct = draftTotal > 0 ? Math.max(0, Math.min(100, (draftCountdown / draftTotal) * 100)) : 0; const timerColor = draftPct > 60 ? "var(--success)" : (draftPct > 30 ? "var(--warning)" : "var(--danger)"); const pickGlow = 0.45 + (0.55 * ((Math.sin(draftNow / 280) + 1) / 2)); const history = (draftStatus?.history || []).slice(-5).reverse(); const rawHistory = draftStatus?.history || []; const latestHistoryItem = rawHistory[rawHistory.length - 1] || null; const maxManual = draftStatus?.max_manual_rounds || 2; const modeLabel = (draftStatus?.round || 1) > maxManual ? "AUTO-DRAFT" : "MANUAL DRAFT"; const isUndraftedMe = !me?.tid && (draftUndrafted || []).some(p => (p.email || p.id) === me?.id); const getDraftHistoryKey = (item) => { if (!item) return ""; return [ item.pick_id, item.player_email, item.player_id, item.player_name, item.team, item.team_id, item.team_name, item.message, ].filter(Boolean).join("::"); }; const getDraftMemberKey = (teamId, entity) => [ teamId || "", entity?.email || entity?.player_email || entity?.id || entity?.player_id || entity?.name || entity?.player_name || "", ].join("::"); const latestHistoryKey = getDraftHistoryKey(latestHistoryItem); const latestHistoryTeamId = latestHistoryItem?.team || latestHistoryItem?.team_id || latestHistoryItem?.teamId || ""; const latestHistoryMemberKey = latestHistoryItem ? getDraftMemberKey(latestHistoryTeamId, latestHistoryItem) : ""; uE(() => { if (!historyHydratedRef.current) { historyHydratedRef.current = true; prevLatestHistoryKeyRef.current = latestHistoryKey; return; } if (!latestHistoryKey || latestHistoryKey === prevLatestHistoryKeyRef.current) { prevLatestHistoryKeyRef.current = latestHistoryKey; return; } prevLatestHistoryKeyRef.current = latestHistoryKey; setAnimatedHistoryKey(latestHistoryKey); setAnimatedMemberKey(latestHistoryMemberKey); const t = setTimeout(() => { setAnimatedHistoryKey(""); setAnimatedMemberKey(""); }, 1200); return () => clearTimeout(t); }, [latestHistoryKey, latestHistoryMemberKey]); uE(() => { if (isMyTurn && !prevIsMyTurnRef.current) setYourTurnFxSeed(s => s + 1); prevIsMyTurnRef.current = isMyTurn; }, [isMyTurn]); uE(() => { const wasDraftActive = prevDraftActiveRef.current; if (wasDraftActive && !draftActive) { setShowConfetti(true); const t = setTimeout(() => setShowConfetti(false), 3000); prevDraftActiveRef.current = draftActive; return () => clearTimeout(t); } prevDraftActiveRef.current = draftActive; }, [draftActive]); // ── Sound integration (DraftAudio) ──────────────────────── const soundInitRef = uR(false); const prevSoundDraftActive = uR(false); const prevSoundHistoryKey = uR(""); const prevSoundIsMyTurn = uR(false); // Init audio context on first user interaction uE(() => { if (soundInitRef.current) return; const handler = () => { if (typeof DraftAudio !== "undefined") { DraftAudio.init(theme?.id); soundInitRef.current = true; } document.removeEventListener("click", handler); document.removeEventListener("touchstart", handler); }; document.addEventListener("click", handler, { once: true }); document.addEventListener("touchstart", handler, { once: true }); return () => { document.removeEventListener("click", handler); document.removeEventListener("touchstart", handler); }; }, []); // Set theme when it changes uE(() => { if (typeof DraftAudio !== "undefined" && theme?.id) DraftAudio.setTheme(theme.id); }, [theme?.id]); // Draft start/end sounds uE(() => { if (typeof DraftAudio === "undefined") return; if (draftActive && !prevSoundDraftActive.current) { DraftAudio.draftStart(); setTimeout(() => DraftAudio.startCrowd(), 800); } if (!draftActive && prevSoundDraftActive.current) { DraftAudio.draftComplete(); DraftAudio.stopCrowd(); } prevSoundDraftActive.current = draftActive; }, [draftActive]); // Pick sounds (new history entry) uE(() => { if (typeof DraftAudio === "undefined") return; if (latestHistoryKey && latestHistoryKey !== prevSoundHistoryKey.current && prevSoundHistoryKey.current !== "") { DraftAudio.pickConfirm(); setTimeout(() => DraftAudio.crowdCheer(), 200); } prevSoundHistoryKey.current = latestHistoryKey; }, [latestHistoryKey]); // Your turn notification sound uE(() => { if (typeof DraftAudio === "undefined") return; if (isMyTurn && !prevSoundIsMyTurn.current) { DraftAudio.yourTurn(); } prevSoundIsMyTurn.current = isMyTurn; }, [isMyTurn]); // Tick countdown (last 10s of pick timer) uE(() => { if (typeof DraftAudio === "undefined" || !draftActive) return; if (draftCountdown <= 10 && draftCountdown > 0) { DraftAudio.startTickCountdown(Math.round(draftCountdown)); } if (draftCountdown <= 0) { DraftAudio.stopTickCountdown(); if (draftCountdown === 0 && draftActive) DraftAudio.timeoutBuzzer(); } return () => DraftAudio.stopTickCountdown(); }, [draftActive, Math.round(draftCountdown)]); const confettiPieces = uM(() => ( Array.from({ length: 18 }, (_, idx) => ({ id: idx, left: 3 + (idx * 5.3), delay: (idx % 6) * 0.11, duration: 2.15 + ((idx % 5) * 0.16), width: idx % 4 === 0 ? 12 : 8, height: idx % 3 === 0 ? 24 : 18, color: ["var(--accent)", "var(--warning)", "var(--success)", "var(--danger)"][idx % 4], })) ), []); const confettiOverlay = showConfetti ? ( ) : null; const renderDraftBoard = () => (
{theme.teams.map(t => { const teamData = (draftTeams && draftTeams[t.id]) || {}; const members = teamData.members || []; const captainEmail = teamData.captain?.email || (members.find(m => m.isCaptain) || {}).email; return (
{t.name}
{members.length}
{(teamData.captain || members.find(m => m.isCaptain))?.name ? `★ ${(teamData.captain || members.find(m => m.isCaptain)).name}` : tc("Captain pending")}
{members.length ? members.map(member => (
{getInitials(member.name)}
{member.name}
{member.isCaptain ? tc("CAPTAIN") : tc("SIGNED")}
)) : (
{tc("No picks yet")}
)}
); })}
); if (draftActive) { return (
{confettiOverlay}
{isCaptain ? (isMyTurn ? tc("YOUR PICK") : `WAITING FOR ${(currentCaptain?.name || currentCaptain?.email || tc("CAPTAIN")).toUpperCase()}`) : tc("DRAFT IN PROGRESS")}
{isCaptain ? (isMyTurn ? tc("Make it count.") : `${currentCaptain?.name || tc("Captain")} is on the clock`) : `Round ${draftStatus?.round || 1} · ${modeLabel}`}
{!isCaptain && currentCaptain && (
{currentCaptain.name || currentCaptain.email || tc("Captain")} {currentCaptain.teamName ? · {currentCaptain.teamName} : null} {draftCountdown.toFixed(1)}s
)}
{isCaptain ? (isMyTurn ? `You have ${draftCountdown.toFixed(1)}s to lock your next signing.` : `${currentCaptain?.teamName || tc("Team")} is selecting now.`) : (isUndraftedMe ? tc("You're in the pool — a captain will pick you!") : `Live board${me?.tid ? ` · currently assigned to ${getTeamName(me.tid)}` : ""}`)}
{tc("Pick clock")} {draftCountdown.toFixed(1)}s
{currentCaptain?.name || currentCaptain?.email || tc("Awaiting captain")} · {currentCaptain?.teamName || tc("Draft board")}
{(draftUndrafted || []).length > 0 && (
{isCaptain && isMyTurn ? tc("YOUR PICK — SELECT A PLAYER") : `${tc("AVAILABLE PLAYERS")} · ${(draftUndrafted || []).filter(p => (p.email || p.id) !== me?.id).length}`}
{(draftUndrafted || []) /* Filter out self — captains can't pick themselves (server blocks it anyway), and showing "you" in the pickable list is confusing UX (see: clicked own card → server no-op + 1.5s later auto-pick assigns me elsewhere → my name vanishes mid-tap). */ .filter(player => (player.email || player.id) !== me?.id) .map(player => ( ))}
)} {!isCaptain && history.length > 0 && (
{tc("SIGNINGS")}
{history.map((item, idx) => (
📣 {item.message || `${item.player_name || tc("Player")} → ${item.team_name || getTeamName(item.team || item.team_id)}`}
))}
{renderDraftBoard()}
)} {(isCaptain || history.length === 0) && renderDraftBoard()}
{tc("Live draft board · Team colors hot · Pick window synced from server")}{connected ? "" : " (demo)"} {typeof DraftAudio !== "undefined" && ( )}
{!draftActive && }
); } // Pick team description based on game language const getLang = () => { try { return (typeof getLanguage === "function" ? getLanguage() : "en") || "en"; } catch(_) { return "en"; } }; const lang = getLang(); const getTeamDesc = (t) => { if (lang === "es" && t.desc_es) return t.desc_es; if (lang === "pt" && t.desc_pt) return t.desc_pt; return t.desc || t.city; }; // Available (unassigned) players const teamIds = new Set(theme.teams.map(t => t.id)); const availablePlayers = leaderboard.filter(p => !p.tid || !teamIds.has(p.tid)); return (
{confettiOverlay}
{theme.phaseNames.lobby} · {connected ? tc("Waiting for the proctor") : tc("First whistle in")}
{!connected &&
{mm}:{ss}
} {connected &&
{tc("Sit tight — the action starts soon")}
}
{theme.teams.map(t => { const players = leaderboard.filter(p => p.tid === t.id); return (
{t.emoji ?
{t.emoji}
:
{t.short}
}
{t.name}
{getTeamDesc(t)}
{players.map(p => (
{p.name.split(" ").map(n=>n[0]).join("").slice(0,2)} {p.name} {p.isCaptain && ★ CPT}
))} {Array.from({length: Math.max(0, 4 - players.length)}).map((_, i) => (
·· {tc("waiting…")}
))}
); })}
{connected && availablePlayers.length > 0 && (
🎮 {tc("Available Players")} {availablePlayers.length}
{availablePlayers.map(p => (
{p.name.split(" ").map(n=>n[0]).join("").slice(0,2)} {p.name}
))}
)}
{connected ? ( <>
{tc("Proctor is prepping the board · Ambient audio ON · Reduced-motion respected")}
) : ( <>
{tc("Proctor is prepping the board · Ambient audio ON · Reduced-motion respected")}
)}
); } function checkCorrect(question, answer) { const spec = question.spec || question; const correct = spec?.correct; const type = question?.type; // Proctor-scored types — cannot auto-grade if (['free_text', 'hotspot', 'buzzer', 'structured_form'].includes(type)) return null; // No answer key provided — skip for types that embed correctness in spec structure if ((correct === undefined || correct === null) && !['drag_match', 'cascade', 'numeric_slider'].includes(type)) return null; // Type-specific grading switch (type) { case 'single_select': case 'true_false': return answer === correct; case 'multi_select': if (!Array.isArray(answer) || !Array.isArray(correct)) return false; return answer.length === correct.length && JSON.stringify([...answer].sort()) === JSON.stringify([...correct].sort()); case 'rank_order': if (!Array.isArray(answer) || !Array.isArray(correct)) return false; return JSON.stringify(answer) === JSON.stringify(correct); // order matters! case 'cascade': if (spec.steps) return spec.steps.every(s => answer?.[s.id] === s.correct); return null; case 'drag_match': if (spec.pairs) return spec.pairs.every(p => answer?.[p.leftId] === p.rightId); return null; case 'group_sort': if (typeof correct === 'object' && !Array.isArray(correct)) { return Object.keys(correct).every(bucketId => { const expected = [...(correct[bucketId] || [])].sort(); const actual = [...(answer?.[bucketId] || [])].sort(); return expected.length === actual.length && expected.every((v, i) => v === actual[i]); }); } return null; case 'numeric_slider': if (spec.target != null) { const tol = spec.tolerance ?? (spec.max - spec.min) * 0.1; return typeof answer === 'number' && Math.abs(answer - spec.target) <= tol; } return null; default: // Fallback for types without a dedicated case (e.g. boolean/string correct keys) if (typeof correct === 'boolean' || typeof correct === 'string') return answer === correct; return null; // unknown type — don't auto-pass } } window.checkCorrect = checkCorrect; function QuizScreen({ theme, qIdx, question, totalQuestions: totalQProp, onAnswer, onNext, onSkip, selected, locked, correctKey, onSelect, gradedOutcome }) { const isNewFormat = question.type && window.RENDERERS && window.RENDERERS[question.type]; // New-format state const [value, setValue] = uS(() => isNewFormat ? window.RENDERERS[question.type].getDefaultValue(question.spec) : null ); const [localLocked, setLocalLocked] = uS(false); const [localGraded, setLocalGraded] = uS(false); // Skip double-tap: first tap arms confirm, second tap fires const [skipPending, setSkipPending] = uS(false); const skipTimerRef = uR(null); // ── Per-question countdown timer ───────────────────────────────────────── // Client-side only. Resets to ARENA_CONFIG.timerPerQuestionSec (default 30) // each time qIdx changes. Visual urgency cue only — does NOT auto-submit // (server-side enforcement would require timestamping each question; we // can add later). When it hits 0, we just freeze at 0 and tint the bar // danger-color. Stops counting once the user locks in an answer. const perQDuration = (window.ARENA_CONFIG && window.ARENA_CONFIG.timerPerQuestionSec) || 30; const [qSecsLeft, setQSecsLeft] = uS(perQDuration); uE(() => { setQSecsLeft(perQDuration); }, [qIdx]); uE(() => { if (locked || localLocked) return; // freeze countdown after submit if (qSecsLeft <= 0) return; const t = setTimeout(() => setQSecsLeft(s => Math.max(0, s - 1)), 1000); return () => clearTimeout(t); }, [qSecsLeft, locked, localLocked]); const qPct = Math.max(0, Math.min(100, (qSecsLeft / perQDuration) * 100)); const qTimerColor = qSecsLeft <= 5 ? 'var(--danger, #E10600)' : qSecsLeft <= 10 ? 'var(--warning, #F0A000)' : 'var(--accent, #00BCEB)'; // ── Phase-level timer expiry guard ────────────────────────────────────────── // Polls /api/g/{gid}/timer/status every 5s. When quiz phase timer expires, // disables submit/next/skip so players don't keep clicking (server still 403s). const [phaseExpired, setPhaseExpired] = uS(false); uE(() => { var gid = typeof Session !== 'undefined' ? Session.getGameId() : null; if (!gid) return; function checkPhaseTimer() { fetch('/api/g/' + gid + '/timer/status') .then(function(r) { return r.json(); }) .then(function(data) { var qt = data && data.quiz; setPhaseExpired(!!(qt && qt.expired && !qt.paused)); }) .catch(function() {}); } checkPhaseTimer(); var tid = setInterval(checkPhaseTimer, 5000); return function() { clearInterval(tid); }; }, []); // Reset new-format state when question changes uE(() => { if (isNewFormat) { setValue(window.RENDERERS[question.type].getDefaultValue(question.spec)); setLocalLocked(false); setLocalGraded(false); setSkipPending(false); if (skipTimerRef.current) clearTimeout(skipTimerRef.current); } }, [qIdx]); const totalQuestions = totalQProp || window.QUESTIONS?.length || 5; if (isNewFormat) { const renderer = window.RENDERERS[question.type]; const Comp = renderer.Component; // Prefer authoritative server outcome (gradedOutcome) over local check. // Fallback to null (neutral) rather than false so that an unresolved state // never accidentally shows the "wrong" (err) feedback class. const isPartial = gradedOutcome === 'partial'; const isSkipped = gradedOutcome === 'skipped'; const localCheck = localGraded ? checkCorrect(question, value) : null; const isCorrect = gradedOutcome === 'correct' ? true : gradedOutcome === 'wrong' ? false : isPartial ? null // partial handled separately in feedback : isSkipped ? null // skipped handled separately in feedback : localCheck; const canLockIn = renderer.isValid(value, question.spec) && !localLocked; // Strip correct/explanation from spec passed to renderer — anti-cheat (dev tools) const safeSpec = Object.assign({}, question.spec); delete safeSpec.correct; delete safeSpec.explanation; return (
Q{String(qIdx+1).padStart(2,"0")} / {String(totalQuestions).padStart(2,"0")} · {question.tag}
{tc("LIVE")}
{/* Per-question countdown — visible always, sticky on mobile so it never scrolls out of view. Color shifts to warn → danger as time runs low. Pure visual timer (no auto-submit). */}
{tc('TIME')}
{qSecsLeft}s
{/* Phase-timer expired banner */} {phaseExpired && (
{tc('⏱ Tiempo terminado. Esperando al proctor para más tiempo o avanzar de fase.')}
)}

{question.prompt}

!localLocked && setValue(v)} locked={localLocked} graded={localGraded} />
{!localLocked && (
{/* Universal skip — double-tap to confirm; first tap arms, second fires */} {onSkip && !phaseExpired && ( )}
)} {localLocked && (() => { const isUngraded = isCorrect === null && !isPartial && !isSkipped; const fbClass = isSkipped ? "" : isPartial ? "ok" : isUngraded ? "ok" : (isCorrect ? "ok" : "err"); const fbLabel = isSkipped ? tc("SKIPPED") : isPartial ? tc("PARTIAL CREDIT") : isUngraded ? tc("SUBMITTED ✓") : (isCorrect ? tc("CORRECT") : tc("NOT QUITE")); const fbPts = isSkipped ? `+0 · ${tc("streak broken")}` : isPartial ? tc("partial match · some pts awarded") : isUngraded ? `+${question.points || 50} · ${tc("participation")}` : (isCorrect ? `+${question.points || 120} · STREAK++` : `+0 · ${tc("streak broken")}`); return (
{fbLabel}
{fbPts}
{!isSkipped && question.spec && question.spec.explanation &&
{question.spec.explanation}
} {/* Skipped questions auto-advance; all others show explicit Next button */} {!isSkipped && ( )}
); })()}
); } // Legacy format fallback return (
Q{String(qIdx+1).padStart(2,"0")} / {String(totalQuestions).padStart(2,"0")} · {question.tag}
{tc("LIVE")}
{/* Phase-timer expired banner (legacy) */} {phaseExpired && (
{tc('⏱ Tiempo terminado. Esperando al proctor para más tiempo o avanzar de fase.')}
)}

{question.prompt}

{question.options.map(o => { const isSel = selected === o.k; const isCorrect = locked && o.k === correctKey; // Only mark wrong when we have a known key; undefined correctKey must not penalise the chip. const isWrong = locked && isSel && correctKey != null && o.k !== correctKey; return ( ); })} {locked && (() => { // Determine outcome: prefer authoritative gradedOutcome, then correctKey comparison. // If neither is available (correctKey undefined, no gradedOutcome), show neutral "submitted". const legacyCorrect = gradedOutcome === 'correct' || (correctKey != null && selected === correctKey); const legacyUnknown = correctKey == null && gradedOutcome == null; const fbClass = legacyUnknown ? "ok" : (legacyCorrect ? "ok" : "err"); const fbLabel = legacyUnknown ? tc("SUBMITTED ✓") : (legacyCorrect ? tc("CORRECT") : tc("NOT QUITE")); const fbPts = legacyUnknown ? `+${question.points || 50} · ${tc("participation")}` : (legacyCorrect ? `+120 · STREAK++` : `+0 · ${tc("streak broken")}`); return (
{fbLabel}
{fbPts}
{question.explanation}
); })()} {!locked && selected && !phaseExpired && (
)}
); } // --------------------------------------------------------------------------- // Power-up card component // --------------------------------------------------------------------------- function PowerUpCard({ icon, title, subtitle, cost, status, disabled, active, onClick }) { return ( ); } // --------------------------------------------------------------------------- // Skip confirmation modal // --------------------------------------------------------------------------- function SkipConfirmModal({ open, onCancel, onConfirm, penaltyPct, copy }) { if (!open) return null; return (
{tc("⏭️ Skip confirmation")}

{tc("Skip this ticket?")}

{copy}

{tc("Penalty")} {tc("-" + penaltyPct + "% of ticket value")}
); } // --------------------------------------------------------------------------- // Triage Screen — with power-ups // --------------------------------------------------------------------------- function TriageScreen({ theme, ticket, ticketIdx, totalTickets, gameId, onSubmit, grading, graded, gradeMsg, onFinish, onNextTicket, onShuffle }) { const [ans, setAns] = uS(""); const [usedPowerUps, setUsedPowerUps] = uS({ hint: false, skip: false, shuffle: false, double: false }); const [revealedPanels, setRevealedPanels] = uS({ hint: false }); const [doubleArmed, setDoubleArmed] = uS(false); const [showSkipConfirm, setShowSkipConfirm] = uS(false); const hintPenalty = 20; const skipPenalty = 50; const shufflePenalty = 10; // Reset power-up state when ticket changes uE(() => { setAns(""); setUsedPowerUps({ hint: false, skip: false, shuffle: false, double: false }); setRevealedPanels({ hint: false }); setDoubleArmed(false); setShowSkipConfirm(false); }, [ticket && ticket.id]); const locked = !!graded || usedPowerUps.skip; const canSubmit = ans.trim().length >= 20 && !locked; const markUsed = (key) => setUsedPowerUps(prev => ({ ...prev, [key]: true })); const handleHint = () => { if (usedPowerUps.hint || locked) return; markUsed("hint"); setRevealedPanels(prev => ({ ...prev, hint: true })); }; const handleShuffle = () => { if (usedPowerUps.shuffle || locked) return; markUsed("shuffle"); if (onShuffle) onShuffle(); }; const handleDoubleToggle = () => { if (usedPowerUps.double || locked) return; setDoubleArmed(prev => !prev); }; const handleSkipConfirm = () => { markUsed("skip"); setShowSkipConfirm(false); onSubmit(null, { skipUsed: true, penaltyPct: skipPenalty }); }; const handleSubmitAnswer = () => { if (!canSubmit) return; if (doubleArmed) markUsed("double"); onSubmit(ans.trim(), { hintUsed: usedPowerUps.hint, shuffleUsed: usedPowerUps.shuffle, double: !!doubleArmed, skipUsed: false, hintPenalty, shufflePenalty, }); }; // Guard: if no ticket loaded yet, or the assigned ticket is missing the // expected shape (no `log` array), render a loading state instead of // throwing on `ticket.log.map(...)` further down — that throw blanks the // screen ("black screen") when admin advances all players to triage before // tickets have been fetched, or when a server-loaded ticket lacks fields. if (!ticket || !Array.isArray(ticket.log)) { return (

{tc('Loading ticket…')}

); } return (
{tc('TICKET', 'ui')} · {ticket.id} {totalTickets > 1 && ( ({ticketIdx + 1} / {totalTickets}) )}

{tc(ticket.title)}

{tc(ticket.customer)}
{tc(ticket.priority)}

{tc(ticket.desc)}

{/* The actual prompt being asked of the player (audienceFraming). */} {ticket.question && (
{tc('Your task')}
{tc(ticket.question)}
)} {/* Symptoms checklist (ISE tickets — separate from raw syslog). */} {Array.isArray(ticket.symptomsList) && ticket.symptomsList.length > 0 && (
{tc('Reported symptoms')}
    {ticket.symptomsList.map((s, i) =>
  • {tc(s)}
  • )}
)} {/* Licensing angle (collapsible — present for ISE tickets). */} {ticket.licensingAngle && (
{tc('Licensing angle')}
{tc(ticket.licensingAngle)}
)}
{tc("Syslog · core-sw-west-01")} tail -f
{ticket.log.map((l,i) => (
{l.includes("STP-2") ? {l} : l.includes("DHCPD-6") ? {l} : l.includes("registered to") ? {l} : l}
))}
{/* POWER-UPS DISABLED for live event 2026-04-25 — re-enable by removing this guard. Server-side validation pending (Oracle review P1). */} {false && (
{tc("Power-ups")}
{tc("Choose your advantage")}
{tc("Each power-up: one use per ticket · combine freely")}
setShowSkipConfirm(true)} />
{/* Telemetry chips */}
{usedPowerUps.hint ? {tc("💡 Hint used · -" + hintPenalty + "%")} : null} {usedPowerUps.shuffle ? {tc("🔀 Shuffle used · -" + shufflePenalty + "%")} : null} {doubleArmed ? {tc("⚡ Double armed · high risk")} : null}
)} {/* Hint reveal */} {revealedPanels.hint && (
{tc("💡 Pit wall clue")} {tc("-" + hintPenalty + "% score")}
{tc((ticket.hint && typeof ticket.hint === 'object' ? ticket.hint.text : ticket.hint) || "Check the strongest signal first, then prove impact before remediation.")}
)} {/* Answer area */}
{tc("Your answer · diagnosis + next steps")} {ans.length} / 800