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:
parent
b7b1cd383f
commit
bc7977463e
4 changed files with 209 additions and 26 deletions
140
apps/web/src/components/game/GameSetup.tsx
Normal file
140
apps/web/src/components/game/GameSetup.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue