/* =========================================================================
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 (
Cisco
Arena
{tc("Live player check-in")}
{tc("Enter the")} Arena
{tc("Join your live event in seconds. Enter the session code from the host screen to unlock the themed experience.")}
{tc("Session access")}
{tc("Enter your event code")}
{tc("Use the code shown on the main display to continue.")}
);
}
// ── 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.
{!connected && (
{ setStep("code"); setError(""); }}>
{tc("← Change session code")}
)}
);
}
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 ? (
{confettiPieces.map(piece => (
))}
) : 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 && isMyTurn && onDraftPick && onDraftPick(player.email || player.id)}
style={{
textAlign: "left",
padding: 14,
borderRadius: 18,
border: isCaptain && isMyTurn ? "1px solid color-mix(in oklch, var(--accent) 42%, var(--line))" : "1px solid var(--line)",
background: "linear-gradient(180deg, color-mix(in oklch, var(--surface-raised) 88%, white 12%), color-mix(in oklch, var(--surface) 92%, black 8%))",
color: "var(--ink)",
cursor: isCaptain && isMyTurn ? "pointer" : "default",
boxShadow: "0 12px 28px rgba(0,0,0,.18)",
opacity: isCaptain && isMyTurn ? 1 : 0.65,
}}
>
{getInitials(player.name)}
{player.name}
{(player.score || 0).toLocaleString()} pts
))}
)}
{!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" && (
{ DraftAudio.toggleMute(); setDraftNow(Date.now()); }}
style={{background: "none", border: "1px solid var(--line)", borderRadius: 8, padding: "4px 10px", cursor: "pointer", color: "var(--ink-muted)", fontSize: 12}}>
{DraftAudio.isMuted() ? "🔇 " + tc("Unmute") : "🔊 " + tc("Mute")}
)}
{!draftActive &&
{tc("Skip countdown →")} }
{ window.location.href = '/v2/?reset'; }}>
{tc("Wrong session? Change code")}
);
}
// 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")}
{ window.location.href = '/v2/?reset'; }}>
{tc("Wrong session? Change code")}
>
) : (
<>
{tc("Proctor is prepping the board · Ambient audio ON · Reduced-motion respected")}
{tc("Skip countdown →")}
>
)}
);
}
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 && (
{
if (!skipPending) {
setSkipPending(true);
// Auto-dismiss confirm state after 3 s if not confirmed
if (skipTimerRef.current) clearTimeout(skipTimerRef.current);
skipTimerRef.current = setTimeout(() => setSkipPending(false), 3000);
} else {
if (skipTimerRef.current) clearTimeout(skipTimerRef.current);
setLocalLocked(true);
onSkip();
}
}}
>
{skipPending ? tc("Tap again to confirm") : tc("Skip →")}
)}
{
setLocalLocked(true);
setLocalGraded(true);
onAnswer(value);
}} disabled={!canLockIn || phaseExpired} style={{opacity: (canLockIn && !phaseExpired) ? 1 : 0.4}}>
{canLockIn && !phaseExpired ? tc('Submit answer') : phaseExpired ? tc('⏱ Tiempo terminado') : tc('Type your answer…')} ↵
)}
{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 (
{!isSkipped && question.spec && question.spec.explanation &&
{question.spec.explanation}
}
{/* Skipped questions auto-advance; all others show explicit Next button */}
{!isSkipped && (
{qIdx < totalQuestions - 1 ? tc("Next question →") : tc("Finish phase →")}
)}
);
})()}
);
}
// 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 && !phaseExpired && onSelect(o.k)}
role="button"
aria-pressed={isSel}
>
{o.k}
{o.text}
{isCorrect ? "+120" : isWrong ? "×" : locked ? "" : "↵"}
);
})}
{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 (
{question.explanation}
{qIdx < totalQuestions - 1 ? tc("Next question →") : tc("Finish phase →")}
);
})()}
{!locked && selected && !phaseExpired && (
onAnswer(selected)}>
{tc("Lock in answer")} ↵
)}
);
}
// ---------------------------------------------------------------------------
// Power-up card component
// ---------------------------------------------------------------------------
function PowerUpCard({ icon, title, subtitle, cost, status, disabled, active, onClick }) {
return (
{icon}
{cost}
{title}
{status ? {status} : null}
{subtitle}
);
}
// ---------------------------------------------------------------------------
// 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")}
{tc("Stay on ticket")}
{tc("Confirm skip")}
);
}
// ---------------------------------------------------------------------------
// 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 (
);
}
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
setShowSkipConfirm(false)}
onConfirm={handleSkipConfirm}
penaltyPct={skipPenalty}
copy={tc((ticket.skip && typeof ticket.skip === 'object' ? (ticket.skip.label || ticket.skip.text) : ticket.skip) || "Skip this ticket and take the penalty. Use when preserving time matters more than forcing an answer.")}
/>
);
}
// ---------------------------------------------------------------------------
// Phase Transition / Scoreboard Screen
// Shown between quiz → triage to celebrate scores and build anticipation
// ---------------------------------------------------------------------------
function getPhaseTransitionInitials(name) {
return (name || "")
.split(" ")
.filter(Boolean)
.map(part => part[0])
.join("")
.slice(0, 2)
.toUpperCase() || "CA";
}
function PhaseTransitionScreen({ theme, leaderboard, me, onContinue, gameId, targetSubPhase = "triage", fromPhase = "quiz" }) {
const advanceRef = uR(false);
const [isLive, setIsLive] = uS(false);
const nextPhaseName = (() => {
if (targetSubPhase === "triage") return (theme && theme.phaseNames && theme.phaseNames.triage) || tc("Triage");
if (targetSubPhase === "gameplan") return tc("Game Plan");
if (targetSubPhase === "zonamixta") return tc("Zona Mixta");
return tc(targetSubPhase);
})();
const fromPhaseName = (() => {
if (fromPhase === "quiz") return (theme && theme.phaseNames && theme.phaseNames.quiz) || tc("Quiz");
if (fromPhase === "triage") return (theme && theme.phaseNames && theme.phaseNames.triage) || tc("Triage");
if (fromPhase === "gameplan") return tc("Game Plan");
return (theme && theme.phaseNames && theme.phaseNames[fromPhase]) || tc(fromPhase);
})();
const copy = uM(() => {
if (theme && theme.id === "gp") {
return {
eyebrow: tc("🏁 Race standings"),
title: tc("Qualifying complete"),
deck: tc("The grid is set. One sharp pit stop can still flip the podium."),
standings: tc("Constructor standings"),
playerCard: tc("Driver briefing"),
nextCard: tc("Next up"),
nextCopy: tc("Pit crew ready. Diagnose fast, stay precise, and protect every point."),
button: tc("Continue to pit stop"),
};
}
if (theme && theme.id === "cup") {
return {
eyebrow: tc("🏟️ Match update"),
title: tc("Warmup complete"),
deck: tc("The scoreline is alive. One clutch extra-time performance changes everything."),
standings: tc("Team standings"),
playerCard: tc("Player briefing"),
nextCard: tc("Next up"),
nextCopy: tc("The pressure rises now. Read the play, solve the issue, and steal the momentum."),
button: tc("Continue to extra time"),
};
}
if (theme && theme.id === "nfl") {
return {
eyebrow: tc("🏈 Drive summary"),
title: tc("Film study complete"),
deck: tc("The field is tight. One big audible can decide the game."),
standings: tc("Team standings"),
playerCard: tc("Player briefing"),
nextCard: tc("Next up"),
nextCopy: tc("New possession. Read the scenario, call the play, and execute cleanly."),
button: tc("Continue to audible"),
};
}
if (theme && theme.id === "realm") {
return {
eyebrow: tc("⚔️ Realm standings"),
title: tc("The trial is complete"),
deck: tc("The houses are close. One decisive siege can redraw the kingdom."),
standings: tc("House standings"),
playerCard: tc("Champion briefing"),
nextCard: tc("Next up"),
nextCopy: tc("Steel your nerves. Read the signals, break the problem, and claim the advantage."),
button: tc("Continue to the siege"),
};
}
return {
eyebrow: tc("📊 Standings update"),
title: tc("Phase complete"),
deck: tc("The board is tightening. One strong final challenge can change the order."),
standings: tc("Team standings"),
playerCard: tc("Player briefing"),
nextCard: tc("Next up"),
nextCopy: tc("The next challenge starts now. Stay sharp, stay fast, and protect your lead."),
button: tc("Continue"),
};
}, [theme]);
const players = uM(() => {
return (leaderboard || [])
.map(p => ({
id: p.id || p.email || p.name,
tid: p.tid || p.team || "",
name: p.name || tc("Player"),
score: Number(p.score) || 0,
}))
.sort((a, b) => b.score - a.score);
}, [leaderboard]);
const teams = uM(() => {
const themeTeams = (theme && theme.teams) || [];
const mapped = themeTeams.map(teamDef => {
const members = players.filter(p => p.tid === teamDef.id);
const total = members.reduce((sum, p) => sum + (p.score || 0), 0);
return {
id: teamDef.id,
name: teamDef.name,
short: teamDef.short,
emoji: teamDef.emoji,
color: teamDef.color || "var(--accent)",
members,
memberCount: members.length,
total,
};
}).sort((a, b) => b.total - a.total);
const leaderTotal = mapped[0] ? mapped[0].total : 0;
return mapped.map((teamItem, idx) => ({
...teamItem,
rank: idx + 1,
barWidth: leaderTotal > 0 ? Math.max(10, Math.round((teamItem.total / leaderTotal) * 100)) : 10,
}));
}, [theme, players]);
const currentPlayer = uM(() => {
const found = players.find(p =>
p.id === me.id ||
p.id === me.email ||
p.name === me.name
);
return found || {
id: me.id,
tid: me.tid,
name: me.name || tc("Player"),
score: 0,
};
}, [players, me]);
const currentTeam = uM(() => {
return teams.find(t => t.id === currentPlayer.tid) || null;
}, [teams, currentPlayer]);
const playerRank = uM(() => {
const idx = players.findIndex(p => p.id === currentPlayer.id);
return idx >= 0 ? idx + 1 : players.length + 1;
}, [players, currentPlayer]);
const teamRank = uM(() => {
const idx = teams.findIndex(t => t.id === currentPlayer.tid);
return idx >= 0 ? idx + 1 : null;
}, [teams, currentPlayer]);
const leadPlayerScore = players[0] ? players[0].score : 0;
const playerGap = Math.max(0, leadPlayerScore - (currentPlayer.score || 0));
const topTeam = teams[0];
const handleAdvance = () => {
if (advanceRef.current) return;
advanceRef.current = true;
if (onContinue) onContinue();
};
uE(() => {
advanceRef.current = false;
setIsLive(false);
const enterId = requestAnimationFrame(() => setIsLive(true));
let pollId = null;
if (gameId) {
pollId = window.setInterval(() => {
fetch(`/api/g/${gameId}/sub-phase`)
.then(r => r.ok ? r.json() : null)
.then(data => {
if (data && data.sub_phase === targetSubPhase) {
handleAdvance();
}
})
.catch(() => {});
}, 3000);
}
return () => {
cancelAnimationFrame(enterId);
if (pollId !== null) clearInterval(pollId);
};
}, [gameId]);
return (
{copy.standings}
{topTeam ? `${topTeam.name} ${tc("lead the board")}` : tc("Standings loading")}
{teams.length} {tc("teams")}
{teams.map((teamItem, idx) => (
{tc("P")}{teamItem.rank}
{teamItem.emoji || teamItem.short}
{teamItem.name}
{teamItem.memberCount} {tc("players")}
{currentPlayer.tid === teamItem.id ? ` · ${tc("your team")}` : ""}
{teamItem.total.toLocaleString()}
))}
{copy.playerCard}
{getPhaseTransitionInitials(currentPlayer.name)}
{currentPlayer.name}
{currentTeam ? currentTeam.name : tc("No team assigned")}
{tc("Individual rank")}
{tc("P")}{playerRank}
{tc("Points")}
{(currentPlayer.score || 0).toLocaleString()}
{tc("Team rank")}
{teamRank ? `${tc("P")}${teamRank}` : "—"}
{tc("Gap to lead")}
{playerGap > 0 ? `-${playerGap.toLocaleString()}` : tc("Leader")}
{copy.nextCard}
{nextPhaseName}
{copy.nextCopy}
);
}
function WrapScreen({ theme, leaderboard, me }) {
const sorted = [...leaderboard].sort((a,b)=>b.score-a.score);
const teamAvgById = leaderboard.reduce((acc, p) => {
const tid = p.tid || p.team;
if (!tid) return acc;
if (!acc[tid]) acc[tid] = { total: 0, count: 0 };
acc[tid].total += Number(p.score || 0);
acc[tid].count += 1;
return acc;
}, {});
const teamAvgLabel = (tid) => {
const bucket = teamAvgById[tid];
return bucket && bucket.count ? Math.round(bucket.total / bucket.count).toLocaleString() : "—";
};
// Certificate + FeedbackGate state
const [feedbackDone, setFeedbackDone] = uS(false);
const certRef = uR(null);
if (!sorted || !sorted.length) {
return {tc("Waiting for final results…")}
;
}
// Computed values for certificate (derived after guard so sorted is non-empty)
const gameId = Session.getGameId() || 'arena';
const myEntry = sorted.find(p => p.email === me.id || p.name === me.name);
const myRank = myEntry ? sorted.indexOf(myEntry) + 1 : null;
const myTeam = theme.teams.find(t => t.id === me.tid);
const isWinner = myRank === 1;
const playerEmail = me.id || '';
const top3Teams = theme.teams
.map(t => ({
...t,
total: leaderboard.filter(p => p.tid === t.id).reduce((a,b)=>a+b.score, 0),
}))
.sort((a,b)=>b.total - a.total);
const topTeam = top3Teams[0];
const mvp = sorted[0];
const today = new Date().toLocaleDateString("en-US", {weekday:"long", year:"numeric", month:"long", day:"numeric"});
return (
{tc("THE ARENA DAILY")}
{today.toUpperCase()} · {tc("FINAL · VOL. 04 / ISSUE 17")}
{theme.phaseNames.wrap.toUpperCase()} · {theme.label.toUpperCase()}
{topTeam.name} {tc("take the crown")}
{theme.id === "cup" && tc(" in a stunning full-time finish.")}
{theme.id === "gp" && tc(" from the front of the grid.")}
{theme.id === "nfl" && tc(" with a fourth-quarter comeback.")}
{theme.id === "realm" && tc(" and the ravens sing their name.")}
{theme.id === "cisco" && tc(" after five clinical rounds.")}
A session of sharp answers, a steady streak, and one decisive triage under the wire. The team banked {topTeam.total.toLocaleString()} total points — a margin of {topTeam.total - (top3Teams[1]?.total||0)} over {top3Teams[1]?.name||"the runners-up"}.
{tc("Final standings")}
{top3Teams.slice(0,4).map((t, i) => (
{i===0?tc("1st"):i===1?tc("2nd"):i===2?tc("3rd"):tc("4th")}
{t.short}
{t.name}
{t.total.toLocaleString()}
))}
{tc("Match MVP")}
{mvp.name}
{sorted[0].score.toLocaleString()} {tc("points over 5 rounds. 4-question streak during the")} {theme.phaseNames.quiz.toLowerCase()} {tc("phase. First on triage; cleanest diagnosis of the night.")}
{tc("Biggest comeback")}
+{Math.floor((sorted[3]?.score ?? sorted[0].score) * 0.6)} in Round 4
{(sorted[3] || sorted[sorted.length-1]).name} {tc("opened Round 1 in 14th, closed Round 4 in the top five. Two clutch power-ups and a quiet textbook answer on the VoIP ticket.")}
{theme.id==="gp"?tc("Fastest lap"):theme.id==="cup"?tc("Golden boot"):theme.id==="nfl"?tc("Play of the game"):theme.id==="realm"?tc("The oracle"):tc("Best answer")}
{(sorted[2] || sorted[sorted.length-1]).name}
{tc("Fastest lock-in on Q02 (BPDU Guard) at 4.2 seconds. Cleanest read of the round.")}
{tc("Individual table")}
{sorted.map((p, i) => {
const tid = p.tid || p.team;
return (
#{i+1}
{p.name}
{(p.score || 0).toLocaleString()}
Team avg: {teamAvgLabel(tid)}
);
})}
{tc("NEXT SESSION · TUE 22:00 UTC")}
{tc("Play again")}
{/* ── Certificate + Feedback Gate ─────────────────────────────── */}
{!feedbackDone ? (
) : (
)}
);
}
// ---------------------------------------------------------------------------
// Game Plan Screen — Customer brief review + BOM design
// ---------------------------------------------------------------------------
function GamePlanScreen({ theme, gameModule, me, gameId, onFinish }) {
const [gpStatus, setGpStatus] = uS(null);
const [briefIdx, setBriefIdx] = uS(0);
const [bomUpload, setBomUpload] = uS(null); // { url, filename, size, uploaded_at }
const [archUpload, setArchUpload] = uS(null);
const [bomUploading, setBomUploading] = uS(false);
const [archUploading, setArchUploading] = uS(false);
const [uploadError, setUploadError] = uS("");
const [isLive, setIsLive] = uS(false);
const [previewIdx, setPreviewIdx] = uS(null);
const [selectingIdx, setSelectingIdx] = uS(null);
const [pickerToasts, setPickerToasts] = uS([]);
const [pickCelebration, setPickCelebration] = uS(null);
const bomFileRef = uR(null);
const archFileRef = uR(null);
const fileRef = bomFileRef; // backward-compat for any existing references
const prevSelectionsRef = uR(null);
const celebrationTimerRef = uR(null);
const briefs = uM(() => {
if (!gameModule || !gameModule.data || !gameModule.data.briefs) return [];
const all = gameModule.data.briefs;
// Cap by organizer's gameplan_count if set (>0). 0/undefined = use all.
const cap = (window.__GAME_CONFIG__ && window.__GAME_CONFIG__.gameplan_count) || 0;
return cap > 0 ? all.slice(0, cap) : all;
}, [gameModule]);
const bomRubric = uM(() => {
if (gameModule && gameModule.bomRubric) return gameModule.bomRubric;
return [];
}, [gameModule]);
const copy = uM(() => {
if (theme && theme.id === "gp") {
return {
eyebrow: tc("🏁 Pit wall brief"),
title: tc("Build the race-winning deployment"),
deck: tc("Read the telemetry, respect the constraints, and submit a BOM that can survive race day pressure."),
statusLabel: tc("Garage status"),
statusReady: tc("Your team is locked on this client brief."),
statusWaiting: tc("Waiting for team allocations to settle across the grid."),
contextTitle: tc("Race context"),
risksTitle: tc("Pressure points"),
constraintsTitle: tc("Pit lane limits"),
requirementTitle: tc("Decisive requirement"),
licensingTitle: tc("Licensing telemetry"),
rubricTitle: tc("Scrutineering rubric"),
uploadTitle: tc("Send the final setup sheet"),
uploadDeck: tc("Upload the BOM once your team signs off on the architecture, licensing, and resilience plan."),
};
}
if (theme && theme.id === "cup") {
return {
eyebrow: tc("🏟️ Tactical board"),
title: tc("Shape the match-winning solution"),
deck: tc("Study the opponent, protect the weak zones, and deliver a BOM ready for ninety relentless minutes."),
statusLabel: tc("Locker room status"),
statusReady: tc("Your squad is preparing this customer play."),
statusWaiting: tc("Waiting for the remaining teams to lock their tactics."),
contextTitle: tc("Match context"),
risksTitle: tc("Pressure points"),
constraintsTitle: tc("Tournament rules"),
requirementTitle: tc("Winning condition"),
licensingTitle: tc("Licensing board"),
rubricTitle: tc("Scouting rubric"),
uploadTitle: tc("Hand in the match plan"),
uploadDeck: tc("Upload the BOM when the squad agrees on the formation, substitutions, and defensive cover."),
};
}
if (theme && theme.id === "nfl") {
return {
eyebrow: tc("🏈 Playbook package"),
title: tc("Call the right play under pressure"),
deck: tc("Read the field, cover the gaps, and hand over a BOM that can execute for four hard quarters."),
statusLabel: tc("Sideline status"),
statusReady: tc("Your team owns this drive."),
statusWaiting: tc("Waiting for all teams to finish the draft board."),
contextTitle: tc("Drive context"),
risksTitle: tc("Coverage gaps"),
constraintsTitle: tc("Clock management"),
requirementTitle: tc("Must-convert play"),
licensingTitle: tc("Licensing breakdown"),
rubricTitle: tc("Film room rubric"),
uploadTitle: tc("Deliver the play sheet"),
uploadDeck: tc("Upload the BOM once your offense, defense, and failover package are aligned."),
};
}
if (theme && theme.id === "realm") {
return {
eyebrow: tc("⚔️ War council"),
title: tc("Assemble the kingdom's defense"),
deck: tc("Read the realm, fortify the borders, and submit a BOM worthy of the throne room."),
statusLabel: tc("Council status"),
statusReady: tc("Your house is sworn to this brief."),
statusWaiting: tc("Waiting for the other houses to choose their battles."),
contextTitle: tc("Realm context"),
risksTitle: tc("Threats on the horizon"),
constraintsTitle: tc("Ancient constraints"),
requirementTitle: tc("Royal decree"),
licensingTitle: tc("Licensing codex"),
rubricTitle: tc("Council rubric"),
uploadTitle: tc("Seal the battle plan"),
uploadDeck: tc("Upload the BOM once your champions agree on the stronghold, licenses, and contingency plan."),
};
}
return {
eyebrow: tc("📋 Game plan"),
title: tc("Design the winning customer plan"),
deck: tc("Absorb the brief, respect the constraints, and submit a BOM that balances business fit with technical precision."),
statusLabel: tc("Brief status"),
statusReady: tc("Your team is assigned to this customer brief."),
statusWaiting: tc("Waiting for all team assignments to finish."),
contextTitle: tc("Client context"),
risksTitle: tc("Pain points"),
constraintsTitle: tc("Constraints"),
requirementTitle: tc("Special requirement"),
licensingTitle: tc("Licensing guide"),
rubricTitle: tc("BOM scoring rubric"),
uploadTitle: tc("Submit the BOM"),
uploadDeck: tc("Upload the BOM after your team validates the architecture, license tier, and implementation fit."),
};
}, [theme]);
const bomPoints = uM(() => {
if (window.ScoringEngine && window.ScoringEngine.getBomPoints) {
return window.ScoringEngine.getBomPoints((me && me.audienceMode) || "internal");
}
return 500;
}, [me]);
const addPickerToast = (head, msg, kind = "") => {
const id = Date.now() + Math.random();
setPickerToasts(prev => [...prev.slice(-3), { id, head, msg, kind }]);
setTimeout(() => {
setPickerToasts(prev => prev.filter(t => t.id !== id));
}, 3500);
};
const teamMetaById = uM(() => {
const map = {};
((theme && theme.teams) || []).forEach(team => {
map[team.id] = team;
});
return map;
}, [theme]);
const isCaptain = uM(() => {
if (me && me.isCaptain === true) return true;
const role = String((me && me.role) || "").toLowerCase();
return role === "captain";
}, [me]);
const launchPickCelebration = (selection) => {
if (!selection) return;
const pickedTeam = teamMetaById[selection.team_id] || null;
const pickedBrief = briefs[selection.scenario_index] || null;
if (!pickedTeam || !pickedBrief) return;
setPickCelebration({
key: `${selection.team_id}-${selection.scenario_index}-${selection.timestamp || Date.now()}`,
team: pickedTeam,
brief: pickedBrief,
});
};
const fetchGameplanStatus = () => {
if (!gameId) return Promise.resolve(null);
return fetch(`/api/g/${gameId}/gameplan/status`)
.then(r => r.ok ? r.json() : null)
.then(data => {
if (!data) return null;
const incomingSelections = Array.isArray(data.selections) ? data.selections : [];
const prevSelections = Array.isArray(prevSelectionsRef.current) ? prevSelectionsRef.current : null;
if (prevSelections) {
const prevKeys = new Set(prevSelections.map(s => `${s.team_id}:${s.scenario_index}`));
const newestSelection = incomingSelections.find(s => !prevKeys.has(`${s.team_id}:${s.scenario_index}`));
if (newestSelection) launchPickCelebration(newestSelection);
}
prevSelectionsRef.current = incomingSelections;
setGpStatus(data);
// Hydrate uploads for my team from server (survives refresh)
try {
const subs = (data && data.submissions) || {};
const mine = (me && me.tid && subs[me.tid]) || null;
if (mine) {
if (mine.bom_file) setBomUpload({ url: mine.bom_file, filename: mine.bom_filename || "BOM", size: mine.bom_size || 0, uploaded_at: mine.bom_uploaded_at || 0 });
if (mine.architecture_file) setArchUpload({ url: mine.architecture_file, filename: mine.architecture_filename || "Arquitectura", size: mine.architecture_size || 0, uploaded_at: mine.architecture_uploaded_at || 0 });
}
} catch (_) {}
return data;
})
.catch(() => null);
};
// Poll game plan status
uE(() => {
if (!gameId) return;
fetchGameplanStatus();
const id = setInterval(fetchGameplanStatus, 3000);
return () => clearInterval(id);
}, [gameId, briefs, theme]);
uE(() => {
setIsLive(false);
const id = requestAnimationFrame(() => setIsLive(true));
return () => cancelAnimationFrame(id);
}, []);
uE(() => {
if (!pickCelebration) return;
if (celebrationTimerRef.current) clearTimeout(celebrationTimerRef.current);
celebrationTimerRef.current = setTimeout(() => setPickCelebration(null), 3000);
return () => {
if (celebrationTimerRef.current) clearTimeout(celebrationTimerRef.current);
};
}, [pickCelebration]);
// Find my team's selection
const myTeamId = me.tid;
const mySelection = uM(() => {
if (!gpStatus || !gpStatus.selections) return null;
return gpStatus.selections.find(s => s.team_id === myTeamId);
}, [gpStatus, myTeamId]);
const myTeam = uM(() => {
const teams = (theme && theme.teams) || [];
return teams.find(t => t.id === myTeamId) || null;
}, [theme, myTeamId]);
const currentTurnTeam = uM(() => {
if (!gpStatus || !gpStatus.current_turn) return null;
return teamMetaById[gpStatus.current_turn] || null;
}, [gpStatus, teamMetaById]);
const selectionByScenario = uM(() => {
const map = {};
((gpStatus && gpStatus.selections) || []).forEach(sel => {
map[sel.scenario_index] = sel;
});
return map;
}, [gpStatus]);
const availableCount = uM(() => {
return briefs.filter((_, idx) => !selectionByScenario[idx]).length;
}, [briefs, selectionByScenario]);
const displayBrief = uM(() => {
if (mySelection && briefs[mySelection.scenario_index]) return briefs[mySelection.scenario_index];
if (briefs.length > 0) return briefs[briefIdx];
return null;
}, [mySelection, briefs, briefIdx]);
const previewBrief = uM(() => {
if (previewIdx === null || !briefs[previewIdx]) return null;
return briefs[previewIdx];
}, [briefs, previewIdx]);
const contextParas = uM(() => {
if (!displayBrief || !displayBrief.context) return [];
return String(displayBrief.context).split("\n").map(p => p.trim()).filter(Boolean);
}, [displayBrief]);
const previewContextParas = uM(() => {
if (!previewBrief || !previewBrief.context) return [];
return String(previewBrief.context).split("\n").map(p => p.trim()).filter(Boolean);
}, [previewBrief]);
const selectionCount = gpStatus && gpStatus.selections ? gpStatus.selections.length : 0;
const totalTeams = theme && theme.teams ? theme.teams.length : 0;
const teamColor = (myTeam && myTeam.color) || "var(--accent)";
const canPickNow = !!(gpStatus && gpStatus.current_turn === myTeamId && isCaptain && !mySelection);
const getBriefComplexity = (brief) => {
if (!brief) return "";
return brief.difficulty || brief.complexity || brief.complexityHint || brief.complexity_hint || "";
};
const handleScenarioSelect = (scenarioIndex) => {
const captainEmail = typeof Session !== 'undefined' ? Session.getEmail() : '';
if (!captainEmail) {
addPickerToast(tc("Pick failed"), tc("Captain email not available in session."), "danger");
return;
}
setSelectingIdx(scenarioIndex);
fetch(`/api/g/${gameId}/gameplan/select`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ captain_email: captainEmail, scenario_index: scenarioIndex })
})
.then(async r => {
const data = await r.json().catch(() => null);
if (!r.ok) throw new Error((data && (data.detail || data.message)) || tc("Try again"));
return fetchGameplanStatus();
})
.then(() => setPreviewIdx(null))
.catch(err => addPickerToast(tc("Pick failed"), (err && err.message) || tc("Try again"), "danger"))
.finally(() => setSelectingIdx(null));
};
const handleUpload = (kind, evt) => {
const file = evt && evt.target && evt.target.files && evt.target.files[0];
if (!file) return;
if (!gameId) return;
const email = (typeof Session !== 'undefined' ? Session.getEmail() : '') || (me && me.email) || '';
if (!email) { setUploadError(tc("Sesión no encontrada. Recarga la página.")); return; }
setUploadError("");
if (kind === 'bom') setBomUploading(true); else setArchUploading(true);
const fd = new FormData();
fd.append('kind', kind);
fd.append('email', email);
fd.append('file', file);
fetch(`/api/g/${gameId}/gameplan/upload`, { method: 'POST', body: fd })
.then(async r => {
if (!r.ok) {
const txt = await r.text().catch(() => '');
let msg = txt;
try { msg = JSON.parse(txt).detail || txt; } catch (_) {}
throw new Error(msg || `HTTP ${r.status}`);
}
return r.json();
})
.then(data => {
const entry = { url: data.url, filename: data.filename, size: data.size, uploaded_at: Date.now() / 1000 };
if (kind === 'bom') setBomUpload(entry); else setArchUpload(entry);
// Reset the input so the same file can be re-selected if needed
try { evt.target.value = ''; } catch (_) {}
})
.catch(err => {
setUploadError((err && err.message) || tc("Falló la subida. Intenta de nuevo."));
})
.finally(() => {
if (kind === 'bom') setBomUploading(false); else setArchUploading(false);
});
};
const renderPickerToasts = () => {
if (!pickerToasts.length) return null;
return (
{pickerToasts.map(t => (
))}
);
};
const renderPickCelebration = () => {
if (!pickCelebration) return null;
const confetti = Array.from({ length: 18 }).map((_, i) => ({
left: 4 + (i * 5.1),
dur: 1.9 + ((i % 5) * 0.14),
delay: (i % 6) * 0.05,
bg: i % 2 === 0
? (pickCelebration.team.color || "var(--accent)")
: (i % 3 === 0 ? "var(--accent)" : "var(--warning)"),
rot: (i * 37) % 360,
}));
return (
{confetti.map((piece, idx) => (
))}
{pickCelebration.team.emoji || "🎯"}
{tc("Scenario locked")}
{`${tc(pickCelebration.team.name)} ${tc("picked")} ${tc(pickCelebration.brief.client)}!`}
);
};
const renderPicker = () => {
const currentTurnColor = (currentTurnTeam && currentTurnTeam.color) || teamColor;
return (
{renderPickerToasts()}
{renderPickCelebration()}
{tc("🎯 Scenario draft")}
{tc("Choose the customer battle")}
{canPickNow
? tc("Your team is on the clock. Browse the full scenario pool, zoom into the details, and lock one shared brief before another captain does.")
: tc("Browse every customer scenario while the draft runs. Captains can only lock a brief when their team is officially on the clock.")}
{myTeam ? `${myTeam.emoji || "🎯"} ${tc(myTeam.name)}` : tc("Your team")}
{availableCount} {tc("available")}
{currentTurnTeam && (
{tc("On the clock")}: {`${currentTurnTeam.emoji || "🎯"} ${tc(currentTurnTeam.short || currentTurnTeam.name || currentTurnTeam.id)}`}
)}
{currentTurnTeam ? (currentTurnTeam.emoji || "🎯") : "⏳"}
{tc("Draft status")}
{canPickNow
? tc("Your captain can pick now")
: (isCaptain ? tc("Browse and be ready") : tc("Captain picks for your team"))}
{selectionCount} / {totalTeams} {tc("teams locked in")}
{tc("Turn order")}
{tc("Lowest score picks first")}
{((gpStatus && gpStatus.turn_order) || []).map((slot, idx) => {
const slotTeam = teamMetaById[slot.team_id] || null;
const slotSelection = ((gpStatus && gpStatus.selections) || []).find(sel => sel.team_id === slot.team_id) || null;
const slotBrief = slotSelection ? briefs[slotSelection.scenario_index] : null;
const isCurrent = gpStatus && gpStatus.current_turn === slot.team_id;
const isPicked = !!slotSelection;
const isMine = myTeamId === slot.team_id;
return (
#{idx + 1}
{`${(slotTeam && slotTeam.emoji) || "🎯"} ${tc((slotTeam && (slotTeam.short || slotTeam.name)) || slot.team_id)}`}
{slot.total_score}
{isPicked ? (
<>
✓ {tc("Picked")}
{`${(slotBrief && slotBrief.logo) || "📋"} ${slotBrief ? tc(slotBrief.client) : tc("Scenario locked")}`}
>
) : isCurrent ? (
<>
{tc("PICKING NOW")}
>
) : (
{tc("Waiting for turn")}
)}
{isMine && {tc("YOUR TEAM")} }
);
})}
{previewBrief ? (
setPreviewIdx(null)}>
{tc("← Back to all scenarios")}
{selectionByScenario[previewIdx] ? (
{`${tc("Taken by")} ${((teamMetaById[selectionByScenario[previewIdx].team_id] || {}).emoji) || "🎯"} ${tc(((teamMetaById[selectionByScenario[previewIdx].team_id] || {}).short) || ((teamMetaById[selectionByScenario[previewIdx].team_id] || {}).name) || selectionByScenario[previewIdx].team_id)}`}
) : (
{tc("Available")}
)}
{previewBrief.logo || "📋"}
{tc("Scenario preview")}
{tc(previewBrief.client)}
{tc(previewBrief.industry)}
{getBriefComplexity(previewBrief) && {tc(getBriefComplexity(previewBrief))} }
{previewBrief.licensingRequirements && {`${tc("Tier")} · ${tc(previewBrief.licensingRequirements.expectedTier)}`} }
{previewBrief.endpoints ? {`${(previewBrief.endpoints || 0).toLocaleString()} ${tc("endpoints")}`} : null}
{copy.contextTitle}
{previewContextParas.length > 0 ? previewContextParas.map((para, idx) => (
{tc(para)}
)) :
{tc("No context provided.")}
}
{tc("Requirements summary")}
{(previewBrief.painPoints || []).slice(0, 4).map((point, idx) => {tc(point)} )}
{copy.constraintsTitle}
{(previewBrief.constraints || []).slice(0, 4).map((constraint, idx) => {tc(constraint)} )}
{copy.licensingTitle}
{previewBrief.licensingRequirements ? (
{tc(previewBrief.licensingRequirements.description)}
{tc("Tier")} {tc(previewBrief.licensingRequirements.expectedTier)}
{tc("Term")} {tc(previewBrief.licensingRequirements.subscriptionTerm)}
) : (
{tc("No licensing guidance included for this brief.")}
)}
{previewBrief.trickRequirement && (
{copy.requirementTitle}
{tc("The make-or-break twist")}
{tc(previewBrief.trickRequirement)}
)}
{(((!selectionByScenario[previewIdx]) || (gpStatus && gpStatus.pool_exhausted)) && isCaptain && gpStatus && gpStatus.current_turn === myTeamId) && (
handleScenarioSelect(previewIdx)}
>
{selectingIdx === previewIdx
? tc("Locking scenario…")
: `${tc("Select this scenario for")} ${tc((myTeam && myTeam.name) || "your team")}`}
)}
) : (
{tc("Scenario pool")}
{tc("Open every brief before you commit")}
{briefs.map((brief, idx) => {
const selection = selectionByScenario[idx] || null;
const ownerTeam = selection ? (teamMetaById[selection.team_id] || null) : null;
const complexity = getBriefComplexity(brief);
return (
setPreviewIdx(idx)}
>
{brief.logo || "📋"}
{selection
? `${tc("Taken by")} ${(ownerTeam && ownerTeam.emoji) || "🎯"} ${tc((ownerTeam && (ownerTeam.short || ownerTeam.name)) || selection.team_id)}`
: tc("Available")}
{tc(brief.industry)}
{tc(brief.client)}
{tc((String(brief.context || "").split("\n").map(p => p.trim()).filter(Boolean)[0]) || "Open the preview to inspect context, constraints, and licensing detail.")}
{complexity && {tc(complexity)} }
{brief.licensingRequirements && {`${tc("Tier")} · ${tc(brief.licensingRequirements.expectedTier)}`} }
{tc("Open preview →")}
);
})}
)}
);
};
if (!gpStatus) {
return (
{tc("Loading game plan state…")}
{tc("Checking turn order and scenario selections.")}
);
}
if (briefs.length === 0) {
return (
{tc("📋 Game Plan")}
{tc("Waiting for scenarios to be loaded by the proctor…")}
);
}
if (gpStatus.all_selected === true && !mySelection) {
return (
{tc("📋 Game Plan")}
{tc("All scenarios taken before your team picked — proctor will assign one.")}
);
}
if (gpStatus.all_selected === false && !mySelection) {
return renderPicker();
}
if (!displayBrief) {
return (
{tc("📋 Game Plan")}
{tc("No customer briefs available for this module.")}
{onFinish && (
{tc("Continue →")}
)}
);
}
return (
{renderPickerToasts()}
{renderPickCelebration()}
{copy.eyebrow}
{tc(displayBrief.client)}
{tc(displayBrief.industry)}
{copy.deck}
{myTeam ? `${myTeam.emoji || "🎯"} ${tc(myTeam.name)}` : tc("Your team")}
{copy.statusLabel}: {mySelection ? copy.statusReady : copy.statusWaiting}
{totalTeams > 0 && (
{selectionCount} / {totalTeams} {tc("teams assigned")}
)}
{displayBrief.logo || "📋"}
{tc("Customer brief")}
{tc(displayBrief.client)}
{tc(displayBrief.budget)}
{copy.contextTitle}
{tc("Read the customer room before you design")}
{contextParas.length > 0 ? contextParas.map((para, idx) => (
{tc(para)}
)) :
{tc("No context provided.")}
}
{copy.risksTitle}
{tc("Signals you must solve")}
{(displayBrief.painPoints || []).map((p, i) => {tc(p)} )}
{copy.constraintsTitle}
{tc("Boundaries you cannot break")}
{(displayBrief.constraints || []).map((c, i) => {tc(c)} )}
{displayBrief.trickRequirement && (
{copy.requirementTitle}
{tc("This is where teams win or miss")}
{tc(displayBrief.trickRequirement)}
)}
{tc("Telemetry")}
{tc("Employees")}
{(displayBrief.employees || 0).toLocaleString()}
{tc("Sites")}
{displayBrief.sites}
{tc("Endpoints")}
{(displayBrief.endpoints || 0).toLocaleString()}
{tc("Budget")}
{tc(displayBrief.budget)}
{displayBrief.licensingRequirements && (
{copy.licensingTitle}
{tc("Align the commercial package")}
{tc(displayBrief.licensingRequirements.description)}
{tc("Tier")} {tc(displayBrief.licensingRequirements.expectedTier)}
{tc("Endpoints")} {(displayBrief.licensingRequirements.endpointCount || 0).toLocaleString()}
{tc("Term")} {tc(displayBrief.licensingRequirements.subscriptionTerm)}
{displayBrief.licensingRequirements.deviceAdmin !== undefined && (
{tc("Device Admin")}
{displayBrief.licensingRequirements.deviceAdmin
? `${tc("Yes")} · ${(displayBrief.licensingRequirements.deviceAdminDevices || 0).toLocaleString()}`
: tc("No")}
)}
)}
{bomRubric.length > 0 && (
{copy.rubricTitle}
{tc("How the facilitators will score it")}
{bomRubric.map((r, i) => (
{tc(r.category)}
{tc(r.check)}
+{r.points}
))}
)}
{copy.uploadTitle}
{isCaptain
? 👑 {tc("Eres el capitán")} — {tc("sube el GamePlan de tu equipo")}
: tc("Finish strong and submit the sheet")}
+{bomPoints} {tc("pts")}
{copy.uploadDeck}
{!isCaptain ? (
🔒
{tc("Solo el capitán de tu equipo puede subir el BOM y arquitectura.")}
{bomUpload || archUpload
? `${bomUpload ? "✅ BOM" : "⏳ BOM"} · ${archUpload ? "✅ Arquitectura" : "⏳ Arquitectura"}`
: tc("Espera a que el capitán de tu equipo suba el GamePlan.")}
) : (
{/* BOM SLOT */}
handleUpload('bom', e)}
/>
{bomUpload ? '✅' : '📊'}
{tc("BOM (Bill of Materials)")}
.xlsx · .csv · .pdf
{bomUpload ? (
{ if (bomFileRef.current) bomFileRef.current.click(); }}
>
{bomUploading ? tc("Subiendo…") : tc("Reemplazar BOM")}
) : (
{ if (bomFileRef.current) bomFileRef.current.click(); }}
>
{bomUploading ? tc("Subiendo…") : tc("📎 Subir BOM")}
)}
{/* ARCHITECTURE SLOT */}
handleUpload('architecture', e)}
/>
{archUpload ? '✅' : '🏗️'}
{tc("Arquitectura (diagrama)")}
jpg · png · pdf · svg
{archUpload ? (
{ if (archFileRef.current) archFileRef.current.click(); }}
>
{archUploading ? tc("Subiendo…") : tc("Reemplazar arquitectura")}
) : (
{ if (archFileRef.current) archFileRef.current.click(); }}
>
{archUploading ? tc("Subiendo…") : tc("📎 Subir arquitectura")}
)}
)}
{uploadError && isCaptain ? (
⚠️ {uploadError}
) : null}
{isCaptain && (bomUpload || archUpload) ? (
{bomUpload && archUpload ? tc("✅ GamePlan completo") : tc("⏳ Falta un archivo")}
{bomUpload && archUpload ? tc("Ready for facilitator review.") : tc("Sube ambos archivos: BOM y arquitectura.")}
) : null}
{onFinish && (
{tc("Continue →")}
)}
);
}
// ---------------------------------------------------------------------------
// Zona Mixta Screen — Curveball challenge scenarios (LIVE verbal answers)
// ---------------------------------------------------------------------------
function ZonaMixtaScreen({ theme, gameModule, me, onFinish }) {
const [cbIdx, setCbIdx] = uS(0);
const [showStrong, setShowStrong] = uS(!!(me && me.isProctor));
const [showWeak, setShowWeak] = uS(!!(me && me.isProctor));
const [isLive, setIsLive] = uS(false);
const curveballs = uM(() => {
if (!gameModule || !gameModule.data || !gameModule.data.curveballs) return [];
const all = gameModule.data.curveballs;
// Cap by organizer's zonamixta_count if set (>0). 0/undefined = use all.
const cap = (window.__GAME_CONFIG__ && window.__GAME_CONFIG__.zonamixta_count) || 0;
return cap > 0 ? all.slice(0, cap) : all;
}, [gameModule]);
const copy = uM(() => {
if (theme && theme.id === "gp") {
return {
eyebrow: tc("🏁 Post-race debrief"),
title: tc("Hold the microphone under pressure"),
deck: tc("One sharp answer can swing perception, momentum, and points for the whole garage."),
promptTitle: tc("What the pit wall expects"),
promptCopy: tc("Answer with confidence, mention trade-offs, and land on a defensible next move."),
scoringTitle: tc("Judging board"),
progressTitle: tc("Debrief run sheet"),
};
}
if (theme && theme.id === "cup") {
return {
eyebrow: tc("🏟️ Mixed zone pressure"),
title: tc("Answer before the cameras move on"),
deck: tc("This is the post-match microphone moment: clear thinking, composed language, and a tactical response."),
promptTitle: tc("What the coaches want"),
promptCopy: tc("Frame the answer around business impact, coverage, and the next tactical move."),
scoringTitle: tc("Commentary scorecard"),
progressTitle: tc("Interview queue"),
};
}
if (theme && theme.id === "nfl") {
return {
eyebrow: tc("🏈 Sideline interview"),
title: tc("Call the answer before the next snap"),
deck: tc("The pressure is live, the cameras are rolling, and the team needs a crisp read on the play."),
promptTitle: tc("What the sideline needs"),
promptCopy: tc("State the read, explain the risk, and call the smartest next down."),
scoringTitle: tc("Analyst grading"),
progressTitle: tc("Drive sequence"),
};
}
if (theme && theme.id === "realm") {
return {
eyebrow: tc("⚔️ Council challenge"),
title: tc("Speak before the court turns restless"),
deck: tc("Every response is weighed by the council. Clear judgment and strategic calm win favor."),
promptTitle: tc("What the council demands"),
promptCopy: tc("Answer with conviction, defend the trade-offs, and show the kingdom a path forward."),
scoringTitle: tc("Council scoring"),
progressTitle: tc("Petitions in queue"),
};
}
return {
eyebrow: tc("🎙️ Zona Mixta"),
title: tc("Respond under pressure"),
deck: tc("This is the live-response round. Think clearly, answer decisively, and bring the whole team with you."),
promptTitle: tc("What good answers do"),
promptCopy: tc("Show technical clarity, business relevance, and a concrete recommendation."),
scoringTitle: tc("Scoring tiers"),
progressTitle: tc("Scenario progress"),
};
}, [theme]);
const zmScoring = uM(() => {
if (window.ScoringEngine && window.ScoringEngine.getZonaMixtaScoring) {
return window.ScoringEngine.getZonaMixtaScoring((me && me.audienceMode) || "internal");
}
return { weak: 100, partial: 250, solid: 500 };
}, [me]);
const currentTeam = uM(() => {
const teams = (theme && theme.teams) || [];
return teams.find(t => t.id === (me && me.tid)) || null;
}, [theme, me]);
const teamColor = (currentTeam && currentTeam.color) || "var(--accent)";
const cb = curveballs[cbIdx];
uE(() => {
setIsLive(false);
const id = requestAnimationFrame(() => setIsLive(true));
return () => cancelAnimationFrame(id);
}, []);
uE(() => {
setShowStrong(!!(me && me.isProctor));
setShowWeak(!!(me && me.isProctor));
}, [cbIdx, me]);
if (!cb) {
return (
{tc("🎙️ Zona Mixta")}
{tc("No curveball scenarios available for this module.")}
{onFinish && (
{tc("Continue to results →")}
)}
);
}
return (
🎯
{tc("Live prompt")}
{tc(cb.text)}
{cb.context &&
{tc(cb.context)}
}
{copy.scoringTitle}
{tc("What different answer quality sounds like")}
{tc("Weak")}
+{zmScoring.weak}
{tc("Generic response, thin justification, and weak connection to customer risk.")}
{tc("Partial")}
+{zmScoring.partial}
{tc("Gets part of the story right, but leaves gaps in execution or business framing.")}
{tc("Solid")}
+{zmScoring.solid}
{tc("Clear recommendation, strong trade-offs, and a confident path forward for the customer.")}
{copy.progressTitle}
{curveballs.map((item, idx) => (
setCbIdx(idx)}
aria-label={`${tc("Go to scenario")} ${idx + 1}`}
>
{idx + 1}
))}
{tc("Move through the full interview queue and sharpen the team answer with each new curveball.")}
💬
{copy.promptTitle}
{copy.promptCopy}
{me && me.isProctor && cb.strongResponse && (
setShowStrong(prev => !prev)}>
{showStrong ? tc("🔽 Strong response reference") : tc("▶️ Strong response reference")}
{showStrong && (
{String(cb.strongResponse).split("\n").map((line, idx) =>
{tc(line)}
)}
)}
)}
{me && me.isProctor && cb.weakResponse && (
setShowWeak(prev => !prev)}>
{showWeak ? tc("🔽 Common mistakes to avoid") : tc("▶️ Common mistakes to avoid")}
{showWeak && (
)}
)}
{cbIdx === curveballs.length - 1 && onFinish && (
{tc("Next phase")}
{tc("Finish the mixed-zone round")}
{tc("The microphones are off. Lock the result and move to the final standings.")}
{tc("Continue to results →")}
)}
);
}
window.JoinScreen = JoinScreen;
window.LobbyScreen = LobbyScreen;
window.QuizScreen = QuizScreen;
window.PhaseTransitionScreen = PhaseTransitionScreen;
window.TriageScreen = TriageScreen;
window.WrapScreen = WrapScreen;
window.GamePlanScreen = GamePlanScreen;
window.ZonaMixtaScreen = ZonaMixtaScreen;
// ============================================================================
// TECH-DAY QUIZ SCREEN
// ============================================================================
// Renders the active speaker's questions for tech-day events.
// Driven by tech-day state polling (parent passes tdState + my-questions).
// Each question is single-shot: submit once, see feedback, advance.
// When questions_open=false OR no active_agenda_id → parent unmounts us.
function TechDayQuizScreen({ theme, tdState, email, name }) {
const RND = window.RENDERERS || {};
const [data, setData] = uS(null); // { agenda_id, questions, per_question_seconds, ... }
const [answeredMap, setAnsweredMap] = uS({}); // question_id -> { correct, awarded }
const [qIdx, setQIdx] = uS(0);
const [value, setValue] = uS(null);
const [submitting, setSubmitting] = uS(false);
const [feedback, setFeedback] = uS(null); // { correct, awarded, gradable, correct_answer }
const [error, setError] = uS(null);
const [nowTs, setNowTs] = uS(() => Math.floor(Date.now() / 1000));
const agendaId = tdState && tdState.active_agenda_id;
const closesAt = tdState && tdState.questions_closes_at;
// ── Fetch questions when agenda changes ──────────────────────────────────
uE(() => {
if (!agendaId || !email) return;
let cancelled = false;
Promise.all([
Api.getMyTechDayQuestions(email),
Api.getMyTechDayAnswers(email).catch(() => ({ answers: [] }))
]).then(([qResp, aResp]) => {
if (cancelled) return;
// Skip qtypes that don't make sense async (buzzer = first-to-press)
const SKIP_QTYPES = { buzzer: 1 };
const filteredQs = (qResp.questions || []).filter(qq => !SKIP_QTYPES[qq.qtype || qq.type]);
const filtered = Object.assign({}, qResp, { questions: filteredQs });
setData(filtered);
const map = {};
(aResp.answers || []).forEach(a => { map[a.question_id] = { correct: !!a.correct, awarded: a.awarded || 0, prior: true }; });
setAnsweredMap(map);
// Skip to first un-answered
const qs = filteredQs;
let firstOpen = 0;
while (firstOpen < qs.length && map[qs[firstOpen].question_id]) firstOpen++;
setQIdx(Math.min(firstOpen, Math.max(0, qs.length - 1)));
setFeedback(null);
setValue(null);
setError(null);
}).catch(err => {
if (cancelled) return;
setError(err.message || 'Failed to load questions');
});
return () => { cancelled = true; };
}, [agendaId, email]);
// ── Countdown tick (1s) ──────────────────────────────────────────────────
uE(() => {
const t = setInterval(() => setNowTs(Math.floor(Date.now() / 1000)), 1000);
return () => clearInterval(t);
}, []);
// ── Derived ──────────────────────────────────────────────────────────────
const questions = (data && data.questions) || [];
const total = questions.length;
const q = questions[qIdx];
const qtype = q && (q.qtype || q.type);
const renderer = qtype && RND[qtype];
const alreadyAnswered = q && !!answeredMap[q.question_id];
const secsLeft = closesAt ? Math.max(0, Math.floor(closesAt - nowTs)) : null;
const expired = secsLeft === 0;
const allDone = total > 0 && Object.keys(answeredMap).length >= total;
// Init value when question changes
uE(() => {
if (!q || !renderer) { setValue(null); return; }
setValue(renderer.getDefaultValue(q.spec || {}));
setFeedback(null);
}, [q && q.question_id]);
// ── Submit ───────────────────────────────────────────────────────────────
const handleSubmit = async () => {
if (!q || submitting || alreadyAnswered) return;
if (renderer && !renderer.isValid(value, q.spec || {})) {
setError(tc('Pick an answer first'));
setTimeout(() => setError(null), 2500);
return;
}
setSubmitting(true);
setError(null);
try {
const res = await Api.submitTechDayAnswer(email, q.question_id, value);
setFeedback({
correct: !!res.correct,
awarded: res.awarded || 0,
gradable: !!res.gradable,
correct_answer: res.correct_answer || ''
});
setAnsweredMap(prev => {
const next = Object.assign({}, prev);
next[q.question_id] = { correct: !!res.correct, awarded: res.awarded || 0 };
return next;
});
} catch (err) {
setError(err.message || 'Submit failed');
} finally {
setSubmitting(false);
}
};
const handleNext = () => {
setFeedback(null);
setValue(null);
if (qIdx + 1 < total) setQIdx(qIdx + 1);
else setQIdx(qIdx); // stay; allDone branch handles UI
};
// ── States ───────────────────────────────────────────────────────────────
if (!data) {
return (
{tc('LOADING')}
{tc('Fetching your questions…')}
);
}
if (!total) {
return (
{tc('TECH DAY')}
{data.agenda_title || tdState.active_title || tc('Active talk')}
{tc('No questions for this talk yet. Sit back and listen — we will let you know when there is something to answer.')}
);
}
if (allDone) {
const correctCount = Object.values(answeredMap).filter(a => a.correct).length;
const totalEarned = Object.values(answeredMap).reduce((s, a) => s + (a.awarded || 0), 0);
return (
{tc('ALL ANSWERED')}
{tc('Nice work')}, {name || ''}!
{tc('You answered')} {total}/{total} · {tc('correct')}: {correctCount} · +{totalEarned} {tc('pts')}
{tc('Wait for the next talk.')}
);
}
const Comp = renderer && renderer.Component;
const safeSpec = Object.assign({}, q.spec || {});
delete safeSpec.correct;
delete safeSpec.explanation;
const timerColor = secsLeft == null ? 'var(--accent)'
: secsLeft <= 10 ? 'var(--danger, #E10600)'
: secsLeft <= 30 ? 'var(--warning, #F0A000)'
: 'var(--accent, #00BCEB)';
const mm = secsLeft == null ? '' : String(Math.floor(secsLeft / 60));
const ss = secsLeft == null ? '' : String(secsLeft % 60).padStart(2, '0');
return (
Q{String(qIdx + 1).padStart(2, '0')}
/ {String(total).padStart(2, '0')} · {tdState.active_title || tc('Talk')}
{tc('LIVE Q&A')}
{/* Talk-level countdown */}
{secsLeft != null && (
{expired ? tc('TIME UP') : tc('TIME LEFT')}
{mm}:{ss}
)}
{q.prompt}
{alreadyAnswered && !feedback && (
{answeredMap[q.question_id].correct
? `✓ ${tc('You answered this correctly earlier')} (+${answeredMap[q.question_id].awarded} ${tc('pts')})`
: `${tc('You already submitted an answer for this question')} (+${answeredMap[q.question_id].awarded} ${tc('pts')})`}
)}
{Comp ? (
!feedback && !alreadyAnswered && setValue(v)}
locked={!!feedback || alreadyAnswered}
graded={!!feedback}
/>
) : (
{tc('This question type is not yet renderable on mobile.')} ({qtype})
)}
{feedback && (
{feedback.correct ? `✓ ${tc('Correct!')}`
: feedback.gradable ? `✗ ${tc('Not quite')}`
: `${tc('Thanks!')} ${tc('Saved.')}`}
{feedback.awarded > 0 && (
+{feedback.awarded} {tc('pts')}
)}
{feedback.gradable && !feedback.correct && feedback.correct_answer && (
{tc('Correct answer')}: {feedback.correct_answer}
)}
)}
{error && (
{error}
)}
{!feedback && !alreadyAnswered && (
{submitting ? tc('Submitting…') : expired ? tc('Time up') : tc('Submit answer')}
)}
{(feedback || alreadyAnswered) && qIdx + 1 < total && (
{tc('Next question →')}
)}
{(feedback || alreadyAnswered) && qIdx + 1 >= total && (
{tc('That was the last one.')}
)}
);
}
window.TechDayQuizScreen = TechDayQuizScreen;