feat(web): add minimal playable quiz at /play
- Add Vite proxy for /api → localhost:3000 (no CORS needed in dev) - Create /play route with hardcoded game settings (en→it, nouns, easy) - Three-phase state machine: loading → playing → finished - Show prompt, optional gloss, and 4 answer buttons per question - Submit answers to /api/v1/game/answer, show correct/wrong feedback - Manual Next button to advance after answering - Score screen on completion - Add selectedOptionId to AnswerResult schema (discovered during frontend work that the result needs to be self-contained for rendering feedback without separate client state) Intentionally unstyled — component extraction and polish come next.
This commit is contained in:
parent
075a691849
commit
ea33b7fcc8
6 changed files with 151 additions and 3 deletions
|
|
@ -94,5 +94,6 @@ export const evaluateAnswer = async (
|
||||||
questionId: submission.questionId,
|
questionId: submission.questionId,
|
||||||
isCorrect: submission.selectedOptionId === correctOptionId,
|
isCorrect: submission.selectedOptionId === correctOptionId,
|
||||||
correctOptionId,
|
correctOptionId,
|
||||||
|
selectedOptionId: submission.selectedOptionId,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,15 @@
|
||||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
|
import { Route as PlayRouteImport } from './routes/play'
|
||||||
import { Route as AboutRouteImport } from './routes/about'
|
import { Route as AboutRouteImport } from './routes/about'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
|
|
||||||
|
const PlayRoute = PlayRouteImport.update({
|
||||||
|
id: '/play',
|
||||||
|
path: '/play',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const AboutRoute = AboutRouteImport.update({
|
const AboutRoute = AboutRouteImport.update({
|
||||||
id: '/about',
|
id: '/about',
|
||||||
path: '/about',
|
path: '/about',
|
||||||
|
|
@ -26,31 +32,42 @@ const IndexRoute = IndexRouteImport.update({
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/about': typeof AboutRoute
|
'/about': typeof AboutRoute
|
||||||
|
'/play': typeof PlayRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/about': typeof AboutRoute
|
'/about': typeof AboutRoute
|
||||||
|
'/play': typeof PlayRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/about': typeof AboutRoute
|
'/about': typeof AboutRoute
|
||||||
|
'/play': typeof PlayRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths: '/' | '/about'
|
fullPaths: '/' | '/about' | '/play'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/' | '/about'
|
to: '/' | '/about' | '/play'
|
||||||
id: '__root__' | '/' | '/about'
|
id: '__root__' | '/' | '/about' | '/play'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
AboutRoute: typeof AboutRoute
|
AboutRoute: typeof AboutRoute
|
||||||
|
PlayRoute: typeof PlayRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface FileRoutesByPath {
|
interface FileRoutesByPath {
|
||||||
|
'/play': {
|
||||||
|
id: '/play'
|
||||||
|
path: '/play'
|
||||||
|
fullPath: '/play'
|
||||||
|
preLoaderRoute: typeof PlayRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/about': {
|
'/about': {
|
||||||
id: '/about'
|
id: '/about'
|
||||||
path: '/about'
|
path: '/about'
|
||||||
|
|
@ -71,6 +88,7 @@ declare module '@tanstack/react-router' {
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
AboutRoute: AboutRoute,
|
AboutRoute: AboutRoute,
|
||||||
|
PlayRoute: PlayRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
._addFileChildren(rootRouteChildren)
|
._addFileChildren(rootRouteChildren)
|
||||||
|
|
|
||||||
123
apps/web/src/routes/play.tsx
Normal file
123
apps/web/src/routes/play.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import type { GameSession, AnswerResult } from "@glossa/shared";
|
||||||
|
|
||||||
|
const GAME_SETTINGS = {
|
||||||
|
source_language: "en",
|
||||||
|
target_language: "it",
|
||||||
|
pos: "noun",
|
||||||
|
difficulty: "easy",
|
||||||
|
rounds: "3",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function Play() {
|
||||||
|
const [gameSession, setGameSession] = useState<GameSession | null>(null);
|
||||||
|
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||||
|
const [results, setResults] = useState<AnswerResult[]>([]);
|
||||||
|
const [currentResult, setCurrentResult] = useState<AnswerResult | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const startGame = async () => {
|
||||||
|
const response = await fetch("/api/v1/game/start", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(GAME_SETTINGS),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
setGameSession(data.data);
|
||||||
|
};
|
||||||
|
startGame();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAnswer = async (optionId: number) => {
|
||||||
|
if (!gameSession || currentResult) return;
|
||||||
|
|
||||||
|
const question = gameSession.questions[currentQuestionIndex];
|
||||||
|
if (!question) return;
|
||||||
|
|
||||||
|
const response = await fetch("/api/v1/game/answer", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
sessionId: gameSession.sessionId,
|
||||||
|
questionId: question.questionId,
|
||||||
|
selectedOptionId: optionId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
setCurrentResult(data.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (!currentResult) return;
|
||||||
|
setResults((prev) => [...prev, currentResult]);
|
||||||
|
setCurrentQuestionIndex((prev) => prev + 1);
|
||||||
|
setCurrentResult(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Phase: loading
|
||||||
|
if (!gameSession) {
|
||||||
|
return <p>Loading...</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase: finished
|
||||||
|
if (currentQuestionIndex >= gameSession.questions.length) {
|
||||||
|
const score = results.filter((r) => r.isCorrect).length;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>
|
||||||
|
{score} / {results.length}
|
||||||
|
</h2>
|
||||||
|
<p>Game over!</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase: playing
|
||||||
|
const question = gameSession.questions[currentQuestionIndex]!;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
Question {currentQuestionIndex + 1} / {gameSession.questions.length}
|
||||||
|
</p>
|
||||||
|
<h2>{question.prompt}</h2>
|
||||||
|
{question.gloss && <p>{question.gloss}</p>}
|
||||||
|
<div>
|
||||||
|
{question.options.map((option) => {
|
||||||
|
let style = {};
|
||||||
|
if (currentResult) {
|
||||||
|
if (option.optionId === currentResult.correctOptionId) {
|
||||||
|
style = { backgroundColor: "green", color: "white" };
|
||||||
|
} else if (option.optionId === currentResult.selectedOptionId) {
|
||||||
|
style = { backgroundColor: "red", color: "white" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.optionId}
|
||||||
|
onClick={() => handleAnswer(option.optionId)}
|
||||||
|
disabled={!!currentResult}
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
margin: "4px 0",
|
||||||
|
padding: "8px 16px",
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.text}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{currentResult && (
|
||||||
|
<button onClick={handleNext} style={{ marginTop: "16px" }}>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/play")({ component: Play });
|
||||||
|
|
@ -10,4 +10,5 @@ export default defineConfig({
|
||||||
react(),
|
react(),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
],
|
],
|
||||||
|
server: { proxy: { "/api": "http://localhost:3000" } },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -298,3 +298,7 @@ Required before: implementing the double join for source language prompt.
|
||||||
`buildSession()`.
|
`buildSession()`.
|
||||||
- **Session statefulness:** game loop is currently stateless (fetch all questions upfront).
|
- **Session statefulness:** game loop is currently stateless (fetch all questions upfront).
|
||||||
Confirm this is still the intended MVP approach before building `buildSession()`.
|
Confirm this is still the intended MVP approach before building `buildSession()`.
|
||||||
|
- **Glosses can leak answers:** some WordNet glosses contain the target-language
|
||||||
|
word in the definition text (e.g. "Padre" appearing in the English gloss for
|
||||||
|
"father"). Address during the post-MVP data enrichment pass — either clean the
|
||||||
|
glosses, replace them with custom definitions, or filter at the service layer.
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ export const AnswerResultSchema = z.object({
|
||||||
questionId: z.uuid(),
|
questionId: z.uuid(),
|
||||||
isCorrect: z.boolean(),
|
isCorrect: z.boolean(),
|
||||||
correctOptionId: z.number().int().min(0).max(3),
|
correctOptionId: z.number().int().min(0).max(3),
|
||||||
|
selectedOptionId: z.number().int().min(0).max(3),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AnswerResult = z.infer<typeof AnswerResultSchema>;
|
export type AnswerResult = z.infer<typeof AnswerResultSchema>;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue