kaboot/App.tsx

373 lines
12 KiB
TypeScript

import React, { useState } from 'react';
import { useAuth } from 'react-oidc-context';
import { useLocation, useNavigate } from 'react-router-dom';
import { useGame } from './hooks/useGame';
import { useQuizLibrary } from './hooks/useQuizLibrary';
import { useUserConfig } from './hooks/useUserConfig';
import { useUserPreferences } from './hooks/useUserPreferences';
import { Landing } from './components/Landing';
import { Lobby } from './components/Lobby';
import { GameScreen } from './components/GameScreen';
import { Scoreboard } from './components/Scoreboard';
import { Podium } from './components/Podium';
import { QuizCreator } from './components/QuizCreator';
import { RevealScreen } from './components/RevealScreen';
import { SaveQuizPrompt } from './components/SaveQuizPrompt';
import { QuizEditor } from './components/QuizEditor';
import { SaveOptionsModal } from './components/SaveOptionsModal';
import { DisconnectedScreen } from './components/DisconnectedScreen';
import { WaitingToRejoin } from './components/WaitingToRejoin';
import { HostReconnected } from './components/HostReconnected';
import { SharedQuizView } from './components/SharedQuizView';
import { UpgradePage } from './components/UpgradePage';
import { PaymentResult } from './components/PaymentResult';
import type { Quiz, GameConfig } from './types';
const seededRandom = (seed: number) => {
const x = Math.sin(seed * 9999) * 10000;
return x - Math.floor(x);
};
const FLOATING_SHAPES = [...Array(15)].map((_, i) => ({
left: `${seededRandom(i * 1) * 100}%`,
width: `${seededRandom(i * 2) * 100 + 40}px`,
height: `${seededRandom(i * 3) * 100 + 40}px`,
animationDuration: `${seededRandom(i * 4) * 20 + 15}s`,
animationDelay: `-${seededRandom(i * 5) * 20}s`,
borderRadius: seededRandom(i * 6) > 0.5 ? '50%' : '20%',
background: 'rgba(255, 255, 255, 0.05)',
}));
const FloatingShapes = React.memo(() => {
return (
<>
{FLOATING_SHAPES.map((style, i) => (
<div key={i} className="floating-shape" style={style} />
))}
</>
);
});
function App() {
const auth = useAuth();
const location = useLocation();
const navigate = useNavigate();
const { saveQuiz, updateQuiz, saving } = useQuizLibrary();
const { defaultConfig } = useUserConfig();
const { subscription } = useUserPreferences();
const maxPlayersLimit = (!subscription || subscription.accessType === 'none') ? 10 : 150;
const [showSaveOptions, setShowSaveOptions] = useState(false);
const [pendingEditedQuiz, setPendingEditedQuiz] = useState<Quiz | null>(null);
const {
role,
gameState,
quiz,
players,
currentQuestionIndex,
timeLeft,
error,
gamePin,
startQuizGen,
startManualCreation,
cancelCreation,
finalizeManualQuiz,
loadSavedQuiz,
joinGame,
startGame,
handleAnswer,
hasAnswered,
lastPointsEarned,
lastAnswerCorrect,
nextQuestion,
showScoreboard,
currentCorrectShape,
selectedOption,
currentPlayerScore,
currentStreak,
currentPlayerId,
pendingQuizToSave,
dismissSavePrompt,
sourceQuizId,
updateQuizFromEditor,
startGameFromEditor,
backFromEditor,
gameConfig,
isReconnecting,
currentPlayerName,
attemptReconnect,
goHomeFromDisconnected,
endGame,
resumeGame,
presenterId,
setPresenterPlayer,
sendAdvance,
kickPlayer,
leaveGame
} = useGame(defaultConfig);
const handleSaveQuiz = async () => {
if (!pendingQuizToSave) return;
const source = pendingQuizToSave.topic ? 'ai_generated' : 'manual';
const topic = pendingQuizToSave.topic || undefined;
await saveQuiz(pendingQuizToSave.quiz, source, topic);
dismissSavePrompt();
};
const handleEditorSave = async (editedQuiz: Quiz) => {
updateQuizFromEditor(editedQuiz);
if (auth.isAuthenticated) {
if (sourceQuizId) {
// Quiz was loaded from library - show options modal
setPendingEditedQuiz(editedQuiz);
setShowSaveOptions(true);
} else {
// New quiz (AI-generated or manual) - save as new
const source = pendingQuizToSave?.topic ? 'ai_generated' : 'manual';
const topic = pendingQuizToSave?.topic || undefined;
await saveQuiz(editedQuiz, source, topic);
dismissSavePrompt();
}
}
};
const handleOverwriteQuiz = async () => {
if (!pendingEditedQuiz || !sourceQuizId) return;
await updateQuiz(sourceQuizId, pendingEditedQuiz);
setShowSaveOptions(false);
setPendingEditedQuiz(null);
dismissSavePrompt();
};
const handleSaveAsNew = async () => {
if (!pendingEditedQuiz) return;
await saveQuiz(pendingEditedQuiz, 'manual');
setShowSaveOptions(false);
setPendingEditedQuiz(null);
dismissSavePrompt();
};
const currentQ = quiz?.questions[currentQuestionIndex];
const correctOpt = currentQ?.options.find(o => {
if (role === 'HOST') return o.isCorrect;
return o.shape === currentCorrectShape;
});
const sharedMatch = location.pathname.match(/^\/shared\/([a-zA-Z0-9_-]+)$/);
const isSharedQuizRoute = !!sharedMatch && gameState === 'LANDING';
const isUpgradeRoute = location.pathname === '/upgrade' && gameState === 'LANDING';
const isPaymentSuccessRoute = location.pathname === '/payment/success' && gameState === 'LANDING';
const isPaymentCancelRoute = location.pathname === '/payment/cancel' && gameState === 'LANDING';
const navigateHome = () => {
navigate('/', { replace: true });
};
if (isUpgradeRoute) {
return <UpgradePage onBack={navigateHome} />;
}
if (isPaymentSuccessRoute) {
return <PaymentResult status="success" onBack={navigateHome} />;
}
if (isPaymentCancelRoute) {
return <PaymentResult status="cancel" onBack={navigateHome} />;
}
if (isSharedQuizRoute) {
return (
<div className="h-screen text-white relative overflow-hidden">
<FloatingShapes />
<div className="relative z-10 h-full">
<SharedQuizView onHostQuiz={(sharedQuiz) => loadSavedQuiz(sharedQuiz)} shareToken={sharedMatch![1]} />
</div>
</div>
);
}
return (
<div className="h-screen text-white relative overflow-hidden">
<FloatingShapes />
<div className="relative z-10 h-full">
{gameState === 'LANDING' || gameState === 'GENERATING' ? (
<Landing
onGenerate={startQuizGen}
onCreateManual={startManualCreation}
onLoadQuiz={loadSavedQuiz}
onJoin={joinGame}
isLoading={gameState === 'GENERATING'}
error={error}
initialPin={gamePin}
/>
) : null}
{gameState === 'CREATING' ? (
<QuizCreator
onFinalize={finalizeManualQuiz}
onCancel={cancelCreation}
/>
) : null}
{gameState === 'EDITING' && quiz ? (
<QuizEditor
quiz={quiz}
onSave={handleEditorSave}
onStartGame={startGameFromEditor}
onBack={backFromEditor}
showSaveButton={auth.isAuthenticated}
defaultConfig={defaultConfig}
maxPlayersLimit={maxPlayersLimit}
/>
) : null}
{gameState === 'LOBBY' ? (
<>
<Lobby
quizTitle={quiz?.title || 'Kaboot'}
players={players}
gamePin={gamePin}
role={role}
onStart={startGame}
onEndGame={role === 'HOST' ? endGame : undefined}
currentPlayerId={currentPlayerId}
hostParticipates={gameConfig.hostParticipates}
presenterId={presenterId}
onSetPresenter={setPresenterPlayer}
onKickPlayer={role === 'HOST' ? kickPlayer : undefined}
onLeaveGame={role === 'CLIENT' ? leaveGame : undefined}
/>
{auth.isAuthenticated && pendingQuizToSave && (
<SaveQuizPrompt
isOpen={true}
quizTitle={pendingQuizToSave.quiz.title}
onSave={handleSaveQuiz}
onSkip={dismissSavePrompt}
/>
)}
</>
) : null}
{(gameState === 'COUNTDOWN' || gameState === 'QUESTION') ? (
gameState === 'COUNTDOWN' ? (
<div className="flex flex-col items-center justify-center h-screen animate-bounce">
<div className="text-4xl font-display font-bold mb-4">Get Ready!</div>
<div className="text-[12rem] font-black leading-none drop-shadow-[0_10px_0_rgba(0,0,0,0.3)]">
{timeLeft}
</div>
</div>
) : quiz?.questions[currentQuestionIndex] ? (
<GameScreen
question={quiz.questions[currentQuestionIndex]}
timeLeft={timeLeft}
totalQuestions={quiz.questions.length}
currentQuestionIndex={currentQuestionIndex}
gameState={gameState}
role={role}
onAnswer={handleAnswer}
hasAnswered={hasAnswered}
lastPointsEarned={lastPointsEarned}
hostPlays={gameConfig.hostParticipates}
/>
) : role === 'CLIENT' && hasAnswered ? (
<div className="flex flex-col items-center justify-center h-screen">
<div className="bg-theme-primary/95 rounded-[2rem] p-12 text-center">
<div className="text-6xl mb-6">🚀</div>
<h2 className="text-4xl md:text-5xl font-black text-white font-display mb-4">Answer Sent!</h2>
<p className="text-xl font-bold opacity-80">Cross your fingers...</p>
</div>
</div>
) : currentPlayerName ? (
<WaitingToRejoin
playerName={currentPlayerName}
score={currentPlayerScore}
/>
) : null
) : null}
{gameState === 'REVEAL' ? (
correctOpt ? (
<RevealScreen
isCorrect={lastAnswerCorrect === true}
pointsEarned={lastPointsEarned || 0}
newScore={currentPlayerScore}
streak={currentStreak}
correctOption={correctOpt}
selectedOption={selectedOption}
role={role}
onNext={showScoreboard}
isPresenter={currentPlayerId === presenterId}
onPresenterAdvance={() => sendAdvance('SCOREBOARD')}
hostParticipates={gameConfig.hostParticipates}
/>
) : currentPlayerName ? (
<WaitingToRejoin
playerName={currentPlayerName}
score={currentPlayerScore}
/>
) : null
) : null}
{gameState === 'SCOREBOARD' ? (
<Scoreboard
players={players}
onNext={nextQuestion}
isHost={role === 'HOST'}
currentPlayerId={currentPlayerId}
isPresenter={currentPlayerId === presenterId}
onPresenterAdvance={() => sendAdvance('NEXT')}
/>
) : null}
{gameState === 'PODIUM' ? (
<Podium
players={players}
onRestart={endGame}
/>
) : null}
{gameState === 'DISCONNECTED' && currentPlayerName && gamePin ? (
<DisconnectedScreen
playerName={currentPlayerName}
gamePin={gamePin}
isReconnecting={isReconnecting}
onReconnect={attemptReconnect}
onGoHome={goHomeFromDisconnected}
/>
) : null}
{gameState === 'WAITING_TO_REJOIN' && currentPlayerName ? (
<WaitingToRejoin
playerName={currentPlayerName}
score={currentPlayerScore}
/>
) : null}
{gameState === 'HOST_RECONNECTED' && quiz ? (
<HostReconnected
quizTitle={quiz.title}
currentQuestionIndex={currentQuestionIndex}
totalQuestions={quiz.questions.length}
playerCount={players.filter(p => p.id !== 'host').length}
onResume={resumeGame}
onEndGame={endGame}
/>
) : null}
</div>
<SaveOptionsModal
isOpen={showSaveOptions}
onClose={() => {
setShowSaveOptions(false);
setPendingEditedQuiz(null);
}}
onOverwrite={handleOverwriteQuiz}
onSaveNew={handleSaveAsNew}
isSaving={saving}
/>
</div>
);
}
export default App;