From 37f6a55798a2ab4f9278efc243b481d3b38d903e Mon Sep 17 00:00:00 2001 From: lila Date: Sat, 30 May 2026 03:47:52 +0200 Subject: [PATCH 1/3] updating docs --- ...-task.md => 99-current-task-blueprint.md} | 24 +++--- documentation/ai-context/99-current-task.md | 78 +++++++++++++++++++ 2 files changed, 93 insertions(+), 9 deletions(-) rename documentation/ai-context/{99-current-task.md => 99-current-task-blueprint.md} (72%) create mode 100644 documentation/ai-context/99-current-task.md diff --git a/documentation/ai-context/99-current-task.md b/documentation/ai-context/99-current-task-blueprint.md similarity index 72% rename from documentation/ai-context/99-current-task.md rename to documentation/ai-context/99-current-task-blueprint.md index cd28365..9bc1325 100644 --- a/documentation/ai-context/99-current-task.md +++ b/documentation/ai-context/99-current-task-blueprint.md @@ -34,6 +34,7 @@ Example: "Implement guest play flow so users can try a 3-round quiz without crea [List files you've identified. The LLM may ask for additional ones.] Example: + - `apps/api/src/controllers/gameController.ts` — needs guest variant - `apps/api/src/middleware/authMiddleware.ts` — needs optional auth path - `packages/shared/src/schemas/game.ts` — needs GuestGameRequestSchema @@ -43,20 +44,24 @@ Example: ## Constraints & Requirements **Must have:** + - [ ] - [ ] **Nice to have:** + - [ ] - [ ] **Must NOT break:** + - [ ] Existing auth flow (logged-in users still work normally) - [ ] WebSocket protocol (if applicable) - [ ] Database schema (additive changes only unless migration planned) - [ ] Zod schemas in `packages/shared` (no silent drift) **Known blockers or open questions:** + - [ ] --- @@ -80,17 +85,18 @@ After the task is complete, ask the LLM: The LLM should check: -| File | Check if... | -|------|-------------| -| `documentation/STATUS.md` | Task changes what's working or what's blocked | -| `documentation/BACKLOG.md` | Task completes a backlog item or creates a new one | -| `documentation/DECISIONS.md` | Task involved choosing between alternatives with long-term consequences | -| `documentation/ARCHITECTURE.md` | Task changes monorepo structure, data flow, or layer boundaries | -| `documentation/ai-context/*.md` | Task changes schemas, endpoints, protocol, or pipeline stages | -| `packages/shared/src/schemas/*.ts` | Task changes request/response shapes or WS message types | -| `README.md` | Task changes quickstart steps, stack, or current status | +| File | Check if... | +| ---------------------------------- | ----------------------------------------------------------------------- | +| `documentation/STATUS.md` | Task changes what's working or what's blocked | +| `documentation/BACKLOG.md` | Task completes a backlog item or creates a new one | +| `documentation/DECISIONS.md` | Task involved choosing between alternatives with long-term consequences | +| `documentation/ARCHITECTURE.md` | Task changes monorepo structure, data flow, or layer boundaries | +| `documentation/ai-context/*.md` | Task changes schemas, endpoints, protocol, or pipeline stages | +| `packages/shared/src/schemas/*.ts` | Task changes request/response shapes or WS message types | +| `README.md` | Task changes quickstart steps, stack, or current status | **Expected output format:** + ``` - FILE: [filename] — REASON: [what changed and why the doc needs updating] ``` diff --git a/documentation/ai-context/99-current-task.md b/documentation/ai-context/99-current-task.md new file mode 100644 index 0000000..24a16eb --- /dev/null +++ b/documentation/ai-context/99-current-task.md @@ -0,0 +1,78 @@ +# 99 — Current Task + +## Task Description + +Implement guest play flow so users can try a 3-round singleplayer quiz without creating an account. After completing the quiz, optionally prompt them to sign up to save progress. + +## Context + +**Which parts of the codebase does this touch?** + +- [x] Frontend (`apps/web/`) +- [x] Backend API (`apps/api/`) +- [ ] Database schema (`packages/db/`) — no schema changes needed +- [x] Shared schemas (`packages/shared/`) — may need GuestGameRequestSchema +- [ ] WebSocket protocol (`apps/api/src/ws/`) — not touched (guest play is singleplayer only) +- [ ] Data pipeline (`data-pipeline/`) +- [ ] Infrastructure / deployment (`docker-compose.yml`, Caddyfile, etc.) +- [x] Documentation + +**Relevant files I already know about:** + +- `apps/api/src/middleware/authMiddleware.ts` — needs optional auth path +- `apps/api/src/controllers/gameController.ts` — needs guest variant of start/answer +- `apps/api/src/services/gameService.ts` — may need guest session logic +- `apps/api/src/routes/gameRouter.ts` — route definitions +- `apps/web/src/routes/play.tsx` — singleplayer route +- `apps/web/src/components/game/GameSetup.tsx` — start quiz UI +- `packages/shared/src/schemas/game.ts` — request/response schemas +- `apps/web/src/components/auth/AuthModal.tsx` — post-game auth prompt + +## Constraints & Requirements + +**Must have:** + +- [ ] Guest users can start and complete a singleplayer quiz (3 or 10 rounds) +- [ ] No login required to reach `/play` or call `POST /api/v1/game/start` +- [ ] Server-side answer evaluation still works (correct answer never sent to frontend) +- [ ] Guest sessions are ephemeral (no database storage of guest progress) +- [ ] After quiz completion, show a friendly "Save your progress?" prompt with auth options + +**Nice to have:** + +- [ ] Guest sessions stored in-memory with a TTL (e.g., 24h) so refreshing the page doesn't lose the current quiz +- [ ] Post-game prompt includes a "Continue as guest" option to play again without signing up + +**Must NOT break:** + +- [x] Existing auth flow (logged-in users still work normally) +- [x] WebSocket protocol (if applicable) +- [x] Database schema (additive changes only unless migration planned) +- [x] Zod schemas in `packages/shared` (no silent drift) + +**Known blockers or open questions:** + +- [ ] Should guest sessions use the same `GameSessionStore` interface with a guest flag, or a separate store? +- [ ] Should the post-game auth prompt be a modal or a redirect to a dedicated page? + +## Definition of Done + +- [ ] Code implemented and tested +- [ ] No TypeScript errors (`pnpm typecheck` passes) +- [ ] Tests pass (`pnpm test`) +- [ ] Manual verification in dev environment (both logged-in and guest flows) +- [ ] Commit message follows convention +- [ ] Feature branch merged to main + +## Post-Work Checklist + +After the task is complete, ask the LLM: + +> "Review the post-work checklist in prompts/meta.md. Which documentation files need updates based on what we just changed?" + +Expected doc updates: + +- `documentation/STATUS.md` — Guest play is now live +- `documentation/ai-context/03-api-contract.md` — New guest endpoint or schema changes +- `packages/shared/src/schemas/game.ts` — If GuestGameRequestSchema added +- `README.md` — Quickstart may mention guest play From d55a1ed6486875eeaa9dbaa2ec8357c1f792a278 Mon Sep 17 00:00:00 2001 From: lila Date: Sat, 30 May 2026 03:47:59 +0200 Subject: [PATCH 2/3] wip --- .../src/gameSessionStore/GameSessionStore.ts | 2 +- .../api/src/middleware/authMiddleware.test.ts | 103 ++++++++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/middleware/authMiddleware.test.ts diff --git a/apps/api/src/gameSessionStore/GameSessionStore.ts b/apps/api/src/gameSessionStore/GameSessionStore.ts index 3e6c5d2..271d733 100644 --- a/apps/api/src/gameSessionStore/GameSessionStore.ts +++ b/apps/api/src/gameSessionStore/GameSessionStore.ts @@ -1,6 +1,6 @@ export type GameSessionData = { answers: Map; - userId: string; + userId: string | null; }; export interface GameSessionStore { diff --git a/apps/api/src/middleware/authMiddleware.test.ts b/apps/api/src/middleware/authMiddleware.test.ts new file mode 100644 index 0000000..db79ea2 --- /dev/null +++ b/apps/api/src/middleware/authMiddleware.test.ts @@ -0,0 +1,103 @@ +import express from "express"; +import request from "supertest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Session, User } from "better-auth"; + +vi.mock("../lib/auth.js", () => ({ auth: { api: { getSession: vi.fn() } } })); + +vi.mock("better-auth/node", () => ({ + fromNodeHeaders: vi.fn().mockReturnValue({}), +})); + +import { auth } from "../lib/auth.js"; +import { requireAuth, optionalAuth } from "./authMiddleware.js"; + +const mockGetSession = vi.mocked(auth.api.getSession); + +function createOptionalAuthApp() { + const app = express(); + app.use(optionalAuth); + app.get("/test", (req, res) => { + res + .status(200) + .json({ + hasSession: !!req.session, + userId: req.session?.user?.id ?? null, + }); + }); + return app; +} + +describe("optionalAuth", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("allows the request through when no session exists (guest)", async () => { + mockGetSession.mockResolvedValue(null); + + const app = createOptionalAuthApp(); + const res = await request(app).get("/test"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ hasSession: false, userId: null }); + }); + + it("attaches session to req when user is authenticated", async () => { + mockGetSession.mockResolvedValue({ + session: { id: "session-1" } as Session, + user: { id: "user-1" } as User, + }); + + const app = createOptionalAuthApp(); + const res = await request(app).get("/test"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ hasSession: true, userId: "user-1" }); + }); + + it("allows the request through even when getSession throws", async () => { + mockGetSession.mockRejectedValue(new Error("auth service down")); + + const app = createOptionalAuthApp(); + const res = await request(app).get("/test"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ hasSession: false, userId: null }); + }); +}); + +describe("requireAuth", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 401 when no session exists", async () => { + mockGetSession.mockResolvedValue(null); + + const app = express(); + app.use(requireAuth); + app.get("/test", (_req, res) => res.status(200).json({ ok: true })); + + const res = await request(app).get("/test"); + expect(res.status).toBe(401); + expect(res.body).toEqual({ success: false, error: "Unauthorized" }); + }); + + it("allows the request through when session exists", async () => { + mockGetSession.mockResolvedValue({ + session: { id: "session-1" } as Session, + user: { id: "user-1" } as User, + }); + + const app = express(); + app.use(requireAuth); + app.get("/test", (req, res) => { + res.status(200).json({ userId: req.session?.user?.id }); + }); + + const res = await request(app).get("/test"); + expect(res.status).toBe(200); + expect(res.body).toEqual({ userId: "user-1" }); + }); +}); From 0118798e36d101066f09769ccaa50fc59fdb3aca Mon Sep 17 00:00:00 2001 From: lila Date: Sun, 31 May 2026 21:28:08 +0200 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20guest=20play=20=E2=80=94=20allow=20?= =?UTF-8?q?singleplayer=20quiz=20without=20auth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add optionalAuth middleware: attaches session when present, never blocks (guests pass through) - Make game endpoints (start/answer) accept optional auth - GameSessionStore.userId: string → string | null - Rate limiter falls back to IP for unauthenticated users - Frontend: remove /play route guard, show 'Create account' CTA on score screen for guests - Add tests for guest session creation, answer submission, and cross-user session isolation --- .gitignore | 1 + .../src/controllers/gameController.test.ts | 116 ++++++++++++++++++ apps/api/src/controllers/gameController.ts | 10 +- apps/api/src/middleware/authMiddleware.ts | 20 +++ apps/api/src/middleware/rateLimiters.ts | 8 +- apps/api/src/routes/gameRouter.ts | 6 +- apps/api/src/services/gameService.test.ts | 86 +++++++++++++ apps/api/src/services/gameService.ts | 10 +- apps/api/src/types/express.d.ts | 4 + apps/web/src/components/game/ScoreScreen.tsx | 46 +++++-- apps/web/src/routes/play.tsx | 23 ++-- 11 files changed, 298 insertions(+), 32 deletions(-) diff --git a/.gitignore b/.gitignore index 07fc409..f8dbdb9 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ data-pipeline/stage-4-merge/output/ data-pipeline/db/pipeline.db data-pipeline/reports/ data-pipeline/.env +.aider* diff --git a/apps/api/src/controllers/gameController.test.ts b/apps/api/src/controllers/gameController.test.ts index d45298a..83b5672 100644 --- a/apps/api/src/controllers/gameController.test.ts +++ b/apps/api/src/controllers/gameController.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import request from "supertest"; import type { GameSession, AnswerResult } from "@lila/shared"; +import { auth } from "../lib/auth.js"; type SuccessResponse = { success: true; data: T }; type ErrorResponse = { success: false; error: string }; @@ -48,6 +49,36 @@ vi.mock("better-auth/node", () => ({ toNodeHandler: vi.fn().mockReturnValue(vi.fn()), })); +const mockGetSession = vi.mocked(auth.api.getSession); + +function setAuthenticatedUser(userId: string) { + mockGetSession.mockResolvedValue({ + session: { + id: "session-1", + userId, + token: "fake-token", + expiresAt: new Date(Date.now() + 1000 * 60 * 60), + createdAt: new Date(), + updatedAt: new Date(), + ipAddress: null, + userAgent: null, + }, + user: { + id: userId, + name: "Test User", + email: "test@test.com", + emailVerified: false, + image: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); +} + +function setGuestUser() { + mockGetSession.mockResolvedValue(null); +} + import { getGameTerms, getDistractors } from "@lila/db"; import { createApp } from "../app.js"; @@ -76,6 +107,7 @@ const fakeTerms = [ beforeEach(() => { vi.clearAllMocks(); + setAuthenticatedUser("user-1"); // default: authenticated mockGetGameTerms.mockResolvedValue(fakeTerms); mockGetDistractors.mockResolvedValue(["wrong1", "wrong2", "wrong3"]); }); @@ -221,3 +253,87 @@ describe("POST /api/v1/game/answer", () => { expect(body.success).toBe(false); }); }); + +describe("POST /api/v1/game/start — guest", () => { + beforeEach(() => { + setGuestUser(); + }); + + it("returns 200 for a guest with valid game settings", async () => { + const res = await request(app).post("/api/v1/game/start").send(validBody); + const body = res.body as GameStartResponse; + expect(res.status).toBe(200); + expect(body.success).toBe(true); + expect(body.data.sessionId).toBeDefined(); + expect(body.data.questions).toHaveLength(3); + }); + + it("creates a session without userId for guests", async () => { + const startRes = await request(app) + .post("/api/v1/game/start") + .send(validBody); + const startBody = startRes.body as GameStartResponse; + const { sessionId, questions } = startBody.data; + + // Guest can answer — no 404 from userId mismatch + const res = await request(app) + .post("/api/v1/game/answer") + .send({ + sessionId, + questionId: questions[0]!.questionId, + selectedOptionId: 0, + }); + expect(res.status).toBe(200); + }); +}); + +describe("POST /api/v1/game/answer — guest", () => { + beforeEach(() => { + setGuestUser(); + }); + + it("allows a guest to submit an answer", async () => { + const startRes = await request(app) + .post("/api/v1/game/start") + .send(validBody); + const startBody = startRes.body as GameStartResponse; + const { sessionId, questions } = startBody.data; + const question = questions[0]!; + + const res = await request(app) + .post("/api/v1/game/answer") + .send({ + sessionId, + questionId: question.questionId, + selectedOptionId: 0, + }); + const body = res.body as GameAnswerResponse; + expect(res.status).toBe(200); + expect(body.success).toBe(true); + expect(body.data.questionId).toBe(question.questionId); + }); + + it("returns 404 when a guest tries to answer an authenticated user's session", async () => { + // First: create session as authenticated user + setAuthenticatedUser("user-1"); + const startRes = await request(app) + .post("/api/v1/game/start") + .send(validBody); + const startBody = startRes.body as GameStartResponse; + const { sessionId, questions } = startBody.data; + + // Then: try to answer as guest + setGuestUser(); + const res = await request(app) + .post("/api/v1/game/answer") + .send({ + sessionId, + questionId: questions[0]!.questionId, + selectedOptionId: 0, + }); + const body = res.body as ErrorResponse; + expect(res.status).toBe(404); + expect(body.success).toBe(false); + expect(body.error).toContain("Game session not found"); + }); +}); diff --git a/apps/api/src/controllers/gameController.ts b/apps/api/src/controllers/gameController.ts index 2a0416e..ea68cce 100644 --- a/apps/api/src/controllers/gameController.ts +++ b/apps/api/src/controllers/gameController.ts @@ -16,10 +16,11 @@ export const createGameController = (store: GameSessionStore) => ({ if (!gameSettings.success) { throw new ValidationError("Invalid game settings"); } + const userId = req.session?.user.id ?? null; const gameQuestions = await createGameSession( gameSettings.data, store, - req.session.user.id, + userId, ); res.json({ success: true, data: gameQuestions }); } catch (error) { @@ -37,11 +38,8 @@ export const createGameController = (store: GameSessionStore) => ({ if (!submission.success) { throw new ValidationError("Invalid answer submission"); } - const result = await evaluateAnswer( - submission.data, - store, - req.session.user.id, - ); + const userId = req.session?.user.id ?? null; + const result = await evaluateAnswer(submission.data, store, userId); res.json({ success: true, data: result }); } catch (error) { next(error); diff --git a/apps/api/src/middleware/authMiddleware.ts b/apps/api/src/middleware/authMiddleware.ts index 744c5e4..7a161f1 100644 --- a/apps/api/src/middleware/authMiddleware.ts +++ b/apps/api/src/middleware/authMiddleware.ts @@ -20,3 +20,23 @@ export const requireAuth = async ( next(); }; + +export const optionalAuth = async ( + req: Request, + _res: Response, + next: NextFunction, +) => { + try { + const session = await auth.api.getSession({ + headers: fromNodeHeaders(req.headers), + }); + + if (session) { + req.session = session; + } + } catch (err) { + console.warn("Auth check failed, continuing as guest:", err); + } + + next(); +}; diff --git a/apps/api/src/middleware/rateLimiters.ts b/apps/api/src/middleware/rateLimiters.ts index 2f2eaf6..4dfcc79 100644 --- a/apps/api/src/middleware/rateLimiters.ts +++ b/apps/api/src/middleware/rateLimiters.ts @@ -1,4 +1,4 @@ -import rateLimit from "express-rate-limit"; +import rateLimit, { ipKeyGenerator } from "express-rate-limit"; import type { Request } from "express"; // TODO: When Valkey is wired up, swap the default in-memory store for @@ -33,7 +33,8 @@ export const gameLimiter = rateLimit({ limit: 150, standardHeaders: "draft-8", legacyHeaders: false, - keyGenerator: (req: Request) => req.session!.user.id, + keyGenerator: (req: Request) => + req.session?.user.id ?? ipKeyGenerator(req.ip ?? "unknown"), message: { success: false, error: "Too many requests, please try again later.", @@ -45,7 +46,8 @@ export const lobbyLimiter = rateLimit({ limit: 20, standardHeaders: "draft-8", legacyHeaders: false, - keyGenerator: (req: Request) => req.session!.user.id, + keyGenerator: (req: Request) => + req.session?.user.id ?? ipKeyGenerator(req.ip ?? "unknown"), message: { success: false, error: "Too many requests, please try again later.", diff --git a/apps/api/src/routes/gameRouter.ts b/apps/api/src/routes/gameRouter.ts index 9e29a5d..9bc3e53 100644 --- a/apps/api/src/routes/gameRouter.ts +++ b/apps/api/src/routes/gameRouter.ts @@ -1,7 +1,7 @@ import express from "express"; import type { Router } from "express"; import { createGameController } from "../controllers/gameController.js"; -import { requireAuth } from "../middleware/authMiddleware.js"; +import { optionalAuth } from "../middleware/authMiddleware.js"; import { gameLimiter } from "../middleware/rateLimiters.js"; import type { GameSessionStore } from "../gameSessionStore/index.js"; @@ -9,11 +9,11 @@ export const createGameRouter = (store: GameSessionStore): Router => { const router = express.Router(); const controller = createGameController(store); - router.use(requireAuth); + router.use(optionalAuth); router.use(gameLimiter); router.post("/start", controller.createGame as express.RequestHandler); router.post("/answer", controller.submitAnswer as express.RequestHandler); - + return router; }; diff --git a/apps/api/src/services/gameService.test.ts b/apps/api/src/services/gameService.test.ts index 160d816..97bf075 100644 --- a/apps/api/src/services/gameService.test.ts +++ b/apps/api/src/services/gameService.test.ts @@ -332,3 +332,89 @@ describe("evaluateAnswer", () => { ).rejects.toMatchObject({ statusCode: 422 }); }); }); + +// Add to existing gameService.test.ts + +describe("createGameSession — guest", () => { + let store: InMemoryGameSessionStore; + + beforeEach(() => { + store = new InMemoryGameSessionStore(); + }); + + it("creates a session with userId null for guests", async () => { + const session = await createGameSession(validRequest, store, null); + + expect(session.sessionId).toBeDefined(); + expect(session.questions).toHaveLength(3); + }); + + it("stores userId as null in the session store", async () => { + const session = await createGameSession(validRequest, store, null); + const stored = await store.get(session.sessionId); + + expect(stored).not.toBeNull(); + expect(stored!.userId).toBeNull(); + }); +}); + +describe("evaluateAnswer — guest", () => { + let store: InMemoryGameSessionStore; + + beforeEach(() => { + store = new InMemoryGameSessionStore(); + }); + + it("allows a guest to answer their own session", async () => { + const session = await createGameSession(validRequest, store, null); + const question = session.questions[0]!; + const correctText = fakeTerms[0]!.targetText; + const correctOption = question.options.find((o) => o.text === correctText)!; + + const result = await evaluateAnswer( + { + sessionId: session.sessionId, + questionId: question.questionId, + selectedOptionId: correctOption.optionId, + }, + store, + null, + ); + + expect(result.isCorrect).toBe(true); + }); + + it("throws NotFoundError when guest tries to answer an authenticated session", async () => { + const authSession = await createGameSession(validRequest, store, "user-1"); + const question = authSession.questions[0]!; + + await expect( + evaluateAnswer( + { + sessionId: authSession.sessionId, + questionId: question.questionId, + selectedOptionId: 0, + }, + store, + null, + ), + ).rejects.toThrow("Game session not found"); + }); + + it("throws NotFoundError when authenticated user tries to answer a guest session", async () => { + const guestSession = await createGameSession(validRequest, store, null); + const question = guestSession.questions[0]!; + + await expect( + evaluateAnswer( + { + sessionId: guestSession.sessionId, + questionId: question.questionId, + selectedOptionId: 0, + }, + store, + "user-1", + ), + ).rejects.toThrow("Game session not found"); + }); +}); diff --git a/apps/api/src/services/gameService.ts b/apps/api/src/services/gameService.ts index a31014a..550fde2 100644 --- a/apps/api/src/services/gameService.ts +++ b/apps/api/src/services/gameService.ts @@ -19,7 +19,7 @@ import { shuffleArray } from "../lib/utils.js"; export const createGameSession = async ( request: GameRequest, store: GameSessionStore, - userId: string, + userId: string | null, ): Promise => { const terms = await getGameTerms( request.source_language, @@ -87,11 +87,15 @@ export const createGameSession = async ( export const evaluateAnswer = async ( submission: AnswerSubmission, store: GameSessionStore, - userId: string, + userId: string | null, ): Promise => { const session = await store.get(submission.sessionId); - if (!session || session.userId !== userId) { + if (!session) { + throw new NotFoundError(`Game session not found: ${submission.sessionId}`); + } + + if (session.userId !== userId) { throw new NotFoundError(`Game session not found: ${submission.sessionId}`); } diff --git a/apps/api/src/types/express.d.ts b/apps/api/src/types/express.d.ts index da7633a..a310394 100644 --- a/apps/api/src/types/express.d.ts +++ b/apps/api/src/types/express.d.ts @@ -18,3 +18,7 @@ declare module "ws" { export type AuthenticatedRequest = Request & { session: { session: Session; user: User }; }; + +export type GuestRequest = Request & { + session?: { session: Session; user: User }; +}; diff --git a/apps/web/src/components/game/ScoreScreen.tsx b/apps/web/src/components/game/ScoreScreen.tsx index ac64da2..5206209 100644 --- a/apps/web/src/components/game/ScoreScreen.tsx +++ b/apps/web/src/components/game/ScoreScreen.tsx @@ -1,9 +1,17 @@ import type { AnswerResult } from "@lila/shared"; import { ConfettiBurst } from "../ui/ConfettiBurst"; -type ScoreScreenProps = { results: AnswerResult[]; onPlayAgain: () => void }; +type ScoreScreenProps = { + results: AnswerResult[]; + onPlayAgain: () => void; + showAuthPrompt?: boolean; +}; -export const ScoreScreen = ({ results, onPlayAgain }: ScoreScreenProps) => { +export const ScoreScreen = ({ + results, + onPlayAgain, + showAuthPrompt = false, +}: ScoreScreenProps) => { const score = results.filter((r) => r.isCorrect).length; const total = results.length; const percentage = Math.round((score / total) * 100); @@ -58,12 +66,34 @@ export const ScoreScreen = ({ results, onPlayAgain }: ScoreScreenProps) => { ))} - + {showAuthPrompt && ( +
+

+ Want to save your progress and compete with friends? +

+ + Create an account + + +
+ )} + + {!showAuthPrompt && ( + + )} ); }; diff --git a/apps/web/src/routes/play.tsx b/apps/web/src/routes/play.tsx index bc4cde3..f2c85ee 100644 --- a/apps/web/src/routes/play.tsx +++ b/apps/web/src/routes/play.tsx @@ -1,5 +1,5 @@ -import { createFileRoute, redirect } from "@tanstack/react-router"; -import { useState, useCallback } from "react"; +import { createFileRoute } from "@tanstack/react-router"; +import { useState, useCallback, useEffect } from "react"; import type { GameSession, GameRequest, AnswerResult } from "@lila/shared"; import { QuestionCard } from "../components/game/QuestionCard"; import { ScoreScreen } from "../components/game/ScoreScreen"; @@ -18,6 +18,13 @@ function Play() { const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); const [results, setResults] = useState([]); const [currentResult, setCurrentResult] = useState(null); + const [isAuthenticated, setIsAuthenticated] = useState(null); + + useEffect(() => { + void authClient.getSession().then(({ data }) => { + setIsAuthenticated(!!data); + }); + }, []); const startGame = useCallback(async (settings: GameRequest) => { setIsLoading(true); @@ -100,7 +107,11 @@ function Play() { if (currentQuestionIndex >= gameSession.questions.length) { return (
- +
); } @@ -129,10 +140,4 @@ function Play() { export const Route = createFileRoute("/play")({ component: Play, errorComponent: RouteError, - beforeLoad: async () => { - const { data: session } = await authClient.getSession(); - if (!session) { - throw redirect({ to: "/", search: { modal: "auth", redirect: "/play" } }); - } - }, });