Skip to content

Commit ef5b350

Browse files
feat(q&a): introduce localized questions schema and seeding
1 parent abf2ae5 commit ef5b350

13 files changed

Lines changed: 707 additions & 18184 deletions

File tree

Lines changed: 58 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,72 +1,78 @@
11
import { NextResponse } from 'next/server';
22
import { db } from '@/db';
3-
import { questions, categories } from '@/db/schema';
3+
import { questions } from '@/db/schema';
44
import { eq, sql, and, ilike } from 'drizzle-orm';
55

66
const DEFAULT_PAGE = 1;
7-
const DEFAULT_LIMIT = 10;
7+
const DEFAULT_LIMIT = 20;
8+
const DEFAULT_LOCALE = 'uk';
89

910
export async function GET(
1011
req: Request,
1112
ctx: { params: Promise<{ category: string }> }
1213
) {
13-
const { category } = await ctx.params;
14-
const { searchParams } = new URL(req.url);
14+
try {
15+
const { category } = await ctx.params;
16+
const { searchParams } = new URL(req.url);
1517

16-
const page = Math.max(
17-
1,
18-
parseInt(searchParams.get('page') || String(DEFAULT_PAGE), 10)
19-
);
20-
const limit = Math.max(
21-
1,
22-
Math.min(
18+
const page = Math.max(1, Number(searchParams.get('page') ?? DEFAULT_PAGE));
19+
const limit = Math.min(
2320
50,
24-
parseInt(searchParams.get('limit') || String(DEFAULT_LIMIT), 10)
25-
)
26-
);
27-
const search = searchParams.get('search')?.trim() || '';
28-
const offset = (page - 1) * limit;
21+
Math.max(1, Number(searchParams.get('limit') ?? DEFAULT_LIMIT))
22+
);
23+
const offset = (page - 1) * limit;
2924

30-
const cat = await db
31-
.select()
32-
.from(categories)
33-
.where(eq(categories.name, category))
34-
.limit(1);
25+
const locale =
26+
searchParams.get('locale') ||
27+
req.headers.get('x-locale') ||
28+
DEFAULT_LOCALE;
3529

36-
if (!cat.length) {
37-
return NextResponse.json({
38-
items: [],
39-
total: 0,
40-
page,
41-
totalPages: 0,
42-
});
43-
}
30+
const search = searchParams.get('search')?.trim();
31+
32+
const baseCondition = and(
33+
eq(questions.categorySlug, category.toLowerCase()),
34+
eq(questions.locale, locale)
35+
);
4436

45-
const baseCondition = eq(questions.categoryId, cat[0].id);
46-
const whereCondition = search
47-
? and(baseCondition, ilike(questions.question, `%${search}%`))
48-
: baseCondition;
37+
const whereCondition = search
38+
? and(baseCondition, ilike(questions.question, `%${search}%`))
39+
: baseCondition;
4940

50-
const [countResult] = await db
51-
.select({ count: sql<number>`count(*)` })
52-
.from(questions)
53-
.where(whereCondition);
41+
const [{ count }] = await db
42+
.select({ count: sql<number>`count(*)` })
43+
.from(questions)
44+
.where(whereCondition);
5445

55-
const total = Number(countResult.count);
56-
const totalPages = Math.ceil(total / limit);
46+
const total = Number(count);
47+
const totalPages = Math.ceil(total / limit);
5748

58-
const items = await db
59-
.select()
60-
.from(questions)
61-
.where(whereCondition)
62-
.orderBy(questions.id)
63-
.limit(limit)
64-
.offset(offset);
49+
const items = await db
50+
.select()
51+
.from(questions)
52+
.where(whereCondition)
53+
.orderBy(questions.sortOrder)
54+
.limit(limit)
55+
.offset(offset);
6556

66-
return NextResponse.json({
67-
items,
68-
total,
69-
page,
70-
totalPages,
71-
});
57+
return NextResponse.json({
58+
items,
59+
total,
60+
page,
61+
totalPages,
62+
locale,
63+
});
64+
} catch (error) {
65+
console.error('[GET /api/questions/:category]', error);
66+
67+
return NextResponse.json(
68+
{
69+
items: [],
70+
total: 0,
71+
page: 1,
72+
totalPages: 0,
73+
locale: DEFAULT_LOCALE,
74+
},
75+
{ status: 500 }
76+
);
77+
}
7278
}

frontend/components/q&a/TabsSection.tsx

Lines changed: 44 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
'use client';
22

33
import { useState, useEffect, useCallback, useRef } from 'react';
4-
import { useSearchParams } from 'next/navigation';
4+
import { useSearchParams, useParams } from 'next/navigation';
55
import { useRouter } from '@/i18n/routing';
66
import { useTranslations } from 'next-intl';
77
import { Search, X } from 'lucide-react';
8+
89
import AccordionList from '@/components/q&a/AccordionList';
910
import { Pagination } from '@/components/q&a/Pagination';
1011
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
@@ -24,8 +25,11 @@ export default function TabsSection() {
2425
const t = useTranslations('qa');
2526
const router = useRouter();
2627
const searchParams = useSearchParams();
28+
const params = useParams();
29+
30+
const locale = params.locale as string;
2731

28-
const pageFromUrl = parseInt(searchParams.get('page') || '1', 10);
32+
const pageFromUrl = Number(searchParams.get('page') || 1);
2933
const categoryFromUrl = searchParams.get('category') || DEFAULT_CATEGORY;
3034
const searchFromUrl = searchParams.get('search') || '';
3135

@@ -40,56 +44,59 @@ export default function TabsSection() {
4044
const [isLoading, setIsLoading] = useState(true);
4145

4246
const debounceRef = useRef<NodeJS.Timeout | null>(null);
47+
const mountedRef = useRef(false);
4348

4449
const updateUrl = useCallback(
4550
(category: string, page: number, search: string) => {
4651
const params = new URLSearchParams();
47-
if (category !== DEFAULT_CATEGORY) {
48-
params.set('category', category);
49-
}
50-
if (page > 1) {
51-
params.set('page', String(page));
52-
}
53-
if (search) {
54-
params.set('search', search);
55-
}
52+
53+
if (category !== DEFAULT_CATEGORY) params.set('category', category);
54+
if (page > 1) params.set('page', String(page));
55+
if (search) params.set('search', search);
56+
5657
const queryString = params.toString();
57-
router.push(`/q&a${queryString ? `?${queryString}` : ''}`, {
58+
59+
router.replace(`/q&a${queryString ? `?${queryString}` : ''}`, {
5860
scroll: false,
5961
});
6062
},
6163
[router]
6264
);
6365

6466
useEffect(() => {
65-
if (debounceRef.current) {
66-
clearTimeout(debounceRef.current);
67+
if (!mountedRef.current) {
68+
mountedRef.current = true;
69+
return;
6770
}
6871

72+
if (debounceRef.current) clearTimeout(debounceRef.current);
73+
6974
debounceRef.current = setTimeout(() => {
7075
setDebouncedSearch(searchQuery);
7176
setCurrentPage(1);
7277
updateUrl(active, 1, searchQuery);
7378
}, DEBOUNCE_MS);
7479

7580
return () => {
76-
if (debounceRef.current) {
77-
clearTimeout(debounceRef.current);
78-
}
81+
if (debounceRef.current) clearTimeout(debounceRef.current);
7982
};
80-
}, [searchQuery]);
83+
}, [searchQuery, active, updateUrl]);
8184

8285
useEffect(() => {
8386
async function load() {
8487
setIsLoading(true);
88+
8589
try {
8690
const searchParam = debouncedSearch
8791
? `&search=${encodeURIComponent(debouncedSearch)}`
8892
: '';
93+
8994
const res = await fetch(
90-
`/api/questions/${active}?page=${currentPage}&limit=10${searchParam}`
95+
`/api/questions/${active}?page=${currentPage}&limit=10&locale=${locale}${searchParam}`
9196
);
97+
9298
const data: PaginatedResponse = await res.json();
99+
93100
setItems(data.items);
94101
setTotalPages(data.totalPages);
95102
} catch (error) {
@@ -100,8 +107,9 @@ export default function TabsSection() {
100107
setIsLoading(false);
101108
}
102109
}
110+
103111
load();
104-
}, [active, currentPage, debouncedSearch]);
112+
}, [active, currentPage, debouncedSearch, locale]);
105113

106114
const handleCategoryChange = (category: string) => {
107115
setActive(category);
@@ -117,46 +125,35 @@ export default function TabsSection() {
117125
window.scrollTo({ top: 0, behavior: 'smooth' });
118126
};
119127

120-
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
121-
setSearchQuery(e.target.value);
122-
};
123-
124-
const handleClearSearch = () => {
125-
setSearchQuery('');
126-
setDebouncedSearch('');
127-
setCurrentPage(1);
128-
updateUrl(active, 1, '');
129-
};
130-
131128
return (
132129
<div className="w-full">
133130
<div className="relative mb-6">
134-
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
135-
<Search className="h-5 w-5 text-gray-400" />
136-
</div>
131+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
132+
137133
<input
138134
type="text"
139135
value={searchQuery}
140-
onChange={handleSearchChange}
136+
onChange={e => setSearchQuery(e.target.value)}
141137
placeholder={t('searchPlaceholder')}
142-
className="block w-full pl-10 pr-10 py-3 border border-gray-300 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors"
138+
className="w-full pl-10 pr-10 py-3 border rounded-lg"
143139
/>
140+
144141
{searchQuery && (
145142
<button
146-
onClick={handleClearSearch}
147-
className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
148-
aria-label={t('clearSearch')}
143+
onClick={() => {
144+
setSearchQuery('');
145+
setDebouncedSearch('');
146+
setCurrentPage(1);
147+
updateUrl(active, 1, '');
148+
}}
149+
className="absolute right-3 top-1/2 -translate-y-1/2"
149150
>
150151
<X className="h-5 w-5" />
151152
</button>
152153
)}
153154
</div>
154155

155-
<Tabs
156-
value={active}
157-
onValueChange={handleCategoryChange}
158-
className="w-full"
159-
>
156+
<Tabs value={active} onValueChange={handleCategoryChange}>
160157
<TabsList className="grid grid-cols-7 mb-6">
161158
{categoryNames.map(c => (
162159
<TabsTrigger key={c} value={c}>
@@ -169,12 +166,12 @@ export default function TabsSection() {
169166
<TabsContent key={c} value={c}>
170167
{isLoading ? (
171168
<div className="flex justify-center py-12">
172-
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
169+
<div className="animate-spin h-8 w-8 border-b-2" />
173170
</div>
174-
) : items.length > 0 ? (
171+
) : items.length ? (
175172
<AccordionList items={items} />
176173
) : (
177-
<p className="text-center py-12 text-gray-500 dark:text-gray-400">
174+
<p className="text-center py-12">
178175
{debouncedSearch
179176
? t('noResults', { query: debouncedSearch })
180177
: t('noQuestions')}

frontend/db/schema/categories.ts

Lines changed: 14 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,14 @@
1-
import { pgTable, serial, text, jsonb, integer } from 'drizzle-orm/pg-core';
2-
import { relations } from 'drizzle-orm';
3-
4-
export const categories = pgTable('categories', {
5-
id: serial('id').primaryKey(),
6-
name: text('name').notNull().unique(),
7-
});
8-
9-
export const questions = pgTable('questions', {
10-
id: serial('id').primaryKey(),
11-
question: text('question').notNull(),
12-
answerBlocks: jsonb('answer_blocks').notNull(),
13-
categoryId: integer('category_id')
14-
.notNull()
15-
.references(() => categories.id, { onDelete: 'cascade' }),
16-
});
17-
18-
export const categoriesRelations = relations(categories, ({ many }) => ({
19-
questions: many(questions),
20-
}));
21-
22-
export const questionsRelations = relations(questions, ({ one }) => ({
23-
category: one(categories, {
24-
fields: [questions.categoryId],
25-
references: [categories.id],
26-
}),
27-
}));
1+
import { pgTable, uuid, varchar, text, unique } from 'drizzle-orm/pg-core';
2+
3+
export const categories = pgTable(
4+
'categories',
5+
{
6+
id: uuid('id').defaultRandom().primaryKey(),
7+
slug: varchar('slug', { length: 50 }).notNull(),
8+
locale: varchar('locale', { length: 5 }).notNull(),
9+
title: text('title').notNull(),
10+
},
11+
table => [
12+
unique('categories_slug_locale_unique').on(table.slug, table.locale),
13+
]
14+
);

frontend/db/schema/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from './categories';
2+
export * from './questions';
23
export * from './quiz';
34
export * from './users';
45
export * from './points';
5-
export * from "./shop";
6+
export * from './shop';

0 commit comments

Comments
 (0)