WP-17. Minor: push notifications become usable and nothing a player could reach was removed. The admin console's jobs need backend 3.0.0. Push (M35): the whole browser half - the service worker's handlers, the subscribe/unsubscribe calls, the server's VAPID key - had shipped with nothing calling it, so no browser ever held a subscription. The toggle lives in Profile because the permission prompt has to come from a click. The old isPushSubscribed awaited serviceWorker.ready, which never settles with no worker registered; it uses getRegistration() now. Admin: each job pages and the button becomes Continue with the server's cursor; the recalculate card also stopped printing "undefined leaderboard entries" for a field the server had not sent for several releases. The three copy-pasted job cards are one component. Consolidation: the four game screens share use-game-screen and one search panel; the review page's static chain is ChainDisplay in a static mode (21 rendered states verified byte-identical); formatTime and error extraction exist once; Endless and quick play share a franchise-aware picker instead of two divergent sort(() => Math.random() - 0.5) copies. The dead api/index barrel and the fourteen functions only it referenced are gone. noUncheckedIndexedAccess is on. Whiplash's TMDB id is corrected and every preset title is checked against TMDB.
143 lines
5.1 KiB
TypeScript
143 lines
5.1 KiB
TypeScript
import { useState, useEffect, useRef } from 'react';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
} from '@/components/ui/dialog';
|
|
import { Button } from '@/components/ui/button';
|
|
import { useGameStore } from '@/stores/game-store';
|
|
import ScoreDisplay from './ScoreDisplay';
|
|
import ShareableResult from './ShareableResult';
|
|
import type { GameMode } from './ShareableResult';
|
|
import CelebrationOverlay from './CelebrationOverlay';
|
|
import { playSound } from '@/lib/sounds';
|
|
import { storage } from '@/lib/storage';
|
|
import { formatTime } from '@/lib/format';
|
|
import { useNavigate } from 'react-router';
|
|
import { Loader2 } from 'lucide-react';
|
|
|
|
export default function GameCompletionModal() {
|
|
const status = useGameStore((s) => s.status);
|
|
const score = useGameStore((s) => s.score);
|
|
const chain = useGameStore((s) => s.chain);
|
|
const movieA = useGameStore((s) => s.movieA);
|
|
const movieB = useGameStore((s) => s.movieB);
|
|
const hintsUsed = useGameStore((s) => s.hintsUsed);
|
|
const isDailyChallenge = useGameStore((s) => s.isDailyChallenge);
|
|
const dailyChallengeDate = useGameStore((s) => s.dailyChallengeDate);
|
|
const dailyChallengeDifficulty = useGameStore((s) => s.dailyChallengeDifficulty);
|
|
const validationError = useGameStore((s) => s.validationError);
|
|
const resetGame = useGameStore((s) => s.resetGame);
|
|
const navigate = useNavigate();
|
|
const [dismissed, setDismissed] = useState(false);
|
|
const [prevStatus, setPrevStatus] = useState(status);
|
|
const celebrated = useRef(false);
|
|
|
|
// Derived from status rather than mirrored into state by an effect, with
|
|
// dismissal reset as soon as the game leaves the completed state.
|
|
if (status !== prevStatus) {
|
|
setPrevStatus(status);
|
|
if (status !== 'completed') setDismissed(false);
|
|
}
|
|
const showModal = status === 'completed' && !dismissed;
|
|
|
|
const gameMode: GameMode = isDailyChallenge && dailyChallengeDate
|
|
? { type: 'daily', date: dailyChallengeDate }
|
|
: { type: 'freeplay' };
|
|
|
|
// Side effects only — fired once per completion, guarded by a ref so a
|
|
// re-render or dependency change cannot replay the sound or the write.
|
|
useEffect(() => {
|
|
if (status !== 'completed') {
|
|
celebrated.current = false;
|
|
return;
|
|
}
|
|
if (celebrated.current) return;
|
|
celebrated.current = true;
|
|
|
|
playSound('completion');
|
|
|
|
// Mark daily challenge as completed in localStorage (fallback for anonymous users)
|
|
if (isDailyChallenge && dailyChallengeDate) {
|
|
storage.setDailyCompleted(dailyChallengeDate, dailyChallengeDifficulty ?? undefined);
|
|
}
|
|
}, [status, isDailyChallenge, dailyChallengeDate, dailyChallengeDifficulty]);
|
|
|
|
const handlePlayAgain = () => {
|
|
resetGame();
|
|
navigate('/');
|
|
};
|
|
|
|
const handleViewChain = () => {
|
|
setDismissed(true);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{status === 'completed' && <CelebrationOverlay />}
|
|
<Dialog open={showModal} onOpenChange={(open) => !open && setDismissed(true)}>
|
|
<DialogContent
|
|
className="sm:max-w-md"
|
|
showCloseButton={false}
|
|
>
|
|
<DialogHeader>
|
|
<DialogTitle className="text-center text-2xl">
|
|
Congratulations!
|
|
</DialogTitle>
|
|
<DialogDescription className="text-center">
|
|
You completed the movie loop!
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="py-4">
|
|
{!score && !validationError && (
|
|
<div className="flex flex-col items-center gap-2 py-4">
|
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
<span className="text-sm text-muted-foreground">Calculating score...</span>
|
|
</div>
|
|
)}
|
|
{validationError && (
|
|
<div className="text-center text-sm text-destructive py-4">
|
|
{validationError}
|
|
</div>
|
|
)}
|
|
{score && (
|
|
<>
|
|
<ScoreDisplay score={score} />
|
|
<div className="mt-3 flex justify-center gap-4 text-sm text-muted-foreground">
|
|
<span>Completed in {formatTime(score.elapsedSeconds)}</span>
|
|
{hintsUsed > 0 && (
|
|
<span>{hintsUsed} hint{hintsUsed > 1 ? 's' : ''} used</span>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
<div className="flex flex-col items-center gap-3">
|
|
{score && movieA && movieB && (
|
|
<ShareableResult
|
|
score={score}
|
|
chain={chain}
|
|
movieATitle={movieA.title}
|
|
movieBTitle={movieB.title}
|
|
movieAReleaseDate={movieA.releaseDate}
|
|
movieBReleaseDate={movieB.releaseDate}
|
|
mode={gameMode}
|
|
/>
|
|
)}
|
|
<div className="flex gap-3">
|
|
<Button variant="outline" onClick={handleViewChain}>
|
|
View Chain
|
|
</Button>
|
|
<Button onClick={handlePlayAgain} size="lg">
|
|
Play Again
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|