nerdware/apps/web/src/components/game-end-view.tsx
AyoubBenziza 1e5ef2be90 feat(web): i18n (FR/EN) — homemade typed dictionary
- lightweight, dependency-free i18n: typed fr/en dictionaries (fr is the source
  of truth, en must match its shape → compile error on drift), I18nProvider +
  useI18n hook, browser detection + localStorage persistence
- FR/EN language switcher (home + room header)
- translated the whole player-facing flow: home, join, pseudo, room shell,
  lobby (incl. settings, categories, bots, track submission, history),
  quiz, blindtest, player-cards, round transitions, end screen (podium, awards,
  tracks, rounds recap), and server-error messages (mapped by code)
- history dates localized to the active language

Content (questions/categories) intentionally left as-is. Back-office (admin)
not translated yet — internal, token-gated tool.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 19:03:28 +02:00

616 lines
20 KiB
TypeScript

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"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@workspace/ui/components/tooltip"
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"
import { useI18n, type Dict } from "@/i18n/context"
/** 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<Place, {
bar: string
pedestal: string
text: string
ring: string
avatar: string
}> = {
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 (
<motion.div
initial={{ y: 30, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ type: "spring", stiffness: 260, damping: 22, delay }}
className="flex w-24 flex-col items-center gap-1"
>
<div className="relative">
{place === 1 && (
<motion.span
initial={{ y: -26, rotate: -25, opacity: 0 }}
animate={{ y: 0, rotate: 0, opacity: 1 }}
transition={{ type: "spring", stiffness: 500, damping: 14, delay: delay + 0.25 }}
className="absolute -top-5 left-1/2 z-10 -translate-x-1/2"
>
<Crown className="size-8 fill-amber-400 text-amber-400 drop-shadow" />
</motion.span>
)}
<motion.div
animate={
place === 1
? {
boxShadow: [
"0 0 0px 0px rgba(251,191,36,0)",
"0 0 24px 4px rgba(251,191,36,0.5)",
"0 0 12px 2px rgba(251,191,36,0.25)",
],
}
: undefined
}
transition={{ duration: 2, repeat: Infinity, repeatType: "reverse" }}
className="rounded-full"
>
<Avatar seed={name} className={`${cfg.avatar} ring-2 ${cfg.ring}`} />
</motion.div>
</div>
<span className="max-w-24 truncate text-sm font-medium">
{name}
{isMe && <span className="text-muted-foreground"> (toi)</span>}
</span>
<span className={`font-heading text-lg font-black tabular-nums ${cfg.text}`}>
{entry.score}
</span>
<div
className={`mt-1 flex w-full items-start justify-center rounded-t-lg border-x border-t-2 pt-1 ${cfg.bar} ${cfg.pedestal}`}
>
<span className={`font-heading text-2xl font-black ${cfg.text}`}>
{place}
</span>
</div>
</motion.div>
)
}
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 leaveRoom = useRoomStore((s) => s.leaveRoom)
const returnToLobby = useRoomStore((s) => s.returnToLobby)
const { t } = useI18n()
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 = [t.end.resultsTitle, "", t.end.ranking]
ranked.forEach((s, i) =>
lines.push(`${i + 1}. ${nameOf(s.playerId)}${s.score}`)
)
if (gameRecap && gameRecap.length > 0) {
lines.push("", t.end.rounds)
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)
const hasTracks = !!gameTracks && gameTracks.length > 0
const hasRecap = !!gameRecap && gameRecap.length > 0
return (
<div className="flex w-full flex-col items-center gap-6">
<div className="flex w-full max-w-xl flex-col gap-6 text-center">
<header>
<p className="text-muted-foreground text-xs uppercase">{t.end.over}</p>
<h2 className="font-heading text-2xl font-bold">
{t.end.winner(first ? nameOf(first.playerId) : "?")}
</h2>
</header>
{/* Podium : 2e à gauche, champion au centre, 3e à droite. */}
<div className="mt-4 flex items-end justify-center gap-2">
{second && (
<PodiumColumn
entry={second}
place={2}
name={nameOf(second.playerId)}
isMe={isMe(second.playerId)}
delay={0.1}
/>
)}
{first && (
<PodiumColumn
entry={first}
place={1}
name={nameOf(first.playerId)}
isMe={isMe(first.playerId)}
delay={0.3}
/>
)}
{third && (
<PodiumColumn
entry={third}
place={3}
name={nameOf(third.playerId)}
isMe={isMe(third.playerId)}
delay={0}
/>
)}
</div>
{rest.length > 0 && (
<ol className="flex flex-col gap-1">
{rest.map((s, i) => (
<li
key={s.playerId}
className={`bg-muted/40 flex items-center justify-between rounded-md px-3 py-2 text-sm ${
isMe(s.playerId) ? "ring-primary ring-2" : ""
}`}
>
<span className="flex items-center gap-2">
<span className="text-muted-foreground w-5 text-right tabular-nums">
{i + 4}
</span>
<Avatar seed={nameOf(s.playerId)} className="size-7" />
<span>
{nameOf(s.playerId)}
{isMe(s.playerId) && (
<span className="text-muted-foreground"> (toi)</span>
)}
</span>
</span>
<span className="font-heading font-bold tabular-nums">
{s.score}
</span>
</li>
))}
</ol>
)}
{gameRecap && gameRecap.length > 0 && (
<FunStats recap={gameRecap} snapshot={snapshot} t={t} />
)}
</div>
{/* Détails : musiques + récap côte à côte sur desktop s'il y a les deux,
sinon une seule colonne centrée (sinon le récap se calait à gauche). */}
{(hasTracks || hasRecap) && (
<div
className={
hasTracks && hasRecap
? "grid w-full gap-6 md:grid-cols-2 md:items-start"
: "flex w-full max-w-xl flex-col gap-6"
}
>
{hasTracks && <TracksRecap tracks={gameTracks!} t={t} />}
{hasRecap && (
<RoundsRecap
recap={gameRecap!}
nameOf={nameOf}
playerId={playerId}
t={t}
/>
)}
</div>
)}
<div className="flex w-full max-w-sm flex-col gap-2">
<Button variant="secondary" className="w-full" onClick={copyResults}>
{copied ? <Check className="text-green-500" /> : <ClipboardList />}
{copied ? t.end.copied : t.end.copy}
</Button>
{isHost ? (
<Button className="w-full" onClick={returnToLobby}>
{t.end.replay}
</Button>
) : (
<p className="text-muted-foreground text-xs">
{t.end.waitHostReplay}
</p>
)}
<Link href="/">
<Button variant="secondary" className="w-full" onClick={leaveRoom}>
{t.end.leave}
</Button>
</Link>
</div>
</div>
)
}
function TracksRecap({ tracks, t }: { tracks: BlindtestTrackInfo[]; t: Dict }) {
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 (
<section className="flex flex-col gap-2 text-left">
<div className="flex items-center justify-between">
<h3 className="text-muted-foreground flex items-center gap-1.5 text-sm font-medium">
<Music className="size-4" /> {t.end.tracksTitle}
</h3>
<Button size="sm" variant="secondary" onClick={copyPlaylist}>
{copied ? <Check className="text-green-500" /> : <ClipboardList />}
{copied ? t.roomCode.copied : t.end.playlist}
</Button>
</div>
<ul className="flex flex-col gap-2">
{tracks.map((track) => (
<li
key={track.youtubeId}
className="bg-muted/40 flex items-center gap-3 rounded-lg p-2"
>
<img
src={`https://img.youtube.com/vi/${track.youtubeId}/mqdefault.jpg`}
alt=""
className="h-10 w-16 shrink-0 rounded object-cover"
/>
<span className="min-w-0 flex-1">
<span className="line-clamp-1 text-xs font-medium">
{track.title}
</span>
<span className="text-muted-foreground line-clamp-1 text-[10px]">
{t.end.addedBy(track.submittedByName)}
</span>
</span>
<Tooltip>
<TooltipTrigger asChild>
<a href={spotifySearch(track)} target="_blank" rel="noreferrer">
<Button size="icon-sm" variant="secondary">
<SiSpotify color="default" />
</Button>
</a>
</TooltipTrigger>
<TooltipContent>{t.end.searchSpotify}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<a href={track.url} target="_blank" rel="noreferrer">
<Button size="icon-sm" variant="secondary">
<SiYoutube color="default" />
</Button>
</a>
</TooltipTrigger>
<TooltipContent>{t.end.openYoutube}</TooltipContent>
</Tooltip>
</li>
))}
</ul>
</section>
)
}
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,
t,
}: {
recap: RoundRecap[]
snapshot: RoomSnapshot
t: Dict
}) {
const stats = new Map<string, PlayerStat>(
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: t.end.awardPerfect, ids: perfect.map((p) => p.id) })
}
const brains = winners((s) => s.correct)
if (brains.length && brains.length < players.length) {
awards.push({ emoji: "🧠", label: t.end.awardBrain, ids: brains.map((p) => p.id) })
}
const fastest = winners((s) => s.speed)
if (fastest.length && fastest.length < players.length) {
awards.push({
emoji: "⚡",
label: t.end.awardFastest,
ids: fastest.map((p) => p.id),
})
}
const decoy = winners((s) => s.decoy)
if (decoy.length) {
awards.push({
emoji: "🦹",
label: t.end.awardDecoy,
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: t.end.awardBoulet, ids: boulets.map((p) => p.id) })
}
if (awards.length === 0) {
return null
}
return (
<section className="flex flex-col gap-2">
<h3 className="text-muted-foreground text-sm font-medium">{t.end.awards}</h3>
<div className="flex flex-wrap gap-2">
{awards.map((a) => (
<div
key={a.label}
className="bg-muted/40 flex flex-1 flex-col items-center gap-1 rounded-xl border px-3 py-2 text-center"
>
<span className="text-2xl">{a.emoji}</span>
<span className="text-[10px] font-medium uppercase">{a.label}</span>
<div className="flex flex-wrap items-center justify-center gap-1">
{a.ids.map((id) => (
<span key={id} className="flex items-center gap-1 text-xs">
<Avatar seed={nameOf(id)} className="size-5" />
{nameOf(id)}
</span>
))}
</div>
</div>
))}
</div>
</section>
)
}
function RoundsRecap({
recap,
nameOf,
playerId,
t,
}: {
recap: RoundRecap[]
nameOf: (id: string) => string
playerId: string | null
t: Dict
}) {
return (
<details className="rounded-xl border text-left">
<summary className="text-muted-foreground flex cursor-pointer items-center gap-1.5 p-3 text-sm font-medium select-none">
<ListChecks className="size-4" /> {t.end.recapTitle(recap.length)}
</summary>
<ul className="flex flex-col gap-2 p-3 pt-0">
{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 (
<li key={r.index} className="bg-muted/40 flex gap-3 rounded-lg p-2">
{r.youtubeId ? (
<img
src={`https://img.youtube.com/vi/${r.youtubeId}/mqdefault.jpg`}
alt=""
className="h-12 w-16 shrink-0 rounded object-cover"
/>
) : r.imageUrl ? (
<img
src={recapImage(r.imageUrl)}
alt=""
className="h-12 w-16 shrink-0 rounded object-contain"
/>
) : null}
<div className="min-w-0 flex-1">
<p className="text-muted-foreground text-[10px] uppercase">
{r.index + 1} ·{" "}
{r.category ??
(r.type === "blindtest"
? t.gameTypes.blindtest
: t.gameTypes.quiz)}
{" · "}
<span className="text-green-500"> {found}</span>
</p>
{r.type !== "blindtest" && (
<p className="line-clamp-1 text-xs">{r.label}</p>
)}
<p className="text-sm font-medium">
{r.answer}
{r.addedBy && (
<span className="text-muted-foreground font-normal">
{" "}
· {r.addedBy}
</span>
)}
</p>
<ul className="mt-1 flex flex-col gap-0.5">
{answers.length === 0 ? (
<li className="text-muted-foreground text-[10px]">
{t.end.noAnswer}
</li>
) : (
answers.map((a) => (
<li
key={a.playerId}
className="flex items-center gap-1 text-[10px]"
>
<Avatar seed={nameOf(a.playerId)} className="size-4" />
<span
className={
a.playerId === playerId
? "font-semibold"
: "text-muted-foreground"
}
>
{nameOf(a.playerId)}
{a.playerId === playerId && t.end.you} :
</span>
<span
className={
a.correct ? "text-green-500" : "text-red-400"
}
>
{a.answer || "—"}
</span>
{pointsBy.get(a.playerId) ? (
<span className="text-muted-foreground">
+{pointsBy.get(a.playerId)}
</span>
) : null}
</li>
))
)}
</ul>
</div>
</li>
)
})}
</ul>
</details>
)
}