From 9f1f66d1b831579026dce5824c25e5b4be1b9c8c Mon Sep 17 00:00:00 2001 From: Viktor Svertoka Date: Sun, 11 Jan 2026 23:30:13 +0200 Subject: [PATCH 1/5] refactor(qa): extract tabs logic and align layout --- frontend/app/[locale]/q&a/page.tsx | 6 +- frontend/components/q&a/AccordionList.tsx | 81 ++------- frontend/components/q&a/QaSection.tsx | 95 ++++++++++ frontend/components/q&a/TabsSection.tsx | 201 ---------------------- frontend/components/q&a/types.ts | 97 +++++++++++ frontend/components/q&a/useQaTabs.ts | 171 ++++++++++++++++++ 6 files changed, 380 insertions(+), 271 deletions(-) create mode 100644 frontend/components/q&a/QaSection.tsx delete mode 100644 frontend/components/q&a/TabsSection.tsx create mode 100644 frontend/components/q&a/types.ts create mode 100644 frontend/components/q&a/useQaTabs.ts diff --git a/frontend/app/[locale]/q&a/page.tsx b/frontend/app/[locale]/q&a/page.tsx index 7cd98ec2..f0bf623a 100644 --- a/frontend/app/[locale]/q&a/page.tsx +++ b/frontend/app/[locale]/q&a/page.tsx @@ -1,6 +1,6 @@ import { Suspense } from 'react'; import { getTranslations } from 'next-intl/server'; -import TabsSection from '@/components/q&a/TabsSection'; +import QaSection from '@/components/q&a/QaSection'; export async function generateMetadata({ params, @@ -18,9 +18,9 @@ export async function generateMetadata({ export default function QAPage() { return ( -
+
...}> - +
); diff --git a/frontend/components/q&a/AccordionList.tsx b/frontend/components/q&a/AccordionList.tsx index f4d52044..dbab8608 100644 --- a/frontend/components/q&a/AccordionList.tsx +++ b/frontend/components/q&a/AccordionList.tsx @@ -9,73 +9,20 @@ import { } from '@/components/ui/accordion'; import CodeBlock from '@/components/q&a/CodeBlock'; - -type TextNode = { - text: string; - bold?: boolean; - italic?: boolean; - code?: boolean; - boldItalic?: boolean; -}; - -type CodeBlock = { - type: 'code'; - language: string | null; - content: string; -}; - -type ListEntry = ListItemBlock | ListItemChild; - -type BulletListBlock = { - type: 'bulletList'; - children: ListEntry[]; -}; - -type NumberedListBlock = { - type: 'numberedList'; - children: ListEntry[]; -}; - -type ListItemChild = TextNode | CodeBlock | BulletListBlock | NumberedListBlock; - -type ListItemBlock = { - type: 'listItem'; - children: ListItemChild[]; -}; - -type ParagraphBlock = { - type: 'paragraph'; - children: TextNode[]; -}; - -type HeadingBlock = { - type: 'heading'; - level: 3 | 4; - children: TextNode[]; -}; - -type TableCell = TextNode[]; - -type TableBlock = { - type: 'table'; - header: TableCell[]; - rows: TableCell[][]; -}; - -type AnswerBlock = - | ParagraphBlock - | HeadingBlock - | BulletListBlock - | NumberedListBlock - | CodeBlock - | TableBlock; - -type QuestionEntry = { - id?: number | string; - question: string; - category: string; - answerBlocks: AnswerBlock[]; -}; +import type { + AnswerBlock, + BulletListBlock, + HeadingBlock, + ListEntry, + ListItemBlock, + ListItemChild, + NumberedListBlock, + ParagraphBlock, + QuestionEntry, + TableBlock, + TableCell, + TextNode, +} from '@/components/q&a/types'; function isListItemBlock(value: ListEntry): value is ListItemBlock { return ( diff --git a/frontend/components/q&a/QaSection.tsx b/frontend/components/q&a/QaSection.tsx new file mode 100644 index 00000000..4291f56d --- /dev/null +++ b/frontend/components/q&a/QaSection.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { Search, X } from 'lucide-react'; + +import AccordionList from '@/components/q&a/AccordionList'; +import { Pagination } from '@/components/q&a/Pagination'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; +import { categoryData } from '@/data/category'; +import { useQaTabs } from '@/components/q&a/useQaTabs'; + +export default function TabsSection() { + const t = useTranslations('qa'); + const { + active, + currentPage, + debouncedSearch, + handleCategoryChange, + handlePageChange, + isLoading, + items, + localeKey, + searchQuery, + setSearchQuery, + totalPages, + clearSearch, + } = useQaTabs(); + + return ( +
+
+ + + setSearchQuery(e.target.value)} + placeholder={t('searchPlaceholder')} + className="w-full pl-10 pr-10 py-3 border rounded-lg" + /> + + {searchQuery && ( + + )} +
+ + + + {categoryData.map(category => ( + + {category.translations[localeKey] ?? + category.translations.en ?? + category.slug} + + ))} + + + {categoryData.map(category => ( + + {isLoading ? ( +
+
+
+ ) : items.length ? ( + + ) : ( +

+ {debouncedSearch + ? t('noResults', { query: debouncedSearch }) + : t('noQuestions')} +

+ )} + + ))} + + + {!isLoading && totalPages > 1 && ( + + )} +
+ ); +} diff --git a/frontend/components/q&a/TabsSection.tsx b/frontend/components/q&a/TabsSection.tsx deleted file mode 100644 index d9dcdeb4..00000000 --- a/frontend/components/q&a/TabsSection.tsx +++ /dev/null @@ -1,201 +0,0 @@ -'use client'; - -import { useState, useEffect, useCallback, useRef } from 'react'; -import { useSearchParams, useParams } from 'next/navigation'; -import { useRouter } from '@/i18n/routing'; -import { useTranslations } from 'next-intl'; -import { Search, X } from 'lucide-react'; - -import AccordionList from '@/components/q&a/AccordionList'; -import { Pagination } from '@/components/q&a/Pagination'; -import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; -import { categoryData } from '@/data/category'; - -const CATEGORY_SLUGS = categoryData.map(category => category.slug); -const DEFAULT_CATEGORY = CATEGORY_SLUGS[0] || 'html'; -const DEBOUNCE_MS = 400; - -interface PaginatedResponse { - items: any[]; - total: number; - page: number; - totalPages: number; -} - -export default function TabsSection() { - const t = useTranslations('qa'); - const router = useRouter(); - const searchParams = useSearchParams(); - const params = useParams(); - - const locale = params.locale as string; - const localeKey = (['uk', 'en', 'pl'] as const).includes(locale as 'uk' | 'en' | 'pl') - ? (locale as 'uk' | 'en' | 'pl') - : 'en'; - - const pageFromUrl = Number(searchParams.get('page') || 1); - const categoryFromUrl = searchParams.get('category') || DEFAULT_CATEGORY; - const searchFromUrl = searchParams.get('search') || ''; - - const [active, setActive] = useState( - CATEGORY_SLUGS.includes(categoryFromUrl) - ? categoryFromUrl - : DEFAULT_CATEGORY - ); - const [currentPage, setCurrentPage] = useState(pageFromUrl); - const [searchQuery, setSearchQuery] = useState(searchFromUrl); - const [debouncedSearch, setDebouncedSearch] = useState(searchFromUrl); - const [items, setItems] = useState([]); - const [totalPages, setTotalPages] = useState(0); - const [isLoading, setIsLoading] = useState(true); - - const debounceRef = useRef(null); - const mountedRef = useRef(false); - - const updateUrl = useCallback( - (category: string, page: number, search: string) => { - const params = new URLSearchParams(); - - if (category !== DEFAULT_CATEGORY) params.set('category', category); - if (page > 1) params.set('page', String(page)); - if (search) params.set('search', search); - - const queryString = params.toString(); - - router.replace(`/q&a${queryString ? `?${queryString}` : ''}`, { - scroll: false, - }); - }, - [router] - ); - - useEffect(() => { - if (!mountedRef.current) { - mountedRef.current = true; - return; - } - - if (debounceRef.current) clearTimeout(debounceRef.current); - - debounceRef.current = setTimeout(() => { - setDebouncedSearch(searchQuery); - setCurrentPage(1); - updateUrl(active, 1, searchQuery); - }, DEBOUNCE_MS); - - return () => { - if (debounceRef.current) clearTimeout(debounceRef.current); - }; - }, [searchQuery, active, updateUrl]); - - useEffect(() => { - async function load() { - setIsLoading(true); - - try { - const searchParam = debouncedSearch - ? `&search=${encodeURIComponent(debouncedSearch)}` - : ''; - - const res = await fetch( - `/api/questions/${active}?page=${currentPage}&limit=10&locale=${locale}${searchParam}` - ); - - const data: PaginatedResponse = await res.json(); - - setItems(data.items); - setTotalPages(data.totalPages); - } catch (error) { - console.error('Failed to load questions:', error); - setItems([]); - setTotalPages(0); - } finally { - setIsLoading(false); - } - } - - load(); - }, [active, currentPage, debouncedSearch, locale]); - - const handleCategoryChange = (category: string) => { - setActive(category); - setCurrentPage(1); - setSearchQuery(''); - setDebouncedSearch(''); - updateUrl(category, 1, ''); - }; - - const handlePageChange = (page: number) => { - setCurrentPage(page); - updateUrl(active, page, debouncedSearch); - window.scrollTo({ top: 0, behavior: 'smooth' }); - }; - - return ( -
-
- - - setSearchQuery(e.target.value)} - placeholder={t('searchPlaceholder')} - className="w-full pl-10 pr-10 py-3 border rounded-lg" - /> - - {searchQuery && ( - - )} -
- - - - {categoryData.map(category => ( - - {category.translations[localeKey] ?? - category.translations.en ?? - category.slug} - - ))} - - - {categoryData.map(category => ( - - {isLoading ? ( -
-
-
- ) : items.length ? ( - - ) : ( -

- {debouncedSearch - ? t('noResults', { query: debouncedSearch }) - : t('noQuestions')} -

- )} - - ))} - - - {!isLoading && totalPages > 1 && ( - - )} -
- ); -} diff --git a/frontend/components/q&a/types.ts b/frontend/components/q&a/types.ts new file mode 100644 index 00000000..3c097667 --- /dev/null +++ b/frontend/components/q&a/types.ts @@ -0,0 +1,97 @@ +'use client'; + +import { categoryData } from '@/data/category'; + +const SUPPORTED_LOCALES = ['uk', 'en', 'pl'] as const; + +export type Locale = (typeof SUPPORTED_LOCALES)[number]; +export type CategorySlug = (typeof categoryData)[number]['slug']; + +export type TextNode = { + text: string; + bold?: boolean; + italic?: boolean; + code?: boolean; + boldItalic?: boolean; +}; + +export type CodeBlock = { + type: 'code'; + language: string | null; + content: string; +}; + +export type ListEntry = ListItemBlock | ListItemChild; + +export type BulletListBlock = { + type: 'bulletList'; + children: ListEntry[]; +}; + +export type NumberedListBlock = { + type: 'numberedList'; + children: ListEntry[]; +}; + +export type ListItemChild = TextNode | CodeBlock | BulletListBlock | NumberedListBlock; + +export type ListItemBlock = { + type: 'listItem'; + children: ListItemChild[]; +}; + +export type ParagraphBlock = { + type: 'paragraph'; + children: TextNode[]; +}; + +export type HeadingBlock = { + type: 'heading'; + level: 3 | 4; + children: TextNode[]; +}; + +export type TableCell = TextNode[]; + +export type TableBlock = { + type: 'table'; + header: TableCell[]; + rows: TableCell[][]; +}; + +export type AnswerBlock = + | ParagraphBlock + | HeadingBlock + | BulletListBlock + | NumberedListBlock + | CodeBlock + | TableBlock; + +export interface QuestionEntry { + id?: number | string; + question: string; + category: string; + answerBlocks: AnswerBlock[]; +} + +export interface QuestionApiItem { + id: string; + categoryId: string; + sortOrder: number | null; + difficulty: string | null; + question: string; + answerBlocks: AnswerBlock[]; + locale: Locale; +} + +export interface PaginatedResponse { + items: T[]; + total: number; + page: number; + totalPages: number; + locale: Locale; +} + +export const qaConstants = { + supportedLocales: SUPPORTED_LOCALES, +} as const; diff --git a/frontend/components/q&a/useQaTabs.ts b/frontend/components/q&a/useQaTabs.ts new file mode 100644 index 00000000..cc9df608 --- /dev/null +++ b/frontend/components/q&a/useQaTabs.ts @@ -0,0 +1,171 @@ +'use client'; + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { useSearchParams, useParams } from 'next/navigation'; +import { useRouter } from '@/i18n/routing'; +import { categoryData } from '@/data/category'; +import { + qaConstants, + type CategorySlug, + type Locale, + type PaginatedResponse, + type QuestionApiItem, + type QuestionEntry, +} from '@/components/q&a/types'; + +const CATEGORY_SLUGS = categoryData.map(category => category.slug); +const DEFAULT_CATEGORY = CATEGORY_SLUGS[0] || 'html'; +const DEBOUNCE_MS = 400; + +function resolveLocale(value: string): Locale { + return qaConstants.supportedLocales.includes(value as Locale) + ? (value as Locale) + : 'en'; +} + +function isCategorySlug(value: string): value is CategorySlug { + return CATEGORY_SLUGS.includes(value); +} + +export function useQaTabs() { + const router = useRouter(); + const searchParams = useSearchParams(); + const params = useParams(); + + const locale = params.locale as string; + const localeKey = resolveLocale(locale); + + const pageFromUrl = Number(searchParams.get('page') || 1); + const categoryFromUrl = searchParams.get('category') || DEFAULT_CATEGORY; + const searchFromUrl = searchParams.get('search') || ''; + + const [active, setActive] = useState( + isCategorySlug(categoryFromUrl) ? categoryFromUrl : DEFAULT_CATEGORY + ); + const [currentPage, setCurrentPage] = useState(pageFromUrl); + const [searchQuery, setSearchQuery] = useState(searchFromUrl); + const [debouncedSearch, setDebouncedSearch] = useState(searchFromUrl); + const [items, setItems] = useState([]); + const [totalPages, setTotalPages] = useState(0); + const [isLoading, setIsLoading] = useState(true); + + const debounceRef = useRef | null>(null); + const mountedRef = useRef(false); + + const updateUrl = useCallback( + (category: CategorySlug, page: number, search: string) => { + const params = new URLSearchParams(); + + if (category !== DEFAULT_CATEGORY) params.set('category', category); + if (page > 1) params.set('page', String(page)); + if (search) params.set('search', search); + + const queryString = params.toString(); + + router.replace(`/q&a${queryString ? `?${queryString}` : ''}`, { + scroll: false, + }); + }, + [router] + ); + + useEffect(() => { + if (!mountedRef.current) { + mountedRef.current = true; + return; + } + + if (debounceRef.current) clearTimeout(debounceRef.current); + + debounceRef.current = setTimeout(() => { + setDebouncedSearch(searchQuery); + setCurrentPage(1); + updateUrl(active, 1, searchQuery); + }, DEBOUNCE_MS); + + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [searchQuery, active, updateUrl]); + + useEffect(() => { + async function load() { + setIsLoading(true); + + try { + const searchParam = debouncedSearch + ? `&search=${encodeURIComponent(debouncedSearch)}` + : ''; + + const res = await fetch( + `/api/questions/${active}?page=${currentPage}&limit=10&locale=${locale}${searchParam}` + ); + + const data: PaginatedResponse = await res.json(); + + setItems( + data.items.map(item => ({ + id: item.id, + question: item.question, + category: active, + answerBlocks: item.answerBlocks, + })) + ); + setTotalPages(data.totalPages); + } catch (error) { + console.error('Failed to load questions:', error); + setItems([]); + setTotalPages(0); + } finally { + setIsLoading(false); + } + } + + load(); + }, [active, currentPage, debouncedSearch, locale]); + + const handleCategoryChange = useCallback( + (category: string) => { + if (!isCategorySlug(category)) { + return; + } + setActive(category); + setCurrentPage(1); + setSearchQuery(''); + setDebouncedSearch(''); + updateUrl(category, 1, ''); + }, + [updateUrl] + ); + + const handlePageChange = useCallback( + (page: number) => { + setCurrentPage(page); + updateUrl(active, page, debouncedSearch); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }, + [active, debouncedSearch, updateUrl] + ); + + const clearSearch = useCallback(() => { + setSearchQuery(''); + setDebouncedSearch(''); + setCurrentPage(1); + updateUrl(active, 1, ''); + }, [active, updateUrl]); + + return { + active, + currentPage, + debouncedSearch, + handleCategoryChange, + handlePageChange, + isLoading, + items, + localeKey, + searchQuery, + setSearchQuery, + totalPages, + clearSearch, + }; +} From 79f0a68e135b66f939a965d63d65de6aed8744ed Mon Sep 17 00:00:00 2001 From: Viktor Svertoka Date: Sun, 11 Jan 2026 23:53:58 +0200 Subject: [PATCH 2/5] fix(qa): resolve CodeBlock type collision in AccordionList --- frontend/components/q&a/AccordionList.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/components/q&a/AccordionList.tsx b/frontend/components/q&a/AccordionList.tsx index dbab8608..b1538f06 100644 --- a/frontend/components/q&a/AccordionList.tsx +++ b/frontend/components/q&a/AccordionList.tsx @@ -12,6 +12,7 @@ import CodeBlock from '@/components/q&a/CodeBlock'; import type { AnswerBlock, BulletListBlock, + CodeBlock as CodeBlockEntry, HeadingBlock, ListEntry, ListItemBlock, @@ -20,7 +21,6 @@ import type { ParagraphBlock, QuestionEntry, TableBlock, - TableCell, TextNode, } from '@/components/q&a/types'; @@ -92,7 +92,7 @@ function renderTextNodes(nodes: TextNode[]): ReactNode { return nodes.map((node, i) => renderTextNode(node, i)); } -function renderCodeBlock(block: CodeBlock, index: number): ReactNode { +function renderCodeBlock(block: CodeBlockEntry, index: number): ReactNode { return ( ); From f23f8316b5165ba8575ea0becdbd4888023cf75e Mon Sep 17 00:00:00 2001 From: Viktor Svertoka Date: Sun, 11 Jan 2026 23:59:03 +0200 Subject: [PATCH 3/5] fix(qa): guard page param and check response status --- frontend/components/q&a/useQaTabs.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/frontend/components/q&a/useQaTabs.ts b/frontend/components/q&a/useQaTabs.ts index cc9df608..d7074edf 100644 --- a/frontend/components/q&a/useQaTabs.ts +++ b/frontend/components/q&a/useQaTabs.ts @@ -35,14 +35,17 @@ export function useQaTabs() { const locale = params.locale as string; const localeKey = resolveLocale(locale); - const pageFromUrl = Number(searchParams.get('page') || 1); + const rawPage = searchParams.get('page'); + const pageFromUrl = rawPage ? Number(rawPage) : 1; + const safePageFromUrl = + Number.isFinite(pageFromUrl) && pageFromUrl > 0 ? pageFromUrl : 1; const categoryFromUrl = searchParams.get('category') || DEFAULT_CATEGORY; const searchFromUrl = searchParams.get('search') || ''; const [active, setActive] = useState( isCategorySlug(categoryFromUrl) ? categoryFromUrl : DEFAULT_CATEGORY ); - const [currentPage, setCurrentPage] = useState(pageFromUrl); + const [currentPage, setCurrentPage] = useState(safePageFromUrl); const [searchQuery, setSearchQuery] = useState(searchFromUrl); const [debouncedSearch, setDebouncedSearch] = useState(searchFromUrl); const [items, setItems] = useState([]); @@ -101,6 +104,10 @@ export function useQaTabs() { `/api/questions/${active}?page=${currentPage}&limit=10&locale=${locale}${searchParam}` ); + if (!res.ok) { + throw new Error(`Failed to load questions: ${res.status}`); + } + const data: PaginatedResponse = await res.json(); setItems( From f44bf20a5f4460e8e612fd606d0e0cab14c5e064 Mon Sep 17 00:00:00 2001 From: Viktor Svertoka Date: Mon, 12 Jan 2026 00:05:38 +0200 Subject: [PATCH 4/5] fix(qa): use validated locale in questions fetch --- frontend/components/q&a/useQaTabs.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/components/q&a/useQaTabs.ts b/frontend/components/q&a/useQaTabs.ts index d7074edf..90496c87 100644 --- a/frontend/components/q&a/useQaTabs.ts +++ b/frontend/components/q&a/useQaTabs.ts @@ -101,7 +101,7 @@ export function useQaTabs() { : ''; const res = await fetch( - `/api/questions/${active}?page=${currentPage}&limit=10&locale=${locale}${searchParam}` + `/api/questions/${active}?page=${currentPage}&limit=10&locale=${localeKey}${searchParam}` ); if (!res.ok) { @@ -129,7 +129,7 @@ export function useQaTabs() { } load(); - }, [active, currentPage, debouncedSearch, locale]); + }, [active, currentPage, debouncedSearch, localeKey]); const handleCategoryChange = useCallback( (category: string) => { From 6354dfd076003eb255d9a4aeed766f1fc8075544 Mon Sep 17 00:00:00 2001 From: Viktor Svertoka Date: Mon, 12 Jan 2026 00:13:00 +0200 Subject: [PATCH 5/5] fix(qa): guard locale param and cancel inflight fetch --- frontend/components/q&a/useQaTabs.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/frontend/components/q&a/useQaTabs.ts b/frontend/components/q&a/useQaTabs.ts index 90496c87..cd585deb 100644 --- a/frontend/components/q&a/useQaTabs.ts +++ b/frontend/components/q&a/useQaTabs.ts @@ -32,7 +32,8 @@ export function useQaTabs() { const searchParams = useSearchParams(); const params = useParams(); - const locale = params.locale as string; + const locale = + typeof params.locale === 'string' ? params.locale : params.locale?.[0] ?? ''; const localeKey = resolveLocale(locale); const rawPage = searchParams.get('page'); @@ -92,6 +93,8 @@ export function useQaTabs() { }, [searchQuery, active, updateUrl]); useEffect(() => { + const controller = new AbortController(); + async function load() { setIsLoading(true); @@ -101,7 +104,8 @@ export function useQaTabs() { : ''; const res = await fetch( - `/api/questions/${active}?page=${currentPage}&limit=10&locale=${localeKey}${searchParam}` + `/api/questions/${active}?page=${currentPage}&limit=10&locale=${localeKey}${searchParam}`, + { signal: controller.signal } ); if (!res.ok) { @@ -129,6 +133,9 @@ export function useQaTabs() { } load(); + return () => { + controller.abort(); + }; }, [active, currentPage, debouncedSearch, localeKey]); const handleCategoryChange = useCallback(