feat(web): add game settings screen and submit confirmation

- Add GameSetup component with Duolingo-style button selectors for
  language pair, POS, difficulty, and rounds
- Language swap: selecting the same language for source and target
  automatically swaps them instead of allowing duplicates
- Add selection-before-submission flow: user clicks an option to
  highlight it, then confirms with a Submit button to prevent misclicks
- Add selected state to OptionButton (purple ring highlight)
- Play Again on score screen returns to settings instead of
  auto-restarting with the same configuration
- Remove hardcoded GAME_SETTINGS, game configuration is now user-driven
This commit is contained in:
lila 2026-04-11 21:18:35 +02:00
parent b7b1cd383f
commit bc7977463e
4 changed files with 209 additions and 26 deletions

View file

@ -0,0 +1,140 @@
import { useState } from "react";
import {
SUPPORTED_LANGUAGE_CODES,
SUPPORTED_POS,
DIFFICULTY_LEVELS,
GAME_ROUNDS,
} from "@glossa/shared";
import type { GameRequest } from "@glossa/shared";
const LABELS: Record<string, string> = {
en: "English",
it: "Italian",
noun: "Nouns",
verb: "Verbs",
easy: "Easy",
intermediate: "Intermediate",
hard: "Hard",
"3": "3 rounds",
"10": "10 rounds",
};
type GameSetupProps = { onStart: (settings: GameRequest) => void };
type SettingGroupProps = {
label: string;
options: readonly string[];
selected: string;
onSelect: (value: string) => void;
};
const SettingGroup = ({
label,
options,
selected,
onSelect,
}: SettingGroupProps) => (
<div className="w-full">
<p className="text-sm font-medium text-purple-400 mb-2">{label}</p>
<div className="flex gap-2 flex-wrap">
{options.map((option) => (
<button
key={option}
onClick={() => onSelect(option)}
className={`py-2 px-5 rounded-xl font-semibold text-sm border-b-4 transition-all duration-200 cursor-pointer ${
selected === option
? "bg-purple-600 text-white border-purple-800"
: "bg-white text-purple-900 border-purple-200 hover:bg-purple-50 hover:border-purple-300"
}`}
>
{LABELS[option] ?? option}
</button>
))}
</div>
</div>
);
export const GameSetup = ({ onStart }: GameSetupProps) => {
const [sourceLanguage, setSourceLanguage] = useState<string>(
SUPPORTED_LANGUAGE_CODES[0],
);
const [targetLanguage, setTargetLanguage] = useState<string>(
SUPPORTED_LANGUAGE_CODES[1],
);
const [pos, setPos] = useState<string>(SUPPORTED_POS[0]);
const [difficulty, setDifficulty] = useState<string>(DIFFICULTY_LEVELS[0]);
const [rounds, setRounds] = useState<string>(GAME_ROUNDS[0]);
const handleSourceLanguage = (value: string) => {
if (value === targetLanguage) {
setTargetLanguage(sourceLanguage);
}
setSourceLanguage(value);
};
const handleTargetLanguage = (value: string) => {
if (value === sourceLanguage) {
setSourceLanguage(targetLanguage);
}
setTargetLanguage(value);
};
const handleStart = () => {
onStart({
source_language: sourceLanguage,
target_language: targetLanguage,
pos,
difficulty,
rounds,
} as GameRequest);
};
return (
<div className="flex flex-col items-center gap-6 w-full max-w-md mx-auto">
<div className="bg-white rounded-3xl shadow-lg p-8 w-full text-center">
<h1 className="text-3xl font-bold text-purple-900 mb-1">Glossa</h1>
<p className="text-sm text-gray-400">Set up your quiz</p>
</div>
<div className="bg-white rounded-3xl shadow-lg p-6 w-full flex flex-col gap-5">
<SettingGroup
label="I speak"
options={SUPPORTED_LANGUAGE_CODES}
selected={sourceLanguage}
onSelect={handleSourceLanguage}
/>
<SettingGroup
label="I want to learn"
options={SUPPORTED_LANGUAGE_CODES}
selected={targetLanguage}
onSelect={handleTargetLanguage}
/>
<SettingGroup
label="Word type"
options={SUPPORTED_POS}
selected={pos}
onSelect={setPos}
/>
<SettingGroup
label="Difficulty"
options={DIFFICULTY_LEVELS}
selected={difficulty}
onSelect={setDifficulty}
/>
<SettingGroup
label="Rounds"
options={GAME_ROUNDS}
selected={rounds}
onSelect={setRounds}
/>
</div>
<button
onClick={handleStart}
className="w-full py-4 rounded-2xl text-xl font-bold bg-linear-to-r from-pink-400 to-purple-500 text-white border-b-4 border-purple-700 hover:-translate-y-0.5 active:translate-y-0 active:border-b-2 transition-all duration-200 cursor-pointer"
>
Start Quiz
</button>
</div>
);
};

View file

@ -1,6 +1,6 @@
type OptionButtonProps = { type OptionButtonProps = {
text: string; text: string;
state: "idle" | "disabled" | "correct" | "wrong"; state: "idle" | "selected" | "disabled" | "correct" | "wrong";
onSelect: () => void; onSelect: () => void;
}; };
@ -10,6 +10,8 @@ export const OptionButton = ({ text, state, onSelect }: OptionButtonProps) => {
const styles = { const styles = {
idle: "bg-white text-purple-900 border-purple-200 hover:bg-purple-50 hover:border-purple-300 hover:-translate-y-0.5 active:translate-y-0 active:border-b-2", idle: "bg-white text-purple-900 border-purple-200 hover:bg-purple-50 hover:border-purple-300 hover:-translate-y-0.5 active:translate-y-0 active:border-b-2",
selected:
"bg-purple-100 text-purple-900 border-purple-400 ring-2 ring-purple-400",
disabled: "bg-gray-100 text-gray-400 border-gray-200 cursor-default", disabled: "bg-gray-100 text-gray-400 border-gray-200 cursor-default",
correct: "bg-emerald-400 text-white border-emerald-600 scale-[1.02]", correct: "bg-emerald-400 text-white border-emerald-600 scale-[1.02]",
wrong: "bg-pink-400 text-white border-pink-600", wrong: "bg-pink-400 text-white border-pink-600",
@ -19,7 +21,9 @@ export const OptionButton = ({ text, state, onSelect }: OptionButtonProps) => {
<button <button
className={`${base} ${styles[state]}`} className={`${base} ${styles[state]}`}
onClick={onSelect} onClick={onSelect}
disabled={state !== "idle"} disabled={
state === "disabled" || state === "correct" || state === "wrong"
}
> >
{text} {text}
</button> </button>

View file

@ -1,3 +1,4 @@
import { useState } from "react";
import type { GameQuestion, AnswerResult } from "@glossa/shared"; import type { GameQuestion, AnswerResult } from "@glossa/shared";
import { OptionButton } from "./OptionButton"; import { OptionButton } from "./OptionButton";
@ -18,11 +19,31 @@ export const QuestionCard = ({
onAnswer, onAnswer,
onNext, onNext,
}: QuestionCardProps) => { }: QuestionCardProps) => {
const [selectedOptionId, setSelectedOptionId] = useState<number | null>(null);
const getOptionState = (optionId: number) => { const getOptionState = (optionId: number) => {
if (!currentResult) return "idle" as const; if (currentResult) {
if (optionId === currentResult.correctOptionId) return "correct" as const; if (optionId === currentResult.correctOptionId) return "correct" as const;
if (optionId === currentResult.selectedOptionId) return "wrong" as const; if (optionId === currentResult.selectedOptionId) return "wrong" as const;
return "disabled" as const; return "disabled" as const;
}
if (optionId === selectedOptionId) return "selected" as const;
return "idle" as const;
};
const handleSelect = (optionId: number) => {
if (currentResult) return;
setSelectedOptionId(optionId);
};
const handleSubmit = () => {
if (selectedOptionId === null) return;
onAnswer(selectedOptionId);
};
const handleNext = () => {
setSelectedOptionId(null);
onNext();
}; };
return ( return (
@ -48,15 +69,24 @@ export const QuestionCard = ({
key={option.optionId} key={option.optionId}
text={option.text} text={option.text}
state={getOptionState(option.optionId)} state={getOptionState(option.optionId)}
onSelect={() => onAnswer(option.optionId)} onSelect={() => handleSelect(option.optionId)}
/> />
))} ))}
</div> </div>
{!currentResult && selectedOptionId !== null && (
<button
onClick={handleSubmit}
className="w-full py-3 rounded-2xl text-lg font-bold bg-linear-to-r from-pink-400 to-purple-500 text-white border-b-4 border-purple-700 hover:-translate-y-0.5 active:translate-y-0 active:border-b-2 transition-all duration-200 cursor-pointer"
>
Submit
</button>
)}
{currentResult && ( {currentResult && (
<button <button
onClick={onNext} onClick={handleNext}
className="mt-2 py-3 px-10 rounded-2xl text-lg font-bold bg-purple-600 text-white border-b-4 border-purple-800 hover:bg-purple-500 hover:-translate-y-0.5 active:translate-y-0 active:border-b-2 transition-all duration-200 cursor-pointer" className="w-full py-3 rounded-2xl text-lg font-bold bg-purple-600 text-white border-b-4 border-purple-800 hover:bg-purple-500 hover:-translate-y-0.5 active:translate-y-0 active:border-b-2 transition-all duration-200 cursor-pointer"
> >
{questionNumber === totalQuestions ? "See Results" : "Next"} {questionNumber === totalQuestions ? "See Results" : "Next"}
</button> </button>

View file

@ -1,38 +1,38 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { useState, useEffect } from "react"; import { useState, useCallback } from "react";
import type { GameSession, AnswerResult } from "@glossa/shared"; import type { GameSession, GameRequest, AnswerResult } from "@glossa/shared";
import { QuestionCard } from "../components/game/QuestionCard"; import { QuestionCard } from "../components/game/QuestionCard";
import { ScoreScreen } from "../components/game/ScoreScreen"; import { ScoreScreen } from "../components/game/ScoreScreen";
import { GameSetup } from "../components/game/GameSetup";
const GAME_SETTINGS = {
source_language: "en",
target_language: "it",
pos: "noun",
difficulty: "easy",
rounds: "3",
} as const;
function Play() { function Play() {
const [gameSession, setGameSession] = useState<GameSession | null>(null); const [gameSession, setGameSession] = useState<GameSession | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [results, setResults] = useState<AnswerResult[]>([]); const [results, setResults] = useState<AnswerResult[]>([]);
const [currentResult, setCurrentResult] = useState<AnswerResult | null>(null); const [currentResult, setCurrentResult] = useState<AnswerResult | null>(null);
const startGame = async () => { const startGame = useCallback(async (settings: GameRequest) => {
setIsLoading(true);
const response = await fetch("/api/v1/game/start", { const response = await fetch("/api/v1/game/start", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(GAME_SETTINGS), body: JSON.stringify(settings),
}); });
const data = await response.json(); const data = await response.json();
setGameSession(data.data); setGameSession(data.data);
setCurrentQuestionIndex(0); setCurrentQuestionIndex(0);
setResults([]); setResults([]);
setCurrentResult(null); setCurrentResult(null);
}; setIsLoading(false);
}, []);
useEffect(() => { const resetToSetup = useCallback(() => {
startGame(); setGameSession(null);
setIsLoading(false);
setCurrentQuestionIndex(0);
setResults([]);
setCurrentResult(null);
}, []); }, []);
const handleAnswer = async (optionId: number) => { const handleAnswer = async (optionId: number) => {
@ -61,8 +61,17 @@ function Play() {
setCurrentResult(null); setCurrentResult(null);
}; };
// Phase: setup
if (!gameSession && !isLoading) {
return (
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
<GameSetup onStart={startGame} />
</div>
);
}
// Phase: loading // Phase: loading
if (!gameSession) { if (isLoading || !gameSession) {
return ( return (
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center"> <div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center">
<p className="text-purple-400 text-lg font-medium">Loading...</p> <p className="text-purple-400 text-lg font-medium">Loading...</p>
@ -74,7 +83,7 @@ function Play() {
if (currentQuestionIndex >= gameSession.questions.length) { if (currentQuestionIndex >= gameSession.questions.length) {
return ( return (
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6"> <div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
<ScoreScreen results={results} onPlayAgain={startGame} /> <ScoreScreen results={results} onPlayAgain={resetToSetup} />
</div> </div>
); );
} }