nerdware/apps/web/src/components/quiz-view.tsx
AyoubBenziza e3315d3b35 feat: mixed games by default, free-text quiz, mode-aware transitions
Mixed mode (default):
- gameType is now mixed | quiz | blindtest, default "mixed"; the lobby
  interleaves quiz + blindtest rounds so modes alternate
- lobby reworked: 3-way game-type switch, quiz count and/or blindtest config
  + track submission shown per selected type

Free-text quiz questions:
- quiz supports the `free` format (e.g. maths) alongside mcq/truefalse:
  payload without choices, {text} vote, tolerant matching on acceptedAnswers
- shared QuizQuestionPayload.choices optional; reveal truth carries answer
- match util moved to game/match.ts (shared by quiz + blindtest)
- repo/seed include free + acceptedAnswers; in-code bank gains free questions
- client quiz view renders a text input for free questions

Mode-aware transitions:
- RoundTransition themed per mode (colors/label/emoji); announces the mode
  on change (quiz <-> blindtest), lighter teaser within the same mode
- custom media buckets per mode: assets/transitions/{quiz,blindtest}/ + shared
- store tracks roundModeChanged

Verified e2e: mixed game alternates quiz/blindtest to completion; free-text
scoring covered by tests (22 pass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:41:29 +02:00

157 lines
4.8 KiB
TypeScript

import { useState } from "react"
import type {
QuizPerPlayerResult,
QuizQuestionPayload,
QuizRevealTruth,
RoomSnapshot,
} from "@nerdware/shared"
import { Button } from "@workspace/ui/components/button"
import { useRoomStore } from "@/store/room"
import { Countdown } from "@/components/countdown"
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"
export function QuizView({ snapshot }: { snapshot: RoomSnapshot }) {
const round = useRoomStore((s) => s.round)
const reveal = useRoomStore((s) => s.reveal)
const voteProgress = useRoomStore((s) => s.voteProgress)
const myChoiceIndex = useRoomStore((s) => s.myChoiceIndex)
const hasVoted = useRoomStore((s) => s.hasVoted)
const playerId = useRoomStore((s) => s.playerId)
const vote = useRoomStore((s) => s.vote)
const voteText = useRoomStore((s) => s.voteText)
if (!round) {
return (
<p className="text-muted-foreground text-sm">Préparation de la manche</p>
)
}
const question = round.payload as QuizQuestionPayload
const truth = reveal ? (reveal.truth as QuizRevealTruth) : null
const showReveal = truth !== null
const isFree = question.format === "free"
const myResult = reveal
? (reveal.perPlayerResult as QuizPerPlayerResult)[playerId ?? ""]
: undefined
function choiceClass(index: number): string {
const base =
"w-full rounded-lg border px-4 py-3 text-left text-sm transition-colors"
if (showReveal) {
if (index === truth?.correctIndex) {
return `${base} border-green-500 bg-green-500/15 font-medium`
}
if (index === myChoiceIndex) {
return `${base} border-red-500 bg-red-500/10`
}
return `${base} border-input opacity-60`
}
if (index === myChoiceIndex) {
return `${base} border-primary bg-primary/10 font-medium`
}
return `${base} border-input hover:bg-muted/60`
}
return (
<div className="flex w-full max-w-md flex-col gap-5">
<header className="flex items-center justify-between">
<span className="text-muted-foreground text-xs uppercase">
Question {snapshot.currentRound + 1} / {snapshot.totalRounds}
{question.category ? ` · ${question.category}` : ""}
</span>
{!showReveal && (
<Countdown
key={round.endsAt}
startsAt={round.startsAt}
endsAt={round.endsAt}
/>
)}
</header>
<h2 className="font-heading text-xl font-semibold text-balance">
{question.prompt}
</h2>
{isFree ? (
<FreeAnswer
disabled={showReveal || hasVoted}
onSubmit={voteText}
/>
) : (
<div className="flex flex-col gap-2">
{(question.choices ?? []).map((choice, index) => (
<button
key={index}
className={choiceClass(index)}
disabled={showReveal || hasVoted}
onClick={() => vote(index)}
>
{choice}
</button>
))}
</div>
)}
{!showReveal &&
(hasVoted ? (
<p className="text-muted-foreground text-center text-xs">
Réponse envoyée en attente des autres
{voteProgress ? ` (${voteProgress.count}/${voteProgress.total})` : ""}
</p>
) : (
voteProgress && (
<p className="text-muted-foreground text-center text-xs">
{voteProgress.count}/{voteProgress.total} ont répondu
</p>
)
))}
{showReveal && (
<div className="flex flex-col items-center gap-1">
{isFree && (
<p className="text-sm">
<span className="text-muted-foreground">Réponse : </span>
<span className="font-medium">{truth?.answer}</span>
</p>
)}
<p
className={`text-center text-sm font-medium ${myResult?.correct ? "text-green-500" : "text-red-500"}`}
>
{myResult?.correct
? "Bonne réponse ! 🎉"
: !hasVoted
? "Pas de réponse 😴"
: "Raté 💥"}
</p>
</div>
)}
</div>
)
}
function FreeAnswer({
disabled,
onSubmit,
}: {
disabled: boolean
onSubmit: (text: string) => void
}) {
const [text, setText] = useState("")
return (
<div className="flex gap-2">
<input
className={inputClass}
placeholder="Ta réponse"
value={text}
disabled={disabled}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && text.trim() && onSubmit(text)}
/>
<Button disabled={disabled || !text.trim()} onClick={() => onSubmit(text)}>
Valider
</Button>
</div>
)
}