- Create NotificationService with context provider for centralized error/success messaging - Add NotificationContainer component to render notification stack - Integrate NotificationProvider into App root - Refactor BanUnbanForm to use notification service instead of local error state - Update fetchError utility to optionally use notification callbacks - Add comprehensive error handling guidelines to Web-Development.md - Prevent duplicate notifications with deduplication logic - Support auto-dismiss with configurable TTL per notification type Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
166 lines
5.0 KiB
TypeScript
166 lines
5.0 KiB
TypeScript
/**
|
|
* Centralized notification service and provider.
|
|
*
|
|
* Provides a single channel for displaying user-facing notifications (errors, success, warnings, info).
|
|
* Prevents duplicate messaging, handles auto-dismiss, and maintains a queue of active notifications.
|
|
* Consumers use the `useNotification` hook to access the service.
|
|
*/
|
|
|
|
import { createContext, useCallback, useContext, useMemo, useState } from "react";
|
|
import type { Notification, NotificationIntent, NotificationService } from "../types/notification";
|
|
|
|
/** Default auto-dismiss durations (milliseconds) for each notification type. */
|
|
const DEFAULT_DURATIONS: Record<NotificationIntent, number> = {
|
|
success: 5000,
|
|
error: 8000,
|
|
warning: 6000,
|
|
info: 5000,
|
|
};
|
|
|
|
/** Context holding active notifications and the service API. */
|
|
const NotificationContext = createContext<NotificationService | null>(null);
|
|
|
|
/**
|
|
* NotificationProvider component.
|
|
* Must wrap the entire application to make notifications available to all children.
|
|
*/
|
|
export function NotificationProvider({ children }: { children: React.ReactNode }): React.JSX.Element {
|
|
const [notifications, setNotifications] = useState<Notification[]>([]);
|
|
|
|
/**
|
|
* Remove a notification by ID.
|
|
*/
|
|
const dismiss = useCallback((id: string): void => {
|
|
setNotifications((prev) => prev.filter((n) => n.id !== id));
|
|
}, []);
|
|
|
|
/**
|
|
* Check if a notification with this message and intent already exists to prevent duplicates.
|
|
*/
|
|
const isDuplicate = useCallback(
|
|
(message: string, intent: NotificationIntent): boolean =>
|
|
notifications.some((n) => n.message === message && n.intent === intent),
|
|
[notifications],
|
|
);
|
|
|
|
/**
|
|
* Generate a unique ID for a notification.
|
|
*/
|
|
const generateId = useCallback((): string => {
|
|
const timestamp = Date.now().toString(36);
|
|
const random = Math.random().toString(36).substring(2, 9);
|
|
return `notif-${timestamp}-${random}`;
|
|
}, []);
|
|
|
|
/**
|
|
* Add a notification to the queue.
|
|
*/
|
|
const add = useCallback(
|
|
(intent: NotificationIntent, message: string, autoCloseMsec?: number): void => {
|
|
// Prevent duplicate identical notifications
|
|
if (isDuplicate(message, intent)) {
|
|
return;
|
|
}
|
|
|
|
const id = generateId();
|
|
const duration = autoCloseMsec !== undefined ? autoCloseMsec : DEFAULT_DURATIONS[intent];
|
|
|
|
setNotifications((prev) => [...prev, { id, intent, message, autoCloseMsec: duration }]);
|
|
|
|
// Schedule auto-dismiss if duration is set
|
|
if (duration) {
|
|
setTimeout(() => {
|
|
dismiss(id);
|
|
}, duration);
|
|
}
|
|
},
|
|
[isDuplicate, generateId, dismiss],
|
|
);
|
|
|
|
/**
|
|
* Public service API.
|
|
*/
|
|
const service: NotificationService = useMemo((): NotificationService => {
|
|
return {
|
|
success: (message: string, autoCloseMsec?: number): void => {
|
|
add("success", message, autoCloseMsec);
|
|
},
|
|
error: (message: string, autoCloseMsec?: number): void => {
|
|
add("error", message, autoCloseMsec);
|
|
},
|
|
warning: (message: string, autoCloseMsec?: number): void => {
|
|
add("warning", message, autoCloseMsec);
|
|
},
|
|
info: (message: string, autoCloseMsec?: number): void => {
|
|
add("info", message, autoCloseMsec);
|
|
},
|
|
dismiss,
|
|
};
|
|
}, [add, dismiss]);
|
|
|
|
return (
|
|
<NotificationContext.Provider value={service}>
|
|
<NotificationContextInternal.Provider value={{ notifications, service }}>
|
|
{children}
|
|
</NotificationContextInternal.Provider>
|
|
</NotificationContext.Provider>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Internal context for accessing the notification queue (for rendering).
|
|
* This is separate from the public context to keep the public API clean.
|
|
*/
|
|
interface NotificationContextValue {
|
|
notifications: Notification[];
|
|
service: NotificationService;
|
|
}
|
|
|
|
const NotificationContextInternal = createContext<NotificationContextValue | null>(null);
|
|
|
|
/**
|
|
* Hook to access the notification service.
|
|
* Use this in any component to show success, error, warning, or info messages.
|
|
*
|
|
* @returns NotificationService API for showing messages.
|
|
* @throws Error if used outside NotificationProvider.
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* function MyComponent() {
|
|
* const notification = useNotification();
|
|
*
|
|
* const handleSave = async () => {
|
|
* try {
|
|
* await saveData();
|
|
* notification.success("Data saved successfully");
|
|
* } catch (err) {
|
|
* notification.error("Failed to save data");
|
|
* }
|
|
* };
|
|
*
|
|
* return <button onClick={handleSave}>Save</button>;
|
|
* }
|
|
* ```
|
|
*/
|
|
export function useNotification(): NotificationService {
|
|
const context = useContext(NotificationContext);
|
|
if (!context) {
|
|
throw new Error("useNotification must be used within NotificationProvider");
|
|
}
|
|
return context;
|
|
}
|
|
|
|
/**
|
|
* Internal hook to access the notifications queue for rendering.
|
|
* Only used by NotificationContainer.
|
|
*/
|
|
export function useNotificationQueue(): Notification[] {
|
|
const context = useContext(NotificationContextInternal);
|
|
if (!context) {
|
|
throw new Error("NotificationContextInternal must be used within NotificationProvider");
|
|
}
|
|
return context.notifications;
|
|
}
|
|
|