From 7bddc5e29c9d103effd2a9fe002b396c2fd1bf23 Mon Sep 17 00:00:00 2001
From: Lesia Soloviova
Date: Sun, 8 Feb 2026 18:55:38 +0200
Subject: [PATCH 1/3] feat(quiz): add guest warning before start and bot
protection
Guest warning: show login/signup/continue buttons for unauthenticated
users on quiz rules screen before starting.
Bot protection: multi-attempt verification via Redis - each question
can only be verified once per user per attempt. Keys use dynamic TTL
matching quiz time limit and are cleared on retake.
Additional fixes:
- Footer flash on quiz navigation (added loading.tsx, eliminated redirect)
- Renamed QaLoader to Loader for reuse across pages
- React compiler purity errors (crypto.getRandomValues in handlers)
- Start button disabled after retake (isStarting not reset)
---
frontend/actions/quiz.ts | 52 +++++++--------
frontend/app/[locale]/q&a/page.tsx | 4 +-
frontend/app/[locale]/quiz/[slug]/loading.tsx | 9 +++
frontend/app/[locale]/quiz/[slug]/page.tsx | 16 ++---
frontend/app/api/quiz/verify-answer/route.ts | 48 +++++++++++++-
frontend/components/q&a/QaSection.tsx | 4 +-
frontend/components/quiz/QuizCard.tsx | 22 +++++--
frontend/components/quiz/QuizContainer.tsx | 65 +++++++++++++++----
.../shared/{QaLoader.tsx => Loader.tsx} | 4 +-
frontend/lib/quiz/quiz-answers-redis.ts | 55 ++++++++++++++++
frontend/messages/en.json | 10 ++-
frontend/messages/pl.json | 10 ++-
frontend/messages/uk.json | 10 ++-
13 files changed, 228 insertions(+), 81 deletions(-)
create mode 100644 frontend/app/[locale]/quiz/[slug]/loading.tsx
rename frontend/components/shared/{QaLoader.tsx => Loader.tsx} (97%)
diff --git a/frontend/actions/quiz.ts b/frontend/actions/quiz.ts
index 58ef6432..4f5b2097 100644
--- a/frontend/actions/quiz.ts
+++ b/frontend/actions/quiz.ts
@@ -49,19 +49,6 @@ function calculateIntegrityScore(violations: ViolationEvent[]): number {
return Math.max(0, 100 - penalty);
}
-function validateTimeSpent(
- startedAt: Date,
- completedAt: Date,
- questionCount: number
-): boolean {
- const MIN_SECONDS_PER_QUESTION = 1;
- const timeSpentSeconds = Math.floor(
- (completedAt.getTime() - startedAt.getTime()) / 1000
- );
- const minRequiredTime = questionCount * MIN_SECONDS_PER_QUESTION;
-
- return timeSpentSeconds >= minRequiredTime;
-}
async function getQuizQuestionIds(quizId: string): Promise {
const rows = await db
@@ -176,18 +163,6 @@ export async function submitQuizAttempt(
return { success: false, error: 'Invalid time values' };
}
- const isValidTime = validateTimeSpent(
- startedAtDate,
- completedAtDate,
- questionIds.length
- );
- if (!isValidTime) {
- return {
- success: false,
- error: 'Invalid time spent: quiz completed too quickly',
- };
- }
-
const percentage = (
(correctAnswersCount / questionIds.length) *
100
@@ -260,8 +235,33 @@ export async function initializeQuizCache(
quizId: string
): Promise<{ success: boolean; error?: string }> {
try {
- const { getOrCreateQuizAnswersCache } =
+ const { getOrCreateQuizAnswersCache, clearVerifiedQuestions } =
await import('@/lib/quiz/quiz-answers-redis');
+
+ // Resolve identifier (same logic as verify-answer route)
+ const { headers } = await import('next/headers');
+ const { verifyAuthToken } = await import('@/lib/auth');
+ const headersList = await headers();
+ let identifier: string;
+
+ const cookieHeader = headersList.get('cookie') ?? '';
+ const authCookie = cookieHeader
+ .split(';')
+ .find(c => c.trim().startsWith('auth_session='));
+
+ if (authCookie) {
+ const token = authCookie.split('=').slice(1).join('=').trim();
+ const payload = verifyAuthToken(token);
+ identifier = payload?.userId ?? 'unknown';
+ } else {
+ identifier =
+ headersList.get('x-forwarded-for')?.split(',')[0]?.trim() ??
+ headersList.get('x-real-ip') ??
+ 'unknown';
+ }
+
+ await clearVerifiedQuestions(quizId, identifier);
+
const success = await getOrCreateQuizAnswersCache(quizId);
if (!success) {
diff --git a/frontend/app/[locale]/q&a/page.tsx b/frontend/app/[locale]/q&a/page.tsx
index cd67a173..30a52030 100644
--- a/frontend/app/[locale]/q&a/page.tsx
+++ b/frontend/app/[locale]/q&a/page.tsx
@@ -2,8 +2,8 @@ import { getTranslations } from 'next-intl/server';
import { Suspense } from 'react';
import QaSection from '@/components/q&a/QaSection';
-import { QaLoader } from '@/components/shared/QaLoader';
import { DynamicGridBackground } from '@/components/shared/DynamicGridBackground';
+import { Loader } from '@/components/shared/Loader';
export async function generateMetadata({
params,
@@ -40,7 +40,7 @@ export default async function QAPage({
-
+
}
>
diff --git a/frontend/app/[locale]/quiz/[slug]/loading.tsx b/frontend/app/[locale]/quiz/[slug]/loading.tsx
new file mode 100644
index 00000000..69203c53
--- /dev/null
+++ b/frontend/app/[locale]/quiz/[slug]/loading.tsx
@@ -0,0 +1,9 @@
+import { Loader } from '@/components/shared/Loader';
+
+export default function QuizLoading() {
+ return (
+
+
+
+ );
+}
diff --git a/frontend/app/[locale]/quiz/[slug]/page.tsx b/frontend/app/[locale]/quiz/[slug]/page.tsx
index 49ca8c65..111f4e88 100644
--- a/frontend/app/[locale]/quiz/[slug]/page.tsx
+++ b/frontend/app/[locale]/quiz/[slug]/page.tsx
@@ -1,5 +1,5 @@
import type { Metadata } from 'next';
-import { notFound, redirect } from 'next/navigation';
+import { notFound } from 'next/navigation';
import { getTranslations } from 'next-intl/server';
import { QuizContainer } from '@/components/quiz/QuizContainer';
@@ -49,16 +49,10 @@ export default async function QuizPage({
notFound();
}
- if (!seedParam) {
- // eslint-disable-next-line react-hooks/purity -- redirect throws, value never used in render
- redirect(`/${locale}/quiz/${slug}?seed=${Date.now()}`);
- }
-
- const seed = Number.parseInt(seedParam, 10);
- if (Number.isNaN(seed)) {
- // eslint-disable-next-line react-hooks/purity -- redirect throws, value never used in render
- redirect(`/${locale}/quiz/${slug}?seed=${Date.now()}`);
- }
+ const parsedSeed = seedParam ? Number.parseInt(seedParam, 10) : Number.NaN;
+ const seed = Number.isFinite(parsedSeed)
+ ? parsedSeed
+ : crypto.getRandomValues(new Uint32Array(1))[0]!;
const questions = await getQuizQuestionsRandomized(quiz.id, locale, seed);
diff --git a/frontend/app/api/quiz/verify-answer/route.ts b/frontend/app/api/quiz/verify-answer/route.ts
index 5d4485c8..687f29ed 100644
--- a/frontend/app/api/quiz/verify-answer/route.ts
+++ b/frontend/app/api/quiz/verify-answer/route.ts
@@ -1,6 +1,13 @@
+import { headers } from 'next/headers';
import { NextResponse } from 'next/server';
-import { getCorrectAnswer, getOrCreateQuizAnswersCache } from '@/lib/quiz/quiz-answers-redis';
+import { verifyAuthToken } from '@/lib/auth';
+import {
+ getCorrectAnswer,
+ getOrCreateQuizAnswersCache,
+ isQuestionAlreadyVerified,
+ markQuestionVerified,
+} from '@/lib/quiz/quiz-answers-redis';
export const runtime = 'nodejs';
@@ -15,7 +22,40 @@ export async function POST(req: Request) {
);
}
- const { quizId, questionId, selectedAnswerId } = body;
+ const { quizId, questionId, selectedAnswerId, timeLimitSeconds } = body;
+
+ // Identify user: userId for authenticated, IP for guests
+ const headersList = await headers();
+ let identifier: string;
+
+ const cookieHeader = headersList.get('cookie') ?? '';
+ const authCookie = cookieHeader
+ .split(';')
+ .find(c => c.trim().startsWith('auth_session='));
+
+ if (authCookie) {
+ const token = authCookie.split('=').slice(1).join('=').trim();
+ const payload = verifyAuthToken(token);
+ identifier = payload?.userId ?? 'unknown';
+ } else {
+ identifier =
+ headersList.get('x-forwarded-for')?.split(',')[0]?.trim() ??
+ headersList.get('x-real-ip') ??
+ 'unknown';
+ }
+
+ // Reject duplicate verification (bot protection)
+ const alreadyVerified = await isQuestionAlreadyVerified(
+ quizId,
+ questionId,
+ identifier
+ );
+ if (alreadyVerified) {
+ return NextResponse.json(
+ { success: false, error: 'Question already answered' },
+ { status: 409 }
+ );
+ }
let correctAnswerId = await getCorrectAnswer(quizId, questionId);
@@ -33,6 +73,10 @@ export async function POST(req: Request) {
);
}
+ const ttl = typeof timeLimitSeconds === 'number' && timeLimitSeconds > 0
+ ? timeLimitSeconds + 60
+ : 900; // 15min fallback
+ await markQuestionVerified(quizId, questionId, identifier, ttl);
const isCorrect = selectedAnswerId === correctAnswerId;
return NextResponse.json({
diff --git a/frontend/components/q&a/QaSection.tsx b/frontend/components/q&a/QaSection.tsx
index 221b1d0c..d4b8d9bc 100644
--- a/frontend/components/q&a/QaSection.tsx
+++ b/frontend/components/q&a/QaSection.tsx
@@ -4,11 +4,11 @@ import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import AccordionList from '@/components/q&a/AccordionList';
-import { QaLoader } from '@/components/shared/QaLoader';
import { Pagination } from '@/components/q&a/Pagination';
import type { CategorySlug } from '@/components/q&a/types';
import { useQaTabs } from '@/components/q&a/useQaTabs';
import { CategoryTabButton } from '@/components/shared/CategoryTabButton';
+import { Loader } from '@/components/shared/Loader';
import { Tabs, TabsContent, TabsList } from '@/components/ui/tabs';
import { categoryData } from '@/data/category';
import { categoryTabStyles } from '@/data/categoryStyles';
@@ -103,7 +103,7 @@ export default function TabsSection() {
{isLoading && (
-
+
)}
0
? Math.round((userProgress.bestScore / userProgress.totalQuestions) * 100)
: 0;
+ const handleStart = () => {
+ const seed = makeSeed(); // runs on click, not render
+ router.push(`/quiz/${quiz.slug}?seed=${seed}`);
+ };
+
return (
)}
-
-
+
);
}
diff --git a/frontend/components/quiz/QuizContainer.tsx b/frontend/components/quiz/QuizContainer.tsx
index 4f963a9a..862ac144 100644
--- a/frontend/components/quiz/QuizContainer.tsx
+++ b/frontend/components/quiz/QuizContainer.tsx
@@ -1,5 +1,5 @@
'use client';
-import { Ban, Clock, FileText, TriangleAlert } from 'lucide-react';
+import { Ban, FileText, TriangleAlert, UserRound } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import {
@@ -19,6 +19,7 @@ import type { QuizQuestionClient } from '@/db/queries/quiz';
import { useAntiCheat } from '@/hooks/useAntiCheat';
import { useQuizGuards } from '@/hooks/useQuizGuards';
import { useQuizSession } from '@/hooks/useQuizSession';
+import { Link } from '@/i18n/routing';
import { savePendingQuizResult } from '@/lib/quiz/guest-quiz';
import {
clearQuizSession,
@@ -160,12 +161,14 @@ export function QuizContainer({
onBackToTopics,
}: QuizContainerProps) {
const tRules = useTranslations('quiz.rules');
+ const tResult = useTranslations('quiz.result');
const tExit = useTranslations('quiz.exitModal');
const tQuestion = useTranslations('quiz.question');
const categoryStyle = categorySlug
? categoryTabStyles[categorySlug as keyof typeof categoryTabStyles]
: null;
const accentColor = categoryStyle?.accent ?? '#3B82F6';
+ const [isStarting, setIsStarting] = useState(false);
const [isPending, startTransition] = useTransition();
const [state, dispatch] = useReducer(quizReducer, {
status: 'rules',
@@ -219,11 +222,13 @@ export function QuizContainer({
}, [seed, searchParams, router]);
const handleStart = async () => {
+ setIsStarting(true);
try {
const result = await initializeQuizCache(quizId);
if (!result.success) {
toast.error('Failed to start quiz session');
+ setIsStarting(false);
return;
}
@@ -231,6 +236,7 @@ export function QuizContainer({
dispatch({ type: 'START_QUIZ' });
} catch {
toast.error('Failed to start quiz session');
+ setIsStarting(false);
}
};
@@ -242,6 +248,7 @@ export function QuizContainer({
questionId: currentQuestion.id,
selectedAnswerId: answerId,
quizId,
+ timeLimitSeconds,
}),
});
@@ -370,6 +377,7 @@ export function QuizContainer({
const handleRestart = () => {
clearQuizSession(quizId);
resetViolations();
+ setIsStarting(false);
dispatch({ type: 'RESTART' });
};
@@ -446,22 +454,52 @@ export function QuizContainer({
-
-
-
-
{tRules('time.title')}
-
- {tRules('time.description', { seconds: totalQuestions * 3 })}
+
+ {isGuest ? (
+ <>
+
+
+
+ {tRules('guestWarning')}
-
-
+
+
+
+
+
+
+
+
+
+ >
+ ) : (
+ )}
);
}
diff --git a/frontend/components/shared/QaLoader.tsx b/frontend/components/shared/Loader.tsx
similarity index 97%
rename from frontend/components/shared/QaLoader.tsx
rename to frontend/components/shared/Loader.tsx
index 320c106a..2982dd4e 100644
--- a/frontend/components/shared/QaLoader.tsx
+++ b/frontend/components/shared/Loader.tsx
@@ -4,7 +4,7 @@ import { useEffect, useRef } from 'react';
import { cn } from '@/lib/utils';
-interface QaLoaderProps {
+interface LoaderProps {
className?: string;
size?: number;
}
@@ -22,7 +22,7 @@ interface ParticleState {
const TWO_PI = Math.PI * 2;
-export function QaLoader({ className, size = 240 }: QaLoaderProps) {
+export function Loader({ className, size = 240 }: LoaderProps) {
const canvasRef = useRef(null);
const particlesRef = useRef([]);
const animationRef = useRef(null);
diff --git a/frontend/lib/quiz/quiz-answers-redis.ts b/frontend/lib/quiz/quiz-answers-redis.ts
index 58b47fe0..32e12061 100644
--- a/frontend/lib/quiz/quiz-answers-redis.ts
+++ b/frontend/lib/quiz/quiz-answers-redis.ts
@@ -228,3 +228,58 @@ export async function getOrCreateQuestionsCache(
return questions;
}
+
+export async function isQuestionAlreadyVerified(
+ quizId: string,
+ questionId: string,
+ clientIp: string
+): Promise {
+ const redis = getRedisClient();
+ if (!redis) return false;
+
+ const key = `quiz:verified:${quizId}:${clientIp}:${questionId}`;
+ try {
+ const exists = await redis.get(key);
+ return exists !== null;
+ } catch {
+ return false;
+ }
+}
+
+export async function markQuestionVerified(
+ quizId: string,
+ questionId: string,
+ clientIp: string,
+ ttlSeconds: number = 900
+): Promise {
+ const redis = getRedisClient();
+ if (!redis) return;
+
+ const key = `quiz:verified:${quizId}:${clientIp}:${questionId}`;
+ try {
+ await redis.set(key, 1, { ex: ttlSeconds });
+ } catch {
+ // silent fail — verification still works without tracking
+ }
+}
+export async function clearVerifiedQuestions(
+ quizId: string,
+ identifier: string
+): Promise {
+ const redis = getRedisClient();
+ if (!redis) return;
+
+ const pattern = `quiz:verified:${quizId}:${identifier}:*`;
+ try {
+ let cursor = '0';
+ do {
+ const [nextCursor, keys] = await redis.scan(cursor, { match: pattern, count: 100 });
+ cursor = nextCursor;
+ if (keys.length > 0) {
+ await redis.del(...keys);
+ }
+ } while (cursor !== '0');
+ } catch (err) {
+ console.warn('Failed to clear verified questions:', err);
+ }
+}
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index a91376e9..b9595c6a 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -117,13 +117,11 @@
},
"control": {
"title": "Control System",
- "description": "Rule violations are automatically detected. With 3+ violations, the result will not count towards the leaderboard."
+ "description": "Rule violations are automatically detected. For registered users, results are always saved to your profile, but with 4+ violations points will not count towards the leaderboard."
},
- "time": {
- "title": "Time Requirements",
- "description": "Minimum time: {seconds} seconds (3 seconds per question). Results that are too fast will not be counted."
- },
- "startButton": "Start Quiz"
+ "startButton": "Start Quiz",
+ "guestWarning": "You are playing as a guest. Your result will not be saved.",
+ "continueAsGuest": "Continue as Guest"
},
"question": {
"correct": "Correct",
diff --git a/frontend/messages/pl.json b/frontend/messages/pl.json
index 0c5a6540..d1aff35d 100644
--- a/frontend/messages/pl.json
+++ b/frontend/messages/pl.json
@@ -117,13 +117,11 @@
},
"control": {
"title": "System Kontroli",
- "description": "Naruszenia zasad są wykrywane automatycznie. Przy 3+ naruszeniach wynik nie będzie liczony do rankingu."
+ "description": "Naruszenia zasad są wykrywane automatycznie. Dla zarejestrowanych użytkowników wynik jest zawsze zapisywany w profilu, ale przy 4+ naruszeniach punkty nie będą liczone do rankingu."
},
- "time": {
- "title": "Wymagania Czasowe",
- "description": "Minimalny czas: {seconds} sekund (3 sekundy na pytanie). Wyniki zbyt szybkie nie będą liczone."
- },
- "startButton": "Rozpocznij Quiz"
+ "startButton": "Rozpocznij Quiz",
+ "guestWarning": "Grasz jako gość. Twój wynik nie zostanie zapisany.",
+ "continueAsGuest": "Kontynuuj jako gość"
},
"question": {
"correct": "Poprawnie",
diff --git a/frontend/messages/uk.json b/frontend/messages/uk.json
index a5172539..fd61da70 100644
--- a/frontend/messages/uk.json
+++ b/frontend/messages/uk.json
@@ -117,13 +117,11 @@
},
"control": {
"title": "Система контролю",
- "description": "Порушення правил фіксуються автоматично. При 3+ порушеннях результат не зараховується до рейтингу."
+ "description": "Порушення фіксуються автоматично. Для зареєстрованих користувачів результат завжди зберігається в кабінеті, але при 4+ порушеннях бали не зараховуються до рейтингу."
},
- "time": {
- "title": "Час проходження",
- "description": "Мінімальний час: {seconds} секунд (по 3 секунди на питання). Занадто швидке проходження не зараховується."
- },
- "startButton": "Почати квіз"
+ "startButton": "Почати квіз",
+ "guestWarning": "Ви граєте як гість. Ваш результат не буде збережено.",
+ "continueAsGuest": "Продовжити як гість"
},
"question": {
"correct": "Правильно",
From c5a74e8346de639122bed8a92f7795156b489cd0 Mon Sep 17 00:00:00 2001
From: Lesia Soloviova
Date: Sun, 8 Feb 2026 20:31:06 +0200
Subject: [PATCH 2/3] refactor(quiz): PR review feedback
- Extract shared resolveRequestIdentifier() helper to eliminate
duplicated auth/IP resolution logic in route.ts and actions/quiz.ts
- Return null instead of 'unknown' when identifier unresolvable,
skip verification tracking for unidentifiable users
- Cap Redis TTL with MAX_TTL (3600s) to prevent client-supplied
timeLimitSeconds from persisting keys indefinitely
- Add locale prefix to returnTo paths in guest warning links
- Replace nested Button inside Link with styled Link to fix
invalid HTML (interactive element nesting)
---
frontend/actions/quiz.ts | 25 ++-------
frontend/app/api/quiz/verify-answer/route.ts | 54 ++++++++------------
frontend/components/quiz/QuizContainer.tsx | 14 +++--
frontend/lib/quiz/resolve-identifier.ts | 20 ++++++++
4 files changed, 52 insertions(+), 61 deletions(-)
create mode 100644 frontend/lib/quiz/resolve-identifier.ts
diff --git a/frontend/actions/quiz.ts b/frontend/actions/quiz.ts
index 4f5b2097..d89c74d2 100644
--- a/frontend/actions/quiz.ts
+++ b/frontend/actions/quiz.ts
@@ -238,29 +238,14 @@ export async function initializeQuizCache(
const { getOrCreateQuizAnswersCache, clearVerifiedQuestions } =
await import('@/lib/quiz/quiz-answers-redis');
- // Resolve identifier (same logic as verify-answer route)
+ const { resolveRequestIdentifier } = await import('@/lib/quiz/resolve-identifier');
const { headers } = await import('next/headers');
- const { verifyAuthToken } = await import('@/lib/auth');
const headersList = await headers();
- let identifier: string;
-
- const cookieHeader = headersList.get('cookie') ?? '';
- const authCookie = cookieHeader
- .split(';')
- .find(c => c.trim().startsWith('auth_session='));
-
- if (authCookie) {
- const token = authCookie.split('=').slice(1).join('=').trim();
- const payload = verifyAuthToken(token);
- identifier = payload?.userId ?? 'unknown';
- } else {
- identifier =
- headersList.get('x-forwarded-for')?.split(',')[0]?.trim() ??
- headersList.get('x-real-ip') ??
- 'unknown';
- }
+ const identifier = resolveRequestIdentifier(headersList);
- await clearVerifiedQuestions(quizId, identifier);
+ if (identifier) {
+ await clearVerifiedQuestions(quizId, identifier);
+ }
const success = await getOrCreateQuizAnswersCache(quizId);
diff --git a/frontend/app/api/quiz/verify-answer/route.ts b/frontend/app/api/quiz/verify-answer/route.ts
index 687f29ed..bbe81a60 100644
--- a/frontend/app/api/quiz/verify-answer/route.ts
+++ b/frontend/app/api/quiz/verify-answer/route.ts
@@ -1,7 +1,7 @@
import { headers } from 'next/headers';
import { NextResponse } from 'next/server';
-import { verifyAuthToken } from '@/lib/auth';
+import { resolveRequestIdentifier } from '@/lib/quiz/resolve-identifier'
import {
getCorrectAnswer,
getOrCreateQuizAnswersCache,
@@ -26,35 +26,19 @@ export async function POST(req: Request) {
// Identify user: userId for authenticated, IP for guests
const headersList = await headers();
- let identifier: string;
-
- const cookieHeader = headersList.get('cookie') ?? '';
- const authCookie = cookieHeader
- .split(';')
- .find(c => c.trim().startsWith('auth_session='));
-
- if (authCookie) {
- const token = authCookie.split('=').slice(1).join('=').trim();
- const payload = verifyAuthToken(token);
- identifier = payload?.userId ?? 'unknown';
- } else {
- identifier =
- headersList.get('x-forwarded-for')?.split(',')[0]?.trim() ??
- headersList.get('x-real-ip') ??
- 'unknown';
- }
-
- // Reject duplicate verification (bot protection)
- const alreadyVerified = await isQuestionAlreadyVerified(
- quizId,
- questionId,
- identifier
- );
- if (alreadyVerified) {
- return NextResponse.json(
- { success: false, error: 'Question already answered' },
- { status: 409 }
+ const identifier = resolveRequestIdentifier(headersList)
+ if (identifier) {
+ const alreadyVerified = await isQuestionAlreadyVerified(
+ quizId,
+ questionId,
+ identifier
);
+ if (alreadyVerified) {
+ return NextResponse.json(
+ { success: false, error: 'Question already answered' },
+ { status: 409 }
+ );
+ }
}
let correctAnswerId = await getCorrectAnswer(quizId, questionId);
@@ -73,10 +57,14 @@ export async function POST(req: Request) {
);
}
- const ttl = typeof timeLimitSeconds === 'number' && timeLimitSeconds > 0
- ? timeLimitSeconds + 60
- : 900; // 15min fallback
- await markQuestionVerified(quizId, questionId, identifier, ttl);
+ const MAX_TTL = 3600;
+ const ttl = typeof timeLimitSeconds === 'number' && timeLimitSeconds > 0
+ ? Math.min(timeLimitSeconds + 60, MAX_TTL)
+ : 900;
+
+ if (identifier) {
+ await markQuestionVerified(quizId, questionId, identifier, ttl);
+ }
const isCorrect = selectedAnswerId === correctAnswerId;
return NextResponse.json({
diff --git a/frontend/components/quiz/QuizContainer.tsx b/frontend/components/quiz/QuizContainer.tsx
index 862ac144..a4718ed4 100644
--- a/frontend/components/quiz/QuizContainer.tsx
+++ b/frontend/components/quiz/QuizContainer.tsx
@@ -468,18 +468,16 @@ export function QuizContainer({
-
+ {tResult('loginButton')}
-
+ {tResult('signupButton')}