From 0cf9210a5f272fc3c689494c58eadac52fc43132 Mon Sep 17 00:00:00 2001 From: Dionei Bianchati Date: Fri, 5 Jun 2026 11:07:34 -0300 Subject: [PATCH 01/22] feat: add quiz types and localStorage helpers Co-Authored-By: Claude Sonnet 4.6 --- src/lib/__tests__/quiz.test.ts | 155 +++++++++++++++++++++++++++++++++ src/lib/quiz.ts | 149 +++++++++++++++++++++++++++++++ 2 files changed, 304 insertions(+) create mode 100644 src/lib/__tests__/quiz.test.ts create mode 100644 src/lib/quiz.ts diff --git a/src/lib/__tests__/quiz.test.ts b/src/lib/__tests__/quiz.test.ts new file mode 100644 index 0000000..2426b23 --- /dev/null +++ b/src/lib/__tests__/quiz.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, beforeEach } from 'vitest' + +const localStorageMock = (() => { + let store: Record = {} + return { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { store[key] = value }, + removeItem: (key: string) => { delete store[key] }, + clear: () => { store = {} }, + } +})() +Object.defineProperty(global, 'localStorage', { value: localStorageMock }) + +import { + getQuizStore, startQuiz, answerCurrentQuestion, + completeQuiz, forceCompleteQuiz, getQuizById, + clearQuizHistory, computeScore, computeCategoryBreakdown, +} from '../quiz' +import type { QuizConfig } from '../quiz' +import type { QuestionMeta } from '../content' + +beforeEach(() => localStorage.clear()) + +const mockConfig: QuizConfig = { + categories: ['frontend'], + count: 2, + difficulties: ['pleno'], + tags: [], + timerMinutes: null, +} + +const mockQuestions: QuestionMeta[] = [ + { slug: 'hooks', title: 'What are hooks?', category: 'frontend', subcategory: 'react', tags: ['react'], difficulty: 'pleno', lang: 'pt', path: 'frontend/react/hooks', quickAnswer: 'Functions that let you use state in functional components.' }, + { slug: 'let-const-var', title: 'var vs let vs const?', category: 'frontend', subcategory: 'javascript', tags: ['js'], difficulty: 'pleno', lang: 'pt', path: 'frontend/javascript/let-const-var', quickAnswer: 'Scope and mutability differences.' }, +] + +describe('getQuizStore', () => { + it('returns empty store when nothing saved', () => { + expect(getQuizStore()).toEqual({ active: null, history: [] }) + }) +}) + +describe('startQuiz', () => { + it('saves active quiz with correct shape', () => { + const quiz = startQuiz(mockConfig, mockQuestions, 'pt') + expect(quiz.config).toEqual(mockConfig) + expect(quiz.questions).toEqual(mockQuestions) + expect(quiz.answers).toEqual([null, null]) + expect(quiz.currentIndex).toBe(0) + expect(quiz.locale).toBe('pt') + expect(typeof quiz.id).toBe('string') + expect(getQuizStore().active?.id).toBe(quiz.id) + }) +}) + +describe('answerCurrentQuestion', () => { + it('records answer and advances index', () => { + startQuiz(mockConfig, mockQuestions, 'pt') + const updated = answerCurrentQuestion('known') + expect(updated?.answers[0]).toBe('known') + expect(updated?.currentIndex).toBe(1) + }) + + it('returns null when no active quiz', () => { + expect(answerCurrentQuestion('known')).toBeNull() + }) +}) + +describe('completeQuiz', () => { + it('moves active to history and clears active', () => { + startQuiz(mockConfig, mockQuestions, 'pt') + answerCurrentQuestion('known') + answerCurrentQuestion('unknown') + const completed = completeQuiz() + expect(completed).not.toBeNull() + expect(typeof completed?.completedAt).toBe('number') + expect(typeof completed?.durationSeconds).toBe('number') + const store = getQuizStore() + expect(store.active).toBeNull() + expect(store.history).toHaveLength(1) + expect(store.history[0].id).toBe(completed?.id) + }) + + it('returns null when no active quiz', () => { + expect(completeQuiz()).toBeNull() + }) +}) + +describe('forceCompleteQuiz', () => { + it('fills unanswered questions with unknown', () => { + startQuiz(mockConfig, mockQuestions, 'pt') + answerCurrentQuestion('known') + // currentIndex is now 1, second question unanswered + const completed = forceCompleteQuiz() + expect(completed?.answers[0]).toBe('known') + expect(completed?.answers[1]).toBe('unknown') + }) +}) + +describe('getQuizById', () => { + it('finds active quiz by id', () => { + const quiz = startQuiz(mockConfig, mockQuestions, 'pt') + expect(getQuizById(quiz.id)?.id).toBe(quiz.id) + }) + + it('finds completed quiz in history', () => { + const quiz = startQuiz(mockConfig, mockQuestions, 'pt') + const id = quiz.id + completeQuiz() + expect(getQuizById(id)?.id).toBe(id) + }) + + it('returns null for unknown id', () => { + expect(getQuizById('nonexistent')).toBeNull() + }) +}) + +describe('clearQuizHistory', () => { + it('empties history', () => { + startQuiz(mockConfig, mockQuestions, 'pt') + completeQuiz() + clearQuizHistory() + expect(getQuizStore().history).toHaveLength(0) + }) +}) + +describe('computeScore', () => { + it('calculates score with partial worth 0.5', () => { + startQuiz(mockConfig, mockQuestions, 'pt') + answerCurrentQuestion('known') + answerCurrentQuestion('partial') + const completed = completeQuiz()! + const { known, partial, unknown, total, score } = computeScore(completed) + expect(known).toBe(1) + expect(partial).toBe(1) + expect(unknown).toBe(0) + expect(total).toBe(2) + expect(score).toBe(75) // (1 + 0.5) / 2 * 100 + }) +}) + +describe('computeCategoryBreakdown', () => { + it('groups results by category', () => { + startQuiz(mockConfig, mockQuestions, 'pt') + answerCurrentQuestion('known') + answerCurrentQuestion('unknown') + const completed = completeQuiz()! + const breakdown = computeCategoryBreakdown(completed) + expect(breakdown).toHaveLength(1) + expect(breakdown[0].category).toBe('frontend') + expect(breakdown[0].known).toBe(1) + expect(breakdown[0].unknown).toBe(1) + expect(breakdown[0].score).toBe(50) + }) +}) diff --git a/src/lib/quiz.ts b/src/lib/quiz.ts new file mode 100644 index 0000000..7dcd02d --- /dev/null +++ b/src/lib/quiz.ts @@ -0,0 +1,149 @@ +import type { QuestionMeta, Difficulty } from './content' + +const STORAGE_KEY = 'cq_quiz' +const MAX_HISTORY = 50 + +export type QuizAnswer = 'known' | 'partial' | 'unknown' + +export type QuizConfig = { + categories: string[] + count: number + difficulties: Difficulty[] + tags: string[] + timerMinutes: number | null +} + +export type ActiveQuiz = { + id: string + config: QuizConfig + questions: QuestionMeta[] + answers: (QuizAnswer | null)[] + currentIndex: number + startedAt: number + locale: string +} + +export type CompletedQuiz = ActiveQuiz & { + completedAt: number + durationSeconds: number +} + +export type QuizStore = { + active: ActiveQuiz | null + history: CompletedQuiz[] +} + +export function getQuizStore(): QuizStore { + if (typeof window === 'undefined') return { active: null, history: [] } + try { + const raw = localStorage.getItem(STORAGE_KEY) + return raw ? JSON.parse(raw) : { active: null, history: [] } + } catch { + return { active: null, history: [] } + } +} + +function saveQuizStore(store: QuizStore): void { + localStorage.setItem(STORAGE_KEY, JSON.stringify(store)) +} + +export function startQuiz( + config: QuizConfig, + questions: QuestionMeta[], + locale: string +): ActiveQuiz { + const quiz: ActiveQuiz = { + id: Date.now().toString(), + config, + questions, + answers: questions.map(() => null), + currentIndex: 0, + startedAt: Date.now(), + locale, + } + const store = getQuizStore() + store.active = quiz + saveQuizStore(store) + return quiz +} + +export function answerCurrentQuestion(answer: QuizAnswer): ActiveQuiz | null { + const store = getQuizStore() + if (!store.active) return null + const quiz = store.active + const answers = [...quiz.answers] + answers[quiz.currentIndex] = answer + const updated: ActiveQuiz = { ...quiz, answers, currentIndex: quiz.currentIndex + 1 } + store.active = updated + saveQuizStore(store) + return updated +} + +export function completeQuiz(): CompletedQuiz | null { + const store = getQuizStore() + if (!store.active) return null + const now = Date.now() + const completed: CompletedQuiz = { + ...store.active, + completedAt: now, + durationSeconds: Math.round((now - store.active.startedAt) / 1000), + } + store.active = null + store.history = [completed, ...store.history].slice(0, MAX_HISTORY) + saveQuizStore(store) + return completed +} + +export function forceCompleteQuiz(): CompletedQuiz | null { + const store = getQuizStore() + if (!store.active) return null + const now = Date.now() + const answers = store.active.answers.map(a => a ?? 'unknown') as QuizAnswer[] + const completed: CompletedQuiz = { + ...store.active, + answers, + currentIndex: store.active.questions.length, + completedAt: now, + durationSeconds: Math.round((now - store.active.startedAt) / 1000), + } + store.active = null + store.history = [completed, ...store.history].slice(0, MAX_HISTORY) + saveQuizStore(store) + return completed +} + +export function getQuizById(id: string): ActiveQuiz | CompletedQuiz | null { + const store = getQuizStore() + if (store.active?.id === id) return store.active + return store.history.find(q => q.id === id) ?? null +} + +export function clearQuizHistory(): void { + const store = getQuizStore() + store.history = [] + saveQuizStore(store) +} + +export function computeScore(quiz: CompletedQuiz) { + const known = quiz.answers.filter(a => a === 'known').length + const partial = quiz.answers.filter(a => a === 'partial').length + const unknown = quiz.answers.filter(a => a === 'unknown').length + const total = quiz.questions.length + const score = Math.round(((known + partial * 0.5) / total) * 100) + return { known, partial, unknown, total, score } +} + +export function computeCategoryBreakdown(quiz: CompletedQuiz) { + const byCategory: Record = {} + quiz.questions.forEach((q, i) => { + if (!byCategory[q.category]) byCategory[q.category] = { known: 0, partial: 0, unknown: 0, total: 0 } + const answer = (quiz.answers[i] ?? 'unknown') as QuizAnswer + byCategory[q.category][answer]++ + byCategory[q.category].total++ + }) + return Object.entries(byCategory).map(([category, s]) => ({ + category, + ...s, + score: Math.round(((s.known + s.partial * 0.5) / s.total) * 100), + })) +} From f52e0bea6dea2a21d76ab5aca2273960497d2581 Mon Sep 17 00:00:00 2001 From: Dionei Bianchati Date: Fri, 5 Jun 2026 11:10:35 -0300 Subject: [PATCH 02/22] fix: use crypto.randomUUID for quiz id, add answer bounds guard Co-Authored-By: Claude Sonnet 4.6 --- src/lib/quiz.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/lib/quiz.ts b/src/lib/quiz.ts index 7dcd02d..e77204c 100644 --- a/src/lib/quiz.ts +++ b/src/lib/quiz.ts @@ -44,6 +44,7 @@ export function getQuizStore(): QuizStore { } function saveQuizStore(store: QuizStore): void { + if (typeof window === 'undefined') return localStorage.setItem(STORAGE_KEY, JSON.stringify(store)) } @@ -52,13 +53,14 @@ export function startQuiz( questions: QuestionMeta[], locale: string ): ActiveQuiz { + const now = Date.now() const quiz: ActiveQuiz = { - id: Date.now().toString(), + id: crypto.randomUUID(), config, questions, answers: questions.map(() => null), currentIndex: 0, - startedAt: Date.now(), + startedAt: now, locale, } const store = getQuizStore() @@ -71,6 +73,7 @@ export function answerCurrentQuestion(answer: QuizAnswer): ActiveQuiz | null { const store = getQuizStore() if (!store.active) return null const quiz = store.active + if (quiz.currentIndex >= quiz.questions.length) return quiz // already complete const answers = [...quiz.answers] answers[quiz.currentIndex] = answer const updated: ActiveQuiz = { ...quiz, answers, currentIndex: quiz.currentIndex + 1 } From 8fe2f35381833a28644bba33295ba5fec53eea89 Mon Sep 17 00:00:00 2001 From: Dionei Bianchati Date: Fri, 5 Jun 2026 11:11:44 -0300 Subject: [PATCH 03/22] feat: add quiz i18n keys --- src/messages/en.json | 30 ++++++++++++++++++++++++++++++ src/messages/pt.json | 30 ++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/src/messages/en.json b/src/messages/en.json index 295aafe..56efd35 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -92,5 +92,35 @@ "footer": { "openSource": "Open source on GitHub", "madeBy": "Made by" + }, + "quiz": { + "createTest": "Create Test", + "categories": "Categories", + "questionCount": "Questions", + "seniority": "Seniority", + "tags": "Tags", + "timer": "Time (optional)", + "timerUnit": "min", + "start": "Start", + "questionsAvailable": "available", + "noQuestionsHint": "No questions match these filters", + "questionLabel": "Question", + "revealAnswer": "Reveal quick answer", + "known": "Knew it", + "partial": "Sort of", + "unknown": "Didn't know", + "endTest": "End test", + "endConfirm": "End now? Remaining questions will be marked as unknown.", + "result": "Result", + "byCategory": "By Category", + "score": "Score", + "duration": "Duration", + "newTest": "New Test", + "viewHistory": "View History", + "history": "Test History", + "noHistory": "No tests completed yet.", + "clearHistory": "Clear history", + "clearHistoryConfirm": "Delete all history?", + "notFound": "Test not found." } } diff --git a/src/messages/pt.json b/src/messages/pt.json index 95fcbbf..b3b586d 100644 --- a/src/messages/pt.json +++ b/src/messages/pt.json @@ -92,5 +92,35 @@ "footer": { "openSource": "Open source no GitHub", "madeBy": "Feito por" + }, + "quiz": { + "createTest": "Criar Teste", + "categories": "Categorias", + "questionCount": "Perguntas", + "seniority": "Senioridade", + "tags": "Tags", + "timer": "Tempo (opcional)", + "timerUnit": "min", + "start": "Iniciar", + "questionsAvailable": "disponíveis", + "noQuestionsHint": "Nenhuma pergunta com esses filtros", + "questionLabel": "Pergunta", + "revealAnswer": "Ver resposta rápida", + "known": "Sabia", + "partial": "Mais ou menos", + "unknown": "Não sabia", + "endTest": "Encerrar teste", + "endConfirm": "Encerrar agora? As perguntas restantes serão marcadas como não sabia.", + "result": "Resultado", + "byCategory": "Por Categoria", + "score": "Aproveitamento", + "duration": "Duração", + "newTest": "Novo Teste", + "viewHistory": "Ver Histórico", + "history": "Histórico de Testes", + "noHistory": "Nenhum teste realizado ainda.", + "clearHistory": "Limpar histórico", + "clearHistoryConfirm": "Apagar todo o histórico?", + "notFound": "Teste não encontrado." } } From 75611d3af55f3cb7c7a99178f285eee59b90f08f Mon Sep 17 00:00:00 2001 From: Dionei Bianchati Date: Fri, 5 Jun 2026 11:11:58 -0300 Subject: [PATCH 04/22] feat: add server action for quiz question fetching --- src/app/quiz-actions.ts | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/app/quiz-actions.ts diff --git a/src/app/quiz-actions.ts b/src/app/quiz-actions.ts new file mode 100644 index 0000000..6f6d5be --- /dev/null +++ b/src/app/quiz-actions.ts @@ -0,0 +1,34 @@ +'use server' + +import { getAllQuestionMeta } from '@/lib/content' +import type { QuizConfig } from '@/lib/quiz' +import type { QuestionMeta } from '@/lib/content' + +function shuffle(arr: T[]): T[] { + const a = [...arr] + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[a[i], a[j]] = [a[j], a[i]] + } + return a +} + +export async function getQuestionsForQuiz( + locale: string, + config: QuizConfig +): Promise { + const all: QuestionMeta[] = [] + for (const category of config.categories) { + all.push(...getAllQuestionMeta(locale, category)) + } + + const filtered = all.filter(q => { + const diffMatch = + config.difficulties.length === 0 || config.difficulties.includes(q.difficulty) + const tagMatch = + config.tags.length === 0 || config.tags.some(tag => q.tags.includes(tag)) + return diffMatch && tagMatch + }) + + return shuffle(filtered).slice(0, config.count) +} From 76d6c0351dc115c85877b16a6ac2afd5d2a742b5 Mon Sep 17 00:00:00 2001 From: Dionei Bianchati Date: Fri, 5 Jun 2026 11:14:16 -0300 Subject: [PATCH 05/22] feat: add QuizSetupPanel component --- src/components/quiz/QuizSetupPanel.tsx | 254 +++++++++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 src/components/quiz/QuizSetupPanel.tsx diff --git a/src/components/quiz/QuizSetupPanel.tsx b/src/components/quiz/QuizSetupPanel.tsx new file mode 100644 index 0000000..b03669b --- /dev/null +++ b/src/components/quiz/QuizSetupPanel.tsx @@ -0,0 +1,254 @@ +'use client' + +import { useState, useTransition, useRef, useMemo } from 'react' +import { useLocale, useTranslations } from 'next-intl' +import { useRouter } from 'next/navigation' +import { CATEGORIES } from '@/lib/categories' +import { getQuestionsForQuiz } from '@/app/quiz-actions' +import { startQuiz } from '@/lib/quiz' +import type { QuizConfig } from '@/lib/quiz' +import type { Difficulty } from '@/lib/content' + +const DIFFICULTIES: Difficulty[] = ['junior', 'pleno', 'senior', 'especialista'] + +type Props = { + preselectedCategories: string[] + categoryCounts: Record +} + +export function QuizSetupPanel({ preselectedCategories, categoryCounts }: Props) { + const t = useTranslations('quiz') + const tQuestion = useTranslations('question') + const locale = useLocale() + const router = useRouter() + const [isPending, startTransition] = useTransition() + + const [open, setOpen] = useState(false) + const [categories, setCategories] = useState(preselectedCategories) + const [count, setCount] = useState(10) + const [difficulties, setDifficulties] = useState([...DIFFICULTIES]) + const [tagInput, setTagInput] = useState('') + const [tags, setTags] = useState([]) + const [timerMinutes, setTimerMinutes] = useState('') + const [noResults, setNoResults] = useState(false) + const inputRef = useRef(null) + + const availableCount = useMemo( + () => CATEGORIES + .filter(c => categories.includes(c.slug)) + .reduce((sum, c) => sum + (categoryCounts[c.slug] ?? 0), 0), + [categories, categoryCounts] + ) + const maxCount = Math.min(availableCount, 100) + const safeCount = Math.max(1, Math.min(count, maxCount || 1)) + + const toggleCategory = (slug: string) => + setCategories(prev => + prev.includes(slug) ? prev.filter(c => c !== slug) : [...prev, slug] + ) + + const toggleDifficulty = (d: Difficulty) => + setDifficulties(prev => + prev.includes(d) ? prev.filter(x => x !== d) : [...prev, d] + ) + + const addTag = (tag: string) => { + const trimmed = tag.trim() + if (trimmed && !tags.includes(trimmed)) setTags(prev => [...prev, trimmed]) + setTagInput('') + inputRef.current?.focus() + } + + const handleTagKeyDown = (e: React.KeyboardEvent) => { + if ((e.key === 'Enter' || e.key === ',') && tagInput.trim()) { + e.preventDefault() + addTag(tagInput) + } + if (e.key === 'Backspace' && tagInput === '' && tags.length > 0) { + setTags(prev => prev.slice(0, -1)) + } + } + + const handleStart = () => { + setNoResults(false) + const config: QuizConfig = { + categories, + count: safeCount, + difficulties, + tags, + timerMinutes: timerMinutes ? parseInt(timerMinutes) : null, + } + startTransition(async () => { + const questions = await getQuestionsForQuiz(locale, config) + if (questions.length === 0) { setNoResults(true); return } + const quiz = startQuiz(config, questions, locale) + router.push(`/${locale}/quiz/${quiz.id}`) + }) + } + + if (!open) { + return ( + + ) + } + + return ( +
+
+ + 🎯 {t('createTest')} + + +
+ + {/* Categories */} +
+ + {t('categories')} + +
+ {CATEGORIES.map(cat => { + const active = categories.includes(cat.slug) + const cnt = categoryCounts[cat.slug] ?? 0 + return ( + + ) + })} +
+
+ + {/* Count + Timer */} +
+
+ + {t('questionCount')} + + setCount(Math.max(1, parseInt(e.target.value) || 1))} + className="w-full bg-dark-surface border border-dark-border rounded px-3 py-1.5 text-sm text-dark-text focus:border-blue-500/50 outline-none" + /> +
+
+ + {t('timer')} + +
+ setTimerMinutes(e.target.value)} + className="w-full bg-dark-surface border border-dark-border rounded px-3 py-1.5 text-sm text-dark-text focus:border-blue-500/50 outline-none" + /> + {t('timerUnit')} +
+
+
+ + {/* Seniority */} +
+ + {t('seniority')} + +
+ {DIFFICULTIES.map(d => { + const active = difficulties.includes(d) + return ( + + ) + })} +
+
+ + {/* Tags */} +
+ + {t('tags')} + +
inputRef.current?.focus()} + > + {tags.map(tag => ( + + {tag} + + + ))} + setTagInput(e.target.value)} + onKeyDown={handleTagKeyDown} + placeholder={tags.length === 0 ? 'react, hooks, ...' : ''} + className="flex-1 bg-transparent text-sm text-dark-text placeholder-dark-muted outline-none min-w-[100px]" + /> +
+
+ + {/* Start */} +
+ + {categories.length > 0 && maxCount > 0 && ( + + {availableCount} {t('questionsAvailable')} + + )} + {noResults && ( + {t('noQuestionsHint')} + )} +
+
+ ) +} From 4268d75b1935e7025b05ab41b81d19253f2bd7f2 Mon Sep 17 00:00:00 2001 From: Dionei Bianchati Date: Fri, 5 Jun 2026 11:16:15 -0300 Subject: [PATCH 06/22] feat: add QuizSetupPanel to question list page --- src/app/[locale]/questions/[category]/page.tsx | 10 +++++++++- src/components/questions/QuestionListClient.tsx | 7 ++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/app/[locale]/questions/[category]/page.tsx b/src/app/[locale]/questions/[category]/page.tsx index 491bd21..4fc54e0 100644 --- a/src/app/[locale]/questions/[category]/page.tsx +++ b/src/app/[locale]/questions/[category]/page.tsx @@ -26,6 +26,10 @@ export default async function CategoryPage({ params }: Props) { const questions = getAllQuestionMeta(locale, category) + const categoryCounts = Object.fromEntries( + CATEGORIES.map(c => [c.slug, getAllQuestionMeta(locale, c.slug).length]) + ) + return (
@@ -54,7 +58,11 @@ export default async function CategoryPage({ params }: Props) {
) : ( - + )}
) diff --git a/src/components/questions/QuestionListClient.tsx b/src/components/questions/QuestionListClient.tsx index ac0dfa7..a63a483 100644 --- a/src/components/questions/QuestionListClient.tsx +++ b/src/components/questions/QuestionListClient.tsx @@ -7,6 +7,7 @@ import { Badge } from '@/components/ui/Badge' import { FocusMode } from './FocusMode' import type { QuestionMeta } from '@/lib/content' import { QuestionCard } from './QuestionCard' +import { QuizSetupPanel } from '@/components/quiz/QuizSetupPanel' const LEVELS = ['junior', 'pleno', 'senior', 'especialista'] as const @@ -19,9 +20,11 @@ const difficultyVariant: Record = { type Props = { questions: QuestionMeta[] + categoryCounts: Record + currentCategory: string } -export function QuestionListClient({ questions }: Props) { +export function QuestionListClient({ questions, categoryCounts, currentCategory }: Props) { const t = useTranslations('categories') const tQuestion = useTranslations('question') @@ -149,6 +152,8 @@ export function QuestionListClient({ questions }: Props) { return (
+ + {/* Level filter */}
From cf7fe64b8bb8aa43eda2d86d1c070e7928237b2d Mon Sep 17 00:00:00 2001 From: Dionei Bianchati Date: Fri, 5 Jun 2026 11:16:18 -0300 Subject: [PATCH 07/22] feat: add quiz entry point to category hub --- src/app/[locale]/questions/page.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/app/[locale]/questions/page.tsx b/src/app/[locale]/questions/page.tsx index 843de38..7d109cf 100644 --- a/src/app/[locale]/questions/page.tsx +++ b/src/app/[locale]/questions/page.tsx @@ -2,6 +2,7 @@ import { getTranslations, getLocale } from 'next-intl/server' import { CATEGORIES } from '@/lib/categories' import { getAllQuestionMeta } from '@/lib/content' import { CategoryCard } from '@/components/questions/CategoryCard' +import { QuizSetupPanel } from '@/components/quiz/QuizSetupPanel' export async function generateStaticParams() { return [{ locale: 'en' }, { locale: 'pt' }] @@ -11,10 +12,14 @@ export default async function QuestionsPage() { const t = await getTranslations() const locale = await getLocale() + const categoryCounts = Object.fromEntries( + CATEGORIES.map(cat => [cat.slug, getAllQuestionMeta(locale, cat.slug).length]) + ) + const categoriesWithCount = CATEGORIES.map(cat => ({ cat, label: t(`categories.${cat.slug}`), - count: getAllQuestionMeta(locale, cat.slug).length, + count: categoryCounts[cat.slug], })) return ( @@ -25,11 +30,18 @@ export default async function QuestionsPage() {

{t('categories.subtitle')}

-
+
{categoriesWithCount.map(({ cat, label, count }) => ( ))}
+ +
+ +
) } From 25ed650277bd12b80cec799d75409e6c7d8b29f2 Mon Sep 17 00:00:00 2001 From: Dionei Bianchati Date: Fri, 5 Jun 2026 11:18:21 -0300 Subject: [PATCH 08/22] feat: add quiz page route --- src/app/[locale]/quiz/[id]/loading.tsx | 14 ++++++++++++++ src/app/[locale]/quiz/[id]/page.tsx | 8 ++++++++ 2 files changed, 22 insertions(+) create mode 100644 src/app/[locale]/quiz/[id]/loading.tsx create mode 100644 src/app/[locale]/quiz/[id]/page.tsx diff --git a/src/app/[locale]/quiz/[id]/loading.tsx b/src/app/[locale]/quiz/[id]/loading.tsx new file mode 100644 index 0000000..761a9c2 --- /dev/null +++ b/src/app/[locale]/quiz/[id]/loading.tsx @@ -0,0 +1,14 @@ +export default function Loading() { + return ( +
+
+
+ {Array.from({ length: 10 }).map((_, i) => ( +
+ ))} +
+
+
+
+ ) +} diff --git a/src/app/[locale]/quiz/[id]/page.tsx b/src/app/[locale]/quiz/[id]/page.tsx new file mode 100644 index 0000000..ac9df79 --- /dev/null +++ b/src/app/[locale]/quiz/[id]/page.tsx @@ -0,0 +1,8 @@ +import { QuizPageClient } from '@/components/quiz/QuizPageClient' + +type Props = { params: Promise<{ locale: string; id: string }> } + +export default async function QuizPage({ params }: Props) { + const { id } = await params + return +} From 53caad38545e829938386eea58ddc90fa87c2537 Mon Sep 17 00:00:00 2001 From: Dionei Bianchati Date: Fri, 5 Jun 2026 11:18:44 -0300 Subject: [PATCH 09/22] feat: add QuizQuestion component --- src/components/quiz/QuizQuestion.tsx | 143 +++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 src/components/quiz/QuizQuestion.tsx diff --git a/src/components/quiz/QuizQuestion.tsx b/src/components/quiz/QuizQuestion.tsx new file mode 100644 index 0000000..70438df --- /dev/null +++ b/src/components/quiz/QuizQuestion.tsx @@ -0,0 +1,143 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useTranslations } from 'next-intl' +import { Badge } from '@/components/ui/Badge' +import type { QuestionMeta } from '@/lib/content' +import type { QuizAnswer } from '@/lib/quiz' + +const difficultyVariant: Record = { + junior: 'green', + pleno: 'blue', + senior: 'orange', + especialista: 'orange', +} + +function formatTime(seconds: number): string { + const m = Math.floor(seconds / 60) + const s = seconds % 60 + return `${m}:${String(s).padStart(2, '0')}` +} + +type Props = { + question: QuestionMeta + questionNumber: number + totalQuestions: number + answers: (QuizAnswer | null)[] + secondsRemaining: number | null + onAnswer: (answer: QuizAnswer) => void + onClose: () => void +} + +export function QuizQuestion({ + question, + questionNumber, + totalQuestions, + answers, + secondsRemaining, + onAnswer, + onClose, +}: Props) { + const t = useTranslations('quiz') + const tQuestion = useTranslations('question') + const [revealed, setRevealed] = useState(false) + + useEffect(() => { setRevealed(false) }, [question.slug]) + + return ( +
+ {/* Header */} +
+ + {t('questionLabel')} {questionNumber} / {totalQuestions} + +
+ {secondsRemaining !== null && ( + + ⏱ {formatTime(secondsRemaining)} + + )} + +
+
+ + {/* Progress bar */} +
+ {answers.map((a, i) => ( +
+ ))} +
+ + {/* Badges */} +
+ + {tQuestion(`difficulty.${question.difficulty}`)} + + {question.category} + {question.tags.slice(0, 3).map(tag => ( + {tag} + ))} +
+ + {/* Question title */} +

+ {question.title} +

+ + {/* Reveal / Quick answer */} + {!revealed ? ( + + ) : ( +
+

+ Quick answer +

+

+ {question.quickAnswer || '—'} +

+
+ )} + + {/* Answer buttons */} +
+ + + +
+
+ ) +} From e4b8e23101799be21c729e1734123b109806b247 Mon Sep 17 00:00:00 2001 From: Dionei Bianchati Date: Fri, 5 Jun 2026 11:19:06 -0300 Subject: [PATCH 10/22] feat: add QuizReport component --- src/components/quiz/QuizReport.tsx | 128 +++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 src/components/quiz/QuizReport.tsx diff --git a/src/components/quiz/QuizReport.tsx b/src/components/quiz/QuizReport.tsx new file mode 100644 index 0000000..4e46b6c --- /dev/null +++ b/src/components/quiz/QuizReport.tsx @@ -0,0 +1,128 @@ +'use client' + +import Link from 'next/link' +import { useTranslations } from 'next-intl' +import type { CompletedQuiz } from '@/lib/quiz' +import { computeScore, computeCategoryBreakdown } from '@/lib/quiz' + +type Props = { + quiz: CompletedQuiz + locale: string +} + +export function QuizReport({ quiz, locale }: Props) { + const t = useTranslations('quiz') + const { known, partial, unknown, total, score } = computeScore(quiz) + const breakdown = computeCategoryBreakdown(quiz) + const minutes = Math.floor(quiz.durationSeconds / 60) + const seconds = quiz.durationSeconds % 60 + + return ( +
+

+ 🎯 {t('result')} +

+ + {/* Score cards */} +
+
+
{known}
+
{t('known')}
+
+
+
{partial}
+
{t('partial')}
+
+
+
{unknown}
+
{t('unknown')}
+
+
+ +
+ + {t('score')}: {score}% + + + {t('duration')}: + {minutes}:{String(seconds).padStart(2, '0')} + + + | + {total} perguntas +
+ + {/* Category breakdown */} + {breakdown.length > 0 && ( +
+

+ {t('byCategory')} +

+
+ {breakdown.map(cat => ( +
+
+ {cat.category} + {cat.score}% +
+
+
+
+
+ ))} +
+
+ )} + + {/* Question list */} +
+

+ Perguntas +

+
+ {quiz.questions.map((q, i) => { + const answer = quiz.answers[i] + const icon = answer === 'known' ? '✅' : answer === 'partial' ? '🤔' : '❌' + return ( +
+ {q.title} +
+ {icon} + + ver → + +
+
+ ) + })} +
+
+ + {/* Actions */} +
+ + {t('newTest')} + + + {t('viewHistory')} + +
+
+ ) +} From afe6fbe4d671764042c8536bfb525c24b315c2f5 Mon Sep 17 00:00:00 2001 From: Dionei Bianchati Date: Fri, 5 Jun 2026 11:20:35 -0300 Subject: [PATCH 11/22] feat: add QuizPageClient with timer and answer flow --- src/components/quiz/QuizPageClient.tsx | 109 +++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 src/components/quiz/QuizPageClient.tsx diff --git a/src/components/quiz/QuizPageClient.tsx b/src/components/quiz/QuizPageClient.tsx new file mode 100644 index 0000000..ee4f19e --- /dev/null +++ b/src/components/quiz/QuizPageClient.tsx @@ -0,0 +1,109 @@ +'use client' + +import { useEffect, useState, useCallback } from 'react' +import { useLocale, useTranslations } from 'next-intl' +import Link from 'next/link' +import type { ActiveQuiz, CompletedQuiz, QuizAnswer } from '@/lib/quiz' +import { + getQuizById, + answerCurrentQuestion, + completeQuiz as doCompleteQuiz, + forceCompleteQuiz, +} from '@/lib/quiz' +import { QuizQuestion } from './QuizQuestion' +import { QuizReport } from './QuizReport' + +type Props = { id: string } + +export function QuizPageClient({ id }: Props) { + const locale = useLocale() + const t = useTranslations('quiz') + const [quiz, setQuiz] = useState(undefined) + const [secondsRemaining, setSecondsRemaining] = useState(null) + + useEffect(() => { + setQuiz(getQuizById(id)) + }, [id]) + + // Timer countdown + useEffect(() => { + if (!quiz || 'completedAt' in quiz) return + const active = quiz as ActiveQuiz + if (!active.config.timerMinutes) { setSecondsRemaining(null); return } + + const endTime = active.startedAt + active.config.timerMinutes * 60 * 1000 + + const tick = () => { + const remaining = Math.round((endTime - Date.now()) / 1000) + if (remaining <= 0) { + clearInterval(interval) + const completed = forceCompleteQuiz() + if (completed) setQuiz(completed) + } else { + setSecondsRemaining(remaining) + } + } + + tick() + const interval = setInterval(tick, 1000) + return () => clearInterval(interval) + }, [quiz && 'id' in quiz ? quiz.id : null]) // eslint-disable-line react-hooks/exhaustive-deps + + const handleAnswer = useCallback((answer: QuizAnswer) => { + const updated = answerCurrentQuestion(answer) + if (!updated) return + if (updated.currentIndex >= updated.questions.length) { + const completed = doCompleteQuiz() + if (completed) setQuiz(completed) + } else { + setQuiz({ ...updated }) + } + }, []) + + const handleClose = useCallback(() => { + if (!confirm(t('endConfirm'))) return + const completed = forceCompleteQuiz() + if (completed) setQuiz(completed) + }, [t]) + + // Loading state + if (quiz === undefined) { + return ( +
+
+
+
+ ) + } + + // Not found + if (quiz === null) { + return ( +
+

{t('notFound')}

+ + {t('viewHistory')} + +
+ ) + } + + // Completed — show report + if ('completedAt' in quiz) { + return + } + + // Active — show question + const active = quiz as ActiveQuiz + return ( + + ) +} From 5b2fd1234100477b4ce72f8fcaae6b41ebafa7cf Mon Sep 17 00:00:00 2001 From: Dionei Bianchati Date: Fri, 5 Jun 2026 11:21:07 -0300 Subject: [PATCH 12/22] feat: add quiz history page --- src/app/[locale]/quiz/history/page.tsx | 14 +++++ src/components/quiz/QuizHistoryList.tsx | 77 +++++++++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 src/app/[locale]/quiz/history/page.tsx create mode 100644 src/components/quiz/QuizHistoryList.tsx diff --git a/src/app/[locale]/quiz/history/page.tsx b/src/app/[locale]/quiz/history/page.tsx new file mode 100644 index 0000000..d679c8c --- /dev/null +++ b/src/app/[locale]/quiz/history/page.tsx @@ -0,0 +1,14 @@ +import { getTranslations } from 'next-intl/server' +import { QuizHistoryList } from '@/components/quiz/QuizHistoryList' + +export default async function QuizHistoryPage() { + const t = await getTranslations('quiz') + return ( +
+

+ {t('history')} +

+ +
+ ) +} diff --git a/src/components/quiz/QuizHistoryList.tsx b/src/components/quiz/QuizHistoryList.tsx new file mode 100644 index 0000000..f8cdae9 --- /dev/null +++ b/src/components/quiz/QuizHistoryList.tsx @@ -0,0 +1,77 @@ +'use client' + +import { useState, useEffect } from 'react' +import Link from 'next/link' +import { useLocale, useTranslations } from 'next-intl' +import type { CompletedQuiz } from '@/lib/quiz' +import { getQuizStore, clearQuizHistory, computeScore } from '@/lib/quiz' + +export function QuizHistoryList() { + const t = useTranslations('quiz') + const locale = useLocale() + const [history, setHistory] = useState([]) + + useEffect(() => { + setHistory(getQuizStore().history) + }, []) + + const handleClear = () => { + if (!confirm(t('clearHistoryConfirm'))) return + clearQuizHistory() + setHistory([]) + } + + if (history.length === 0) { + return ( +
+

{t('noHistory')}

+ + {t('newTest')} + +
+ ) + } + + return ( +
+
+ {history.map(quiz => { + const { known, partial, unknown, score } = computeScore(quiz) + const date = new Date(quiz.completedAt).toLocaleString(locale === 'pt' ? 'pt-BR' : 'en-US', { + day: '2-digit', month: '2-digit', year: 'numeric', + hour: '2-digit', minute: '2-digit', + }) + return ( + +
+ + {quiz.config.categories.join(' · ')} + + {date} +
+
+ ✅ {known} + 🤔 {partial} + ❌ {unknown} + {score}% +
+ + ) + })} +
+ +
+ +
+
+ ) +} From 10749ff5e9691bcfa73f0f483dd45beb5458c077 Mon Sep 17 00:00:00 2001 From: Dionei Bianchati Date: Fri, 5 Jun 2026 11:28:44 -0300 Subject: [PATCH 13/22] refactor: convert QuizSetupPanel inline panel to modal --- src/app/[locale]/questions/page.tsx | 2 +- .../questions/QuestionListClient.tsx | 4 +- src/components/quiz/QuizSetupPanel.tsx | 323 ++++++++++-------- 3 files changed, 176 insertions(+), 153 deletions(-) diff --git a/src/app/[locale]/questions/page.tsx b/src/app/[locale]/questions/page.tsx index 7d109cf..35238be 100644 --- a/src/app/[locale]/questions/page.tsx +++ b/src/app/[locale]/questions/page.tsx @@ -36,7 +36,7 @@ export default async function QuestionsPage() { ))}
-
+
- +
+ +
{/* Level filter */}
diff --git a/src/components/quiz/QuizSetupPanel.tsx b/src/components/quiz/QuizSetupPanel.tsx index b03669b..8bc017f 100644 --- a/src/components/quiz/QuizSetupPanel.tsx +++ b/src/components/quiz/QuizSetupPanel.tsx @@ -1,6 +1,7 @@ 'use client' -import { useState, useTransition, useRef, useMemo } from 'react' +import { useState, useTransition, useRef, useMemo, useEffect } from 'react' +import { createPortal } from 'react-dom' import { useLocale, useTranslations } from 'next-intl' import { useRouter } from 'next/navigation' import { CATEGORIES } from '@/lib/categories' @@ -42,6 +43,13 @@ export function QuizSetupPanel({ preselectedCategories, categoryCounts }: Props) const maxCount = Math.min(availableCount, 100) const safeCount = Math.max(1, Math.min(count, maxCount || 1)) + useEffect(() => { + if (!open) return + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + }, [open]) + const toggleCategory = (slug: string) => setCategories(prev => prev.includes(slug) ? prev.filter(c => c !== slug) : [...prev, slug] @@ -86,169 +94,182 @@ export function QuizSetupPanel({ preselectedCategories, categoryCounts }: Props) }) } - if (!open) { - return ( + return ( + <> - ) - } - return ( -
-
- - 🎯 {t('createTest')} - - -
- - {/* Categories */} -
- - {t('categories')} - -
- {CATEGORIES.map(cat => { - const active = categories.includes(cat.slug) - const cnt = categoryCounts[cat.slug] ?? 0 - return ( +
e.stopPropagation()} + > + {/* Header */} +
+ + 🎯 {t('createTest')} + - ) - })} -
-
- - {/* Count + Timer */} -
-
- - {t('questionCount')} - - setCount(Math.max(1, parseInt(e.target.value) || 1))} - className="w-full bg-dark-surface border border-dark-border rounded px-3 py-1.5 text-sm text-dark-text focus:border-blue-500/50 outline-none" - /> -
-
- - {t('timer')} - -
- setTimerMinutes(e.target.value)} - className="w-full bg-dark-surface border border-dark-border rounded px-3 py-1.5 text-sm text-dark-text focus:border-blue-500/50 outline-none" - /> - {t('timerUnit')} -
-
-
- - {/* Seniority */} -
- - {t('seniority')} - -
- {DIFFICULTIES.map(d => { - const active = difficulties.includes(d) - return ( - - ) - })} -
-
- - {/* Tags */} -
- - {t('tags')} - -
inputRef.current?.focus()} - > - {tags.map(tag => ( - - {tag} +
+ + {/* Scrollable body */} +
+ + {/* Categories */} +
+ + {t('categories')} + +
+ {CATEGORIES.map(cat => { + const active = categories.includes(cat.slug) + const cnt = categoryCounts[cat.slug] ?? 0 + return ( + + ) + })} +
+
+ + {/* Count + Timer */} +
+
+ + {t('questionCount')} + + setCount(Math.max(1, parseInt(e.target.value) || 1))} + className="w-full bg-dark-surface border border-dark-border rounded px-3 py-1.5 text-sm text-dark-text focus:border-blue-500/50 outline-none" + /> +
+
+ + {t('timer')} + +
+ setTimerMinutes(e.target.value)} + className="w-full bg-dark-surface border border-dark-border rounded px-3 py-1.5 text-sm text-dark-text focus:border-blue-500/50 outline-none" + /> + {t('timerUnit')} +
+
+
+ + {/* Seniority */} +
+ + {t('seniority')} + +
+ {DIFFICULTIES.map(d => { + const active = difficulties.includes(d) + return ( + + ) + })} +
+
+ + {/* Tags */} +
+ + {t('tags')} + +
inputRef.current?.focus()} + > + {tags.map(tag => ( + + {tag} + + + ))} + setTagInput(e.target.value)} + onKeyDown={handleTagKeyDown} + placeholder={tags.length === 0 ? 'react, hooks, ...' : ''} + className="flex-1 bg-transparent text-sm text-dark-text placeholder-dark-muted outline-none min-w-[100px]" + /> +
+
+
+ + {/* Footer */} +
- - ))} - setTagInput(e.target.value)} - onKeyDown={handleTagKeyDown} - placeholder={tags.length === 0 ? 'react, hooks, ...' : ''} - className="flex-1 bg-transparent text-sm text-dark-text placeholder-dark-muted outline-none min-w-[100px]" - /> -
-
- - {/* Start */} -
- - {categories.length > 0 && maxCount > 0 && ( - - {availableCount} {t('questionsAvailable')} - - )} - {noResults && ( - {t('noQuestionsHint')} - )} -
-
+ {categories.length > 0 && maxCount > 0 && ( + + {availableCount} {t('questionsAvailable')} + + )} + {noResults && ( + {t('noQuestionsHint')} + )} +
+
+
, + document.body + )} + ) } From ea8d92bc022271bc88e5a7e0c9fbf326d7faf5b0 Mon Sep 17 00:00:00 2001 From: Dionei Bianchati Date: Fri, 5 Jun 2026 11:31:40 -0300 Subject: [PATCH 14/22] refactor: move QuizSetupPanel to page headers --- .../[locale]/questions/[category]/page.tsx | 19 +++++++++---------- src/app/[locale]/questions/page.tsx | 19 +++++++++---------- .../questions/QuestionListClient.tsx | 10 +--------- 3 files changed, 19 insertions(+), 29 deletions(-) diff --git a/src/app/[locale]/questions/[category]/page.tsx b/src/app/[locale]/questions/[category]/page.tsx index 4fc54e0..25102fd 100644 --- a/src/app/[locale]/questions/[category]/page.tsx +++ b/src/app/[locale]/questions/[category]/page.tsx @@ -4,6 +4,7 @@ import Link from 'next/link' import { getAllQuestionMeta } from '@/lib/content' import { getCategoryBySlug, CATEGORIES } from '@/lib/categories' import { QuestionListClient } from '@/components/questions/QuestionListClient' +import { QuizSetupPanel } from '@/components/quiz/QuizSetupPanel' type Props = { params: Promise<{ locale: string; category: string }> @@ -43,11 +44,13 @@ export default async function CategoryPage({ params }: Props) {
{cat.icon} -
-

- {t(`categories.${cat.slug}`)} -

-
+

+ {t(`categories.${cat.slug}`)} +

+
{questions.length === 0 ? ( @@ -58,11 +61,7 @@ export default async function CategoryPage({ params }: Props) {
) : ( - + )}
) diff --git a/src/app/[locale]/questions/page.tsx b/src/app/[locale]/questions/page.tsx index 35238be..4c0f380 100644 --- a/src/app/[locale]/questions/page.tsx +++ b/src/app/[locale]/questions/page.tsx @@ -24,9 +24,15 @@ export default async function QuestionsPage() { return (
-

- {t('categories.title')} -

+
+

+ {t('categories.title')} +

+ +

{t('categories.subtitle')}

@@ -35,13 +41,6 @@ export default async function QuestionsPage() { ))}
- -
- -
) } diff --git a/src/components/questions/QuestionListClient.tsx b/src/components/questions/QuestionListClient.tsx index dd50d4f..b783262 100644 --- a/src/components/questions/QuestionListClient.tsx +++ b/src/components/questions/QuestionListClient.tsx @@ -7,8 +7,6 @@ import { Badge } from '@/components/ui/Badge' import { FocusMode } from './FocusMode' import type { QuestionMeta } from '@/lib/content' import { QuestionCard } from './QuestionCard' -import { QuizSetupPanel } from '@/components/quiz/QuizSetupPanel' - const LEVELS = ['junior', 'pleno', 'senior', 'especialista'] as const const difficultyVariant: Record = { @@ -20,11 +18,9 @@ const difficultyVariant: Record = { type Props = { questions: QuestionMeta[] - categoryCounts: Record - currentCategory: string } -export function QuestionListClient({ questions, categoryCounts, currentCategory }: Props) { +export function QuestionListClient({ questions }: Props) { const t = useTranslations('categories') const tQuestion = useTranslations('question') @@ -152,10 +148,6 @@ export function QuestionListClient({ questions, categoryCounts, currentCategory return (
-
- -
- {/* Level filter */}
From 51147ee2345f8953295c73b45d1c6fac91b94f99 Mon Sep 17 00:00:00 2001 From: Dionei Bianchati Date: Fri, 5 Jun 2026 11:33:25 -0300 Subject: [PATCH 15/22] refactor: move quiz button to toolbar beside random question button --- .../[locale]/questions/[category]/page.tsx | 19 +++++++------- .../questions/QuestionListClient.tsx | 25 +++++++++++++------ 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/src/app/[locale]/questions/[category]/page.tsx b/src/app/[locale]/questions/[category]/page.tsx index 25102fd..4fc54e0 100644 --- a/src/app/[locale]/questions/[category]/page.tsx +++ b/src/app/[locale]/questions/[category]/page.tsx @@ -4,7 +4,6 @@ import Link from 'next/link' import { getAllQuestionMeta } from '@/lib/content' import { getCategoryBySlug, CATEGORIES } from '@/lib/categories' import { QuestionListClient } from '@/components/questions/QuestionListClient' -import { QuizSetupPanel } from '@/components/quiz/QuizSetupPanel' type Props = { params: Promise<{ locale: string; category: string }> @@ -44,13 +43,11 @@ export default async function CategoryPage({ params }: Props) {
{cat.icon} -

- {t(`categories.${cat.slug}`)} -

- +
+

+ {t(`categories.${cat.slug}`)} +

+
{questions.length === 0 ? ( @@ -61,7 +58,11 @@ export default async function CategoryPage({ params }: Props) {
) : ( - + )}
) diff --git a/src/components/questions/QuestionListClient.tsx b/src/components/questions/QuestionListClient.tsx index b783262..df3ac8e 100644 --- a/src/components/questions/QuestionListClient.tsx +++ b/src/components/questions/QuestionListClient.tsx @@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl' import { useEffect } from 'react' import { Badge } from '@/components/ui/Badge' import { FocusMode } from './FocusMode' +import { QuizSetupPanel } from '@/components/quiz/QuizSetupPanel' import type { QuestionMeta } from '@/lib/content' import { QuestionCard } from './QuestionCard' const LEVELS = ['junior', 'pleno', 'senior', 'especialista'] as const @@ -18,9 +19,11 @@ const difficultyVariant: Record = { type Props = { questions: QuestionMeta[] + categoryCounts: Record + currentCategory: string } -export function QuestionListClient({ questions }: Props) { +export function QuestionListClient({ questions, categoryCounts, currentCategory }: Props) { const t = useTranslations('categories') const tQuestion = useTranslations('question') @@ -239,13 +242,19 @@ export function QuestionListClient({ questions }: Props) { )} - +
+ + +
{/* Focus mode overlay */} From 094b0a9ede75bcd636c415a2a7658191f5d76038 Mon Sep 17 00:00:00 2001 From: Dionei Bianchati Date: Fri, 5 Jun 2026 11:42:10 -0300 Subject: [PATCH 16/22] fix: opaque modal backdrop, remove answer icons, rename to Acertei/Errei, add history to nav --- src/components/layout/Header.tsx | 7 +++++++ src/components/quiz/QuizHistoryList.tsx | 6 +++--- src/components/quiz/QuizQuestion.tsx | 6 +++--- src/components/quiz/QuizReport.tsx | 5 +++-- src/components/quiz/QuizSetupPanel.tsx | 2 +- src/messages/en.json | 4 ++-- src/messages/pt.json | 4 ++-- 7 files changed, 21 insertions(+), 13 deletions(-) diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 99d1aa0..9fe367a 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -5,6 +5,7 @@ import { useLocale, useTranslations } from "next-intl"; export function Header() { const t = useTranslations("nav"); + const tQuiz = useTranslations("quiz"); const locale = useLocale(); return ( @@ -24,6 +25,12 @@ export function Header() { > {t("questions")} + + {tQuiz("history")} + {date}
- ✅ {known} - 🤔 {partial} - ❌ {unknown} + {known} acertei + {partial} mais ou menos + {unknown} errei {score}%
diff --git a/src/components/quiz/QuizQuestion.tsx b/src/components/quiz/QuizQuestion.tsx index 70438df..28ad57f 100644 --- a/src/components/quiz/QuizQuestion.tsx +++ b/src/components/quiz/QuizQuestion.tsx @@ -123,19 +123,19 @@ export function QuizQuestion({ onClick={() => onAnswer('known')} className="flex-1 bg-green-700 hover:bg-green-600 text-white font-medium text-sm py-3 rounded-lg transition-colors" > - ✅ {t('known')} + {t('known')}
diff --git a/src/components/quiz/QuizReport.tsx b/src/components/quiz/QuizReport.tsx index 4e46b6c..666daba 100644 --- a/src/components/quiz/QuizReport.tsx +++ b/src/components/quiz/QuizReport.tsx @@ -85,7 +85,8 @@ export function QuizReport({ quiz, locale }: Props) {
{quiz.questions.map((q, i) => { const answer = quiz.answers[i] - const icon = answer === 'known' ? '✅' : answer === 'partial' ? '🤔' : '❌' + const answerLabel = answer === 'known' ? t('known') : answer === 'partial' ? t('partial') : t('unknown') + const answerColor = answer === 'known' ? 'text-green-400' : answer === 'partial' ? 'text-yellow-400' : 'text-red-400' return (
{q.title}
- {icon} + {answerLabel} setOpen(false)} >
Date: Fri, 5 Jun 2026 11:49:36 -0300 Subject: [PATCH 17/22] refactor: move history link to quiz modal footer, remove from nav --- src/components/layout/Header.tsx | 7 ----- src/components/quiz/QuizSetupPanel.tsx | 40 ++++++++++++++++---------- 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 9fe367a..99d1aa0 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -5,7 +5,6 @@ import { useLocale, useTranslations } from "next-intl"; export function Header() { const t = useTranslations("nav"); - const tQuiz = useTranslations("quiz"); const locale = useLocale(); return ( @@ -25,12 +24,6 @@ export function Header() { > {t("questions")} - - {tQuiz("history")} - {/* Footer */} -
- + {categories.length > 0 && maxCount > 0 && ( + + {availableCount} {t('questionsAvailable')} + + )} + {noResults && ( + {t('noQuestionsHint')} + )} +
+ setOpen(false)} + className="text-xs text-dark-muted hover:text-dark-heading transition-colors" > - {isPending ? '...' : `${t('start')} →`} - - {categories.length > 0 && maxCount > 0 && ( - - {availableCount} {t('questionsAvailable')} - - )} - {noResults && ( - {t('noQuestionsHint')} - )} + {t('viewHistory')} +
, From 5f4e8e2f481275574ae978c2bfea659ae04bae95 Mon Sep 17 00:00:00 2001 From: Dionei Bianchati Date: Fri, 5 Jun 2026 12:00:34 -0300 Subject: [PATCH 18/22] style: highlight random and create test buttons with solid colors --- src/components/questions/QuestionListClient.tsx | 2 +- src/components/quiz/QuizSetupPanel.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/questions/QuestionListClient.tsx b/src/components/questions/QuestionListClient.tsx index df3ac8e..1601a4f 100644 --- a/src/components/questions/QuestionListClient.tsx +++ b/src/components/questions/QuestionListClient.tsx @@ -246,7 +246,7 @@ export function QuestionListClient({ questions, categoryCounts, currentCategory diff --git a/src/components/quiz/QuizSetupPanel.tsx b/src/components/quiz/QuizSetupPanel.tsx index ab62a5c..98599d0 100644 --- a/src/components/quiz/QuizSetupPanel.tsx +++ b/src/components/quiz/QuizSetupPanel.tsx @@ -99,7 +99,7 @@ export function QuizSetupPanel({ preselectedCategories, categoryCounts }: Props) <> From 8a22a7216777daf000ebd170deea5813743977aa Mon Sep 17 00:00:00 2001 From: Dionei Bianchati Date: Fri, 5 Jun 2026 12:01:24 -0300 Subject: [PATCH 19/22] style: soften button highlight to colored border and text --- src/components/questions/QuestionListClient.tsx | 2 +- src/components/quiz/QuizSetupPanel.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/questions/QuestionListClient.tsx b/src/components/questions/QuestionListClient.tsx index 1601a4f..b841cc2 100644 --- a/src/components/questions/QuestionListClient.tsx +++ b/src/components/questions/QuestionListClient.tsx @@ -246,7 +246,7 @@ export function QuestionListClient({ questions, categoryCounts, currentCategory diff --git a/src/components/quiz/QuizSetupPanel.tsx b/src/components/quiz/QuizSetupPanel.tsx index 98599d0..0333c6a 100644 --- a/src/components/quiz/QuizSetupPanel.tsx +++ b/src/components/quiz/QuizSetupPanel.tsx @@ -99,7 +99,7 @@ export function QuizSetupPanel({ preselectedCategories, categoryCounts }: Props) <> From 382af52fba0803a6af2188338f52bf2405118a3c Mon Sep 17 00:00:00 2001 From: Dionei Bianchati Date: Fri, 5 Jun 2026 12:02:26 -0300 Subject: [PATCH 20/22] style: remove icons from random and create test buttons --- src/components/questions/QuestionListClient.tsx | 2 +- src/components/quiz/QuizSetupPanel.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/questions/QuestionListClient.tsx b/src/components/questions/QuestionListClient.tsx index b841cc2..7d4a521 100644 --- a/src/components/questions/QuestionListClient.tsx +++ b/src/components/questions/QuestionListClient.tsx @@ -248,7 +248,7 @@ export function QuestionListClient({ questions, categoryCounts, currentCategory disabled={filtered.length === 0} className="inline-flex items-center gap-2 border border-indigo-500/40 text-indigo-400 hover:border-indigo-500/70 hover:text-indigo-300 text-sm px-4 py-2 rounded-lg transition-colors disabled:opacity-40 disabled:pointer-events-none" > - 🎲 {t('randomQuestion')} + {t('randomQuestion')} setOpen(true)} className="inline-flex items-center gap-2 border border-blue-500/40 text-blue-400 hover:border-blue-500/70 hover:text-blue-300 text-sm px-4 py-2 rounded-lg transition-colors" > - 🎯 {t('createTest')} + {t('createTest')} {open && createPortal( @@ -116,7 +116,7 @@ export function QuizSetupPanel({ preselectedCategories, categoryCounts }: Props) {/* Header */}
- 🎯 {t('createTest')} + {t('createTest')}