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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,4 @@ frontend/.env.bak
frontend/.env*.bak
frontend/.env.bak

tmpclaude-*
10 changes: 7 additions & 3 deletions frontend/components/quiz/CountdownTimer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,21 @@ interface CountdownTimerProps {
timeLimitSeconds: number;
onTimeUp: () => void;
isActive: boolean;
startedAt: Date;
}

export function CountdownTimer({
timeLimitSeconds,
onTimeUp,
isActive,
startedAt,
}: CountdownTimerProps) {
const t = useTranslations('quiz.timer');
const [endTime] = useState(() => Date.now() + timeLimitSeconds * 1000);
const [remainingSeconds, setRemainingSeconds] = useState(timeLimitSeconds);

const endTime = startedAt.getTime() + timeLimitSeconds * 1000;
const [remainingSeconds, setRemainingSeconds] = useState(() =>
Math.max(0, Math.floor((endTime - Date.now()) / 1000))
);

useEffect(() => {
if (!isActive) return;

Expand Down
92 changes: 72 additions & 20 deletions frontend/components/quiz/QuizContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,21 @@

import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect } from 'react';
import { savePendingQuizResult } from '@/lib/quiz/guest-quiz';
import { useReducer, useTransition, useState } from 'react';
import { useReducer, useTransition, useState, useEffect, useCallback } from 'react';
import { toast } from 'sonner';
import { useAntiCheat } from '@/hooks/useAntiCheat';
import { useQuizSession } from '@/hooks/useQuizSession';
import { useQuizGuards } from '@/hooks/useQuizGuards';
import { QuizProgress } from './QuizProgress';
import { QuizQuestion } from './QuizQuestion';
import { QuizResult } from './QuizResult';
import { CountdownTimer } from './CountdownTimer';
import { Button } from '@/components/ui/button';
import { submitQuizAttempt } from '@/actions/quiz';
import { clearQuizSession, type QuizSessionData } from '@/lib/quiz/quiz-session';
import { savePendingQuizResult } from '@/lib/quiz/guest-quiz';
import type { QuizQuestionClient } from '@/db/queries/quiz';
import { ConfirmModal } from '@/components/ui/confirm-modal';
import { Button } from '@/components/ui/button';

interface Answer {
questionId: string;
Expand Down Expand Up @@ -58,12 +58,15 @@ function quizReducer(state: QuizState, action: QuizAction): QuizState {
};

case 'ANSWER_SELECTED':
const answersWithoutThisQuestion = state.answers.filter(
a => a.questionId !== action.payload.questionId
);
return {
...state,
selectedAnswerId: action.payload.answerId,
questionStatus: 'revealed',
answers: [
...state.answers,
...answersWithoutThisQuestion,
{
questionId: action.payload.questionId,
selectedAnswerId: action.payload.answerId,
Expand Down Expand Up @@ -144,6 +147,7 @@ export function QuizContainer({
}: QuizContainerProps) {
const tRules = useTranslations('quiz.rules');
const tExit = useTranslations('quiz.exitModal');
const tQuestion = useTranslations('quiz.question');
const [isPending, startTransition] = useTransition();
const [state, dispatch] = useReducer(quizReducer, {
status: 'rules',
Expand All @@ -156,7 +160,8 @@ export function QuizContainer({
isIncomplete: false,
});
const [showExitModal, setShowExitModal] = useState(false);

const [isVerifyingAnswer, setIsVerifyingAnswer] = useState(false);

const locale = useLocale();
const router = useRouter();
const searchParams = useSearchParams();
Expand All @@ -169,10 +174,15 @@ export function QuizContainer({
const currentQuestion = questions[state.currentIndex];
const totalQuestions = questions.length;

useQuizSession({
const handleRestoreSession = useCallback(
(data: QuizSessionData) => dispatch({ type: 'RESTORE_SESSION', payload: data }),
[]
);

useQuizSession({
quizId,
state,
onRestore: data => dispatch({ type: 'RESTORE_SESSION', payload: data }),
onRestore: handleRestoreSession,
});

const { markQuitting } = useQuizGuards({
Expand Down Expand Up @@ -200,7 +210,7 @@ const { markQuitting } = useQuizGuards({
dispatch({ type: 'START_QUIZ' });
};

const handleAnswer = async (answerId: string) => {
const verifyAnswer = async (answerId: string) => {
const response = await fetch('/api/quiz/verify-answer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
Expand All @@ -210,19 +220,60 @@ const { markQuitting } = useQuizGuards({
encryptedAnswers,
}),
});

const { isCorrect } = await response.json();

dispatch({
type: 'ANSWER_SELECTED',
payload: {
answerId,
isCorrect,
questionId: currentQuestion.id,
},
});
if (!response.ok) {
throw new Error('Verify answer failed');
}

const data = await response.json();

if (typeof data.isCorrect !== 'boolean') {
throw new Error('Invalid verify response');
}

return data.isCorrect;
};

const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));


const handleAnswer = async (answerId: string) => {
if (state.questionStatus !== 'answering') return;
if (isVerifyingAnswer) return;

setIsVerifyingAnswer(true);

const maxRetries = 1;
let attempt = 0;

try {
while (true) {
try {
const isCorrect = await verifyAnswer(answerId);

dispatch({
type: 'ANSWER_SELECTED',
payload: {
answerId,
isCorrect,
questionId: currentQuestion.id,
},
});
return;
} catch {
if (attempt >= maxRetries) {
toast.error(tQuestion('verifyFailed'));
return;
}
attempt += 1;
toast(tQuestion('verifyRetry'));
await sleep(600);
}
}
} finally {
setIsVerifyingAnswer(false);
}
};

const handleNext = () => {
if (state.currentIndex + 1 >= totalQuestions) {
Expand Down Expand Up @@ -373,7 +424,7 @@ const confirmQuit = () => {
</div>
</div>

<Button onClick={handleStart} className="w-full" size="lg">
<Button onClick={handleStart} className="w-full" size="md">
{tRules('startButton')}
</Button>
</div>
Expand Down Expand Up @@ -429,6 +480,7 @@ const confirmQuit = () => {
timeLimitSeconds={calculatedTime}
onTimeUp={handleTimeUp}
isActive={state.status === 'in_progress'}
startedAt={state.startedAt!}
/>
);
})()}
Expand Down
50 changes: 27 additions & 23 deletions frontend/components/quiz/QuizResult.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,27 +90,31 @@ export function QuizResult({
return (
<div className="max-w-2xl mx-auto space-y-8">
<div className="text-center text-6xl">{motivation.emoji}</div>
<div className="text-center space-y-2">
<h2 className="text-4xl font-bold text-gray-900 dark:text-gray-100">
{score} / {total}
</h2>
<p className="text-xl text-gray-600 dark:text-gray-400">
{percentage.toFixed(0)}% {t('correctAnswers')}
</p>
</div>
<div className="space-y-2">
<div className="h-4 bg-gray-200 dark:bg-gray-800 rounded-full overflow-hidden">
<div
className={cn(
'h-full transition-all duration-1000 ease-out',
percentage < 50 && 'bg-red-500',
percentage >= 50 && percentage < 80 && 'bg-orange-500',
percentage >= 80 && 'bg-green-500'
)}
style={{ width: `${percentage}%` }}
/>
</div>
</div>
{!isIncomplete && (
<>
<div className="text-center space-y-2">
<h2 className="text-4xl font-bold text-gray-900 dark:text-gray-100">
{score} / {total}
</h2>
<p className="text-xl text-gray-600 dark:text-gray-400">
{percentage.toFixed(0)}% {t('correctAnswers')}
</p>
</div>
<div className="space-y-2">
<div className="h-4 bg-gray-200 dark:bg-gray-800 rounded-full overflow-hidden">
<div
className={cn(
'h-full transition-all duration-1000 ease-out',
percentage < 50 && 'bg-red-500',
percentage >= 50 && percentage < 80 && 'bg-orange-500',
percentage >= 80 && 'bg-green-500'
)}
style={{ width: `${percentage}%` }}
/>
</div>
</div>
</>
)}
<div className="text-center space-y-2">
<h3 className={cn('text-xl font-semibold', motivation.color)}>
{motivation.title}
Expand Down Expand Up @@ -141,7 +145,7 @@ export function QuizResult({
</p>
</div>
)}
{isGuest ? (
{isGuest && !isIncomplete ? (
<div className="space-y-4">
<div className="p-4 rounded-xl bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
<p className="text-center text-blue-800 dark:text-blue-200 font-medium">
Expand All @@ -154,7 +158,7 @@ export function QuizResult({
const url = `/${locale}/login?returnTo=/quiz/${quizSlug}`;
window.location.href = url;
}}
variant="primary">
>
{t('loginButton')}
</Button>
<Button
Expand Down
4 changes: 3 additions & 1 deletion frontend/components/ui/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'outline';
variant?: 'primary' | 'secondary' | 'outline'| 'accent';
size?: 'sm' | 'md' | 'lg';
}

Expand All @@ -17,6 +17,8 @@
'disabled:opacity-50 disabled:pointer-events-none',

// Variants
variant === 'accent' &&
'bg-accent text-accent-foreground hover:bg-accent/90',
variant === 'primary' &&
'bg-blue-600 text-white hover:bg-blue-700 active:bg-blue-800',
variant === 'secondary' &&
Expand Down
4 changes: 2 additions & 2 deletions frontend/data/category.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ export const categoryData = [
createCategory('javascript', 'JavaScript', 3),
createCategory('typescript', 'TypeScript', 4),
createCategory('react', 'React', 5),
createCategory('angular', 'Angular', 6),
createCategory('next', 'Next.js', 6),
createCategory('vue', 'Vue.js', 7),
createCategory('next', 'Next.js', 8),
createCategory('angular', 'Angular', 8),
createCategory('node', 'Node.js', 9),
];
Loading