diff --git a/frontend/app/api/questions/[category]/route.ts b/frontend/app/api/questions/[category]/route.ts index f4dffc65..bcee00aa 100644 --- a/frontend/app/api/questions/[category]/route.ts +++ b/frontend/app/api/questions/[category]/route.ts @@ -29,7 +29,6 @@ type QaApiResponse = { function dedupeItems(items: QaApiResponse['items']) { const seenById = new Set(); - const seenByText = new Set(); const unique: QaApiResponse['items'] = []; for (const item of items) { @@ -37,13 +36,7 @@ function dedupeItems(items: QaApiResponse['items']) { continue; } - const textKey = `${item.locale}:${item.question.trim().toLowerCase()}`; - if (seenByText.has(textKey)) { - continue; - } - seenById.add(item.id); - seenByText.add(textKey); unique.push(item); } diff --git a/frontend/components/about/HeroSection.tsx b/frontend/components/about/HeroSection.tsx index f48163ea..f78aaefb 100644 --- a/frontend/components/about/HeroSection.tsx +++ b/frontend/components/about/HeroSection.tsx @@ -16,7 +16,7 @@ export function HeroSection({ stats }: { stats?: PlatformStats }) { questionsSolved: '850+', githubStars: '120+', activeUsers: '200+', - linkedinFollowers: '1.7k+', + linkedinFollowers: '1.8k+', }; return ( diff --git a/frontend/components/q&a/AccordionList.tsx b/frontend/components/q&a/AccordionList.tsx index 2f7f4893..e0496b42 100644 --- a/frontend/components/q&a/AccordionList.tsx +++ b/frontend/components/q&a/AccordionList.tsx @@ -7,7 +7,8 @@ import { useEffect, useState, } from 'react'; -import { Bookmark } from 'lucide-react'; +import { Bookmark, CheckCircle } from 'lucide-react'; +import { useTranslations } from 'next-intl'; import { Badge } from '@/components/ui/badge'; import AIWordHelper from '@/components/q&a/AIWordHelper'; @@ -75,7 +76,7 @@ function writeStoredQuestionIds(storageKey: string, ids: Set) { function getQuestionStorageId(question: QuestionEntry): string { if (question.id !== undefined && question.id !== null) { - return String(question.id); + return `${question.category}:${String(question.id)}`; } return `${question.category}:${question.question}`; @@ -354,7 +355,14 @@ function renderBlock( } } -export default function AccordionList({ items }: { items: QuestionEntry[] }) { +export default function AccordionList({ + items, + totalItems, +}: { + items: QuestionEntry[]; + totalItems: number; +}) { + const t = useTranslations('qa'); const [selectedText, setSelectedText] = useState(null); const [buttonPosition, setButtonPosition] = useState<{ x: number; @@ -474,8 +482,81 @@ export default function AccordionList({ items }: { items: QuestionEntry[] }) { }); }, []); + const categoryKey = items[0]?.category ?? null; + const viewedCategoryCount = categoryKey + ? [...viewedItems].filter(id => id.startsWith(`${categoryKey}:`)).length + : 0; + const viewedPercentage = + totalItems > 0 + ? Math.round((viewedCategoryCount / totalItems) * 100) + : 0; + const progressAccent = + items[0]?.category + ? categoryTabStyles[items[0].category as keyof typeof categoryTabStyles] + ?.accent ?? 'var(--accent-primary)' + : 'var(--accent-primary)'; + const progressTrackBorder = hexToRgba(progressAccent, 0.38); + const progressFill = `linear-gradient(90deg, ${hexToRgba(progressAccent, 0.72)} 0%, ${hexToRgba(progressAccent, 0.18)} 100%)`; + + const resetVisibleProgress = useCallback(() => { + if (!categoryKey) return; + + setViewedItems(prev => { + const next = new Set( + [...prev].filter(id => !id.startsWith(`${categoryKey}:`)) + ); + writeStoredQuestionIds(QA_VIEWED_STORAGE_KEY, next); + return next; + }); + + setBookmarkedItems(prev => { + const next = new Set( + [...prev].filter(id => !id.startsWith(`${categoryKey}:`)) + ); + writeStoredQuestionIds(QA_BOOKMARK_STORAGE_KEY, next); + return next; + }); + }, [categoryKey]); + return ( <> +
+
+
+ {t('progressLabel')}:{' '} + + {viewedCategoryCount}/{totalItems} + +
+ +
+
+
+
+
+ {items.map((q, idx) => { const key = q.id ?? idx; @@ -501,48 +582,54 @@ export default function AccordionList({ items }: { items: QuestionEntry[] }) { > markAsViewed(questionId)} trailing={ - isViewed ? ( - - ) : ( - diff --git a/frontend/components/q&a/QaSection.tsx b/frontend/components/q&a/QaSection.tsx index a8ebb0c3..f0cb231f 100644 --- a/frontend/components/q&a/QaSection.tsx +++ b/frontend/components/q&a/QaSection.tsx @@ -29,6 +29,7 @@ export default function TabsSection() { localeKey, pageSize, pageSizeOptions, + totalItems, totalPages, } = useQaTabs(); const animationKey = useMemo( @@ -115,7 +116,11 @@ export default function TabsSection() { aria-busy={isLoading} > {items.length ? ( - + ) : (
{emptyStateLines[0] && ( diff --git a/frontend/components/q&a/useQaTabs.ts b/frontend/components/q&a/useQaTabs.ts index f7eb4c29..ff4cbf6e 100644 --- a/frontend/components/q&a/useQaTabs.ts +++ b/frontend/components/q&a/useQaTabs.ts @@ -49,6 +49,7 @@ export function useQaTabs() { const [currentPage, setCurrentPage] = useState(safePageFromUrl); const [pageSize, setPageSize] = useState(safePageSizeFromUrl); const [items, setItems] = useState([]); + const [totalItems, setTotalItems] = useState(0); const [totalPages, setTotalPages] = useState(0); const [isLoading, setIsLoading] = useState(true); @@ -111,6 +112,7 @@ export function useQaTabs() { answerBlocks: item.answerBlocks, })) ); + setTotalItems(data.total); setTotalPages(data.totalPages); } catch (error) { if (!isActive || controller.signal.aborted) { @@ -118,6 +120,7 @@ export function useQaTabs() { } console.error('Failed to load questions:', error); setItems([]); + setTotalItems(0); setTotalPages(0); } finally { if (isActive) { @@ -177,6 +180,7 @@ export function useQaTabs() { localeKey, pageSize, pageSizeOptions: PAGE_SIZE_OPTIONS, + totalItems, totalPages, }; } diff --git a/frontend/components/shared/GitHubStarButton.tsx b/frontend/components/shared/GitHubStarButton.tsx index 13794f8c..e540da35 100644 --- a/frontend/components/shared/GitHubStarButton.tsx +++ b/frontend/components/shared/GitHubStarButton.tsx @@ -20,13 +20,21 @@ interface GitHubStarButtonProps { export function GitHubStarButton({ className = '' }: GitHubStarButtonProps) { const t = useTranslations('aria'); - const [storedStars] = useState(getStoredStars); - const [displayCount, setDisplayCount] = useState(storedStars ?? 0); - const [finalCount, setFinalCount] = useState(storedStars); + const [displayCount, setDisplayCount] = useState(0); + const [finalCount, setFinalCount] = useState(null); const githubUrl = 'https://github.com/DevLoversTeam/devlovers.net'; useEffect(() => { - if (storedStars !== null) return; + const cachedStars = getStoredStars(); + + if (cachedStars !== null) { + const frame = window.requestAnimationFrame(() => { + setDisplayCount(cachedStars); + setFinalCount(cachedStars); + }); + + return () => window.cancelAnimationFrame(frame); + } const fetchStars = async () => { try { @@ -58,7 +66,7 @@ export function GitHubStarButton({ className = '' }: GitHubStarButtonProps) { }, []); useEffect(() => { - if (finalCount === null || storedStars !== null) return; + if (finalCount === null || finalCount === displayCount) return; const duration = 2000; const steps = 60; @@ -79,7 +87,7 @@ export function GitHubStarButton({ className = '' }: GitHubStarButtonProps) { }, duration / steps); return () => clearInterval(timer); - }, [finalCount, storedStars]); + }, [displayCount, finalCount]); const formatStarCount = (count: number): string => { return count.toLocaleString(); diff --git a/frontend/components/tests/q&a/accordion-list.test.tsx b/frontend/components/tests/q&a/accordion-list.test.tsx index ee58d5eb..92855223 100644 --- a/frontend/components/tests/q&a/accordion-list.test.tsx +++ b/frontend/components/tests/q&a/accordion-list.test.tsx @@ -25,6 +25,10 @@ vi.mock('@/lib/ai/explainCache', () => ({ getCachedTerms: () => getCachedTermsMock(), })); +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, +})); + vi.mock('@/components/ui/accordion', () => ({ Accordion: ({ children }: { children: React.ReactNode }) => (
{children}
@@ -36,11 +40,13 @@ vi.mock('@/components/ui/accordion', () => ({ children, leading, trailing, + chevronOutside, onClick, }: { children: React.ReactNode; leading?: React.ReactNode; trailing?: React.ReactNode; + chevronOutside?: boolean; onClick?: () => void; }) => (
@@ -49,6 +55,7 @@ vi.mock('@/components/ui/accordion', () => ({ {children} {trailing} + {chevronOutside ? : null}
), AccordionContent: ({ children }: { children: React.ReactNode }) => ( @@ -163,7 +170,7 @@ describe('AccordionList', () => { }, ]; - render(); + render(); fireEvent.click(screen.getByText('What is CSS?')); @@ -189,10 +196,104 @@ describe('AccordionList', () => { }, ]; - render(); + render(); expect(screen.getByText('What is CSS?')).toBeTruthy(); expect(screen.getByText('CSS styles pages.')).toBeTruthy(); + expect(screen.getByText('progressLabel:')).toBeTruthy(); + expect(screen.getByText('0/1')).toBeTruthy(); + }); + + it('marks an accordion as viewed after opening it', () => { + const items: QuestionEntry[] = [ + { + id: 'q1', + question: 'What is CSS?', + category: 'css', + answerBlocks: [ + { + type: 'paragraph', + children: [{ text: 'CSS styles pages.' }], + }, + ], + }, + ]; + + render(); + + expect( + screen.queryByRole('button', { name: 'Add bookmark' }) + ).toBeNull(); + + fireEvent.click(screen.getByText('What is CSS?')); + + expect( + screen.getByRole('button', { name: 'Add bookmark' }) + ).toBeTruthy(); + expect( + JSON.parse( + localStorage.getItem('devlovers_qa_viewed_questions') ?? '[]' + ) + ).toContain('css:q1'); + expect(screen.getByText('1/1')).toBeTruthy(); + }); + + it('resets progress for visible accordion items', () => { + const items: QuestionEntry[] = [ + { + id: 'q1', + question: 'What is CSS?', + category: 'css', + answerBlocks: [ + { + type: 'paragraph', + children: [{ text: 'CSS styles pages.' }], + }, + ], + }, + ]; + + render(); + + fireEvent.click(screen.getByText('What is CSS?')); + fireEvent.click(screen.getByText('resetProgress')); + + expect(screen.getByText('0/1')).toBeTruthy(); + expect( + JSON.parse( + localStorage.getItem('devlovers_qa_viewed_questions') ?? '[]' + ) + ).not.toContain('css:q1'); + }); + + it('toggles bookmark state for viewed accordion', () => { + const items: QuestionEntry[] = [ + { + id: 'q1', + question: 'What is CSS?', + category: 'css', + answerBlocks: [ + { + type: 'paragraph', + children: [{ text: 'CSS styles pages.' }], + }, + ], + }, + ]; + + render(); + + fireEvent.click(screen.getByText('What is CSS?')); + fireEvent.click(screen.getByRole('button', { name: 'Add bookmark' })); + + expect( + screen.getByRole('button', { name: 'Remove bookmark' }) + ).toBeTruthy(); + expect( + JSON.parse( + localStorage.getItem('devlovers_qa_bookmarked_questions') ?? '[]' + ) + ).toContain('css:q1'); }); it('marks an accordion as viewed after opening it', () => { @@ -273,7 +374,7 @@ describe('AccordionList', () => { }, ]; - render(); + render(); fireEvent.click(screen.getByText('select-text')); fireEvent.click(screen.getByText('explain')); @@ -298,7 +399,7 @@ describe('AccordionList', () => { }, ]; - render(); + render(); fireEvent.click(screen.getByText('HTML')); @@ -320,7 +421,7 @@ describe('AccordionList', () => { }, ]; - render(); + render(); fireEvent.click(screen.getByText('select-text')); expect(screen.getByText('explain')).toBeTruthy(); @@ -344,7 +445,7 @@ describe('AccordionList', () => { }, ]; - render(); + render(); fireEvent.click(screen.getByText('select-text')); fireEvent.click(screen.getByText('explain')); @@ -416,7 +517,7 @@ describe('AccordionList', () => { }, ]; - render(); + render(); expect(screen.getByText('Bold').tagName).toBe('STRONG'); expect(screen.getByText('Italic').tagName).toBe('EM'); diff --git a/frontend/components/tests/q&a/qa-section.test.tsx b/frontend/components/tests/q&a/qa-section.test.tsx index be0c042d..99d7a97c 100644 --- a/frontend/components/tests/q&a/qa-section.test.tsx +++ b/frontend/components/tests/q&a/qa-section.test.tsx @@ -15,6 +15,7 @@ const qaState = { isLoading: false, items: [] as unknown[], localeKey: 'en', + totalItems: 0, totalPages: 0, }; @@ -24,7 +25,13 @@ vi.mock('@/components/q&a/useQaTabs', () => ({ vi.mock('@/components/q&a/AccordionList', () => ({ __esModule: true, - default: ({ items }: { items: unknown[] }) => ( + default: ({ + items, + totalItems, + }: { + items: unknown[]; + totalItems: number; + }) => (
{items.length}
), })); @@ -61,6 +68,7 @@ describe('QaSection', () => { it('renders category tabs and pagination', () => { qaState.totalPages = 3; qaState.items = [{ id: 'q1' }]; + qaState.totalItems = 42; render(); const buttons = screen.getAllByRole('button'); diff --git a/frontend/components/tests/q&a/use-qa-tabs.test.tsx b/frontend/components/tests/q&a/use-qa-tabs.test.tsx index aa46a2d8..d24aa18d 100644 --- a/frontend/components/tests/q&a/use-qa-tabs.test.tsx +++ b/frontend/components/tests/q&a/use-qa-tabs.test.tsx @@ -64,6 +64,7 @@ describe('useQaTabs', () => { expect.objectContaining({ signal: expect.any(AbortSignal) }) ); expect(result.current.items).toHaveLength(1); + expect(result.current.totalItems).toBe(1); }); it('updates page and URL on page change', async () => { @@ -136,6 +137,7 @@ describe('useQaTabs', () => { }); expect(result.current.items).toEqual([]); + expect(result.current.totalItems).toBe(0); expect(result.current.totalPages).toBe(0); consoleSpy.mockRestore(); }); diff --git a/frontend/components/ui/accordion.tsx b/frontend/components/ui/accordion.tsx index 7cb92c5a..fc78083c 100644 --- a/frontend/components/ui/accordion.tsx +++ b/frontend/components/ui/accordion.tsx @@ -30,15 +30,20 @@ function AccordionTrigger({ children, leading, trailing, + chevronOutside = false, ...props }: React.ComponentProps & { leading?: React.ReactNode; trailing?: React.ReactNode; + chevronOutside?: boolean; }) { + const triggerRef = React.useRef(null); + return ( - + {leading} svg]:rotate-180', @@ -47,12 +52,27 @@ function AccordionTrigger({ {...props} > {children} - {trailing} + {chevronOutside && ( + + )} ); } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index af269a5b..de7e2386 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -127,6 +127,8 @@ "subtitle": "Explore curated interview questions by category", "searchPlaceholder": "Search questions...", "clearSearch": "Clear search", + "progressLabel": "Progress", + "resetProgress": "Reset progress", "noResults": "Nothing found for \"{query}\"", "noQuestions": "No questions yet…\nBut they’re already in development.\nStay tuned - new content coming soon!", "metaTitle": "Q&A | DevLovers", diff --git a/frontend/messages/pl.json b/frontend/messages/pl.json index dc50ac59..5bab4ce7 100644 --- a/frontend/messages/pl.json +++ b/frontend/messages/pl.json @@ -127,6 +127,8 @@ "subtitle": "Przeglądaj wybrane pytania według kategorii", "searchPlaceholder": "Szukaj pytań...", "clearSearch": "Wyczyść wyszukiwanie", + "progressLabel": "Postęp", + "resetProgress": "Resetuj postęp", "noResults": "Nic nie znaleziono dla \"{query}\"", "noQuestions": "Brak pytań na ten moment…\nAle już są w przygotowaniu.\nBądź na bieżąco - wkrótce pojawią się nowe materiały!", "metaTitle": "Q&A | DevLovers", diff --git a/frontend/messages/uk.json b/frontend/messages/uk.json index 14f46b0c..84f59e23 100644 --- a/frontend/messages/uk.json +++ b/frontend/messages/uk.json @@ -127,6 +127,8 @@ "subtitle": "Переглядайте добірку питань за категоріями", "searchPlaceholder": "Пошук...", "clearSearch": "Очистити пошук", + "progressLabel": "Прогрес", + "resetProgress": "Скинути прогрес", "noResults": "Нічого не знайдено за запитом \"{query}\"", "noQuestions": "Питань поки що немає…\nАле вони вже в розробці.\nСлідкуйте за оновленнями - скоро зʼявиться новий контент!", "metaTitle": "Q&A | DevLovers",