From 1e5ef2be9088a3e576ae71f04d34acae925fb20c Mon Sep 17 00:00:00 2001 From: AyoubBenziza Date: Thu, 11 Jun 2026 19:03:28 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20i18n=20(FR/EN)=20=E2=80=94=20homem?= =?UTF-8?q?ade=20typed=20dictionary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- apps/web/src/components/blindtest-view.tsx | 34 ++-- apps/web/src/components/game-end-view.tsx | 116 +++++++----- apps/web/src/components/lobby-view.tsx | 165 ++++++++--------- apps/web/src/components/player-cards.tsx | 12 +- apps/web/src/components/pseudo-screen.tsx | 10 +- apps/web/src/components/quiz-view.tsx | 31 ++-- apps/web/src/components/room-code.tsx | 71 +++++--- apps/web/src/components/round-transition.tsx | 6 +- apps/web/src/i18n/context.ts | 36 ++++ apps/web/src/i18n/en.ts | 180 +++++++++++++++++++ apps/web/src/i18n/error.ts | 10 ++ apps/web/src/i18n/fr.ts | 179 ++++++++++++++++++ apps/web/src/i18n/language-toggle.tsx | 25 +++ apps/web/src/i18n/provider.tsx | 30 ++++ apps/web/src/main.tsx | 5 +- apps/web/src/pages/home.tsx | 21 ++- apps/web/src/pages/join.tsx | 13 +- apps/web/src/pages/room.tsx | 16 +- 18 files changed, 737 insertions(+), 223 deletions(-) create mode 100644 apps/web/src/i18n/context.ts create mode 100644 apps/web/src/i18n/en.ts create mode 100644 apps/web/src/i18n/error.ts create mode 100644 apps/web/src/i18n/fr.ts create mode 100644 apps/web/src/i18n/language-toggle.tsx create mode 100644 apps/web/src/i18n/provider.tsx diff --git a/apps/web/src/components/blindtest-view.tsx b/apps/web/src/components/blindtest-view.tsx index cfc2a76..af3eacf 100644 --- a/apps/web/src/components/blindtest-view.tsx +++ b/apps/web/src/components/blindtest-view.tsx @@ -14,6 +14,7 @@ import { useRoomStore } from "@/store/room" import { useYoutube, type YoutubeApi } from "@/lib/youtube" import { Countdown } from "@/components/countdown" import { Avatar } from "@/components/avatar" +import { useI18n } from "@/i18n/context" const inputClass = "border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50" @@ -33,6 +34,7 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) { const voteBlindtest = useRoomStore((s) => s.voteBlindtest) const mediaControl = useRoomStore((s) => s.mediaControl) + const { t } = useI18n() const track = round?.payload as BlindtestRoundPayload | undefined const { hostRef, ready, api } = useYoutube(track?.youtubeId ?? "") @@ -68,7 +70,7 @@ export function BlindtestView({ snapshot }: { snapshot: RoomSnapshot }) {
- Blindtest {snapshot.currentRound + 1} / {snapshot.totalRounds} + {t.game.blindtest(snapshot.currentRound + 1, snapshot.totalRounds)} {!showReveal && round && ( void }) { + const { t } = useI18n() const [pos, setPos] = useState(0) const [dur, setDur] = useState(0) @@ -153,7 +156,7 @@ function DjControls({ return (
- Tu es le DJ 🎧 — pilote la lecture (et vote comme les autres) + {t.blindtest.djHint}
@@ -214,14 +217,15 @@ function DjControls({ } function MisleadCard({ mode }: { mode: BlindtestMode }) { + const { t } = useI18n() return (
🤫 -

C'est ton titre !

+

{t.blindtest.yourTrack}

{mode === "title_artist" - ? "Tu ne votes pas. Savoure pendant que les autres cherchent." - : "Tu ne votes pas — ton but : que personne ne devine que c'est toi qui l'as ajouté. +50 par joueur trompé."} + ? t.blindtest.yourTrackTitleArtist + : t.blindtest.yourTrackWhoAdded}

) @@ -240,6 +244,7 @@ function VoteForm({ disabled: boolean onVote: (answer: BlindtestAnswer) => void }) { + const { t } = useI18n() const [title, setTitle] = useState("") const [artist, setArtist] = useState("") const [guessed, setGuessed] = useState(null) @@ -270,7 +275,7 @@ function VoteForm({ if (disabled) { return (

- Réponse envoyée — en attente des autres + {t.game.answerSent}

) } @@ -281,13 +286,13 @@ function VoteForm({ <> setTitle(e.target.value)} /> setArtist(e.target.value)} /> @@ -314,7 +319,7 @@ function VoteForm({
)}
) @@ -327,12 +332,13 @@ function RevealCard({ truth: BlindtestRevealTruth result?: BlindtestPerPlayerResult[string] }) { + const { t } = useI18n() return (

{truth.title}

{truth.artist}

- Ajouté par + {t.blindtest.revealAddedBy} {truth.submittedByName}

@@ -340,7 +346,7 @@ function RevealCard({

0 ? "text-green-500" : "text-red-500"}`} > - {result.points > 0 ? `+${result.points} points 🎉` : "Raté 💥"} + {result.points > 0 ? t.blindtest.points(result.points) : t.game.wrong}

)}
diff --git a/apps/web/src/components/game-end-view.tsx b/apps/web/src/components/game-end-view.tsx index cb18a90..fb1bcf4 100644 --- a/apps/web/src/components/game-end-view.tsx +++ b/apps/web/src/components/game-end-view.tsx @@ -10,6 +10,11 @@ import type { 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) => @@ -18,6 +23,7 @@ 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 { @@ -135,6 +141,7 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) { 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). @@ -149,12 +156,12 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) { const [copied, setCopied] = useState(false) async function copyResults() { - const lines = ["NerdWare — Résultats", "", "🏆 Classement"] + 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("", "Manches") + lines.push("", t.end.rounds) gameRecap.forEach((r) => lines.push( `${r.index + 1}. ${r.type === "blindtest" ? "🎧" : "🧠"} ${r.answer}` @@ -185,11 +192,9 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
-

- Partie terminée -

+

{t.end.over}

- 🏆 {first ? nameOf(first.playerId) : "?"} l'emporte ! + {t.end.winner(first ? nameOf(first.playerId) : "?")}

@@ -254,7 +259,7 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) { )} {gameRecap && gameRecap.length > 0 && ( - + )}
@@ -268,9 +273,14 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) { : "flex w-full max-w-xl flex-col gap-6" } > - {hasTracks && } + {hasTracks && } {hasRecap && ( - + )}
)} @@ -278,20 +288,20 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
{isHost ? ( ) : (

- En attente que l'hôte relance une partie… + {t.end.waitHostReplay}

)}
@@ -299,7 +309,7 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) { ) } -function TracksRecap({ tracks }: { tracks: BlindtestTrackInfo[] }) { +function TracksRecap({ tracks, t }: { tracks: BlindtestTrackInfo[]; t: Dict }) { const [copied, setCopied] = useState(false) async function copyPlaylist() { @@ -317,45 +327,52 @@ function TracksRecap({ tracks }: { tracks: BlindtestTrackInfo[] }) {

- Les musiques de la partie + {t.end.tracksTitle}

    - {tracks.map((t) => ( + {tracks.map((track) => (
  • - {t.title} + + {track.title} + - ajouté par {t.submittedByName} + {t.end.addedBy(track.submittedByName)} - - - - - - + + + + + + + {t.end.searchSpotify} + + + + + + + + {t.end.openYoutube} +
  • ))}
@@ -376,9 +393,11 @@ const QUIZ_BASE_POINTS = 100 function FunStats({ recap, snapshot, + t, }: { recap: RoundRecap[] snapshot: RoomSnapshot + t: Dict }) { const stats = new Map( snapshot.players.map((p) => [ @@ -427,17 +446,17 @@ function FunStats({ (p) => total > 0 && stat(p.id).correct === total ) if (perfect.length) { - awards.push({ emoji: "🎯", label: "Sans-faute", ids: perfect.map((p) => p.id) }) + 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: "Le cerveau", ids: brains.map((p) => p.id) }) + 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: "Le plus rapide", + label: t.end.awardFastest, ids: fastest.map((p) => p.id), }) } @@ -445,7 +464,7 @@ function FunStats({ if (decoy.length) { awards.push({ emoji: "🦹", - label: "Roi de la tromperie", + label: t.end.awardDecoy, ids: decoy.map((p) => p.id), }) } @@ -453,7 +472,7 @@ function FunStats({ (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) }) + awards.push({ emoji: "🪨", label: t.end.awardBoulet, ids: boulets.map((p) => p.id) }) } if (awards.length === 0) { @@ -462,7 +481,7 @@ function FunStats({ return (
-

Récompenses

+

{t.end.awards}

{awards.map((a) => (
string playerId: string | null + t: Dict }) { return (
- Récapitulatif des manches ({recap.length}) + {t.end.recapTitle(recap.length)}
    {recap.map((r) => { @@ -528,7 +549,10 @@ function RoundsRecap({

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

    @@ -547,7 +571,7 @@ function RoundsRecap({
      {answers.length === 0 ? (
    • - Aucune réponse + {t.end.noAnswer}
    • ) : ( answers.map((a) => ( @@ -564,7 +588,7 @@ function RoundsRecap({ } > {nameOf(a.playerId)} - {a.playerId === playerId && " (toi)"} : + {a.playerId === playerId && t.end.you} : = { - quiz: "Quiz", - image: "Images", - blindtest: "Blindtest", -} import { Button } from "@workspace/ui/components/button" import { Tooltip, @@ -38,32 +31,21 @@ import { } from "@workspace/ui/components/tooltip" import { useRoomStore } from "@/store/room" import { Avatar } from "@/components/avatar" - -const BLINDTEST_LOCK_HINT = "3 joueurs dont 2 réels (un bot ne peut pas être DJ)" +import { useI18n } from "@/i18n/context" +import { errorMessage } from "@/i18n/error" const MAX_TRACKS = 10 const GAME_TYPES: { value: GameType - label: string Icon: LucideIcon needsThree: boolean }[] = [ - { value: "mixed", label: "Mixte", Icon: Shuffle, needsThree: false }, - { value: "quiz", label: "Quiz", Icon: Brain, needsThree: false }, - { value: "image", label: "Images", Icon: ImageIcon, needsThree: false }, - { value: "blindtest", label: "Blindtest", Icon: Headphones, needsThree: true }, + { value: "mixed", Icon: Shuffle, needsThree: false }, + { value: "quiz", Icon: Brain, needsThree: false }, + { value: "image", Icon: ImageIcon, needsThree: false }, + { value: "blindtest", Icon: Headphones, needsThree: true }, ] -const MODE_LABELS: Record = { - title_artist: "Titre & artiste", - who_added: "Qui l'a ajouté ?", - mixed: "Mixte", -} const BOT_DIFFICULTIES = ["easy", "normal", "hard"] as const -const BOT_DIFF_LABELS: Record<(typeof BOT_DIFFICULTIES)[number], string> = { - easy: "Facile", - normal: "Normal", - hard: "Difficile", -} const inputClass = "border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50" @@ -151,6 +133,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { const startGame = useRoomStore((s) => s.startGame) const addBot = useRoomStore((s) => s.addBot) const removeBot = useRoomStore((s) => s.removeBot) + const { t, lang } = useI18n() const isHost = snapshot.hostId === playerId const botCount = snapshot.players.filter((p) => p.bot).length const { @@ -241,7 +224,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { try { await startGame(rounds) } catch (err) { - setError((err as { message?: string }).message ?? "Erreur") + setError(errorMessage(t, err)) } finally { setBusy(false) } @@ -251,7 +234,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {

      - Joueurs ({snapshot.players.length}) + {t.lobby.players(snapshot.players.length)}

        {snapshot.players.map((p) => ( @@ -265,12 +248,12 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { {p.name} {p.id === playerId && ( - (toi) + {t.common.you} )} - {p.bot ? "🤖 Bot" : p.id === snapshot.hostId ? "Hôte" : ""} - {!p.bot && !p.connected && " · hors ligne"} + {p.bot ? t.lobby.bot : p.id === snapshot.hostId ? t.lobby.host : ""} + {!p.bot && !p.connected && ` · ${t.lobby.offline}`} ))} @@ -278,7 +261,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { {isHost && (
        - Bots : {botCount} + {t.lobby.bots} : {botCount}
        ) return locked ? ( {tile} - {BLINDTEST_LOCK_HINT} + {t.lobby.blindtestLockHint} ) : ( tile @@ -346,8 +329,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
        {!blindtestAvailable && (

        - Le blindtest se débloque à 3 joueurs dont 2 réels (un bot ne peut - pas être DJ). + {t.lobby.blindtestUnlock}

        )}
        @@ -355,7 +337,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { {/* Sous-modes du mixte (toggles) */} {isMixed && (
        - Sous-modes inclus + {t.lobby.subModes}
        {(["quiz", "image", "blindtest"] as const).map((m) => { const locked = m === "blindtest" && !blindtestAvailable @@ -374,13 +356,13 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { : "border-input hover:bg-muted/60" }`} > - {MIX_LABELS[m]} + {t.mixModes[m]} ) return locked ? ( {tile} - {BLINDTEST_LOCK_HINT} + {t.lobby.blindtestLockHint} ) : ( tile @@ -394,7 +376,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { {showQuiz && (
        - Questions de quiz + {t.lobby.quizCount}
        @@ -405,7 +387,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
        - Images à deviner + {t.lobby.imageCount}
        -

        - Nécessite des questions « Image » créées dans le back-office. -

        +

        {t.lobby.imageHint}

        )} @@ -424,7 +404,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { {(showQuiz || showImage) && allCategories.length > 0 && (
        - Catégories + {t.lobby.categories}
        {allCategories.map((c) => ( ))}
        @@ -479,7 +459,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) {
        - Mode blindtest + {t.lobby.blindtestMode}
        {(["title_artist", "who_added", "mixed"] as const).map((m) => ( @@ -490,14 +470,14 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { variant={blindtestMode === m ? "default" : "secondary"} onClick={() => updateSettings({ blindtestMode: m })} > - {MODE_LABELS[m]} + {t.blindtestModes[m]} ))}
        - Titres par joueur + {t.lobby.tracksPerPlayer} {showBlindtest && !allSubmitted && (

        - En attente que tout le monde ait soumis ses {tracksPerPlayer}{" "} - titre(s). + {t.lobby.waitTracks(tracksPerPlayer)}

        )}
        ) : (

        - En attente du lancement par l'hôte… - {showBlindtest && ` (${totalTracks} titres soumis)`} + {t.lobby.waitHost} + {showBlindtest && t.lobby.tracksSubmitted(totalTracks)}

        )} @@ -537,7 +517,7 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { {history.length > 0 && (
        - Parties précédentes ({history.length}) + {t.lobby.prevGames(history.length)}
          {history.map((g) => { @@ -545,10 +525,10 @@ export function LobbyView({ snapshot }: { snapshot: RoomSnapshot }) { return (
        • - {new Date(g.playedAt).toLocaleString("fr-FR", { - dateStyle: "short", - timeStyle: "short", - })} + {new Date(g.playedAt).toLocaleString( + lang === "fr" ? "fr-FR" : "en-US", + { dateStyle: "short", timeStyle: "short" } + )} {" · "} {g.modes.join(", ")}

          @@ -585,6 +565,7 @@ function TrackSubmission({ }) { const submitTrack = useRoomStore((s) => s.submitTrack) const removeTrack = useRoomStore((s) => s.removeTrack) + const { t } = useI18n() const [url, setUrl] = useState("") const [submitting, setSubmitting] = useState(false) const [feedback, setFeedback] = useState(null) @@ -605,7 +586,7 @@ function TrackSubmission({ ]) setUrl("") } else { - setFeedback(res.reason ?? "Refusé") + setFeedback(res.reason ?? t.lobby.rejected) } setSubmitting(false) } @@ -620,40 +601,48 @@ function TrackSubmission({ return (
          - Tes titres ({myCount}/{tracksPerPlayer}) + {t.lobby.myTracks(myCount, tracksPerPlayer)} {tracks.length > 0 && (
            - {tracks.map((t) => ( + {tracks.map((track) => (
          • - {t.title} - - - - + {track.title} + + + + + + + {t.lobby.openYoutube} + + + + + + {t.lobby.remove} +
          • ))}
          @@ -663,7 +652,7 @@ function TrackSubmission({
          setUrl(e.target.value)} @@ -674,7 +663,7 @@ function TrackSubmission({ disabled={submitting || !url.trim()} onClick={submit} > - Ajouter + {t.lobby.add}
          )} diff --git a/apps/web/src/components/player-cards.tsx b/apps/web/src/components/player-cards.tsx index a77c388..ddb322f 100644 --- a/apps/web/src/components/player-cards.tsx +++ b/apps/web/src/components/player-cards.tsx @@ -4,6 +4,7 @@ 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 @@ -21,6 +22,7 @@ export function PlayerCards({ showVoteStatus = false, votedIds = [], }: PlayerCardsProps) { + const { t } = useI18n() const scoreOf = (id: string) => snapshot.scores.find((s) => s.playerId === id)?.score ?? 0 @@ -79,7 +81,9 @@ export function PlayerCards({ /> {p.name} - {isMe && (toi)} + {isMe && ( + {t.common.you} + )} - Prêt + {t.playerCards.ready} ) : ( @@ -109,7 +113,9 @@ export function PlayerCards({ transition={{ duration: 1.1, repeat: Infinity }} className="bg-muted-foreground size-1.5 rounded-full" /> - en attente + + {t.playerCards.waiting} + ))} diff --git a/apps/web/src/components/pseudo-screen.tsx b/apps/web/src/components/pseudo-screen.tsx index 5a11d8c..39b8fe6 100644 --- a/apps/web/src/components/pseudo-screen.tsx +++ b/apps/web/src/components/pseudo-screen.tsx @@ -2,6 +2,7 @@ import { useState } from "react" import { Button } from "@workspace/ui/components/button" import { useRoomStore } from "@/store/room" import { Avatar } from "@/components/avatar" +import { useI18n } from "@/i18n/context" const inputClass = "border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-center text-sm focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none" @@ -9,6 +10,7 @@ const inputClass = /** Choix du pseudo dans la room, avec aperçu de l'avatar en temps réel. */ export function PseudoScreen({ code }: { code: string }) { const setName = useRoomStore((s) => s.setName) + const { t } = useI18n() const [name, setName_] = useState("") const canSubmit = name.trim().length > 0 @@ -16,16 +18,16 @@ export function PseudoScreen({ code }: { code: string }) {

          - Room {code} + {t.pseudo.room(code)}

          -

          Choisis ton pseudo

          +

          {t.pseudo.choose}

          setName(name)} > - Entrer dans la room + {t.pseudo.enter}
          diff --git a/apps/web/src/components/quiz-view.tsx b/apps/web/src/components/quiz-view.tsx index 124f89c..d5fa2dd 100644 --- a/apps/web/src/components/quiz-view.tsx +++ b/apps/web/src/components/quiz-view.tsx @@ -8,6 +8,7 @@ import type { import { Button } from "@workspace/ui/components/button" import { useRoomStore } from "@/store/room" import { Countdown } from "@/components/countdown" +import { useI18n, type Dict } from "@/i18n/context" const inputClass = "border-input bg-background h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50" @@ -24,11 +25,10 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) { const playerId = useRoomStore((s) => s.playerId) const vote = useRoomStore((s) => s.vote) const voteText = useRoomStore((s) => s.voteText) + const { t } = useI18n() if (!round) { - return ( -

          Préparation de la manche…

          - ) + return

          {t.common.loading}

          } const question = round.payload as QuizQuestionPayload @@ -62,7 +62,7 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
          - Question {snapshot.currentRound + 1} / {snapshot.totalRounds} + {t.game.question(snapshot.currentRound + 1, snapshot.totalRounds)} {question.category ? ` · ${question.category}` : ""} {!showReveal && ( @@ -92,10 +92,7 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) { )} {isText ? ( - + ) : (
          {(question.choices ?? []).map((choice, index) => ( @@ -114,13 +111,13 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) { {!showReveal && (hasVoted ? (

          - Réponse envoyée — en attente des autres + {t.game.answerSent} {voteProgress ? ` (${voteProgress.count}/${voteProgress.total})` : ""}

          ) : ( voteProgress && (

          - {voteProgress.count}/{voteProgress.total} ont répondu + {t.game.answeredCount(voteProgress.count, voteProgress.total)}

          ) ))} @@ -129,7 +126,7 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
          {isText && (

          - Réponse : + {t.game.answer} {truth?.answer}

          )} @@ -137,10 +134,10 @@ export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) { className={`text-center text-sm font-medium ${myResult?.correct ? "text-green-500" : "text-red-500"}`} > {myResult?.correct - ? "Bonne réponse ! 🎉" + ? t.game.correct : !hasVoted - ? "Pas de réponse 😴" - : "Raté 💥"} + ? t.game.noAnswer + : t.game.wrong}

          )} @@ -193,23 +190,25 @@ function ImageReveal({ function FreeAnswer({ disabled, onSubmit, + t, }: { disabled: boolean onSubmit: (text: string) => void + t: Dict }) { const [text, setText] = useState("") return (
          setText(e.target.value)} onKeyDown={(e) => e.key === "Enter" && text.trim() && onSubmit(text)} />
          ) diff --git a/apps/web/src/components/room-code.tsx b/apps/web/src/components/room-code.tsx index ccd599d..b4dc669 100644 --- a/apps/web/src/components/room-code.tsx +++ b/apps/web/src/components/room-code.tsx @@ -1,11 +1,18 @@ import { useState } from "react" import { Check, Copy, Link2 } from "lucide-react" import { Button } from "@workspace/ui/components/button" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@workspace/ui/components/tooltip" +import { useI18n } from "@/i18n/context" type Copied = "code" | "link" | null /** Code de room copiable + bouton pour copier le lien d'invitation. */ export function RoomCode({ code }: { code: string }) { + const { t } = useI18n() const [copied, setCopied] = useState(null) async function copy(what: Copied, text: string) { @@ -22,35 +29,43 @@ export function RoomCode({ code }: { code: string }) { return (
          - + + + + + {t.roomCode.copyCode} + - + + + + + {t.roomCode.copyLink} +
          ) } diff --git a/apps/web/src/components/round-transition.tsx b/apps/web/src/components/round-transition.tsx index 6969c29..c8e947e 100644 --- a/apps/web/src/components/round-transition.tsx +++ b/apps/web/src/components/round-transition.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react" import { createPortal } from "react-dom" import { AnimatePresence, motion } from "framer-motion" +import { useI18n } from "@/i18n/context" /** Catégorie visuelle de la transition (image_reveal est distinct du quiz). */ export type TransitionKind = "quiz" | "image" | "blindtest" @@ -72,6 +73,7 @@ export function RoundTransition({ index: number modeChanged: boolean }) { + const { t } = useI18n() const [show, setShow] = useState(true) const theme = THEME[kind] const [media] = useState(() => { @@ -142,7 +144,7 @@ export function RoundTransition({ ) : ( <> - {theme.kind} + {t.transition.kind[kind]} - PRÊT ?! + {t.transition.ready} )} diff --git a/apps/web/src/i18n/context.ts b/apps/web/src/i18n/context.ts new file mode 100644 index 0000000..d35146b --- /dev/null +++ b/apps/web/src/i18n/context.ts @@ -0,0 +1,36 @@ +import { createContext, useContext } from "react" +import { fr } from "./fr" + +export type Lang = "fr" | "en" +/** Forme du dictionnaire, dérivée du FR (source de vérité). */ +export type Dict = typeof fr + +export const LANG_KEY = "nerdware-lang" + +export function detectLang(): Lang { + try { + const stored = localStorage.getItem(LANG_KEY) + if (stored === "fr" || stored === "en") { + return stored + } + } catch { + // localStorage indisponible : on retombe sur la langue du navigateur + } + return navigator.language?.toLowerCase().startsWith("fr") ? "fr" : "en" +} + +export interface I18nContextValue { + lang: Lang + t: Dict + setLang: (lang: Lang) => void +} + +export const I18nContext = createContext(null) + +export function useI18n(): I18nContextValue { + const ctx = useContext(I18nContext) + if (!ctx) { + throw new Error("useI18n doit être utilisé dans ") + } + return ctx +} diff --git a/apps/web/src/i18n/en.ts b/apps/web/src/i18n/en.ts new file mode 100644 index 0000000..785fbaa --- /dev/null +++ b/apps/web/src/i18n/en.ts @@ -0,0 +1,180 @@ +// Dictionnaire EN. Doit respecter la forme de `fr` (Dict) — sinon erreur TS. + +import type { Dict } from "./context" + +export const en: Dict = { + common: { + you: "(you)", + back: "Back to home", + backShort: "Back", + loading: "Loading…", + error: "Error", + }, + home: { + tagline: "Geek culture party game", + create: "Create a room", + orJoin: "or join", + codePlaceholder: "CODE", + join: "Join", + connecting: "Connecting to server…", + }, + pseudo: { + room: (code: string) => `Room ${code}`, + choose: "Choose your nickname", + placeholder: "Your nickname", + enter: "Enter the room", + }, + joinPage: { + connecting: (code: string) => `Joining room ${code}…`, + notFound: "Room not found.", + }, + room: { + notFound: "Room not found or session lost.", + connected: "Connected", + disconnected: "Disconnected", + leave: "Leave", + }, + roomCode: { + label: "Room code", + copyCode: "Copy code", + copyLink: "Copy invite link", + link: "Link", + copied: "Copied", + }, + lobby: { + players: (n: number) => `Players (${n})`, + host: "Host", + bot: "🤖 Bot", + offline: "offline", + bots: "Bots", + settings: "Game settings", + gameMode: "Game mode", + blindtestLockHint: "3 players incl. 2 real ones (a bot can't DJ)", + blindtestUnlock: + "Blindtest unlocks at 3 players incl. 2 real ones (a bot can't DJ).", + subModes: "Included sub-modes", + quizCount: "Quiz questions", + imageCount: "Images to guess", + imageHint: "Requires “Image” questions created in the back-office.", + categories: "Categories", + allCategories: "All", + botLevel: "🤖 Bot difficulty", + blindtestMode: "Blindtest mode", + tracksPerPlayer: "Tracks per player", + launch: (n: number) => `Start (${n} rounds)`, + launching: "Starting…", + waitTracks: (n: number) => + `Waiting for everyone to submit their ${n} track(s).`, + waitHost: "Waiting for the host to start…", + tracksSubmitted: (n: number) => ` (${n} tracks submitted)`, + prevGames: (n: number) => `Previous games (${n})`, + myTracks: (count: number, max: number) => `Your tracks (${count}/${max})`, + youtubePlaceholder: "YouTube link", + add: "Add", + openYoutube: "Open on YouTube", + remove: "Remove", + rejected: "Rejected", + }, + gameTypes: { + mixed: "Mixed", + quiz: "Quiz", + image: "Images", + blindtest: "Blindtest", + }, + mixModes: { + quiz: "Quiz", + image: "Images", + blindtest: "Blindtest", + }, + blindtestModes: { + title_artist: "Title & artist", + who_added: "Who added it?", + mixed: "Mixed", + }, + botDifficulty: { + easy: "Easy", + normal: "Normal", + hard: "Hard", + }, + game: { + question: (i: number, n: number) => `Question ${i} / ${n}`, + blindtest: (i: number, n: number) => `Blindtest ${i} / ${n}`, + answerSent: "Answer sent — waiting for the others", + answeredCount: (count: number, total: number) => `${count}/${total} answered`, + answer: "Answer:", + correct: "Correct! 🎉", + noAnswer: "No answer 😴", + wrong: "Wrong 💥", + yourAnswer: "Your answer", + validate: "Submit", + }, + blindtest: { + djHint: "You're the DJ 🎧 — control playback (and vote like everyone)", + play: "Play", + pause: "Pause", + restart: "Start", + yourTrack: "It's your track!", + yourTrackTitleArtist: "You don't vote. Enjoy while the others search.", + yourTrackWhoAdded: + "You don't vote — your goal: nobody guesses you added it. +50 per fooled player.", + titlePlaceholder: "Title", + artistPlaceholder: "Artist", + whoAdded: "Who added it?", + vote: "Vote", + pickPlayer: "Pick a player", + revealTitle: "Title", + revealArtist: "Artist", + revealAddedBy: "Added by", + points: (n: number) => `+${n} points 🎉`, + submit: "Submit my answer", + }, + playerCards: { + ready: "Ready", + waiting: "waiting", + }, + transition: { + kind: { + quiz: "Question", + image: "Image", + blindtest: "Track", + }, + ready: "READY ?!", + }, + end: { + over: "Game over", + winner: (name: string) => `🏆 ${name} wins!`, + copy: "Copy results", + copied: "Copied!", + replay: "Play again", + waitHostReplay: "Waiting for the host to start a new game…", + leave: "Leave", + resultsTitle: "NerdWare — Results", + ranking: "🏆 Ranking", + rounds: "Rounds", + tracksTitle: "Tracks from the game", + playlist: "Playlist", + addedBy: (name: string) => `added by ${name}`, + searchSpotify: "Search on Spotify", + openYoutube: "Open on YouTube", + awards: "Awards", + awardPerfect: "Flawless", + awardBrain: "The brain", + awardFastest: "The fastest", + awardDecoy: "Master of deception", + awardBoulet: "The dud", + recapTitle: (n: number) => `Rounds recap (${n})`, + noAnswer: "No answer", + you: " (you)", + }, + errors: { + ROOM_NOT_FOUND: "This room doesn't exist.", + ROOM_IN_PROGRESS: "The game has already started.", + ALREADY_STARTED: "Game already started.", + NO_ROUNDS: "No round configured.", + NEED_THREE: "Blindtest needs at least 3 players incl. 2 real ones.", + TRACKS_PENDING: "Not all players have submitted their tracks yet.", + CODE_EXHAUSTED: "Couldn't generate a room code.", + REJOIN_FAILED: "Reconnection failed.", + UNKNOWN_MODE: "Round unavailable.", + }, +} diff --git a/apps/web/src/i18n/error.ts b/apps/web/src/i18n/error.ts new file mode 100644 index 0000000..e1a0580 --- /dev/null +++ b/apps/web/src/i18n/error.ts @@ -0,0 +1,10 @@ +import type { Dict } from "./context" + +/** Traduit une erreur serveur via son code, sinon retombe sur son message. */ +export function errorMessage(t: Dict, err: unknown): string { + const e = err as { code?: string; message?: string } | null + if (e?.code && e.code in t.errors) { + return t.errors[e.code as keyof typeof t.errors] + } + return e?.message ?? t.common.error +} diff --git a/apps/web/src/i18n/fr.ts b/apps/web/src/i18n/fr.ts new file mode 100644 index 0000000..ce0220f --- /dev/null +++ b/apps/web/src/i18n/fr.ts @@ -0,0 +1,179 @@ +// Dictionnaire FR — source de vérité de la forme du dictionnaire (voir en.ts). + +export const fr = { + common: { + you: "(toi)", + back: "Retour à l'accueil", + backShort: "Retour", + loading: "Chargement…", + error: "Erreur", + }, + home: { + tagline: "Party game culture geek", + create: "Créer une room", + orJoin: "ou rejoindre", + codePlaceholder: "CODE", + join: "Rejoindre", + connecting: "Connexion au serveur…", + }, + pseudo: { + room: (code: string) => `Room ${code}`, + choose: "Choisis ton pseudo", + placeholder: "Ton pseudo", + enter: "Entrer dans la room", + }, + joinPage: { + connecting: (code: string) => `Connexion à la room ${code}…`, + notFound: "Room introuvable.", + }, + room: { + notFound: "Room introuvable ou session perdue.", + connected: "Connecté", + disconnected: "Déconnecté", + leave: "Quitter", + }, + roomCode: { + label: "Code room", + copyCode: "Copier le code", + copyLink: "Copier le lien d'invitation", + link: "Lien", + copied: "Copié", + }, + lobby: { + players: (n: number) => `Joueurs (${n})`, + host: "Hôte", + bot: "🤖 Bot", + offline: "hors ligne", + bots: "Bots", + settings: "Réglages de la partie", + gameMode: "Mode de jeu", + blindtestLockHint: "3 joueurs dont 2 réels (un bot ne peut pas être DJ)", + blindtestUnlock: + "Le blindtest se débloque à 3 joueurs dont 2 réels (un bot ne peut pas être DJ).", + subModes: "Sous-modes inclus", + quizCount: "Questions de quiz", + imageCount: "Images à deviner", + imageHint: "Nécessite des questions « Image » créées dans le back-office.", + categories: "Catégories", + allCategories: "Toutes", + botLevel: "🤖 Niveau des bots", + blindtestMode: "Mode blindtest", + tracksPerPlayer: "Titres par joueur", + launch: (n: number) => `Lancer (${n} manches)`, + launching: "Lancement…", + waitTracks: (n: number) => + `En attente que tout le monde ait soumis ses ${n} titre(s).`, + waitHost: "En attente du lancement par l'hôte…", + tracksSubmitted: (n: number) => ` (${n} titres soumis)`, + prevGames: (n: number) => `Parties précédentes (${n})`, + myTracks: (count: number, max: number) => `Tes titres (${count}/${max})`, + youtubePlaceholder: "Lien YouTube", + add: "Ajouter", + openYoutube: "Ouvrir sur YouTube", + remove: "Supprimer", + rejected: "Refusé", + }, + gameTypes: { + mixed: "Mixte", + quiz: "Quiz", + image: "Images", + blindtest: "Blindtest", + }, + mixModes: { + quiz: "Quiz", + image: "Images", + blindtest: "Blindtest", + }, + blindtestModes: { + title_artist: "Titre & artiste", + who_added: "Qui l'a ajouté ?", + mixed: "Mixte", + }, + botDifficulty: { + easy: "Facile", + normal: "Normal", + hard: "Difficile", + }, + game: { + question: (i: number, n: number) => `Question ${i} / ${n}`, + blindtest: (i: number, n: number) => `Blindtest ${i} / ${n}`, + answerSent: "Réponse envoyée — en attente des autres", + answeredCount: (count: number, total: number) => `${count}/${total} ont répondu`, + answer: "Réponse :", + correct: "Bonne réponse ! 🎉", + noAnswer: "Pas de réponse 😴", + wrong: "Raté 💥", + yourAnswer: "Ta réponse", + validate: "Valider", + }, + blindtest: { + djHint: "Tu es le DJ 🎧 — pilote la lecture (et vote comme les autres)", + play: "Play", + pause: "Pause", + restart: "Début", + yourTrack: "C'est ton titre !", + yourTrackTitleArtist: + "Tu ne votes pas. Savoure pendant que les autres cherchent.", + yourTrackWhoAdded: + "Tu ne votes pas — ton but : que personne ne devine que c'est toi qui l'as ajouté. +50 par joueur trompé.", + titlePlaceholder: "Titre", + artistPlaceholder: "Artiste", + whoAdded: "Qui l'a ajouté ?", + vote: "Voter", + pickPlayer: "Choisis un joueur", + revealTitle: "Titre", + revealArtist: "Artiste", + revealAddedBy: "Ajouté par", + points: (n: number) => `+${n} points 🎉`, + submit: "Valider ma réponse", + }, + playerCards: { + ready: "Prêt", + waiting: "en attente", + }, + transition: { + kind: { + quiz: "Question", + image: "Image", + blindtest: "Titre", + }, + ready: "PRÊT ?!", + }, + end: { + over: "Partie terminée", + winner: (name: string) => `🏆 ${name} l'emporte !`, + copy: "Copier les résultats", + copied: "Copié !", + replay: "Rejouer", + waitHostReplay: "En attente que l'hôte relance une partie…", + leave: "Quitter", + resultsTitle: "NerdWare — Résultats", + ranking: "🏆 Classement", + rounds: "Manches", + tracksTitle: "Les musiques de la partie", + playlist: "Playlist", + addedBy: (name: string) => `ajouté par ${name}`, + searchSpotify: "Chercher sur Spotify", + openYoutube: "Ouvrir sur YouTube", + awards: "Récompenses", + awardPerfect: "Sans-faute", + awardBrain: "Le cerveau", + awardFastest: "Le plus rapide", + awardDecoy: "Roi de la tromperie", + awardBoulet: "Le boulet", + recapTitle: (n: number) => `Récapitulatif des manches (${n})`, + noAnswer: "Aucune réponse", + you: " (toi)", + }, + errors: { + ROOM_NOT_FOUND: "Cette room n'existe pas.", + ROOM_IN_PROGRESS: "La partie a déjà commencé.", + ALREADY_STARTED: "Partie déjà lancée.", + NO_ROUNDS: "Aucune épreuve configurée.", + NEED_THREE: "Le blindtest nécessite au moins 3 joueurs dont 2 réels.", + TRACKS_PENDING: "Tous les joueurs n'ont pas encore soumis leurs titres.", + CODE_EXHAUSTED: "Impossible de générer un code de room.", + REJOIN_FAILED: "Reconnexion impossible.", + UNKNOWN_MODE: "Épreuve indisponible.", + }, +} diff --git a/apps/web/src/i18n/language-toggle.tsx b/apps/web/src/i18n/language-toggle.tsx new file mode 100644 index 0000000..29f4c57 --- /dev/null +++ b/apps/web/src/i18n/language-toggle.tsx @@ -0,0 +1,25 @@ +import { useI18n, type Lang } from "./context" + +const LANGS: Lang[] = ["fr", "en"] + +/** Petit sélecteur FR / EN. */ +export function LanguageToggle({ className = "" }: { className?: string }) { + const { lang, setLang } = useI18n() + return ( +
          + {LANGS.map((l) => ( + + ))} +
          + ) +} diff --git a/apps/web/src/i18n/provider.tsx b/apps/web/src/i18n/provider.tsx new file mode 100644 index 0000000..190ef8f --- /dev/null +++ b/apps/web/src/i18n/provider.tsx @@ -0,0 +1,30 @@ +import { useState, type ReactNode } from "react" +import { fr } from "./fr" +import { en } from "./en" +import { + I18nContext, + LANG_KEY, + detectLang, + type Dict, + type Lang, +} from "./context" + +export function I18nProvider({ children }: { children: ReactNode }) { + const [lang, setLangState] = useState(detectLang) + const dicts: Record = { fr, en } + + function setLang(next: Lang) { + try { + localStorage.setItem(LANG_KEY, next) + } catch { + // ignore + } + setLangState(next) + } + + return ( + + {children} + + ) +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 6d99f3c..e611d29 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import "@workspace/ui/globals.css" import { App } from "./App.tsx" import { ThemeProvider } from "@/components/theme-provider.tsx" +import { I18nProvider } from "@/i18n/provider" const queryClient = new QueryClient() @@ -12,7 +13,9 @@ createRoot(document.getElementById("root")!).render( - + + + diff --git a/apps/web/src/pages/home.tsx b/apps/web/src/pages/home.tsx index c124839..41b6e70 100644 --- a/apps/web/src/pages/home.tsx +++ b/apps/web/src/pages/home.tsx @@ -4,6 +4,9 @@ import { motion } from "framer-motion" import { LogIn, Sparkles } from "lucide-react" import { Button } from "@workspace/ui/components/button" import { useRoomStore } from "@/store/room" +import { useI18n } from "@/i18n/context" +import { errorMessage } from "@/i18n/error" +import { LanguageToggle } from "@/i18n/language-toggle" const inputClass = "border-input bg-background ring-offset-background focus-visible:ring-ring h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50" @@ -20,6 +23,7 @@ const FLOATERS = [ export function HomePage() { const [, navigate] = useLocation() + const { t } = useI18n() const createRoom = useRoomStore((s) => s.createRoom) const joinRoom = useRoomStore((s) => s.joinRoom) const connected = useRoomStore((s) => s.connected) @@ -35,7 +39,7 @@ export function HomePage() { const { roomCode } = await createRoom() navigate(`/room/${roomCode}`) } catch (err) { - setError((err as { message?: string }).message ?? "Erreur") + setError(errorMessage(t, err)) } finally { setBusy(false) } @@ -48,7 +52,7 @@ export function HomePage() { const { roomCode } = await joinRoom(code.trim().toUpperCase()) navigate(`/room/${roomCode}`) } catch (err) { - setError((err as { message?: string }).message ?? "Erreur") + setError(errorMessage(t, err)) } finally { setBusy(false) } @@ -56,6 +60,7 @@ export function HomePage() { return (
          + {/* Halo animé */}

          - Party game culture geek + {t.home.tagline}

          @@ -109,20 +114,20 @@ export function HomePage() { disabled={!connected || busy} onClick={handleCreate} > - Créer une room + {t.home.create}
          - ou rejoindre + {t.home.orJoin}
          setCode(e.target.value)} @@ -133,13 +138,13 @@ export function HomePage() { disabled={!connected || busy || code.trim().length === 0} onClick={handleJoin} > - Rejoindre + {t.home.join}
          {!connected && (

          - Connexion au serveur… + {t.home.connecting}

          )} {error && ( diff --git a/apps/web/src/pages/join.tsx b/apps/web/src/pages/join.tsx index bf8d624..467f236 100644 --- a/apps/web/src/pages/join.tsx +++ b/apps/web/src/pages/join.tsx @@ -2,10 +2,13 @@ import { useEffect, useRef, useState } from "react" import { Link, useLocation } from "wouter" import { Button } from "@workspace/ui/components/button" import { useRoomStore } from "@/store/room" +import { useI18n } from "@/i18n/context" +import { errorMessage } from "@/i18n/error" /** Lien d'invitation /join/:code → rejoint la room puis redirige vers le lobby. */ export function JoinPage({ code }: { code: string }) { const [, navigate] = useLocation() + const { t } = useI18n() const joinRoom = useRoomStore((s) => s.joinRoom) const connected = useRoomStore((s) => s.connected) const [error, setError] = useState(null) @@ -18,10 +21,8 @@ export function JoinPage({ code }: { code: string }) { tried.current = true joinRoom(code) .then(({ roomCode }) => navigate(`/room/${roomCode}`, { replace: true })) - .catch((err) => - setError((err as { message?: string }).message ?? "Room introuvable.") - ) - }, [connected, code, joinRoom, navigate]) + .catch((err) => setError(errorMessage(t, err))) + }, [connected, code, joinRoom, navigate, t]) return (
          @@ -29,12 +30,12 @@ export function JoinPage({ code }: { code: string }) { <>

          {error}

          - + ) : (

          - Connexion à la room {code}… + {t.joinPage.connecting(code)}

          )}
          diff --git a/apps/web/src/pages/room.tsx b/apps/web/src/pages/room.tsx index 4f46d49..6e88854 100644 --- a/apps/web/src/pages/room.tsx +++ b/apps/web/src/pages/room.tsx @@ -17,9 +17,12 @@ import { RoomCode } from "@/components/room-code" import { Boom } from "@/components/boom" import { RoundTransition } from "@/components/round-transition" import { PseudoScreen } from "@/components/pseudo-screen" +import { useI18n } from "@/i18n/context" +import { LanguageToggle } from "@/i18n/language-toggle" export function RoomPage({ code }: { code: string }) { const [, navigate] = useLocation() + const { t } = useI18n() const snapshot = useRoomStore((s) => s.snapshot) const roomCode = useRoomStore((s) => s.roomCode) const leaveRoom = useRoomStore((s) => s.leaveRoom) @@ -37,11 +40,9 @@ export function RoomPage({ code }: { code: string }) { if (roomCode !== code) { return (
          -

          - Room introuvable ou session perdue. -

          +

          {t.room.notFound}

          - +
          ) @@ -56,7 +57,7 @@ export function RoomPage({ code }: { code: string }) { if (!snapshot || snapshot.code !== code) { return (
          -

          Chargement…

          +

          {t.common.loading}

          ) } @@ -96,9 +97,10 @@ export function RoomPage({ code }: { code: string }) { /> - {connected ? "Connecté" : "Déconnecté"} + {connected ? t.room.connected : t.room.disconnected} +