Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions frontend/app/[locale]/quiz/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { getQuizBySlug, getQuizQuestionsRandomized } from '@/db/queries/quiz';
import { notFound } from 'next/navigation';
import { getTranslations } from 'next-intl/server';
import { QuizContainer } from '@/components/quiz/QuizContainer';
import { PendingResultHandler } from '@/components/quiz/PendingResultHandler';

Expand All @@ -11,6 +12,7 @@ interface QuizPageProps {

export default async function QuizPage({ params }: QuizPageProps) {
const { locale, slug } = await params;
const t = await getTranslations({ locale, namespace: 'quiz.page' });

const user = await getCurrentUser();

Expand All @@ -22,11 +24,11 @@ export default async function QuizPage({ params }: QuizPageProps) {

const seed = Date.now();
const questions = await getQuizQuestionsRandomized(quiz.id, locale, seed);

if (!questions.length) {
return (
<div className="min-h-screen flex items-center justify-center">
<p className="text-gray-600">Немає питань для цього квізу</p>
<p className="text-gray-600">{t('noQuestions')}</p>
</div>
);
}
Expand All @@ -44,9 +46,9 @@ export default async function QuizPage({ params }: QuizPageProps) {
</p>
)}
<div className="mt-4 flex gap-4 text-sm text-gray-500">
<span>Питань: {quiz.questionsCount}</span>
<span>{t('questionsLabel')}: {quiz.questionsCount}</span>
<span>
Час: {Math.floor((quiz.timeLimitSeconds ?? questions.length * 30) / 60)} хв
{t('timeLabel')}: {Math.floor((quiz.timeLimitSeconds ?? questions.length * 30) / 60)} {t('minutes')}
</span>
</div>
</div>
Expand Down
14 changes: 8 additions & 6 deletions frontend/app/[locale]/quizzes/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { getActiveQuizzes, getUserQuizzesProgress } from '@/db/queries/quiz';
import { getCurrentUser } from '@/lib/auth';
import { getTranslations } from 'next-intl/server';
import QuizzesSection from '@/components/quiz/QuizzesSection';

type PageProps = { params: Promise<{ locale: string }> };
Expand All @@ -8,8 +9,9 @@ export const dynamic = 'force-dynamic';

export default async function QuizzesPage({ params }: PageProps) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'quiz.list' });
const session = await getCurrentUser();

const quizzes = await getActiveQuizzes(locale);

let userProgressMap: Record<string, any> = {};
Expand All @@ -22,9 +24,9 @@ export default async function QuizzesPage({ params }: PageProps) {
if (!quizzes.length) {
return (
<div className="mx-auto max-w-5xl py-12">
<h1 className="text-3xl font-bold mb-4">Quizzes</h1>
<h1 className="text-3xl font-bold mb-4">{t('title')}</h1>
<p className="text-gray-600 dark:text-gray-400">
No quizzes available yet. Please check back soon.
{t('noQuizzes')}
</p>
</div>
);
Expand All @@ -34,11 +36,11 @@ export default async function QuizzesPage({ params }: PageProps) {
<div className="mx-auto max-w-5xl py-12">
<div className="mb-8">
<p className="text-sm text-blue-600 dark:text-blue-400 font-semibold">
Practice
{t('practice')}
</p>
<h1 className="text-3xl font-bold">Quizzes</h1>
<h1 className="text-3xl font-bold">{t('title')}</h1>
<p className="text-gray-600 dark:text-gray-400">
Choose a quiz to test your knowledge.
{t('subtitle')}
</p>
</div>

Expand Down
15 changes: 9 additions & 6 deletions frontend/components/q&a/Pagination.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client';

import { useTranslations } from 'next-intl';
import { cn } from '@/lib/utils';

interface PaginationProps {
Expand All @@ -13,6 +14,8 @@ export function Pagination({
totalPages,
onPageChange,
}: PaginationProps) {
const t = useTranslations('qa.pagination');

if (totalPages <= 1) return null;

const getPageNumbers = (): (number | 'ellipsis')[] => {
Expand Down Expand Up @@ -57,7 +60,7 @@ export function Pagination({
return (
<nav
className="flex items-center justify-center gap-1 mt-8"
aria-label="Пагінація"
aria-label={t('label')}
>
<button
onClick={() => onPageChange(currentPage - 1)}
Expand All @@ -69,9 +72,9 @@ export function Pagination({
? 'text-gray-400 dark:text-gray-600 cursor-not-allowed'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
)}
aria-label="Попередня сторінка"
aria-label={t('previousPage')}
>
Назад
{t('previous')}
</button>

<div className="flex items-center gap-1 mx-2">
Expand All @@ -94,7 +97,7 @@ export function Pagination({
? 'bg-blue-600 text-white'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
)}
aria-label={`Сторінка ${page}`}
aria-label={t('page', { page })}
aria-current={page === currentPage ? 'page' : undefined}
>
{page}
Expand All @@ -113,9 +116,9 @@ export function Pagination({
? 'text-gray-400 dark:text-gray-600 cursor-not-allowed'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
)}
aria-label="Наступна сторінка"
aria-label={t('nextPage')}
>
Вперед
{t('next')}
</button>
</nav>
);
Expand Down
6 changes: 4 additions & 2 deletions frontend/components/quiz/CountdownTimer.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { cn } from '@/lib/utils';

interface CountdownTimerProps {
Expand All @@ -14,6 +15,7 @@ export function CountdownTimer({
onTimeUp,
isActive,
}: CountdownTimerProps) {
const t = useTranslations('quiz.timer');
const [endTime] = useState(() => Date.now() + timeLimitSeconds * 1000);
const [remainingSeconds, setRemainingSeconds] = useState(timeLimitSeconds);

Expand Down Expand Up @@ -79,7 +81,7 @@ export function CountdownTimer({
percentage <= 10 && 'animate-pulse'
)}>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">Залишилось часу:</span>
<span className="text-sm font-medium">{t('label')}</span>
<span className="text-2xl font-bold font-mono">
{String(minutes).padStart(2, '0')}:{String(seconds).padStart(2, '0')}
</span>
Expand All @@ -97,7 +99,7 @@ export function CountdownTimer({

{percentage <= 30 && (
<p className="text-xs mt-2 font-medium">
{percentage <= 10 ? '⚠️ Час майже закінчився!' : '⏰ Поспішайте!'}
{percentage <= 10 ? t('almostDone') : t('hurryUp')}
</p>
)}
</div>
Expand Down
16 changes: 9 additions & 7 deletions frontend/components/quiz/QuizCard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client';

import { useTranslations } from 'next-intl';
import { Link } from '@/i18n/routing';
import { Badge } from '@/components/ui/badge';

Expand All @@ -21,16 +22,17 @@ interface QuizCardProps {
}

export function QuizCard({ quiz, userProgress }: QuizCardProps) {
const t = useTranslations('quiz.card');
const percentage = userProgress && userProgress.totalQuestions > 0
? Math.round((userProgress.bestScore / userProgress.totalQuestions) * 100)
: 0;

return (
<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">
<div className="flex gap-2 mb-3">
<Badge variant="blue">{quiz.categoryName ?? 'Uncategorized'}</Badge>
<Badge variant="blue">{quiz.categoryName ?? t('uncategorized')}</Badge>
{userProgress && (
<Badge variant="success">✓ Completed</Badge>
<Badge variant="success">{t('completed')}</Badge>
)}
</div>
<h2 className="text-xl font-semibold mb-2">
Expand All @@ -42,16 +44,16 @@ export function QuizCard({ quiz, userProgress }: QuizCardProps) {
</p>
)}
<div className="flex gap-3 text-xs text-gray-500 mb-3">
<span>📝 {quiz.questionsCount} questions</span>
<span>📝 {quiz.questionsCount} {t('questions')}</span>
<span>
⏱️ {Math.floor((quiz.timeLimitSeconds ?? quiz.questionsCount * 30) / 60)} min
⏱️ {Math.floor((quiz.timeLimitSeconds ?? quiz.questionsCount * 30) / 60)} {t('min')}
</span>
</div>
{userProgress && (
<div className="mb-4">
<div className="flex justify-between text-xs mb-1.5">
<span className="text-gray-600 dark:text-gray-400">
Best: {userProgress.bestScore}/{userProgress.totalQuestions}
{t('best')} {userProgress.bestScore}/{userProgress.totalQuestions}
</span>
<span className="font-medium text-gray-900 dark:text-gray-100">
{percentage}%
Expand All @@ -64,15 +66,15 @@ export function QuizCard({ quiz, userProgress }: QuizCardProps) {
/>
</div>
<p className="text-xs text-gray-500 mt-1">
{userProgress.attemptsCount} {userProgress.attemptsCount === 1 ? 'attempt' : 'attempts'}
{userProgress.attemptsCount} {userProgress.attemptsCount === 1 ? t('attempt') : t('attempts')}
</p>
</div>
)}
<Link
href={`/quiz/${quiz.slug}`}
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"
>
{userProgress ? 'Retake Quiz' : 'Start Quiz'}
{userProgress ? t('retake') : t('start')}
</Link>
</div>
);
Expand Down
32 changes: 15 additions & 17 deletions frontend/components/quiz/QuizContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useLocale } from 'next-intl';
import { useLocale, useTranslations } from 'next-intl';
import { savePendingQuizResult } from '@/lib/guest-quiz';
import { useReducer, useTransition } from 'react';
import { useAntiCheat } from '@/hooks/useAntiCheat';
Expand Down Expand Up @@ -114,6 +114,7 @@ export function QuizContainer({
timeLimitSeconds,
onBackToTopics,
}: QuizContainerProps) {
const tRules = useTranslations('quiz.rules');
const [isPending, startTransition] = useTransition();
const [state, dispatch] = useReducer(quizReducer, {
status: 'rules',
Expand Down Expand Up @@ -230,59 +231,56 @@ const locale = useLocale();
return (
<div className="max-w-2xl mx-auto space-y-6 p-6 rounded-xl border border-gray-200 dark:border-gray-800">
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
Правила проходження квізу
{tRules('title')}
</h2>

<div className="space-y-4 text-gray-700 dark:text-gray-300">
<div className="flex gap-3">
<span className="text-xl">📝</span>
<div>
<p className="font-medium">Загальні правила</p>
<p className="font-medium">{tRules('general.title')}</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
Відповідайте на питання чесно. Кожне питання має тільки одну
правильну відповідь.
{tRules('general.description')}
</p>
</div>
</div>

<div className="flex gap-3">
<span className="text-xl">🚫</span>
<div>
<p className="font-medium">Заборонено</p>
<p className="font-medium">{tRules('forbidden.title')}</p>
<ul className="text-sm text-gray-600 dark:text-gray-400 list-disc list-inside space-y-1">
<li>Копіювання та вставка тексту</li>
<li>Використання контекстного меню (права кнопка миші)</li>
<li>Переключення на інші вкладки або програми</li>
<li>Використання сторонніх джерел інформації</li>
<li>{tRules('forbidden.copyPaste')}</li>
<li>{tRules('forbidden.contextMenu')}</li>
<li>{tRules('forbidden.tabSwitch')}</li>
<li>{tRules('forbidden.externalSources')}</li>
</ul>
</div>
</div>

<div className="flex gap-3">
<span className="text-xl">⚠️</span>
<div>
<p className="font-medium">Система контролю</p>
<p className="font-medium">{tRules('control.title')}</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
Порушення правил фіксуються автоматично. При 3+ порушеннях
результат не зараховується до рейтингу.
{tRules('control.description')}
</p>
</div>
</div>

<div className="flex gap-3">
<span className="text-xl">⏱️</span>
<div>
<p className="font-medium">Час проходження</p>
<p className="font-medium">{tRules('time.title')}</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
Мінімальний час: {totalQuestions * 3} секунд (по 3 секунди на
питання). Занадто швидке проходження не зараховується.
{tRules('time.description', { seconds: totalQuestions * 3 })}
</p>
</div>
</div>
</div>

<Button onClick={handleStart} className="w-full" size="lg">
Почати квіз
{tRules('startButton')}
</Button>
</div>
);
Expand Down
4 changes: 3 additions & 1 deletion frontend/components/quiz/QuizProgress.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client';

import { useTranslations } from 'next-intl';
import { cn } from '@/lib/utils';

interface Answer {
Expand Down Expand Up @@ -46,13 +47,14 @@ function getVisibleIndices(current: number, total: number): (number | 'ellipsis'
}

export function QuizProgress({ current, total, answers }: QuizProgressProps) {
const t = useTranslations('quiz.progress');
const visibleIndices = getVisibleIndices(current, total);

return (
<div className="space-y-4">
<div className="text-center">
<span className="text-sm font-medium text-gray-600 dark:text-gray-400">
Питання {current + 1} / {total}
{t('label', { current: current + 1, total })}
</span>
</div>

Expand Down
Loading