import React, { useState, useEffect } from 'react'; import { Player, PointsBreakdown } from '../types'; import { motion, AnimatePresence, useSpring, useTransform, LayoutGroup } from 'framer-motion'; import { Loader2, Flame, Rocket, Zap, X, Crown, Medal, Trophy } from 'lucide-react'; import { PlayerAvatar, getAvatarColors } from './PlayerAvatar'; const AnimatedNumber: React.FC<{ value: number; duration?: number }> = ({ value, duration = 600 }) => { const spring = useSpring(0, { duration }); const display = useTransform(spring, (latest) => Math.round(latest)); const [displayValue, setDisplayValue] = useState(0); useEffect(() => { spring.set(value); const unsubscribe = display.on('change', (v) => setDisplayValue(v)); return () => unsubscribe(); }, [value, spring, display]); return {displayValue}; }; interface BonusBadgeProps { points: number; label: string; icon: React.ReactNode; color: string; delay: number; } const BonusBadge: React.FC = ({ points, label, icon, color, delay }) => ( {icon} +{points} {label} ); const PenaltyBadge: React.FC<{ points: number; delay: number }> = ({ points, delay }) => ( -{points} ); interface PlayerRowProps { player: Player & { displayName: string }; maxScore: number; rank: number; currentScore: number; phase: number; baseDelay: number; } const PlayerRow: React.FC = ({ player, maxScore, rank, currentScore, phase, baseDelay }) => { const breakdown = player.pointsBreakdown; const barWidth = maxScore > 0 ? (currentScore / maxScore) * 100 : 0; const isFirst = rank === 1; const isSecond = rank === 2; const isThird = rank === 3; const isTop3 = rank <= 3; let rankStyles = "bg-white border-gray-100"; let rankBadgeStyles = "bg-gray-100 text-gray-500"; if (isFirst) { rankStyles = "bg-gradient-to-r from-yellow-50 to-amber-100 border-amber-300 shadow-lg scale-[1.02] z-10"; rankBadgeStyles = "bg-gradient-to-br from-yellow-400 to-amber-600 text-white shadow-sm"; } else if (isSecond) { rankStyles = "bg-gradient-to-r from-gray-50 to-slate-100 border-slate-300 shadow-md z-0"; rankBadgeStyles = "bg-gradient-to-br from-slate-300 to-slate-500 text-white shadow-sm"; } else if (isThird) { rankStyles = "bg-gradient-to-r from-orange-50 to-orange-100 border-orange-200 shadow-md z-0"; rankBadgeStyles = "bg-gradient-to-br from-orange-300 to-orange-500 text-white shadow-sm"; } return (
{rank}
{isFirst && ( )}
{player.displayName}
{breakdown === null && ( No answer )} {breakdown && breakdown.penalty > 0 && phase >= 1 && ( )} {breakdown && breakdown.basePoints > 0 && phase >= 1 && ( +{breakdown.basePoints} )} {breakdown && breakdown.streakBonus > 0 && phase >= 2 && ( } color="bg-amber-500" delay={0} /> )} {breakdown && breakdown.comebackBonus > 0 && phase >= 3 && ( } color="bg-blue-500" delay={0} /> )} {breakdown && breakdown.firstCorrectBonus > 0 && phase >= 4 && ( } color="bg-yellow-500" delay={0} /> )}
); }; interface ScoreboardProps { players: Player[]; onNext: () => void; isHost: boolean; currentPlayerId: string | null; } interface AnimatedPlayerState extends Player { displayName: string; currentScore: number; phase: number; initialIndex: number; } export const Scoreboard: React.FC = ({ players, onNext, isHost, currentPlayerId }) => { // Initialize players sorted by previousScore to start const [animatedPlayers, setAnimatedPlayers] = useState(() => { const playersWithMeta = players.map(p => ({ ...p, displayName: p.id === currentPlayerId ? `${p.name} (You)` : p.name, currentScore: p.previousScore, phase: 0, })); // Sort by previous score initially (descending) // Add a secondary sort by ID to ensure stable sorting return playersWithMeta .sort((a, b) => (b.previousScore - a.previousScore) || a.id.localeCompare(b.id)) .map((p, index) => ({ ...p, initialIndex: index // Store the initial rank for staggered animation timing })); }); useEffect(() => { const timers: NodeJS.Timeout[] = []; // We use the initial state to set up the timers // We can't use animatedPlayers in the dependency array or it will loop // But we need a reference to the initial setup to schedule things. // The state initializer runs once, so we need to reconstruct that list or trust the state is fresh on mount. // However, inside useEffect, animatedPlayers might change if we include it in deps. // We want to schedule based on the INITIAL state. // Let's grab the initial list again to be safe and consistent with the initializer logic const initialPlayers = players.map(p => ({ ...p, previousScore: p.previousScore, pointsBreakdown: p.pointsBreakdown })).sort((a, b) => (b.previousScore - a.previousScore) || a.id.localeCompare(b.id)); initialPlayers.forEach((player, initialIndex) => { const breakdown = player.pointsBreakdown; const baseDelay = initialIndex * 0.1; // Helper to update state const updatePlayerState = (phase: number, scoreToAdd: number) => { setAnimatedPlayers(prev => { const updated = prev.map(p => { if (p.id !== player.id) return p; return { ...p, phase, currentScore: p.currentScore + scoreToAdd }; }); // Re-sort on every update return updated.sort((a, b) => (b.currentScore - a.currentScore) || a.id.localeCompare(b.id)); }); }; if (!breakdown) return; // Phase 1: Base Points + Penalty // (baseDelay + 0.2)s timers.push(setTimeout(() => { const points = breakdown.basePoints - breakdown.penalty; updatePlayerState(1, points); }, (baseDelay + 0.2) * 1000)); // Phase 2: Streak // (baseDelay + 0.8)s if (breakdown.streakBonus > 0) { timers.push(setTimeout(() => { updatePlayerState(2, breakdown.streakBonus); }, (baseDelay + 0.8) * 1000)); } // Phase 3: Comeback // (baseDelay + 1.2)s if (breakdown.comebackBonus > 0) { timers.push(setTimeout(() => { updatePlayerState(3, breakdown.comebackBonus); }, (baseDelay + 1.2) * 1000)); } // Phase 4: First Correct // (baseDelay + 1.6)s if (breakdown.firstCorrectBonus > 0) { timers.push(setTimeout(() => { updatePlayerState(4, breakdown.firstCorrectBonus); }, (baseDelay + 1.6) * 1000)); } }); return () => timers.forEach(clearTimeout); }, []); // Run once on mount // Calculate max score based on FINAL scores (so the bar scale is consistent/correct relative to the winner) const maxScore = Math.max(...players.map(p => p.score), 1); return (

Scoreboard

{animatedPlayers.map((player, index) => ( ))}
{isHost ? ( ) : (
Waiting for host...
)}
); };