Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/components/layout/header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useState, useEffect } from 'react';
import Menu from './Menu';
import { NotificationBanner, useNotificationBannerVisible } from '../notification-banner';

export type HeaderProps = {
ghost?: boolean;
Expand All @@ -11,6 +12,7 @@ type ScrollState = 'at-top' | 'scrolling-up' | 'scrolling-down';

function Header({ ghost }: HeaderProps) {
const [scrollState, setScrollState] = useState<ScrollState>('at-top');
const showBanner = useNotificationBannerVisible();

useEffect(() => {
let previousScrollY = window.scrollY;
Expand All @@ -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 && <div className="h-[48px] w-full md:h-[56px]" />}
{!ghost && <div className={`w-full ${spacerClass}`} />}
<header
data-scroll-state={scrollState}
className="fixed left-0 right-0 top-0 w-full bg-surface"
Expand All @@ -45,6 +50,8 @@ function Header({ ghost }: HeaderProps) {
<div className="w-full border-b border-dashed border-[var(--grid-cell-muted)]">
<Menu />
</div>
{/* Notification banner below navbar */}
<NotificationBanner />
</header>
</>
);
Expand Down
92 changes: 92 additions & 0 deletions src/components/layout/notification-banner.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="relative w-full bg-primary">
{/* Grid background overlay */}
<div
className="pointer-events-none absolute inset-0 bg-dot-grid opacity-30"
aria-hidden="true"
/>

{/* Content container - same height as header */}
<div className="relative flex h-[48px] items-center md:h-[56px]">
<div className="container mx-auto flex items-center justify-center gap-4 px-4 sm:px-6 md:px-8">
{/* Badge for multiple notifications */}
{totalCount > 1 && (
<span className="font-zen text-xs text-primary-foreground/80">1/{totalCount}</span>
)}

{/* Custom icon if provided */}
{currentNotification.icon && (
<span className="text-primary-foreground">{currentNotification.icon}</span>
)}

{/* Message */}
<p className="font-zen text-sm text-primary-foreground">{currentNotification.message}</p>

{/* Action button */}
{action &&
(action.href ? (
<Link
href={action.href}
onClick={handleDismiss}
className="font-zen text-xs text-primary-foreground underline-offset-2 transition-colors hover:underline"
>
{action.label}
</Link>
) : (
<button
type="button"
onClick={() => {
action.onClick?.();
handleDismiss();
}}
className="font-zen text-xs text-primary-foreground underline-offset-2 transition-colors hover:underline"
>
{action.label}
</button>
))}

{/* Close button */}
<button
type="button"
onClick={handleDismiss}
className="absolute right-4 p-1 text-primary-foreground/80 transition-colors hover:text-primary-foreground sm:right-6 md:right-8"
aria-label="Dismiss notification"
>
<RiCloseLine className="h-5 w-5" />
</button>
</div>
</div>
</div>
);
}

/**
* 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;
};
50 changes: 50 additions & 0 deletions src/config/notifications.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Comment on lines +5 to +9
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Make NotificationAction type-safe.

The type allows actions with neither href nor onClick (button does nothing) or both (ambiguous behavior). Use a discriminated union to enforce exactly one action type.

🔎 Proposed type-safe structure
-export type NotificationAction = {
-  label: string;
-  href?: string;
-  onClick?: () => void;
-};
+export type NotificationAction =
+  | { label: string; href: string }
+  | { label: string; onClick: () => void };
🤖 Prompt for AI Agents
In src/config/notifications.ts around lines 5 to 9, the NotificationAction type
currently allows actions with neither or both href and onClick; replace it with
a discriminated union that enforces exactly one action shape (e.g., { type:
'link'; label: string; href: string } | { type: 'button'; label: string;
onClick: () => void }) and update any consumers to switch on the discriminant
(or check the type field) when rendering/handling actions; ensure label remains
required and adjust imports/usage sites to handle the new union so the compiler
enforces a single valid action per NotificationAction.


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'),
// },
];
96 changes: 96 additions & 0 deletions src/hooks/useActiveNotifications.ts
Original file line number Diff line number Diff line change
@@ -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,
};
};
40 changes: 40 additions & 0 deletions src/hooks/useNotificationConditions.ts
Original file line number Diff line number Diff line change
@@ -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<string, ConditionResult> => {
const conditions = useMemo(() => {
const map = new Map<string, ConditionResult>();

// 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;
};
58 changes: 58 additions & 0 deletions src/stores/useNotificationStore.ts
Original file line number Diff line number Diff line change
@@ -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<NotificationState>) => 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<NotificationStore>()(
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',
},
),
);