+ {Array.from({ length: CONFETTI_COUNT }).map((_, i) => {
+ const color =
+ CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)];
+ const left = Math.random() * 100;
+ const delay = Math.random() * 1;
+ const duration = 2 + Math.random() * 1.5;
+ const size = 6 + Math.random() * 6;
+
+ return (
+
0.5 ? '50%' : '2px',
+ animationDelay: `${delay}s`,
+ animationDuration: `${duration}s`,
+ }}
+ />
+ );
+ })}
+
+ );
+}
diff --git a/src/components/game/ChainDisplay.tsx b/src/components/game/ChainDisplay.tsx
new file mode 100644
index 0000000..268f564
--- /dev/null
+++ b/src/components/game/ChainDisplay.tsx
@@ -0,0 +1,229 @@
+import { useGameStore } from '@/stores/game-store';
+import MovieCard from './MovieCard';
+import ActorCard from './ActorCard';
+import SerpentineConnector from './SerpentineConnector';
+import { cn } from '@/lib/utils';
+import { Loader2 } from 'lucide-react';
+import type { ActorChainLink, MovieChainLink } from '@/types';
+
+interface ChainPair {
+ actor: ActorChainLink;
+ movie?: MovieChainLink;
+}
+
+export default function ChainDisplay() {
+ const chain = useGameStore((s) => s.chain);
+ const movieA = useGameStore((s) => s.movieA);
+ const movieB = useGameStore((s) => s.movieB);
+ const status = useGameStore((s) => s.status);
+ const isValidating = useGameStore((s) => s.isValidating);
+
+ // chain[0] = MovieA (standalone starting point)
+ // chain[1..2] = pair 0 (Actor, Movie)
+ // chain[3..4] = pair 1 (Actor, Movie)
+ // If chain length is even, last pair is incomplete (actor only, awaiting movie)
+ const pairs: ChainPair[] = [];
+ for (let i = 1; i < chain.length; i += 2) {
+ const actor = chain[i] as ActorChainLink;
+ const movie = chain[i + 1] as MovieChainLink | undefined;
+ pairs.push({ actor, movie });
+ }
+
+ const startMovie = chain[0] as MovieChainLink | undefined;
+
+ // Geometry for w-[85%] offset cards with L-shaped connectors:
+ // Odd pairs (0,2,4) are right-aligned → card spans 15%-100%
+ // Even pairs (1,3,5) are left-aligned → card spans 0%-85%
+ //
+ // Vertical line at 7.5% (left) or 92.5% (right) runs through the gap
+ // AND continues into the card row down to its vertical center, where
+ // a horizontal arm connects to the card edge (15% or 85%).
+
+ return (
+
+ {/* Starting movie (Movie A) — full width */}
+ {startMovie && (
+
+ )}
+
+ {/* Chain pairs */}
+ {pairs.map((pair, pairIndex) => {
+ const isOdd = pairIndex % 2 === 0; // 0-indexed: pair 0,2,4 are "odd" layout
+ const isLastPair = pairIndex === pairs.length - 1;
+ const isComplete = !!pair.movie;
+
+ // Determine movie target markers
+ let movieTarget: 'A' | 'B' | undefined;
+ if (pair.movie) {
+ if (pair.movie.id === movieB?.id) movieTarget = 'B';
+ else if (
+ pair.movie.id === movieA?.id &&
+ isLastPair &&
+ status === 'completed'
+ )
+ movieTarget = 'A';
+ }
+
+ // Connector geometry — L-shaped, aligned verticals
+ const lineX = isOdd ? 7.5 : 92.5;
+ const cardEdge = isOdd ? 15 : 85;
+ // For the horizontal arm: left% and right% in CSS
+ const armLeft = Math.min(lineX, cardEdge);
+ const armRight = 100 - Math.max(lineX, cardEdge);
+
+ return (
+
+ {/* Vertical line through the gap */}
+
+
+ {/* Pair row — 85% width, alternating alignment */}
+
+ {/* Vertical continuation from top of row to vertical center */}
+
+ {/* Horizontal arm at vertical center to card edge */}
+
+
+ {isOdd ? (
+ <>
+ {/* Odd pairs: [Actor | dot | Movie] */}
+
+
+
+ {pair.movie ? (
+
+
+ {movieTarget && }
+
+ ) : (
+
+ )}
+
+ >
+ ) : (
+ <>
+ {/* Even pairs: [Movie | dot | Actor] (reversed) */}
+
+ {pair.movie ? (
+
+ {movieTarget && }
+
+
+ ) : (
+
+ )}
+
+
+
+ >
+ )}
+
+
+
+ );
+ })}
+
+ {/* Validating spinner when searching for an actor (no incomplete pair yet) */}
+ {isValidating && status === 'playing' && chain.length > 0 && chain.length % 2 === 1 && (
+
+
+
+ {/* Vertical continuation + horizontal arm for validating row */}
+
+
+
+
+
+ Validating...
+
+
+
+
+ )}
+
+ {status === 'completed' && (
+
+ )}
+
+ );
+}
+
+function Dot({ muted }: { muted?: boolean }) {
+ return (
+
+ );
+}
+
+function PendingSlot({ loading }: { loading?: boolean }) {
+ return (
+
+ {loading ? (
+
+ ) : (
+
+ )}
+
+ {loading ? 'Validating...' : '?'}
+
+
+ );
+}
+
+function TargetBadge({ target }: { target: 'A' | 'B' }) {
+ return (
+
+ {target}
+
+ );
+}
diff --git a/src/components/game/GameCompletionModal.tsx b/src/components/game/GameCompletionModal.tsx
new file mode 100644
index 0000000..204e4f2
--- /dev/null
+++ b/src/components/game/GameCompletionModal.tsx
@@ -0,0 +1,118 @@
+import { useState, useEffect } 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 { useAuthStore } from '@/stores/auth-store';
+import { submitScore } from '@/api/leaderboards';
+import ScoreDisplay from './ScoreDisplay';
+import ShareableResult from './ShareableResult';
+import CelebrationOverlay from './CelebrationOverlay';
+import { playSound } from '@/lib/sounds';
+import { useNavigate } from 'react-router';
+
+function formatElapsed(seconds: number): string {
+ const m = Math.floor(seconds / 60);
+ const s = seconds % 60;
+ return `${m}:${s.toString().padStart(2, '0')}`;
+}
+
+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 resetGame = useGameStore((s) => s.resetGame);
+ const user = useAuthStore((s) => s.user);
+ const navigate = useNavigate();
+ const [showModal, setShowModal] = useState(false);
+
+ useEffect(() => {
+ if (status === 'completed') {
+ setShowModal(true);
+ playSound('completion');
+
+ // Auto-submit score if logged in
+ if (user && score) {
+ submitScore(
+ score.totalScore,
+ score.chainLength,
+ score.elapsedSeconds,
+ hintsUsed,
+ ).catch(() => {
+ // Silent fail — score is still shown locally
+ });
+ }
+ } else {
+ setShowModal(false);
+ }
+ }, [status]);
+
+ const handlePlayAgain = () => {
+ resetGame();
+ navigate('/');
+ };
+
+ const handleViewChain = () => {
+ setShowModal(false);
+ };
+
+ return (
+ <>
+ {status === 'completed' &&
}
+
!open && setShowModal(false)}>
+
+
+
+ Congratulations!
+
+
+ You completed the movie loop!
+
+
+
+ {score && (
+ <>
+
+
+ Completed in {formatElapsed(score.elapsedSeconds)}
+ {hintsUsed > 0 && (
+ {hintsUsed} hint{hintsUsed > 1 ? 's' : ''} used
+ )}
+
+ >
+ )}
+
+
+ {score && movieA && movieB && (
+
+ )}
+
+
+ View Chain
+
+
+ Play Again
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/components/game/GameHeader.tsx b/src/components/game/GameHeader.tsx
new file mode 100644
index 0000000..b160b90
--- /dev/null
+++ b/src/components/game/GameHeader.tsx
@@ -0,0 +1,46 @@
+import { useGameStore } from '@/stores/game-store';
+import { getLinkCount } from '@/types';
+import MovieCard from './MovieCard';
+import Timer from './Timer';
+import { Badge } from '@/components/ui/badge';
+import { Separator } from '@/components/ui/separator';
+import { CheckCircle2, Circle } from 'lucide-react';
+
+export default function GameHeader() {
+ const movieA = useGameStore((s) => s.movieA);
+ const movieB = useGameStore((s) => s.movieB);
+ const chain = useGameStore((s) => s.chain);
+ const passedMovieB = useGameStore((s) => s.passedMovieB);
+
+ if (!movieA || !movieB) return null;
+
+ return (
+
+
+
+
+
+
+
+
Chain: {getLinkCount(chain)} links
+
+ {passedMovieB ? (
+
+ ) : (
+
+ )}
+
+ Passed Movie B
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/game/HintButton.tsx b/src/components/game/HintButton.tsx
new file mode 100644
index 0000000..aac9972
--- /dev/null
+++ b/src/components/game/HintButton.tsx
@@ -0,0 +1,71 @@
+import { useHints } from '@/hooks/use-hints';
+import { useChainValidation } from '@/hooks/use-chain-validation';
+import { profileUrl } from '@/lib/tmdb';
+import { Button } from '@/components/ui/button';
+import { Lightbulb, Loader2 } from 'lucide-react';
+import type { TmdbPersonResult } from '@/types';
+
+export default function HintButton() {
+ const { hints, isLoading, getHint, clearHints, canHint, hintsUsed, maxHints } =
+ useHints();
+ const { validateAndAddActor } = useChainValidation();
+
+ const handleSelectHint = (member: { id: number; name: string; profile_path: string | null; popularity: number; known_for_department: string }) => {
+ clearHints();
+ // Convert cast member to TmdbPersonResult shape for validation
+ const person: TmdbPersonResult = {
+ id: member.id,
+ name: member.name,
+ profile_path: member.profile_path,
+ popularity: member.popularity,
+ known_for_department: member.known_for_department,
+ known_for: [],
+ adult: false,
+ };
+ validateAndAddActor(person);
+ };
+
+ const remaining = maxHints - hintsUsed;
+
+ return (
+
+
+ {isLoading ? (
+
+ ) : (
+
+ )}
+ Hint ({remaining} left)
+ -150 pts
+
+
+ {hints.length > 0 && (
+
+ {hints.map((member) => (
+
handleSelectHint(member)}
+ >
+
+ {member.name}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/components/game/MovieCard.tsx b/src/components/game/MovieCard.tsx
new file mode 100644
index 0000000..75eb631
--- /dev/null
+++ b/src/components/game/MovieCard.tsx
@@ -0,0 +1,73 @@
+import { cn } from '@/lib/utils';
+import { posterUrl, releaseYear } from '@/lib/tmdb';
+import type { MovieChainLink } from '@/types';
+
+interface MovieCardProps {
+ movie: MovieChainLink;
+ highlight?: boolean;
+ isTarget?: 'A' | 'B';
+ compact?: boolean;
+}
+
+export default function MovieCard({
+ movie,
+ highlight,
+ isTarget,
+ compact,
+}: MovieCardProps) {
+ const imgSrc = posterUrl(movie.posterPath, 'w154');
+
+ if (compact) {
+ return (
+
+
+
+
{movie.title}
+
+ {releaseYear(movie.releaseDate)}
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
{movie.title}
+
+ {releaseYear(movie.releaseDate)}
+
+ {isTarget && (
+
+ Movie {isTarget}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/game/ScoreDisplay.tsx b/src/components/game/ScoreDisplay.tsx
new file mode 100644
index 0000000..eaea263
--- /dev/null
+++ b/src/components/game/ScoreDisplay.tsx
@@ -0,0 +1,65 @@
+import { cn } from '@/lib/utils';
+import type { ScoreBreakdown } from '@/types';
+
+interface ScoreDisplayProps {
+ score: ScoreBreakdown;
+}
+
+export default function ScoreDisplay({ score }: ScoreDisplayProps) {
+ return (
+
+
{score.totalScore}
+
points
+
+
+
+
+
+
+ {score.hintPenalty > 0 && (
+
+ )}
+
+
+ );
+}
+
+function ScoreLine({
+ label,
+ value,
+ bonus,
+ penalty,
+}: {
+ label: string;
+ value: number;
+ bonus?: boolean;
+ penalty?: boolean;
+}) {
+ return (
+
+ {label}
+ 0 && 'text-green-600',
+ penalty && 'text-red-500',
+ )}
+ >
+ {value > 0 && bonus ? '+' : ''}
+ {value}
+
+
+ );
+}
diff --git a/src/components/game/SearchAutocomplete.tsx b/src/components/game/SearchAutocomplete.tsx
new file mode 100644
index 0000000..4ab4fe7
--- /dev/null
+++ b/src/components/game/SearchAutocomplete.tsx
@@ -0,0 +1,288 @@
+import { useState, useEffect, useRef, useCallback } from 'react';
+import { Input } from '@/components/ui/input';
+import { useDebounce } from '@/hooks/use-debounce';
+import { searchMovies } from '@/api/movies';
+import { searchPersons } from '@/api/persons';
+import { posterUrl, profileUrl, releaseYear } from '@/lib/tmdb';
+import type {
+ SearchMode,
+ TmdbMovieResult,
+ TmdbPersonResult,
+} from '@/types';
+import { Loader2 } from 'lucide-react';
+
+interface SearchAutocompleteProps {
+ mode: SearchMode;
+ onSelectActor: (person: TmdbPersonResult) => void;
+ onSelectMovie: (movie: TmdbMovieResult) => void;
+ disabled?: boolean;
+ placeholder?: string;
+}
+
+export default function SearchAutocomplete({
+ mode,
+ onSelectActor,
+ onSelectMovie,
+ disabled,
+ placeholder,
+}: SearchAutocompleteProps) {
+ const [query, setQuery] = useState('');
+ const [isOpen, setIsOpen] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+ const [searchError, setSearchError] = useState
(null);
+ const [movieResults, setMovieResults] = useState([]);
+ const [personResults, setPersonResults] = useState([]);
+ const [activeIndex, setActiveIndex] = useState(-1);
+ const debouncedQuery = useDebounce(query, 300);
+ const containerRef = useRef(null);
+ const inputRef = useRef(null);
+ const listId = `search-listbox-${mode}`;
+
+ const results = mode === 'actor' ? personResults : movieResults;
+
+ useEffect(() => {
+ if (!debouncedQuery || debouncedQuery.length < 2) {
+ setMovieResults([]);
+ setPersonResults([]);
+ setIsOpen(false);
+ return;
+ }
+
+ let cancelled = false;
+ setIsLoading(true);
+ setSearchError(null);
+
+ const doSearch = async () => {
+ try {
+ if (mode === 'actor') {
+ const data = await searchPersons(debouncedQuery);
+ if (!cancelled) {
+ setPersonResults(
+ data.results.filter(
+ (p) => p.known_for_department === 'Acting',
+ ),
+ );
+ setMovieResults([]);
+ setIsOpen(true);
+ setActiveIndex(-1);
+ }
+ } else {
+ const data = await searchMovies(debouncedQuery);
+ if (!cancelled) {
+ setMovieResults(data.results);
+ setPersonResults([]);
+ setIsOpen(true);
+ setActiveIndex(-1);
+ }
+ }
+ } catch {
+ if (!cancelled) {
+ setSearchError('Search failed. Please try again.');
+ setIsOpen(true);
+ }
+ } finally {
+ if (!cancelled) setIsLoading(false);
+ }
+ };
+
+ doSearch();
+ return () => {
+ cancelled = true;
+ };
+ }, [debouncedQuery, mode]);
+
+ // Reset when mode changes
+ useEffect(() => {
+ setQuery('');
+ setMovieResults([]);
+ setPersonResults([]);
+ setIsOpen(false);
+ setActiveIndex(-1);
+ // Auto-focus on mode change
+ inputRef.current?.focus();
+ }, [mode]);
+
+ // Close dropdown on click outside
+ useEffect(() => {
+ const handleClickOutside = (e: MouseEvent) => {
+ if (
+ containerRef.current &&
+ !containerRef.current.contains(e.target as Node)
+ ) {
+ setIsOpen(false);
+ }
+ };
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, []);
+
+ const handleSelectPerson = useCallback((person: TmdbPersonResult) => {
+ setQuery('');
+ setIsOpen(false);
+ setPersonResults([]);
+ setActiveIndex(-1);
+ onSelectActor(person);
+ }, [onSelectActor]);
+
+ const handleSelectMovie = useCallback((movie: TmdbMovieResult) => {
+ setQuery('');
+ setIsOpen(false);
+ setMovieResults([]);
+ setActiveIndex(-1);
+ onSelectMovie(movie);
+ }, [onSelectMovie]);
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (!isOpen || results.length === 0) return;
+
+ switch (e.key) {
+ case 'ArrowDown':
+ e.preventDefault();
+ setActiveIndex((prev) =>
+ prev < results.length - 1 ? prev + 1 : 0,
+ );
+ break;
+ case 'ArrowUp':
+ e.preventDefault();
+ setActiveIndex((prev) =>
+ prev > 0 ? prev - 1 : results.length - 1,
+ );
+ break;
+ case 'Enter':
+ e.preventDefault();
+ if (activeIndex >= 0 && activeIndex < results.length) {
+ if (mode === 'actor') {
+ handleSelectPerson(personResults[activeIndex]);
+ } else {
+ handleSelectMovie(movieResults[activeIndex]);
+ }
+ }
+ break;
+ case 'Escape':
+ setIsOpen(false);
+ setActiveIndex(-1);
+ break;
+ }
+ };
+
+ return (
+
+
+ setQuery(e.target.value)}
+ onKeyDown={handleKeyDown}
+ placeholder={placeholder || (mode === 'actor' ? 'Search for an actor...' : 'Search for a movie...')}
+ disabled={disabled}
+ onFocus={() => {
+ if (results.length > 0) {
+ setIsOpen(true);
+ }
+ }}
+ role="searchbox"
+ aria-autocomplete="list"
+ aria-controls={listId}
+ aria-activedescendant={
+ activeIndex >= 0 ? `${listId}-option-${activeIndex}` : undefined
+ }
+ aria-label={mode === 'actor' ? 'Search for an actor' : 'Search for a movie'}
+ />
+ {isLoading && (
+
+ )}
+
+
+ {isOpen && (
+
+ {mode === 'actor' &&
+ personResults.map((person, idx) => (
+
+ handleSelectPerson(person)}
+ tabIndex={-1}
+ >
+
+
+
{person.name}
+ {person.known_for.length > 0 && (
+
+ Known for:{' '}
+ {person.known_for
+ .slice(0, 2)
+ .map((m) => m.title)
+ .join(', ')}
+
+ )}
+
+
+
+ ))}
+
+ {mode === 'movie' &&
+ movieResults.map((movie, idx) => (
+
+ handleSelectMovie(movie)}
+ tabIndex={-1}
+ >
+
+
+
{movie.title}
+
+ {releaseYear(movie.release_date)}
+
+
+
+
+ ))}
+
+ {searchError && (
+
+
+ {searchError}
+
+
+ )}
+
+ {!isLoading &&
+ !searchError &&
+ results.length === 0 && (
+
+
+ No results found
+
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/src/components/game/SerpentineConnector.tsx b/src/components/game/SerpentineConnector.tsx
new file mode 100644
index 0000000..19db1f4
--- /dev/null
+++ b/src/components/game/SerpentineConnector.tsx
@@ -0,0 +1,20 @@
+interface SerpentineConnectorProps {
+ /** X% where the vertical line runs */
+ lineX: number;
+}
+
+/**
+ * Renders only the vertical portion of the connector in the gap between rows.
+ * The horizontal arm is rendered by the card row itself so it can align
+ * with the card's vertical center.
+ */
+export default function SerpentineConnector({ lineX }: SerpentineConnectorProps) {
+ return (
+
+ );
+}
diff --git a/src/components/game/ShareableResult.tsx b/src/components/game/ShareableResult.tsx
new file mode 100644
index 0000000..ca105a8
--- /dev/null
+++ b/src/components/game/ShareableResult.tsx
@@ -0,0 +1,90 @@
+import { useState } from 'react';
+import { Button } from '@/components/ui/button';
+import type { ScoreBreakdown, ChainLink } from '@/types';
+import { getLinkCount } from '@/types';
+import { Share2, Check, Copy } from 'lucide-react';
+
+interface ShareableResultProps {
+ score: ScoreBreakdown;
+ chain: ChainLink[];
+ movieATitle: string;
+ movieBTitle: string;
+}
+
+function generateShareText(props: ShareableResultProps): string {
+ const { score, chain, movieATitle, movieBTitle } = props;
+ const movieCount = chain.filter((l) => l.type === 'movie').length;
+ const actorCount = chain.filter((l) => l.type === 'actor').length;
+ const minutes = Math.floor(score.elapsedSeconds / 60);
+ const seconds = score.elapsedSeconds % 60;
+
+ const lines = [
+ 'You Know Who Else Was In That Movie?',
+ '',
+ `${movieATitle} <-> ${movieBTitle}`,
+ `Score: ${score.totalScore.toLocaleString()}`,
+ `Chain: ${getLinkCount(chain)} links (${actorCount} actors, ${movieCount} movies)`,
+ `Time: ${minutes}:${seconds.toString().padStart(2, '0')}`,
+ '',
+ // Visual chain representation
+ chain
+ .map((link) => (link.type === 'movie' ? '\u{1F3AC}' : '\u{1F9D1}'))
+ .join(' \u2192 '),
+ '',
+ 'Play at youknowwhoelse.com',
+ ];
+
+ return lines.join('\n');
+}
+
+export default function ShareableResult(props: ShareableResultProps) {
+ const [copied, setCopied] = useState(false);
+
+ const handleCopy = async () => {
+ const text = generateShareText(props);
+ try {
+ await navigator.clipboard.writeText(text);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch {
+ // Clipboard API not available
+ }
+ };
+
+ const handleShare = async () => {
+ const text = generateShareText(props);
+ if (navigator.share) {
+ try {
+ await navigator.share({ text });
+ } catch {
+ // User cancelled share
+ }
+ } else {
+ handleCopy();
+ }
+ };
+
+ return (
+
+
+ {copied ? (
+ <>
+
+ Copied!
+ >
+ ) : (
+ <>
+
+ Copy Result
+ >
+ )}
+
+ {typeof navigator.share === 'function' && (
+
+
+ Share
+
+ )}
+
+ );
+}
diff --git a/src/components/game/SoundToggle.tsx b/src/components/game/SoundToggle.tsx
new file mode 100644
index 0000000..b1cc424
--- /dev/null
+++ b/src/components/game/SoundToggle.tsx
@@ -0,0 +1,29 @@
+import { useState } from 'react';
+import { isSoundEnabled, toggleSound } from '@/lib/sounds';
+import { Volume2, VolumeX } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+
+export default function SoundToggle() {
+ const [enabled, setEnabled] = useState(isSoundEnabled);
+
+ const handleToggle = () => {
+ const next = toggleSound();
+ setEnabled(next);
+ };
+
+ return (
+
+ {enabled ? (
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/src/components/game/Timer.tsx b/src/components/game/Timer.tsx
new file mode 100644
index 0000000..6d0c60f
--- /dev/null
+++ b/src/components/game/Timer.tsx
@@ -0,0 +1,35 @@
+import { useGameStore } from '@/stores/game-store';
+import { useTimer, type BonusTier } from '@/hooks/use-timer';
+import { Timer as TimerIcon } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+const tierColors: Record = {
+ high: 'text-green-600',
+ medium: 'text-yellow-600',
+ low: 'text-orange-500',
+ none: 'text-red-500',
+};
+
+const tierLabels: Record = {
+ high: '+500',
+ medium: '+300',
+ low: '+100',
+ none: '+0',
+};
+
+export default function Timer() {
+ const startTime = useGameStore((s) => s.startTime);
+ const status = useGameStore((s) => s.status);
+ const { formattedTime, bonusTier } = useTimer(
+ startTime,
+ status === 'playing',
+ );
+
+ return (
+
+
+ {formattedTime}
+ ({tierLabels[bonusTier]})
+
+ );
+}
diff --git a/src/components/game/ValidationFeedback.tsx b/src/components/game/ValidationFeedback.tsx
new file mode 100644
index 0000000..68d4855
--- /dev/null
+++ b/src/components/game/ValidationFeedback.tsx
@@ -0,0 +1,51 @@
+import { useGameStore } from '@/stores/game-store';
+import { Loader2, AlertCircle, CheckCircle2 } from 'lucide-react';
+import { useEffect, useState } from 'react';
+
+export default function ValidationFeedback() {
+ const isValidating = useGameStore((s) => s.isValidating);
+ const validationError = useGameStore((s) => s.validationError);
+ const chain = useGameStore((s) => s.chain);
+ const [showCheck, setShowCheck] = useState(false);
+
+ // Flash a green check when chain grows (successful validation)
+ useEffect(() => {
+ if (chain.length > 1) {
+ setShowCheck(true);
+ const timer = setTimeout(() => setShowCheck(false), 600);
+ return () => clearTimeout(timer);
+ }
+ }, [chain.length]);
+
+ if (isValidating) {
+ return (
+
+
+ Validating connection...
+
+ );
+ }
+
+ if (validationError) {
+ return (
+
+ );
+ }
+
+ if (showCheck) {
+ return (
+
+
+ Connected!
+
+ );
+ }
+
+ return null;
+}
diff --git a/src/components/layout/NotificationBell.tsx b/src/components/layout/NotificationBell.tsx
new file mode 100644
index 0000000..c9b8026
--- /dev/null
+++ b/src/components/layout/NotificationBell.tsx
@@ -0,0 +1,159 @@
+import { useEffect, useRef, useState } from 'react';
+import { useNavigate } from 'react-router';
+import { useNotificationStore } from '@/stores/notification-store';
+import { Bell, Check, CheckCheck, Trash2, X } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+function timeAgo(dateStr: string) {
+ const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
+ if (seconds < 60) return 'just now';
+ const minutes = Math.floor(seconds / 60);
+ if (minutes < 60) return `${minutes}m ago`;
+ const hours = Math.floor(minutes / 60);
+ if (hours < 24) return `${hours}h ago`;
+ const days = Math.floor(hours / 24);
+ return `${days}d ago`;
+}
+
+export default function NotificationBell() {
+ const [open, setOpen] = useState(false);
+ const ref = useRef(null);
+ const navigate = useNavigate();
+ const notifications = useNotificationStore((s) => s.notifications);
+ const unreadCount = useNotificationStore((s) => s.unreadCount);
+ const fetchNotifications = useNotificationStore((s) => s.fetchNotifications);
+ const markRead = useNotificationStore((s) => s.markRead);
+ const markAllRead = useNotificationStore((s) => s.markAllRead);
+ const dismiss = useNotificationStore((s) => s.dismiss);
+
+ // Close on outside click
+ useEffect(() => {
+ function handleClickOutside(e: MouseEvent) {
+ if (ref.current && !ref.current.contains(e.target as Node)) {
+ setOpen(false);
+ }
+ }
+ if (open) {
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }
+ }, [open]);
+
+ const handleToggle = () => {
+ if (!open) {
+ fetchNotifications();
+ }
+ setOpen(!open);
+ };
+
+ const handleNotificationClick = (notification: (typeof notifications)[0]) => {
+ if (!notification.read) {
+ markRead(notification.id);
+ }
+ // Navigate to leaderboard if it's a versus attempt notification
+ if (notification.data?.matchId) {
+ navigate(`/versus/async/${notification.data.matchId}/leaderboard`);
+ setOpen(false);
+ }
+ };
+
+ return (
+
+
+
+ {unreadCount > 0 && (
+
+ {unreadCount > 99 ? '99+' : unreadCount}
+
+ )}
+
+
+ {open && (
+
+
+
Notifications
+
+ {unreadCount > 0 && (
+
+
+
+ )}
+ setOpen(false)}
+ className="rounded p-1 text-muted-foreground hover:text-foreground"
+ >
+
+
+
+
+
+
+ {notifications.length === 0 ? (
+
+ No notifications
+
+ ) : (
+ notifications.map((n) => (
+
handleNotificationClick(n)}
+ className={cn(
+ 'flex cursor-pointer items-start gap-2 border-b border-border px-3 py-2.5 transition-colors last:border-0 hover:bg-muted/50',
+ !n.read && 'bg-primary/5',
+ )}
+ >
+
+
+ {!n.read && (
+
+ )}
+
{n.title}
+
+
+ {n.message}
+
+
+ {timeAgo(n.createdAt)}
+
+
+
+ {!n.read && (
+ {
+ e.stopPropagation();
+ markRead(n.id);
+ }}
+ className="rounded p-1 text-muted-foreground hover:text-foreground"
+ title="Mark as read"
+ >
+
+
+ )}
+ {
+ e.stopPropagation();
+ dismiss(n.id);
+ }}
+ className="rounded p-1 text-muted-foreground hover:text-destructive"
+ title="Dismiss"
+ >
+
+
+
+
+ ))
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/src/components/layout/PageLayout.tsx b/src/components/layout/PageLayout.tsx
new file mode 100644
index 0000000..825c4c1
--- /dev/null
+++ b/src/components/layout/PageLayout.tsx
@@ -0,0 +1,80 @@
+import { Link, useLocation } from 'react-router';
+import { cn } from '@/lib/utils';
+import { useAuthStore } from '@/stores/auth-store';
+import SoundToggle from '@/components/game/SoundToggle';
+import ThemeToggle from '@/components/layout/ThemeToggle';
+import NotificationBell from '@/components/layout/NotificationBell';
+import { User } from 'lucide-react';
+
+const NAV_LINKS = [
+ { path: '/', label: 'Home' },
+ { path: '/daily', label: 'Daily' },
+ { path: '/versus', label: 'Versus' },
+ { path: '/leaderboard', label: 'Ranks' },
+] as const;
+
+export default function PageLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const location = useLocation();
+ const user = useAuthStore((s) => s.user);
+
+ return (
+
+ );
+}
diff --git a/src/components/layout/ThemeToggle.tsx b/src/components/layout/ThemeToggle.tsx
new file mode 100644
index 0000000..9cc5b8a
--- /dev/null
+++ b/src/components/layout/ThemeToggle.tsx
@@ -0,0 +1,33 @@
+import { useEffect, useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { Moon, Sun } from 'lucide-react';
+
+function getInitialTheme(): 'dark' | 'light' {
+ const stored = localStorage.getItem('theme');
+ if (stored === 'light' || stored === 'dark') return stored;
+ return 'dark';
+}
+
+export default function ThemeToggle() {
+ const [theme, setTheme] = useState<'dark' | 'light'>(getInitialTheme);
+
+ useEffect(() => {
+ document.documentElement.classList.toggle('dark', theme === 'dark');
+ localStorage.setItem('theme', theme);
+ }, [theme]);
+
+ return (
+ setTheme(theme === 'dark' ? 'light' : 'dark')}
+ aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
+ >
+ {theme === 'dark' ? (
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx
new file mode 100644
index 0000000..1fe3176
--- /dev/null
+++ b/src/components/ui/alert.tsx
@@ -0,0 +1,76 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const alertVariants = cva(
+ "group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
+ {
+ variants: {
+ variant: {
+ default: "bg-card text-card-foreground",
+ destructive:
+ "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function Alert({
+ className,
+ variant,
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ )
+}
+
+function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function AlertDescription({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export { Alert, AlertTitle, AlertDescription, AlertAction }
diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx
new file mode 100644
index 0000000..b20959d
--- /dev/null
+++ b/src/components/ui/badge.tsx
@@ -0,0 +1,52 @@
+import { mergeProps } from "@base-ui/react/merge-props"
+import { useRender } from "@base-ui/react/use-render"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const badgeVariants = cva(
+ "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
+ secondary:
+ "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
+ destructive:
+ "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
+ outline:
+ "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
+ ghost:
+ "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function Badge({
+ className,
+ variant = "default",
+ render,
+ ...props
+}: useRender.ComponentProps<"span"> & VariantProps
) {
+ return useRender({
+ defaultTagName: "span",
+ props: mergeProps<"span">(
+ {
+ className: cn(badgeVariants({ variant }), className),
+ },
+ props
+ ),
+ render,
+ state: {
+ slot: "badge",
+ variant,
+ },
+ })
+}
+
+export { Badge, badgeVariants }
diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx
new file mode 100644
index 0000000..9bd5a25
--- /dev/null
+++ b/src/components/ui/card.tsx
@@ -0,0 +1,103 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Card({
+ className,
+ size = "default",
+ ...props
+}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
+ return (
+ img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+}
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..807e1fa
--- /dev/null
+++ b/src/components/ui/dialog.tsx
@@ -0,0 +1,157 @@
+"use client"
+
+import * as React from "react"
+import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { XIcon } from "lucide-react"
+
+function Dialog({ ...props }: DialogPrimitive.Root.Props) {
+ return
+}
+
+function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
+ return
+}
+
+function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
+ return
+}
+
+function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
+ return
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: DialogPrimitive.Backdrop.Props) {
+ return (
+
+ )
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: DialogPrimitive.Popup.Props & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+ }
+ >
+
+ Close
+
+ )}
+
+
+ )
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function DialogFooter({
+ className,
+ showCloseButton = false,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+ {children}
+ {showCloseButton && (
+ }>
+ Close
+
+ )}
+
+ )
+}
+
+function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
+ return (
+
+ )
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: DialogPrimitive.Description.Props) {
+ return (
+
+ )
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+}
diff --git a/src/components/ui/difficulty-badge.tsx b/src/components/ui/difficulty-badge.tsx
new file mode 100644
index 0000000..8aa670a
--- /dev/null
+++ b/src/components/ui/difficulty-badge.tsx
@@ -0,0 +1,44 @@
+import { cn } from '@/lib/utils';
+
+type Difficulty = 'easy' | 'medium' | 'hard';
+
+const DIFFICULTY_STYLES: Record
= {
+ easy: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300',
+ medium: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
+ hard: 'bg-rose-100 text-rose-800 dark:bg-rose-900/40 dark:text-rose-300',
+};
+
+const DIFFICULTY_BUTTON_ACTIVE: Record = {
+ easy: 'border-emerald-500 bg-emerald-100 text-emerald-800 dark:border-emerald-500 dark:bg-emerald-900/40 dark:text-emerald-300',
+ medium: 'border-amber-500 bg-amber-100 text-amber-800 dark:border-amber-500 dark:bg-amber-900/40 dark:text-amber-300',
+ hard: 'border-rose-500 bg-rose-100 text-rose-800 dark:border-rose-500 dark:bg-rose-900/40 dark:text-rose-300',
+};
+
+export function getDifficultyButtonClass(difficulty: Difficulty, isActive: boolean): string {
+ if (!isActive) return '';
+ return DIFFICULTY_BUTTON_ACTIVE[difficulty];
+}
+
+export function DifficultyBadge({
+ difficulty,
+ className,
+}: {
+ difficulty: string;
+ className?: string;
+}) {
+ const level = (difficulty as Difficulty) in DIFFICULTY_STYLES
+ ? (difficulty as Difficulty)
+ : 'medium';
+
+ return (
+
+ {difficulty}
+
+ );
+}
diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx
new file mode 100644
index 0000000..7d21bab
--- /dev/null
+++ b/src/components/ui/input.tsx
@@ -0,0 +1,20 @@
+import * as React from "react"
+import { Input as InputPrimitive } from "@base-ui/react/input"
+
+import { cn } from "@/lib/utils"
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ )
+}
+
+export { Input }
diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx
new file mode 100644
index 0000000..72000e5
--- /dev/null
+++ b/src/components/ui/scroll-area.tsx
@@ -0,0 +1,54 @@
+"use client"
+
+import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
+
+import { cn } from "@/lib/utils"
+
+function ScrollArea({
+ className,
+ children,
+ ...props
+}: ScrollAreaPrimitive.Root.Props) {
+ return (
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function ScrollBar({
+ className,
+ orientation = "vertical",
+ ...props
+}: ScrollAreaPrimitive.Scrollbar.Props) {
+ return (
+
+
+
+ )
+}
+
+export { ScrollArea, ScrollBar }
diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx
new file mode 100644
index 0000000..4f65961
--- /dev/null
+++ b/src/components/ui/separator.tsx
@@ -0,0 +1,23 @@
+import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
+
+import { cn } from "@/lib/utils"
+
+function Separator({
+ className,
+ orientation = "horizontal",
+ ...props
+}: SeparatorPrimitive.Props) {
+ return (
+
+ )
+}
+
+export { Separator }
diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx
new file mode 100644
index 0000000..0118624
--- /dev/null
+++ b/src/components/ui/skeleton.tsx
@@ -0,0 +1,13 @@
+import { cn } from "@/lib/utils"
+
+function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export { Skeleton }
diff --git a/src/components/versus/AsyncCompletionModal.tsx b/src/components/versus/AsyncCompletionModal.tsx
new file mode 100644
index 0000000..08ee4ff
--- /dev/null
+++ b/src/components/versus/AsyncCompletionModal.tsx
@@ -0,0 +1,146 @@
+import { useNavigate } from 'react-router';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Trophy } from 'lucide-react';
+import type { ScoreBreakdown } from '@/types';
+
+interface AsyncCompletionModalProps {
+ open: boolean;
+ matchId: string;
+ mode: 'creator' | 'challenger';
+ myScore: ScoreBreakdown | null;
+ creatorScore?: { totalScore: number } | null;
+ creatorChainLength?: number | null;
+ myChainLength: number;
+ rank?: number;
+}
+
+export default function AsyncCompletionModal({
+ open,
+ matchId,
+ mode,
+ myScore,
+ creatorScore,
+ creatorChainLength,
+ myChainLength,
+ rank,
+}: AsyncCompletionModalProps) {
+ const navigate = useNavigate();
+
+ if (!open || !myScore) return null;
+
+ const myTotal = myScore.totalScore ?? 0;
+
+ if (mode === 'creator') {
+ return (
+
+
+
+
+
+ Score Submitted!
+
+
+
+
+
{myTotal}
+
+ Your score · {myChainLength} links
+
+
+
+ Your match is now open for challengers!
+
+
+ navigate('/versus')}
+ >
+ Back to Versus
+
+ navigate(`/versus/async/${matchId}/leaderboard`)}
+ >
+ View Leaderboard
+
+
+
+
+
+ );
+ }
+
+ // Challenger mode
+ const creatorTotal = creatorScore?.totalScore ?? 0;
+ const won = myTotal > creatorTotal;
+ const tied = myTotal === creatorTotal;
+
+ return (
+
+
+
+
+
+ Challenge Complete!
+
+
+
+
+ {tied ? (
+
It's a tie!
+ ) : won ? (
+
You beat the creator!
+ ) : (
+
Creator wins!
+ )}
+
+
+
+
+
You
+
{myTotal}
+
{myChainLength} links
+
+
vs
+
+
Creator
+
{creatorTotal}
+
+ {creatorChainLength ?? '?'} links
+
+
+
+
+ {rank && (
+
+ Your rank: #{rank}
+
+ )}
+
+
+ navigate('/versus')}
+ >
+ Back to Versus
+
+ navigate(`/versus/async/${matchId}/leaderboard`)}
+ >
+ Full Leaderboard
+
+
+
+
+
+ );
+}
diff --git a/src/components/versus/OpponentProgress.tsx b/src/components/versus/OpponentProgress.tsx
new file mode 100644
index 0000000..883367f
--- /dev/null
+++ b/src/components/versus/OpponentProgress.tsx
@@ -0,0 +1,41 @@
+import { useVersusStore } from '@/stores/versus-store';
+import { useAuthStore } from '@/stores/auth-store';
+import { rawToLinkCount } from '@/types';
+import { User, CheckCircle } from 'lucide-react';
+
+export default function OpponentProgress() {
+ const user = useAuthStore((s) => s.user);
+ const player1 = useVersusStore((s) => s.player1);
+ const player2 = useVersusStore((s) => s.player2);
+ const opponentChainLength = useVersusStore((s) => s.opponentChainLength);
+ const opponentFinished = useVersusStore((s) => s.opponentFinished);
+
+ const opponent = user?.id === player1?.id ? player2 : player1;
+ if (!opponent) return null;
+
+ return (
+
+
+
+
+ {opponent.username}
+
+ {opponentFinished ? (
+
+
+ Finished!
+
+ ) : (
+
+ Chain: {rawToLinkCount(opponentChainLength)} links
+
+ )}
+
+ {opponentFinished && (
+
+ Your opponent has finished — hurry up!
+
+ )}
+
+ );
+}
diff --git a/src/components/versus/VersusCompletionModal.tsx b/src/components/versus/VersusCompletionModal.tsx
new file mode 100644
index 0000000..049bd2d
--- /dev/null
+++ b/src/components/versus/VersusCompletionModal.tsx
@@ -0,0 +1,108 @@
+import { useNavigate } from 'react-router';
+import { useVersusStore } from '@/stores/versus-store';
+import { useAuthStore } from '@/stores/auth-store';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Trophy, Loader2 } from 'lucide-react';
+
+export default function VersusCompletionModal() {
+ const navigate = useNavigate();
+ const user = useAuthStore((s) => s.user);
+ const lobbyState = useVersusStore((s) => s.lobbyState);
+ const matchResult = useVersusStore((s) => s.matchResult);
+ const myScore = useVersusStore((s) => s.myScore);
+ const reset = useVersusStore((s) => s.reset);
+
+ const iAmDone = !!myScore;
+ const isOpen = iAmDone;
+ if (!isOpen) return null;
+
+ const waitingForOpponent = iAmDone && lobbyState !== 'finished';
+ const myTotal = (myScore as any)?.totalScore ?? 0;
+
+ const handleBackToVersus = () => {
+ reset();
+ navigate('/versus');
+ };
+
+ return (
+
+
+ {waitingForOpponent ? (
+ <>
+
+ You finished!
+
+
+
{myTotal}
+
Your score
+
+
+ Waiting for opponent to finish...
+
+
+ >
+ ) : matchResult ? (
+ <>
+
+
+
+ Match Complete!
+
+
+
+
+
+ Back to Versus
+
+
+ >
+ ) : null}
+
+
+ );
+}
+
+function MatchResultDisplay({
+ matchResult,
+ userId,
+}: {
+ matchResult: NonNullable['matchResult']>;
+ userId?: string;
+}) {
+ const p1Score = (matchResult.player1Score as any)?.totalScore ?? 0;
+ const p2Score = (matchResult.player2Score as any)?.totalScore ?? 0;
+ const isWinner = matchResult.winnerId === userId;
+ const isTie = matchResult.winnerId === null;
+
+ return (
+
+
+ {isTie ? (
+
It's a tie!
+ ) : isWinner ? (
+
You win!
+ ) : (
+
You lose!
+ )}
+
+
+
+
+
{matchResult.player1.username}
+
{p1Score}
+
+
vs
+
+
{matchResult.player2.username}
+
{p2Score}
+
+
+
+ );
+}
diff --git a/src/hooks/use-chain-validation.ts b/src/hooks/use-chain-validation.ts
new file mode 100644
index 0000000..956a6bf
--- /dev/null
+++ b/src/hooks/use-chain-validation.ts
@@ -0,0 +1,164 @@
+import { useCallback } from 'react';
+import { useGameStore } from '@/stores/game-store';
+import { getMovieCredits } from '@/api/movies';
+import { getPersonMovieCredits } from '@/api/persons';
+import { playSound } from '@/lib/sounds';
+import type {
+ ActorChainLink,
+ MovieChainLink,
+ TmdbPersonResult,
+ TmdbMovieResult,
+} from '@/types';
+
+export function useChainValidation() {
+ const {
+ chain,
+ addActorToChain,
+ addMovieToChain,
+ setValidating,
+ setValidationError,
+ } = useGameStore();
+
+ const validateAndAddActor = useCallback(
+ async (person: TmdbPersonResult) => {
+ const lastLink = chain[chain.length - 1];
+ if (lastLink.type !== 'movie') {
+ setValidationError('Expected a movie as the last chain link.');
+ playSound('invalid');
+ return;
+ }
+
+ // Check if actor is already in the chain
+ if (chain.some((link) => link.type === 'actor' && link.id === person.id)) {
+ setValidationError(`${person.name} is already in your chain.`);
+ playSound('invalid');
+ return;
+ }
+
+ setValidating(true);
+ setValidationError(null);
+
+ try {
+ const credits = await getMovieCredits(lastLink.id);
+ const isInCast = credits.cast.some(
+ (member) => member.id === person.id,
+ );
+
+ if (!isInCast) {
+ setValidationError(
+ `${person.name} was not in "${lastLink.title}".`,
+ );
+ playSound('invalid');
+ setValidating(false);
+ return;
+ }
+
+ const actorLink: ActorChainLink = {
+ type: 'actor',
+ id: person.id,
+ name: person.name,
+ profilePath: person.profile_path,
+ popularity: person.popularity,
+ };
+
+ addActorToChain(actorLink);
+ playSound('valid');
+ } catch (error: unknown) {
+ const isNetwork =
+ error instanceof Error && error.message === 'Network Error';
+ setValidationError(
+ isNetwork
+ ? 'Network error — check your connection and try again.'
+ : 'Failed to validate. Please try again.',
+ );
+ playSound('invalid');
+ } finally {
+ setValidating(false);
+ }
+ },
+ [chain, addActorToChain, setValidating, setValidationError],
+ );
+
+ const validateAndAddMovie = useCallback(
+ async (movie: TmdbMovieResult) => {
+ const lastLink = chain[chain.length - 1];
+ if (lastLink.type !== 'actor') {
+ setValidationError('Expected an actor as the last chain link.');
+ playSound('invalid');
+ return;
+ }
+
+ // Check if movie is already in the chain (except movieA for loop closure)
+ const movieA = useGameStore.getState().movieA;
+ const movieB = useGameStore.getState().movieB;
+ const passedMovieB = useGameStore.getState().passedMovieB;
+
+ // Allow movieA only if we've passed movieB (closing the loop)
+ const isMovieA = movie.id === movieA?.id;
+ const isMovieB = movie.id === movieB?.id;
+
+ if (
+ !isMovieA &&
+ !isMovieB &&
+ chain.some((link) => link.type === 'movie' && link.id === movie.id)
+ ) {
+ setValidationError(`"${movie.title}" is already in your chain.`);
+ playSound('invalid');
+ return;
+ }
+
+ if (isMovieA && !passedMovieB) {
+ setValidationError(
+ `You need to pass through "${movieB?.title}" before returning to "${movieA?.title}".`,
+ );
+ playSound('invalid');
+ return;
+ }
+
+ setValidating(true);
+ setValidationError(null);
+
+ try {
+ const credits = await getPersonMovieCredits(lastLink.id);
+ const isInFilmography = credits.cast.some(
+ (credit) => credit.id === movie.id,
+ );
+
+ if (!isInFilmography) {
+ setValidationError(
+ `${lastLink.name} was not in "${movie.title}".`,
+ );
+ playSound('invalid');
+ setValidating(false);
+ return;
+ }
+
+ const movieLink: MovieChainLink = {
+ type: 'movie',
+ id: movie.id,
+ title: movie.title,
+ posterPath: movie.poster_path,
+ releaseDate: movie.release_date,
+ popularity: movie.popularity,
+ };
+
+ addMovieToChain(movieLink);
+ playSound('valid');
+ } catch (error: unknown) {
+ const isNetwork =
+ error instanceof Error && error.message === 'Network Error';
+ setValidationError(
+ isNetwork
+ ? 'Network error — check your connection and try again.'
+ : 'Failed to validate. Please try again.',
+ );
+ playSound('invalid');
+ } finally {
+ setValidating(false);
+ }
+ },
+ [chain, addMovieToChain, setValidating, setValidationError],
+ );
+
+ return { validateAndAddActor, validateAndAddMovie };
+}
diff --git a/src/hooks/use-debounce.ts b/src/hooks/use-debounce.ts
new file mode 100644
index 0000000..01cac76
--- /dev/null
+++ b/src/hooks/use-debounce.ts
@@ -0,0 +1,12 @@
+import { useState, useEffect } from 'react';
+
+export function useDebounce(value: T, delayMs = 300): T {
+ const [debouncedValue, setDebouncedValue] = useState(value);
+
+ useEffect(() => {
+ const timer = setTimeout(() => setDebouncedValue(value), delayMs);
+ return () => clearTimeout(timer);
+ }, [value, delayMs]);
+
+ return debouncedValue;
+}
diff --git a/src/hooks/use-hints.ts b/src/hooks/use-hints.ts
new file mode 100644
index 0000000..e464f82
--- /dev/null
+++ b/src/hooks/use-hints.ts
@@ -0,0 +1,65 @@
+import { useState, useCallback } from 'react';
+import { useGameStore } from '@/stores/game-store';
+import { getMovieCredits } from '@/api/movies';
+import type { TmdbCastMember } from '@/types';
+
+const MAX_HINTS = 3;
+const HINTS_PER_REQUEST = 3;
+
+export function useHints() {
+ const [hints, setHints] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+
+ const chain = useGameStore((s) => s.chain);
+ const currentSearchMode = useGameStore((s) => s.currentSearchMode);
+ const hintsUsed = useGameStore((s) => s.hintsUsed);
+ const incrementHintsUsed = useGameStore((s) => s.incrementHintsUsed);
+ const status = useGameStore((s) => s.status);
+
+ // Can only hint when looking for an actor after a movie
+ const lastLink = chain[chain.length - 1];
+ const canHint =
+ status === 'playing' &&
+ currentSearchMode === 'actor' &&
+ lastLink?.type === 'movie' &&
+ hintsUsed < MAX_HINTS &&
+ !isLoading;
+
+ const getHint = useCallback(async () => {
+ if (!canHint || lastLink?.type !== 'movie') return;
+
+ setIsLoading(true);
+ try {
+ const credits = await getMovieCredits(lastLink.id);
+
+ // Filter out actors already in the chain
+ const chainActorIds = new Set(
+ chain.filter((l) => l.type === 'actor').map((l) => l.id),
+ );
+ const available = credits.cast.filter(
+ (member) => !chainActorIds.has(member.id),
+ );
+
+ // Pick random actors from available cast
+ const shuffled = [...available].sort(() => Math.random() - 0.5);
+ setHints(shuffled.slice(0, HINTS_PER_REQUEST));
+ incrementHintsUsed();
+ } catch {
+ // silently fail
+ } finally {
+ setIsLoading(false);
+ }
+ }, [canHint, lastLink, chain, incrementHintsUsed]);
+
+ const clearHints = useCallback(() => setHints([]), []);
+
+ return {
+ hints,
+ isLoading,
+ getHint,
+ clearHints,
+ canHint,
+ hintsUsed,
+ maxHints: MAX_HINTS,
+ };
+}
diff --git a/src/hooks/use-timer.ts b/src/hooks/use-timer.ts
new file mode 100644
index 0000000..8cc1c09
--- /dev/null
+++ b/src/hooks/use-timer.ts
@@ -0,0 +1,39 @@
+import { useState, useEffect } from 'react';
+
+export type BonusTier = 'high' | 'medium' | 'low' | 'none';
+
+function getBonusTier(seconds: number): BonusTier {
+ if (seconds < 60) return 'high';
+ if (seconds < 180) return 'medium';
+ if (seconds < 300) return 'low';
+ return 'none';
+}
+
+function formatTime(seconds: number): string {
+ const m = Math.floor(seconds / 60);
+ const s = seconds % 60;
+ return `${m}:${s.toString().padStart(2, '0')}`;
+}
+
+export function useTimer(startTime: number | null, isRunning: boolean) {
+ const [elapsedSeconds, setElapsedSeconds] = useState(0);
+
+ useEffect(() => {
+ if (!startTime || !isRunning) return;
+
+ // Set initial value
+ setElapsedSeconds(Math.floor((Date.now() - startTime) / 1000));
+
+ const interval = setInterval(() => {
+ setElapsedSeconds(Math.floor((Date.now() - startTime) / 1000));
+ }, 1000);
+
+ return () => clearInterval(interval);
+ }, [startTime, isRunning]);
+
+ return {
+ elapsedSeconds,
+ formattedTime: formatTime(elapsedSeconds),
+ bonusTier: getBonusTier(elapsedSeconds),
+ };
+}
diff --git a/src/index.css b/src/index.css
index 223c38c..9d6166c 100644
--- a/src/index.css
+++ b/src/index.css
@@ -5,73 +5,75 @@
@custom-variant dark (&:is(.dark *));
+/* Light theme — soft warm gray with indigo accent */
:root {
- --background: oklch(1 0 0);
- --foreground: oklch(0.145 0 0);
- --card: oklch(1 0 0);
- --card-foreground: oklch(0.145 0 0);
- --popover: oklch(1 0 0);
- --popover-foreground: oklch(0.145 0 0);
- --primary: oklch(0.205 0 0);
+ --background: oklch(0.975 0.005 260);
+ --foreground: oklch(0.175 0.015 260);
+ --card: oklch(0.995 0.002 260);
+ --card-foreground: oklch(0.175 0.015 260);
+ --popover: oklch(0.995 0.002 260);
+ --popover-foreground: oklch(0.175 0.015 260);
+ --primary: oklch(0.51 0.17 265);
--primary-foreground: oklch(0.985 0 0);
- --secondary: oklch(0.97 0 0);
- --secondary-foreground: oklch(0.205 0 0);
- --muted: oklch(0.97 0 0);
- --muted-foreground: oklch(0.556 0 0);
- --accent: oklch(0.97 0 0);
- --accent-foreground: oklch(0.205 0 0);
+ --secondary: oklch(0.945 0.01 260);
+ --secondary-foreground: oklch(0.25 0.02 260);
+ --muted: oklch(0.945 0.01 260);
+ --muted-foreground: oklch(0.52 0.015 260);
+ --accent: oklch(0.935 0.015 260);
+ --accent-foreground: oklch(0.25 0.02 260);
--destructive: oklch(0.58 0.22 27);
- --border: oklch(0.922 0 0);
- --input: oklch(0.922 0 0);
- --ring: oklch(0.708 0 0);
- --chart-1: oklch(0.809 0.105 251.813);
- --chart-2: oklch(0.623 0.214 259.815);
- --chart-3: oklch(0.546 0.245 262.881);
- --chart-4: oklch(0.488 0.243 264.376);
- --chart-5: oklch(0.424 0.199 265.638);
+ --border: oklch(0.905 0.01 260);
+ --input: oklch(0.905 0.01 260);
+ --ring: oklch(0.51 0.17 265);
+ --chart-1: oklch(0.65 0.18 265);
+ --chart-2: oklch(0.60 0.16 340);
+ --chart-3: oklch(0.70 0.14 150);
+ --chart-4: oklch(0.65 0.15 45);
+ --chart-5: oklch(0.55 0.20 265);
--radius: 0.625rem;
- --sidebar: oklch(0.985 0 0);
- --sidebar-foreground: oklch(0.145 0 0);
- --sidebar-primary: oklch(0.205 0 0);
+ --sidebar: oklch(0.97 0.005 260);
+ --sidebar-foreground: oklch(0.175 0.015 260);
+ --sidebar-primary: oklch(0.51 0.17 265);
--sidebar-primary-foreground: oklch(0.985 0 0);
- --sidebar-accent: oklch(0.97 0 0);
- --sidebar-accent-foreground: oklch(0.205 0 0);
- --sidebar-border: oklch(0.922 0 0);
- --sidebar-ring: oklch(0.708 0 0);
+ --sidebar-accent: oklch(0.94 0.01 260);
+ --sidebar-accent-foreground: oklch(0.25 0.02 260);
+ --sidebar-border: oklch(0.905 0.01 260);
+ --sidebar-ring: oklch(0.51 0.17 265);
}
+/* Dark theme — deep slate with warm amber accent */
.dark {
- --background: oklch(0.145 0 0);
- --foreground: oklch(0.985 0 0);
- --card: oklch(0.205 0 0);
- --card-foreground: oklch(0.985 0 0);
- --popover: oklch(0.205 0 0);
- --popover-foreground: oklch(0.985 0 0);
- --primary: oklch(0.87 0.00 0);
- --primary-foreground: oklch(0.205 0 0);
- --secondary: oklch(0.269 0 0);
- --secondary-foreground: oklch(0.985 0 0);
- --muted: oklch(0.269 0 0);
- --muted-foreground: oklch(0.708 0 0);
- --accent: oklch(0.371 0 0);
- --accent-foreground: oklch(0.985 0 0);
+ --background: oklch(0.155 0.015 265);
+ --foreground: oklch(0.935 0.01 260);
+ --card: oklch(0.195 0.015 265);
+ --card-foreground: oklch(0.935 0.01 260);
+ --popover: oklch(0.195 0.015 265);
+ --popover-foreground: oklch(0.935 0.01 260);
+ --primary: oklch(0.72 0.15 75);
+ --primary-foreground: oklch(0.16 0.02 265);
+ --secondary: oklch(0.24 0.015 265);
+ --secondary-foreground: oklch(0.90 0.01 260);
+ --muted: oklch(0.24 0.015 265);
+ --muted-foreground: oklch(0.62 0.01 260);
+ --accent: oklch(0.28 0.015 265);
+ --accent-foreground: oklch(0.93 0.01 260);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
- --input: oklch(1 0 0 / 15%);
- --ring: oklch(0.556 0 0);
- --chart-1: oklch(0.809 0.105 251.813);
- --chart-2: oklch(0.623 0.214 259.815);
- --chart-3: oklch(0.546 0.245 262.881);
- --chart-4: oklch(0.488 0.243 264.376);
- --chart-5: oklch(0.424 0.199 265.638);
- --sidebar: oklch(0.205 0 0);
- --sidebar-foreground: oklch(0.985 0 0);
- --sidebar-primary: oklch(0.488 0.243 264.376);
- --sidebar-primary-foreground: oklch(0.985 0 0);
- --sidebar-accent: oklch(0.269 0 0);
- --sidebar-accent-foreground: oklch(0.985 0 0);
+ --input: oklch(1 0 0 / 12%);
+ --ring: oklch(0.72 0.15 75);
+ --chart-1: oklch(0.72 0.15 75);
+ --chart-2: oklch(0.65 0.16 340);
+ --chart-3: oklch(0.70 0.14 150);
+ --chart-4: oklch(0.60 0.18 265);
+ --chart-5: oklch(0.55 0.15 45);
+ --sidebar: oklch(0.185 0.015 265);
+ --sidebar-foreground: oklch(0.935 0.01 260);
+ --sidebar-primary: oklch(0.72 0.15 75);
+ --sidebar-primary-foreground: oklch(0.16 0.02 265);
+ --sidebar-accent: oklch(0.26 0.015 265);
+ --sidebar-accent-foreground: oklch(0.93 0.01 260);
--sidebar-border: oklch(1 0 0 / 10%);
- --sidebar-ring: oklch(0.556 0 0);
+ --sidebar-ring: oklch(0.72 0.15 75);
}
@theme inline {
@@ -116,14 +118,63 @@
--radius-4xl: calc(var(--radius) * 2.6);
}
+@theme inline {
+ --animate-confetti: confetti-fall 2.5s ease-in forwards;
+ --animate-slide-in: slide-in-from-bottom 0.3s ease-out;
+ --animate-shake: shake 0.4s ease-in-out;
+ --animate-check-flash: check-flash 0.6s ease-out;
+ --animate-glow-pulse: glow-pulse 1.5s ease-in-out infinite;
+}
+
+@keyframes confetti-fall {
+ 0% {
+ transform: translateY(0) rotate(0deg);
+ opacity: 1;
+ }
+ 100% {
+ transform: translateY(100vh) rotate(720deg);
+ opacity: 0;
+ }
+}
+
+@keyframes slide-in-from-bottom {
+ 0% {
+ transform: translateY(12px);
+ opacity: 0;
+ }
+ 100% {
+ transform: translateY(0);
+ opacity: 1;
+ }
+}
+
+@keyframes shake {
+ 0%, 100% { transform: translateX(0); }
+ 20% { transform: translateX(-6px); }
+ 40% { transform: translateX(6px); }
+ 60% { transform: translateX(-4px); }
+ 80% { transform: translateX(4px); }
+}
+
+@keyframes check-flash {
+ 0% { opacity: 0; transform: scale(0.5); }
+ 50% { opacity: 1; transform: scale(1.2); }
+ 100% { opacity: 0; transform: scale(1); }
+}
+
+@keyframes glow-pulse {
+ 0%, 100% { box-shadow: 0 0 4px oklch(0.72 0.15 75 / 0.3); }
+ 50% { box-shadow: 0 0 16px oklch(0.72 0.15 75 / 0.5); }
+}
+
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
- @apply bg-background text-foreground;
+ @apply bg-background text-foreground antialiased;
}
html {
@apply font-sans;
}
-}
\ No newline at end of file
+}
diff --git a/src/lib/movie-pairs.ts b/src/lib/movie-pairs.ts
new file mode 100644
index 0000000..88d8970
--- /dev/null
+++ b/src/lib/movie-pairs.ts
@@ -0,0 +1,253 @@
+import type { PresetMoviePair } from '@/types';
+
+export const GENRES = [
+ 'Action',
+ 'Drama',
+ 'Sci-Fi',
+ 'Comedy',
+ 'Thriller',
+ 'Crime',
+ 'Fantasy',
+ 'Animation',
+] as const;
+
+export const DECADES = ['1970s', '1980s', '1990s', '2000s', '2010s', '2020s'] as const;
+
+interface TaggedPair extends PresetMoviePair {
+ genres?: string[];
+ decade?: string;
+}
+
+const PRESET_PAIRS: TaggedPair[] = [
+ {
+ movieA: { id: 27205, title: 'Inception' },
+ movieB: { id: 155, title: 'The Dark Knight' },
+ description: 'Christopher Nolan classics',
+ genres: ['Action', 'Sci-Fi', 'Thriller'],
+ decade: '2000s',
+ },
+ {
+ movieA: { id: 680, title: 'Pulp Fiction' },
+ movieB: { id: 600, title: 'Full Metal Jacket' },
+ description: 'Iconic 90s/80s cinema',
+ genres: ['Crime', 'Drama'],
+ decade: '1990s',
+ },
+ {
+ movieA: { id: 120, title: 'The Lord of the Rings: The Fellowship of the Ring' },
+ movieB: { id: 22, title: 'Pirates of the Caribbean: The Curse of the Black Pearl' },
+ description: 'Epic fantasy adventures',
+ genres: ['Fantasy', 'Action'],
+ decade: '2000s',
+ },
+ {
+ movieA: { id: 550, title: 'Fight Club' },
+ movieB: { id: 807, title: 'Se7en' },
+ description: 'David Fincher thrillers',
+ genres: ['Thriller', 'Drama'],
+ decade: '1990s',
+ },
+ {
+ movieA: { id: 13, title: 'Forrest Gump' },
+ movieB: { id: 862, title: 'Toy Story' },
+ description: 'Tom Hanks classics',
+ genres: ['Drama', 'Comedy', 'Animation'],
+ decade: '1990s',
+ },
+ {
+ movieA: { id: 11, title: 'Star Wars' },
+ movieB: { id: 85, title: 'Raiders of the Lost Ark' },
+ description: 'Harrison Ford adventures',
+ genres: ['Sci-Fi', 'Action'],
+ decade: '1970s',
+ },
+ {
+ movieA: { id: 603, title: 'The Matrix' },
+ movieB: { id: 24, title: 'Kill Bill: Vol. 1' },
+ description: 'Action masterpieces',
+ genres: ['Action', 'Sci-Fi'],
+ decade: '1990s',
+ },
+ {
+ movieA: { id: 238, title: 'The Godfather' },
+ movieB: { id: 769, title: 'GoodFellas' },
+ description: 'Mob masterpieces',
+ genres: ['Crime', 'Drama'],
+ decade: '1970s',
+ },
+ {
+ movieA: { id: 278, title: 'The Shawshank Redemption' },
+ movieB: { id: 510, title: 'One Flew Over the Cuckoo\'s Nest' },
+ description: 'Prison dramas',
+ genres: ['Drama'],
+ decade: '1990s',
+ },
+ {
+ movieA: { id: 497, title: 'The Green Mile' },
+ movieB: { id: 194, title: 'Amélie' },
+ description: 'Unexpected connections',
+ genres: ['Drama', 'Comedy'],
+ decade: '1990s',
+ },
+ {
+ movieA: { id: 299536, title: 'Avengers: Infinity War' },
+ movieB: { id: 264660, title: 'Ex Machina' },
+ description: 'Oscar Isaac sci-fi',
+ genres: ['Action', 'Sci-Fi'],
+ decade: '2010s',
+ },
+ {
+ movieA: { id: 157336, title: 'Interstellar' },
+ movieB: { id: 11324, title: 'Shutter Island' },
+ description: 'Mind-bending epics',
+ genres: ['Sci-Fi', 'Thriller'],
+ decade: '2010s',
+ },
+ {
+ movieA: { id: 68718, title: 'Django Unchained' },
+ movieB: { id: 16869, title: 'Inglourious Basterds' },
+ description: 'Tarantino history',
+ genres: ['Action', 'Drama'],
+ decade: '2010s',
+ },
+ {
+ movieA: { id: 105, title: 'Back to the Future' },
+ movieB: { id: 329, title: 'Jurassic Park' },
+ description: 'Spielberg sci-fi',
+ genres: ['Sci-Fi', 'Action'],
+ decade: '1980s',
+ },
+ {
+ movieA: { id: 568, title: 'Apollo 13' },
+ movieB: { id: 607, title: 'Men in Black' },
+ description: '90s blockbusters',
+ genres: ['Sci-Fi', 'Action', 'Drama'],
+ decade: '1990s',
+ },
+ {
+ movieA: { id: 122, title: 'The Lord of the Rings: The Return of the King' },
+ movieB: { id: 49026, title: 'The Dark Knight Rises' },
+ description: 'Epic trilogy finales',
+ genres: ['Fantasy', 'Action'],
+ decade: '2000s',
+ },
+ {
+ movieA: { id: 98, title: 'Gladiator' },
+ movieB: { id: 76341, title: 'Mad Max: Fury Road' },
+ description: 'Arena of chaos',
+ genres: ['Action', 'Drama'],
+ decade: '2000s',
+ },
+ {
+ movieA: { id: 274, title: 'The Silence of the Lambs' },
+ movieB: { id: 578, title: 'Jaws' },
+ description: 'Iconic predators',
+ genres: ['Thriller'],
+ decade: '1990s',
+ },
+ {
+ movieA: { id: 245891, title: 'John Wick' },
+ movieB: { id: 82992, title: 'Fast & Furious 6' },
+ description: 'High-octane action',
+ genres: ['Action', 'Thriller'],
+ decade: '2010s',
+ },
+ {
+ movieA: { id: 101, title: 'Léon: The Professional' },
+ movieB: { id: 153, title: 'Lost in Translation' },
+ description: 'Scarlett Johansson range',
+ genres: ['Drama', 'Thriller'],
+ decade: '1990s',
+ },
+ {
+ movieA: { id: 807, title: 'Se7en' },
+ movieB: { id: 745, title: 'The Sixth Sense' },
+ description: 'Dark twist endings',
+ genres: ['Thriller'],
+ decade: '1990s',
+ },
+ {
+ movieA: { id: 640, title: 'Catch Me If You Can' },
+ movieB: { id: 1422, title: 'The Departed' },
+ description: 'Leonardo DiCaprio crime',
+ genres: ['Crime', 'Drama'],
+ decade: '2000s',
+ },
+ {
+ movieA: { id: 274870, title: 'Whiplash' },
+ movieB: { id: 313369, title: 'La La Land' },
+ description: 'Damien Chazelle music',
+ genres: ['Drama'],
+ decade: '2010s',
+ },
+ {
+ movieA: { id: 346, title: 'Seven Samurai' },
+ movieB: { id: 620, title: 'Ghostbusters' },
+ description: 'Assembling the team',
+ genres: ['Action', 'Comedy'],
+ decade: '1980s',
+ },
+ {
+ movieA: { id: 11036, title: 'The Notebook' },
+ movieB: { id: 12445, title: 'Harry Potter and the Deathly Hallows: Part 2' },
+ description: 'Cross-genre fan favorites',
+ genres: ['Drama', 'Fantasy'],
+ decade: '2000s',
+ },
+ {
+ movieA: { id: 530385, title: 'Midsommar' },
+ movieB: { id: 419704, title: 'Ad Astra' },
+ description: '2019 slow burns',
+ genres: ['Thriller', 'Sci-Fi'],
+ decade: '2010s',
+ },
+ {
+ movieA: { id: 324857, title: 'Spider-Man: Into the Spider-Verse' },
+ movieB: { id: 508947, title: 'Turning Red' },
+ description: 'Animated hits',
+ genres: ['Animation', 'Action'],
+ decade: '2010s',
+ },
+ {
+ movieA: { id: 121, title: 'The Lord of the Rings: The Two Towers' },
+ movieB: { id: 114, title: 'Pretty Woman' },
+ description: 'Wildly different vibes',
+ genres: ['Fantasy', 'Comedy'],
+ decade: '2000s',
+ },
+ {
+ movieA: { id: 429617, title: 'Spider-Man: Far From Home' },
+ movieB: { id: 399566, title: 'Godzilla vs. Kong' },
+ description: 'Blockbuster spectacles',
+ genres: ['Action', 'Sci-Fi'],
+ decade: '2010s',
+ },
+];
+
+export function getRandomPair(): PresetMoviePair {
+ const index = Math.floor(Math.random() * PRESET_PAIRS.length);
+ return PRESET_PAIRS[index];
+}
+
+export function getFilteredPair(
+ genre: string | null,
+ decade: string | null,
+): PresetMoviePair {
+ let filtered = PRESET_PAIRS;
+
+ if (genre) {
+ filtered = filtered.filter((p) => p.genres?.includes(genre));
+ }
+ if (decade) {
+ filtered = filtered.filter((p) => p.decade === decade);
+ }
+
+ if (filtered.length === 0) {
+ return getRandomPair();
+ }
+
+ const index = Math.floor(Math.random() * filtered.length);
+ return filtered[index];
+}
+
+export { PRESET_PAIRS };
diff --git a/src/lib/scoring.ts b/src/lib/scoring.ts
new file mode 100644
index 0000000..c9c8cb7
--- /dev/null
+++ b/src/lib/scoring.ts
@@ -0,0 +1,60 @@
+import type { ChainLink, ScoreBreakdown } from '@/types';
+import { getLinkCount } from '@/types';
+
+export function calculateScore(
+ chain: ChainLink[],
+ startTime: number,
+ completedAt: number,
+ hintsUsed: number,
+): ScoreBreakdown {
+ const baseScore = 1000;
+ const chainLength = chain.length;
+ const linkCount = getLinkCount(chain);
+
+ // Chain length bonus: fewer links are rewarded (par = 4 links)
+ const chainLengthBonus = Math.max(0, (4 - linkCount) * 200);
+
+ // Time bonus
+ const elapsedSeconds = Math.floor((completedAt - startTime) / 1000);
+ let timeBonus: number;
+ if (elapsedSeconds < 60) {
+ timeBonus = 500;
+ } else if (elapsedSeconds < 180) {
+ timeBonus = 300;
+ } else if (elapsedSeconds < 300) {
+ timeBonus = 100;
+ } else {
+ timeBonus = 0;
+ }
+
+ // Obscurity bonus (skip the first link which is the starting movie)
+ let obscurityBonus = 0;
+ for (let i = 1; i < chain.length; i++) {
+ const link = chain[i];
+ if (link.type === 'actor') {
+ if (link.popularity < 5) obscurityBonus += 250;
+ else if (link.popularity < 10) obscurityBonus += 150;
+ } else if (link.type === 'movie') {
+ if (link.popularity < 5) obscurityBonus += 250;
+ else if (link.popularity < 20) obscurityBonus += 100;
+ }
+ }
+
+ // Hint penalty
+ const hintPenalty = hintsUsed * 150;
+
+ const totalScore =
+ baseScore + chainLengthBonus + timeBonus + obscurityBonus - hintPenalty;
+
+ return {
+ baseScore,
+ chainLength,
+ linkCount,
+ chainLengthBonus,
+ timeBonus,
+ obscurityBonus,
+ hintPenalty,
+ elapsedSeconds,
+ totalScore: Math.max(0, totalScore),
+ };
+}
diff --git a/src/lib/sounds.ts b/src/lib/sounds.ts
new file mode 100644
index 0000000..8f4b8e8
--- /dev/null
+++ b/src/lib/sounds.ts
@@ -0,0 +1,42 @@
+type SoundName = 'valid' | 'invalid' | 'completion';
+
+const SOUND_PATHS: Record = {
+ valid: '/sounds/valid.mp3',
+ invalid: '/sounds/invalid.mp3',
+ completion: '/sounds/completion.mp3',
+};
+
+const STORAGE_KEY = 'movieloop-sound-enabled';
+
+export function isSoundEnabled(): boolean {
+ try {
+ const stored = localStorage.getItem(STORAGE_KEY);
+ return stored === null ? true : stored === 'true';
+ } catch {
+ return true;
+ }
+}
+
+export function toggleSound(): boolean {
+ const next = !isSoundEnabled();
+ try {
+ localStorage.setItem(STORAGE_KEY, String(next));
+ } catch {
+ // localStorage unavailable
+ }
+ return next;
+}
+
+export function playSound(name: SoundName): void {
+ if (!isSoundEnabled()) return;
+
+ try {
+ const audio = new Audio(SOUND_PATHS[name]);
+ audio.volume = 0.5;
+ audio.play().catch(() => {
+ // Sound file missing or autoplay blocked — no-op
+ });
+ } catch {
+ // Audio API unavailable
+ }
+}
diff --git a/src/lib/tmdb.ts b/src/lib/tmdb.ts
new file mode 100644
index 0000000..0fe43b0
--- /dev/null
+++ b/src/lib/tmdb.ts
@@ -0,0 +1,25 @@
+const TMDB_IMAGE_BASE = 'https://image.tmdb.org/t/p';
+
+type PosterSize = 'w92' | 'w154' | 'w185' | 'w342' | 'w500' | 'w780' | 'original';
+type ProfileSize = 'w45' | 'w185' | 'h632' | 'original';
+
+export function posterUrl(
+ path: string | null,
+ size: PosterSize = 'w342',
+): string | null {
+ if (!path) return null;
+ return `${TMDB_IMAGE_BASE}/${size}${path}`;
+}
+
+export function profileUrl(
+ path: string | null,
+ size: ProfileSize = 'w185',
+): string | null {
+ if (!path) return null;
+ return `${TMDB_IMAGE_BASE}/${size}${path}`;
+}
+
+export function releaseYear(date: string | undefined | null): string {
+ if (!date) return '';
+ return date.substring(0, 4);
+}
diff --git a/src/lib/ws.ts b/src/lib/ws.ts
new file mode 100644
index 0000000..6491fda
--- /dev/null
+++ b/src/lib/ws.ts
@@ -0,0 +1,6 @@
+export function getWsUrl(namespace: string): string {
+ const apiUrl = import.meta.env.VITE_API_URL;
+ if (!apiUrl) return namespace;
+ const origin = apiUrl.replace(/\/api\/v1\/?$/, '');
+ return `${origin}${namespace}`;
+}
diff --git a/src/pages/Achievements.tsx b/src/pages/Achievements.tsx
new file mode 100644
index 0000000..bc593af
--- /dev/null
+++ b/src/pages/Achievements.tsx
@@ -0,0 +1,164 @@
+import { useEffect, useState } from 'react';
+import { useNavigate } from 'react-router';
+import { Badge } from '@/components/ui/badge';
+import { Skeleton } from '@/components/ui/skeleton';
+import PageLayout from '@/components/layout/PageLayout';
+import { useAuthStore } from '@/stores/auth-store';
+import { getUserAchievements, type Achievement } from '@/api/achievements';
+import { cn } from '@/lib/utils';
+import {
+ Trophy,
+ Zap,
+ Link,
+ EyeOff,
+ Compass,
+ Flame,
+ Star,
+ Circle,
+ Play,
+ Award,
+ Film,
+ Swords,
+ Crown,
+} from 'lucide-react';
+
+const ICON_MAP: Record = {
+ trophy: ,
+ zap: ,
+ link: ,
+ 'eye-off': ,
+ compass: ,
+ flame: ,
+ star: ,
+ circle: ,
+ play: ,
+ award: ,
+ film: ,
+ swords: ,
+ crown: ,
+};
+
+const CATEGORY_LABELS: Record = {
+ general: 'General',
+ speed: 'Speed',
+ chain: 'Chain',
+ obscurity: 'Obscurity',
+ streak: 'Streaks',
+ score: 'Score',
+ versus: 'Versus',
+};
+
+export default function Achievements() {
+ const user = useAuthStore((s) => s.user);
+ const navigate = useNavigate();
+ const [achievements, setAchievements] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ if (!user) {
+ navigate('/login');
+ return;
+ }
+ getUserAchievements()
+ .then(setAchievements)
+ .catch(() => setAchievements([]))
+ .finally(() => setLoading(false));
+ }, [user, navigate]);
+
+ if (!user) return null;
+
+ const unlocked = achievements.filter((a) => a.unlocked);
+ const grouped = achievements.reduce(
+ (acc, a) => {
+ const cat = a.category;
+ if (!acc[cat]) acc[cat] = [];
+ acc[cat].push(a);
+ return acc;
+ },
+ {} as Record,
+ );
+
+ return (
+
+
+
+
+
+
Achievements
+
+
+ {unlocked.length} / {achievements.length} unlocked
+
+
+
+ {loading && (
+
+ {Array.from({ length: 8 }).map((_, i) => (
+
+ ))}
+
+ )}
+
+ {!loading &&
+ Object.entries(grouped).map(([category, items]) => (
+
+
+ {CATEGORY_LABELS[category] ?? category}
+
+
+ {items.map((a) => (
+
+
+ {ICON_MAP[a.icon] ?? }
+
+
+
{a.name}
+
+ {a.description}
+
+ {!a.unlocked && a.threshold > 1 && (
+
+
+
+ {a.progress} / {a.threshold}
+
+
+ )}
+ {a.unlocked && a.unlockedAt && (
+
+ Unlocked{' '}
+ {new Date(a.unlockedAt).toLocaleDateString()}
+
+ )}
+
+
+ ))}
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/pages/AsyncMatchLeaderboard.tsx b/src/pages/AsyncMatchLeaderboard.tsx
new file mode 100644
index 0000000..61c99af
--- /dev/null
+++ b/src/pages/AsyncMatchLeaderboard.tsx
@@ -0,0 +1,185 @@
+import { useEffect, useState } from 'react';
+import { useParams, useNavigate } from 'react-router';
+import PageLayout from '@/components/layout/PageLayout';
+import { Button } from '@/components/ui/button';
+import { DifficultyBadge } from '@/components/ui/difficulty-badge';
+import { Skeleton } from '@/components/ui/skeleton';
+import { useAuthStore } from '@/stores/auth-store';
+import {
+ getAsyncLeaderboard,
+ type AsyncLeaderboardResponse,
+ type LeaderboardEntry,
+} from '@/api/versus';
+import { Trophy, Crown, Clock, ArrowLeft } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { rawToLinkCount } from '@/types';
+
+function timeRemaining(expiresAt: string | null) {
+ if (!expiresAt) return null;
+ const remaining = new Date(expiresAt).getTime() - Date.now();
+ if (remaining <= 0) return 'Expired';
+ const hours = Math.floor(remaining / (1000 * 60 * 60));
+ const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60));
+ return `${hours}h ${minutes}m remaining`;
+}
+
+export default function AsyncMatchLeaderboard() {
+ const { matchId } = useParams<{ matchId: string }>();
+ const navigate = useNavigate();
+ const user = useAuthStore((s) => s.user);
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!matchId) return;
+ setLoading(true);
+ getAsyncLeaderboard(matchId)
+ .then(setData)
+ .catch((err) => setError(err?.response?.data?.message ?? 'Failed to load'))
+ .finally(() => setLoading(false));
+ }, [matchId]);
+
+ if (loading) {
+ return (
+
+
+
+
+
+
+
+ );
+ }
+
+ if (error || !data) {
+ return (
+
+
+
{error ?? 'Match not found'}
+
navigate('/versus')}>Back to Versus
+
+
+ );
+ }
+
+ const { match, leaderboard } = data;
+ const expiry = timeRemaining(match.expiresAt);
+
+ return (
+
+
+
+
navigate('/versus')}>
+
+ Back
+
+
Async Challenge
+
+
+ {/* Match info */}
+
+
+
+
+ {match.lobbyName || `${data.creator.username}'s challenge`}
+
+
+ {match.movieATitle} ↔ {match.movieBTitle}
+
+
+
+
+
+ by {data.creator.username}
+ {expiry && (
+
+
+ {expiry}
+
+ )}
+ {match.status === 'expired' && (
+
+ Expired
+
+ )}
+
+
+
+ {/* Leaderboard */}
+
+
+
+
+
Leaderboard
+
+ ({leaderboard.length} player{leaderboard.length !== 1 ? 's' : ''})
+
+
+
+
+ {leaderboard.length === 0 ? (
+
+ No results yet
+
+ ) : (
+
+ {leaderboard.map((entry) => (
+
+ ))}
+
+ )}
+
+
+
+ );
+}
+
+function LeaderboardRow({
+ entry,
+ isMe,
+}: {
+ entry: LeaderboardEntry;
+ isMe: boolean;
+}) {
+ const totalScore = entry.score?.totalScore ?? 0;
+
+ return (
+
+
+ #{entry.rank}
+
+
+
+
+ {entry.username}
+
+ {entry.isCreator && (
+
+ )}
+ {isMe && (
+
+ YOU
+
+ )}
+
+
+
+
{totalScore}
+
+ {entry.chainLength != null ? rawToLinkCount(entry.chainLength) : '?'} links
+
+
+
+ );
+}
diff --git a/src/pages/AsyncVersusGame.tsx b/src/pages/AsyncVersusGame.tsx
new file mode 100644
index 0000000..12c065f
--- /dev/null
+++ b/src/pages/AsyncVersusGame.tsx
@@ -0,0 +1,274 @@
+import { useEffect, useState } from 'react';
+import { useParams, useNavigate } from 'react-router';
+import { useGameStore } from '@/stores/game-store';
+import { useAuthStore } from '@/stores/auth-store';
+import { useChainValidation } from '@/hooks/use-chain-validation';
+import PageLayout from '@/components/layout/PageLayout';
+import GameHeader from '@/components/game/GameHeader';
+import ChainDisplay from '@/components/game/ChainDisplay';
+import SearchAutocomplete from '@/components/game/SearchAutocomplete';
+import ValidationFeedback from '@/components/game/ValidationFeedback';
+import HintButton from '@/components/game/HintButton';
+import AsyncCompletionModal from '@/components/versus/AsyncCompletionModal';
+import { Button } from '@/components/ui/button';
+import { Separator } from '@/components/ui/separator';
+import { Skeleton } from '@/components/ui/skeleton';
+import { Undo2, Target, Trophy } from 'lucide-react';
+import {
+ getMatch,
+ submitCreatorScore,
+ startAsyncAttempt,
+ submitAsyncAttempt,
+ getAsyncLeaderboard,
+} from '@/api/versus';
+import { getLinkCount, rawToLinkCount } from '@/types';
+
+type AsyncMode = 'creator' | 'challenger';
+
+export default function AsyncVersusGame() {
+ const { matchId } = useParams<{ matchId: string }>();
+ const navigate = useNavigate();
+ const user = useAuthStore((s) => s.user);
+
+ const status = useGameStore((s) => s.status);
+ const chain = useGameStore((s) => s.chain);
+ const currentSearchMode = useGameStore((s) => s.currentSearchMode);
+ const passedMovieB = useGameStore((s) => s.passedMovieB);
+ const movieA = useGameStore((s) => s.movieA);
+ const movieB = useGameStore((s) => s.movieB);
+ const isValidating = useGameStore((s) => s.isValidating);
+ const score = useGameStore((s) => s.score);
+ const undoLastLink = useGameStore((s) => s.undoLastLink);
+ const startGameFromVersus = useGameStore((s) => s.startGameFromVersus);
+ const resetGame = useGameStore((s) => s.resetGame);
+ const { validateAndAddActor, validateAndAddMovie } = useChainValidation();
+
+ const [loading, setLoading] = useState(true);
+ const [mode, setMode] = useState('creator');
+ const [targetChainLength, setTargetChainLength] = useState(null);
+ const [showCompletion, setShowCompletion] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+ const [creatorScore, setCreatorScore] = useState<{ totalScore: number } | null>(null);
+ const [myRank, setMyRank] = useState();
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!matchId || !user) {
+ navigate('/versus');
+ return;
+ }
+
+ async function init() {
+ try {
+ const match = await getMatch(matchId!);
+
+ if (!match.isAsync) {
+ navigate('/versus');
+ return;
+ }
+
+ if (match.player1.id === user!.id) {
+ // Creator mode
+ if (match.status !== 'waiting') {
+ // Already submitted — go to leaderboard
+ navigate(`/versus/async/${matchId}/leaderboard`);
+ return;
+ }
+ setMode('creator');
+ await startGameFromVersus(
+ {
+ movieA: { id: match.movieAId, title: match.movieATitle! },
+ movieB: { id: match.movieBId, title: match.movieBTitle! },
+ },
+ Date.now(),
+ );
+ } else {
+ // Challenger mode
+ const result = await startAsyncAttempt(matchId!);
+ setMode('challenger');
+ setTargetChainLength(result.chainLength);
+ await startGameFromVersus(
+ {
+ movieA: { id: result.movieAId, title: result.movieATitle },
+ movieB: { id: result.movieBId, title: result.movieBTitle },
+ },
+ Date.now(),
+ );
+ }
+ } catch (err: unknown) {
+ const message = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
+ setError(message ?? 'Failed to load match');
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ resetGame();
+ init();
+
+ return () => {
+ resetGame();
+ };
+ }, [matchId, user, navigate]);
+
+ // Handle game completion
+ useEffect(() => {
+ if (status !== 'completed' || !score || !matchId || submitting || showCompletion) return;
+
+ setSubmitting(true);
+ const chainLength = chain.length;
+
+ (async () => {
+ try {
+ if (mode === 'creator') {
+ await submitCreatorScore(matchId, score, chainLength);
+ } else {
+ await submitAsyncAttempt(matchId, score, chainLength);
+ // Fetch leaderboard to get rank and creator score
+ try {
+ const lb = await getAsyncLeaderboard(matchId);
+ const creatorEntry = lb.leaderboard.find((e) => e.isCreator);
+ if (creatorEntry?.score) {
+ setCreatorScore(creatorEntry.score as { totalScore: number });
+ }
+ const myEntry = lb.leaderboard.find((e) => e.playerId === user?.id);
+ if (myEntry) setMyRank(myEntry.rank);
+ } catch {
+ // Non-critical
+ }
+ }
+ setShowCompletion(true);
+ } catch {
+ setError('Failed to submit score');
+ } finally {
+ setSubmitting(false);
+ }
+ })();
+ }, [status, score, matchId, mode, submitting, showCompletion]);
+
+ if (!user) return null;
+
+ if (loading) {
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
{error}
+
navigate('/versus')}>Back to Versus
+
+
+ );
+ }
+
+ if (status === 'idle' || !movieA || !movieB) {
+ return null;
+ }
+
+ const lastLink = chain[chain.length - 1];
+ let searchLabel = '';
+ let searchPlaceholder = '';
+
+ if (currentSearchMode === 'actor' && lastLink?.type === 'movie') {
+ searchLabel = `Who was in "${lastLink.title}"?`;
+ searchPlaceholder = 'Search for an actor...';
+ } else if (currentSearchMode === 'movie' && lastLink?.type === 'actor') {
+ searchLabel = `What movie was ${lastLink.name} also in?`;
+ searchPlaceholder = 'Search for a movie...';
+ }
+
+ const showLoopHint = passedMovieB && currentSearchMode === 'movie' && movieA;
+
+ return (
+
+
+
+
+ {/* Mode indicator */}
+
+
+ {mode === 'creator' ? (
+
+
+
+ Your score will be the benchmark!
+
+
+ ) : (
+
+
+
+ Target: {targetChainLength != null ? rawToLinkCount(targetChainLength) : '?'} links
+
+
+ )}
+
+
+
+
+
+
+
+ {status === 'playing' && (
+
+
+
+
+
+
{searchLabel}
+
+
+
+
+ Undo
+
+
+
+
+
+
+
+
+ {showLoopHint && (
+
+ Select "{movieA.title}" to close the loop!
+
+ )}
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/pages/DailyChallenge.tsx b/src/pages/DailyChallenge.tsx
new file mode 100644
index 0000000..e0ae7ec
--- /dev/null
+++ b/src/pages/DailyChallenge.tsx
@@ -0,0 +1,119 @@
+import { useEffect, useState } from 'react';
+import { useNavigate } from 'react-router';
+import { Button } from '@/components/ui/button';
+import { DifficultyBadge } from '@/components/ui/difficulty-badge';
+import { Skeleton } from '@/components/ui/skeleton';
+import { useGameStore } from '@/stores/game-store';
+import PageLayout from '@/components/layout/PageLayout';
+import { getTodaysChallenge, type DailyChallenge as DailyChallengeType } from '@/api/daily-challenges';
+import { Loader2, Calendar, Trophy } from 'lucide-react';
+
+export default function DailyChallenge() {
+ const [challenge, setChallenge] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const startGame = useGameStore((s) => s.startGame);
+ const isValidating = useGameStore((s) => s.isValidating);
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ getTodaysChallenge()
+ .then(setChallenge)
+ .catch(() => setError('Failed to load today\'s challenge.'))
+ .finally(() => setLoading(false));
+ }, []);
+
+ const handlePlay = async () => {
+ if (!challenge) return;
+ await startGame({
+ movieA: { id: challenge.movieAId, title: challenge.movieATitle },
+ movieB: { id: challenge.movieBId, title: challenge.movieBTitle },
+ });
+ navigate('/game');
+ };
+
+ const formatDate = (dateStr: string) => {
+ return new Date(dateStr).toLocaleDateString('en-US', {
+ weekday: 'long',
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ });
+ };
+
+ return (
+
+
+
+
+
+
Daily Challenge
+
+ {challenge && (
+
+ {formatDate(challenge.date)}
+
+ )}
+
+
+ {loading && (
+
+
+
+ )}
+
+ {error && (
+
{error}
+ )}
+
+ {challenge && (
+
+
+
+
+
+
+ Par: {challenge.par} links
+
+
+
+
+
+
+ Movie A
+
+
{challenge.movieATitle}
+
+
+ ⇵
+
+
+
+ Movie B
+
+
{challenge.movieBTitle}
+
+
+
+
+ {isValidating ? (
+ <>
+
+ Loading...
+ >
+ ) : (
+ 'Play Today\'s Challenge'
+ )}
+
+
+
+ )}
+
+
+ );
+}
diff --git a/src/pages/Endless.tsx b/src/pages/Endless.tsx
new file mode 100644
index 0000000..8bde750
--- /dev/null
+++ b/src/pages/Endless.tsx
@@ -0,0 +1,155 @@
+import { useState } from 'react';
+import { useNavigate } from 'react-router';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import PageLayout from '@/components/layout/PageLayout';
+import { useGameStore } from '@/stores/game-store';
+import { getFilteredPair, GENRES, DECADES } from '@/lib/movie-pairs';
+import { discoverMovies } from '@/api/movies';
+import { getDifficultyButtonClass } from '@/components/ui/difficulty-badge';
+import { Loader2, Shuffle, Filter } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+type Difficulty = 'easy' | 'medium' | 'hard';
+
+export default function Endless() {
+ const startGame = useGameStore((s) => s.startGame);
+ const isValidating = useGameStore((s) => s.isValidating);
+ const navigate = useNavigate();
+ const [difficulty, setDifficulty] = useState('medium');
+ const [genre, setGenre] = useState(null);
+ const [decade, setDecade] = useState(null);
+ const [showFilters, setShowFilters] = useState(false);
+
+ const handleStart = async () => {
+ let pair;
+ if (genre || decade) {
+ pair = getFilteredPair(genre, decade);
+ } else {
+ // Use TMDB discover for unfiltered random pairs
+ const page = Math.floor(Math.random() * 20) + 1;
+ const data = await discoverMovies(page);
+ const movies = data.results.filter((m) => m.title && m.release_date);
+ if (movies.length < 2) throw new Error('Not enough movies');
+ const shuffled = movies.sort(() => Math.random() - 0.5);
+ pair = {
+ movieA: { id: shuffled[0].id, title: shuffled[0].title },
+ movieB: { id: shuffled[1].id, title: shuffled[1].title },
+ };
+ }
+ await startGame(pair);
+ navigate('/game');
+ };
+
+ return (
+
+
+
+
+
+
Endless Mode
+
+
+ Practice with random movie pairs. No pressure, no leaderboard.
+
+
+
+
+
+
+ Difficulty
+
+
+ {(['easy', 'medium', 'hard'] as Difficulty[]).map((d) => (
+ setDifficulty(d)}
+ >
+ {d.charAt(0).toUpperCase() + d.slice(1)}
+
+ ))}
+
+
+
setShowFilters(!showFilters)}
+ >
+
+ {showFilters ? 'Hide Filters' : 'Show Filters'}
+
+
+ {showFilters && (
+
+
+
Genre
+
+ setGenre(null)}
+ >
+ Any
+
+ {GENRES.map((g) => (
+ setGenre(g)}
+ >
+ {g}
+
+ ))}
+
+
+
+
+
Decade
+
+ setDecade(null)}
+ >
+ Any
+
+ {DECADES.map((d) => (
+ setDecade(d)}
+ >
+ {d}
+
+ ))}
+
+
+
+ )}
+
+
+
+ {isValidating ? (
+ <>
+
+ Loading...
+ >
+ ) : (
+ 'Start Practice Game'
+ )}
+
+
+
+
+ );
+}
diff --git a/src/pages/Game.tsx b/src/pages/Game.tsx
new file mode 100644
index 0000000..ca97500
--- /dev/null
+++ b/src/pages/Game.tsx
@@ -0,0 +1,110 @@
+import { useEffect } from 'react';
+import { useNavigate } from 'react-router';
+import { useGameStore } from '@/stores/game-store';
+import { useChainValidation } from '@/hooks/use-chain-validation';
+import PageLayout from '@/components/layout/PageLayout';
+import GameHeader from '@/components/game/GameHeader';
+import ChainDisplay from '@/components/game/ChainDisplay';
+import SearchAutocomplete from '@/components/game/SearchAutocomplete';
+import ValidationFeedback from '@/components/game/ValidationFeedback';
+import GameCompletionModal from '@/components/game/GameCompletionModal';
+import HintButton from '@/components/game/HintButton';
+import { Button } from '@/components/ui/button';
+import { Separator } from '@/components/ui/separator';
+import { Undo2 } from 'lucide-react';
+
+export default function Game() {
+ const status = useGameStore((s) => s.status);
+ const chain = useGameStore((s) => s.chain);
+ const currentSearchMode = useGameStore((s) => s.currentSearchMode);
+ const passedMovieB = useGameStore((s) => s.passedMovieB);
+ const movieA = useGameStore((s) => s.movieA);
+ const movieB = useGameStore((s) => s.movieB);
+ const isValidating = useGameStore((s) => s.isValidating);
+ const undoLastLink = useGameStore((s) => s.undoLastLink);
+ const navigate = useNavigate();
+ const { validateAndAddActor, validateAndAddMovie } = useChainValidation();
+
+ useEffect(() => {
+ if (status === 'idle') {
+ navigate('/');
+ }
+ }, [status, navigate]);
+
+ if (status === 'idle' || !movieA || !movieB) {
+ return null;
+ }
+
+ // Build contextual search label
+ const lastLink = chain[chain.length - 1];
+ let searchLabel = '';
+ let searchPlaceholder = '';
+
+ if (currentSearchMode === 'actor' && lastLink?.type === 'movie') {
+ searchLabel = `Who was in "${lastLink.title}"?`;
+ searchPlaceholder = 'Search for an actor...';
+ } else if (currentSearchMode === 'movie' && lastLink?.type === 'actor') {
+ searchLabel = `What movie was ${lastLink.name} also in?`;
+ searchPlaceholder = 'Search for a movie...';
+ }
+
+ // Hint for closing the loop
+ const showLoopHint =
+ passedMovieB &&
+ currentSearchMode === 'movie' &&
+ movieA;
+
+ return (
+
+
+
+
+
+
+
+
+ {status === 'playing' && (
+
+
+
+
+
+
{searchLabel}
+
+
+
+
+ Undo
+
+
+
+
+
+
+
+
+ {showLoopHint && (
+
+ Select "{movieA.title}" to close the loop!
+
+ )}
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx
new file mode 100644
index 0000000..69e281f
--- /dev/null
+++ b/src/pages/Home.tsx
@@ -0,0 +1,152 @@
+import { useNavigate, Link } from 'react-router';
+import { Button, buttonVariants } from '@/components/ui/button';
+import { useGameStore } from '@/stores/game-store';
+import { discoverMovies } from '@/api/movies';
+import PageLayout from '@/components/layout/PageLayout';
+import { cn } from '@/lib/utils';
+import { Loader2, Calendar, Swords, Shuffle } from 'lucide-react';
+import type { MoviePair } from '@/types';
+
+/** Known franchise groups — movies in the same group won't be paired together. */
+const FRANCHISE_TAGS: Record = {
+ // MCU
+ 299536: 'mcu', 299534: 'mcu', 24428: 'mcu', 100402: 'mcu', 284053: 'mcu',
+ 118340: 'mcu', 99861: 'mcu', 68721: 'mcu', 1726: 'mcu', 10195: 'mcu',
+ 315635: 'mcu', 284054: 'mcu', 271110: 'mcu', 283995: 'mcu', 429617: 'mcu',
+ // Star Wars
+ 11: 'sw', 1891: 'sw', 1892: 'sw', 1893: 'sw', 1894: 'sw', 1895: 'sw',
+ 140607: 'sw', 181808: 'sw', 181812: 'sw', 330459: 'sw', 348350: 'sw',
+ // LotR / Hobbit
+ 120: 'lotr', 121: 'lotr', 122: 'lotr', 49051: 'lotr', 57158: 'lotr', 122917: 'lotr',
+ // Harry Potter
+ 671: 'hp', 672: 'hp', 673: 'hp', 674: 'hp', 675: 'hp', 767: 'hp', 12444: 'hp', 12445: 'hp',
+ // Fast & Furious
+ 9799: 'ff', 13804: 'ff', 51497: 'ff', 82992: 'ff', 168259: 'ff', 337339: 'ff', 385128: 'ff',
+ // Godfather
+ 238: 'gf', 240: 'gf', 242: 'gf',
+ // Dark Knight
+ 155: 'dk', 49026: 'dk', 272: 'dk',
+ // Jurassic
+ 329: 'jp', 330: 'jp', 331: 'jp', 135397: 'jp', 351286: 'jp',
+ // Toy Story
+ 862: 'ts', 863: 'ts', 10193: 'ts', 301528: 'ts',
+ // Pirates
+ 22: 'potc', 58: 'potc', 285: 'potc', 1865: 'potc', 166426: 'potc',
+};
+
+function areSameFranchise(idA: number, idB: number): boolean {
+ const tagA = FRANCHISE_TAGS[idA];
+ const tagB = FRANCHISE_TAGS[idB];
+ return !!(tagA && tagB && tagA === tagB);
+}
+
+async function getRandomTmdbPair(): Promise {
+ // Pick a random page from the first 20 pages of popular movies
+ const page = Math.floor(Math.random() * 20) + 1;
+ const data = await discoverMovies(page);
+ const movies = data.results.filter((m) => m.title && m.release_date);
+
+ if (movies.length < 2) {
+ throw new Error('Not enough movies returned from TMDB');
+ }
+
+ // Shuffle and pick two that aren't from the same franchise
+ const shuffled = movies.sort(() => Math.random() - 0.5);
+ let movieA = shuffled[0];
+ let movieB = shuffled[1];
+
+ for (let i = 2; i < shuffled.length; i++) {
+ if (!areSameFranchise(movieA.id, shuffled[i].id)) {
+ movieB = shuffled[i];
+ break;
+ }
+ }
+
+ return {
+ movieA: { id: movieA.id, title: movieA.title },
+ movieB: { id: movieB.id, title: movieB.title },
+ };
+}
+
+export default function Home() {
+ const startGame = useGameStore((s) => s.startGame);
+ const isValidating = useGameStore((s) => s.isValidating);
+ const navigate = useNavigate();
+
+ const handleStartGame = async () => {
+ const pair = await getRandomTmdbPair();
+ await startGame(pair);
+ navigate('/game');
+ };
+
+ return (
+
+
+
+
+ You Know Who Else Was In That Movie?
+
+
+ Build a chain of actor-movie connections forming a complete loop
+ between two movies. Can you find the path?
+
+
+
+
+
+ How to Play
+
+
+ You get two movies (Movie A and Movie B)
+ Find an actor who was in Movie A
+ Find another movie that actor was also in
+ Keep going through Movie B...
+ ...and back to Movie A to complete the loop!
+
+
+
+
+
+ {isValidating ? (
+ <>
+
+ Loading...
+ >
+ ) : (
+ 'Quick Play'
+ )}
+
+
+
+
+ Daily Challenge
+
+
+
+
+ Endless Mode
+
+
+
+
+ Versus Mode
+
+
+
+
+ );
+}
diff --git a/src/pages/Leaderboard.tsx b/src/pages/Leaderboard.tsx
new file mode 100644
index 0000000..292b124
--- /dev/null
+++ b/src/pages/Leaderboard.tsx
@@ -0,0 +1,161 @@
+import { useEffect, useState, useCallback } from 'react';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { Skeleton } from '@/components/ui/skeleton';
+import PageLayout from '@/components/layout/PageLayout';
+import { getLeaderboard, type LeaderboardEntry, type LeaderboardResponse } from '@/api/leaderboards';
+import { Trophy, Clock, ChevronLeft, ChevronRight } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+type Period = 'daily' | 'weekly' | 'all-time';
+
+const PERIODS: { value: Period; label: string }[] = [
+ { value: 'daily', label: 'Today' },
+ { value: 'weekly', label: 'This Week' },
+ { value: 'all-time', label: 'All Time' },
+];
+
+function formatTime(seconds: number) {
+ const m = Math.floor(seconds / 60);
+ const s = seconds % 60;
+ return `${m}:${s.toString().padStart(2, '0')}`;
+}
+
+export default function Leaderboard() {
+ const [period, setPeriod] = useState('daily');
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [page, setPage] = useState(1);
+
+ const fetchData = useCallback(async () => {
+ setLoading(true);
+ try {
+ const result = await getLeaderboard(period, page);
+ setData(result);
+ } catch {
+ setData(null);
+ } finally {
+ setLoading(false);
+ }
+ }, [period, page]);
+
+ useEffect(() => {
+ fetchData();
+ }, [fetchData]);
+
+ const handlePeriodChange = (p: Period) => {
+ setPeriod(p);
+ setPage(1);
+ };
+
+ return (
+
+
+
+
+
+ {PERIODS.map((p) => (
+ handlePeriodChange(p.value)}
+ >
+ {p.label}
+
+ ))}
+
+
+ {loading && (
+
+ {Array.from({ length: 10 }).map((_, i) => (
+
+ ))}
+
+ )}
+
+ {!loading && data && data.data.length === 0 && (
+
+ No scores yet for this period. Be the first!
+
+ )}
+
+ {!loading && data && data.data.length > 0 && (
+ <>
+
+
+ #
+ Player
+ Score
+ Time
+ Chain
+
+ {data.data.map((entry: LeaderboardEntry) => (
+
+
+ {entry.rank <= 3 ? (
+
+ {entry.rank}
+
+ ) : (
+ entry.rank
+ )}
+
+ {entry.username}
+
+ {entry.score.toLocaleString()}
+
+
+
+ {formatTime(entry.timeSeconds)}
+
+
+ {entry.chainLength}
+
+
+ ))}
+
+
+ {data.totalPages > 1 && (
+
+ setPage(page - 1)}
+ >
+
+
+
+ Page {page} of {data.totalPages}
+
+ setPage(page + 1)}
+ >
+
+
+
+ )}
+ >
+ )}
+
+
+ );
+}
diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx
new file mode 100644
index 0000000..932592e
--- /dev/null
+++ b/src/pages/Login.tsx
@@ -0,0 +1,114 @@
+import { useState } from 'react';
+import { useNavigate } from 'react-router';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Alert, AlertDescription } from '@/components/ui/alert';
+import { useAuthStore } from '@/stores/auth-store';
+import PageLayout from '@/components/layout/PageLayout';
+import { Loader2 } from 'lucide-react';
+
+export default function Login() {
+ const [isRegister, setIsRegister] = useState(false);
+ const [email, setEmail] = useState('');
+ const [username, setUsername] = useState('');
+ const [password, setPassword] = useState('');
+ const login = useAuthStore((s) => s.login);
+ const register = useAuthStore((s) => s.register);
+ const isLoading = useAuthStore((s) => s.isLoading);
+ const error = useAuthStore((s) => s.error);
+ const navigate = useNavigate();
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ try {
+ if (isRegister) {
+ await register(email, username, password);
+ } else {
+ await login(email, password);
+ }
+ navigate('/profile');
+ } catch {
+ // Error is set in the store
+ }
+ };
+
+ return (
+
+
+
+ {isRegister ? 'Create Account' : 'Sign In'}
+
+
+
+
+
+ {isRegister ? 'Already have an account?' : "Don't have an account?"}{' '}
+ setIsRegister(!isRegister)}
+ className="text-foreground underline underline-offset-2"
+ >
+ {isRegister ? 'Sign in' : 'Create one'}
+
+
+
+
+ );
+}
diff --git a/src/pages/NotFound.tsx b/src/pages/NotFound.tsx
new file mode 100644
index 0000000..49097ea
--- /dev/null
+++ b/src/pages/NotFound.tsx
@@ -0,0 +1,13 @@
+import { Link } from 'react-router'
+
+export default function NotFound() {
+ return (
+
+
404
+
Page not found.
+
+ Back to Home
+
+
+ )
+}
diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx
new file mode 100644
index 0000000..d773dbe
--- /dev/null
+++ b/src/pages/Profile.tsx
@@ -0,0 +1,223 @@
+import { useEffect, useState } from 'react';
+import { useNavigate, Link } from 'react-router';
+import { Button, buttonVariants } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Skeleton } from '@/components/ui/skeleton';
+import { Separator } from '@/components/ui/separator';
+import PageLayout from '@/components/layout/PageLayout';
+import { useAuthStore } from '@/stores/auth-store';
+import { getUserStats, type UserStats } from '@/api/leaderboards';
+import { updateProfile } from '@/api/users';
+import { User, Flame, Trophy, BarChart3, LogOut, Pencil, Check, X } from 'lucide-react';
+
+function formatTime(seconds: number) {
+ const m = Math.floor(seconds / 60);
+ const s = seconds % 60;
+ return `${m}:${s.toString().padStart(2, '0')}`;
+}
+
+export default function Profile() {
+ const user = useAuthStore((s) => s.user);
+ const logout = useAuthStore((s) => s.logout);
+ const loadUser = useAuthStore((s) => s.loadUser);
+ const navigate = useNavigate();
+ const [stats, setStats] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [editing, setEditing] = useState(false);
+ const [displayName, setDisplayName] = useState('');
+
+ useEffect(() => {
+ if (!user) {
+ navigate('/login');
+ return;
+ }
+ getUserStats()
+ .then(setStats)
+ .catch(() => setStats(null))
+ .finally(() => setLoading(false));
+ }, [user, navigate]);
+
+ const handleLogout = () => {
+ logout();
+ navigate('/');
+ };
+
+ const handleSaveProfile = async () => {
+ try {
+ await updateProfile({ displayName: displayName || undefined });
+ await loadUser();
+ setEditing(false);
+ } catch {
+ // Error handled by API client
+ }
+ };
+
+ if (!user) return null;
+
+ return (
+
+
+
+
+
+
+
+
+ {editing ? (
+
+ setDisplayName(e.target.value)}
+ placeholder="Display name"
+ className="h-8 w-40"
+ />
+
+
+
+ setEditing(false)}>
+
+
+
+ ) : (
+
+
{user.username}
+
{
+ setDisplayName('');
+ setEditing(true);
+ }}
+ className="text-muted-foreground hover:text-foreground"
+ aria-label="Edit display name"
+ >
+
+
+
+ )}
+
{user.email}
+
+
+
+
+ Sign Out
+
+
+
+
+
+ {loading && (
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+ )}
+
+ {stats && (
+ <>
+
+ }
+ label="Games Played"
+ value={stats.totalGamesPlayed.toString()}
+ />
+ }
+ label="Best Score"
+ value={stats.bestScore.toLocaleString()}
+ />
+ }
+ label="Current Streak"
+ value={`${stats.currentStreak} day${stats.currentStreak !== 1 ? 's' : ''}`}
+ />
+ }
+ label="Longest Streak"
+ value={`${stats.longestStreak} day${stats.longestStreak !== 1 ? 's' : ''}`}
+ />
+
+
+
+
+ Average Score
+ {stats.averageScore.toLocaleString()}
+
+
+ Total Score
+ {stats.totalScore.toLocaleString()}
+
+
+
+
+
+ View Achievements
+
+
+ {stats.recentScores.length > 0 && (
+
+
Recent Games
+
+
+ Date
+ Score
+ Time
+ Chain
+
+ {stats.recentScores.map((entry, i) => (
+
+
+ {new Date(entry.date).toLocaleDateString()}
+
+
+ {entry.score.toLocaleString()}
+
+
+ {formatTime(entry.timeSeconds)}
+
+
+ {entry.chainLength}
+
+
+ ))}
+
+
+ )}
+ >
+ )}
+
+ {!loading && !stats && (
+
+ No stats yet. Play a daily challenge to get started!
+
+ )}
+
+
+ );
+}
+
+function StatCard({
+ icon,
+ label,
+ value,
+}: {
+ icon: React.ReactNode;
+ label: string;
+ value: string;
+}) {
+ return (
+
+
+ {icon}
+ {label}
+
+
{value}
+
+ );
+}
diff --git a/src/pages/Versus.tsx b/src/pages/Versus.tsx
new file mode 100644
index 0000000..9368c7e
--- /dev/null
+++ b/src/pages/Versus.tsx
@@ -0,0 +1,532 @@
+import { useEffect, useState } from 'react';
+import { useNavigate } from 'react-router';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { DifficultyBadge, getDifficultyButtonClass } from '@/components/ui/difficulty-badge';
+import { Skeleton } from '@/components/ui/skeleton';
+import { Separator } from '@/components/ui/separator';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import PageLayout from '@/components/layout/PageLayout';
+import { useAuthStore } from '@/stores/auth-store';
+import {
+ createMatch,
+ getWaitingMatches,
+ getMyWaitingMatches,
+ joinMatch,
+ cancelMatch,
+ startAsyncAttempt,
+ type VersusMatch,
+} from '@/api/versus';
+import {
+ Swords,
+ Loader2,
+ RefreshCw,
+ Users,
+ Lock,
+ X,
+ ArrowRight,
+ Clock,
+ Zap,
+ Timer,
+ BarChart3,
+} from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { rawToLinkCount } from '@/types';
+
+type Difficulty = 'easy' | 'medium' | 'hard';
+type FilterMode = 'all' | 'sync' | 'async';
+
+function timeRemaining(expiresAt: string | null | undefined) {
+ if (!expiresAt) return null;
+ const remaining = new Date(expiresAt).getTime() - Date.now();
+ if (remaining <= 0) return 'Expired';
+ const hours = Math.floor(remaining / (1000 * 60 * 60));
+ const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60));
+ return `${hours}h ${minutes}m`;
+}
+
+export default function Versus() {
+ const user = useAuthStore((s) => s.user);
+ const navigate = useNavigate();
+ const [matches, setMatches] = useState([]);
+ const [myMatches, setMyMatches] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [creating, setCreating] = useState(false);
+ const [joining, setJoining] = useState(null);
+ const [cancelling, setCancelling] = useState(null);
+ const [difficulty, setDifficulty] = useState('medium');
+ const [lobbyName, setLobbyName] = useState('');
+ const [password, setPassword] = useState('');
+ const [isAsync, setIsAsync] = useState(false);
+ const [filterMode, setFilterMode] = useState('all');
+
+ // Password prompt state
+ const [passwordPromptMatch, setPasswordPromptMatch] = useState(null);
+ const [passwordPromptAction, setPasswordPromptAction] = useState<'join' | 'challenge'>('join');
+ const [joinPassword, setJoinPassword] = useState('');
+ const [joinError, setJoinError] = useState('');
+
+ const fetchMatches = async () => {
+ setLoading(true);
+ try {
+ const [open, mine] = await Promise.all([
+ getWaitingMatches(user?.id, filterMode === 'all' ? undefined : filterMode),
+ getMyWaitingMatches(),
+ ]);
+ setMatches(open);
+ setMyMatches(mine);
+ } catch {
+ setMatches([]);
+ setMyMatches([]);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleCancel = async (matchId: string) => {
+ setCancelling(matchId);
+ try {
+ await cancelMatch(matchId);
+ setMyMatches((prev) => prev.filter((m) => m.id !== matchId));
+ } catch {
+ // Error handled by API client
+ } finally {
+ setCancelling(null);
+ }
+ };
+
+ useEffect(() => {
+ if (!user) {
+ navigate('/login');
+ return;
+ }
+ fetchMatches();
+ }, [user, navigate, filterMode]);
+
+ const handleCreate = async () => {
+ setCreating(true);
+ try {
+ const match = await createMatch(
+ difficulty,
+ lobbyName.trim() || undefined,
+ password.trim() || undefined,
+ isAsync || undefined,
+ );
+ if (isAsync) {
+ navigate(`/versus/async-game/${match.id}`);
+ } else {
+ navigate(`/versus/lobby/${match.id}`);
+ }
+ } catch {
+ // Error handled by API client
+ } finally {
+ setCreating(false);
+ }
+ };
+
+ const handleJoin = async (matchId: string, match: VersusMatch) => {
+ if (match.hasPassword) {
+ setPasswordPromptMatch(match);
+ setPasswordPromptAction('join');
+ setJoinPassword('');
+ setJoinError('');
+ return;
+ }
+ await doJoin(matchId);
+ };
+
+ const handleChallenge = async (matchId: string, match: VersusMatch) => {
+ if (match.hasPassword) {
+ setPasswordPromptMatch(match);
+ setPasswordPromptAction('challenge');
+ setJoinPassword('');
+ setJoinError('');
+ return;
+ }
+ await doChallenge(matchId);
+ };
+
+ const doJoin = async (matchId: string, pw?: string) => {
+ setJoining(matchId);
+ try {
+ await joinMatch(matchId, pw);
+ setPasswordPromptMatch(null);
+ navigate(`/versus/lobby/${matchId}`);
+ } catch (err: unknown) {
+ if (pw) {
+ const message = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
+ setJoinError(message ?? 'Incorrect password');
+ }
+ } finally {
+ setJoining(null);
+ }
+ };
+
+ const doChallenge = async (matchId: string, pw?: string) => {
+ setJoining(matchId);
+ try {
+ await startAsyncAttempt(matchId, pw);
+ setPasswordPromptMatch(null);
+ navigate(`/versus/async-game/${matchId}`);
+ } catch (err: unknown) {
+ const message = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
+ if (pw) {
+ setJoinError(message ?? 'Incorrect password');
+ } else if (message === 'You have already attempted this match') {
+ navigate(`/versus/async/${matchId}/leaderboard`);
+ }
+ } finally {
+ setJoining(null);
+ }
+ };
+
+ const handlePasswordSubmit = () => {
+ if (!passwordPromptMatch) return;
+ if (passwordPromptAction === 'challenge') {
+ doChallenge(passwordPromptMatch.id, joinPassword);
+ } else {
+ doJoin(passwordPromptMatch.id, joinPassword);
+ }
+ };
+
+ if (!user) return null;
+
+ return (
+
+
+
+
+
Versus Mode
+
+
+
+
Create a Match
+
+ {(['easy', 'medium', 'hard'] as Difficulty[]).map((d) => (
+ setDifficulty(d)}
+ >
+ {d.charAt(0).toUpperCase() + d.slice(1)}
+
+ ))}
+
+
+ setLobbyName(e.target.value)}
+ maxLength={50}
+ />
+ setPassword(e.target.value)}
+ maxLength={50}
+ />
+
+
+
+ setIsAsync(e.target.checked)}
+ className="rounded border-border"
+ />
+
+ Async Challenge
+
+ {isAsync && (
+
+ — Play first, then others challenge your score (24h)
+
+ )}
+
+
+ {creating ? (
+ <>
+
+ Creating...
+ >
+ ) : (
+ 'Create Match'
+ )}
+
+
+
+ {/* My Waiting Matches */}
+ {!loading && myMatches.length > 0 && (
+ <>
+
+
+
My Matches
+
+ {myMatches.map((match) => (
+
+
+
+
+ {match.lobbyName || 'Unnamed match'}
+
+ {match.isAsync ? (
+
+ ASYNC
+
+ ) : (
+
+ LIVE
+
+ )}
+
+
+
+ {match.hasPassword && (
+
+ )}
+ {match.isAsync && match.status === 'open' && (
+
+
+ {match.attemptCount ?? 0} challengers
+
+ )}
+
+
+
+ {match.isAsync && match.status === 'open' ? (
+
navigate(`/versus/async/${match.id}/leaderboard`)}
+ >
+
+ Leaderboard
+
+ ) : (
+
{
+ if (match.isAsync) {
+ navigate(`/versus/async-game/${match.id}`);
+ } else {
+ navigate(`/versus/lobby/${match.id}`);
+ }
+ }}
+ >
+
+ {match.isAsync ? 'Play' : 'Rejoin'}
+
+ )}
+ {match.status === 'waiting' && (
+
handleCancel(match.id)}
+ disabled={cancelling === match.id}
+ >
+ {cancelling === match.id ? (
+
+ ) : (
+ <>
+
+ Cancel
+ >
+ )}
+
+ )}
+
+
+ ))}
+
+
+ >
+ )}
+
+
+
+
+
+
+
+
Open Matches
+
+
+
+ Refresh
+
+
+
+ {/* Filter tabs */}
+
+ {([
+ { key: 'all' as const, label: 'All', icon: undefined },
+ { key: 'sync' as const, label: 'Live', icon: Zap },
+ { key: 'async' as const, label: 'Async', icon: Clock },
+ ]).map(({ key, label, icon: Icon }) => (
+ setFilterMode(key)}
+ className={cn(
+ 'flex flex-1 items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
+ filterMode === key
+ ? 'bg-background text-foreground shadow-sm'
+ : 'text-muted-foreground hover:text-foreground',
+ )}
+ >
+ {Icon && }
+ {label}
+
+ ))}
+
+
+ {loading && (
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+ )}
+
+ {!loading && matches.length === 0 && (
+
+ No open matches right now. Create one!
+
+ )}
+
+ {!loading && matches.length > 0 && (
+
+ {matches.map((match) => (
+
+
+
+
+ {match.lobbyName || `${match.player1?.username ?? 'Unknown'}'s match`}
+
+ {match.isAsync ? (
+
+ ASYNC
+
+ ) : (
+
+ LIVE
+
+ )}
+
+
+ by {match.player1?.username ?? 'Unknown'}
+
+ {match.hasPassword && (
+
+ )}
+ {match.isAsync && (
+ <>
+ {match.chainLength != null && (
+
+ Chain: {rawToLinkCount(match.chainLength!)} links
+
+ )}
+ {match.expiresAt && (
+
+
+ {timeRemaining(match.expiresAt)}
+
+ )}
+
+
+ {match.attemptCount ?? 0} challengers
+
+ >
+ )}
+
+
+ {match.isAsync ? (
+
handleChallenge(match.id, match)}
+ disabled={joining === match.id}
+ >
+ {joining === match.id ? (
+
+ ) : (
+ 'Challenge'
+ )}
+
+ ) : (
+
handleJoin(match.id, match)}
+ disabled={joining === match.id}
+ >
+ {joining === match.id ? (
+
+ ) : (
+ 'Join'
+ )}
+
+ )}
+
+ ))}
+
+ )}
+
+
+
+ {/* Password prompt dialog */}
+ {
+ if (!open) setPasswordPromptMatch(null);
+ }}
+ >
+
+
+ Enter Password
+
+
+
{
+ setJoinPassword(e.target.value);
+ setJoinError('');
+ }}
+ onKeyDown={(e) => e.key === 'Enter' && handlePasswordSubmit()}
+ />
+ {joinError && (
+
{joinError}
+ )}
+
+ {joining === passwordPromptMatch?.id ? (
+
+ ) : passwordPromptAction === 'challenge' ? (
+ 'Start Challenge'
+ ) : (
+ 'Join Match'
+ )}
+
+
+
+
+
+ );
+}
diff --git a/src/pages/VersusGame.tsx b/src/pages/VersusGame.tsx
new file mode 100644
index 0000000..57d1daa
--- /dev/null
+++ b/src/pages/VersusGame.tsx
@@ -0,0 +1,126 @@
+import { useEffect } from 'react';
+import { useNavigate } from 'react-router';
+import { useGameStore } from '@/stores/game-store';
+import { useVersusStore } from '@/stores/versus-store';
+import { useChainValidation } from '@/hooks/use-chain-validation';
+import PageLayout from '@/components/layout/PageLayout';
+import GameHeader from '@/components/game/GameHeader';
+import ChainDisplay from '@/components/game/ChainDisplay';
+import SearchAutocomplete from '@/components/game/SearchAutocomplete';
+import ValidationFeedback from '@/components/game/ValidationFeedback';
+import HintButton from '@/components/game/HintButton';
+import OpponentProgress from '@/components/versus/OpponentProgress';
+import VersusCompletionModal from '@/components/versus/VersusCompletionModal';
+import { Button } from '@/components/ui/button';
+import { Separator } from '@/components/ui/separator';
+import { Undo2 } from 'lucide-react';
+
+export default function VersusGame() {
+ const status = useGameStore((s) => s.status);
+ const chain = useGameStore((s) => s.chain);
+ const currentSearchMode = useGameStore((s) => s.currentSearchMode);
+ const passedMovieB = useGameStore((s) => s.passedMovieB);
+ const movieA = useGameStore((s) => s.movieA);
+ const movieB = useGameStore((s) => s.movieB);
+ const isValidating = useGameStore((s) => s.isValidating);
+ const score = useGameStore((s) => s.score);
+ const undoLastLink = useGameStore((s) => s.undoLastLink);
+ const navigate = useNavigate();
+ const { validateAndAddActor, validateAndAddMovie } = useChainValidation();
+ const sendChainUpdate = useVersusStore((s) => s.sendChainUpdate);
+ const sendMatchComplete = useVersusStore((s) => s.sendMatchComplete);
+
+ // Broadcast chain updates to opponent
+ useEffect(() => {
+ if (chain.length > 0) {
+ sendChainUpdate(chain.length);
+ }
+ }, [chain.length, sendChainUpdate]);
+
+ // Send match completion when game completes
+ useEffect(() => {
+ if (status === 'completed' && score) {
+ sendMatchComplete(score);
+ }
+ }, [status, score, sendMatchComplete]);
+
+ useEffect(() => {
+ if (status === 'idle') {
+ navigate('/versus');
+ }
+ }, [status, navigate]);
+
+ if (status === 'idle' || !movieA || !movieB) {
+ return null;
+ }
+
+ const lastLink = chain[chain.length - 1];
+ let searchLabel = '';
+ let searchPlaceholder = '';
+
+ if (currentSearchMode === 'actor' && lastLink?.type === 'movie') {
+ searchLabel = `Who was in "${lastLink.title}"?`;
+ searchPlaceholder = 'Search for an actor...';
+ } else if (currentSearchMode === 'movie' && lastLink?.type === 'actor') {
+ searchLabel = `What movie was ${lastLink.name} also in?`;
+ searchPlaceholder = 'Search for a movie...';
+ }
+
+ const showLoopHint = passedMovieB && currentSearchMode === 'movie' && movieA;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {status === 'playing' && (
+
+
+
+
+
+
{searchLabel}
+
+
+
+
+ Undo
+
+
+
+
+
+
+
+
+ {showLoopHint && (
+
+ Select "{movieA.title}" to close the loop!
+
+ )}
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/pages/VersusLobby.tsx b/src/pages/VersusLobby.tsx
new file mode 100644
index 0000000..7ecaa55
--- /dev/null
+++ b/src/pages/VersusLobby.tsx
@@ -0,0 +1,159 @@
+import { useEffect, useCallback } from 'react';
+import { useParams, useNavigate } from 'react-router';
+import { Button } from '@/components/ui/button';
+import PageLayout from '@/components/layout/PageLayout';
+import { useAuthStore } from '@/stores/auth-store';
+import { useVersusStore } from '@/stores/versus-store';
+import { useGameStore } from '@/stores/game-store';
+import { Loader2, X, Play } from 'lucide-react';
+import type { MoviePair } from '@/types';
+
+export default function VersusLobby() {
+ const { matchId } = useParams<{ matchId: string }>();
+ const navigate = useNavigate();
+ const user = useAuthStore((s) => s.user);
+ const startGameFromVersus = useGameStore((s) => s.startGameFromVersus);
+
+ const {
+ connect,
+ joinLobby,
+ startCountdown,
+ leaveLobby,
+ reset,
+ lobbyState,
+ countdownValue,
+ player1,
+ player2,
+ connected,
+ } = useVersusStore();
+
+ const isCreator = user?.id === player1?.id;
+
+ const handleGameStart = useCallback(
+ async (data: { movieA: MoviePair['movieA']; movieB: MoviePair['movieB']; startTime: number }) => {
+ await startGameFromVersus(
+ { movieA: data.movieA, movieB: data.movieB },
+ data.startTime,
+ );
+ navigate('/versus/game');
+ },
+ [startGameFromVersus, navigate],
+ );
+
+ useEffect(() => {
+ if (!user || !matchId) {
+ navigate('/versus');
+ return;
+ }
+
+ connect();
+ }, [user, matchId, connect, navigate]);
+
+ useEffect(() => {
+ if (connected && matchId) {
+ joinLobby(matchId);
+ }
+ }, [connected, matchId, joinLobby]);
+
+ // Listen for game-start event
+ useEffect(() => {
+ const socket = useVersusStore.getState().socket;
+ if (!socket) return;
+
+ const handler = (data: any) => handleGameStart(data);
+ socket.on('game-start', handler);
+ return () => {
+ socket.off('game-start', handler);
+ };
+ }, [connected, handleGameStart]);
+
+ // Handle match cancellation
+ useEffect(() => {
+ if (lobbyState === 'idle' && connected) {
+ navigate('/versus');
+ }
+ }, [lobbyState, connected, navigate]);
+
+ const handleLeave = () => {
+ leaveLobby();
+ reset();
+ navigate('/versus');
+ };
+
+ if (!user || !matchId) return null;
+
+ return (
+
+
+ {/* Countdown overlay */}
+ {lobbyState === 'countdown' && countdownValue !== null && (
+
+
+ {countdownValue}
+
+
Get ready...
+
+ )}
+
+ {/* Waiting state */}
+ {lobbyState === 'waiting' && (
+
+
Versus Lobby
+
+
+ Waiting for opponent...
+
+
+
{player1?.username ?? 'You'}
+ {isCreator && (
+
+
+ Cancel Match
+
+ )}
+
+
+ )}
+
+ {/* Opponent joined */}
+ {lobbyState === 'opponent-joined' && (
+
+
Opponent Found!
+
+
+
{player1?.username}
+
Creator
+
+
vs
+
+
{player2?.username}
+
Challenger
+
+
+ {isCreator ? (
+
+
+ Start Game
+
+ ) : (
+
+ Waiting for {player1?.username} to start...
+
+ )}
+
+ Leave Lobby
+
+
+ )}
+
+ {/* Loading / connecting */}
+ {(lobbyState === 'idle' || !connected) && lobbyState !== 'countdown' && (
+
+
+ Connecting...
+
+ )}
+
+
+ );
+}
diff --git a/src/stores/auth-store.ts b/src/stores/auth-store.ts
new file mode 100644
index 0000000..f587091
--- /dev/null
+++ b/src/stores/auth-store.ts
@@ -0,0 +1,77 @@
+import { create } from 'zustand';
+import * as authApi from '@/api/auth';
+import { useNotificationStore } from '@/stores/notification-store';
+
+interface User {
+ id: string;
+ email: string;
+ username: string;
+}
+
+interface AuthState {
+ user: User | null;
+ isLoading: boolean;
+ error: string | null;
+
+ login: (email: string, password: string) => Promise;
+ register: (email: string, username: string, password: string) => Promise;
+ logout: () => void;
+ loadUser: () => Promise;
+}
+
+export const useAuthStore = create((set) => ({
+ user: null,
+ isLoading: false,
+ error: null,
+
+ login: async (email, password) => {
+ set({ isLoading: true, error: null });
+ try {
+ const response = await authApi.login(email, password);
+ localStorage.setItem('auth_token', response.access_token);
+ set({ user: response.user, isLoading: false });
+ useNotificationStore.getState().connect();
+ } catch (err: any) {
+ const message =
+ err.response?.data?.message || 'Login failed. Please try again.';
+ set({ error: message, isLoading: false });
+ throw err;
+ }
+ },
+
+ register: async (email, username, password) => {
+ set({ isLoading: true, error: null });
+ try {
+ const response = await authApi.register(email, username, password);
+ localStorage.setItem('auth_token', response.access_token);
+ set({ user: response.user, isLoading: false });
+ useNotificationStore.getState().connect();
+ } catch (err: any) {
+ const message =
+ err.response?.data?.message || 'Registration failed. Please try again.';
+ set({ error: message, isLoading: false });
+ throw err;
+ }
+ },
+
+ logout: () => {
+ localStorage.removeItem('auth_token');
+ useNotificationStore.getState().disconnect();
+ set({ user: null, error: null });
+ },
+
+ loadUser: async () => {
+ const token = localStorage.getItem('auth_token');
+ if (!token) return;
+
+ set({ isLoading: true });
+ try {
+ const profile = await authApi.getProfile();
+ set({ user: { id: profile.id, email: profile.email, username: profile.username }, isLoading: false });
+ useNotificationStore.getState().connect();
+ } catch {
+ localStorage.removeItem('auth_token');
+ set({ user: null, isLoading: false });
+ }
+ },
+}));
diff --git a/src/stores/game-store.ts b/src/stores/game-store.ts
new file mode 100644
index 0000000..e37c154
--- /dev/null
+++ b/src/stores/game-store.ts
@@ -0,0 +1,259 @@
+import { create } from 'zustand';
+import { getMovieDetails } from '@/api/movies';
+import { calculateScore } from '@/lib/scoring';
+import type {
+ ChainLink,
+ MovieChainLink,
+ ActorChainLink,
+ SearchMode,
+ GameStatus,
+ ScoreBreakdown,
+ MoviePair,
+} from '@/types';
+
+interface GameState {
+ movieA: MovieChainLink | null;
+ movieB: MovieChainLink | null;
+ status: GameStatus;
+ chain: ChainLink[];
+ currentSearchMode: SearchMode;
+ passedMovieB: boolean;
+ startTime: number | null;
+ isValidating: boolean;
+ validationError: string | null;
+ score: ScoreBreakdown | null;
+ hintsUsed: number;
+ sessionId: string | null;
+
+ startGame: (pair: MoviePair) => Promise;
+ startGameFromVersus: (pair: MoviePair, startTime: number) => Promise;
+ addActorToChain: (actor: ActorChainLink) => void;
+ addMovieToChain: (movie: MovieChainLink) => void;
+ undoLastLink: () => void;
+ setValidating: (isValidating: boolean) => void;
+ setValidationError: (error: string | null) => void;
+ incrementHintsUsed: () => void;
+ resetGame: () => void;
+}
+
+const initialState = {
+ movieA: null as MovieChainLink | null,
+ movieB: null as MovieChainLink | null,
+ status: 'idle' as GameStatus,
+ chain: [] as ChainLink[],
+ currentSearchMode: 'actor' as SearchMode,
+ passedMovieB: false,
+ startTime: null as number | null,
+ isValidating: false,
+ validationError: null as string | null,
+ score: null as ScoreBreakdown | null,
+ hintsUsed: 0,
+ sessionId: null as string | null,
+};
+
+export const useGameStore = create((set, get) => ({
+ ...initialState,
+
+ startGame: async (pair: MoviePair) => {
+ // Reset if already playing
+ set({ ...initialState, status: 'playing', isValidating: true });
+
+ try {
+ const [detailsA, detailsB] = await Promise.all([
+ getMovieDetails(pair.movieA.id),
+ getMovieDetails(pair.movieB.id),
+ ]);
+
+ const movieALink: MovieChainLink = {
+ type: 'movie',
+ id: detailsA.id,
+ title: detailsA.title,
+ posterPath: detailsA.poster_path,
+ releaseDate: detailsA.release_date,
+ popularity: detailsA.popularity,
+ };
+
+ const movieBLink: MovieChainLink = {
+ type: 'movie',
+ id: detailsB.id,
+ title: detailsB.title,
+ posterPath: detailsB.poster_path,
+ releaseDate: detailsB.release_date,
+ popularity: detailsB.popularity,
+ };
+
+ set({
+ movieA: movieALink,
+ movieB: movieBLink,
+ chain: [movieALink],
+ currentSearchMode: 'actor',
+ startTime: Date.now(),
+ isValidating: false,
+ });
+
+ // Fire-and-forget backend session creation
+ import('@/api/games').then(({ createGameSession }) => {
+ createGameSession(
+ pair.movieA.id,
+ pair.movieA.title,
+ pair.movieB.id,
+ pair.movieB.title,
+ )
+ .then((session) => set({ sessionId: session.id }))
+ .catch(() => {
+ // Backend unavailable — game continues without persistence
+ });
+ });
+ } catch {
+ set({
+ status: 'idle',
+ isValidating: false,
+ validationError: 'Failed to load movie details. Please try again.',
+ });
+ }
+ },
+
+ startGameFromVersus: async (pair: MoviePair, startTime: number) => {
+ set({ ...initialState, status: 'playing', isValidating: true });
+
+ try {
+ const [detailsA, detailsB] = await Promise.all([
+ getMovieDetails(pair.movieA.id),
+ getMovieDetails(pair.movieB.id),
+ ]);
+
+ const movieALink: MovieChainLink = {
+ type: 'movie',
+ id: detailsA.id,
+ title: detailsA.title,
+ posterPath: detailsA.poster_path,
+ releaseDate: detailsA.release_date,
+ popularity: detailsA.popularity,
+ };
+
+ const movieBLink: MovieChainLink = {
+ type: 'movie',
+ id: detailsB.id,
+ title: detailsB.title,
+ posterPath: detailsB.poster_path,
+ releaseDate: detailsB.release_date,
+ popularity: detailsB.popularity,
+ };
+
+ set({
+ movieA: movieALink,
+ movieB: movieBLink,
+ chain: [movieALink],
+ currentSearchMode: 'actor',
+ startTime,
+ isValidating: false,
+ });
+ } catch {
+ set({
+ status: 'idle',
+ isValidating: false,
+ validationError: 'Failed to load movie details. Please try again.',
+ });
+ }
+ },
+
+ addActorToChain: (actor: ActorChainLink) => {
+ const { status, chain } = get();
+ if (status !== 'playing') return;
+
+ set({
+ chain: [...chain, actor],
+ currentSearchMode: 'movie',
+ validationError: null,
+ });
+ },
+
+ addMovieToChain: (movie: MovieChainLink) => {
+ const { status, chain, movieA, movieB, passedMovieB, startTime, hintsUsed } = get();
+ if (status !== 'playing') return;
+
+ const newChain = [...chain, movie];
+
+ // Check if this movie is movieB
+ const hasPassedMovieB = passedMovieB || movie.id === movieB?.id;
+
+ // Check for loop completion:
+ // Chain must have >= 5 links, have visited movieB, and last movie is movieA
+ if (
+ hasPassedMovieB &&
+ movieA &&
+ movie.id === movieA.id &&
+ newChain.length >= 5
+ ) {
+ const completedAt = Date.now();
+ const score = calculateScore(
+ newChain,
+ startTime!,
+ completedAt,
+ hintsUsed,
+ );
+
+ set({
+ chain: newChain,
+ status: 'completed',
+ passedMovieB: true,
+ score,
+ currentSearchMode: 'actor',
+ validationError: null,
+ });
+
+ // Fire-and-forget backend completion
+ const { sessionId } = get();
+ if (sessionId) {
+ import('@/api/games').then(({ completeGameSession }) => {
+ completeGameSession(sessionId, newChain, hintsUsed).catch(() => {
+ // Backend unavailable — score already calculated client-side
+ });
+ });
+ }
+ return;
+ }
+
+ set({
+ chain: newChain,
+ currentSearchMode: 'actor',
+ passedMovieB: hasPassedMovieB,
+ validationError: null,
+ });
+ },
+
+ undoLastLink: () => {
+ const { status, chain, movieB } = get();
+ if (status !== 'playing') return;
+ if (chain.length <= 1) return;
+
+ const newChain = chain.slice(0, -1);
+ const lastLink = newChain[newChain.length - 1];
+
+ // Recompute passedMovieB
+ const hasPassedMovieB = newChain.some(
+ (link) => link.type === 'movie' && link.id === movieB?.id,
+ );
+
+ // Determine search mode from the last link
+ const newSearchMode: SearchMode =
+ lastLink.type === 'movie' ? 'actor' : 'movie';
+
+ set({
+ chain: newChain,
+ currentSearchMode: newSearchMode,
+ passedMovieB: hasPassedMovieB,
+ validationError: null,
+ });
+ },
+
+ setValidating: (isValidating: boolean) => set({ isValidating }),
+
+ setValidationError: (error: string | null) =>
+ set({ validationError: error }),
+
+ incrementHintsUsed: () =>
+ set((state) => ({ hintsUsed: state.hintsUsed + 1 })),
+
+ resetGame: () => set(initialState),
+}));
diff --git a/src/stores/notification-store.ts b/src/stores/notification-store.ts
new file mode 100644
index 0000000..dd2b9be
--- /dev/null
+++ b/src/stores/notification-store.ts
@@ -0,0 +1,123 @@
+import { create } from 'zustand';
+import { io, Socket } from 'socket.io-client';
+import { getWsUrl } from '@/lib/ws';
+import * as notificationsApi from '@/api/notifications';
+import type { Notification } from '@/api/notifications';
+
+interface NotificationState {
+ notifications: Notification[];
+ unreadCount: number;
+ socket: Socket | null;
+
+ connect: () => void;
+ disconnect: () => void;
+ fetchNotifications: () => Promise;
+ fetchUnreadCount: () => Promise;
+ markRead: (id: string) => Promise;
+ markAllRead: () => Promise;
+ dismiss: (id: string) => Promise;
+}
+
+export const useNotificationStore = create((set, get) => ({
+ notifications: [],
+ unreadCount: 0,
+ socket: null,
+
+ connect: () => {
+ const existing = get().socket;
+ if (existing?.connected) return;
+
+ const token = localStorage.getItem('auth_token');
+ if (!token) return;
+
+ const socket = io(getWsUrl('/notifications'), {
+ auth: { token },
+ transports: ['websocket'],
+ });
+
+ socket.on('connect', () => {
+ // Fetch initial state on connect
+ get().fetchUnreadCount();
+ });
+
+ socket.on('notification', (notification: Notification) => {
+ set((state) => ({
+ notifications: [notification, ...state.notifications],
+ }));
+ });
+
+ socket.on('unread-count', (count: number) => {
+ set({ unreadCount: count });
+ });
+
+ set({ socket });
+ },
+
+ disconnect: () => {
+ const { socket } = get();
+ if (socket) {
+ socket.disconnect();
+ set({ socket: null, notifications: [], unreadCount: 0 });
+ }
+ },
+
+ fetchNotifications: async () => {
+ try {
+ const result = await notificationsApi.getNotifications();
+ set({ notifications: result.data, unreadCount: result.unreadCount });
+ } catch {
+ // Silently fail
+ }
+ },
+
+ fetchUnreadCount: async () => {
+ try {
+ const count = await notificationsApi.getUnreadCount();
+ set({ unreadCount: count });
+ } catch {
+ // Silently fail
+ }
+ },
+
+ markRead: async (id: string) => {
+ // Optimistic update
+ set((state) => ({
+ notifications: state.notifications.map((n) =>
+ n.id === id ? { ...n, read: true } : n,
+ ),
+ unreadCount: Math.max(0, state.unreadCount - 1),
+ }));
+ try {
+ await notificationsApi.markNotificationRead(id);
+ } catch {
+ // Revert on failure
+ get().fetchNotifications();
+ }
+ },
+
+ markAllRead: async () => {
+ const prev = get().notifications;
+ set((state) => ({
+ notifications: state.notifications.map((n) => ({ ...n, read: true })),
+ unreadCount: 0,
+ }));
+ try {
+ await notificationsApi.markAllNotificationsRead();
+ } catch {
+ set({ notifications: prev });
+ get().fetchUnreadCount();
+ }
+ },
+
+ dismiss: async (id: string) => {
+ const prev = get().notifications;
+ set((state) => ({
+ notifications: state.notifications.filter((n) => n.id !== id),
+ }));
+ try {
+ await notificationsApi.deleteNotification(id);
+ } catch {
+ set({ notifications: prev });
+ }
+ },
+}));
diff --git a/src/stores/versus-store.ts b/src/stores/versus-store.ts
new file mode 100644
index 0000000..33129ae
--- /dev/null
+++ b/src/stores/versus-store.ts
@@ -0,0 +1,192 @@
+import { create } from 'zustand';
+import { io, Socket } from 'socket.io-client';
+import { getWsUrl } from '@/lib/ws';
+
+export type LobbyState =
+ | 'idle'
+ | 'waiting'
+ | 'opponent-joined'
+ | 'countdown'
+ | 'playing'
+ | 'finished';
+
+interface MatchResult {
+ winnerId: string | null;
+ player1: { id: string; username: string };
+ player2: { id: string; username: string };
+ player1Score: object | null;
+ player2Score: object | null;
+}
+
+interface VersusState {
+ socket: Socket | null;
+ connected: boolean;
+ matchId: string | null;
+ lobbyState: LobbyState;
+ countdownValue: number | null;
+ player1: { id: string; username: string } | null;
+ player2: { id: string; username: string } | null;
+ opponentChainLength: number;
+ opponentFinished: boolean;
+ matchResult: MatchResult | null;
+ myScore: object | null;
+
+ connect: () => void;
+ disconnect: () => void;
+ joinLobby: (matchId: string) => void;
+ startCountdown: () => void;
+ sendChainUpdate: (chainLength: number) => void;
+ sendMatchComplete: (score: object) => void;
+ leaveLobby: () => void;
+ reset: () => void;
+}
+
+const initialState = {
+ socket: null as Socket | null,
+ connected: false,
+ matchId: null as string | null,
+ lobbyState: 'idle' as LobbyState,
+ countdownValue: null as number | null,
+ player1: null as { id: string; username: string } | null,
+ player2: null as { id: string; username: string } | null,
+ opponentChainLength: 0,
+ opponentFinished: false,
+ matchResult: null as MatchResult | null,
+ myScore: null as object | null,
+};
+
+export const useVersusStore = create((set, get) => ({
+ ...initialState,
+
+ connect: () => {
+ const existing = get().socket;
+ if (existing?.connected) return;
+
+ const token = localStorage.getItem('auth_token');
+ if (!token) return;
+
+ const socket = io(getWsUrl('/versus'), {
+ auth: { token },
+ transports: ['websocket'],
+ });
+
+ socket.on('connect', () => {
+ set({ connected: true });
+ });
+
+ socket.on('disconnect', () => {
+ set({ connected: false });
+ });
+
+ socket.on('lobby-state', (data) => {
+ set({
+ lobbyState: data.player2 ? 'opponent-joined' : 'waiting',
+ player1: data.player1,
+ player2: data.player2,
+ });
+ });
+
+ socket.on('player-joined', (data) => {
+ set({
+ lobbyState: 'opponent-joined',
+ player1: data.player1,
+ player2: data.player2,
+ });
+ });
+
+ socket.on('player-left', () => {
+ set({
+ lobbyState: 'waiting',
+ player2: null,
+ });
+ });
+
+ socket.on('countdown', (data: { value: number }) => {
+ set({ lobbyState: 'countdown', countdownValue: data.value });
+ });
+
+ socket.on('game-start', (data) => {
+ set({ lobbyState: 'playing' });
+ // The VersusLobby page will handle navigation using this event
+ // Store the event data for the page to consume
+ const handler = (get() as any)._onGameStart;
+ if (handler) handler(data);
+ });
+
+ socket.on('opponent-progress', (data: { chainLength: number }) => {
+ set({ opponentChainLength: data.chainLength });
+ });
+
+ socket.on('opponent-finished', () => {
+ set({ opponentFinished: true });
+ });
+
+ socket.on('match-finished', (data) => {
+ set({
+ lobbyState: 'finished',
+ matchResult: {
+ winnerId: data.winnerId,
+ player1: data.player1,
+ player2: data.player2,
+ player1Score: data.player1Score,
+ player2Score: data.player2Score,
+ },
+ });
+ });
+
+ socket.on('match-cancelled', () => {
+ set({ lobbyState: 'idle', matchId: null });
+ });
+
+ set({ socket });
+ },
+
+ disconnect: () => {
+ const { socket } = get();
+ if (socket) {
+ socket.disconnect();
+ set({ socket: null, connected: false });
+ }
+ },
+
+ joinLobby: (matchId: string) => {
+ const { socket } = get();
+ if (!socket) return;
+ set({ matchId });
+ socket.emit('join-lobby', { matchId });
+ },
+
+ startCountdown: () => {
+ const { socket, matchId } = get();
+ if (!socket || !matchId) return;
+ socket.emit('start-countdown', { matchId });
+ },
+
+ sendChainUpdate: (chainLength: number) => {
+ const { socket, matchId } = get();
+ if (!socket || !matchId) return;
+ socket.emit('chain-update', { matchId, chainLength });
+ },
+
+ sendMatchComplete: (score: object) => {
+ const { socket, matchId } = get();
+ if (!socket || !matchId) return;
+ set({ myScore: score });
+ socket.emit('match-complete', { matchId, score });
+ },
+
+ leaveLobby: () => {
+ const { socket, matchId } = get();
+ if (!socket || !matchId) return;
+ socket.emit('leave-lobby', { matchId });
+ set({ matchId: null, lobbyState: 'idle' });
+ },
+
+ reset: () => {
+ const { socket } = get();
+ if (socket) {
+ socket.disconnect();
+ }
+ set({ ...initialState });
+ },
+}));
diff --git a/src/types/game.ts b/src/types/game.ts
new file mode 100644
index 0000000..0d3e4f6
--- /dev/null
+++ b/src/types/game.ts
@@ -0,0 +1,52 @@
+export type SearchMode = 'actor' | 'movie';
+export type GameStatus = 'idle' | 'playing' | 'completed';
+
+export interface MovieChainLink {
+ type: 'movie';
+ id: number;
+ title: string;
+ posterPath: string | null;
+ releaseDate: string;
+ popularity: number;
+}
+
+export interface ActorChainLink {
+ type: 'actor';
+ id: number;
+ name: string;
+ profilePath: string | null;
+ popularity: number;
+}
+
+export type ChainLink = MovieChainLink | ActorChainLink;
+
+export interface MoviePair {
+ movieA: { id: number; title: string };
+ movieB: { id: number; title: string };
+}
+
+export interface PresetMoviePair extends MoviePair {
+ description?: string;
+}
+
+export interface ScoreBreakdown {
+ baseScore: number;
+ chainLength: number;
+ linkCount: number;
+ chainLengthBonus: number;
+ timeBonus: number;
+ obscurityBonus: number;
+ hintPenalty: number;
+ elapsedSeconds: number;
+ totalScore: number;
+}
+
+/** Number of actor-movie pair "links" in a chain (excludes starting movie) */
+export function getLinkCount(chain: ChainLink[]): number {
+ return Math.floor((chain.length - 1) / 2);
+}
+
+/** Convert a raw chain length number to link count */
+export function rawToLinkCount(rawLength: number): number {
+ return Math.floor((rawLength - 1) / 2);
+}
diff --git a/src/types/index.ts b/src/types/index.ts
new file mode 100644
index 0000000..359ac4a
--- /dev/null
+++ b/src/types/index.ts
@@ -0,0 +1,2 @@
+export * from './tmdb';
+export * from './game';
diff --git a/src/types/tmdb.ts b/src/types/tmdb.ts
new file mode 100644
index 0000000..dad2656
--- /dev/null
+++ b/src/types/tmdb.ts
@@ -0,0 +1,97 @@
+// TMDB API response types (mirrors backend types)
+
+export interface TmdbMovieResult {
+ id: number;
+ title: string;
+ original_title: string;
+ overview: string;
+ poster_path: string | null;
+ backdrop_path: string | null;
+ release_date: string;
+ popularity: number;
+ vote_average: number;
+ vote_count: number;
+ genre_ids: number[];
+ adult: boolean;
+}
+
+export interface TmdbPersonResult {
+ id: number;
+ name: string;
+ profile_path: string | null;
+ popularity: number;
+ known_for_department: string;
+ known_for: TmdbMovieResult[];
+ adult: boolean;
+}
+
+export interface TmdbCastMember {
+ id: number;
+ name: string;
+ character: string;
+ profile_path: string | null;
+ popularity: number;
+ known_for_department: string;
+ order: number;
+ adult: boolean;
+}
+
+export interface TmdbPersonCastCredit {
+ id: number;
+ title: string;
+ original_title: string;
+ character: string;
+ poster_path: string | null;
+ release_date: string;
+ popularity: number;
+ vote_average: number;
+ vote_count: number;
+ adult: boolean;
+}
+
+export interface TmdbSearchMovieResponse {
+ page: number;
+ results: TmdbMovieResult[];
+ total_pages: number;
+ total_results: number;
+}
+
+export interface TmdbSearchPersonResponse {
+ page: number;
+ results: TmdbPersonResult[];
+ total_pages: number;
+ total_results: number;
+}
+
+export interface TmdbMovieCreditsResponse {
+ id: number;
+ cast: TmdbCastMember[];
+}
+
+export interface TmdbPersonMovieCreditsResponse {
+ id: number;
+ cast: TmdbPersonCastCredit[];
+}
+
+export interface TmdbMovieDetailsResponse extends TmdbMovieResult {
+ runtime: number | null;
+ budget: number;
+ revenue: number;
+ status: string;
+ tagline: string;
+ genres: { id: number; name: string }[];
+}
+
+export interface TmdbPersonDetailsResponse {
+ id: number;
+ name: string;
+ biography: string;
+ profile_path: string | null;
+ birthday: string | null;
+ deathday: string | null;
+ place_of_birth: string | null;
+ popularity: number;
+ known_for_department: string;
+ also_known_as: string[];
+ adult: boolean;
+}
diff --git a/vite.config.ts b/vite.config.ts
index 26303df..377d3a9 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -16,6 +16,11 @@ export default defineConfig({
target: 'http://localhost:3000',
changeOrigin: true,
},
+ '/socket.io': {
+ target: 'http://localhost:3000',
+ ws: true,
+ changeOrigin: true,
+ },
},
},
})