diff --git a/CHANGELOG.md b/CHANGELOG.md index 1143467e..210eed6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -684,3 +684,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - Stronger password validation and confirmation flow - Improved server-side validation and error reporting + +## [1.0.3] - 2026-02-23 + +### Fixed + +- Blog (Sanity): + - Fixed images not rendering on Vercel production + - Improved image optimization across blog pages and components +- SSR hydration mismatch in AchievementBadge (client/server state sync) +- Header hardcoded locale issues +- Notification rendering consistency + +### Changed + +- Q&A UI: + - Updated Git category tab color for stronger visual distinction from HTML + +- Header & Localization: + - Translated all header UI strings (en / uk / pl) + - Notifications now fully localized with locale-aware relative time + - LanguageSwitcher and User menu labels localized + +- Dashboard UX: + - Unified dashboard cards styling using shared `dashboard-card` class + - Clickable profile stats with smooth scroll navigation + - Improved avatar detection logic + +### Performance & Stability + +- Improved client-render guards using stable subscription pattern +- Reduced hydration inconsistencies in production diff --git a/assets/01-screencapture.png b/assets/01-screencapture.png index ed5db339..0dead048 100644 Binary files a/assets/01-screencapture.png and b/assets/01-screencapture.png differ diff --git a/assets/02-screencapture.png b/assets/02-screencapture.png index 38e6915a..aa2049a8 100644 Binary files a/assets/02-screencapture.png and b/assets/02-screencapture.png differ diff --git a/assets/03-screencapture.png b/assets/03-screencapture.png index 55a73811..7a927bfe 100644 Binary files a/assets/03-screencapture.png and b/assets/03-screencapture.png differ diff --git a/assets/04-screencapture.png b/assets/04-screencapture.png index 8827bd0a..a5e2c6d1 100644 Binary files a/assets/04-screencapture.png and b/assets/04-screencapture.png differ diff --git a/assets/05-screencapture.png b/assets/05-screencapture.png index 663e942e..8a125989 100644 Binary files a/assets/05-screencapture.png and b/assets/05-screencapture.png differ diff --git a/assets/08-screencapture.png b/assets/08-screencapture.png index ea16d4be..6f418395 100644 Binary files a/assets/08-screencapture.png and b/assets/08-screencapture.png differ diff --git a/frontend/actions/notifications.ts b/frontend/actions/notifications.ts index f973a5dc..e177eef2 100644 --- a/frontend/actions/notifications.ts +++ b/frontend/actions/notifications.ts @@ -1,11 +1,10 @@ 'use server'; -import { desc, eq, and } from 'drizzle-orm'; +import { and,desc, eq } from 'drizzle-orm'; import { revalidatePath } from 'next/cache'; import { db } from '@/db'; import { notifications } from '@/db/schema/notifications'; - import { getCurrentUser } from '@/lib/auth'; export async function getNotifications() { diff --git a/frontend/actions/quiz.ts b/frontend/actions/quiz.ts index f9fc36d1..761fe18a 100644 --- a/frontend/actions/quiz.ts +++ b/frontend/actions/quiz.ts @@ -231,14 +231,15 @@ export async function submitQuizAttempt( const earnedAfter = computeAchievements(statsAfter).filter(a => a.earned); const newlyEarned = earnedAfter.filter(a => !earnedBefore.has(a.id)); - // Trigger notifications for any newly earned achievements + // Trigger notifications for any newly earned achievements. + // title/message are stable English fallbacks; NotificationBell renders + // them dynamically in the viewer's locale using metadata.badgeId. for (const achievement of newlyEarned) { - // Find full object to get the fancy translated string (if needed) or just generic name await createNotification({ userId: session.id, type: 'ACHIEVEMENT', title: 'Achievement Unlocked!', - message: `You just earned the ${achievement.id} badge!`, + message: achievement.id, metadata: { badgeId: achievement.id, icon: achievement.icon }, }); } diff --git a/frontend/app/[locale]/blog/[slug]/PostDetails.tsx b/frontend/app/[locale]/blog/[slug]/PostDetails.tsx index 733b2723..d1f9ab79 100644 --- a/frontend/app/[locale]/blog/[slug]/PostDetails.tsx +++ b/frontend/app/[locale]/blog/[slug]/PostDetails.tsx @@ -7,6 +7,7 @@ import { client } from '@/client'; import { DynamicGridBackground } from '@/components/shared/DynamicGridBackground'; import { Link } from '@/i18n/routing'; import { formatBlogDate } from '@/lib/blog/date'; +import { shouldBypassImageOptimization } from '@/lib/blog/image'; export const revalidate = 0; @@ -285,6 +286,7 @@ function renderPortableText( alt={postTitle || 'Post image'} width={1200} height={800} + unoptimized={shouldBypassImageOptimization(block.url)} sizes="100vw" className="my-6 h-auto w-full rounded-xl border border-gray-200" /> @@ -577,6 +579,7 @@ export default async function PostDetails({ src={post.mainImage} alt={post.title || 'Post image'} fill + unoptimized={shouldBypassImageOptimization(post.mainImage)} className="object-contain" /> @@ -618,6 +621,9 @@ export default async function PostDetails({ src={item.mainImage} alt={item.title || 'Post image'} fill + unoptimized={shouldBypassImageOptimization( + item.mainImage + )} className="object-cover transition-transform duration-300 group-hover:scale-[1.03]" /> @@ -640,6 +646,9 @@ export default async function PostDetails({ src={item.author.image} alt={item.author.name || 'Author'} fill + unoptimized={shouldBypassImageOptimization( + item.author.image + )} className="object-cover" /> diff --git a/frontend/app/[locale]/blog/category/[category]/page.tsx b/frontend/app/[locale]/blog/category/[category]/page.tsx index ea470ee9..4746431e 100644 --- a/frontend/app/[locale]/blog/category/[category]/page.tsx +++ b/frontend/app/[locale]/blog/category/[category]/page.tsx @@ -9,6 +9,7 @@ import { FeaturedPostCtaButton } from '@/components/blog/FeaturedPostCtaButton'; import { DynamicGridBackground } from '@/components/shared/DynamicGridBackground'; import { Link } from '@/i18n/routing'; import { formatBlogDate } from '@/lib/blog/date'; +import { shouldBypassImageOptimization } from '@/lib/blog/image'; export const revalidate = 0; @@ -121,6 +122,9 @@ export default async function BlogCategoryPage({ alt={featuredPost.title} width={1400} height={800} + unoptimized={shouldBypassImageOptimization( + featuredPost.mainImage + )} className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.03]" priority={false} /> @@ -142,6 +146,9 @@ export default async function BlogCategoryPage({ alt={featuredPost.author.name || 'Author'} width={28} height={28} + unoptimized={shouldBypassImageOptimization( + featuredPost.author.image + )} className="h-7 w-7 rounded-full object-cover" /> )} diff --git a/frontend/app/[locale]/dashboard/page.tsx b/frontend/app/[locale]/dashboard/page.tsx index aa7d7b79..f867603a 100644 --- a/frontend/app/[locale]/dashboard/page.tsx +++ b/frontend/app/[locale]/dashboard/page.tsx @@ -17,8 +17,8 @@ import { } from '@/db/queries/quizzes/quiz'; import { getUserGlobalRank, getUserProfile } from '@/db/queries/users'; import { redirect } from '@/i18n/routing'; -import { getCurrentUser } from '@/lib/auth'; import { computeAchievements } from '@/lib/achievements'; +import { getCurrentUser } from '@/lib/auth'; import { getUserStatsForAchievements } from '@/lib/user-stats'; export async function generateMetadata({ @@ -216,7 +216,7 @@ export default async function DashboardPage({ totalAttempts={totalAttempts} globalRank={globalRank} /> -
+
diff --git a/frontend/app/[locale]/layout.tsx b/frontend/app/[locale]/layout.tsx index 955ac96b..c194341a 100644 --- a/frontend/app/[locale]/layout.tsx +++ b/frontend/app/[locale]/layout.tsx @@ -10,6 +10,7 @@ import { AppChrome } from '@/components/header/AppChrome'; import { MainSwitcher } from '@/components/header/MainSwitcher'; import { CookieBanner } from '@/components/shared/CookieBanner'; import Footer from '@/components/shared/Footer'; +import { ScrollWatcher } from '@/components/shared/ScrollWatcher'; import { ThemeProvider } from '@/components/theme/ThemeProvider'; import { locales } from '@/i18n/config'; import { getCurrentUser } from '@/lib/auth'; @@ -78,6 +79,7 @@ export default async function LocaleLayout({
diff --git a/frontend/components/dashboard/AchievementBadge.tsx b/frontend/components/dashboard/AchievementBadge.tsx index 7a654537..9b02acf9 100644 --- a/frontend/components/dashboard/AchievementBadge.tsx +++ b/frontend/components/dashboard/AchievementBadge.tsx @@ -1,30 +1,30 @@ 'use client'; import { + Anchor, + Atom, Brain, Code, Crown, Diamond, Fire, GithubLogo, + GraduationCap, Heart, Infinity as InfinityIcon, Lightning, Medal, + Meteor, Moon, Rocket, Seal, Shield, + Sparkle, Star, + Sun, Target, Trophy, Waves, - Meteor, - Sparkle, - GraduationCap, - Atom, - Sun, - Anchor, } from '@phosphor-icons/react'; import { motion, @@ -34,7 +34,7 @@ import { useTransform, } from 'framer-motion'; import { useTranslations } from 'next-intl'; -import { useEffect,useState } from 'react'; +import { useEffect, useState } from 'react'; import type { AchievementIconName, @@ -107,12 +107,14 @@ export function AchievementBadge({ achievement }: AchievementBadgeProps) { const [isDark, setIsDark] = useState(false); useEffect(() => { - const root = document.documentElement; - setIsDark(root.classList.contains('dark')); - const observer = new MutationObserver(() => { - setIsDark(root.classList.contains('dark')); + const update = () => + setIsDark(document.documentElement.classList.contains('dark')); + update(); + const observer = new MutationObserver(update); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['class'], }); - observer.observe(root, { attributes: true, attributeFilter: ['class'] }); return () => observer.disconnect(); }, []); @@ -188,7 +190,7 @@ export function AchievementBadge({ achievement }: AchievementBadgeProps) { onMouseLeave={handleMouseLeave} > @@ -400,7 +402,7 @@ export function AchievementBadge({ achievement }: AchievementBadgeProps) {
-
+
-

+

a.earned).length; - const cardStyles = 'dashboard-card hover:translate-y-0 hover:shadow-sm'; + const cardStyles = 'dashboard-card'; const iconBoxStyles = 'shrink-0 rounded-xl bg-white/40 border border-white/20 shadow-xs backdrop-blur-xs p-3 dark:bg-white/5 dark:border-white/10'; const previewBadges = achievements.slice(0, 6); diff --git a/frontend/components/dashboard/ActivityHeatmapCard.tsx b/frontend/components/dashboard/ActivityHeatmapCard.tsx index e56976cc..d442c0b4 100644 --- a/frontend/components/dashboard/ActivityHeatmapCard.tsx +++ b/frontend/components/dashboard/ActivityHeatmapCard.tsx @@ -52,12 +52,7 @@ export function ActivityHeatmapCard({ attempts, locale, currentStreak }: Activit left: number; } | null>(null); - const cardStyles = ` - relative z-10 flex flex-col justify-between overflow-hidden rounded-3xl - border border-gray-200 bg-white/10 shadow-sm backdrop-blur-md - dark:border-neutral-800 dark:bg-neutral-900/10 - p-6 sm:p-8 - `; + const cardStyles = 'dashboard-card flex flex-col justify-between p-6 sm:p-8'; const iconBoxStyles = 'shrink-0 rounded-xl bg-white/40 border border-white/20 shadow-xs backdrop-blur-xs p-3 dark:bg-white/5 dark:border-white/10'; @@ -476,8 +471,8 @@ export function ActivityHeatmapCard({ attempts, locale, currentStreak }: Activit : 'text-(--accent-primary)' }`}> {tooltip.count === 0 - ? 'No activity' - : `${tooltip.count} ${tooltip.count === 1 ? 'attempt' : 'attempts'}`} + ? t('heatmapNoActivity') + : t('heatmapAttempts', { count: tooltip.count })}

@@ -513,7 +508,7 @@ export function ActivityHeatmapCard({ attempts, locale, currentStreak }: Activit {totalActiveDays > 0 && ( - {totalActiveDays} active day{totalActiveDays !== 1 ? 's' : ''} + {t('heatmapActiveDays', { count: totalActiveDays })} )}
diff --git a/frontend/components/dashboard/ExplainedTermsCard.tsx b/frontend/components/dashboard/ExplainedTermsCard.tsx index 16ba1987..e6c0033b 100644 --- a/frontend/components/dashboard/ExplainedTermsCard.tsx +++ b/frontend/components/dashboard/ExplainedTermsCard.tsx @@ -230,7 +230,7 @@ export function ExplainedTermsCard() { const hasTerms = terms.length > 0; const hasHiddenTerms = hiddenTerms.length > 0; - const cardStyles = 'dashboard-card flex flex-col p-6 sm:p-8 lg:p-10 hover:translate-y-0 hover:shadow-sm'; + const cardStyles = 'dashboard-card flex flex-col p-6 sm:p-8 lg:p-10'; const iconBoxStyles = 'shrink-0 rounded-xl bg-white/40 border border-white/20 shadow-xs backdrop-blur-xs p-3 dark:bg-white/5 dark:border-white/10'; return ( @@ -274,7 +274,7 @@ export function ExplainedTermsCard() { return (
handleDrop(index)} diff --git a/frontend/components/dashboard/ProfileCard.tsx b/frontend/components/dashboard/ProfileCard.tsx index 1067e126..66079fac 100644 --- a/frontend/components/dashboard/ProfileCard.tsx +++ b/frontend/components/dashboard/ProfileCard.tsx @@ -12,7 +12,6 @@ import { } from 'lucide-react'; import { useTranslations } from 'next-intl'; import { useState } from 'react'; - import { toast } from 'sonner'; import { updateName, updatePassword } from '@/actions/profile'; @@ -48,6 +47,8 @@ export function ProfileCard({ const [isSaving, setIsSaving] = useState(false); const username = user.name || user.email.split('@')[0]; + const roleLabel = + user.role === 'admin' ? t('roles.admin') : t('roles.user'); const seed = `${username}-${user.id}`; const avatarSrc = user.image || @@ -55,11 +56,6 @@ export function ProfileCard({ const cardStyles = 'dashboard-card flex flex-col p-5 sm:p-6 lg:p-8'; - const scrollTo = (id: string) => (e: React.MouseEvent) => { - e.preventDefault(); - document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' }); - }; - const handleUpdateName = async (e: React.FormEvent) => { e.preventDefault(); setIsSaving(true); @@ -70,7 +66,7 @@ export function ProfileCard({ if (!result.success) { toast.error(result.error || 'Failed to update name'); } - } catch (error) { + } catch { toast.error('Something went wrong'); } finally { setIsSaving(false); @@ -89,7 +85,7 @@ export function ProfileCard({ } else { toast.error(result.error || 'Failed to update password'); } - } catch (error) { + } catch { toast.error('Something went wrong'); } finally { setIsSaving(false); @@ -97,7 +93,7 @@ export function ProfileCard({ }; const statItemBase = - 'flex flex-row items-center gap-2 sm:gap-3 rounded-2xl border border-gray-100 bg-white/50 p-2 sm:p-3 text-left dark:border-white/5 dark:bg-black/20 xl:flex-row-reverse xl:items-center xl:text-right xl:p-3 xl:px-4'; + 'flex flex-row items-center gap-2 sm:gap-3 rounded-2xl border border-gray-100 bg-white/50 p-2 sm:p-3 text-left dark:border-white/5 dark:bg-black/20 xl:flex-row-reverse xl:items-center xl:text-right xl:p-3 xl:px-4 transition-all hover:-translate-y-0.5 hover:border-(--accent-primary)/30 hover:bg-white/80 dark:hover:border-(--accent-primary)/20 dark:hover:bg-black/40'; const iconBoxStyles = 'flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-white/40 border border-white/20 shadow-xs backdrop-blur-xs xl:h-auto xl:w-auto xl:p-2.5 dark:bg-white/5 dark:border-white/10'; @@ -129,7 +125,7 @@ export function ProfileCard({
- {user.role || t('defaultRole')} + {roleLabel} {isSponsor && ( @@ -141,51 +137,59 @@ export function ProfileCard({
-
+
{/* Attempts */} - + {/* Points */} - + {/* Global rank */} -
+
-
+ {t('globalRank')} -
-
+ + {globalRank ? `#${globalRank}` : '—'} -
+
-
+ {/* Joined */}
@@ -193,20 +197,20 @@ export function ProfileCard({
-
+ {t('joined')} -
-
+ + {user.createdAt ? new Date(user.createdAt).toLocaleDateString(locale, { year: 'numeric', month: 'short', }) : '—'} -
+
-
+
diff --git a/frontend/components/dashboard/QuizResultsSection.tsx b/frontend/components/dashboard/QuizResultsSection.tsx index 89b1c798..55a67188 100644 --- a/frontend/components/dashboard/QuizResultsSection.tsx +++ b/frontend/components/dashboard/QuizResultsSection.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Shield, Star, ClipboardList } from 'lucide-react'; +import { ClipboardList } from 'lucide-react'; import { useTranslations } from 'next-intl'; import { QuizResultRow } from '@/components/dashboard/QuizResultRow'; @@ -15,8 +15,7 @@ interface QuizResultsSectionProps { export function QuizResultsSection({ attempts, locale }: QuizResultsSectionProps) { const t = useTranslations('dashboard.quizResults'); - const cardStyles = - 'relative z-10 flex flex-col overflow-hidden rounded-3xl border border-gray-200 bg-white/10 p-6 sm:p-8 lg:p-10 shadow-sm backdrop-blur-md dark:border-neutral-800 dark:bg-neutral-900/10'; + const cardStyles = 'dashboard-card flex flex-col p-6 sm:p-8 lg:p-10'; const iconBoxStyles = 'shrink-0 rounded-xl bg-white/40 border border-white/20 shadow-xs backdrop-blur-xs p-3 dark:bg-white/5 dark:border-white/10'; @@ -67,7 +66,7 @@ export function QuizResultsSection({ attempts, locale }: QuizResultsSectionProps
- Quiz + {t('quiz')}
{t('score')} diff --git a/frontend/components/dashboard/StatsCard.tsx b/frontend/components/dashboard/StatsCard.tsx index 9e700e8a..de719d35 100644 --- a/frontend/components/dashboard/StatsCard.tsx +++ b/frontend/components/dashboard/StatsCard.tsx @@ -26,7 +26,7 @@ export function StatsCard({ stats, attempts = [] }: StatsCardProps) { const tProfile = useTranslations('dashboard.profile'); const hasActivity = stats && stats.totalAttempts > 0; - const cardStyles = 'dashboard-card flex flex-col justify-between p-6 sm:p-8 hover:translate-y-0 hover:shadow-sm'; + const cardStyles = 'dashboard-card flex flex-col justify-between p-6 sm:p-8'; const iconBoxStyles = 'shrink-0 rounded-xl bg-white/40 border border-white/20 shadow-xs backdrop-blur-xs p-3 dark:bg-white/5 dark:border-white/10'; diff --git a/frontend/components/header/AppMobileMenu.tsx b/frontend/components/header/AppMobileMenu.tsx index 78fc14d6..a056788c 100644 --- a/frontend/components/header/AppMobileMenu.tsx +++ b/frontend/components/header/AppMobileMenu.tsx @@ -52,12 +52,6 @@ export function AppMobileMenu({ startNavigation(href); }; - const handleHeaderButtonLinkClick = - (href: string) => (e: React.MouseEvent) => { - e.preventDefault(); - startNavigation(href); - }; - const getBlogCategoryLabel = (categoryName: string): string => { const key = categoryName.toLowerCase() as | 'tech' diff --git a/frontend/components/header/DesktopActions.tsx b/frontend/components/header/DesktopActions.tsx index 2745610c..7fcbc843 100644 --- a/frontend/components/header/DesktopActions.tsx +++ b/frontend/components/header/DesktopActions.tsx @@ -22,7 +22,6 @@ export function DesktopActions({ showAdminLink = false, }: DesktopActionsProps) { const t = useTranslations('navigation'); - const tAria = useTranslations('aria'); const isShop = variant === 'shop'; const isBlog = variant === 'blog'; @@ -32,6 +31,8 @@ export function DesktopActions({ + {isShop && } + {!userExists ? ( {t('login')} diff --git a/frontend/components/header/MobileActions.tsx b/frontend/components/header/MobileActions.tsx index a3a22703..118a8444 100644 --- a/frontend/components/header/MobileActions.tsx +++ b/frontend/components/header/MobileActions.tsx @@ -4,6 +4,7 @@ import { BlogHeaderSearch } from '@/components/blog/BlogHeaderSearch'; import { AppMobileMenu } from '@/components/header/AppMobileMenu'; import LanguageSwitcher from '@/components/shared/LanguageSwitcher'; import { CartButton } from '@/components/shop/header/CartButton'; + import { NotificationBell } from './NotificationBell'; type Category = { diff --git a/frontend/components/header/NotificationBell.tsx b/frontend/components/header/NotificationBell.tsx index ff0f8e67..a3751886 100644 --- a/frontend/components/header/NotificationBell.tsx +++ b/frontend/components/header/NotificationBell.tsx @@ -1,20 +1,22 @@ 'use client'; -import { Bell, FileText, ShoppingBag, Trophy, Info, CheckCircle2, User } from 'lucide-react'; -import { useTranslations } from 'next-intl'; -import { useEffect, useRef, useState } from 'react'; import { AnimatePresence, motion } from 'framer-motion'; +import { Bell, CheckCircle2, FileText, Info, ShoppingBag, Trophy, User } from 'lucide-react'; +import { useLocale, useTranslations } from 'next-intl'; +import { useEffect, useRef, useState } from 'react'; + import { getNotifications, markAllAsRead, markAsRead } from '@/actions/notifications'; -function getRelativeTime(date: Date) { - const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }); - const daysDifference = Math.round((date.getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24)); +function getRelativeTime(date: Date, locale: string, justNow: string) { + const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }); + const now = new Date().getTime(); + const daysDifference = Math.round((date.getTime() - now) / (1000 * 60 * 60 * 24)); if (daysDifference === 0) { - const hoursDifference = Math.round((date.getTime() - new Date().getTime()) / (1000 * 60 * 60)); + const hoursDifference = Math.round((date.getTime() - now) / (1000 * 60 * 60)); if (hoursDifference === 0) { - const minutesDifference = Math.round((date.getTime() - new Date().getTime()) / (1000 * 60)); - if (minutesDifference === 0) return 'Just now'; - return rtf.format(minutesDifference, 'minute'); + const minutesDifference = Math.round((date.getTime() - now) / (1000 * 60)); + if (minutesDifference === 0) return justNow; + return rtf.format(minutesDifference, 'minute'); } return rtf.format(hoursDifference, 'hour'); } @@ -32,13 +34,15 @@ type NotificationItem = { }; export function NotificationBell() { + const t = useTranslations('notifications.ui'); + const tUnlocked = useTranslations('notifications.achievement.unlocked'); + const tAch = useTranslations('dashboard.achievements'); + const locale = useLocale(); const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); - const t = useTranslations('navigation'); - + const [notifications, setNotifications] = useState([]); const [loading, setLoading] = useState(true); - const [displayLimit, setDisplayLimit] = useState(5); const fetchNotifications = async () => { try { @@ -98,6 +102,35 @@ export function NotificationBell() { ); }; + const getNotificationTitle = (n: NotificationItem) => { + if (n.type === 'ACHIEVEMENT' && n.metadata?.badgeId) { + return tUnlocked('title'); + } + return n.title; + }; + + const getNotificationMessage = (n: NotificationItem) => { + if (n.type === 'ACHIEVEMENT' && n.metadata?.badgeId) { + const key = `badges.${n.metadata.badgeId}.name`; + + if (tAch.has(key as any)) { + + const badgeName = tAch(key as any); + return tUnlocked('message', { name: badgeName }); + } + return n.message; + } + return n.message; + }; + + const KNOWN_TYPES = ['SYSTEM', 'ACHIEVEMENT', 'ARTICLE', 'SHOP'] as const; + type KnownType = (typeof KNOWN_TYPES)[number]; + + const getSafeNotificationType = (type: string): KnownType => + (KNOWN_TYPES as readonly string[]).includes(type) + ? (type as KnownType) + : 'SYSTEM'; + const getIconForType = (type: string) => { switch (type) { case 'ACHIEVEMENT': @@ -132,8 +165,8 @@ export function NotificationBell() {
)}
-
+
{loading ? (
-

Syncing

+

{t('syncing')}

) : notifications.length === 0 ? (
-

All caught up!

-

You've handled all your recent activity.

+

{t('emptyTitle')}

+

{t('emptySubtitle')}

) : ( @@ -219,22 +252,22 @@ export function NotificationBell() {

- {notification.title} + {getNotificationTitle(notification)}

{!notification.isRead && (
)}

- {notification.message} + {getNotificationMessage(notification)}

- {getIconForType(notification.type).type.name === 'User' ? 'System' : notification.type} + {t(`types.${getSafeNotificationType(notification.type)}` as const)} - {getRelativeTime(notification.createdAt)} + {getRelativeTime(notification.createdAt, locale, t('justNow'))}
diff --git a/frontend/components/header/UserNavDropdown.tsx b/frontend/components/header/UserNavDropdown.tsx index 3d711c33..b3fffcba 100644 --- a/frontend/components/header/UserNavDropdown.tsx +++ b/frontend/components/header/UserNavDropdown.tsx @@ -48,7 +48,7 @@ export function UserNavDropdown({ showAdminLink = false }: UserNavDropdownProps) {isOpen && (
-

My Account

+

{t('myAccount')}

() => {}; interface CodeSnippet { id: string; @@ -200,13 +202,8 @@ function CodeBlock({ snippet }: { snippet: CodeSnippet }) { } export function FloatingCode() { - const [isMounted, setIsMounted] = useState(false); - - useEffect(() => { - setIsMounted(true); - }, []); - - if (!isMounted) return null; + const isClient = useSyncExternalStore(emptySubscribe, () => true, () => false); + if (!isClient) return null; return (
= { }; export default function LanguageSwitcher() { + const t = useTranslations('navigation.languageSwitcher'); const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); const fullPathname = usePathname(); @@ -44,7 +47,7 @@ export default function LanguageSwitcher() {