Skip to content

Commit 099076e

Browse files
LesiaUKRViktorSvertokaTiZoriiYNazymko12
authored
(SP: 3) [Frontend] Quiz Results Statistics Dashboard (#304)
* chore(docs): update contact email and license year * chore(seo): add OG/Twitter metadata for social link previews * feat(quiz): add Redis cache for quiz questions - Create types/quiz.ts with shared quiz domain types - Add getOrCreateQuestionsCache() to cache questions per quiz/locale - Integrate cache into getQuizQuestions() with DB fallback * fix(quiz): improve Redis cache resilience - Cache empty quiz results to prevent repeated DB queries - Add try/catch around redis.set() for graceful degradation - Redis failures no longer break requests when DB data is available * fix(quiz): add graceful Redis error handling * fix(quiz): remove unused API route - Remove /api/quiz/[slug] route (unused, exposed isCorrect) - Remove related test file quiz-slug-route.test.ts * fix(meta): ensure OG preview on locale home * fix(meta): add locale canonical and OG URL * fix(meta): localize OG image alt text * fix(quiz): wrap Redis cache reads in try/catch for graceful fallback Add error handling to redis.get() calls in: - getOrCreateQuizAnswersCache - getCorrectAnswer - getOrCreateQuestionsCache * fix(meta): localize OG image alt text * fix(meta): localize text * feat(dashboard): explained terms card, layout fixes, support link * Translation fix * 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) * 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) * fix(quiz): fall through to IP when auth cookie is expired/invalid * feat(quiz): add guest warning before start and bot protection (#297) * (SP: 1) [Website] Add humans.txt with team and project info (#299) * feat(txt): add humans.txt with team and project information * fix(txt): update humans.txt with team and project information * fix(txt): update humans.txt * feat: add navigation loading states and responsive GitHub button (#301) * feat(quiz): add quiz results dashboard and review page - Add quiz history section to dashboard with last attempt per quiz - Add review page showing incorrect questions with explanations - Add collapsible cards with expand/collapse all toggle - Add "Review Mistakes" button on quiz result screen - Add category icons to quiz page and review page headers - Add BookOpen icon to explanation block in QuizQuestion - Update guest message to mention error review benefit - Add i18n translations (en/uk/pl) for all new features --------- Co-authored-by: Viktor Svertoka <victor.svertoka@gmail.com> Co-authored-by: tetiana zorii <tanyusha.zoriy@gmail.com> Co-authored-by: Yuliia Nazymko <122815071+YNazymko12@users.noreply.github.com>
1 parent 6c64baa commit 099076e

55 files changed

Lines changed: 2517 additions & 548 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CODE_OF_CONDUCT.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ representative at an online or offline event.
6060

6161
Instances of abusive, harassing, or otherwise unacceptable behavior may be
6262
reported to the community leaders responsible for enforcement at
63-
[devlovers.net@gmail.com](mailto:devlovers.net@gmail.com). All complaints will
63+
[contact@devlovers.net](mailto:contact@devlovers.net). All complaints will
6464
be reviewed and investigated promptly and fairly.
6565

6666
All community leaders are obligated to respect the privacy and security of the

LICENSE.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
MIT License
22

3-
Copyright (c) 2025 DevLovers
3+
Copyright (c) 2026 DevLovers
44

55
Permission is hereby granted, free of charge, to any person obtaining a copy
66
of this software and associated documentation files (the "Software"), to deal

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,3 +154,5 @@ Task tracking via [GitHub Projects](https://github.com/DevLoversTeam/devlovers.n
154154
## License
155155

156156
**MIT**
157+
158+
**Contact me:** [contact@devlovers.net](mailto:contact@devlovers.net)

SECURITY.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,12 @@ appreciate your help in resolving it responsibly. Here's how you can report it:
77

88
1. **Contact me:**
99
Please send an email to
10-
**[devlovers.net@gmail.com](mailto:devlovers.net@gmail.com)** with:
11-
10+
**[contact@devlovers.net](mailto:contact@devlovers.net)** with:
1211
- A clear description of the issue.
1312
- Steps to reproduce the vulnerability.
1413
- Any relevant code or logs for better understanding.
1514

1615
2. **Handling process:**
17-
1816
- I will acknowledge your report within **48 hours**.
1917
- Together, we will work on resolving the issue as quickly as possible.
2018

frontend/README.md

Lines changed: 0 additions & 49 deletions
This file was deleted.

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]/dashboard/page.tsx

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { getTranslations } from 'next-intl/server';
22

33
import { PostAuthQuizSync } from '@/components/auth/PostAuthQuizSync';
4+
import { QuizResultsSection } from '@/components/dashboard/QuizResultsSection';
5+
import { ExplainedTermsCard } from '@/components/dashboard/ExplainedTermsCard';
46
import { ProfileCard } from '@/components/dashboard/ProfileCard';
57
import { QuizSavedBanner } from '@/components/dashboard/QuizSavedBanner';
68
import { StatsCard } from '@/components/dashboard/StatsCard';
79
import { DynamicGridBackground } from '@/components/shared/DynamicGridBackground';
8-
import { getUserQuizStats } from '@/db/queries/quiz';
10+
import { getUserLastAttemptPerQuiz, getUserQuizStats } from '@/db/queries/quiz';
911
import { getUserProfile } from '@/db/queries/users';
1012
import { redirect } from '@/i18n/routing';
1113
import { getCurrentUser } from '@/lib/auth';
@@ -45,6 +47,7 @@ export default async function DashboardPage({
4547
const t = await getTranslations('dashboard');
4648

4749
const attempts = await getUserQuizStats(session.id);
50+
const lastAttempts = await getUserLastAttemptPerQuiz(session.id, locale);
4851

4952
const totalAttempts = attempts.length;
5053

@@ -82,10 +85,9 @@ export default async function DashboardPage({
8285
<div className="min-h-screen">
8386
<PostAuthQuizSync />
8487
<DynamicGridBackground
85-
showStaticGrid
86-
className="min-h-screen bg-gray-50 py-12 transition-colors duration-300 dark:bg-transparent"
88+
className="min-h-screen bg-gray-50 py-10 transition-colors duration-300 dark:bg-transparent"
8789
>
88-
<main className="relative z-10 mx-auto max-w-5xl px-6">
90+
<main className="relative z-10 mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
8991
<header className="mb-12 flex flex-col justify-between gap-6 md:flex-row md:items-center">
9092
<div>
9193
<h1 className="text-4xl font-black tracking-tight md:text-5xl">
@@ -96,12 +98,25 @@ export default async function DashboardPage({
9698
</p>
9799
</div>
98100

99-
<span className={outlineBtnStyles}>{t('supportLink')}</span>
101+
<a
102+
href="https://t.me/devloversteam"
103+
target="_blank"
104+
rel="noopener noreferrer"
105+
className={outlineBtnStyles}
106+
>
107+
{t('supportLink')}
108+
</a>
100109
</header>
101110
<QuizSavedBanner />
102111
<div className="grid gap-8 md:grid-cols-2">
103112
<ProfileCard user={userForDisplay} locale={locale} />
104113
<StatsCard stats={stats} />
114+
</div>
115+
<div className="mt-8">
116+
<ExplainedTermsCard />
117+
</div>
118+
<div className="mt-8">
119+
<QuizResultsSection attempts={lastAttempts} locale={locale} />
105120
</div>
106121
</main>
107122
</DynamicGridBackground>
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import Image from 'next/image';
2+
import { categoryTabStyles } from '@/data/categoryStyles';
3+
4+
import { ArrowLeft, CheckCircle, RotateCcw, SearchX } from 'lucide-react';
5+
import { getTranslations } from 'next-intl/server';
6+
import { cn } from '@/lib/utils';
7+
8+
import { QuizReviewList } from '@/components/dashboard/QuizReviewList';
9+
import { DynamicGridBackground } from '@/components/shared/DynamicGridBackground';
10+
import { getAttemptReviewDetails } from '@/db/queries/quiz';
11+
import { Link, redirect } from '@/i18n/routing';
12+
import { getCurrentUser } from '@/lib/auth';
13+
14+
export async function generateMetadata({
15+
params,
16+
}: {
17+
params: Promise<{ locale: string }>;
18+
}) {
19+
const { locale } = await params;
20+
const t = await getTranslations({ locale, namespace: 'dashboard.quizReview' });
21+
22+
return {
23+
title: t('title'),
24+
};
25+
}
26+
27+
export default async function QuizReviewPage({
28+
params,
29+
}: {
30+
params: Promise<{ locale: string; attemptId: string }>;
31+
}) {
32+
const session = await getCurrentUser();
33+
const { locale, attemptId } = await params;
34+
35+
if (!session) {
36+
redirect({ href: '/login', locale });
37+
return;
38+
}
39+
40+
const t = await getTranslations('dashboard.quizReview');
41+
const review = await getAttemptReviewDetails(attemptId, session.id, locale);
42+
43+
const cardStyles =
44+
'relative overflow-hidden rounded-2xl border border-gray-100 dark:border-white/5 bg-white/60 dark:bg-neutral-900/60 backdrop-blur-xl p-6 sm:p-8';
45+
46+
const btnOutline =
47+
'inline-flex items-center gap-2 rounded-full border border-gray-200 dark:border-white/10 bg-white/50 dark:bg-neutral-900/50 backdrop-blur-sm px-6 py-2.5 text-sm font-medium text-gray-600 dark:text-gray-300 transition-colors hover:bg-white hover:text-[var(--accent-primary)] dark:hover:bg-neutral-800 dark:hover:text-[var(--accent-primary)]';
48+
49+
const btnPrimary =
50+
'inline-flex items-center gap-2 rounded-full px-6 py-2.5 text-sm font-semibold text-white bg-[var(--accent-primary)] hover:bg-[var(--accent-hover)] transition-all hover:scale-105';
51+
52+
// Not found / not owned
53+
if (!review) {
54+
return (
55+
<DynamicGridBackground className="min-h-screen bg-gray-50 py-10 dark:bg-transparent">
56+
<main className="relative z-10 mx-auto max-w-4xl px-4 sm:px-6">
57+
<div className={`${cardStyles} text-center`}>
58+
<SearchX className="mx-auto mb-4 h-10 w-10 text-gray-400" />
59+
<h2 className="mb-2 text-xl font-bold text-gray-900 dark:text-white">
60+
{t('notFound')}
61+
</h2>
62+
<Link href="/dashboard" className={btnOutline}>
63+
<ArrowLeft className="h-4 w-4" />
64+
{t('backToDashboard')}
65+
</Link>
66+
</div>
67+
</main>
68+
</DynamicGridBackground>
69+
);
70+
}
71+
const slug = review.categorySlug;
72+
const categoryStyle =
73+
slug && slug in categoryTabStyles
74+
? categoryTabStyles[slug as keyof typeof categoryTabStyles]
75+
: null;
76+
77+
const incorrectCount = review.incorrectQuestions.length;
78+
79+
// All correct
80+
if (incorrectCount === 0) {
81+
return (
82+
<DynamicGridBackground className="min-h-screen bg-gray-50 py-10 dark:bg-transparent">
83+
<main className="relative z-10 mx-auto max-w-4xl px-4 sm:px-6">
84+
<div className={`${cardStyles} text-center`}>
85+
<CheckCircle className="mx-auto mb-4 h-10 w-10 text-emerald-500" />
86+
<h2 className="mb-2 text-xl font-bold text-gray-900 dark:text-white">
87+
{t('allCorrect')}
88+
</h2>
89+
<p className="mb-6 text-gray-500 dark:text-gray-400">
90+
{t('allCorrectHint')}
91+
</p>
92+
<div className="flex flex-col items-center gap-3 sm:flex-row sm:justify-center">
93+
<Link href="/dashboard" className={btnOutline}>
94+
<ArrowLeft className="h-4 w-4" />
95+
{t('backToDashboard')}
96+
</Link>
97+
<Link href={`/quiz/${review.quizSlug}`} className={btnPrimary}>
98+
<RotateCcw className="h-4 w-4" />
99+
{t('retakeQuiz')}
100+
</Link>
101+
</div>
102+
</div>
103+
</main>
104+
</DynamicGridBackground>
105+
);
106+
}
107+
108+
// Main review
109+
return (
110+
<DynamicGridBackground className="min-h-screen bg-gray-50 py-10 dark:bg-transparent">
111+
<main className="relative z-10 mx-auto max-w-4xl px-4 sm:px-6">
112+
<header className="mb-8">
113+
<div className="flex items-center gap-3">
114+
{categoryStyle && (
115+
<span className="relative h-8 w-8 shrink-0 sm:h-10 sm:w-10">
116+
<Image
117+
src={categoryStyle.icon}
118+
alt=""
119+
fill
120+
className={cn('object-contain', 'iconClassName' in categoryStyle && categoryStyle.iconClassName)}
121+
/>
122+
</span>
123+
)}
124+
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
125+
{review.quizTitle ?? review.quizSlug}
126+
</h1>
127+
</div>
128+
<p className="mt-2 text-lg text-gray-600 dark:text-gray-400">
129+
{t('title')} &mdash;{' '}
130+
{t('subtitle', {
131+
incorrect: incorrectCount,
132+
total: review.totalQuestions,
133+
})}
134+
</p>
135+
</header>
136+
<QuizReviewList
137+
questions={review.incorrectQuestions}
138+
accentColor={categoryStyle?.accent}
139+
/>
140+
<div className="mt-8 flex flex-col items-center gap-3 sm:flex-row sm:justify-center">
141+
<Link href="/dashboard" className={btnOutline}>
142+
<ArrowLeft className="h-4 w-4" />
143+
{t('backToDashboard')}
144+
</Link>
145+
<Link href={`/quiz/${review.quizSlug}`} className={btnPrimary}>
146+
<RotateCcw className="h-4 w-4" />
147+
{t('retakeQuiz')}
148+
</Link>
149+
</div>
150+
</main>
151+
</DynamicGridBackground>
152+
);
153+
}

0 commit comments

Comments
 (0)