From 79ac32cdac052b417fe657a200b1f4033faa8485 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Fri, 5 Jun 2026 13:55:00 +0200 Subject: [PATCH 1/6] reduce notification polling --- src/app/api/notifications/stream/route.ts | 25 -- .../notifications/NotificationCenter.tsx | 24 +- src/data/constants.ts | 1 - src/hooks/useRealtimeNotifications.ts | 207 ---------------- src/server/notifications/batchingService.ts | 40 +-- src/server/notifications/realtimeService.ts | 230 ------------------ src/server/notifications/service.test.ts | 7 - src/server/notifications/service.ts | 85 +------ src/server/notifications/types.ts | 1 - 9 files changed, 29 insertions(+), 591 deletions(-) delete mode 100644 src/app/api/notifications/stream/route.ts delete mode 100644 src/hooks/useRealtimeNotifications.ts delete mode 100644 src/server/notifications/realtimeService.ts diff --git a/src/app/api/notifications/stream/route.ts b/src/app/api/notifications/stream/route.ts deleted file mode 100644 index 6d9718015..000000000 --- a/src/app/api/notifications/stream/route.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { auth } from '@clerk/nextjs/server' -import { connection, type NextRequest } from 'next/server' -import { logger } from '@/lib/logger' -import { - realtimeNotificationService, - createSSEResponse, -} from '@/server/notifications/realtimeService' - -export async function GET(request: NextRequest) { - await connection() - - try { - const { userId } = await auth() - - if (!userId) return new Response('Unauthorized', { status: 401 }) - - const stream = realtimeNotificationService.createSSEConnection(userId) - const origin = request.headers.get('origin') || undefined - - return createSSEResponse(stream, origin) - } catch (error) { - logger.error('SSE connection error:', error) - return new Response('Internal Server Error', { status: 500 }) - } -} diff --git a/src/components/notifications/NotificationCenter.tsx b/src/components/notifications/NotificationCenter.tsx index 5304169a3..d3edec50c 100644 --- a/src/components/notifications/NotificationCenter.tsx +++ b/src/components/notifications/NotificationCenter.tsx @@ -6,7 +6,7 @@ import { Bell, X } from 'lucide-react' import { useRouter } from 'next/navigation' import { useState, useEffect, type MouseEvent } from 'react' import { createPortal } from 'react-dom' -import { POLLING_INTERVALS } from '@/data/constants' +import { CACHE_DURATIONS, POLLING_INTERVALS } from '@/data/constants' import { api } from '@/lib/api' import toast from '@/lib/toast' import { cn } from '@/lib/utils' @@ -27,15 +27,18 @@ function NotificationCenter(props: Props) { const notificationsQuery = api.notifications.get.useQuery( { limit: 10, offset: 0 }, { - enabled: !!user, - refetchOnWindowFocus: true, - refetchInterval: POLLING_INTERVALS.NOTIFICATIONS, + enabled: !!user && isOpen, + refetchOnWindowFocus: isOpen, + refetchInterval: isOpen ? POLLING_INTERVALS.SHORT : false, + staleTime: CACHE_DURATIONS.VERY_SHORT, }, ) const unreadCountQuery = api.notifications.getUnreadCount.useQuery(undefined, { enabled: !!user, + staleTime: CACHE_DURATIONS.SHORT, refetchOnWindowFocus: true, - refetchInterval: POLLING_INTERVALS.NOTIFICATIONS, + refetchInterval: isOpen ? false : POLLING_INTERVALS.EXTRA_LONG, + refetchIntervalInBackground: false, }) // Mutations @@ -85,6 +88,15 @@ function NotificationCenter(props: Props) { router.push('/notifications') } + const handleToggleNotifications = () => { + const nextIsOpen = !isOpen + setIsOpen(nextIsOpen) + + if (nextIsOpen) { + void utils.notifications.getUnreadCount.invalidate() + } + } + // Add escape key handler useEffect(() => { const handleEscape = (event: KeyboardEvent) => { @@ -158,7 +170,7 @@ function NotificationCenter(props: Props) { } onClick={(ev) => { ev.stopPropagation() - setIsOpen(!isOpen) + handleToggleNotifications() }} className="relative p-2 text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 transition-colors" disabled={isLoading} diff --git a/src/data/constants.ts b/src/data/constants.ts index 6d83d8709..831f31375 100644 --- a/src/data/constants.ts +++ b/src/data/constants.ts @@ -3,7 +3,6 @@ import { ms } from '@/utils/time' // Polling intervals in milliseconds export const POLLING_INTERVALS = { SHORT: ms.minutes(1), - NOTIFICATIONS: ms.minutes(3), LONG: ms.minutes(5), EXTRA_LONG: ms.minutes(10), } as const diff --git a/src/hooks/useRealtimeNotifications.ts b/src/hooks/useRealtimeNotifications.ts deleted file mode 100644 index 68668cd7d..000000000 --- a/src/hooks/useRealtimeNotifications.ts +++ /dev/null @@ -1,207 +0,0 @@ -'use client' - -import { useUser } from '@clerk/nextjs' -import { useCallback, useEffect, useRef, useState } from 'react' -import { z } from 'zod' -import { safeParseJSON } from '@/utils/client-validation' - -interface RealtimeNotification { - id: string - type: string - title: string - message: string - actionUrl?: string - createdAt: string -} - -interface SSEMessage { - type: 'connected' | 'notification' | 'unread_count' | 'ping' - data: unknown -} - -// Schemas for validation -const RealtimeNotificationSchema = z.object({ - id: z.string(), - type: z.string(), - title: z.string(), - message: z.string(), - actionUrl: z.string().optional(), - createdAt: z.string(), -}) - -const SSEMessageSchema = z.object({ - type: z.enum(['connected', 'notification', 'unread_count', 'ping']), - data: z.unknown(), -}) - -interface UseRealtimeNotificationsReturn { - isConnected: boolean - notifications: RealtimeNotification[] - unreadCount: number - connect: () => void - disconnect: () => void - markAsRead: (notificationId: string) => void - clearNotifications: () => void -} - -export function useRealtimeNotifications(): UseRealtimeNotificationsReturn { - const { user, isLoaded } = useUser() - const [isConnected, setIsConnected] = useState(false) - const [notifications, setNotifications] = useState([]) - const [unreadCount, setUnreadCount] = useState(0) - - const eventSourceRef = useRef(null) - const reconnectTimeoutRef = useRef(null) - const maxReconnectAttempts = 5 - const reconnectAttempts = useRef(0) - - const disconnect = useCallback(() => { - if (eventSourceRef.current) { - eventSourceRef.current.close() - eventSourceRef.current = null - } - - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current) - reconnectTimeoutRef.current = null - } - - setIsConnected(false) - }, []) - - const connect = useCallback(() => { - if (!user?.id || eventSourceRef.current) { - return - } - - try { - const eventSource = new EventSource(`/api/notifications/stream`) - eventSourceRef.current = eventSource - - eventSource.onopen = () => { - console.log('SSE connection opened') - setIsConnected(true) - reconnectAttempts.current = 0 - } - - eventSource.onmessage = (event) => { - try { - const message = safeParseJSON(event.data, SSEMessageSchema, { - type: 'ping', - data: null, - } as SSEMessage) - - switch (message.type) { - case 'connected': - console.log('Connected to notification stream') - break - - case 'notification': - const validationResult = RealtimeNotificationSchema.safeParse(message.data) - if (!validationResult.success) { - console.warn('Invalid notification data:', validationResult.error) - break - } - const notification = validationResult.data - setNotifications((prev) => [notification, ...prev].slice(0, 50)) // Keep last 50 - - // Show browser notification if permission granted - if (Notification.permission === 'granted') { - new Notification(notification.title, { - body: notification.message, - icon: '/favicon/favicon-32x32.png', - tag: notification.id, - }) - } - break - - case 'unread_count': - setUnreadCount((message.data as { count: number }).count) - break - - case 'ping': - // Respond to ping to keep connection alive - console.log('Received ping from server') - break - - default: - console.log('Unknown SSE message type:', message.type) - } - } catch (error) { - console.error('Error parsing SSE message:', error) - } - } - - eventSource.onerror = () => { - console.error('SSE connection error') - setIsConnected(false) - - // Attempt to reconnect - if (reconnectAttempts.current < maxReconnectAttempts) { - reconnectAttempts.current++ - const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 30000) // Exponential backoff, max 30s - - console.log( - `Attempting to reconnect in ${delay}ms (attempt ${reconnectAttempts.current})`, - ) - - reconnectTimeoutRef.current = setTimeout(() => { - disconnect() - connect() - }, delay) - } else { - console.error('Max reconnection attempts reached') - } - } - } catch (error) { - console.error('Failed to create SSE connection:', error) - } - }, [user?.id, disconnect]) - - const markAsRead = useCallback((notificationId: string) => { - setNotifications((prev) => - prev.map((notif) => (notif.id === notificationId ? { ...notif, read: true } : notif)), - ) - }, []) - - const clearNotifications = useCallback(() => { - setNotifications([]) - }, []) - - // Auto-connect when user is loaded - useEffect(() => { - if (isLoaded && user?.id) { - connect() - } - - return () => { - disconnect() - } - }, [isLoaded, user?.id, connect, disconnect]) - - // Request notification permission on mount - useEffect(() => { - if ('Notification' in window && Notification.permission === 'default') { - Notification.requestPermission().then((permission) => { - console.log('Notification permission:', permission) - }) - } - }, []) - - // Cleanup on unmount - useEffect(() => { - return () => { - disconnect() - } - }, [disconnect]) - - return { - isConnected, - notifications, - unreadCount, - connect, - disconnect, - markAsRead, - clearNotifications, - } -} diff --git a/src/server/notifications/batchingService.ts b/src/server/notifications/batchingService.ts index 0f9d99347..80acb4dd4 100644 --- a/src/server/notifications/batchingService.ts +++ b/src/server/notifications/batchingService.ts @@ -7,7 +7,6 @@ import { NotificationType, } from '@orm/client' import { createEmailService } from './emailService' -import { realtimeNotificationService } from './realtimeService' import type { NotificationData } from './types' export interface BatchedNotification { @@ -204,12 +203,12 @@ export class NotificationBatchingService { // Deliver via appropriate channels const deliveryPromises: Promise[] = [] - // In-app delivery + // In-app delivery is complete once the notification record exists. if ( data.deliveryChannel === DeliveryChannel.IN_APP || data.deliveryChannel === DeliveryChannel.BOTH ) { - deliveryPromises.push(this.deliverInApp(dbNotification.id, data)) + deliveryPromises.push(Promise.resolve(true)) } // Email delivery @@ -245,41 +244,6 @@ export class NotificationBatchingService { } } - // Deliver in-app notification - private async deliverInApp(notificationId: string, data: NotificationData): Promise { - try { - const notification = await prisma.notification.findUnique({ - where: { id: notificationId }, - }) - - if (!notification) return false - - realtimeNotificationService.sendNotificationToUser(data.userId, { - id: notification.id, - type: notification.type, - title: notification.title, - message: notification.message, - actionUrl: notification.actionUrl || undefined, - createdAt: notification.createdAt.toISOString(), - }) - - // Update unread count - const unreadCount = await prisma.notification.count({ - where: { - userId: data.userId, - isRead: false, - }, - }) - - realtimeNotificationService.sendUnreadCountToUser(data.userId, unreadCount) - - return true - } catch (error) { - console.error('In-app delivery error:', error) - return false - } - } - // Deliver email notification private async deliverEmail(data: NotificationData): Promise { if (!this.emailService) return false diff --git a/src/server/notifications/realtimeService.ts b/src/server/notifications/realtimeService.ts deleted file mode 100644 index d60618218..000000000 --- a/src/server/notifications/realtimeService.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { getAllowedOrigins } from '@/lib/cors' - -interface SSEConnection { - userId: string - controller: ReadableStreamDefaultController - lastPing: number -} - -class RealtimeNotificationService { - private connections = new Map() - private pingInterval: NodeJS.Timeout | null = null - - // Connection limits - private readonly MAX_CONNECTIONS = 1000 // Maximum total connections - private readonly MAX_CONNECTIONS_PER_IP = 10 // Maximum connections per IP - private connectionsByIp = new Map>() // IP -> Set of userIds - - constructor() { - this.startPingInterval() - } - - // Create SSE connection for a user - createSSEConnection(userId: string, clientIp?: string): ReadableStream { - // Check total connection limit - if (this.connections.size >= this.MAX_CONNECTIONS) { - throw new Error('Server connection limit reached') - } - - // Check per-IP connection limit if IP is provided - if (clientIp) { - const ipConnections = this.connectionsByIp.get(clientIp) || new Set() - if (ipConnections.size >= this.MAX_CONNECTIONS_PER_IP) { - throw new Error('Connection limit exceeded for this IP') - } - } - - return new ReadableStream({ - start: (controller) => { - // Close existing connection for this user if any - if (this.connections.has(userId)) { - const existing = this.connections.get(userId) - existing?.controller.close() - this.connections.delete(userId) - } - - // Store connection - this.connections.set(userId, { - userId, - controller, - lastPing: Date.now(), - }) - - // Track IP connection if provided - if (clientIp) { - const ipConnections = this.connectionsByIp.get(clientIp) || new Set() - ipConnections.add(userId) - this.connectionsByIp.set(clientIp, ipConnections) - } - - // Send initial connection message - this.sendToUser(userId, { - type: 'connected', - data: { message: 'Connected to notification stream' }, - }) - - console.log(`SSE connection established for user: ${userId}`) - }, - cancel: () => { - this.connections.delete(userId) - - // Clean up IP tracking - if (clientIp) { - const ipConnections = this.connectionsByIp.get(clientIp) - if (ipConnections) { - ipConnections.delete(userId) - if (ipConnections.size === 0) { - this.connectionsByIp.delete(clientIp) - } - } - } - - console.log(`SSE connection closed for user: ${userId}`) - }, - }) - } - - // Send notification to specific user - sendNotificationToUser( - userId: string, - notification: { - id: string - type: string - title: string - message: string - actionUrl?: string - createdAt: string - }, - ): boolean { - return this.sendToUser(userId, { - type: 'notification', - data: notification, - }) - } - - // Send unread count update to user - sendUnreadCountToUser(userId: string, count: number): boolean { - return this.sendToUser(userId, { - type: 'unread_count', - data: { count }, - }) - } - - // Broadcast to all connected users - broadcast(message: { type: string; data: unknown }): void { - for (const [userId] of this.connections) { - this.sendToUser(userId, message) - } - } - - // Send message to specific user - private sendToUser(userId: string, message: { type: string; data: unknown }): boolean { - const connection = this.connections.get(userId) - if (!connection) return false - - try { - const sseData = `data: ${JSON.stringify(message)}\n\n` - connection.controller.enqueue(new TextEncoder().encode(sseData)) - return true - } catch (error) { - console.error(`Failed to send SSE message to user ${userId}:`, error) - this.connections.delete(userId) - return false - } - } - - // Keep connections alive with periodic pings - private startPingInterval(): void { - this.pingInterval = setInterval(() => { - const now = Date.now() - const staleConnections: string[] = [] - - for (const [userId, connection] of this.connections) { - // Send ping - const pingSuccess = this.sendToUser(userId, { - type: 'ping', - data: { timestamp: now }, - }) - - if (!pingSuccess || now - connection.lastPing > 60000) { - // Connection failed or hasn't responded to ping in 60 seconds - staleConnections.push(userId) - } else { - connection.lastPing = now - } - } - - // Clean up stale connections - for (const userId of staleConnections) { - this.connections.delete(userId) - console.log(`Removed stale SSE connection for user: ${userId}`) - } - }, 30000) // Ping every 30 seconds - // Let process exit if this is the only timer - this.pingInterval.unref?.() - } - - // Get connection status - getConnectionStatus(): { - totalConnections: number - connectedUsers: string[] - } { - return { - totalConnections: this.connections.size, - connectedUsers: Array.from(this.connections.keys()), - } - } - - // Cleanup - destroy(): void { - if (this.pingInterval) { - clearInterval(this.pingInterval) - this.pingInterval = null - } - - // Close all connections - for (const [userId, connection] of this.connections) { - try { - connection.controller.close() - } catch (error) { - console.error(`Error closing connection for user ${userId}:`, error) - } - } - - this.connections.clear() - this.connectionsByIp.clear() - } -} - -// Singleton instance -export const realtimeNotificationService = new RealtimeNotificationService() - -/** - * Helper function to create SSE response with proper CORS - * @param stream - * @param origin - */ -export function createSSEResponse(stream: ReadableStream, origin?: string): Response { - // Use centralized CORS configuration - const allowedOrigins = getAllowedOrigins() - - // Allow mobile apps (no origin) or explicitly allowed origins - const allowOrigin = !origin - ? '*' // No origin header (mobile apps) - : allowedOrigins.length === 0 - ? '*' // No origins configured (dev mode) - : allowedOrigins.includes(origin) - ? origin // Origin is allowed - : allowedOrigins[0] || '*' // Fallback to first allowed origin - - return new Response(stream, { - headers: { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - 'Access-Control-Allow-Origin': allowOrigin, - 'Access-Control-Allow-Headers': 'Cache-Control', - 'Access-Control-Allow-Credentials': 'true', - }, - }) -} diff --git a/src/server/notifications/service.test.ts b/src/server/notifications/service.test.ts index d72236f0b..7800d440a 100644 --- a/src/server/notifications/service.test.ts +++ b/src/server/notifications/service.test.ts @@ -105,13 +105,6 @@ vi.mock('@/server/notifications/rateLimitService', () => ({ }, })) -vi.mock('@/server/notifications/realtimeService', () => ({ - realtimeNotificationService: { - sendNotificationToUser: vi.fn().mockReturnValue(true), - sendUnreadCountToUser: vi.fn(), - }, -})) - vi.mock('@/server/notifications/emailService', () => ({ createEmailService: vi.fn().mockReturnValue({ sendNotificationEmail: vi.fn().mockResolvedValue({ success: true }), diff --git a/src/server/notifications/service.ts b/src/server/notifications/service.ts index a42161fcc..dbb6dafe3 100644 --- a/src/server/notifications/service.ts +++ b/src/server/notifications/service.ts @@ -19,7 +19,6 @@ import { import { createEmailService } from './emailService' import { type NotificationEventData, notificationEventEmitter } from './eventEmitter' import { notificationRateLimitService } from './rateLimitService' -import { realtimeNotificationService } from './realtimeService' import { notificationTemplateEngine, type TemplateContext } from './templates' import type { NotificationData, @@ -44,7 +43,6 @@ export class NotificationService { constructor(config: Partial = {}) { this.config = { enableEmailDelivery: false, - enableRealTimeDelivery: true, maxRetries: 3, retryDelayMs: 1000, batchSize: 50, @@ -180,7 +178,7 @@ export class NotificationService { const deliveryResults: NotificationDeliveryResult[] = [] // Always deliver in-app notifications - const inAppResult = await this.deliverInApp(notificationId, data) + const inAppResult = this.deliverInApp() deliveryResults.push(inAppResult) // Deliver email if enabled and email service is configured @@ -216,49 +214,11 @@ export class NotificationService { }) } - private async deliverInApp( - notificationId: string, - data: NotificationData, - ): Promise { - try { - // Send real-time notification if user is connected - const notification = await prisma.notification.findUnique({ - where: { id: notificationId }, - }) - - if (notification) { - const sent = realtimeNotificationService.sendNotificationToUser(data.userId, { - id: notification.id, - type: notification.type, - title: notification.title, - message: notification.message, - actionUrl: notification.actionUrl || undefined, - createdAt: notification.createdAt.toISOString(), - }) - - // Also update unread count - const unreadCount = await prisma.notification.count({ - where: { userId: data.userId, isRead: false }, - }) - - realtimeNotificationService.sendUnreadCountToUser(data.userId, unreadCount) - - logger.log(`Real-time notification ${sent ? 'sent' : 'queued'} for user ${data.userId}`) - } - - return { - success: true, - channel: DeliveryChannel.IN_APP, - status: NotificationDeliveryStatus.SENT, - } - } catch (error) { - console.error('In-app delivery error:', error) - return { - success: false, - channel: DeliveryChannel.IN_APP, - status: NotificationDeliveryStatus.FAILED, - error: error instanceof Error ? error.message : 'Unknown error', - } + private deliverInApp(): NotificationDeliveryResult { + return { + success: true, + channel: DeliveryChannel.IN_APP, + status: NotificationDeliveryStatus.SENT, } } @@ -384,16 +344,8 @@ export class NotificationService { data: { isRead: true }, }) - // If notification was actually updated, invalidate caches and update real-time count + // If notification was actually updated, invalidate caches if (updatedCount.count > 0) { - // Get updated unread count - const unreadCount = await prisma.notification.count({ - where: { userId, isRead: false }, - }) - - // Send real-time unread count update - realtimeNotificationService.sendUnreadCountToUser(userId, unreadCount) - // Clear analytics cache since notification status changed notificationAnalyticsService.clearCache() @@ -408,11 +360,8 @@ export class NotificationService { data: { isRead: true }, }) - // If any notifications were updated, invalidate caches and update real-time count + // If any notifications were updated, invalidate caches if (updatedCount.count > 0) { - // Send real-time unread count update (should be 0 after marking all as read) - realtimeNotificationService.sendUnreadCountToUser(userId, 0) - // Clear analytics cache since notification status changed notificationAnalyticsService.clearCache() @@ -421,29 +370,13 @@ export class NotificationService { } async deleteNotification(notificationId: string, userId: string): Promise { - // First check if the notification exists and is unread - const notification = await prisma.notification.findFirst({ - where: { id: notificationId, userId }, - select: { isRead: true }, - }) - // Delete the notification const deletedCount = await prisma.notification.deleteMany({ where: { id: notificationId, userId }, }) - // If notification was deleted and was unread, update real-time count + // If notification was deleted, invalidate caches if (deletedCount.count > 0) { - // If the deleted notification was unread, update the unread count - if (notification && !notification.isRead) { - const unreadCount = await prisma.notification.count({ - where: { userId, isRead: false }, - }) - - // Send real-time unread count update - realtimeNotificationService.sendUnreadCountToUser(userId, unreadCount) - } - // Clear analytics cache since a notification was deleted notificationAnalyticsService.clearCache() diff --git a/src/server/notifications/types.ts b/src/server/notifications/types.ts index c3832ed9a..a5f5ba333 100644 --- a/src/server/notifications/types.ts +++ b/src/server/notifications/types.ts @@ -72,7 +72,6 @@ export interface NotificationEventPayload { export interface NotificationServiceConfig { enableEmailDelivery: boolean - enableRealTimeDelivery: boolean maxRetries: number retryDelayMs: number batchSize: number From b94585838364d85e9e7b48ff373b493bc38d2e20 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Fri, 5 Jun 2026 14:06:15 +0200 Subject: [PATCH 2/6] clean notification files --- .../notifications/NotificationCenter.tsx | 21 ---- src/server/notifications/batchingService.ts | 32 +---- src/server/notifications/service.test.ts | 2 - src/server/notifications/service.ts | 113 ++---------------- 4 files changed, 11 insertions(+), 157 deletions(-) diff --git a/src/components/notifications/NotificationCenter.tsx b/src/components/notifications/NotificationCenter.tsx index d3edec50c..ec0fe76a3 100644 --- a/src/components/notifications/NotificationCenter.tsx +++ b/src/components/notifications/NotificationCenter.tsx @@ -41,7 +41,6 @@ function NotificationCenter(props: Props) { refetchIntervalInBackground: false, }) - // Mutations const markAsReadMutation = api.notifications.markAsRead.useMutation({ onMutate: () => setIsLoading(true), onSuccess: () => { @@ -97,7 +96,6 @@ function NotificationCenter(props: Props) { } } - // Add escape key handler useEffect(() => { const handleEscape = (event: KeyboardEvent) => { if (event.key === 'Escape' && isOpen) { @@ -116,21 +114,17 @@ function NotificationCenter(props: Props) { } }, [isOpen]) - // Don't render anything if user is not authenticated if (!user) return null const handleNotificationClick = (notification: (typeof notifications)[0]) => { - // Mark as read if not already read (swallow error if it fails) if (!notification.isRead) { markAsReadMutation.mutateAsync({ notificationId: notification.id }).catch(console.error) } setIsOpen(false) - // Navigate based on actionUrl if available if (notification.actionUrl) return router.push(notification.actionUrl) - // Try to extract route from metadata if actionUrl is not available const metadata = notification.metadata as Record if (typeof metadata?.listingId === 'string') { router.push(`/listings/${metadata.listingId}`) @@ -139,7 +133,6 @@ function NotificationCenter(props: Props) { } else if (typeof metadata?.userId === 'string') { router.push(`/users/${metadata.userId}`) } else { - // Default to notifications page if no specific route router.push('/notifications') } } @@ -153,7 +146,6 @@ function NotificationCenter(props: Props) { } const handleBackdropClick = (ev: MouseEvent) => { - // Only close if the click is directly on the backdrop, not bubbling from child elements if (ev.target !== ev.currentTarget) return setIsOpen(false) } @@ -163,7 +155,6 @@ function NotificationCenter(props: Props) { return (
- {/* Notification Bell Button */} - {/* Desktop Dropdown */} {isOpen && ( - {/* Header */}

Notifications

@@ -224,7 +213,6 @@ function NotificationCenter(props: Props) {
- {/* Notifications List */}
- {/* Footer */} {notifications.length > 0 && (