feat: guest play — allow singleplayer quiz without auth

- 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
This commit is contained in:
lila 2026-05-31 21:28:08 +02:00
parent d55a1ed648
commit 0118798e36
11 changed files with 298 additions and 32 deletions

View file

@ -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) => {
))}
</div>
<button
onClick={onPlayAgain}
className="w-full py-3 px-10 rounded-2xl text-lg font-black bg-(--color-primary) text-white shadow-sm hover:shadow-md hover:bg-(--color-primary-dark) hover:-translate-y-0.5 active:translate-y-0 transition-all duration-200 cursor-pointer"
>
Play again
</button>
{showAuthPrompt && (
<div className="w-full rounded-2xl border border-(--color-primary-light) bg-white/60 dark:bg-black/10 backdrop-blur p-6 text-center">
<p className="text-sm text-(--color-text-muted) mb-3">
Want to save your progress and compete with friends?
</p>
<a
href="/?modal=auth&redirect=/play"
className="inline-block w-full py-3 rounded-xl text-base font-bold bg-linear-to-r from-pink-400 to-purple-500 text-white shadow-sm hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 transition-all duration-200"
>
Create an account
</a>
<button
onClick={onPlayAgain}
className="mt-3 text-sm text-(--color-text-muted) hover:text-(--color-primary) transition-colors cursor-pointer"
>
Continue as guest
</button>
</div>
)}
{!showAuthPrompt && (
<button
onClick={onPlayAgain}
className="w-full py-3 px-10 rounded-2xl text-lg font-black bg-(--color-primary) text-white shadow-sm hover:shadow-md hover:bg-(--color-primary-dark) hover:-translate-y-0.5 active:translate-y-0 transition-all duration-200 cursor-pointer"
>
Play again
</button>
)}
</div>
);
};

View file

@ -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<AnswerResult[]>([]);
const [currentResult, setCurrentResult] = useState<AnswerResult | null>(null);
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(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 (
<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={resetToSetup} />
<ScoreScreen
results={results}
onPlayAgain={resetToSetup}
showAuthPrompt={isAuthenticated === false}
/>
</div>
);
}
@ -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" } });
}
},
});