diff --git a/.gitignore b/.gitignore
index 40c0fce..ef333c7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,6 +23,7 @@ Thumbs.db
frontend/node_modules/
frontend/.yarn/
frontend/dist/
+frontend/**/dist/
frontend/build/
frontend/.cache/
frontend/.vite/
diff --git a/frontend/apps/web/node_modules/.vite/vitest/results.json b/frontend/apps/web/node_modules/.vite/vitest/results.json
new file mode 100644
index 0000000..6d23824
--- /dev/null
+++ b/frontend/apps/web/node_modules/.vite/vitest/results.json
@@ -0,0 +1 @@
+{"version":"1.6.1","results":[[":src/services/validation.test.ts",{"duration":2,"failed":false}],[":src/ui/Timer.test.ts",{"duration":2,"failed":false}]]}
\ No newline at end of file
diff --git a/frontend/apps/web/src/App.tsx b/frontend/apps/web/src/App.tsx
index a885ea4..1f20a8a 100644
--- a/frontend/apps/web/src/App.tsx
+++ b/frontend/apps/web/src/App.tsx
@@ -6,12 +6,14 @@ import GameRoute from './routes/Game'
import HomeRoute from './routes/Home'
import LeaderboardRoute from './routes/Leaderboard'
import ProfileRoute from './routes/Profile'
+import ResultsRoute from './routes/Results'
const App: Component = () => {
return (
+
diff --git a/frontend/apps/web/src/components/AppShell.tsx b/frontend/apps/web/src/components/AppShell.tsx
index f57bcf3..e4193e9 100644
--- a/frontend/apps/web/src/components/AppShell.tsx
+++ b/frontend/apps/web/src/components/AppShell.tsx
@@ -1,4 +1,4 @@
-import { A, Outlet } from '@solidjs/router'
+import { A, type RouteSectionProps } from '@solidjs/router'
import type { Component } from 'solid-js'
import AppBar from '@suid/material/AppBar'
@@ -14,7 +14,7 @@ import SportsEsportsIcon from '@suid/icons-material/SportsEsports'
import LeaderboardIcon from '@suid/icons-material/Leaderboard'
import PersonIcon from '@suid/icons-material/Person'
-const AppShell: Component = () => {
+const AppShell: Component = (props) => {
return (
@@ -41,7 +41,7 @@ const AppShell: Component = () => {
-
+ {props.children}
)
diff --git a/frontend/apps/web/src/hooks/useAuth.ts b/frontend/apps/web/src/hooks/useAuth.ts
new file mode 100644
index 0000000..94c9f42
--- /dev/null
+++ b/frontend/apps/web/src/hooks/useAuth.ts
@@ -0,0 +1,29 @@
+import { createSignal } from 'solid-js'
+
+const AUTH_TOKEN_KEY = 'kf.auth.token'
+
+function readToken(): string | null {
+ if (typeof window === 'undefined') return null
+ return window.localStorage.getItem(AUTH_TOKEN_KEY)
+}
+
+export function useAuth() {
+ const [token, setToken] = createSignal(readToken())
+
+ const signInDemo = () => {
+ const next = 'demo-token'
+ window.localStorage.setItem(AUTH_TOKEN_KEY, next)
+ setToken(next)
+ }
+
+ const signOut = () => {
+ window.localStorage.removeItem(AUTH_TOKEN_KEY)
+ setToken(null)
+ }
+
+ return {
+ isAuthenticated: () => token() != null,
+ signInDemo,
+ signOut,
+ }
+}
diff --git a/frontend/apps/web/src/routes/Game.tsx b/frontend/apps/web/src/routes/Game.tsx
index 34e0fb7..6e8f0de 100644
--- a/frontend/apps/web/src/routes/Game.tsx
+++ b/frontend/apps/web/src/routes/Game.tsx
@@ -1,54 +1,153 @@
-import { createMemo, createSignal, onCleanup, onMount } from 'solid-js'
+import { useNavigate } from '@solidjs/router'
+import { createEffect, createMemo, createSignal, onMount } from 'solid-js'
import Box from '@suid/material/Box'
import Button from '@suid/material/Button'
import Card from '@suid/material/Card'
import CardContent from '@suid/material/CardContent'
+import Dialog from '@suid/material/Dialog'
+import DialogActions from '@suid/material/DialogActions'
+import DialogContent from '@suid/material/DialogContent'
+import DialogTitle from '@suid/material/DialogTitle'
import Divider from '@suid/material/Divider'
import Stack from '@suid/material/Stack'
import TextField from '@suid/material/TextField'
import Typography from '@suid/material/Typography'
+import { useTimer } from '../hooks/useTimer'
+import { leaderboardClient } from '../services/api'
+import { appendGameHistory, saveLastResult } from '../services/session'
import AttemptIndicator from '../ui/AttemptIndicator'
import ScoreDisplay from '../ui/ScoreDisplay'
import Timer from '../ui/Timer'
-import { useTimer } from '../hooks/useTimer'
+
+type QuizQuestion = {
+ theme: string
+ text: string
+ answer: string
+ hint: string
+}
+
+const QUESTION: QuizQuestion = {
+ theme: 'Général',
+ text: 'Quel est le plus petit nombre premier ?',
+ answer: '2',
+ hint: 'C’est le seul nombre premier pair.',
+}
+
+function readInputValue(event: Event): string {
+ return (event.target as HTMLInputElement).value
+}
+
+function normalizeAnswer(answer: string): string {
+ return answer.trim().toLowerCase()
+}
+
+async function estimateLeaderboardPosition(score: number, durationSec: number): Promise {
+ try {
+ const rows = await leaderboardClient.top10()
+ const sorted = [...rows].sort((a, b) => {
+ if (b.score !== a.score) return b.score - a.score
+ const durationA = typeof a.durationSec === 'number' ? a.durationSec : Number.MAX_SAFE_INTEGER
+ const durationB = typeof b.durationSec === 'number' ? b.durationSec : Number.MAX_SAFE_INTEGER
+ return durationA - durationB
+ })
+
+ let rank = 1
+ for (const row of sorted) {
+ const rowDuration = typeof row.durationSec === 'number' ? row.durationSec : Number.MAX_SAFE_INTEGER
+ if (score > row.score) break
+ if (score === row.score && durationSec < rowDuration) break
+ rank += 1
+ }
+
+ return rank <= 10 ? rank : null
+ } catch {
+ return null
+ }
+}
export default function GameRoute() {
- // Placeholder game state until backend exists.
- const [question] = createSignal({ theme: 'Général', text: 'Quel est le plus petit nombre premier ?' })
+ const navigate = useNavigate()
+
+ const [question] = createSignal(QUESTION)
const [answer, setAnswer] = createSignal('')
const [attempts, setAttempts] = createSignal(0)
const [hintUsed, setHintUsed] = createSignal(false)
const [score, setScore] = createSignal(0)
const [message, setMessage] = createSignal(null)
+ const [hintDialogOpen, setHintDialogOpen] = createSignal(false)
+ const [finished, setFinished] = createSignal(false)
const durationMs = 30 * 60 * 1000
- const { remainingMs, isExpired, start } = useTimer(durationMs)
+ const startedAt = Date.now()
+ const { remainingMs, isExpired, start, stop } = useTimer(durationMs)
onMount(() => start())
- onCleanup(() => {
- // no-op; hook cleans itself
- })
-
const attemptsLeft = createMemo(() => Math.max(0, 3 - attempts()))
+ const answerLocked = createMemo(() => finished() || attemptsLeft() === 0 || isExpired())
+
+ const finalize = async (finalScore: number, answered: number, correct: number) => {
+ if (finished()) return
+ setFinished(true)
+ stop()
+
+ const elapsedSec = Math.max(1, Math.floor((Date.now() - startedAt) / 1000))
+ const durationSec = Math.min(Math.floor(durationMs / 1000), elapsedSec)
+ const successRate = answered > 0 ? Math.round((correct / answered) * 100) : 0
+ const playerName = window.localStorage.getItem('kf.playerName') ?? 'Anonymous'
+ const leaderboardPosition = await estimateLeaderboardPosition(finalScore, durationSec)
+
+ const result = {
+ playerName,
+ finalScore,
+ answered,
+ correct,
+ successRate,
+ durationSec,
+ leaderboardPosition,
+ finishedAt: new Date().toISOString(),
+ }
+
+ saveLastResult(result)
+ appendGameHistory(result)
+ navigate('/results')
+ }
+
+ createEffect(() => {
+ if (isExpired() && !finished()) {
+ setMessage('Temps écoulé.')
+ const answered = attempts() > 0 ? 1 : 0
+ void finalize(score(), answered, 0)
+ }
+ })
const submit = () => {
- if (isExpired()) {
- setMessage('Session terminée (timeout).')
+ if (answerLocked()) return
+
+ const normalized = normalizeAnswer(answer())
+ if (!normalized) {
+ setMessage('Saisis une réponse avant d’envoyer.')
return
}
- const normalized = answer().trim().toLowerCase()
- const correct = normalized === '2' || normalized === 'deux'
-
- setAttempts((a) => a + 1)
+ const isCorrect = normalized === normalizeAnswer(question().answer) || normalized === 'deux'
+ const nextAttempts = attempts() + 1
+ setAttempts(nextAttempts)
- if (correct) {
+ if (isCorrect) {
const delta = hintUsed() ? 1 : 2
- setScore((s) => s + delta)
- setMessage(`Bonne réponse (+${delta}).`)
+ const nextScore = score() + delta
+ setScore(nextScore)
+ setMessage(`Bonne réponse (+${delta}).`)
+ void finalize(nextScore, 1, 1)
+ return
+ }
+
+ if (nextAttempts >= 3) {
+ setMessage("Mauvaise réponse. Plus d'essais.")
+ void finalize(score(), 1, 0)
} else {
setMessage('Mauvaise réponse.')
}
@@ -56,10 +155,15 @@ export default function GameRoute() {
setAnswer('')
}
- const useHint = () => {
- if (hintUsed()) return
+ const openHintDialog = () => {
+ if (hintUsed() || attempts() > 0 || answerLocked()) return
+ setHintDialogOpen(true)
+ }
+
+ const confirmHint = () => {
setHintUsed(true)
- setMessage('Indice: c’est le seul nombre premier pair.')
+ setHintDialogOpen(false)
+ setMessage(`Indice: ${question().hint}`)
}
return (
@@ -86,8 +190,9 @@ export default function GameRoute() {
setAnswer(e.currentTarget.value)}
+ onInput={(e) => setAnswer(readInputValue(e))}
fullWidth
+ disabled={answerLocked()}
InputLabelProps={{ style: { color: '#cbd5e1' } }}
InputProps={{ style: { color: '#e5e7eb' } }}
onKeyDown={(e) => {
@@ -98,10 +203,10 @@ export default function GameRoute() {
-
@@ -115,6 +220,21 @@ export default function GameRoute() {
+
+
)
}
diff --git a/frontend/apps/web/src/routes/Home.tsx b/frontend/apps/web/src/routes/Home.tsx
index 5506a61..347d1d1 100644
--- a/frontend/apps/web/src/routes/Home.tsx
+++ b/frontend/apps/web/src/routes/Home.tsx
@@ -9,7 +9,11 @@ import Stack from '@suid/material/Stack'
import TextField from '@suid/material/TextField'
import Typography from '@suid/material/Typography'
-import { playerNameSchema } from '../services/validation'
+import { validatePlayerName } from '../services/validation'
+
+function readInputValue(event: Event): string {
+ return (event.target as HTMLInputElement).value
+}
export default function HomeRoute() {
const navigate = useNavigate()
@@ -18,9 +22,9 @@ export default function HomeRoute() {
const start = () => {
const name = playerName().trim()
- const parsed = playerNameSchema.safeParse(name)
- if (!parsed.success) {
- setError(parsed.error.issues[0]?.message ?? 'Nom invalide')
+ const validationError = validatePlayerName(name)
+ if (validationError) {
+ setError(validationError)
return
}
@@ -45,7 +49,7 @@ export default function HomeRoute() {
setPlayerName(e.currentTarget.value)}
+ onInput={(e) => setPlayerName(readInputValue(e))}
error={!!error()}
helperText={error() ?? '2–50 caractères'}
fullWidth
diff --git a/frontend/apps/web/src/routes/Leaderboard.tsx b/frontend/apps/web/src/routes/Leaderboard.tsx
index d72f11e..4eb7adc 100644
--- a/frontend/apps/web/src/routes/Leaderboard.tsx
+++ b/frontend/apps/web/src/routes/Leaderboard.tsx
@@ -6,10 +6,20 @@ import Card from '@suid/material/Card'
import CardContent from '@suid/material/CardContent'
import CircularProgress from '@suid/material/CircularProgress'
import Stack from '@suid/material/Stack'
+import Table from '@suid/material/Table'
+import TableBody from '@suid/material/TableBody'
+import TableCell from '@suid/material/TableCell'
+import TableHead from '@suid/material/TableHead'
+import TableRow from '@suid/material/TableRow'
import Typography from '@suid/material/Typography'
import { leaderboardClient } from '../services/api'
+function formatDuration(durationSec: number): string {
+ const minutes = Math.floor(durationSec / 60)
+ return `${minutes}m`
+}
+
export default function LeaderboardRoute() {
const [items, { refetch }] = createResource(async () => leaderboardClient.top10())
@@ -32,17 +42,32 @@ export default function LeaderboardRoute() {
when={(items() ?? []).length > 0}
fallback={Aucun score pour le moment.}
>
-
-
- {(row, idx) => (
-
- #{idx() + 1}
- {row.player}
- {row.score}
-
- )}
-
-
+
+
+
+ Rang
+ Joueur
+ Score
+ Questions
+ Taux de réussite
+ Durée
+
+
+
+
+ {(row, idx) => (
+
+ #{idx() + 1}
+ {row.player}
+ {row.score}
+ {row.questions}
+ {row.successRate}%
+ {formatDuration(row.durationSec)}
+
+ )}
+
+
+
diff --git a/frontend/apps/web/src/routes/Profile.test.tsx b/frontend/apps/web/src/routes/Profile.test.tsx
deleted file mode 100644
index a5180c1..0000000
--- a/frontend/apps/web/src/routes/Profile.test.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-import { render } from '@solidjs/testing-library'
-import { describe, expect, it } from 'vitest'
-
-import ProfileRoute from './Profile'
-
-describe('ProfileRoute', () => {
- it('renders', () => {
- const { getByText } = render(() => )
- expect(getByText('Profil')).toBeTruthy()
- })
-})
diff --git a/frontend/apps/web/src/routes/Profile.tsx b/frontend/apps/web/src/routes/Profile.tsx
index c6cfd29..355eb61 100644
--- a/frontend/apps/web/src/routes/Profile.tsx
+++ b/frontend/apps/web/src/routes/Profile.tsx
@@ -1,15 +1,36 @@
-import { createSignal } from 'solid-js'
+import { For, Show, createMemo, createSignal } from 'solid-js'
import Box from '@suid/material/Box'
import Button from '@suid/material/Button'
import Card from '@suid/material/Card'
import CardContent from '@suid/material/CardContent'
+import Divider from '@suid/material/Divider'
import Stack from '@suid/material/Stack'
+import Switch from '@suid/material/Switch'
import TextField from '@suid/material/TextField'
import Typography from '@suid/material/Typography'
+import { useAuth } from '../hooks/useAuth'
+import { loadGameHistory } from '../services/session'
+
+function readInputValue(event: Event): string {
+ return (event.target as HTMLInputElement).value
+}
+
export default function ProfileRoute() {
+ const { isAuthenticated, signInDemo, signOut } = useAuth()
const [name, setName] = createSignal(localStorage.getItem('kf.playerName') ?? '')
+ const [showHints, setShowHints] = createSignal(true)
+
+ const gameHistory = createMemo(() => loadGameHistory())
+ const stats = createMemo(() => {
+ const history = gameHistory()
+ const gamesPlayed = history.length
+ const totalScore = history.reduce((acc, item) => acc + item.finalScore, 0)
+ const averageScore = gamesPlayed > 0 ? Math.round((totalScore / gamesPlayed) * 10) / 10 : 0
+ const bestScore = history.reduce((best, item) => Math.max(best, item.finalScore), 0)
+ return { gamesPlayed, averageScore, bestScore }
+ })
const save = () => {
localStorage.setItem('kf.playerName', name().trim())
@@ -17,40 +38,106 @@ export default function ProfileRoute() {
return (
-
-
-
-
- Profil
-
- Paramètres locaux (placeholder).
-
- setName(e.currentTarget.value)}
- fullWidth
- InputLabelProps={{ style: { color: '#cbd5e1' } }}
- InputProps={{ style: { color: '#e5e7eb' } }}
- />
-
-
-
- Enregistrer
-
- {
- localStorage.removeItem('kf.playerName')
- setName('')
- }}
- >
- Effacer
-
-
-
-
-
+
+
+
+
+ Profil
+
+ Connexion requise pour accéder au profil joueur.
+
+ Se connecter (mode démo)
+
+
+
+
+ }
+ >
+
+
+
+
+
+ Statistiques joueur
+
+ Parties jouées : {stats().gamesPlayed}
+ Score moyen : {stats().averageScore}
+ Meilleur score : {stats().bestScore}
+
+
+
+
+
+
+
+
+ Historique des parties
+
+ 0}
+ fallback={Aucune partie enregistrée.}
+ >
+
+ {(item) => (
+
+
+ Score {item.finalScore} • Réussite {item.successRate}% • Durée {Math.floor(item.durationSec / 60)}m
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ Paramètres
+
+
+ setName(readInputValue(e))}
+ fullWidth
+ InputLabelProps={{ style: { color: '#cbd5e1' } }}
+ InputProps={{ style: { color: '#e5e7eb' } }}
+ />
+
+
+ setShowHints((v) => !v)} />
+ Afficher les indices pendant une partie
+
+
+
+
+ Enregistrer
+
+ {
+ localStorage.removeItem('kf.playerName')
+ setName('')
+ }}
+ >
+ Effacer le pseudo
+
+
+ Se déconnecter
+
+
+
+
+
+
+
)
}
diff --git a/frontend/apps/web/src/routes/Results.tsx b/frontend/apps/web/src/routes/Results.tsx
new file mode 100644
index 0000000..22fd575
--- /dev/null
+++ b/frontend/apps/web/src/routes/Results.tsx
@@ -0,0 +1,74 @@
+import { useNavigate } from '@solidjs/router'
+import { Show, createMemo } from 'solid-js'
+
+import Box from '@suid/material/Box'
+import Button from '@suid/material/Button'
+import Card from '@suid/material/Card'
+import CardContent from '@suid/material/CardContent'
+import Stack from '@suid/material/Stack'
+import Typography from '@suid/material/Typography'
+
+import { loadLastResult } from '../services/session'
+
+function formatDuration(durationSec: number): string {
+ const minutes = Math.floor(durationSec / 60)
+ const seconds = durationSec % 60
+ return `${minutes}m ${seconds.toString().padStart(2, '0')}s`
+}
+
+export default function ResultsRoute() {
+ const navigate = useNavigate()
+ const result = createMemo(() => loadLastResult())
+
+ return (
+
+
+
+
+
+ Résultats
+
+ Aucune partie terminée pour le moment.
+ navigate('/game')}>
+ Démarrer une partie
+
+
+ }
+ >
+ {(last) => (
+
+
+ Résultats
+
+ Joueur : {last().playerName}
+
+ Score final : {last().finalScore}
+
+ Questions répondues / correctes : {last().answered} / {last().correct}
+
+ Taux de réussite : {last().successRate}%
+ Durée de session : {formatDuration(last().durationSec)}
+
+ Position leaderboard :{' '}
+ {last().leaderboardPosition != null ? `#${last().leaderboardPosition}` : 'Hors top 10'}
+
+
+
+ navigate('/game')}>
+ Rejouer
+
+ navigate('/leaderboard')}>
+ Voir le leaderboard
+
+
+
+ )}
+
+
+
+
+ )
+}
diff --git a/frontend/apps/web/src/services/api.ts b/frontend/apps/web/src/services/api.ts
index af012bf..5369e05 100644
--- a/frontend/apps/web/src/services/api.ts
+++ b/frontend/apps/web/src/services/api.ts
@@ -1,6 +1,9 @@
-type LeaderboardRow = {
+export type LeaderboardRow = {
player: string
score: number
+ questions: number
+ successRate: number
+ durationSec: number
}
const baseUrl = import.meta.env.VITE_API_BASE_URL as string | undefined
@@ -20,14 +23,21 @@ export const leaderboardClient = {
// For now, return a mock if API is not set.
if (!baseUrl) {
return [
- { player: 'Alice', score: 24 },
- { player: 'Bob', score: 22 },
- { player: 'Charlie', score: 20 },
+ { player: 'Alice', score: 24, questions: 14, successRate: 86, durationSec: 1680 },
+ { player: 'Bob', score: 22, questions: 13, successRate: 85, durationSec: 1500 },
+ { player: 'Charlie', score: 20, questions: 12, successRate: 83, durationSec: 1800 },
+ { player: 'Diana', score: 18, questions: 11, successRate: 82, durationSec: 1320 },
+ { player: 'Eve', score: 16, questions: 10, successRate: 80, durationSec: 1620 },
+ { player: 'Farah', score: 15, questions: 9, successRate: 78, durationSec: 1730 },
+ { player: 'Gino', score: 14, questions: 9, successRate: 74, durationSec: 1780 },
+ { player: 'Hugo', score: 13, questions: 8, successRate: 75, durationSec: 1650 },
+ { player: 'Ines', score: 12, questions: 8, successRate: 71, durationSec: 1600 },
+ { player: 'Jules', score: 11, questions: 7, successRate: 70, durationSec: 1710 },
]
}
// Example endpoint; adjust when gateway routes are finalized
const resp = await json<{ items?: LeaderboardRow[] }>(`/leaderboard/top10`)
- return resp.items ?? []
+ return (resp.items ?? []).slice(0, 10)
},
}
diff --git a/frontend/apps/web/src/services/session.ts b/frontend/apps/web/src/services/session.ts
new file mode 100644
index 0000000..ad45868
--- /dev/null
+++ b/frontend/apps/web/src/services/session.ts
@@ -0,0 +1,50 @@
+export type GameResult = {
+ playerName: string
+ finalScore: number
+ answered: number
+ correct: number
+ successRate: number
+ durationSec: number
+ leaderboardPosition: number | null
+ finishedAt: string
+}
+
+const LAST_RESULT_KEY = 'kf.lastResult'
+const HISTORY_KEY = 'kf.gameHistory'
+const HISTORY_LIMIT = 20
+
+function hasStorage(): boolean {
+ return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
+}
+
+function readJson(key: string): T | null {
+ if (!hasStorage()) return null
+ const raw = window.localStorage.getItem(key)
+ if (!raw) return null
+
+ try {
+ return JSON.parse(raw) as T
+ } catch {
+ return null
+ }
+}
+
+export function saveLastResult(result: GameResult): void {
+ if (!hasStorage()) return
+ window.localStorage.setItem(LAST_RESULT_KEY, JSON.stringify(result))
+}
+
+export function loadLastResult(): GameResult | null {
+ return readJson(LAST_RESULT_KEY)
+}
+
+export function appendGameHistory(result: GameResult): void {
+ if (!hasStorage()) return
+ const history = loadGameHistory()
+ const next = [result, ...history].slice(0, HISTORY_LIMIT)
+ window.localStorage.setItem(HISTORY_KEY, JSON.stringify(next))
+}
+
+export function loadGameHistory(): GameResult[] {
+ return readJson(HISTORY_KEY) ?? []
+}
diff --git a/frontend/apps/web/src/services/validation.test.ts b/frontend/apps/web/src/services/validation.test.ts
new file mode 100644
index 0000000..3d3749a
--- /dev/null
+++ b/frontend/apps/web/src/services/validation.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, it } from 'vitest'
+
+import { validatePlayerName } from './validation'
+
+describe('validatePlayerName', () => {
+ it('accepts a valid player name', () => {
+ expect(validatePlayerName('Player 42')).toBeNull()
+ })
+
+ it('rejects too short names', () => {
+ expect(validatePlayerName('A')).toBe('Le nom doit faire au moins 2 caractères')
+ })
+
+ it('rejects non alphanumeric characters', () => {
+ expect(validatePlayerName('Player@42')).toBe('Le nom doit contenir seulement lettres, chiffres et espaces')
+ })
+})
diff --git a/frontend/apps/web/src/services/validation.ts b/frontend/apps/web/src/services/validation.ts
index 05741d5..887624a 100644
--- a/frontend/apps/web/src/services/validation.ts
+++ b/frontend/apps/web/src/services/validation.ts
@@ -1,6 +1,9 @@
-import { z } from 'zod'
+const PLAYER_NAME_REGEX = /^[A-Za-z0-9 ]+$/
-export const playerNameSchema = z
- .string()
- .min(2, 'Le nom doit faire au moins 2 caractères')
- .max(50, 'Le nom doit faire au plus 50 caractères')
+export function validatePlayerName(value: string): string | null {
+ const name = value.trim()
+ if (name.length < 2) return 'Le nom doit faire au moins 2 caractères'
+ if (name.length > 50) return 'Le nom doit faire au plus 50 caractères'
+ if (!PLAYER_NAME_REGEX.test(name)) return 'Le nom doit contenir seulement lettres, chiffres et espaces'
+ return null
+}
diff --git a/frontend/apps/web/src/ui/Timer.test.ts b/frontend/apps/web/src/ui/Timer.test.ts
new file mode 100644
index 0000000..12eb273
--- /dev/null
+++ b/frontend/apps/web/src/ui/Timer.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from 'vitest'
+
+import { formatMs, getTimerWarning } from './Timer'
+
+describe('Timer helpers', () => {
+ it('formats remaining milliseconds as mm:ss', () => {
+ expect(formatMs(65_000)).toBe('1:05')
+ })
+
+ it('returns the 5-minute warning', () => {
+ expect(getTimerWarning(299_000)).toBe('5 minutes restantes')
+ })
+
+ it('returns the 1-minute warning', () => {
+ expect(getTimerWarning(59_000)).toBe('1 minute restante')
+ })
+
+ it('returns the 10-second warning', () => {
+ expect(getTimerWarning(9_000)).toBe('10 secondes restantes')
+ })
+})
diff --git a/frontend/apps/web/src/ui/Timer.tsx b/frontend/apps/web/src/ui/Timer.tsx
index 8320e67..2237c23 100644
--- a/frontend/apps/web/src/ui/Timer.tsx
+++ b/frontend/apps/web/src/ui/Timer.tsx
@@ -1,22 +1,43 @@
-import type { Component } from 'solid-js'
+import { Show, type Component } from 'solid-js'
import Chip from '@suid/material/Chip'
+import Stack from '@suid/material/Stack'
+import Typography from '@suid/material/Typography'
-function formatMs(ms: number) {
+export function formatMs(ms: number) {
const total = Math.ceil(ms / 1000)
const m = Math.floor(total / 60)
const s = total % 60
return `${m}:${s.toString().padStart(2, '0')}`
}
+export function getTimerWarning(remainingMs: number): string | null {
+ if (remainingMs <= 10_000) return '10 secondes restantes'
+ if (remainingMs <= 60_000) return '1 minute restante'
+ if (remainingMs <= 300_000) return '5 minutes restantes'
+ return null
+}
+
const Timer: Component<{ remainingMs: number }> = (props) => {
const color = () => {
if (props.remainingMs <= 10_000) return 'error'
if (props.remainingMs <= 60_000) return 'warning'
+ if (props.remainingMs <= 300_000) return 'info'
return 'default'
}
- return
+ const warning = () => getTimerWarning(props.remainingMs)
+
+ return (
+
+
+
+
+ {warning()}
+
+
+
+ )
}
export default Timer
diff --git a/frontend/yarn.lock b/frontend/yarn.lock
index eafddbe..9dccb72 100644
--- a/frontend/yarn.lock
+++ b/frontend/yarn.lock
@@ -5,6 +5,19 @@ __metadata:
version: 8
cacheKey: 10c0
+"@asamuzakjp/css-color@npm:^3.2.0":
+ version: 3.2.0
+ resolution: "@asamuzakjp/css-color@npm:3.2.0"
+ dependencies:
+ "@csstools/css-calc": "npm:^2.1.3"
+ "@csstools/css-color-parser": "npm:^3.0.9"
+ "@csstools/css-parser-algorithms": "npm:^3.0.4"
+ "@csstools/css-tokenizer": "npm:^3.0.3"
+ lru-cache: "npm:^10.4.3"
+ checksum: a4bf1c831751b1fae46b437e37e8a38c0b5bd58d23230157ae210bd1e905fe509b89b7c243e63d1522d852668a6292ed730a160e21342772b4e5b7b8ea14c092
+ languageName: node
+ linkType: hard
+
"@babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:^7.28.6, @babel/code-frame@npm:^7.29.0":
version: 7.29.0
resolution: "@babel/code-frame@npm:7.29.0"
@@ -214,6 +227,52 @@ __metadata:
languageName: node
linkType: hard
+"@csstools/color-helpers@npm:^5.1.0":
+ version: 5.1.0
+ resolution: "@csstools/color-helpers@npm:5.1.0"
+ checksum: b7f99d2e455cf1c9b41a67a5327d5d02888cd5c8802a68b1887dffef537d9d4bc66b3c10c1e62b40bbed638b6c1d60b85a232f904ed7b39809c4029cb36567db
+ languageName: node
+ linkType: hard
+
+"@csstools/css-calc@npm:^2.1.3, @csstools/css-calc@npm:^2.1.4":
+ version: 2.1.4
+ resolution: "@csstools/css-calc@npm:2.1.4"
+ peerDependencies:
+ "@csstools/css-parser-algorithms": ^3.0.5
+ "@csstools/css-tokenizer": ^3.0.4
+ checksum: 42ce5793e55ec4d772083808a11e9fb2dfe36db3ec168713069a276b4c3882205b3507c4680224c28a5d35fe0bc2d308c77f8f2c39c7c09aad8747708eb8ddd8
+ languageName: node
+ linkType: hard
+
+"@csstools/css-color-parser@npm:^3.0.9":
+ version: 3.1.0
+ resolution: "@csstools/css-color-parser@npm:3.1.0"
+ dependencies:
+ "@csstools/color-helpers": "npm:^5.1.0"
+ "@csstools/css-calc": "npm:^2.1.4"
+ peerDependencies:
+ "@csstools/css-parser-algorithms": ^3.0.5
+ "@csstools/css-tokenizer": ^3.0.4
+ checksum: 0e0c670ad54ec8ec4d9b07568b80defd83b9482191f5e8ca84ab546b7be6db5d7cc2ba7ac9fae54488b129a4be235d6183d3aab4416fec5e89351f73af4222c5
+ languageName: node
+ linkType: hard
+
+"@csstools/css-parser-algorithms@npm:^3.0.4":
+ version: 3.0.5
+ resolution: "@csstools/css-parser-algorithms@npm:3.0.5"
+ peerDependencies:
+ "@csstools/css-tokenizer": ^3.0.4
+ checksum: d9a1c888bd43849ae3437ca39251d5c95d2c8fd6b5ccdb7c45491dfd2c1cbdc3075645e80901d120e4d2c1993db9a5b2d83793b779dbbabcfb132adb142eb7f7
+ languageName: node
+ linkType: hard
+
+"@csstools/css-tokenizer@npm:^3.0.3":
+ version: 3.0.4
+ resolution: "@csstools/css-tokenizer@npm:3.0.4"
+ checksum: 3b589f8e9942075a642213b389bab75a2d50d05d203727fcdac6827648a5572674caff07907eff3f9a2389d86a4ee47308fafe4f8588f4a77b7167c588d2559f
+ languageName: node
+ linkType: hard
+
"@esbuild/aix-ppc64@npm:0.21.5":
version: 0.21.5
resolution: "@esbuild/aix-ppc64@npm:0.21.5"
@@ -592,11 +651,13 @@ __metadata:
"@solidjs/testing-library": "npm:^0.8.0"
"@suid/icons-material": "npm:^0.7.0"
"@suid/material": "npm:^0.16.0"
+ jsdom: "npm:^26.1.0"
solid-js: "npm:^1.9.0"
typescript: "npm:^5.5.4"
vite: "npm:^5.0.0"
vite-plugin-solid: "npm:^2.8.0"
vitest: "npm:^1.0.0"
+ zod: "npm:^3.25.76"
languageName: unknown
linkType: soft
@@ -1515,6 +1576,16 @@ __metadata:
languageName: node
linkType: hard
+"cssstyle@npm:^4.2.1":
+ version: 4.6.0
+ resolution: "cssstyle@npm:4.6.0"
+ dependencies:
+ "@asamuzakjp/css-color": "npm:^3.2.0"
+ rrweb-cssom: "npm:^0.8.0"
+ checksum: 71add1b0ffafa1bedbef6855db6189b9523d3320e015a0bf3fbd504760efb9a81e1f1a225228d5fa892ee58e56d06994ca372e7f4e461cda7c4c9985fe075f65
+ languageName: node
+ linkType: hard
+
"csstype@npm:^3.1.0, csstype@npm:^3.1.3":
version: 3.2.3
resolution: "csstype@npm:3.2.3"
@@ -1522,6 +1593,16 @@ __metadata:
languageName: node
linkType: hard
+"data-urls@npm:^5.0.0":
+ version: 5.0.0
+ resolution: "data-urls@npm:5.0.0"
+ dependencies:
+ whatwg-mimetype: "npm:^4.0.0"
+ whatwg-url: "npm:^14.0.0"
+ checksum: 1b894d7d41c861f3a4ed2ae9b1c3f0909d4575ada02e36d3d3bc584bdd84278e20709070c79c3b3bff7ac98598cb191eb3e86a89a79ea4ee1ef360e1694f92ad
+ languageName: node
+ linkType: hard
+
"debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4, debug@npm:^4.4.3":
version: 4.4.3
resolution: "debug@npm:4.4.3"
@@ -1534,6 +1615,13 @@ __metadata:
languageName: node
linkType: hard
+"decimal.js@npm:^10.5.0":
+ version: 10.6.0
+ resolution: "decimal.js@npm:10.6.0"
+ checksum: 07d69fbcc54167a340d2d97de95f546f9ff1f69d2b45a02fd7a5292412df3cd9eb7e23065e532a318f5474a2e1bccf8392fdf0443ef467f97f3bf8cb0477e5aa
+ languageName: node
+ linkType: hard
+
"deep-eql@npm:^4.1.3":
version: 4.1.4
resolution: "deep-eql@npm:4.1.4"
@@ -2045,6 +2133,15 @@ __metadata:
languageName: node
linkType: hard
+"html-encoding-sniffer@npm:^4.0.0":
+ version: 4.0.0
+ resolution: "html-encoding-sniffer@npm:4.0.0"
+ dependencies:
+ whatwg-encoding: "npm:^3.1.1"
+ checksum: 523398055dc61ac9b34718a719cb4aa691e4166f29187e211e1607de63dc25ac7af52ca7c9aead0c4b3c0415ffecb17326396e1202e2e86ff4bca4c0ee4c6140
+ languageName: node
+ linkType: hard
+
"html-entities@npm:2.3.3":
version: 2.3.3
resolution: "html-entities@npm:2.3.3"
@@ -2066,7 +2163,7 @@ __metadata:
languageName: node
linkType: hard
-"http-proxy-agent@npm:^7.0.0":
+"http-proxy-agent@npm:^7.0.0, http-proxy-agent@npm:^7.0.2":
version: 7.0.2
resolution: "http-proxy-agent@npm:7.0.2"
dependencies:
@@ -2076,7 +2173,7 @@ __metadata:
languageName: node
linkType: hard
-"https-proxy-agent@npm:^7.0.1":
+"https-proxy-agent@npm:^7.0.1, https-proxy-agent@npm:^7.0.6":
version: 7.0.6
resolution: "https-proxy-agent@npm:7.0.6"
dependencies:
@@ -2093,7 +2190,7 @@ __metadata:
languageName: node
linkType: hard
-"iconv-lite@npm:^0.6.2":
+"iconv-lite@npm:0.6.3, iconv-lite@npm:^0.6.2":
version: 0.6.3
resolution: "iconv-lite@npm:0.6.3"
dependencies:
@@ -2172,6 +2269,13 @@ __metadata:
languageName: node
linkType: hard
+"is-potential-custom-element-name@npm:^1.0.1":
+ version: 1.0.1
+ resolution: "is-potential-custom-element-name@npm:1.0.1"
+ checksum: b73e2f22bc863b0939941d369486d308b43d7aef1f9439705e3582bfccaa4516406865e32c968a35f97a99396dac84e2624e67b0a16b0a15086a785e16ce7db9
+ languageName: node
+ linkType: hard
+
"is-stream@npm:^3.0.0":
version: 3.0.0
resolution: "is-stream@npm:3.0.0"
@@ -2234,6 +2338,39 @@ __metadata:
languageName: node
linkType: hard
+"jsdom@npm:^26.1.0":
+ version: 26.1.0
+ resolution: "jsdom@npm:26.1.0"
+ dependencies:
+ cssstyle: "npm:^4.2.1"
+ data-urls: "npm:^5.0.0"
+ decimal.js: "npm:^10.5.0"
+ html-encoding-sniffer: "npm:^4.0.0"
+ http-proxy-agent: "npm:^7.0.2"
+ https-proxy-agent: "npm:^7.0.6"
+ is-potential-custom-element-name: "npm:^1.0.1"
+ nwsapi: "npm:^2.2.16"
+ parse5: "npm:^7.2.1"
+ rrweb-cssom: "npm:^0.8.0"
+ saxes: "npm:^6.0.0"
+ symbol-tree: "npm:^3.2.4"
+ tough-cookie: "npm:^5.1.1"
+ w3c-xmlserializer: "npm:^5.0.0"
+ webidl-conversions: "npm:^7.0.0"
+ whatwg-encoding: "npm:^3.1.1"
+ whatwg-mimetype: "npm:^4.0.0"
+ whatwg-url: "npm:^14.1.1"
+ ws: "npm:^8.18.0"
+ xml-name-validator: "npm:^5.0.0"
+ peerDependencies:
+ canvas: ^3.0.0
+ peerDependenciesMeta:
+ canvas:
+ optional: true
+ checksum: 5b14a5bc32ce077a06fb42d1ab95b1191afa5cbbce8859e3b96831c5143becbbcbf0511d4d4934e922d2901443ced2cdc3b734c1cf30b5f73b3e067ce457d0f4
+ languageName: node
+ linkType: hard
+
"jsesc@npm:^3.0.2":
version: 3.1.0
resolution: "jsesc@npm:3.1.0"
@@ -2358,6 +2495,13 @@ __metadata:
languageName: node
linkType: hard
+"lru-cache@npm:^10.4.3":
+ version: 10.4.3
+ resolution: "lru-cache@npm:10.4.3"
+ checksum: ebd04fbca961e6c1d6c0af3799adcc966a1babe798f685bb84e6599266599cd95d94630b10262f5424539bc4640107e8a33aa28585374abf561d30d16f4b39fb
+ languageName: node
+ linkType: hard
+
"lru-cache@npm:^11.0.0, lru-cache@npm:^11.1.0, lru-cache@npm:^11.2.1":
version: 11.2.5
resolution: "lru-cache@npm:11.2.5"
@@ -2626,6 +2770,13 @@ __metadata:
languageName: node
linkType: hard
+"nwsapi@npm:^2.2.16":
+ version: 2.2.23
+ resolution: "nwsapi@npm:2.2.23"
+ checksum: e44bfc9246baf659581206ed716d291a1905185247795fb8a302cb09315c943a31023b4ac4d026a5eaf32b2def51d77b3d0f9ebf4f3d35f70e105fcb6447c76e
+ languageName: node
+ linkType: hard
+
"onetime@npm:^6.0.0":
version: 6.0.0
resolution: "onetime@npm:6.0.0"
@@ -2692,7 +2843,7 @@ __metadata:
languageName: node
linkType: hard
-"parse5@npm:^7.1.2":
+"parse5@npm:^7.1.2, parse5@npm:^7.2.1":
version: 7.3.0
resolution: "parse5@npm:7.3.0"
dependencies:
@@ -2844,7 +2995,7 @@ __metadata:
languageName: node
linkType: hard
-"punycode@npm:^2.1.0":
+"punycode@npm:^2.1.0, punycode@npm:^2.3.1":
version: 2.3.1
resolution: "punycode@npm:2.3.1"
checksum: 14f76a8206bc3464f794fb2e3d3cc665ae416c01893ad7a02b23766eb07159144ee612ad67af5e84fa4479ccfe67678c4feb126b0485651b302babf66f04f9e9
@@ -2969,6 +3120,13 @@ __metadata:
languageName: node
linkType: hard
+"rrweb-cssom@npm:^0.8.0":
+ version: 0.8.0
+ resolution: "rrweb-cssom@npm:0.8.0"
+ checksum: 56f2bfd56733adb92c0b56e274c43f864b8dd48784d6fe946ef5ff8d438234015e59ad837fc2ad54714b6421384141c1add4eb569e72054e350d1f8a50b8ac7b
+ languageName: node
+ linkType: hard
+
"safer-buffer@npm:>= 2.1.2 < 3.0.0":
version: 2.1.2
resolution: "safer-buffer@npm:2.1.2"
@@ -2976,6 +3134,15 @@ __metadata:
languageName: node
linkType: hard
+"saxes@npm:^6.0.0":
+ version: 6.0.0
+ resolution: "saxes@npm:6.0.0"
+ dependencies:
+ xmlchars: "npm:^2.2.0"
+ checksum: 3847b839f060ef3476eb8623d099aa502ad658f5c40fd60c105ebce86d244389b0d76fcae30f4d0c728d7705ceb2f7e9b34bb54717b6a7dbedaf5dad2d9a4b74
+ languageName: node
+ linkType: hard
+
"semver@npm:^6.3.1":
version: 6.3.1
resolution: "semver@npm:6.3.1"
@@ -3163,6 +3330,13 @@ __metadata:
languageName: node
linkType: hard
+"symbol-tree@npm:^3.2.4":
+ version: 3.2.4
+ resolution: "symbol-tree@npm:3.2.4"
+ checksum: dfbe201ae09ac6053d163578778c53aa860a784147ecf95705de0cd23f42c851e1be7889241495e95c37cabb058edb1052f141387bef68f705afc8f9dd358509
+ languageName: node
+ linkType: hard
+
"tar@npm:^7.5.4":
version: 7.5.7
resolution: "tar@npm:7.5.7"
@@ -3207,6 +3381,42 @@ __metadata:
languageName: node
linkType: hard
+"tldts-core@npm:^6.1.86":
+ version: 6.1.86
+ resolution: "tldts-core@npm:6.1.86"
+ checksum: 8133c29375f3f99f88fce5f4d62f6ecb9532b106f31e5423b27c1eb1b6e711bd41875184a456819ceaed5c8b94f43911b1ad57e25c6eb86e1fc201228ff7e2af
+ languageName: node
+ linkType: hard
+
+"tldts@npm:^6.1.32":
+ version: 6.1.86
+ resolution: "tldts@npm:6.1.86"
+ dependencies:
+ tldts-core: "npm:^6.1.86"
+ bin:
+ tldts: bin/cli.js
+ checksum: 27ae7526d9d78cb97b2de3f4d102e0b4321d1ccff0648a7bb0e039ed54acbce86bacdcd9cd3c14310e519b457854e7bafbef1f529f58a1e217a737ced63f0940
+ languageName: node
+ linkType: hard
+
+"tough-cookie@npm:^5.1.1":
+ version: 5.1.2
+ resolution: "tough-cookie@npm:5.1.2"
+ dependencies:
+ tldts: "npm:^6.1.32"
+ checksum: 5f95023a47de0f30a902bba951664b359725597d8adeabc66a0b93a931c3af801e1e697dae4b8c21a012056c0ea88bd2bf4dfe66b2adcf8e2f42cd9796fe0626
+ languageName: node
+ linkType: hard
+
+"tr46@npm:^5.1.0":
+ version: 5.1.1
+ resolution: "tr46@npm:5.1.1"
+ dependencies:
+ punycode: "npm:^2.3.1"
+ checksum: ae270e194d52ec67ebd695c1a42876e0f19b96e4aca2ab464ab1d9d17dc3acd3e18764f5034c93897db73421563be27c70c98359c4501136a497e46deda5d5ec
+ languageName: node
+ linkType: hard
+
"ts-api-utils@npm:^2.4.0":
version: 2.4.0
resolution: "ts-api-utils@npm:2.4.0"
@@ -3456,6 +3666,48 @@ __metadata:
languageName: node
linkType: hard
+"w3c-xmlserializer@npm:^5.0.0":
+ version: 5.0.0
+ resolution: "w3c-xmlserializer@npm:5.0.0"
+ dependencies:
+ xml-name-validator: "npm:^5.0.0"
+ checksum: 8712774c1aeb62dec22928bf1cdfd11426c2c9383a1a63f2bcae18db87ca574165a0fbe96b312b73652149167ac6c7f4cf5409f2eb101d9c805efe0e4bae798b
+ languageName: node
+ linkType: hard
+
+"webidl-conversions@npm:^7.0.0":
+ version: 7.0.0
+ resolution: "webidl-conversions@npm:7.0.0"
+ checksum: 228d8cb6d270c23b0720cb2d95c579202db3aaf8f633b4e9dd94ec2000a04e7e6e43b76a94509cdb30479bd00ae253ab2371a2da9f81446cc313f89a4213a2c4
+ languageName: node
+ linkType: hard
+
+"whatwg-encoding@npm:^3.1.1":
+ version: 3.1.1
+ resolution: "whatwg-encoding@npm:3.1.1"
+ dependencies:
+ iconv-lite: "npm:0.6.3"
+ checksum: 273b5f441c2f7fda3368a496c3009edbaa5e43b71b09728f90425e7f487e5cef9eb2b846a31bd760dd8077739c26faf6b5ca43a5f24033172b003b72cf61a93e
+ languageName: node
+ linkType: hard
+
+"whatwg-mimetype@npm:^4.0.0":
+ version: 4.0.0
+ resolution: "whatwg-mimetype@npm:4.0.0"
+ checksum: a773cdc8126b514d790bdae7052e8bf242970cebd84af62fb2f35a33411e78e981f6c0ab9ed1fe6ec5071b09d5340ac9178e05b52d35a9c4bcf558ba1b1551df
+ languageName: node
+ linkType: hard
+
+"whatwg-url@npm:^14.0.0, whatwg-url@npm:^14.1.1":
+ version: 14.2.0
+ resolution: "whatwg-url@npm:14.2.0"
+ dependencies:
+ tr46: "npm:^5.1.0"
+ webidl-conversions: "npm:^7.0.0"
+ checksum: f746fc2f4c906607d09537de1227b13f9494c171141e5427ed7d2c0dd0b6a48b43d8e71abaae57d368d0c06b673fd8ec63550b32ad5ed64990c7b0266c2b4272
+ languageName: node
+ linkType: hard
+
"which@npm:^2.0.1":
version: 2.0.2
resolution: "which@npm:2.0.2"
@@ -3497,6 +3749,35 @@ __metadata:
languageName: node
linkType: hard
+"ws@npm:^8.18.0":
+ version: 8.19.0
+ resolution: "ws@npm:8.19.0"
+ peerDependencies:
+ bufferutil: ^4.0.1
+ utf-8-validate: ">=5.0.2"
+ peerDependenciesMeta:
+ bufferutil:
+ optional: true
+ utf-8-validate:
+ optional: true
+ checksum: 4741d9b9bc3f9c791880882414f96e36b8b254e34d4b503279d6400d9a4b87a033834856dbdd94ee4b637944df17ea8afc4bce0ff4a1560d2166be8855da5b04
+ languageName: node
+ linkType: hard
+
+"xml-name-validator@npm:^5.0.0":
+ version: 5.0.0
+ resolution: "xml-name-validator@npm:5.0.0"
+ checksum: 3fcf44e7b73fb18be917fdd4ccffff3639373c7cb83f8fc35df6001fecba7942f1dbead29d91ebb8315e2f2ff786b508f0c9dc0215b6353f9983c6b7d62cb1f5
+ languageName: node
+ linkType: hard
+
+"xmlchars@npm:^2.2.0":
+ version: 2.2.0
+ resolution: "xmlchars@npm:2.2.0"
+ checksum: b64b535861a6f310c5d9bfa10834cf49127c71922c297da9d4d1b45eeaae40bf9b4363275876088fbe2667e5db028d2cd4f8ee72eed9bede840a67d57dab7593
+ languageName: node
+ linkType: hard
+
"yallist@npm:^3.0.2":
version: 3.1.1
resolution: "yallist@npm:3.1.1"
@@ -3531,3 +3812,10 @@ __metadata:
checksum: 36d4793e9cf7060f9da543baf67c55e354f4862c8d3d34de1a1b1d7c382d44171315cc54abf84d8900b8113d742b830108a1434f4898fb244f9b7e8426d4b8f5
languageName: node
linkType: hard
+
+"zod@npm:^3.25.76":
+ version: 3.25.76
+ resolution: "zod@npm:3.25.76"
+ checksum: 5718ec35e3c40b600316c5b4c5e4976f7fee68151bc8f8d90ec18a469be9571f072e1bbaace10f1e85cf8892ea12d90821b200e980ab46916a6166a4260a983c
+ languageName: node
+ linkType: hard