diff --git a/frontend/actions/notifications.ts b/frontend/actions/notifications.ts index e177eef2..a71a6570 100644 --- a/frontend/actions/notifications.ts +++ b/frontend/actions/notifications.ts @@ -1,7 +1,6 @@ 'use server'; import { and,desc, eq } from 'drizzle-orm'; -import { revalidatePath } from 'next/cache'; import { db } from '@/db'; import { notifications } from '@/db/schema/notifications'; @@ -39,7 +38,6 @@ export async function markAsRead(notificationId: string) { ) ); - revalidatePath('/', 'layout'); return { success: true }; } catch (error) { console.error('Failed to mark notification as read:', error); @@ -57,7 +55,6 @@ export async function markAllAsRead() { .set({ isRead: true }) .where(and(eq(notifications.userId, session.id), eq(notifications.isRead, false))); - revalidatePath('/', 'layout'); return { success: true }; } catch (error) { console.error('Failed to mark all notifications as read:', error); @@ -86,7 +83,6 @@ export async function createNotification(data: { }) .returning(); - revalidatePath('/', 'layout'); return result; } catch (error) { console.error('Failed to create notification:', error); diff --git a/frontend/app/[locale]/blog/[slug]/PostDetails.tsx b/frontend/app/[locale]/blog/[slug]/PostDetails.tsx index d1f9ab79..95b3c13c 100644 --- a/frontend/app/[locale]/blog/[slug]/PostDetails.tsx +++ b/frontend/app/[locale]/blog/[slug]/PostDetails.tsx @@ -9,8 +9,6 @@ import { Link } from '@/i18n/routing'; import { formatBlogDate } from '@/lib/blog/date'; import { shouldBypassImageOptimization } from '@/lib/blog/image'; -export const revalidate = 0; - type SocialLink = { _key?: string; platform?: string; @@ -383,18 +381,14 @@ export default async function PostDetails({ const slugParam = String(slug || '').trim(); if (!slugParam) return notFound(); - const post: Post | null = await client - .withConfig({ useCdn: false }) - .fetch(query, { - slug: slugParam, - locale, - }); - const recommendedAll: Post[] = await client - .withConfig({ useCdn: false }) - .fetch(recommendedQuery, { - slug: slugParam, - locale, - }); + const post: Post | null = await client.fetch(query, { + slug: slugParam, + locale, + }); + const recommendedAll: Post[] = await client.fetch(recommendedQuery, { + slug: slugParam, + locale, + }); const recommendedPosts = seededShuffle( recommendedAll, hashString(slugParam) diff --git a/frontend/app/[locale]/blog/[slug]/page.tsx b/frontend/app/[locale]/blog/[slug]/page.tsx index 1b8be81f..377b9354 100644 --- a/frontend/app/[locale]/blog/[slug]/page.tsx +++ b/frontend/app/[locale]/blog/[slug]/page.tsx @@ -4,6 +4,8 @@ import { client } from '@/client'; import PostDetails from './PostDetails'; +export const revalidate = 3600; + export async function generateStaticParams() { const slugs = await client.fetch( groq`*[_type == "post" && defined(slug.current)][].slug.current` diff --git a/frontend/app/[locale]/blog/category/[category]/page.tsx b/frontend/app/[locale]/blog/category/[category]/page.tsx index 4746431e..8db74901 100644 --- a/frontend/app/[locale]/blog/category/[category]/page.tsx +++ b/frontend/app/[locale]/blog/category/[category]/page.tsx @@ -11,7 +11,7 @@ import { Link } from '@/i18n/routing'; import { formatBlogDate } from '@/lib/blog/date'; import { shouldBypassImageOptimization } from '@/lib/blog/image'; -export const revalidate = 0; +export const revalidate = 3600; type Author = { name?: string; @@ -50,9 +50,7 @@ export default async function BlogCategoryPage({ const t = await getTranslations({ locale, namespace: 'blog' }); const tNav = await getTranslations({ locale, namespace: 'navigation' }); const categoryKey = String(category || '').toLowerCase(); - const categories: Category[] = await client - .withConfig({ useCdn: false }) - .fetch(categoriesQuery); + const categories: Category[] = await client.fetch(categoriesQuery); const matchedCategory = categories.find( item => slugify(item.title) === categoryKey ); @@ -61,7 +59,7 @@ export default async function BlogCategoryPage({ const categoryTitle = matchedCategory.title; const categoryDisplay = getCategoryLabel(categoryTitle, t); - const posts: Post[] = await client.withConfig({ useCdn: false }).fetch( + const posts: Post[] = await client.fetch( groq` *[_type == "post" && defined(slug.current) && $category in categories[]->title] | order(coalesce(publishedAt, _createdAt) desc) { diff --git a/frontend/app/[locale]/blog/page.tsx b/frontend/app/[locale]/blog/page.tsx index 7e5b2506..a0a159d6 100644 --- a/frontend/app/[locale]/blog/page.tsx +++ b/frontend/app/[locale]/blog/page.tsx @@ -1,5 +1,4 @@ import groq from 'groq'; -import { unstable_noStore as noStore } from 'next/cache'; import { getTranslations } from 'next-intl/server'; import { client } from '@/client'; @@ -7,7 +6,7 @@ import BlogFilters from '@/components/blog/BlogFilters'; import { BlogPageHeader } from '@/components/blog/BlogPageHeader'; import { DynamicGridBackground } from '@/components/shared/DynamicGridBackground'; -export const revalidate = 0; +export const revalidate = 3600; export async function generateMetadata({ params, @@ -25,19 +24,13 @@ export async function generateMetadata({ export default async function BlogPage({ params, - searchParams, }: { params: Promise<{ locale: string }>; - searchParams?: Promise<{ [key: string]: string | string[] | undefined }>; }) { - noStore(); const { locale } = await params; const t = await getTranslations({ locale, namespace: 'blog' }); - const sp = searchParams ? await searchParams : undefined; - const authorParam = typeof sp?.author === 'string' ? sp.author.trim() : ''; - const hasAuthorFilter = authorParam.length > 0; - const posts = await client.withConfig({ useCdn: false }).fetch( + const posts = await client.fetch( groq` *[_type == "post" && defined(slug.current)] | order(coalesce(publishedAt, _createdAt) desc) { @@ -74,7 +67,7 @@ export default async function BlogPage({ `, { locale } ); - const categories = await client.withConfig({ useCdn: false }).fetch( + const categories = await client.fetch( groq` *[_type == "category"] | order(orderRank asc) { _id, @@ -87,9 +80,7 @@ export default async function BlogPage({ return (
- {!hasAuthorFilter && ( - - )} + + client.fetch>(groq` + *[_type == "category"] | order(orderRank asc) { + _id, + title + } + `), + ['blog-categories'], + { revalidate: 3600, tags: ['blog-categories'] } +); + export default async function LocaleLayout({ children, params, @@ -30,16 +43,7 @@ export default async function LocaleLayout({ const messages = await getMessages({ locale }); const user = await getCurrentUser(); - const blogCategories: Array<{ _id: string; title: string }> = await client - .withConfig({ useCdn: false }) - .fetch( - groq` - *[_type == "category"] | order(orderRank asc) { - _id, - title - } - ` - ); + const blogCategories = await getCachedBlogCategories(); const userExists = Boolean(user); const enableAdmin = diff --git a/frontend/app/api/blog-author/route.ts b/frontend/app/api/blog-author/route.ts index c59cb47b..a9522040 100644 --- a/frontend/app/api/blog-author/route.ts +++ b/frontend/app/api/blog-author/route.ts @@ -32,9 +32,7 @@ export async function GET(request: Request) { return NextResponse.json(null, { status: 400 }); } - const author = await client - .withConfig({ useCdn: false }) - .fetch(authorQuery, { name, locale }); + const author = await client.fetch(authorQuery, { name, locale }); return NextResponse.json(author || null, { headers: { 'Cache-Control': 'no-store' }, diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 3028e0b4..c5b2e601 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,7 +1,6 @@ import './globals.css'; import { Analytics } from '@vercel/analytics/next'; -import { SpeedInsights } from '@vercel/speed-insights/next'; import type { Metadata } from 'next'; import { Geist, Geist_Mono } from 'next/font/google'; @@ -90,8 +89,7 @@ export default function RootLayout({ className={`${geistSans.variable} ${geistMono.variable} bg-gray-50 text-gray-900 antialiased transition-colors duration-300 dark:bg-neutral-950 dark:text-gray-100`} > {children} - - + {process.env.NODE_ENV === 'production' && } ); diff --git a/frontend/client.ts b/frontend/client.ts index 3085f871..3436262d 100644 --- a/frontend/client.ts +++ b/frontend/client.ts @@ -3,6 +3,6 @@ import { createClient } from '@sanity/client'; export const client = createClient({ projectId: '6y9ive6v', dataset: 'production', - useCdn: false, + useCdn: true, apiVersion: '2025-11-29', }); diff --git a/frontend/components/header/NotificationBell.tsx b/frontend/components/header/NotificationBell.tsx index a3751886..6d5634ed 100644 --- a/frontend/components/header/NotificationBell.tsx +++ b/frontend/components/header/NotificationBell.tsx @@ -1,20 +1,38 @@ 'use client'; import { AnimatePresence, motion } from 'framer-motion'; -import { Bell, CheckCircle2, FileText, Info, ShoppingBag, Trophy, User } from 'lucide-react'; +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'; +import { + getNotifications, + markAllAsRead, + markAsRead, +} from '@/actions/notifications'; 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)); + const daysDifference = Math.round( + (date.getTime() - now) / (1000 * 60 * 60 * 24) + ); if (daysDifference === 0) { - const hoursDifference = Math.round((date.getTime() - now) / (1000 * 60 * 60)); + const hoursDifference = Math.round( + (date.getTime() - now) / (1000 * 60 * 60) + ); if (hoursDifference === 0) { - const minutesDifference = Math.round((date.getTime() - now) / (1000 * 60)); + const minutesDifference = Math.round( + (date.getTime() - now) / (1000 * 60) + ); if (minutesDifference === 0) return justNow; return rtf.format(minutesDifference, 'minute'); } @@ -47,10 +65,9 @@ export function NotificationBell() { const fetchNotifications = async () => { try { const data = await getNotifications(); - // data from db may have Date strings or objects, map accordingly const parsed = data.map(n => ({ ...n, - createdAt: new Date(n.createdAt) + createdAt: new Date(n.createdAt), })); setNotifications(parsed); } catch (error) { @@ -62,12 +79,18 @@ export function NotificationBell() { useEffect(() => { fetchNotifications(); - // Poll every 30 seconds for new notifications just in case - const interval = setInterval(fetchNotifications, 30000); - return () => clearInterval(interval); + const handleVisibility = () => { + if (document.visibilityState === 'visible') { + fetchNotifications(); + } + }; + + document.addEventListener('visibilitychange', handleVisibility); + return () => + document.removeEventListener('visibilitychange', handleVisibility); }, []); - const unreadCount = notifications.filter((n) => !n.isRead).length; + const unreadCount = notifications.filter(n => !n.isRead).length; useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -81,7 +104,6 @@ export function NotificationBell() { if (isOpen) { document.addEventListener('mousedown', handleClickOutside); - // Re-fetch when opening so we know it's fresh fetchNotifications(); } return () => document.removeEventListener('mousedown', handleClickOutside); @@ -89,16 +111,14 @@ export function NotificationBell() { const handleMarkAllAsRead = async () => { await markAllAsRead(); - setNotifications((prev) => - prev.map((n) => ({ ...n, isRead: true })) - ); + setNotifications(prev => prev.map(n => ({ ...n, isRead: true }))); }; const handleMarkAsRead = async (id: string, isRead: boolean) => { if (isRead) return; await markAsRead(id); - setNotifications((prev) => - prev.map((n) => (n.id === id ? { ...n, isRead: true } : n)) + setNotifications(prev => + prev.map(n => (n.id === id ? { ...n, isRead: true } : n)) ); }; @@ -112,9 +132,8 @@ export function NotificationBell() { 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 }); } @@ -165,21 +184,21 @@ export function NotificationBell() {
{isOpen && ( - -
+
{loading ? ( -
- - +
+ + -

{t('syncing')}

+

+ {t('syncing')} +

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

{t('emptyTitle')}

-

{t('emptySubtitle')}

+

+ {t('emptyTitle')} +

+

+ {t('emptySubtitle')} +

) : ( @@ -233,42 +266,59 @@ export function NotificationBell() { initial={{ opacity: 0, x: -10 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: 10 }} - transition={{ + transition={{ type: 'spring', stiffness: 400, damping: 30, - delay: index * 0.02 + delay: index * 0.02, }} - onClick={() => handleMarkAsRead(notification.id, notification.isRead)} - className={`relative flex cursor-pointer items-start gap-3 rounded-lg p-3 transition-colors duration-200 group ${ - notification.isRead - ? 'text-muted-foreground hover:bg-secondary hover:text-foreground active:bg-secondary' + onClick={() => + handleMarkAsRead(notification.id, notification.isRead) + } + className={`group relative flex cursor-pointer items-start gap-3 rounded-lg p-3 transition-colors duration-200 ${ + notification.isRead + ? 'text-muted-foreground hover:bg-secondary hover:text-foreground active:bg-secondary' : 'bg-(--accent-primary)/10 text-gray-900 dark:text-white' }`} > -
+
{getIconForType(notification.type)}
-
+
-

+

{getNotificationTitle(notification)}

{!notification.isRead && ( -
+
)}
-

+

{getNotificationMessage(notification)}

- - {t(`types.${getSafeNotificationType(notification.type)}` as const)} - - - - {getRelativeTime(notification.createdAt, locale, t('justNow'))} - + + {t( + `types.${getSafeNotificationType(notification.type)}` as const + )} + + + + {getRelativeTime( + notification.createdAt, + locale, + t('justNow') + )} +
@@ -276,7 +326,6 @@ export function NotificationBell() { )}
- )} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4098c1da..0b4d34ca 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -26,7 +26,6 @@ "@tiptap/starter-kit": "^3.20.0", "@upstash/redis": "^1.36.1", "@vercel/analytics": "^1.6.1", - "@vercel/speed-insights": "^1.3.1", "bcryptjs": "^3.0.3", "class-variance-authority": "^0.7.1", "cloudinary": "^2.8.0", @@ -6731,40 +6730,6 @@ } } }, - "node_modules/@vercel/speed-insights": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@vercel/speed-insights/-/speed-insights-1.3.1.tgz", - "integrity": "sha512-PbEr7FrMkUrGYvlcLHGkXdCkxnylCWePx7lPxxq36DNdfo9mcUjLOmqOyPDHAOgnfqgGGdmE3XI9L/4+5fr+vQ==", - "license": "Apache-2.0", - "peerDependencies": { - "@sveltejs/kit": "^1 || ^2", - "next": ">= 13", - "react": "^18 || ^19 || ^19.0.0-rc", - "svelte": ">= 4", - "vue": "^3", - "vue-router": "^4" - }, - "peerDependenciesMeta": { - "@sveltejs/kit": { - "optional": true - }, - "next": { - "optional": true - }, - "react": { - "optional": true - }, - "svelte": { - "optional": true - }, - "vue": { - "optional": true - }, - "vue-router": { - "optional": true - } - } - }, "node_modules/@vitest/coverage-v8": { "version": "4.0.18", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz", diff --git a/frontend/package.json b/frontend/package.json index 34129814..dee3e41c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -40,7 +40,6 @@ "@tiptap/starter-kit": "^3.20.0", "@upstash/redis": "^1.36.1", "@vercel/analytics": "^1.6.1", - "@vercel/speed-insights": "^1.3.1", "bcryptjs": "^3.0.3", "class-variance-authority": "^0.7.1", "cloudinary": "^2.8.0",