From be2cc54bc62ac21d42d9ffc4474053caf17de793 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sat, 3 Jan 2026 00:44:50 +0800 Subject: [PATCH] feat: notification system --- src/components/layout/header/Header.tsx | 9 +- src/components/layout/notification-banner.tsx | 92 ++++++++++++++++++ src/config/notifications.ts | 50 ++++++++++ src/hooks/useActiveNotifications.ts | 96 +++++++++++++++++++ src/hooks/useNotificationConditions.ts | 40 ++++++++ src/stores/useNotificationStore.ts | 58 +++++++++++ 6 files changed, 344 insertions(+), 1 deletion(-) create mode 100644 src/components/layout/notification-banner.tsx create mode 100644 src/config/notifications.ts create mode 100644 src/hooks/useActiveNotifications.ts create mode 100644 src/hooks/useNotificationConditions.ts create mode 100644 src/stores/useNotificationStore.ts diff --git a/src/components/layout/header/Header.tsx b/src/components/layout/header/Header.tsx index 5c866d42..afa87ec5 100644 --- a/src/components/layout/header/Header.tsx +++ b/src/components/layout/header/Header.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import Menu from './Menu'; +import { NotificationBanner, useNotificationBannerVisible } from '../notification-banner'; export type HeaderProps = { ghost?: boolean; @@ -11,6 +12,7 @@ type ScrollState = 'at-top' | 'scrolling-up' | 'scrolling-down'; function Header({ ghost }: HeaderProps) { const [scrollState, setScrollState] = useState('at-top'); + const showBanner = useNotificationBannerVisible(); useEffect(() => { let previousScrollY = window.scrollY; @@ -32,10 +34,13 @@ function Header({ ghost }: HeaderProps) { return () => removeEventListener('scroll', handleScroll); }, [ghost]); + // Spacer height: header (48/56px) + banner (48/56px if visible) + const spacerClass = showBanner ? 'h-[96px] md:h-[112px]' : 'h-[48px] md:h-[56px]'; + return ( <> {/* Spacer div for non-ghost headers to prevent content overlap */} - {!ghost &&
} + {!ghost &&
}
+ {/* Notification banner below navbar */} + ); diff --git a/src/components/layout/notification-banner.tsx b/src/components/layout/notification-banner.tsx new file mode 100644 index 00000000..c1bcf9d4 --- /dev/null +++ b/src/components/layout/notification-banner.tsx @@ -0,0 +1,92 @@ +'use client'; + +import Link from 'next/link'; +import { RiCloseLine } from 'react-icons/ri'; +import { useActiveNotifications } from '@/hooks/useActiveNotifications'; +import { useNotificationStore } from '@/stores/useNotificationStore'; + +export function NotificationBanner() { + const { currentNotification, totalCount, isLoading } = useActiveNotifications(); + const dismiss = useNotificationStore((s) => s.dismiss); + + // Don't render if no notification or still loading + if (!currentNotification || isLoading) { + return null; + } + + const handleDismiss = () => { + dismiss(currentNotification.id); + }; + + const action = currentNotification.action; + + return ( +
+ {/* Grid background overlay */} + + ); +} + +/** + * Hook to check if notification banner is currently visible. + * Used by Header to calculate dynamic spacer height. + */ +export const useNotificationBannerVisible = (): boolean => { + const { currentNotification, isLoading } = useActiveNotifications(); + return !isLoading && currentNotification !== null; +}; diff --git a/src/config/notifications.ts b/src/config/notifications.ts new file mode 100644 index 00000000..f491ebfb --- /dev/null +++ b/src/config/notifications.ts @@ -0,0 +1,50 @@ +import type { ReactNode } from 'react'; + +export type NotificationType = 'info' | 'warning' | 'success' | 'alert'; + +export type NotificationAction = { + label: string; + href?: string; + onClick?: () => void; +}; + +export type NotificationConfig = { + /** Unique identifier for persistence */ + id: string; + /** Message to display in the banner */ + message: string; + /** Optional custom icon (ReactNode) */ + icon?: ReactNode; + /** Notification type for styling */ + type: NotificationType; + /** Optional action button */ + action?: NotificationAction; + /** Optional expiration date - notification auto-hides after this */ + expiresAt?: Date; + /** Category: global (all users) or personalized (condition-based) */ + category: 'global' | 'personalized'; + /** For personalized notifications, maps to a condition in useNotificationConditions */ + conditionId?: string; +}; + +/** + * Centralized notification definitions. + * Add new notifications here with a unique id. + * + * Global notifications show to all users until dismissed or expired. + * Personalized notifications require a conditionId that maps to useNotificationConditions. + */ +export const NOTIFICATIONS: NotificationConfig[] = [ + // Example global notification (uncomment to test): + // { + // id: 'autovault-launch-2026', + // message: 'AutoVault is now live! Deploy your own automated lending vault.', + // type: 'info', + // category: 'global', + // action: { + // label: 'Try AutoVault', + // href: '/autovault', + // }, + // expiresAt: new Date('2026-01-04'), + // }, +]; diff --git a/src/hooks/useActiveNotifications.ts b/src/hooks/useActiveNotifications.ts new file mode 100644 index 00000000..51ee0815 --- /dev/null +++ b/src/hooks/useActiveNotifications.ts @@ -0,0 +1,96 @@ +import { useMemo } from 'react'; +import { NOTIFICATIONS, type NotificationConfig } from '@/config/notifications'; +import { useNotificationStore } from '@/stores/useNotificationStore'; +import { useNotificationConditions } from './useNotificationConditions'; + +export type ActiveNotificationsResult = { + /** Current notification to display (first in queue) */ + currentNotification: NotificationConfig | null; + /** Total count of active notifications (for badge) */ + totalCount: number; + /** Current position in queue (1-indexed) */ + currentIndex: number; + /** Whether conditions are still loading */ + isLoading: boolean; + /** All active notifications */ + activeNotifications: NotificationConfig[]; +}; + +/** + * Combines notification config, dismissed state, and conditions + * to return the list of active notifications. + * + * Filters out: + * - Expired notifications (expiresAt < now) + * - Dismissed notifications (in localStorage) + * - Personalized notifications where condition is false or loading + * + * @example + * ```tsx + * const { currentNotification, totalCount, isLoading } = useActiveNotifications(); + * + * if (!isLoading && currentNotification) { + * // Render notification banner + * } + * ``` + */ +export const useActiveNotifications = (): ActiveNotificationsResult => { + const isDismissed = useNotificationStore((s) => s.isDismissed); + const conditions = useNotificationConditions(); + + const { activeNotifications, isLoading } = useMemo(() => { + const now = new Date(); + let hasLoadingCondition = false; + + const active = NOTIFICATIONS.filter((notification) => { + // Check if expired + if (notification.expiresAt && notification.expiresAt < now) { + return false; + } + + // Check if dismissed + if (isDismissed(notification.id)) { + return false; + } + + // For personalized notifications, check condition + if (notification.category === 'personalized' && notification.conditionId) { + const condition = conditions.get(notification.conditionId); + + // If condition not found, don't show + if (!condition) { + return false; + } + + // Track loading state + if (condition.isLoading) { + hasLoadingCondition = true; + return false; + } + + // Only show if condition is true + if (!condition.shouldShow) { + return false; + } + } + + return true; + }); + + return { + activeNotifications: active, + isLoading: hasLoadingCondition, + }; + }, [isDismissed, conditions]); + + const currentNotification = activeNotifications[0] ?? null; + const totalCount = activeNotifications.length; + + return { + currentNotification, + totalCount, + currentIndex: totalCount > 0 ? 1 : 0, + isLoading, + activeNotifications, + }; +}; diff --git a/src/hooks/useNotificationConditions.ts b/src/hooks/useNotificationConditions.ts new file mode 100644 index 00000000..f5d0c7c7 --- /dev/null +++ b/src/hooks/useNotificationConditions.ts @@ -0,0 +1,40 @@ +import { useMemo } from 'react'; + +export type ConditionResult = { + conditionId: string; + shouldShow: boolean; + isLoading: boolean; +}; + +/** + * Evaluates personalized notification conditions. + * Each condition maps to a conditionId used in notification config. + * + * Add new conditions here as needed. Each condition should return: + * - shouldShow: whether the notification should display + * - isLoading: whether data is still loading (prevents flash) + * + * @example + * ```tsx + * const conditions = useNotificationConditions(); + * const vaultCondition = conditions.get('vaultSetupIncomplete'); + * if (vaultCondition?.shouldShow) { ... } + * ``` + */ +export const useNotificationConditions = (): Map => { + const conditions = useMemo(() => { + const map = new Map(); + + // Add conditions here as needed + // Example: + // map.set('vaultSetupIncomplete', { + // conditionId: 'vaultSetupIncomplete', + // shouldShow: /* check if user has vault needing setup */, + // isLoading: /* loading state */, + // }); + + return map; + }, []); + + return conditions; +}; diff --git a/src/stores/useNotificationStore.ts b/src/stores/useNotificationStore.ts new file mode 100644 index 00000000..d83eb97a --- /dev/null +++ b/src/stores/useNotificationStore.ts @@ -0,0 +1,58 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +type NotificationState = { + /** Set of dismissed notification IDs */ + dismissedIds: string[]; +}; + +type NotificationActions = { + /** Dismiss a notification by ID */ + dismiss: (id: string) => void; + /** Check if a notification is dismissed */ + isDismissed: (id: string) => boolean; + /** Bulk update for migration */ + setAll: (state: Partial) => void; +}; + +type NotificationStore = NotificationState & NotificationActions; + +/** + * Zustand store for tracking dismissed notification IDs. + * Automatically persisted to localStorage. + * + * @example + * ```tsx + * const dismiss = useNotificationStore((s) => s.dismiss); + * const isDismissed = useNotificationStore((s) => s.isDismissed); + * + * // Dismiss a notification + * dismiss('notification-id'); + * + * // Check if dismissed + * if (isDismissed('notification-id')) { ... } + * ``` + */ +export const useNotificationStore = create()( + persist( + (set, get) => ({ + dismissedIds: [], + + dismiss: (id) => { + const { dismissedIds } = get(); + if (!dismissedIds.includes(id)) { + set({ dismissedIds: [...dismissedIds, id] }); + } + }, + + isDismissed: (id) => { + return get().dismissedIds.includes(id); + }, + + setAll: (state) => set(state), + }), + { + name: 'monarch_store_notifications', + }, + ), +);