nerdware/apps/web/src/components/player-cards.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

127 lines
4.5 KiB
TypeScript

import { useEffect, useRef } from "react"
import { AnimatePresence, motion } from "framer-motion"
import { Check, Crown } from "lucide-react"
import type { RoomSnapshot } from "@nerdware/shared"
import { Avatar } from "@/components/avatar"
import { celebrateLead } from "@/lib/confetti"
import { useI18n } from "@/i18n/context"
interface PlayerCardsProps {
snapshot: RoomSnapshot
playerId: string | null
/** Affiche l'état de vote (manche en cours uniquement). */
showVoteStatus?: boolean
/** playerIds ayant déjà validé leur réponse. */
votedIds?: string[]
}
/** HUD persistant : une card par joueur avec son score, couronne au leader. */
export function PlayerCards({
snapshot,
playerId,
showVoteStatus = false,
votedIds = [],
}: PlayerCardsProps) {
const { t } = useI18n()
const scoreOf = (id: string) =>
snapshot.scores.find((s) => s.playerId === id)?.score ?? 0
const max = Math.max(0, ...snapshot.players.map((p) => scoreOf(p.id)))
const ranked = [...snapshot.players].sort(
(a, b) => scoreOf(b.id) - scoreOf(a.id)
)
const voted = new Set(votedIds)
const leaderId = max > 0 ? ranked[0].id : null
// Confettis quand un joueur DÉPASSE pour prendre la tête (pas à la 1re prise).
const prevLeader = useRef<string | null>(null)
useEffect(() => {
if (leaderId && leaderId !== prevLeader.current) {
if (prevLeader.current !== null) {
celebrateLead()
}
prevLeader.current = leaderId
}
}, [leaderId])
return (
<div className="flex flex-wrap justify-center gap-2">
<AnimatePresence initial={false}>
{ranked.map((p) => {
const score = scoreOf(p.id)
const isLeader = max > 0 && score === max
const isMe = p.id === playerId
const hasVoted = voted.has(p.id)
return (
<motion.div
key={p.id}
layout
transition={{ type: "spring", stiffness: 500, damping: 40 }}
className={`relative flex min-w-24 flex-col items-center gap-1 rounded-xl border px-3 py-2.5 ${
isLeader
? "border-amber-400/60 bg-amber-400/10"
: "border-input bg-muted/40"
} ${isMe ? "ring-primary ring-2" : ""} ${
p.connected ? "" : "opacity-40"
}`}
>
{isLeader && (
<motion.span
initial={{ scale: 0, rotate: -30 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", stiffness: 600, damping: 20 }}
className="absolute -top-2.5 text-amber-400"
>
<Crown className="size-4 fill-amber-400" />
</motion.span>
)}
<Avatar
seed={p.name}
className={`size-14 ${isLeader ? "ring-2 ring-amber-400" : ""}`}
/>
<span className="max-w-24 truncate text-xs font-medium">
{p.name}
{isMe && (
<span className="text-muted-foreground"> {t.common.you}</span>
)}
</span>
<motion.span
key={score}
initial={{ scale: 1.4 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 500, damping: 25 }}
className="font-heading text-lg font-bold tabular-nums"
>
{score}
</motion.span>
{showVoteStatus &&
(hasVoted ? (
<motion.span
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 600, damping: 18 }}
className="flex items-center gap-0.5 text-[10px] font-medium text-green-500"
>
<Check className="size-3" /> {t.playerCards.ready}
</motion.span>
) : (
<span className="flex items-center gap-1 text-[10px]">
<motion.span
animate={{ opacity: [0.3, 1, 0.3] }}
transition={{ duration: 1.1, repeat: Infinity }}
className="bg-muted-foreground size-1.5 rounded-full"
/>
<span className="text-muted-foreground">
{t.playerCards.waiting}
</span>
</span>
))}
</motion.div>
)
})}
</AnimatePresence>
</div>
)
}