From b7fdbbaded45222d101f01409495d0bd799f4c94 Mon Sep 17 00:00:00 2001 From: tetiana zorii Date: Sun, 22 Feb 2026 21:55:30 -0500 Subject: [PATCH 1/4] feat: header translations, dashboard UX polish & hydration fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Translate all hardcoded header strings across en/uk/pl: - NotificationBell: title, markAllRead, syncing, empty state, justNow, type labels - LanguageSwitcher: title - UserNavDropdown: "My Account" label - Intl.RelativeTimeFormat now uses actual user locale (was hardcoded 'en') - Add notification translations to actions (quiz achievement, profile name/password change) - Fix AchievementBadge hydration mismatch: replace direct document.documentElement check with useState(false) + useEffect + MutationObserver (eliminates SSR/client mismatch) - Dashboard cards: unify all cards to use dashboard-card CSS class for consistent hover - ProfileCard: make stat items clickable (Quizzes→quiz results, Points→stats, Rank→leaderboard) with smooth scrollIntoView behavior - CartButton: restyle to match LanguageSwitcher/NotificationBell minimal icon button --- frontend/actions/notifications.ts | 3 +- frontend/actions/quiz.ts | 7 +- frontend/app/[locale]/dashboard/page.tsx | 4 +- frontend/app/[locale]/layout.tsx | 2 + frontend/app/[locale]/leaderboard/page.tsx | 10 +- frontend/app/globals.css | 108 +++++++++--------- .../components/dashboard/AchievementBadge.tsx | 34 +++--- .../dashboard/AchievementsSection.tsx | 2 +- .../dashboard/ActivityHeatmapCard.tsx | 7 +- .../dashboard/ExplainedTermsCard.tsx | 2 +- frontend/components/dashboard/ProfileCard.tsx | 36 +++--- .../dashboard/QuizResultsSection.tsx | 7 +- frontend/components/dashboard/StatsCard.tsx | 2 +- frontend/components/header/AppMobileMenu.tsx | 6 - frontend/components/header/DesktopActions.tsx | 3 +- frontend/components/header/MobileActions.tsx | 1 + .../components/header/NotificationBell.tsx | 47 ++++---- .../components/header/UserNavDropdown.tsx | 2 +- frontend/components/home/FloatingCode.tsx | 13 +-- .../leaderboard/AchievementPips.tsx | 12 +- .../components/shared/LanguageSwitcher.tsx | 7 +- frontend/components/shared/ScrollWatcher.tsx | 25 ++++ .../components/shop/header/CartButton.tsx | 21 ++-- frontend/components/theme/ThemeToggle.tsx | 10 +- frontend/db/schema/index.ts | 2 +- frontend/lib/user-stats.ts | 4 +- frontend/messages/en.json | 27 ++++- frontend/messages/pl.json | 39 ++++++- frontend/messages/uk.json | 27 ++++- 29 files changed, 294 insertions(+), 176 deletions(-) create mode 100644 frontend/components/shared/ScrollWatcher.tsx 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..33c6a06e 100644 --- a/frontend/actions/quiz.ts +++ b/frontend/actions/quiz.ts @@ -1,6 +1,7 @@ 'use server'; import { eq, inArray } from 'drizzle-orm'; +import { getTranslations } from 'next-intl/server'; import { db } from '@/db'; import { awardQuizPoints, calculateQuizPoints } from '@/db/queries/points'; @@ -232,13 +233,13 @@ export async function submitQuizAttempt( const newlyEarned = earnedAfter.filter(a => !earnedBefore.has(a.id)); // Trigger notifications for any newly earned achievements + const tNotify = await getTranslations('notifications.achievement.unlocked'); 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!`, + title: tNotify('title'), + message: tNotify('message', { id: achievement.id }), metadata: { badgeId: achievement.id, icon: achievement.icon }, }); } 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/app/[locale]/leaderboard/page.tsx b/frontend/app/[locale]/leaderboard/page.tsx index 190b97a4..9d8daa89 100644 --- a/frontend/app/[locale]/leaderboard/page.tsx +++ b/frontend/app/[locale]/leaderboard/page.tsx @@ -70,10 +70,16 @@ export default async function LeaderboardPage() { // ── Inject star_gazer if user has starred the repo ───────────────── // Match by GitHub login (username) or by avatar URL base const avatarBase = user.avatar?.split('?')[0] ?? ''; + const isGitHubAvatar = (() => { + try { + return new URL(avatarBase).hostname === 'avatars.githubusercontent.com'; + } catch { + return false; + } + })(); const hasStarred = stargazerLogins.has(nameLower) || - (avatarBase.includes('avatars.githubusercontent.com') && - stargazerAvatars.has(avatarBase)); + (isGitHubAvatar && stargazerAvatars.has(avatarBase)); if (hasStarred && !achievements.some(a => a.id === 'star_gazer')) { const def = ACHIEVEMENTS.find(a => a.id === 'star_gazer'); diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 9bbbf39f..b6b29817 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -130,22 +130,32 @@ html { } *::-webkit-scrollbar-thumb { - background: rgba(0, 0, 0, 0.25); + background: rgba(0, 0, 0, 0); border-radius: 3px; + transition: background 0.3s ease; } -:is(.dark) *::-webkit-scrollbar-thumb, -.dark::-webkit-scrollbar-thumb { +html.is-scrolling *::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.25); +} + +html.is-scrolling:is(.dark) *::-webkit-scrollbar-thumb, +html.is-scrolling .dark *::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.2); } @supports (-moz-appearance: none) { * { scrollbar-width: thin; + scrollbar-color: transparent transparent; + transition: scrollbar-color 0.3s ease; + } + + html.is-scrolling * { scrollbar-color: rgba(0, 0, 0, 0.25) transparent; } - :is(.dark) * { + html.is-scrolling:is(.dark) * { scrollbar-color: rgba(255, 255, 255, 0.2) transparent; } } @@ -207,12 +217,10 @@ html { .qa-accordion-item { position: relative; overflow: hidden; - background-image: linear-gradient( - 90deg, - transparent 0%, - transparent 54%, - var(--qa-accent-soft, rgba(161, 161, 170, 0.22)) 100% - ); + background-image: linear-gradient(90deg, + transparent 0%, + transparent 54%, + var(--qa-accent-soft, rgba(161, 161, 170, 0.22)) 100%); } .qa-accordion-item:hover, @@ -276,33 +284,30 @@ html { } @keyframes wave-clip { + 0%, 100% { - clip-path: polygon( - 0% 50%, - 15% 48%, - 32% 52%, - 54% 60%, - 70% 62%, - 84% 60%, - 100% 55%, - 100% 100%, - 0% 100% - ); + clip-path: polygon(0% 50%, + 15% 48%, + 32% 52%, + 54% 60%, + 70% 62%, + 84% 60%, + 100% 55%, + 100% 100%, + 0% 100%); } 50% { - clip-path: polygon( - 0% 65%, - 16% 70%, - 34% 72%, - 51% 68%, - 67% 58%, - 84% 52%, - 100% 48%, - 100% 100%, - 0% 100% - ); + clip-path: polygon(0% 65%, + 16% 70%, + 34% 72%, + 51% 68%, + 67% 58%, + 84% 52%, + 100% 48%, + 100% 100%, + 0% 100%); } } @@ -322,8 +327,7 @@ html { } 50% { - transform: translate(var(--card-x, 0), var(--card-y, 0)) scale(1.05) - rotate(calc(var(--card-rotate, 0deg) + var(--card-rotate-offset, 0deg))); + transform: translate(var(--card-x, 0), var(--card-y, 0)) scale(1.05) rotate(calc(var(--card-rotate, 0deg) + var(--card-rotate-offset, 0deg))); } 100% { @@ -400,6 +404,7 @@ html { } @keyframes float { + 0%, 100% { transform: translateY(0); @@ -468,16 +473,12 @@ html { 0 0 0 2px rgba(0, 0, 0, 0.4), 0 0 0 7px rgba(0, 0, 0, 0.1), 0 22px 60px rgba(0, 0, 0, 0.28); - --shop-hero-btn-success-bg: color-mix( - in oklab, - var(--shop-hero-btn-bg) 88%, - white - ); - --shop-hero-btn-success-bg-hover: color-mix( - in oklab, - var(--shop-hero-btn-bg) 80%, - white - ); + --shop-hero-btn-success-bg: color-mix(in oklab, + var(--shop-hero-btn-bg) 88%, + white); + --shop-hero-btn-success-bg-hover: color-mix(in oklab, + var(--shop-hero-btn-bg) 80%, + white); --shop-hero-btn-success-shadow: 0 22px 60px rgba(0, 0, 0, 0.25); --shop-hero-btn-success-shadow-hover: 0 28px 80px rgba(0, 0, 0, 0.32); } @@ -530,16 +531,12 @@ html { 0 0 0 2px rgba(255, 45, 85, 0.7), 0 0 0 7px rgba(255, 45, 85, 0.22), 0 22px 70px rgba(255, 45, 85, 0.38); - --shop-hero-btn-success-bg: color-mix( - in oklab, - var(--accent-primary) 82%, - black - ); - --shop-hero-btn-success-bg-hover: color-mix( - in oklab, - var(--accent-primary) 72%, - black - ); + --shop-hero-btn-success-bg: color-mix(in oklab, + var(--accent-primary) 82%, + black); + --shop-hero-btn-success-bg-hover: color-mix(in oklab, + var(--accent-primary) 72%, + black); --shop-hero-btn-success-shadow: 0 22px 60px rgba(255, 45, 85, 0.45); --shop-hero-btn-success-shadow-hover: 0 28px 80px rgba(255, 45, 85, 0.6); } @@ -599,10 +596,11 @@ html { } @media (prefers-reduced-motion: reduce) { + .animate-float, .animate-spin-slow, .animate-spin-slower, .animate-dash-flow { animation: none !important; } -} +} \ No newline at end of file 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..6c9ba463 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'; diff --git a/frontend/components/dashboard/ExplainedTermsCard.tsx b/frontend/components/dashboard/ExplainedTermsCard.tsx index 16ba1987..0adedc76 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 ( diff --git a/frontend/components/dashboard/ProfileCard.tsx b/frontend/components/dashboard/ProfileCard.tsx index 1067e126..b75fea96 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'; @@ -55,11 +54,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 +64,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 +83,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 +91,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'; @@ -141,9 +135,13 @@ export function ProfileCard({

-
+
{/* Attempts */} -
+ { e.preventDefault(); document.getElementById('quiz-results')?.scrollIntoView({ behavior: 'smooth' }); }} + >
@@ -155,10 +153,14 @@ export function ProfileCard({ {totalAttempts}
-
+ {/* Points */} -
+ { e.preventDefault(); document.getElementById('stats')?.scrollIntoView({ behavior: 'smooth' }); }} + >
@@ -170,10 +172,10 @@ export function ProfileCard({ {user.points}
- + {/* Global rank */} -
+
@@ -185,7 +187,7 @@ export function ProfileCard({ {globalRank ? `#${globalRank}` : '—'}
- + {/* Joined */}
@@ -206,7 +208,7 @@ export function ProfileCard({
-
+
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..9747ca96 100644 --- a/frontend/components/header/NotificationBell.tsx +++ b/frontend/components/header/NotificationBell.tsx @@ -1,20 +1,23 @@ '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 { useEffect, useRef, useState } from 'react'; + +import { useLocale, useTranslations } from 'next-intl'; + 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 +35,13 @@ type NotificationItem = { }; export function NotificationBell() { + const t = useTranslations('notifications.ui'); + 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 { @@ -132,7 +135,7 @@ export function NotificationBell() {
)}
-
+
{loading ? (
-

Syncing

+

{t('syncing')}

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

All caught up!

-

You've handled all your recent activity.

+

{t('emptyTitle')}

+

{t('emptySubtitle')}

) : ( @@ -230,11 +233,11 @@ export function NotificationBell() {

- {getIconForType(notification.type).type.name === 'User' ? 'System' : notification.type} + {t(`types.${notification.type as 'SYSTEM' | 'ACHIEVEMENT' | 'ARTICLE' | 'SHOP'}` 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() {
diff --git a/frontend/components/header/NotificationBell.tsx b/frontend/components/header/NotificationBell.tsx index 9747ca96..f5a1df3e 100644 --- a/frontend/components/header/NotificationBell.tsx +++ b/frontend/components/header/NotificationBell.tsx @@ -36,6 +36,8 @@ 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); @@ -101,6 +103,29 @@ 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 badgeName = tAch(`badges.${n.metadata.badgeId}.name`); + return tUnlocked('message', { name: badgeName }); + } + 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': @@ -222,18 +247,18 @@ export function NotificationBell() {

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

{!notification.isRead && (
)}

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

- {t(`types.${notification.type as 'SYSTEM' | 'ACHIEVEMENT' | 'ARTICLE' | 'SHOP'}` as const)} + {t(`types.${getSafeNotificationType(notification.type)}` as const)} diff --git a/frontend/components/shared/ScrollWatcher.tsx b/frontend/components/shared/ScrollWatcher.tsx index 40aca158..d6edbbf1 100644 --- a/frontend/components/shared/ScrollWatcher.tsx +++ b/frontend/components/shared/ScrollWatcher.tsx @@ -18,6 +18,7 @@ export function ScrollWatcher() { return () => { window.removeEventListener('scroll', onScroll); clearTimeout(timeout); + document.documentElement.classList.remove('is-scrolling'); }; }, []); diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 4c8822f8..683e6e75 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1092,6 +1092,10 @@ "profile": { "defaultName": "Developer", "defaultRole": "user", + "roles": { + "user": "User", + "admin": "Admin" + }, "sponsor": "Sponsor", "becomeSponsor": "Become a Sponsor", "supportAgain": "Show More Love", @@ -1135,7 +1139,10 @@ "last6Months": "Last 6 Months", "lastYear": "This Year", "totalActiveDays": "Active Days", - "mostActiveMonth": "Active Month" + "mostActiveMonth": "Active Month", + "heatmapNoActivity": "No activity", + "heatmapAttempts": "{count, plural, one {# attempt} other {# attempts}}", + "heatmapActiveDays": "{count, plural, one {# active day} other {# active days}}" }, "quizSaved": { "title": "Quiz result saved!", @@ -1536,7 +1543,7 @@ "achievement": { "unlocked": { "title": "Achievement Unlocked!", - "message": "You just earned the {id} badge!" + "message": "You just earned the {name} badge!" } }, "account": { diff --git a/frontend/messages/pl.json b/frontend/messages/pl.json index d7692e93..87261a03 100644 --- a/frontend/messages/pl.json +++ b/frontend/messages/pl.json @@ -12,7 +12,7 @@ "aria": { "primaryNav": "Nawigacja główna", "dashboard": "Panel", - "admin": "Admin panel", + "admin": "Panel administratora", "blogCategories": "Kategorie bloga", "searchBlog": "Szukaj w blogu", "toggleMenu": "Przełącz menu", @@ -27,7 +27,7 @@ }, "mobileMenu": { "newProduct": "Nowy produkt", - "admin": "Admin" + "admin": "Administrator" }, "navigation": { "home": "Strona główna", @@ -1086,12 +1086,18 @@ "dashboard": { "metaTitle": "Panel | DevLovers", "metaDescription": "Śledź swój postęp i wyniki quizów", - "title": "Panel użytkownika", + "title": "Panel", "subtitle": "Witaj z powrotem na swoim placu treningowym", "supportLink": "Opinie", "profile": { "title": "Profil", "subtitle": "Zarządzaj swoim profilem publicznym i preferencjami", + "defaultName": "Programista", + "defaultRole": "użytkownik", + "roles": { + "user": "Użytkownik", + "admin": "Administrator" + }, "sponsor": "Sponsor", "becomeSponsor": "Zostań sponsorem", "supportAgain": "Okaż więcej wsparcia", @@ -1135,7 +1141,10 @@ "last6Months": "Ostatnie 6 miesięcy", "lastYear": "Ten rok", "totalActiveDays": "Aktywne dni", - "mostActiveMonth": "Aktywny miesiąc" + "mostActiveMonth": "Aktywny miesiąc", + "heatmapNoActivity": "Brak aktywności", + "heatmapAttempts": "{count, plural, one {# próba} few {# próby} many {# prób} other {# próby}}", + "heatmapActiveDays": "{count, plural, one {# aktywny dzień} few {# aktywne dni} many {# aktywnych dni} other {# aktywnych dni}}" }, "quizSaved": { "title": "Wynik quizu zapisany!", @@ -1536,7 +1545,7 @@ "achievement": { "unlocked": { "title": "Osiągnięcie odblokowane!", - "message": "Właśnie zdobyłeś odznakę {id}!" + "message": "Właśnie zdobyłeś odznakę {name}!" } }, "account": { diff --git a/frontend/messages/uk.json b/frontend/messages/uk.json index cbbc6f5f..b2609d95 100644 --- a/frontend/messages/uk.json +++ b/frontend/messages/uk.json @@ -11,8 +11,8 @@ }, "aria": { "primaryNav": "Головна навігація", - "dashboard": "Панель керування", - "admin": "Admin panel", + "dashboard": "Кабінет", + "admin": "Панель адміністратора", "blogCategories": "Категорії блогу", "searchBlog": "Пошук у блозі", "toggleMenu": "Відкрити/закрити меню", @@ -1084,9 +1084,9 @@ "divider": "або" }, "dashboard": { - "metaTitle": "Панель | DevLovers", + "metaTitle": "Кабінет | DevLovers", "metaDescription": "Відстежуйте свій прогрес та результати квізів", - "title": "Кабінет користувача", + "title": "Кабінет", "subtitle": "З поверненням на вашу навчальну площадку", "supportLink": "Відгук", "profile": { @@ -1094,6 +1094,10 @@ "subtitle": "Керуйте своїм публічним профілем та налаштуваннями", "defaultName": "Розробник", "defaultRole": "користувач", + "roles": { + "user": "Користувач", + "admin": "Адміністратор" + }, "sponsor": "Спонсор", "becomeSponsor": "Стати спонсором", "supportAgain": "Проявити більше любові", @@ -1139,7 +1143,10 @@ "last6Months": "Останні 6 місяців", "lastYear": "Цей рік", "totalActiveDays": "Активні дні", - "mostActiveMonth": "Активний місяць" + "mostActiveMonth": "Активний місяць", + "heatmapNoActivity": "Немає активності", + "heatmapAttempts": "{count, plural, one {# спроба} few {# спроби} many {# спроб} other {# спроби}}", + "heatmapActiveDays": "{count, plural, one {# активний день} few {# активні дні} many {# активних днів} other {# активні дні}}" }, "quizSaved": { "title": "Результат квізу збережено!", @@ -1538,7 +1545,7 @@ "achievement": { "unlocked": { "title": "Досягнення розблоковано!", - "message": "Ви щойно отримали значок {id}!" + "message": "Ви щойно отримали значок {name}!" } }, "account": { From fc709423acd704a55cdd646b423c05b74a29b19f Mon Sep 17 00:00:00 2001 From: tetiana zorii Date: Mon, 23 Feb 2026 00:25:20 -0500 Subject: [PATCH 3/4] fix --- frontend/app/globals.css | 1 + frontend/components/header/NotificationBell.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 723a988a..2730ca78 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -125,6 +125,7 @@ html { overflow-x: hidden; --scroll-thumb-alpha: 0; + transition: --scroll-thumb-alpha 0.3s ease; } diff --git a/frontend/components/header/NotificationBell.tsx b/frontend/components/header/NotificationBell.tsx index f5a1df3e..46a9dc96 100644 --- a/frontend/components/header/NotificationBell.tsx +++ b/frontend/components/header/NotificationBell.tsx @@ -161,7 +161,7 @@ export function NotificationBell() {