feat: centralized error notification service (issue #15)

- 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>
This commit is contained in:
2026-04-28 08:41:33 +02:00
parent da6433b2cf
commit ae34d98859
8 changed files with 557 additions and 152 deletions

View File

@@ -0,0 +1,165 @@
/**
* 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;
}