Skip to content

Commit 0c375a2

Browse files
Merge pull request #131 from DevLoversTeam/sl/feat/quiz
(SP: 10) [Frontend] Quiz flow: Security encryption + session persistence + UX fixes
2 parents 777219f + 1b62dd4 commit 0c375a2

29 files changed

Lines changed: 910 additions & 112 deletions

frontend/app/[locale]/login/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { useLocale } from 'next-intl';
44
import { Link } from '@/i18n/routing';
55
import { useState } from "react";
66
import { useSearchParams } from "next/navigation";
7-
import { getPendingQuizResult, clearPendingQuizResult } from "@/lib/guest-quiz";
7+
import { getPendingQuizResult, clearPendingQuizResult } from "@/lib/quiz/guest-quiz";
88
import { Button } from "@/components/ui/button";
99
import { OAuthButtons } from '@/components/auth/OAuthButtons';
1010

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

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { createEncryptedAnswersBlob } from '@/lib/quiz/quiz-crypto';
2+
import { stripCorrectAnswers } from '@/db/queries/quiz';
13
import { getQuizBySlug, getQuizQuestionsRandomized } from '@/db/queries/quiz';
24
import { notFound } from 'next/navigation';
35
import { getTranslations } from 'next-intl/server';
@@ -8,11 +10,13 @@ import { getCurrentUser } from '@/lib/auth';
810

911
interface QuizPageProps {
1012
params: Promise<{ locale: string; slug: string }>;
13+
searchParams: Promise<{seed?: string}>;
1114
}
1215

13-
export default async function QuizPage({ params }: QuizPageProps) {
16+
export default async function QuizPage({ params, searchParams }: QuizPageProps) {
1417
const { locale, slug } = await params;
1518
const t = await getTranslations({ locale, namespace: 'quiz.page' });
19+
const { seed: seedParam } = await searchParams;
1620

1721
const user = await getCurrentUser();
1822

@@ -22,9 +26,12 @@ export default async function QuizPage({ params }: QuizPageProps) {
2226
notFound();
2327
}
2428

25-
const seed = Date.now();
29+
const seed = seedParam ? parseInt(seedParam, 10) : Date.now();
2630
const questions = await getQuizQuestionsRandomized(quiz.id, locale, seed);
2731

32+
const encryptedAnswers = createEncryptedAnswersBlob(questions);
33+
const clientQuestions = stripCorrectAnswers(questions);
34+
2835
if (!questions.length) {
2936
return (
3037
<div className="min-h-screen flex items-center justify-center">
@@ -56,9 +63,12 @@ export default async function QuizPage({ params }: QuizPageProps) {
5663
<QuizContainer
5764
quizSlug={slug}
5865
quizId={quiz.id}
59-
questions={questions}
66+
questions={clientQuestions}
67+
encryptedAnswers={encryptedAnswers}
6068
userId={user?.id ?? null}
6169
timeLimitSeconds={quiz.timeLimitSeconds ?? questions.length * 30}
70+
seed={seed}
71+
categorySlug={quiz.categorySlug}
6272
/>
6373
{user && <PendingResultHandler userId={user.id} />}
6474
</div>

frontend/app/[locale]/signup/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { useLocale } from 'next-intl';
44
import { Link } from '@/i18n/routing';
55
import { useState } from "react";
66
import { useSearchParams } from "next/navigation";
7-
import { getPendingQuizResult, clearPendingQuizResult } from "@/lib/guest-quiz";
7+
import { getPendingQuizResult, clearPendingQuizResult } from "@/lib/quiz/guest-quiz";
88
import { Button } from "@/components/ui/button";
99
import { OAuthButtons } from '@/components/auth/OAuthButtons';
1010

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import { decryptAnswers } from '@/lib/quiz/quiz-crypto';
3+
4+
interface VerifyRequest {
5+
questionId: string;
6+
answerId: string;
7+
encryptedAnswers: string;
8+
}
9+
10+
export async function POST(request: NextRequest) {
11+
try {
12+
const body: VerifyRequest = await request.json();
13+
const { questionId, answerId, encryptedAnswers } = body;
14+
15+
if (!questionId || !answerId || !encryptedAnswers) {
16+
return NextResponse.json(
17+
{ error: 'Missing required fields' },
18+
{ status: 400 }
19+
);
20+
}
21+
22+
const correctAnswersMap = decryptAnswers(encryptedAnswers);
23+
24+
if (!correctAnswersMap) {
25+
return NextResponse.json(
26+
{ error: 'Invalid encrypted data' },
27+
{ status: 400 }
28+
);
29+
}
30+
31+
const correctAnswerId = correctAnswersMap[questionId];
32+
33+
if (!correctAnswerId) {
34+
return NextResponse.json(
35+
{ error: 'Question not found' },
36+
{ status: 404 }
37+
);
38+
}
39+
40+
return NextResponse.json({
41+
isCorrect: answerId === correctAnswerId,
42+
});
43+
} catch {
44+
return NextResponse.json(
45+
{ error: 'Internal server error' },
46+
{ status: 500 }
47+
);
48+
}
49+
}

frontend/components/dashboard/StatsCard.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export function StatsCard({ stats }: StatsCardProps) {
5252
<p className="text-slate-500 dark:text-slate-400 mb-8 max-w-xs mx-auto">
5353
Ready to level up? Challenge yourself with a new React quiz.
5454
</p>
55-
<Link href="/quiz/react-fundamentals" className={primaryBtnStyles}>
55+
<Link href="/quizzes" className={primaryBtnStyles}>
5656
<span className="relative z-10">Start a Quiz</span>
5757
<span
5858
className="absolute inset-0 rounded-full bg-gradient-to-r from-white/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity"
@@ -81,7 +81,7 @@ export function StatsCard({ stats }: StatsCardProps) {
8181
</div>
8282

8383
<div className="col-span-2 mt-4">
84-
<Link href="/quiz/react-fundamentals" className={primaryBtnStyles}>
84+
<Link href="/q&a" className={primaryBtnStyles}>
8585
<span className="relative z-10">Continue Learning</span>
8686
</Link>
8787
</div>

frontend/components/quiz/PendingResultHandler.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client';
22

33
import { useEffect } from 'react';
4-
import { getPendingQuizResult, clearPendingQuizResult } from '@/lib/guest-quiz';
4+
import { getPendingQuizResult, clearPendingQuizResult } from '@/lib/quiz/guest-quiz';
55

66
interface Props {
77
userId: string;
@@ -24,13 +24,13 @@ export function PendingResultHandler({ userId }: Props) {
2424
timeSpentSeconds: pending.timeSpentSeconds,
2525
}),
2626
})
27-
.then(async (res) => {
28-
if (res.ok) {
29-
clearPendingQuizResult();
30-
} else {
31-
console.error("Guest-result API error:", res.status);
32-
}
33-
})
27+
.then(async (res) => {
28+
if (res.ok) {
29+
clearPendingQuizResult();
30+
} else {
31+
console.error("Guest-result API error:", res.status);
32+
}
33+
})
3434
.catch(err => {
3535
console.error("Guest-result fetch error:", err);
3636
});

frontend/components/quiz/QuizCard.tsx

Lines changed: 24 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ export function QuizCard({ quiz, userProgress }: QuizCardProps) {
2828
: 0;
2929

3030
return (
31-
<div className="rounded-xl border border-gray-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-5 shadow-sm hover:shadow-md transition-shadow">
31+
<div className="flex flex-col rounded-xl border border-gray-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-5 shadow-sm hover:shadow-md transition-shadow">
32+
<div className="flex-grow">
3233
<div className="flex gap-2 mb-3">
3334
<Badge variant="blue">{quiz.categoryName ?? t('uncategorized')}</Badge>
3435
{userProgress && (
@@ -39,7 +40,7 @@ export function QuizCard({ quiz, userProgress }: QuizCardProps) {
3940
{quiz.title ?? quiz.slug}
4041
</h2>
4142
{quiz.description && (
42-
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
43+
<p className="line-clamp-2 text-sm text-gray-600 dark:text-gray-400 mb-3">
4344
{quiz.description}
4445
</p>
4546
)}
@@ -49,27 +50,28 @@ export function QuizCard({ quiz, userProgress }: QuizCardProps) {
4950
⏱️ {Math.floor((quiz.timeLimitSeconds ?? quiz.questionsCount * 30) / 60)} {t('min')}
5051
</span>
5152
</div>
53+
</div>
5254
{userProgress && (
53-
<div className="mb-4">
54-
<div className="flex justify-between text-xs mb-1.5">
55-
<span className="text-gray-600 dark:text-gray-400">
56-
{t('best')} {userProgress.bestScore}/{userProgress.totalQuestions}
57-
</span>
58-
<span className="font-medium text-gray-900 dark:text-gray-100">
59-
{percentage}%
60-
</span>
61-
</div>
62-
<div className="h-1.5 bg-gray-200 dark:bg-neutral-800 rounded-full overflow-hidden">
63-
<div
64-
className="h-full bg-blue-600 rounded-full transition-all"
65-
style={{ width: `${percentage}%` }}
66-
/>
67-
</div>
68-
<p className="text-xs text-gray-500 mt-1">
69-
{userProgress.attemptsCount} {userProgress.attemptsCount === 1 ? t('attempt') : t('attempts')}
70-
</p>
71-
</div>
72-
)}
55+
<div className="mb-6">
56+
<div className="flex justify-between text-xs mb-1.5">
57+
<span className="text-gray-600 dark:text-gray-400">
58+
{t('best')} {userProgress.bestScore}/{userProgress.totalQuestions}
59+
</span>
60+
<span className="text-gray-500">
61+
{userProgress.attemptsCount} {userProgress.attemptsCount === 1 ? t('attempt') : t('attempts')}
62+
</span>
63+
<span className="font-medium text-gray-900 dark:text-gray-100">
64+
{percentage}%
65+
</span>
66+
</div>
67+
<div className="h-1.5 bg-gray-200 dark:bg-neutral-800 rounded-full overflow-hidden">
68+
<div
69+
className="h-full bg-blue-600 rounded-full transition-all"
70+
style={{ width: `${percentage}%` }}
71+
/>
72+
</div>
73+
</div>
74+
)}
7375
<Link
7476
href={`/quiz/${quiz.slug}`}
7577
className="block w-full text-center rounded-lg bg-blue-600 text-white px-4 py-2.5 text-sm font-medium hover:bg-blue-500 transition-colors"

0 commit comments

Comments
 (0)