Skip to content

Commit aa5654b

Browse files
authored
feat(i18n): localize quiz anti-cheat, header and blog filters (#175)
1 parent ecb7478 commit aa5654b

11 files changed

Lines changed: 178 additions & 53 deletions

File tree

frontend/components/blog/BlogCategoryLinks.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
'use client';
22

3+
import { useTranslations } from 'next-intl';
34
import { Link, usePathname } from '@/i18n/routing';
45
import { cn } from '@/lib/utils';
56

@@ -21,7 +22,22 @@ export function BlogCategoryLinks({
2122
linkClassName,
2223
onNavigate,
2324
}: BlogCategoryLinksProps) {
25+
const t = useTranslations('blog');
26+
const tNav = useTranslations('navigation');
2427
const pathname = usePathname();
28+
29+
// Helper function to get translated category label
30+
const getCategoryLabel = (categoryName: string): string => {
31+
const key = categoryName.toLowerCase() as 'tech' | 'career' | 'insights' | 'news' | 'growth';
32+
const categoryTranslations: Record<string, string> = {
33+
tech: t('categories.tech'),
34+
career: t('categories.career'),
35+
insights: t('categories.insights'),
36+
news: t('categories.news'),
37+
growth: t('categories.growth'),
38+
};
39+
return categoryTranslations[key] || categoryName;
40+
};
2541
const baseLink =
2642
linkClassName ||
2743
'rounded-md px-3 py-2 text-sm font-medium transition-colors ' +
@@ -53,7 +69,7 @@ export function BlogCategoryLinks({
5369
isActive ? 'bg-muted text-foreground' : 'text-muted-foreground'
5470
)}
5571
>
56-
{category.displayTitle}
72+
{getCategoryLabel(category.displayTitle)}
5773
</Link>
5874
);
5975
})}
@@ -66,7 +82,7 @@ export function BlogCategoryLinks({
6682
pathname === '/' ? 'bg-muted text-foreground' : 'text-muted-foreground'
6783
)}
6884
>
69-
Home
85+
{tNav('home')}
7086
</Link>
7187
</nav>
7288
);

frontend/components/blog/BlogFilters.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,20 @@ export default function BlogFilters({
133133
setSelectedAuthor(null);
134134
setSelectedCategory(null);
135135
};
136+
137+
// Helper function to get translated category label
138+
const getCategoryLabel = (categoryName: string): string => {
139+
const key = categoryName.toLowerCase() as 'tech' | 'career' | 'insights' | 'news' | 'growth';
140+
const categoryTranslations: Record<string, string> = {
141+
tech: t('categories.tech'),
142+
career: t('categories.career'),
143+
insights: t('categories.insights'),
144+
news: t('categories.news'),
145+
growth: t('categories.growth'),
146+
};
147+
return categoryTranslations[key] || categoryName;
148+
};
149+
136150
const allCategories = useMemo(() => {
137151
if (categories.length) {
138152
return categories
@@ -343,7 +357,7 @@ export default function BlogFilters({
343357
: 'rounded-full border border-gray-300 bg-white px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 transition dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800'
344358
}
345359
>
346-
All
360+
{t('all')}
347361
</button>
348362
{allCategories.map(category => (
349363
<button
@@ -362,7 +376,7 @@ export default function BlogFilters({
362376
: 'rounded-full border border-gray-300 bg-white px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 transition dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800'
363377
}
364378
>
365-
{category.name}
379+
{getCategoryLabel(category.name)}
366380
</button>
367381
))}
368382
</div>

frontend/components/blog/BlogHeaderSearch.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useEffect, useMemo, useRef, useState } from 'react';
44
import { Search } from 'lucide-react';
5+
import { useTranslations } from 'next-intl';
56
import { useRouter } from '@/i18n/routing';
67

78
type PostSearchItem = {
@@ -36,6 +37,7 @@ function extractSnippet(body: PostSearchItem['body'], query: string) {
3637
const SEARCH_ENDPOINT = '/api/blog-search';
3738

3839
export function BlogHeaderSearch() {
40+
const t = useTranslations('blog');
3941
const [open, setOpen] = useState(false);
4042
const [value, setValue] = useState('');
4143
const [items, setItems] = useState<PostSearchItem[]>([]);
@@ -156,7 +158,7 @@ export function BlogHeaderSearch() {
156158
onKeyDown={event => {
157159
if (event.key === 'Escape') setOpen(false);
158160
}}
159-
placeholder="What're we looking for ?"
161+
placeholder={t('searchPlaceholder')}
160162
className="w-full bg-transparent text-sm text-foreground outline-none"
161163
style={{ fontFamily: 'Lato, system-ui, -apple-system, sans-serif' }}
162164
/>
@@ -200,7 +202,7 @@ export function BlogHeaderSearch() {
200202
)}
201203
{value && !results.length && !isLoading && (
202204
<div className="border-t border-border px-3 py-2 text-xs text-muted-foreground">
203-
No matches
205+
{t('noMatches')}
204206
</div>
205207
)}
206208
</div>

frontend/components/header/AppMobileMenu.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { Menu, X } from 'lucide-react';
44
import { useEffect, useMemo, useState } from 'react';
5+
import { useTranslations } from 'next-intl';
56
import { Link } from '@/i18n/routing';
67

78
import { SITE_LINKS } from '@/lib/navigation';
@@ -24,6 +25,7 @@ export function AppMobileMenu({
2425
showAdminLink = false,
2526
blogCategories = [],
2627
}: Props) {
28+
const t = useTranslations('navigation');
2729
const [open, setOpen] = useState(false);
2830

2931
const close = () => setOpen(false);
@@ -79,7 +81,7 @@ export function AppMobileMenu({
7981
onClick={close}
8082
className="rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
8183
>
82-
Home
84+
{t('home')}
8385
</Link>
8486
) : null}
8587

@@ -98,7 +100,7 @@ export function AppMobileMenu({
98100
onClick={close}
99101
className="rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
100102
>
101-
{link.label}
103+
{'labelKey' in link ? t(link.labelKey) : link.label}
102104
</Link>
103105
))
104106
)}
@@ -122,7 +124,7 @@ export function AppMobileMenu({
122124
onClick={close}
123125
className="rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
124126
>
125-
Dashboard
127+
{t('dashboard')}
126128
</Link>
127129

128130
{showAdminLink ? (
@@ -147,7 +149,7 @@ export function AppMobileMenu({
147149
onClick={close}
148150
className="rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
149151
>
150-
Log in
152+
{t('login')}
151153
</Link>
152154
)}
153155
</div>

frontend/components/header/UnifiedHeader.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
'use client';
22
import { LogIn, Settings, User } from 'lucide-react';
3+
import { useTranslations } from 'next-intl';
34
import { Link } from '@/i18n/routing';
45
import { SITE_LINKS } from '@/lib/navigation';
56

@@ -28,10 +29,11 @@ export function UnifiedHeader({
2829
showAdminLink = false,
2930
blogCategories = [],
3031
}: UnifiedHeaderProps) {
32+
const t = useTranslations('navigation');
3133
const isShop = variant === 'shop';
3234
const isBlog = variant === 'blog';
3335
const brandHref = isShop ? '/shop' : isBlog ? '/blog' : '/';
34-
const brandBadge = isShop ? 'Shop' : isBlog ? 'Blog' : '';
36+
const brandBadge = isShop ? t('shop') : isBlog ? t('blog') : '';
3537

3638
return (
3739
<header className="sticky top-0 z-50 w-full border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
@@ -69,7 +71,7 @@ export function UnifiedHeader({
6971
href={link.href}
7072
className="rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
7173
>
72-
{link.label}
74+
{t(link.labelKey)}
7375
</Link>
7476
))}
7577
</div>
@@ -109,7 +111,7 @@ export function UnifiedHeader({
109111
className="inline-flex items-center gap-2 rounded-md bg-secondary px-3 py-2 text-sm font-medium text-foreground transition-colors hover:opacity-90"
110112
>
111113
<LogIn className="h-4 w-4" />
112-
Log in
114+
{t('login')}
113115
</Link>
114116
) : (
115117
<LogoutButton />

frontend/components/shared/LanguageSwitcher.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ import { useParams, usePathname,useSearchParams } from 'next/navigation';
55
import { locales, type Locale } from '@/i18n/config';
66
import { Link } from '@/i18n/routing';
77

8+
const localeLabels: Record<Locale, string> = {
9+
uk: 'UA',
10+
en: 'EN',
11+
pl: 'PL',
12+
};
13+
814
export default function LanguageSwitcher() {
915
const [isOpen, setIsOpen] = useState(false);
1016
const dropdownRef = useRef<HTMLDivElement>(null);
@@ -37,9 +43,9 @@ export default function LanguageSwitcher() {
3743
<div className="relative" ref={dropdownRef}>
3844
<button
3945
onClick={() => setIsOpen(!isOpen)}
40-
className="flex items-center gap-1 text-gray-700 dark:text-gray-300 font-medium hover:text-blue-600 dark:hover:text-blue-400 transition uppercase"
46+
className="flex items-center gap-1 text-gray-700 dark:text-gray-300 font-medium hover:text-blue-600 dark:hover:text-blue-400 transition"
4147
>
42-
{currentLocale}
48+
{localeLabels[currentLocale]}
4349
<svg
4450
className={`h-4 w-4 transition-transform ${
4551
isOpen ? 'rotate-180' : ''
@@ -70,13 +76,13 @@ export default function LanguageSwitcher() {
7076
}
7177
setIsOpen(false);
7278
}}
73-
className={`block px-4 py-2 text-sm uppercase transition ${
79+
className={`block px-4 py-2 text-sm transition ${
7480
currentLocale === locale
7581
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 font-medium'
7682
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-neutral-800'
7783
}`}
7884
>
79-
{locale}
85+
{localeLabels[locale]}
8086
</Link>
8187
))}
8288
</div>

frontend/hooks/useAntiCheat.ts

Lines changed: 32 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,53 @@
11
'use client';
22

3-
import { useEffect, useRef, useState } from 'react';
3+
import { useCallback, useEffect, useRef, useState } from 'react';
4+
import { useTranslations } from 'next-intl';
45
import { toast } from 'sonner';
56

67
export type AntiCheatViolation = {
78
type: 'copy' | 'context-menu' | 'tab-switch' | 'paste';
89
timestamp: Date;
910
};
1011

12+
const messageKey: Record<AntiCheatViolation['type'], string> = {
13+
copy: 'copy',
14+
paste: 'paste',
15+
'context-menu': 'contextMenu',
16+
'tab-switch': 'tabSwitch',
17+
};
18+
1119
export function useAntiCheat(isActive: boolean = true) {
20+
const t = useTranslations('quiz.antiCheat');
1221
const [violations, setViolations] = useState<AntiCheatViolation[]>([]);
1322
const [isTabActive, setIsTabActive] = useState(true);
1423
const [showWarning, setShowWarning] = useState(false);
1524
const warningTimeoutRef = useRef<NodeJS.Timeout | null>(null);
1625

17-
const addViolation = (type: AntiCheatViolation['type']) => {
18-
if (!isActive) return;
19-
20-
const violation: AntiCheatViolation = {
21-
type,
22-
timestamp: new Date(),
23-
};
26+
const addViolation = useCallback(
27+
(type: AntiCheatViolation['type']) => {
28+
if (!isActive) return;
2429

25-
setViolations(prev => [...prev, violation]);
26-
setShowWarning(true);
30+
const violation: AntiCheatViolation = {
31+
type,
32+
timestamp: new Date(),
33+
};
2734

28-
const messages = {
29-
copy: '⚠️ Копіювання заборонено під час квізу',
30-
paste: '⚠️ Вставка заборонена під час квізу',
31-
'context-menu': '⚠️ Контекстне меню заборонено під час квізу',
32-
'tab-switch': '⚠️ Перехід на іншу вкладку зафіксовано',
33-
};
35+
setViolations(prev => [...prev, violation]);
36+
setShowWarning(true);
3437

35-
toast.warning(messages[type], {
36-
duration: 3000,
37-
});
38+
toast.warning(t(messageKey[type]), {
39+
duration: 3000,
40+
});
3841

39-
if (warningTimeoutRef.current) {
40-
clearTimeout(warningTimeoutRef.current);
41-
}
42-
warningTimeoutRef.current = setTimeout(() => {
43-
setShowWarning(false);
44-
}, 3000);
45-
};
42+
if (warningTimeoutRef.current) {
43+
clearTimeout(warningTimeoutRef.current);
44+
}
45+
warningTimeoutRef.current = setTimeout(() => {
46+
setShowWarning(false);
47+
}, 3000);
48+
},
49+
[isActive, t]
50+
);
4651

4752
useEffect(() => {
4853
if (!isActive) return;
@@ -86,7 +91,7 @@ export function useAntiCheat(isActive: boolean = true) {
8691
clearTimeout(warningTimeoutRef.current);
8792
}
8893
};
89-
}, [isActive]);
94+
}, [isActive, addViolation]);
9095

9196
const resetViolations = () => {
9297
setViolations([]);

frontend/lib/navigation.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
export const SITE_LINKS = [
2-
{ href: '/q&a', label: 'Q&A' },
3-
{ href: '/quizzes', label: 'Quizzes' },
4-
{ href: '/leaderboard', label: 'Leaderboard' },
5-
{ href: '/blog', label: 'Blog' },
6-
{ href: '/about', label: 'About' },
7-
{ href: '/contacts', label: 'Contacts' },
8-
{ href: '/shop', label: 'Shop' },
2+
{ href: '/q&a', labelKey: 'qa' },
3+
{ href: '/quizzes', labelKey: 'quizzes' },
4+
{ href: '/leaderboard', labelKey: 'leaderboard' },
5+
{ href: '/blog', labelKey: 'blog' },
6+
{ href: '/about', labelKey: 'about' },
7+
{ href: '/contacts', labelKey: 'contacts' },
8+
{ href: '/shop', labelKey: 'shop' },
99
] as const;

0 commit comments

Comments
 (0)