feat: Integrate join URL and QR code for player access

Adds a dynamic join URL and QR code displayed on the spectator view and host dashboard. This allows players to easily join the quiz session by scanning the QR code or navigating to the provided URL.

The `joinUrl` is now managed within the `GameContext` and exposed to relevant components. The spectator view uses this URL to generate the QR code, while the host view displays it for easy access. This enhances the onboarding experience for new players and simplifies session management.
This commit is contained in:
Philip
2026-01-28 19:01:34 -08:00
parent 1939574e0f
commit c5cf88491f
7 changed files with 403 additions and 105 deletions
+241 -88
View File
@@ -1,22 +1,29 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import React, { createContext, useContext, useState, useEffect, useRef } from 'react';
import { GamePhase } from '../types';
import type { Player, Team, Question, BuzzerLog, GameState, Game } from '../types';
import type { Player, Team, Question, BuzzerLog, GameState, Game, PlayerIntent } from '../types';
interface GameContextType {
// State
gameState: GameState;
activeGameName: string; // New: Track which game is loaded
activeGameName: string;
joinUrl: string;
players: Player[];
teams: Team[];
questions: Question[]; // The ACTIVE questions currently being played
games: Game[]; // The LIBRARY of saved games
questions: Question[];
games: Game[];
buzzQueue: BuzzerLog[];
currentPlayerId: string | null;
// Sync Status
isHost: boolean;
setIsHost: (isHost: boolean) => void;
isSyncing: boolean;
// Actions
setJoinUrl: (url: string) => void;
addPlayer: (name: string, teamName: string) => void;
approvePlayer: (playerId: string) => void;
removePlayer: (playerId: string) => void; // New action
removePlayer: (playerId: string) => void;
startGame: () => void;
startCountdown: () => void;
openBuzzers: () => void;
@@ -39,6 +46,11 @@ interface GameContextType {
const GameContext = createContext<GameContextType | undefined>(undefined);
// --- API CONFIG ---
// Assuming api.php is at the root. Change if in a subfolder.
// If using Vite development server, you might need to point this to your actual PHP server URL.
const API_URL = import.meta.env.DEV ? 'http://localhost/quiz/api.php' : './api.php';
// Initial Mock Data
const INITIAL_QUESTIONS: Question[] = [
{ id: '1', text: "What is the capital of France?", answer: "Paris", points: 10, category: "Geography" },
@@ -68,6 +80,10 @@ const INITIAL_GAMES: Game[] = [
];
export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [isHost, setIsHost] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
// --- GAME STATE ---
const [gameState, setGameState] = useState<GameState>({
phase: GamePhase.LOBBY,
currentQuestionIndex: -1,
@@ -76,6 +92,7 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
});
const [activeGameName, setActiveGameName] = useState<string>("General Knowledge Demo");
const [joinUrl, setJoinUrl] = useState<string>('');
const [players, setPlayers] = useState<Player[]>([]);
const [teams, setTeams] = useState<Team[]>([]);
const [questions, setQuestionsQuestions] = useState<Question[]>(INITIAL_QUESTIONS);
@@ -83,7 +100,143 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
const [buzzQueue, setBuzzQueue] = useState<BuzzerLog[]>([]);
const [currentPlayerId, setCurrentPlayerId] = useState<string | null>(null);
// Helper: Find or create team
// Refs for accessing state inside intervals without dependencies
const stateRef = useRef({ gameState, players, teams, questions, activeGameName, buzzQueue, joinUrl });
useEffect(() => {
stateRef.current = { gameState, players, teams, questions, activeGameName, buzzQueue, joinUrl };
}, [gameState, players, teams, questions, activeGameName, buzzQueue, joinUrl]);
// Set default Join URL on mount
useEffect(() => {
if (typeof window !== 'undefined') {
const url = `${window.location.origin}${window.location.pathname}#player`;
setJoinUrl(url);
}
}, []);
// --- SYNC ENGINE ---
useEffect(() => {
const syncInterval = setInterval(async () => {
setIsSyncing(true);
try {
if (isHost) {
// --- HOST LOGIC: PULL INTENTS -> PROCESS -> PUSH STATE ---
// 1. Fetch Intents
const response = await fetch(`${API_URL}?action=getIntents`);
const intents = await response.json();
let stateChanged = false;
// 2. Process Intents
if (Array.isArray(intents) && intents.length > 0) {
console.log("Processing Intents:", intents);
intents.forEach((item: any) => {
const { type, payload } = item;
if (type === 'JOIN') {
// Logic extracted from addPlayer
const { name, teamName, tempId } = payload;
const currentTeams = stateRef.current.teams;
const existingTeam = currentTeams.find(t => t.name.toLowerCase() === teamName.toLowerCase());
let teamId = existingTeam?.id;
if (!existingTeam) {
const newTeam: Team = { id: crypto.randomUUID(), name: teamName, score: 0 };
setTeams(prev => [...prev, newTeam]);
teamId = newTeam.id;
}
// Check if player already exists to prevent dupes
const exists = stateRef.current.players.some(p => p.name === name && p.teamId === teamId);
if (!exists && teamId) {
const newPlayer: Player = {
id: tempId || crypto.randomUUID(),
name,
teamId,
score: 0,
isApproved: true, // Auto approve for now for smoother UX
stats: { correctAnswers: 0, totalBuzzes: 0, bestReactionTime: null }
};
setPlayers(prev => [...prev, newPlayer]);
}
stateChanged = true;
}
else if (type === 'BUZZ') {
const { playerId } = payload;
const currentState = stateRef.current.gameState;
const currentQueue = stateRef.current.buzzQueue;
if (currentState.phase === GamePhase.BUZZER_OPEN && !currentQueue.find(b => b.playerId === playerId)) {
const newBuzz: BuzzerLog = {
playerId,
timestamp: Date.now(),
order: currentQueue.length + 1,
status: 'PENDING'
};
setBuzzQueue(prev => {
const updated = [...prev, newBuzz];
if (updated.length === 1) { // Only force update phase if it was the first buzz
setGameState(gs => ({ ...gs, phase: GamePhase.ADJUDICATION }));
}
return updated;
});
stateChanged = true;
}
}
else if (type === 'LEAVE') {
const { playerId } = payload;
setPlayers(prev => prev.filter(p => p.id !== playerId));
stateChanged = true;
}
});
}
// 3. Push State (Always push to keep server alive with latest data, or at least periodically)
// For now, we push every cycle to ensure consistency.
const fullState = {
gameState: stateRef.current.gameState,
players: stateRef.current.players,
teams: stateRef.current.teams,
questions: stateRef.current.questions,
activeGameName: stateRef.current.activeGameName,
buzzQueue: stateRef.current.buzzQueue,
joinUrl: stateRef.current.joinUrl
};
await fetch(`${API_URL}?action=pushState`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(fullState)
});
} else {
// --- CLIENT LOGIC: PULL STATE -> UPDATE LOCAL ---
const response = await fetch(`${API_URL}?action=getState`);
const remoteState = await response.json();
if (remoteState && remoteState.gameState) {
setGameState(remoteState.gameState);
setPlayers(remoteState.players || []);
setTeams(remoteState.teams || []);
setQuestionsQuestions(remoteState.questions || []);
setActiveGameName(remoteState.activeGameName || "");
setBuzzQueue(remoteState.buzzQueue || []);
setJoinUrl(remoteState.joinUrl || "");
}
}
} catch (e) {
console.error("Sync Error:", e);
} finally {
setIsSyncing(false);
}
}, 500); // 500ms polling rate
return () => clearInterval(syncInterval);
}, [isHost]); // Re-run effect if role changes
// --- HOST HELPERS (Local) ---
const getOrCreateTeam = (name: string) => {
const existing = teams.find(t => t.name.toLowerCase() === name.toLowerCase());
if (existing) return existing;
@@ -92,37 +245,72 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
return newTeam;
};
const addPlayer = (name: string, teamName: string) => {
const team = getOrCreateTeam(teamName);
const newPlayer: Player = {
id: crypto.randomUUID(),
name,
teamId: team.id,
score: 0,
isApproved: false, // Requires admin approval
stats: {
correctAnswers: 0,
totalBuzzes: 0,
bestReactionTime: null
// --- ACTIONS ---
const sendIntent = async (type: string, payload: any) => {
try {
await fetch(`${API_URL}?action=pushIntent`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type, payload })
});
} catch (e) {
console.error("Failed to send intent", e);
}
};
setPlayers(prev => [...prev, newPlayer]);
if (!currentPlayerId) setCurrentPlayerId(newPlayer.id);
};
const addPlayer = (name: string, teamName: string) => {
// If Host, do it immediately (Old Logic) - Although now logic is in Sync Loop
// To make it unified, Host also sends intent OR we just trust the sync loop.
// BUT, for "Host-Added Players", we can just do it locally.
// For "Client Joining", they send intent.
// PLAYER-SIDE LOGIC:
const tempId = crypto.randomUUID();
setCurrentPlayerId(tempId); // Set ID immediately so UI shows "Waiting..."
sendIntent('JOIN', { name, teamName, tempId });
};
const removePlayer = (playerId: string) => {
setPlayers(prev => prev.filter(p => p.id !== playerId));
if (currentPlayerId === playerId) {
setCurrentPlayerId(null);
if (isHost) {
setPlayers(prev => prev.filter(p => p.id !== playerId));
} else {
// Player leaving
sendIntent('LEAVE', { playerId });
if (currentPlayerId === playerId) setCurrentPlayerId(null);
}
};
const approvePlayer = (playerId: string) => {
if (!isHost) return;
setPlayers(prev => prev.map(p => p.id === playerId ? { ...p, isApproved: true } : p));
};
const handleBuzz = (playerId: string) => {
if (isHost) {
// Host manual buzz? Rare, but allowed.
if (gameState.phase !== GamePhase.BUZZER_OPEN) return;
if (buzzQueue.find(b => b.playerId === playerId)) return;
const newBuzz: BuzzerLog = {
playerId,
timestamp: Date.now(),
order: buzzQueue.length + 1,
status: 'PENDING'
};
setBuzzQueue(prev => [...prev, newBuzz]);
if (buzzQueue.length === 0) {
setGameState(prev => ({ ...prev, phase: GamePhase.ADJUDICATION }));
}
} else {
// Client buzz
sendIntent('BUZZ', { playerId });
}
};
// --- HOST ONLY ACTIONS (No change needed, just guard them) ---
const startGame = () => {
// Directly start Q1 Countdown, skipping initial Leaderboard
if (!isHost) return;
setGameState({
phase: GamePhase.COUNTDOWN,
currentQuestionIndex: 0,
@@ -133,16 +321,15 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
};
const startCountdown = () => {
if (!isHost) return;
let nextIndex = gameState.currentQuestionIndex;
if (gameState.phase === GamePhase.LEADERBOARD || gameState.phase === GamePhase.LOBBY) {
nextIndex = gameState.currentQuestionIndex + 1;
}
if (nextIndex >= questions.length) {
setGameState(prev => ({ ...prev, phase: GamePhase.FINAL_STATS }));
return;
}
setGameState({
phase: GamePhase.COUNTDOWN,
currentQuestionIndex: nextIndex,
@@ -152,7 +339,9 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
setBuzzQueue([]);
};
// Countdown Timer Effect (Runs on Host Only to drive state)
useEffect(() => {
if (!isHost) return;
let timer: ReturnType<typeof setTimeout>;
if (gameState.phase === GamePhase.COUNTDOWN) {
if (gameState.countdownValue > 0) {
@@ -164,60 +353,32 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
}
}
return () => clearTimeout(timer);
}, [gameState.phase, gameState.countdownValue]);
}, [gameState.phase, gameState.countdownValue, isHost]);
const openBuzzers = () => {
if (!isHost) return;
setGameState(prev => ({ ...prev, phase: GamePhase.BUZZER_OPEN, buzzerOpenTimestamp: Date.now() }));
};
const handleBuzz = (playerId: string) => {
if (gameState.phase !== GamePhase.BUZZER_OPEN) return;
if (buzzQueue.find(b => b.playerId === playerId)) return;
const newBuzz: BuzzerLog = {
playerId,
timestamp: Date.now(),
order: buzzQueue.length + 1,
status: 'PENDING'
};
setBuzzQueue(prev => [...prev, newBuzz]);
if (buzzQueue.length === 0) {
setGameState(prev => ({ ...prev, phase: GamePhase.ADJUDICATION }));
}
};
const resolveBuzz = (playerId: string, correct: boolean) => {
if (!isHost) return;
const currentQ = questions[gameState.currentQuestionIndex];
const player = players.find(p => p.id === playerId);
// Update Stats logic
setPlayers(prev => prev.map(p => {
if (p.id !== playerId) return p;
let newStats = { ...p.stats };
// Increment attempts (total buzzes)
newStats.totalBuzzes += 1;
// Calculate reaction time if this buzz was the first one processed
// We only really care about updating reaction time if it was a successful/valid buzz attempt
if (gameState.buzzerOpenTimestamp) {
const reactionTime = Date.now() - gameState.buzzerOpenTimestamp;
if (newStats.bestReactionTime === null || reactionTime < newStats.bestReactionTime) {
newStats.bestReactionTime = reactionTime;
}
}
if (correct) {
newStats.correctAnswers += 1;
return {
...p,
score: p.score + currentQ.points,
stats: newStats
};
return { ...p, score: p.score + currentQ.points, stats: newStats };
}
return { ...p, stats: newStats };
}));
@@ -237,6 +398,7 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
};
const rectifyBuzz = (playerId: string, newStatus: 'CORRECT' | 'WRONG') => {
if (!isHost) return;
const currentQ = questions[gameState.currentQuestionIndex];
const player = players.find(p => p.id === playerId);
if (!player || !player.teamId) return;
@@ -250,43 +412,38 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
if (newStatus === 'CORRECT' && oldBuzz.status !== 'CORRECT') {
setTeams(prev => prev.map(t => t.id === player.teamId ? { ...t, score: t.score + points } : t));
// Also correct stat if rectifying
setPlayers(prev => prev.map(p => p.id === playerId ? {
...p,
score: p.score + points,
stats: { ...p.stats, correctAnswers: p.stats.correctAnswers + 1 }
...p, score: p.score + points, stats: { ...p.stats, correctAnswers: p.stats.correctAnswers + 1 }
} : p));
setGameState(prev => ({ ...prev, phase: GamePhase.ANSWER_REVEAL }));
}
else if (newStatus === 'WRONG' && oldBuzz.status === 'CORRECT') {
setTeams(prev => prev.map(t => t.id === player.teamId ? { ...t, score: t.score - points } : t));
setPlayers(prev => prev.map(p => p.id === playerId ? {
...p,
score: p.score - points,
stats: { ...p.stats, correctAnswers: Math.max(0, p.stats.correctAnswers - 1) }
...p, score: p.score - points, stats: { ...p.stats, correctAnswers: Math.max(0, p.stats.correctAnswers - 1) }
} : p));
const othersPending = buzzQueue.some(b => b.playerId !== playerId && b.status === 'PENDING');
setGameState(prev => ({
...prev,
phase: othersPending || buzzQueue.length === 0 ? GamePhase.BUZZER_OPEN : GamePhase.ADJUDICATION
...prev, phase: othersPending || buzzQueue.length === 0 ? GamePhase.BUZZER_OPEN : GamePhase.ADJUDICATION
}));
}
};
const skipQuestion = () => {
if (!isHost) return;
setGameState(prev => ({ ...prev, phase: GamePhase.ANSWER_REVEAL }));
setBuzzQueue([]);
};
const nextPhase = () => {
if (!isHost) return;
if (gameState.phase === GamePhase.ANSWER_REVEAL) {
setGameState(prev => ({ ...prev, phase: GamePhase.LEADERBOARD }));
}
};
const playAudio = (url: string, start: number = 0, end?: number) => {
// Audio is mostly local for now
const audio = new Audio(url);
audio.currentTime = start;
audio.play();
@@ -298,34 +455,24 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
};
const resetGame = () => {
// "Soft Reset" - Keep players and teams, but reset their scores and stats
if (!isHost) return;
setGameState({
phase: GamePhase.LOBBY,
currentQuestionIndex: -1,
countdownValue: 3,
buzzerOpenTimestamp: null
});
// Reset Scores and Stats for Players
setPlayers(prev => prev.map(p => ({
...p,
score: 0,
stats: {
correctAnswers: 0,
totalBuzzes: 0,
bestReactionTime: null
}
...p, score: 0, stats: { correctAnswers: 0, totalBuzzes: 0, bestReactionTime: null }
})));
// Reset Team Scores
setTeams(prev => prev.map(t => ({ ...t, score: 0 })));
setBuzzQueue([]);
};
// --- LIBRARY ACTIONS ---
// --- LIBRARY ACTIONS (Local to Host) ---
const createGame = (name: string) => {
if (!isHost) return;
const newGame: Game = {
id: crypto.randomUUID(),
name,
@@ -336,19 +483,21 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
};
const updateGame = (gameId: string, updates: Partial<Game>) => {
if (!isHost) return;
setGames(prev => prev.map(g => g.id === gameId ? { ...g, ...updates } : g));
};
const deleteGame = (gameId: string) => {
if (!isHost) return;
setGames(prev => prev.filter(g => g.id !== gameId));
};
const loadGameToLive = (gameId: string) => {
if (!isHost) return;
const game = games.find(g => g.id === gameId);
if (game) {
setQuestionsQuestions([...game.questions]);
setActiveGameName(game.name);
setGameState({
phase: GamePhase.LOBBY,
currentQuestionIndex: -1,
@@ -356,7 +505,6 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
buzzerOpenTimestamp: null
});
setBuzzQueue([]);
// NOTE: We do not clear players here anymore, allowing roster to persist between games
}
};
@@ -364,12 +512,17 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
<GameContext.Provider value={{
gameState,
activeGameName,
joinUrl,
players,
teams,
questions,
games,
buzzQueue,
currentPlayerId,
isHost,
setIsHost,
isSyncing,
setJoinUrl,
addPlayer,
approvePlayer,
removePlayer,
@@ -399,4 +552,4 @@ export const useGame = () => {
const context = useContext(GameContext);
if (!context) throw new Error("useGame must be used within a GameProvider");
return context;
};
};