diff --git a/frontend/actions/quiz.ts b/frontend/actions/quiz.ts
index 58ef6432..d89c74d2 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,18 @@ export async function initializeQuizCache(
quizId: string
): Promise<{ success: boolean; error?: string }> {
try {
- const { getOrCreateQuizAnswersCache } =
+ const { getOrCreateQuizAnswersCache, clearVerifiedQuestions } =
await import('@/lib/quiz/quiz-answers-redis');
+
+ const { resolveRequestIdentifier } = await import('@/lib/quiz/resolve-identifier');
+ const { headers } = await import('next/headers');
+ const headersList = await headers();
+ const identifier = resolveRequestIdentifier(headersList);
+
+ if (identifier) {
+ 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..3f08b66c 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 {
+ getCorrectAnswer,
+ getOrCreateQuizAnswersCache,
+ isQuestionAlreadyVerified,
+ markQuestionVerified,
+} from '@/lib/quiz/quiz-answers-redis';
+import { resolveRequestIdentifier } from '@/lib/quiz/resolve-identifier'
export const runtime = 'nodejs';
@@ -15,7 +22,24 @@ 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();
+ 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);
@@ -33,6 +57,14 @@ export async function POST(req: Request) {
);
}
+ 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/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..a4718ed4 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,50 @@ export function QuizContainer({
-
-
-
-
{tRules('time.title')}
-
- {tRules('time.description', { seconds: totalQuestions * 3 })}
+
+ {isGuest ? (
+ <>
+
+
+
+ {tRules('guestWarning')}
-
-
+
+
+ {tResult('loginButton')}
+
+
+ {tResult('signupButton')}
+
+
+
+ >
+ ) : (
+ )}
);
}
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/lib/quiz/resolve-identifier.ts b/frontend/lib/quiz/resolve-identifier.ts
new file mode 100644
index 00000000..adf7e0b7
--- /dev/null
+++ b/frontend/lib/quiz/resolve-identifier.ts
@@ -0,0 +1,22 @@
+import { verifyAuthToken } from '@/lib/auth';
+
+export function resolveRequestIdentifier(headersList: Headers): string | null {
+ 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);
+ if (payload?.userId) {
+ return payload.userId;
+ }
+ }
+
+ return (
+ headersList.get('x-forwarded-for')?.split(',')[0]?.trim() ??
+ headersList.get('x-real-ip') ??
+ null
+ );
+}
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": "Правильно",