import { useEffect, useRef, useState } from "react" import { Link } from "wouter" import { motion } from "framer-motion" import { Check, ClipboardList, Crown, ListChecks, Music } from "lucide-react" import { SiSpotify, SiYoutube } from "@icons-pack/react-simple-icons" import type { BlindtestTrackInfo, PlayerScore, RoomSnapshot, RoundRecap, } from "@nerdware/shared" import { Button } from "@workspace/ui/components/button" const SERVER_URL = import.meta.env.VITE_SERVER_URL ?? "http://localhost:3001" const recapImage = (url: string) => url.startsWith("http") ? url : `${SERVER_URL}${url}` import { useRoomStore } from "@/store/room" import { Avatar } from "@/components/avatar" import { celebrate } from "@/lib/confetti" import { playVictory } from "@/lib/sound" /** Lien de recherche Spotify (le titre vidéo contient en général artiste + chanson). */ function spotifySearch(track: BlindtestTrackInfo): string { const query = `${track.title} ${track.artist}`.trim() return `https://open.spotify.com/search/${encodeURIComponent(query)}` } type Place = 1 | 2 | 3 // Hauteur de marche, couleurs (or / argent / bronze) et taille d'avatar par place. const PODIUM: Record = { 1: { bar: "h-24", pedestal: "border-amber-400 bg-gradient-to-b from-amber-400/30 to-amber-400/5", text: "text-amber-400", ring: "ring-amber-400", avatar: "size-20", }, 2: { bar: "h-16", pedestal: "border-zinc-300/70 bg-gradient-to-b from-zinc-300/20 to-zinc-300/5", text: "text-zinc-300", ring: "ring-zinc-300", avatar: "size-16", }, 3: { bar: "h-12", pedestal: "border-amber-700/70 bg-gradient-to-b from-amber-700/25 to-amber-700/5", text: "text-amber-600", ring: "ring-amber-700", avatar: "size-16", }, } function PodiumColumn({ entry, place, name, isMe, delay, }: { entry: PlayerScore place: Place name: string isMe: boolean delay: number }) { const cfg = PODIUM[place] return (
{place === 1 && ( )}
{name} {isMe && (toi)} {entry.score}
{place}
) } export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) { const playerId = useRoomStore((s) => s.playerId) const finalScores = useRoomStore((s) => s.finalScores) const gameTracks = useRoomStore((s) => s.gameTracks) const gameRecap = useRoomStore((s) => s.gameRecap) const reset = useRoomStore((s) => s.reset) const returnToLobby = useRoomStore((s) => s.returnToLobby) const isHost = snapshot.hostId === playerId // Confettis de victoire (une fois à l'arrivée sur l'écran de fin). const celebrated = useRef(false) useEffect(() => { if (!celebrated.current) { celebrated.current = true celebrate() playVictory() } }, []) const [copied, setCopied] = useState(false) async function copyResults() { const lines = ["NerdWare — Résultats", "", "🏆 Classement"] ranked.forEach((s, i) => lines.push(`${i + 1}. ${nameOf(s.playerId)} — ${s.score}`) ) if (gameRecap && gameRecap.length > 0) { lines.push("", "Manches") gameRecap.forEach((r) => lines.push( `${r.index + 1}. ${r.type === "blindtest" ? "🎧" : "🧠"} ${r.answer}` ) ) } try { await navigator.clipboard.writeText(lines.join("\n")) setCopied(true) setTimeout(() => setCopied(false), 1500) } catch { // presse-papier indisponible : on ignore } } const scores: PlayerScore[] = finalScores ?? snapshot.scores const ranked = [...scores].sort((a, b) => b.score - a.score) const nameOf = (id: string) => snapshot.players.find((p) => p.id === id)?.name ?? "?" const isMe = (id: string) => id === playerId const [first, second, third] = ranked const rest = ranked.slice(3) return (

Partie terminée

🏆 {first ? nameOf(first.playerId) : "?"} l'emporte !

{/* Podium : 2e à gauche, champion au centre, 3e à droite. */}
{second && ( )} {first && ( )} {third && ( )}
{rest.length > 0 && (
    {rest.map((s, i) => (
  1. {i + 4} {nameOf(s.playerId)} {isMe(s.playerId) && ( (toi) )} {s.score}
  2. ))}
)} {gameRecap && gameRecap.length > 0 && ( )} {gameTracks && gameTracks.length > 0 && ( )} {gameRecap && gameRecap.length > 0 && ( )}
{isHost ? ( ) : (

En attente que l'hôte relance une partie…

)}
) } function TracksRecap({ tracks }: { tracks: BlindtestTrackInfo[] }) { const [copied, setCopied] = useState(false) async function copyPlaylist() { const text = tracks.map((t) => `${t.title} — ${t.artist}`).join("\n") try { await navigator.clipboard.writeText(text) setCopied(true) setTimeout(() => setCopied(false), 1500) } catch { // presse-papier indisponible : on ignore } } return (

Les musiques de la partie

) } interface PlayerStat { correct: number answered: number decoy: number speed: number } // Points de base d'une bonne réponse quiz/image ; le surplus = bonus de vitesse. const QUIZ_BASE_POINTS = 100 function FunStats({ recap, snapshot, }: { recap: RoundRecap[] snapshot: RoomSnapshot }) { const stats = new Map( snapshot.players.map((p) => [ p.id, { correct: 0, answered: 0, decoy: 0, speed: 0 }, ]) ) for (const r of recap) { const answered = new Set(r.answers.map((a) => a.playerId)) const pointsBy = new Map(r.scorers.map((sc) => [sc.playerId, sc.points])) for (const a of r.answers) { const s = stats.get(a.playerId) if (s) { s.answered++ if (a.correct) { s.correct++ // Bonus de vitesse = points au-dessus de la base (quiz/images). if (r.type !== "blindtest") { s.speed += Math.max(0, (pointsBy.get(a.playerId) ?? 0) - QUIZ_BASE_POINTS) } } } } if (r.type === "blindtest") { for (const sc of r.scorers) { if (!answered.has(sc.playerId)) { const s = stats.get(sc.playerId) if (s) s.decoy += sc.points } } } } const players = snapshot.players const total = recap.length const nameOf = (id: string) => players.find((p) => p.id === id)?.name ?? "?" const stat = (id: string) => stats.get(id) ?? { correct: 0, answered: 0, decoy: 0, speed: 0 } const winners = (pick: (s: PlayerStat) => number, min = 1) => { const max = Math.max(0, ...players.map((p) => pick(stat(p.id)))) return max >= min ? players.filter((p) => pick(stat(p.id)) === max) : [] } const awards: { emoji: string; label: string; ids: string[] }[] = [] const perfect = players.filter( (p) => total > 0 && stat(p.id).correct === total ) if (perfect.length) { awards.push({ emoji: "🎯", label: "Sans-faute", ids: perfect.map((p) => p.id) }) } const brains = winners((s) => s.correct) if (brains.length && brains.length < players.length) { awards.push({ emoji: "🧠", label: "Le cerveau", ids: brains.map((p) => p.id) }) } const fastest = winners((s) => s.speed) if (fastest.length && fastest.length < players.length) { awards.push({ emoji: "⚡", label: "Le plus rapide", ids: fastest.map((p) => p.id), }) } const decoy = winners((s) => s.decoy) if (decoy.length) { awards.push({ emoji: "🦹", label: "Roi de la tromperie", ids: decoy.map((p) => p.id), }) } const boulets = players.filter( (p) => stat(p.id).answered > 0 && stat(p.id).correct === 0 ) if (boulets.length && total >= 3) { awards.push({ emoji: "🪨", label: "Le boulet", ids: boulets.map((p) => p.id) }) } if (awards.length === 0) { return null } return (

Récompenses

{awards.map((a) => (
{a.emoji} {a.label}
{a.ids.map((id) => ( {nameOf(id)} ))}
))}
) } function RoundsRecap({ recap, nameOf, playerId, }: { recap: RoundRecap[] nameOf: (id: string) => string playerId: string | null }) { return (
Récapitulatif des manches ({recap.length})
    {recap.map((r) => { const pointsBy = new Map(r.scorers.map((s) => [s.playerId, s.points])) const found = r.answers.filter((a) => a.correct).length // Tri : plus de points d'abord, puis bonnes réponses. const answers = [...r.answers].sort( (a, b) => (pointsBy.get(b.playerId) ?? 0) - (pointsBy.get(a.playerId) ?? 0) || Number(b.correct) - Number(a.correct) ) return (
  • {r.youtubeId ? ( ) : r.imageUrl ? ( ) : null}

    {r.index + 1} ·{" "} {r.category ?? (r.type === "blindtest" ? "Blindtest" : "Quiz")} {" · "} ✓ {found}

    {r.type !== "blindtest" && (

    {r.label}

    )}

    {r.answer} {r.addedBy && ( {" "} · {r.addedBy} )}

      {answers.length === 0 ? (
    • Aucune réponse
    • ) : ( answers.map((a) => (
    • {nameOf(a.playerId)} {a.playerId === playerId && " (toi)"} : {a.answer || "—"} {pointsBy.get(a.playerId) ? ( +{pointsBy.get(a.playerId)} ) : null}
    • )) )}
  • ) })}
) }