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:
parent
d55a1ed648
commit
0118798e36
11 changed files with 298 additions and 32 deletions
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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" } });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue