Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions frontend/components/blog/BlogCategoryLinks.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client';

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

Expand All @@ -21,7 +22,22 @@ export function BlogCategoryLinks({
linkClassName,
onNavigate,
}: BlogCategoryLinksProps) {
const t = useTranslations('blog');
const tNav = useTranslations('navigation');
const pathname = usePathname();

// Helper function to get translated category label
const getCategoryLabel = (categoryName: string): string => {
const key = categoryName.toLowerCase() as 'tech' | 'career' | 'insights' | 'news' | 'growth';
const categoryTranslations: Record<string, string> = {
tech: t('categories.tech'),
career: t('categories.career'),
insights: t('categories.insights'),
news: t('categories.news'),
growth: t('categories.growth'),
};
return categoryTranslations[key] || categoryName;
};
const baseLink =
linkClassName ||
'rounded-md px-3 py-2 text-sm font-medium transition-colors ' +
Expand Down Expand Up @@ -53,7 +69,7 @@ export function BlogCategoryLinks({
isActive ? 'bg-muted text-foreground' : 'text-muted-foreground'
)}
>
{category.displayTitle}
{getCategoryLabel(category.displayTitle)}
</Link>
);
})}
Expand All @@ -66,7 +82,7 @@ export function BlogCategoryLinks({
pathname === '/' ? 'bg-muted text-foreground' : 'text-muted-foreground'
)}
>
Home
{tNav('home')}
</Link>
</nav>
);
Expand Down
18 changes: 16 additions & 2 deletions frontend/components/blog/BlogFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,20 @@ export default function BlogFilters({
setSelectedAuthor(null);
setSelectedCategory(null);
};

// Helper function to get translated category label
const getCategoryLabel = (categoryName: string): string => {
const key = categoryName.toLowerCase() as 'tech' | 'career' | 'insights' | 'news' | 'growth';
const categoryTranslations: Record<string, string> = {
tech: t('categories.tech'),
career: t('categories.career'),
insights: t('categories.insights'),
news: t('categories.news'),
growth: t('categories.growth'),
};
return categoryTranslations[key] || categoryName;
};
Comment on lines +137 to +148
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

next-intl t.raw() method missing keys error handling behavior

💡 Result:

Short answer: when t.raw(...) (or any t(...) call) can't find a message next-intl logs an i18n error and returns the message id (namespace.key) so the app keeps running. You can customize this via getMessageFallback and onError (e.g., return a different fallback string or change logging/throwing). [1][2]

Relevant docs:

  • Default missing-message behavior and configurable onError/getMessageFallback. [1]
  • next-intl general usage (t and t.raw for raw objects). [2]

If you want the runtime to behave differently for t.raw specifically (e.g., throw or return null), implement getMessageFallback/onError in your request/provider config to handle IntlErrorCode.MISSING_MESSAGE. [1][3]


🌐 Web query:

next-intl t.has() method documentation and usage

💡 Result:

Short answer

  • next-intl exposes t.has(key) to check whether a translation key exists for the current locale; it returns a boolean. Use it to conditionally render optional/incomplete translations. [1][2]

Example

  • In a React component:
    const t = useTranslations('About');
    if (t.has('title')) {
    return

    {t('title')}

    ;
    } else {
    return

    Fallback

    ;
    }
  • t.has accepts the same key strings you pass to t (including nested/namespace keys like 'CompanyStats.yearsOfService.title'). [1]

Notes

  • t.has only checks the current locale’s messages (you can still provide fallback messages via your locale setup). [1]
  • The feature was discussed as the recommended way to handle optional translations in the project’s issue tracker. [2]

Sources
[1] next-intl docs — "Optional messages" / translations usage.
[2] amannn/next-intl GitHub — issue discussing optional translations and t.has.


🏁 Script executed:

# Find next-intl configuration and setup
fd -t f "(next-intl|i18n|translation)" --full-path | head -20

Repository: DevLoversTeam/devlovers.net

Length of output: 144


🏁 Script executed:

# Check package.json for next-intl version
cat package.json | grep -A 2 -B 2 "next-intl"

Repository: DevLoversTeam/devlovers.net

Length of output: 115


🏁 Script executed:

# Search for other t.raw() usage in codebase
rg "t\.raw\(" --type ts --type tsx -A 2 -B 2

Repository: DevLoversTeam/devlovers.net

Length of output: 98


🏁 Script executed:

# Search for t.has() usage in codebase
rg "t\.has\(" --type ts --type tsx -A 2 -B 2

Repository: DevLoversTeam/devlovers.net

Length of output: 98


🏁 Script executed:

# Look for translation function patterns
rg "\.has\(|t\(" frontend/ --type ts --type tsx | head -30

Repository: DevLoversTeam/devlovers.net

Length of output: 98


🏁 Script executed:

# Check the i18n config files
cat -n frontend/i18n/config.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 393


🏁 Script executed:

# Check the i18n request file
cat -n frontend/i18n/request.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 566


🏁 Script executed:

# Search for all translation usage patterns
rg "t\(" frontend/ -A 1 -B 1 | head -60

Repository: DevLoversTeam/devlovers.net

Length of output: 3426


🏁 Script executed:

# Look for how categories are used and if translations are failing
rg "categories\." --max-count 20

Repository: DevLoversTeam/devlovers.net

Length of output: 4597


🏁 Script executed:

# Find where package.json is and check next-intl version
find . -name "package.json" -type f 2>/dev/null | head -5

Repository: DevLoversTeam/devlovers.net

Length of output: 116


🏁 Script executed:

# Check next-intl version in frontend package.json
cat frontend/package.json | grep -A 5 -B 5 "next-intl"

Repository: DevLoversTeam/devlovers.net

Length of output: 382


🏁 Script executed:

# Check the BlogFilters.tsx file to see the full context
cat -n frontend/components/blog/BlogFilters.tsx | sed -n '130,160p'

Repository: DevLoversTeam/devlovers.net

Length of output: 1281


🏁 Script executed:

# Look at message files to see what translations exist for categories
ls -la frontend/messages/

Repository: DevLoversTeam/devlovers.net

Length of output: 363


🏁 Script executed:

# Check if categories translations exist
rg "categories\." frontend/messages/ --max-count 30

Repository: DevLoversTeam/devlovers.net

Length of output: 53


🏁 Script executed:

# Check the message files for categories translations
python3 << 'EOF'
import json

for locale in ['en', 'pl', 'uk']:
    with open(f'frontend/messages/{locale}.json', 'r') as f:
        data = json.load(f)
    
    # Look for categories keys
    if 'categories' in data:
        print(f"\n{locale}.json - categories translations:")
        categories = data['categories']
        if isinstance(categories, dict):
            for key in list(categories.keys())[:10]:
                print(f"  {key}: {categories[key]}")
        else:
            print(f"  (not a dict: {type(categories)})")
    else:
        print(f"\n{locale}.json - NO 'categories' key found")
EOF

Repository: DevLoversTeam/devlovers.net

Length of output: 157


🏁 Script executed:

# Check BlogCategoryLinks.tsx to see if it also uses getCategoryLabel
cat -n frontend/components/blog/BlogCategoryLinks.tsx | grep -A 10 -B 5 "translationKey"

Repository: DevLoversTeam/devlovers.net

Length of output: 909


The fallback logic in getCategoryLabel doesn't work as intended; use t.has() to properly check translation existence.

The typeof translated === 'string' check will always pass because t.raw() returns a string regardless—either the translation value or the message id (e.g., "categories.career") when the key is missing. This means missing translations return the message id instead of falling back to the original category name.

Use t.has() to check if the translation exists before accessing it:

♻️ Suggested fix
  const getCategoryLabel = (categoryName: string): string => {
    const key = categoryName.toLowerCase();
    const translationKey = `categories.${key}`;
-   // Try to get translation, fallback to original name
-   const translated = t.raw(translationKey);
-   return typeof translated === 'string' ? translated : categoryName;
+   return t.has(translationKey) ? t(translationKey) : categoryName;
  };

This same pattern appears in BlogCategoryLinks.tsx (lines 30–35) and should be updated there as well.

🤖 Prompt for AI Agents
In `@frontend/components/blog/BlogFilters.tsx` around lines 137 - 144, The
getCategoryLabel helper currently uses typeof translated === 'string' after
calling t.raw(), which always returns a string (the id when missing); change the
logic to first check t.has(translationKey) and only call t.raw(translationKey)
when t.has(...) is true, otherwise return the original categoryName; apply the
same fix in the BlogCategoryLinks component where the same t.raw(...) fallback
pattern is used so both getCategoryLabel and the category link label logic use
t.has(...) to determine translation existence before reading t.raw(...).


const allCategories = useMemo(() => {
if (categories.length) {
return categories
Expand Down Expand Up @@ -343,7 +357,7 @@ export default function BlogFilters({
: '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'
}
>
All
{t('all')}
</button>
{allCategories.map(category => (
<button
Expand All @@ -362,7 +376,7 @@ export default function BlogFilters({
: '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'
}
>
{category.name}
{getCategoryLabel(category.name)}
</button>
))}
</div>
Expand Down
6 changes: 4 additions & 2 deletions frontend/components/blog/BlogHeaderSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useEffect, useMemo, useRef, useState } from 'react';
import { Search } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useRouter } from '@/i18n/routing';

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

export function BlogHeaderSearch() {
const t = useTranslations('blog');
const [open, setOpen] = useState(false);
const [value, setValue] = useState('');
const [items, setItems] = useState<PostSearchItem[]>([]);
Expand Down Expand Up @@ -156,7 +158,7 @@ export function BlogHeaderSearch() {
onKeyDown={event => {
if (event.key === 'Escape') setOpen(false);
}}
placeholder="What're we looking for ?"
placeholder={t('searchPlaceholder')}
className="w-full bg-transparent text-sm text-foreground outline-none"
style={{ fontFamily: 'Lato, system-ui, -apple-system, sans-serif' }}
/>
Expand Down Expand Up @@ -200,7 +202,7 @@ export function BlogHeaderSearch() {
)}
{value && !results.length && !isLoading && (
<div className="border-t border-border px-3 py-2 text-xs text-muted-foreground">
No matches
{t('noMatches')}
</div>
)}
</div>
Expand Down
10 changes: 6 additions & 4 deletions frontend/components/header/AppMobileMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { Menu, X } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Link } from '@/i18n/routing';

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

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

Expand All @@ -98,7 +100,7 @@ export function AppMobileMenu({
onClick={close}
className="rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
>
{link.label}
{'labelKey' in link ? t(link.labelKey) : link.label}
</Link>
))
)}
Expand All @@ -122,7 +124,7 @@ export function AppMobileMenu({
onClick={close}
className="rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
>
Dashboard
{t('dashboard')}
</Link>

{showAdminLink ? (
Expand All @@ -147,7 +149,7 @@ export function AppMobileMenu({
onClick={close}
className="rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
>
Log in
{t('login')}
</Link>
)}
</div>
Expand Down
8 changes: 5 additions & 3 deletions frontend/components/header/UnifiedHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client';
import { LogIn, Settings, User } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Link } from '@/i18n/routing';
import { SITE_LINKS } from '@/lib/navigation';

Expand Down Expand Up @@ -28,10 +29,11 @@ export function UnifiedHeader({
showAdminLink = false,
blogCategories = [],
}: UnifiedHeaderProps) {
const t = useTranslations('navigation');
const isShop = variant === 'shop';
const isBlog = variant === 'blog';
const brandHref = isShop ? '/shop' : isBlog ? '/blog' : '/';
const brandBadge = isShop ? 'Shop' : isBlog ? 'Blog' : '';
const brandBadge = isShop ? t('shop') : isBlog ? t('blog') : '';

return (
<header className="sticky top-0 z-50 w-full border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
Expand Down Expand Up @@ -69,7 +71,7 @@ export function UnifiedHeader({
href={link.href}
className="rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
>
{link.label}
{t(link.labelKey)}
</Link>
))}
</div>
Expand Down Expand Up @@ -109,7 +111,7 @@ export function UnifiedHeader({
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"
>
<LogIn className="h-4 w-4" />
Log in
{t('login')}
</Link>
) : (
<LogoutButton />
Expand Down
14 changes: 10 additions & 4 deletions frontend/components/shared/LanguageSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ import { useParams, usePathname,useSearchParams } from 'next/navigation';
import { locales, type Locale } from '@/i18n/config';
import { Link } from '@/i18n/routing';

const localeLabels: Record<Locale, string> = {
uk: 'UA',
en: 'EN',
pl: 'PL',
};

export default function LanguageSwitcher() {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -37,9 +43,9 @@ export default function LanguageSwitcher() {
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
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"
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"
>
{currentLocale}
{localeLabels[currentLocale]}
<svg
className={`h-4 w-4 transition-transform ${
isOpen ? 'rotate-180' : ''
Expand Down Expand Up @@ -70,13 +76,13 @@ export default function LanguageSwitcher() {
}
setIsOpen(false);
}}
className={`block px-4 py-2 text-sm uppercase transition ${
className={`block px-4 py-2 text-sm transition ${
currentLocale === locale
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 font-medium'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-neutral-800'
}`}
>
{locale}
{localeLabels[locale]}
</Link>
))}
</div>
Expand Down
59 changes: 32 additions & 27 deletions frontend/hooks/useAntiCheat.ts
Original file line number Diff line number Diff line change
@@ -1,48 +1,53 @@
'use client';

import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';

export type AntiCheatViolation = {
type: 'copy' | 'context-menu' | 'tab-switch' | 'paste';
timestamp: Date;
};

const messageKey: Record<AntiCheatViolation['type'], string> = {
copy: 'copy',
paste: 'paste',
'context-menu': 'contextMenu',
'tab-switch': 'tabSwitch',
};

export function useAntiCheat(isActive: boolean = true) {
const t = useTranslations('quiz.antiCheat');
const [violations, setViolations] = useState<AntiCheatViolation[]>([]);
const [isTabActive, setIsTabActive] = useState(true);
const [showWarning, setShowWarning] = useState(false);
const warningTimeoutRef = useRef<NodeJS.Timeout | null>(null);

const addViolation = (type: AntiCheatViolation['type']) => {
if (!isActive) return;

const violation: AntiCheatViolation = {
type,
timestamp: new Date(),
};
const addViolation = useCallback(
(type: AntiCheatViolation['type']) => {
if (!isActive) return;

setViolations(prev => [...prev, violation]);
setShowWarning(true);
const violation: AntiCheatViolation = {
type,
timestamp: new Date(),
};

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

toast.warning(messages[type], {
duration: 3000,
});
toast.warning(t(messageKey[type]), {
duration: 3000,
});

if (warningTimeoutRef.current) {
clearTimeout(warningTimeoutRef.current);
}
warningTimeoutRef.current = setTimeout(() => {
setShowWarning(false);
}, 3000);
};
if (warningTimeoutRef.current) {
clearTimeout(warningTimeoutRef.current);
}
warningTimeoutRef.current = setTimeout(() => {
setShowWarning(false);
}, 3000);
},
[isActive, t]
);

useEffect(() => {
if (!isActive) return;
Expand Down Expand Up @@ -86,7 +91,7 @@ export function useAntiCheat(isActive: boolean = true) {
clearTimeout(warningTimeoutRef.current);
}
};
}, [isActive]);
}, [isActive, addViolation]);

const resetViolations = () => {
setViolations([]);
Expand Down
14 changes: 7 additions & 7 deletions frontend/lib/navigation.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
export const SITE_LINKS = [
{ href: '/q&a', label: 'Q&A' },
{ href: '/quizzes', label: 'Quizzes' },
{ href: '/leaderboard', label: 'Leaderboard' },
{ href: '/blog', label: 'Blog' },
{ href: '/about', label: 'About' },
{ href: '/contacts', label: 'Contacts' },
{ href: '/shop', label: 'Shop' },
{ href: '/q&a', labelKey: 'qa' },
{ href: '/quizzes', labelKey: 'quizzes' },
{ href: '/leaderboard', labelKey: 'leaderboard' },
{ href: '/blog', labelKey: 'blog' },
{ href: '/about', labelKey: 'about' },
{ href: '/contacts', labelKey: 'contacts' },
{ href: '/shop', labelKey: 'shop' },
] as const;
Loading