Skip to content

Commit febe4d8

Browse files
authored
feat(quiz): add guest warning before start and bot protection (#297)
1 parent 7d1f9d7 commit febe4d8

14 files changed

Lines changed: 221 additions & 81 deletions

File tree

frontend/actions/quiz.ts

Lines changed: 11 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -49,19 +49,6 @@ function calculateIntegrityScore(violations: ViolationEvent[]): number {
4949
return Math.max(0, 100 - penalty);
5050
}
5151

52-
function validateTimeSpent(
53-
startedAt: Date,
54-
completedAt: Date,
55-
questionCount: number
56-
): boolean {
57-
const MIN_SECONDS_PER_QUESTION = 1;
58-
const timeSpentSeconds = Math.floor(
59-
(completedAt.getTime() - startedAt.getTime()) / 1000
60-
);
61-
const minRequiredTime = questionCount * MIN_SECONDS_PER_QUESTION;
62-
63-
return timeSpentSeconds >= minRequiredTime;
64-
}
6552

6653
async function getQuizQuestionIds(quizId: string): Promise<string[]> {
6754
const rows = await db
@@ -176,18 +163,6 @@ export async function submitQuizAttempt(
176163
return { success: false, error: 'Invalid time values' };
177164
}
178165

179-
const isValidTime = validateTimeSpent(
180-
startedAtDate,
181-
completedAtDate,
182-
questionIds.length
183-
);
184-
if (!isValidTime) {
185-
return {
186-
success: false,
187-
error: 'Invalid time spent: quiz completed too quickly',
188-
};
189-
}
190-
191166
const percentage = (
192167
(correctAnswersCount / questionIds.length) *
193168
100
@@ -260,8 +235,18 @@ export async function initializeQuizCache(
260235
quizId: string
261236
): Promise<{ success: boolean; error?: string }> {
262237
try {
263-
const { getOrCreateQuizAnswersCache } =
238+
const { getOrCreateQuizAnswersCache, clearVerifiedQuestions } =
264239
await import('@/lib/quiz/quiz-answers-redis');
240+
241+
const { resolveRequestIdentifier } = await import('@/lib/quiz/resolve-identifier');
242+
const { headers } = await import('next/headers');
243+
const headersList = await headers();
244+
const identifier = resolveRequestIdentifier(headersList);
245+
246+
if (identifier) {
247+
await clearVerifiedQuestions(quizId, identifier);
248+
}
249+
265250
const success = await getOrCreateQuizAnswersCache(quizId);
266251

267252
if (!success) {

frontend/app/[locale]/q&a/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import { getTranslations } from 'next-intl/server';
22
import { Suspense } from 'react';
33

44
import QaSection from '@/components/q&a/QaSection';
5-
import { QaLoader } from '@/components/shared/QaLoader';
65
import { DynamicGridBackground } from '@/components/shared/DynamicGridBackground';
6+
import { Loader } from '@/components/shared/Loader';
77

88
export async function generateMetadata({
99
params,
@@ -40,7 +40,7 @@ export default async function QAPage({
4040
<Suspense
4141
fallback={
4242
<div className="flex justify-center py-16">
43-
<QaLoader className="mx-auto" size={260} />
43+
<Loader className="mx-auto" size={260} />
4444
</div>
4545
}
4646
>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { Loader } from '@/components/shared/Loader';
2+
3+
export default function QuizLoading() {
4+
return (
5+
<div className="flex min-h-screen items-center justify-center bg-white dark:bg-black">
6+
<Loader size={200} />
7+
</div>
8+
);
9+
}

frontend/app/[locale]/quiz/[slug]/page.tsx

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Metadata } from 'next';
2-
import { notFound, redirect } from 'next/navigation';
2+
import { notFound } from 'next/navigation';
33
import { getTranslations } from 'next-intl/server';
44

55
import { QuizContainer } from '@/components/quiz/QuizContainer';
@@ -49,16 +49,10 @@ export default async function QuizPage({
4949
notFound();
5050
}
5151

52-
if (!seedParam) {
53-
// eslint-disable-next-line react-hooks/purity -- redirect throws, value never used in render
54-
redirect(`/${locale}/quiz/${slug}?seed=${Date.now()}`);
55-
}
56-
57-
const seed = Number.parseInt(seedParam, 10);
58-
if (Number.isNaN(seed)) {
59-
// eslint-disable-next-line react-hooks/purity -- redirect throws, value never used in render
60-
redirect(`/${locale}/quiz/${slug}?seed=${Date.now()}`);
61-
}
52+
const parsedSeed = seedParam ? Number.parseInt(seedParam, 10) : Number.NaN;
53+
const seed = Number.isFinite(parsedSeed)
54+
? parsedSeed
55+
: crypto.getRandomValues(new Uint32Array(1))[0]!;
6256

6357
const questions = await getQuizQuestionsRandomized(quiz.id, locale, seed);
6458

frontend/app/api/quiz/verify-answer/route.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
1+
import { headers } from 'next/headers';
12
import { NextResponse } from 'next/server';
23

3-
import { getCorrectAnswer, getOrCreateQuizAnswersCache } from '@/lib/quiz/quiz-answers-redis';
4+
import {
5+
getCorrectAnswer,
6+
getOrCreateQuizAnswersCache,
7+
isQuestionAlreadyVerified,
8+
markQuestionVerified,
9+
} from '@/lib/quiz/quiz-answers-redis';
10+
import { resolveRequestIdentifier } from '@/lib/quiz/resolve-identifier'
411

512
export const runtime = 'nodejs';
613

@@ -15,7 +22,24 @@ export async function POST(req: Request) {
1522
);
1623
}
1724

18-
const { quizId, questionId, selectedAnswerId } = body;
25+
const { quizId, questionId, selectedAnswerId, timeLimitSeconds } = body;
26+
27+
// Identify user: userId for authenticated, IP for guests
28+
const headersList = await headers();
29+
const identifier = resolveRequestIdentifier(headersList);
30+
if (identifier) {
31+
const alreadyVerified = await isQuestionAlreadyVerified(
32+
quizId,
33+
questionId,
34+
identifier
35+
);
36+
if (alreadyVerified) {
37+
return NextResponse.json(
38+
{ success: false, error: 'Question already answered' },
39+
{ status: 409 }
40+
);
41+
}
42+
}
1943

2044
let correctAnswerId = await getCorrectAnswer(quizId, questionId);
2145

@@ -33,6 +57,14 @@ export async function POST(req: Request) {
3357
);
3458
}
3559

60+
const MAX_TTL = 3600;
61+
const ttl = typeof timeLimitSeconds === 'number' && timeLimitSeconds > 0
62+
? Math.min(timeLimitSeconds + 60, MAX_TTL)
63+
: 900;
64+
65+
if (identifier) {
66+
await markQuestionVerified(quizId, questionId, identifier, ttl);
67+
}
3668
const isCorrect = selectedAnswerId === correctAnswerId;
3769

3870
return NextResponse.json({

frontend/components/q&a/QaSection.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ import { useTranslations } from 'next-intl';
44
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
55

66
import AccordionList from '@/components/q&a/AccordionList';
7-
import { QaLoader } from '@/components/shared/QaLoader';
87
import { Pagination } from '@/components/q&a/Pagination';
98
import type { CategorySlug } from '@/components/q&a/types';
109
import { useQaTabs } from '@/components/q&a/useQaTabs';
1110
import { CategoryTabButton } from '@/components/shared/CategoryTabButton';
11+
import { Loader } from '@/components/shared/Loader';
1212
import { Tabs, TabsContent, TabsList } from '@/components/ui/tabs';
1313
import { categoryData } from '@/data/category';
1414
import { categoryTabStyles } from '@/data/categoryStyles';
@@ -103,7 +103,7 @@ export default function TabsSection() {
103103
<TabsContent key={category.slug} value={category.slug}>
104104
{isLoading && (
105105
<div className="flex justify-center py-12">
106-
<QaLoader className="mx-auto" size={240} />
106+
<Loader className="mx-auto" size={240} />
107107
</div>
108108
)}
109109
<div

frontend/components/quiz/QuizCard.tsx

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { useTranslations } from 'next-intl';
55

66
import { Badge } from '@/components/ui/badge';
77
import { categoryTabStyles } from '@/data/categoryStyles';
8-
import { Link } from '@/i18n/routing';
8+
import { useRouter } from '@/i18n/routing';
99

1010
interface QuizCardProps {
1111
quiz: {
@@ -25,18 +25,30 @@ interface QuizCardProps {
2525
} | null;
2626
}
2727

28+
function makeSeed(): number {
29+
const buf = new Uint32Array(1);
30+
crypto.getRandomValues(buf);
31+
return buf[0]!;
32+
}
33+
2834
export function QuizCard({ quiz, userProgress }: QuizCardProps) {
35+
const router = useRouter();
2936
const t = useTranslations('quiz.card');
3037
const slug = quiz.categorySlug as keyof typeof categoryTabStyles | null;
3138
const style =
3239
slug && categoryTabStyles[slug] ? categoryTabStyles[slug] : null;
33-
const accentColor = style?.accent ?? '#3B82F6'; // fallback blue
40+
const accentColor = style?.accent ?? '#3B82F6';
3441

3542
const percentage =
3643
userProgress && userProgress.totalQuestions > 0
3744
? Math.round((userProgress.bestScore / userProgress.totalQuestions) * 100)
3845
: 0;
3946

47+
const handleStart = () => {
48+
const seed = makeSeed(); // runs on click, not render
49+
router.push(`/quiz/${quiz.slug}?seed=${seed}`);
50+
};
51+
4052
return (
4153
<div
4254
className="group/card relative flex flex-col overflow-hidden rounded-xl border border-black/10 bg-white p-5 shadow-sm transition-all duration-300 hover:-translate-y-1 hover:!border-[var(--accent)] hover:shadow-xl dark:border-white/10 dark:bg-neutral-900"
@@ -103,8 +115,8 @@ export function QuizCard({ quiz, userProgress }: QuizCardProps) {
103115
</div>
104116
</div>
105117
)}
106-
<Link
107-
href={`/quiz/${quiz.slug}`}
118+
<button
119+
type="button" onClick={handleStart}
108120
className="group relative block w-full overflow-hidden rounded-xl border px-4 py-2.5 text-center text-sm font-semibold transition-all duration-300"
109121
style={{
110122
borderColor: `${accentColor}50`,
@@ -117,7 +129,7 @@ export function QuizCard({ quiz, userProgress }: QuizCardProps) {
117129
className="pointer-events-none absolute top-1/2 left-1/2 h-[150%] w-[80%] -translate-x-1/2 -translate-y-1/2 rounded-full opacity-0 blur-[20px] transition-opacity duration-300 group-hover:opacity-30"
118130
style={{ backgroundColor: accentColor }}
119131
/>
120-
</Link>
132+
</button>
121133
</div>
122134
);
123135
}

frontend/components/quiz/QuizContainer.tsx

Lines changed: 50 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
'use client';
2-
import { Ban, Clock, FileText, TriangleAlert } from 'lucide-react';
2+
import { Ban, FileText, TriangleAlert, UserRound } from 'lucide-react';
33
import { useRouter, useSearchParams } from 'next/navigation';
44
import { useLocale, useTranslations } from 'next-intl';
55
import {
@@ -19,6 +19,7 @@ import type { QuizQuestionClient } from '@/db/queries/quiz';
1919
import { useAntiCheat } from '@/hooks/useAntiCheat';
2020
import { useQuizGuards } from '@/hooks/useQuizGuards';
2121
import { useQuizSession } from '@/hooks/useQuizSession';
22+
import { Link } from '@/i18n/routing';
2223
import { savePendingQuizResult } from '@/lib/quiz/guest-quiz';
2324
import {
2425
clearQuizSession,
@@ -160,12 +161,14 @@ export function QuizContainer({
160161
onBackToTopics,
161162
}: QuizContainerProps) {
162163
const tRules = useTranslations('quiz.rules');
164+
const tResult = useTranslations('quiz.result');
163165
const tExit = useTranslations('quiz.exitModal');
164166
const tQuestion = useTranslations('quiz.question');
165167
const categoryStyle = categorySlug
166168
? categoryTabStyles[categorySlug as keyof typeof categoryTabStyles]
167169
: null;
168170
const accentColor = categoryStyle?.accent ?? '#3B82F6';
171+
const [isStarting, setIsStarting] = useState(false);
169172
const [isPending, startTransition] = useTransition();
170173
const [state, dispatch] = useReducer(quizReducer, {
171174
status: 'rules',
@@ -219,18 +222,21 @@ export function QuizContainer({
219222
}, [seed, searchParams, router]);
220223

221224
const handleStart = async () => {
225+
setIsStarting(true);
222226
try {
223227
const result = await initializeQuizCache(quizId);
224228

225229
if (!result.success) {
226230
toast.error('Failed to start quiz session');
231+
setIsStarting(false);
227232
return;
228233
}
229234

230235
window.history.pushState({ quizGuard: true }, '');
231236
dispatch({ type: 'START_QUIZ' });
232237
} catch {
233238
toast.error('Failed to start quiz session');
239+
setIsStarting(false);
234240
}
235241
};
236242

@@ -242,6 +248,7 @@ export function QuizContainer({
242248
questionId: currentQuestion.id,
243249
selectedAnswerId: answerId,
244250
quizId,
251+
timeLimitSeconds,
245252
}),
246253
});
247254

@@ -370,6 +377,7 @@ export function QuizContainer({
370377
const handleRestart = () => {
371378
clearQuizSession(quizId);
372379
resetViolations();
380+
setIsStarting(false);
373381
dispatch({ type: 'RESTART' });
374382
};
375383

@@ -446,22 +454,50 @@ export function QuizContainer({
446454
</p>
447455
</div>
448456
</div>
449-
<div className="flex gap-3">
450-
<Clock
451-
className="mt-0.5 h-5 w-5 shrink-0 text-blue-500 dark:text-blue-400"
452-
aria-hidden="true"
453-
/>
454-
<div>
455-
<p className="font-medium">{tRules('time.title')}</p>
456-
<p className="text-sm text-gray-600 dark:text-gray-400">
457-
{tRules('time.description', { seconds: totalQuestions * 3 })}
457+
</div>
458+
{isGuest ? (
459+
<>
460+
<div className="flex gap-3 rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-900/20">
461+
<UserRound
462+
className="mt-0.5 h-5 w-5 shrink-0 text-amber-500 dark:text-amber-400"
463+
aria-hidden="true"
464+
/>
465+
<p className="text-sm font-medium text-amber-800 dark:text-amber-200">
466+
{tRules('guestWarning')}
458467
</p>
459468
</div>
460-
</div>
461-
</div>
469+
<div className="flex flex-col gap-3 sm:flex-row">
470+
<Link
471+
href={`/login?returnTo=/${locale}/quiz/${quizSlug}`}
472+
className="flex-1 inline-flex items-center justify-center rounded-xl font-medium transition-colors px-6 py-3 text-base bg-[var(--accent-primary)] text-white hover:bg-[var(--accent-hover)] active:brightness-90 text-center"
473+
>
474+
{tResult('loginButton')}
475+
</Link>
476+
<Link
477+
href={`/signup?returnTo=/${locale}/quiz/${quizSlug}`}
478+
className="flex-1 inline-flex items-center justify-center rounded-xl font-medium transition-colors px-6 py-3 text-base bg-gray-200 text-gray-900 hover:bg-gray-300 dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700 text-center"
479+
>
480+
{tResult('signupButton')}
481+
</Link>
482+
<button
483+
onClick={handleStart}
484+
disabled={isStarting}
485+
className="disabled:opacity-50 disabled:cursor-not-allowed flex-1 rounded-xl border px-6 py-3 text-center text-base font-semibold transition-all duration-300"
486+
style={{
487+
borderColor: `${accentColor}50`,
488+
backgroundColor: `${accentColor}15`,
489+
color: accentColor,
490+
}}
491+
>
492+
{tRules('continueAsGuest')}
493+
</button>
494+
</div>
495+
</>
496+
) : (
462497
<button
463498
onClick={handleStart}
464-
className="group relative w-full overflow-hidden rounded-xl border px-6 py-3 text-center text-base font-semibold transition-all duration-300"
499+
disabled={isStarting}
500+
className="disabled:opacity-50 disabled:cursor-not-allowed group relative w-full overflow-hidden rounded-xl border px-6 py-3 text-center text-base font-semibold transition-all duration-300"
465501
style={{
466502
borderColor: `${accentColor}50`,
467503
backgroundColor: `${accentColor}15`,
@@ -474,6 +510,7 @@ export function QuizContainer({
474510
style={{ backgroundColor: accentColor }}
475511
/>
476512
</button>
513+
)}
477514
</div>
478515
);
479516
}

0 commit comments

Comments
 (0)