Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
15 changes: 13 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,17 @@ 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();
const translationKey = `categories.${key}` as const;
const translated = t.raw(translationKey);
return typeof translated === 'string' ? translated : categoryName;
};
const baseLink =
linkClassName ||
'rounded-md px-3 py-2 text-sm font-medium transition-colors ' +
Expand Down Expand Up @@ -53,7 +64,7 @@ export function BlogCategoryLinks({
isActive ? 'bg-muted text-foreground' : 'text-muted-foreground'
)}
>
{category.displayTitle}
{getCategoryLabel(category.displayTitle)}
</Link>
);
})}
Expand All @@ -66,7 +77,7 @@ export function BlogCategoryLinks({
pathname === '/' ? 'bg-muted text-foreground' : 'text-muted-foreground'
)}
>
Home
{tNav('home')}
</Link>
</nav>
);
Expand Down
14 changes: 12 additions & 2 deletions frontend/components/blog/BlogFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,16 @@ export default function BlogFilters({
setSelectedAuthor(null);
setSelectedCategory(null);
};

// Helper function to get translated category label
const getCategoryLabel = (categoryName: string): string => {
const key = categoryName.toLowerCase();
const translationKey = `categories.${key}` as const;
// Try to get translation, fallback to original name
const translated = t.raw(translationKey);
return typeof translated === 'string' ? translated : 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 +353,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 +372,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
14 changes: 8 additions & 6 deletions frontend/hooks/useAntiCheat.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

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

export type AntiCheatViolation = {
Expand All @@ -9,6 +10,7 @@ export type AntiCheatViolation = {
};

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);
Expand All @@ -25,14 +27,14 @@ export function useAntiCheat(isActive: boolean = true) {
setViolations(prev => [...prev, violation]);
setShowWarning(true);

const messages = {
copy: '⚠️ Копіювання заборонено під час квізу',
paste: '⚠️ Вставка заборонена під час квізу',
'context-menu': '⚠️ Контекстне меню заборонено під час квізу',
'tab-switch': '⚠️ Перехід на іншу вкладку зафіксовано',
const messageKey: Record<AntiCheatViolation['type'], string> = {
copy: 'copy',
paste: 'paste',
'context-menu': 'contextMenu',
'tab-switch': 'tabSwitch',
};

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

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;
16 changes: 16 additions & 0 deletions frontend/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"about": "About",
"contacts": "Contacts",
"blog": "Blog",
"shop": "Shop",
"login": "Log in",
"logout": "Log out",
"signup": "Sign up",
Expand Down Expand Up @@ -164,14 +165,29 @@
"message": "Your progress will not be saved.",
"confirm": "Exit",
"cancel": "Continue"
},
"antiCheat": {
"copy": "Copying is not allowed during the quiz",
"paste": "Pasting is not allowed during the quiz",
"contextMenu": "Context menu is not allowed during the quiz",
"tabSwitch": "Tab switch detected"
}
},
"blog": {
"title": "All Blog Posts",
"subtitle": "Explore the latest articles in Tech, News, and Career Growth, covering trends, ideas, and practical insights.",
"metaTitle": "Blog | DevLovers",
"metaDescription": "Explore the latest articles and insights",
"all": "All",
"noMatches": "No matches",
"searchPlaceholder": "Search...",
"categories": {
"tech": "Tech",
"career": "Career",
"insights": "Insights",
"news": "News",
"growth": "Career"
},
"removeTag": "Remove tag",
"add": "Add",
"clear": "Clear",
Expand Down
16 changes: 16 additions & 0 deletions frontend/messages/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"about": "O nas",
"contacts": "Kontakt",
"blog": "Blog",
"shop": "Sklep",
"login": "Zaloguj się",
"logout": "Wyloguj się",
"signup": "Zarejestruj się",
Expand Down Expand Up @@ -164,14 +165,29 @@
"message": "Twój postęp nie zostanie zapisany.",
"confirm": "Wyjdź",
"cancel": "Kontynuuj"
},
"antiCheat": {
"copy": "Kopiowanie jest zabronione podczas quizu",
"paste": "Wklejanie jest zabronione podczas quizu",
"contextMenu": "Menu kontekstowe jest zabronione podczas quizu",
"tabSwitch": "Wykryto przełączenie karty"
}
},
"blog": {
"title": "Wszystkie Wpisy na Blogu",
"subtitle": "Poznaj najnowsze artykuły z Tech, News i Career Growth — trendy, idee i praktyczne wskazówki.",
"metaTitle": "Blog | DevLovers",
"metaDescription": "Odkryj najnowsze artykuły i spostrzeżenia",
"all": "Wszystkie",
"noMatches": "Brak wyników",
"searchPlaceholder": "Szukaj...",
"categories": {
"tech": "Technologia",
"career": "Kariera",
"insights": "Spostrzeżenia",
"news": "Aktualności",
"growth": "Kariera"
},
"removeTag": "Usuń tag",
"add": "Dodaj",
"clear": "Wyczyść",
Expand Down
Loading