Files
movieloop-frontend/src/components/game/GameCompletionModal.tsx
T
TehRiehlDeal 5a7e8cebc3 feat: repo hygiene - push opt-in, paged admin jobs, shared game screen (2.2.0)
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.
2026-09-12 00:01:42 -07:00

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>
</>
);
}