Skip to content

Commit dcd8c0f

Browse files
authored
(SP: 2) [Frontend] Quiz UX improvements: violations counter, breadcrumbs, status badges (#320)
* 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 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 * fix(quiz): scroll to next button on answer reveal, scope review cache by userId * fix(quiz): restore type imports and userId cache key after merge conflict * fix: restore type imports, sync @swc/helpers, fix indentation after merge * feat(quiz): add violations counter UI, fix disqualification threshold - Add ViolationsCounter component with color escalation (green/yellow/red) - Sticky top bar keeps counter visible on scroll (mobile/tablet) - Add i18n counter keys for en/uk/pl with ICU plural forms - Fix threshold bug: violations warning now triggers at 4+ (was 3+) to match actual integrity score calculation (100 - violations * 10 < 70) * fix(quiz): fix points mismatch between leaderboard and dashboard Dashboard showed raw pointsEarned from last quiz_attempt, while leaderboard summed improvement deltas from point_transactions. Additionally, orphaned transactions from re-seeded quizzes inflated leaderboard totals (12 rows, 83 ghost points cleaned up in DB). - Dashboard query now joins point_transactions to show actual awarded points per quiz instead of raw attempt score - Leaderboard query filters out orphaned transactions where the source attempt no longer exists in quiz_attempts * OBfix(quiz): fix points mismatch, consistent status badges, mobile UX Dashboard showed raw pointsEarned from last attempt while leaderboard summed improvement deltas from point_transactions. Orphaned transactions from re-seeded quizzes inflated leaderboard totals (cleaned up in DB). - Dashboard query joins point_transactions for actual awarded points - Leaderboard query filters orphaned transactions (source_id not in quiz_attempts) - Quiz cards use 3-level badges (Mastered/Review/Study) matching dashboard - Mobile quiz results show dash for zero points, added chevron indicator * fix(quiz): add breadcrumbs to review page, fix recommendation tautology
1 parent 1165c15 commit dcd8c0f

12 files changed

Lines changed: 152 additions & 53 deletions

File tree

frontend/app/[locale]/dashboard/quiz-review/[attemptId]/page.tsx

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
1-
import Image from 'next/image';
2-
import { categoryTabStyles } from '@/data/categoryStyles';
3-
41
import { ArrowLeft, CheckCircle, RotateCcw, SearchX } from 'lucide-react';
2+
import Image from 'next/image';
53
import { getTranslations } from 'next-intl/server';
6-
import { cn } from '@/lib/utils';
74

85
import { QuizReviewList } from '@/components/dashboard/QuizReviewList';
96
import { DynamicGridBackground } from '@/components/shared/DynamicGridBackground';
7+
import { categoryTabStyles } from '@/data/categoryStyles';
108
import { getAttemptReviewDetails } from '@/db/queries/quiz';
119
import { Link, redirect } from '@/i18n/routing';
1210
import { getCurrentUser } from '@/lib/auth';
11+
import { cn } from '@/lib/utils';
1312

1413
export async function generateMetadata({
1514
params,
@@ -38,6 +37,7 @@ export default async function QuizReviewPage({
3837
}
3938

4039
const t = await getTranslations('dashboard.quizReview');
40+
const tNav = await getTranslations('navigation');
4141
const review = await getAttemptReviewDetails(attemptId, session.id, locale);
4242

4343
const cardStyles =
@@ -109,6 +109,21 @@ export default async function QuizReviewPage({
109109
return (
110110
<DynamicGridBackground className="min-h-screen bg-gray-50 py-10 dark:bg-transparent">
111111
<main className="relative z-10 mx-auto max-w-4xl px-4 sm:px-6">
112+
<nav className="mb-4" aria-label="Breadcrumb">
113+
<ol className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
114+
<li className="flex items-center gap-2">
115+
<Link href="/dashboard" className="underline-offset-4 transition hover:text-[var(--accent-primary)] hover:underline">
116+
{tNav('dashboard')}
117+
</Link>
118+
<span>&gt;</span>
119+
</li>
120+
<li>
121+
<span className="text-[var(--accent-primary)]" aria-current="page">
122+
{review.quizTitle ?? review.quizSlug}
123+
</span>
124+
</li>
125+
</ol>
126+
</nav>
112127
<header className="mb-8">
113128
<div className="flex items-center gap-3">
114129
{categoryStyle && (

frontend/components/dashboard/QuizResultRow.tsx

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22

33
import { ChevronRight, Shield } from 'lucide-react';
44
import Image from 'next/image';
5-
import { useTranslations } from 'next-intl';
65
import { useRouter } from 'next/navigation';
6+
import { useTranslations } from 'next-intl';
77

88
import { Badge, type BadgeProps } from '@/components/ui/badge';
9-
import { categoryTabStyles, type CategoryTabStyle } from '@/data/categoryStyles';
9+
import { type CategoryTabStyle,categoryTabStyles } from '@/data/categoryStyles';
1010
import type { UserLastAttempt } from '@/types/quiz';
1111

1212
interface QuizResultRowProps {
@@ -96,7 +96,7 @@ export function QuizResultRow({ attempt, locale }: QuizResultRowProps) {
9696
onKeyDown={isMastered ? undefined : (e) => { if (e.key === 'Enter') handleClick(); }}
9797
>
9898
{/* Mobile layout: left content + right badge */}
99-
<div className="flex items-center gap-3 md:hidden">
99+
<div className="flex items-center gap-3 sm:grid sm:grid-cols-[minmax(0,2fr)_1fr_auto_20px] md:hidden">
100100
<div className="min-w-0 flex-1 space-y-1.5">
101101
<div className="flex items-center gap-2.5">
102102
{catStyle && (
@@ -120,33 +120,36 @@ export function QuizResultRow({ attempt, locale }: QuizResultRowProps) {
120120
<span className="sm:hidden tabular-nums">{attempt.score}/{attempt.totalQuestions}</span>
121121
<span className="sm:hidden text-gray-200 dark:text-gray-700">&middot;</span>
122122
<span className="sm:hidden tabular-nums">{Math.round(pct)}%</span>
123-
{attempt.pointsEarned > 0 && (
124-
<>
125-
<span className="sm:hidden text-gray-200 dark:text-gray-700">&middot;</span>
126-
<span className="sm:hidden font-medium text-emerald-600 dark:text-emerald-400">
127-
+{attempt.pointsEarned}
128-
</span>
129-
</>
123+
<span className="sm:hidden text-gray-200 dark:text-gray-700">&middot;</span>
124+
{attempt.pointsEarned > 0 ? (
125+
<span className="sm:hidden font-medium text-emerald-600 dark:text-emerald-400">
126+
+{attempt.pointsEarned}
127+
</span>
128+
) : (
129+
<span className="sm:hidden text-gray-300 dark:text-gray-600">&mdash;</span>
130130
)}
131131
</div>
132132
</div>
133133
<div className="hidden flex-1 items-center justify-center gap-2 text-xs text-gray-400 sm:flex md:hidden">
134134
<span className="tabular-nums">{attempt.score}/{attempt.totalQuestions}</span>
135135
<span className="text-gray-200 dark:text-gray-700">&middot;</span>
136136
<span className="tabular-nums">{Math.round(pct)}%</span>
137-
{attempt.pointsEarned > 0 && (
138-
<>
139-
<span className="text-gray-200 dark:text-gray-700">&middot;</span>
140-
<span className="font-medium text-emerald-600 dark:text-emerald-400">
141-
+{attempt.pointsEarned}
142-
</span>
143-
</>
137+
<span className="text-gray-200 dark:text-gray-700">&middot;</span>
138+
{attempt.pointsEarned > 0 ? (
139+
<span className="font-medium text-emerald-600 dark:text-emerald-400">
140+
+{attempt.pointsEarned}
141+
</span>
142+
) : (
143+
<span className="text-gray-300 dark:text-gray-600">&mdash;</span>
144144
)}
145145
</div>
146146
<Badge variant={status.variant} className="shrink-0 gap-1.5 rounded-full">
147147
<span className={`h-1.5 w-1.5 rounded-full ${status.dotColor}`} />
148148
{t(status.label)}
149149
</Badge>
150+
{!isMastered && (
151+
<ChevronRight className="h-4 w-4 shrink-0 text-gray-300 transition-transform duration-300 group-hover:translate-x-0.5 group-hover:text-[var(--accent-primary)] dark:text-gray-600" />
152+
)}
150153
</div>
151154

152155
{/* Desktop layout — CSS Grid */}

frontend/components/quiz/QuizCard.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,18 @@ export function QuizCard({ quiz, userProgress }: QuizCardProps) {
4444
? Math.round((userProgress.bestScore / userProgress.totalQuestions) * 100)
4545
: 0;
4646

47+
const getStatusBadge = () => {
48+
if (!userProgress) return null;
49+
if (percentage === 100)
50+
return { variant: 'success' as const, label: t('mastered'), dot: 'bg-emerald-500' };
51+
if (percentage >= 70)
52+
return { variant: 'warning' as const, label: t('needsReview'), dot: 'bg-amber-500' };
53+
return { variant: 'danger' as const, label: t('study'), dot: 'bg-red-500' };
54+
};
55+
56+
const statusBadge = getStatusBadge();
57+
58+
4759
const handleStart = () => {
4860
const seed = makeSeed(); // runs on click, not render
4961
router.push(`/quiz/${quiz.slug}?seed=${seed}`);
@@ -69,7 +81,12 @@ export function QuizCard({ quiz, userProgress }: QuizCardProps) {
6981
>
7082
{quiz.categoryName ?? t('uncategorized')}
7183
</Badge>
72-
{userProgress && <Badge variant="success">{t('completed')}</Badge>}
84+
{statusBadge && (
85+
<Badge variant={statusBadge.variant} className="gap-1.5 rounded-full">
86+
<span className={`h-1.5 w-1.5 rounded-full ${statusBadge.dot}`} />
87+
{statusBadge.label}
88+
</Badge>
89+
)}
7390
</div>
7491
<h2 className="mb-2 text-xl font-semibold">
7592
{quiz.title ?? quiz.slug}

frontend/components/quiz/QuizContainer.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
'use client';
2+
import { ViolationsCounter } from '@/components/quiz/ViolationsCounter';
23
import { Ban, FileText, TriangleAlert, UserRound } from 'lucide-react';
34
import { useRouter, useSearchParams } from 'next/navigation';
45
import { useLocale, useTranslations } from 'next-intl';
@@ -543,7 +544,8 @@ export function QuizContainer({
543544

544545
return (
545546
<div className="no-select space-y-8">
546-
<div className="flex justify-end">
547+
<div className="sticky top-0 z-10 flex items-center justify-between bg-white/80 py-2 backdrop-blur-sm dark:bg-gray-950/80">
548+
<ViolationsCounter count={violationsCount} />
547549
<Button
548550
variant="outline"
549551
size="sm"

frontend/components/quiz/QuizResult.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ export function QuizResult({
129129
</h3>
130130
<p className="text-gray-600 dark:text-gray-400">{motivation.message}</p>
131131
</div>
132-
{violationsCount >= 3 && (
132+
{violationsCount >= 4 && (
133133
<div className="rounded-xl border border-orange-200 bg-orange-50 p-4 dark:border-orange-800 dark:bg-orange-900/20">
134134
<p className="text-center font-medium text-orange-800 dark:text-orange-200">
135135
<TriangleAlert className="inline h-4 w-4" aria-hidden="true" />{' '}
@@ -154,7 +154,7 @@ export function QuizResult({
154154
>
155155
{pointsAwarded > 0
156156
? t('pointsAwarded', { points: pointsAwarded })
157-
: violationsCount >= 3
157+
: violationsCount >= 4
158158
? t('disqualified')
159159
: t('noPointsAwarded')}
160160
</p>
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
'use client';
2+
3+
import { ShieldAlert } from 'lucide-react';
4+
import { useTranslations } from 'next-intl';
5+
6+
import { cn } from '@/lib/utils';
7+
8+
interface ViolationsCounterProps {
9+
count: number;
10+
}
11+
12+
export function ViolationsCounter({ count }: ViolationsCounterProps) {
13+
const t = useTranslations('quiz.antiCheat');
14+
15+
const getColorClasses = () => {
16+
if (count >= 4) {
17+
return 'text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/30 border-red-200 dark:border-red-800';
18+
}
19+
if (count >= 1) {
20+
return 'text-yellow-600 dark:text-yellow-400 bg-yellow-50 dark:bg-yellow-950/30 border-yellow-200 dark:border-yellow-800';
21+
}
22+
return 'text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-800';
23+
};
24+
25+
return (
26+
<div
27+
className={cn(
28+
'inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1 text-sm font-medium transition-colors',
29+
getColorClasses(),
30+
count >= 4 && 'animate-pulse'
31+
)}
32+
>
33+
<ShieldAlert className="h-4 w-4" aria-hidden="true" />
34+
<span className="sm:hidden">{count}</span>
35+
<span className="hidden sm:inline">
36+
{t('counter', { count })}
37+
</span>
38+
</div>
39+
);
40+
}

frontend/db/queries/leaderboard.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
1-
import { desc, eq, sql } from 'drizzle-orm';
1+
import { desc, sql } from 'drizzle-orm';
22
import { unstable_cache } from 'next/cache';
33
import { cache } from 'react';
44

55
import { User } from '@/components/leaderboard/types';
66

77
import { db } from '../index';
8-
import { pointTransactions } from '../schema/points';
98
import { users } from '../schema/users';
109

1110
const getLeaderboardDataCached = unstable_cache(
@@ -15,12 +14,20 @@ const getLeaderboardDataCached = unstable_cache(
1514
id: users.id,
1615
username: users.name,
1716
avatar: users.image,
18-
points: sql<number>`COALESCE(SUM(${pointTransactions.points}), 0)`,
17+
points: sql<number>`COALESCE(pt_valid.total, 0)`,
1918
})
2019
.from(users)
21-
.leftJoin(pointTransactions, eq(pointTransactions.userId, users.id))
22-
.groupBy(users.id, users.name, users.image)
23-
.orderBy(desc(sql`COALESCE(SUM(${pointTransactions.points}), 0)`))
20+
.leftJoin(
21+
sql`(
22+
SELECT pt.user_id, SUM(pt.points)::int AS total
23+
FROM point_transactions pt
24+
WHERE pt.source = 'quiz'
25+
AND (pt.source_id IS NULL OR pt.source_id IN (SELECT id FROM quiz_attempts))
26+
GROUP BY pt.user_id
27+
) pt_valid`,
28+
sql`pt_valid.user_id = ${users.id}`
29+
)
30+
.orderBy(desc(sql`COALESCE(pt_valid.total, 0)`))
2431
.limit(50);
2532

2633
return dbUsers.map((u, index) => {

frontend/db/queries/quiz.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -462,14 +462,20 @@ export async function getUserLastAttemptPerQuiz(
462462
qa.score,
463463
qa.total_questions AS "totalQuestions",
464464
qa.percentage,
465-
qa.points_earned AS "pointsEarned",
465+
COALESCE(pt_sum.total, 0)::int AS "pointsEarned",
466466
qa.integrity_score AS "integrityScore",
467467
qa.completed_at AS "completedAt"
468468
FROM quiz_attempts qa
469469
JOIN quizzes q ON q.id = qa.quiz_id
470470
LEFT JOIN quiz_translations qt ON qt.quiz_id = q.id AND qt.locale = ${locale}
471471
LEFT JOIN categories c ON c.id = q.category_id
472472
LEFT JOIN category_translations ct ON ct.category_id = c.id AND ct.locale = ${locale}
473+
LEFT JOIN (
474+
SELECT (metadata->>'quizId')::uuid AS quiz_id, SUM(points) AS total
475+
FROM point_transactions
476+
WHERE user_id = ${userId} AND source = 'quiz'
477+
GROUP BY (metadata->>'quizId')::uuid
478+
) pt_sum ON pt_sum.quiz_id = qa.quiz_id
473479
WHERE qa.user_id = ${userId}
474480
ORDER BY qa.quiz_id, qa.completed_at DESC
475481
`);

frontend/messages/en.json

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@
184184
"incorrect": "Incorrect",
185185
"explanationLabel": "Explanation:",
186186
"recommendation": {
187-
"title": "Recommendation",
187+
"title": "Tip",
188188
"description": "We recommend reviewing this topic after completing the quiz"
189189
},
190190
"loading": "Loading...",
@@ -239,7 +239,9 @@
239239
},
240240
"card": {
241241
"uncategorized": "Uncategorized",
242-
"completed": "✓ Completed",
242+
"mastered": "Mastered",
243+
"needsReview": "Review",
244+
"study": "Study",
243245
"questions": "questions",
244246
"min": "min",
245247
"best": "Best:",
@@ -262,7 +264,8 @@
262264
"copy": "Copying is not allowed during the quiz",
263265
"paste": "Pasting is not allowed during the quiz",
264266
"contextMenu": "Context menu is not allowed during the quiz",
265-
"tabSwitch": "Tab switch detected"
267+
"tabSwitch": "Tab switch detected",
268+
"counter": "{count} {count, plural, one {violation} other {violations}}"
266269
}
267270
},
268271
"blog": {

frontend/messages/pl.json

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@
184184
"incorrect": "Niepoprawnie",
185185
"explanationLabel": "Wyjaśnienie:",
186186
"recommendation": {
187-
"title": "Rekomendacja",
187+
"title": "Wskazówka",
188188
"description": "Zalecamy przejrzenie tego tematu po zakończeniu quizu"
189189
},
190190
"loading": "Ładowanie...",
@@ -239,7 +239,9 @@
239239
},
240240
"card": {
241241
"uncategorized": "Nieskategoryzowane",
242-
"completed": "✓ Ukończono",
242+
"mastered": "Opanowane",
243+
"needsReview": "Powtórka",
244+
"study": "Nauka",
243245
"questions": "pytań",
244246
"min": "min",
245247
"best": "Najlepszy:",
@@ -262,7 +264,8 @@
262264
"copy": "Kopiowanie jest zabronione podczas quizu",
263265
"paste": "Wklejanie jest zabronione podczas quizu",
264266
"contextMenu": "Menu kontekstowe jest zabronione podczas quizu",
265-
"tabSwitch": "Wykryto przełączenie karty"
267+
"tabSwitch": "Wykryto przełączenie karty",
268+
"counter": "{count} {count, plural, one {naruszenie} few {naruszenia} other {naruszeń}}"
266269
}
267270
},
268271
"blog": {

0 commit comments

Comments
 (0)