veza/apps/web/src/components/feedback/ToastProvider.tsx
senke 5f88c56113 fix: UI remediation Phase 1 (S0-S5) + Phase 2 Sprint 6 shadow system
Phase 1:
- S0: Fix open redirect (safeNavigate), delete AuthContext/legacy auth, encrypt API keys, gitignore .env files
- S1: Split client.ts god object into 5 modules, unify toast system, delete unused Sidebar
- S2: Add glass button variant, migrate 32 z-index to SUMI tokens, fix card dark mode
- S3: Skip nav link, aria-hidden on icons, focus-visible ring fixes, alt attrs, aria-live regions
- S4: React.memo on list items, fix key={index}, loading=lazy on images
- S5: Branded loading screen, page transitions respect reduced-motion, LikeButton micro-interaction, i18n sidebar/header

Phase 2 Sprint 6:
- Wire Tailwind shadow utilities to SUMI tokens in @theme block (fixes 50+ files)
- Define shadow-card/shadow-card-hover tokens
- Remove dark:shadow-none workarounds from card.tsx (SUMI handles per-theme shadows)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 10:13:44 +01:00

127 lines
2.9 KiB
TypeScript

import {
createContext,
useContext,
useState,
useCallback,
ReactNode,
} from 'react';
import { Toast, ToastComponent } from './Toast';
import { cn } from '@/lib/utils';
interface ToastContextValue {
toasts: Toast[];
addToast: (toast: Omit<Toast, 'id'>) => void;
removeToast: (id: string) => void;
}
const ToastContext = createContext<ToastContextValue | undefined>(undefined);
// Export hooks for usage
export function useToastContext() {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToastContext must be used within ToastProvider');
}
return context;
}
/**
* @deprecated S1.2: Use `useToast` from `@/hooks/useToast` or `toast` from `@/utils/toast` instead.
* Legacy compatibility hook — delegates to react-hot-toast.
*/
export function useToast() {
const context = useToastContext();
const addToast = (messageOrToast: string | Omit<Toast, 'id'>, type?: 'success' | 'error' | 'warning' | 'info') => {
if (typeof messageOrToast === 'string') {
context.addToast({
message: messageOrToast,
type: type || 'info',
});
} else {
context.addToast(messageOrToast);
}
};
return {
...context,
addToast,
};
}
export interface ToastProviderProps {
children: ReactNode;
position?:
| 'top-right'
| 'top-left'
| 'bottom-right'
| 'bottom-left'
| 'top-center'
| 'bottom-center';
className?: string;
}
const POSITION_CLASSES = {
'top-right': 'top-4 right-4',
'top-left': 'top-4 left-4',
'bottom-right': 'bottom-4 right-4',
'bottom-left': 'bottom-4 left-4',
'top-center': 'top-4 left-1/2 -translate-x-1/2',
'bottom-center': 'bottom-4 left-1/2 -translate-x-1/2',
};
/**
* Provider pour gérer la queue des toasts.
*/
export function ToastProvider({
children,
position = 'top-right',
className,
}: ToastProviderProps) {
const [toasts, setToasts] = useState<Toast[]>([]);
const addToast = useCallback((toast: Omit<Toast, 'id'>) => {
if (import.meta.env.VITE_STORYBOOK === 'true') {
console.log('[Storybook Toast]', toast.type ?? 'info', toast.message);
return;
}
const id = `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const newToast: Toast = {
...toast,
id,
};
setToasts((prev) => [...prev, newToast]);
}, []);
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
}, []);
const value: ToastContextValue = {
toasts,
addToast,
removeToast,
};
return (
<ToastContext.Provider value={value}>
{children}
<div
className={cn(
'fixed z-50 flex flex-col gap-2',
POSITION_CLASSES[position],
className,
)}
>
{toasts.map((toast) => (
<ToastComponent
key={toast.id}
toast={toast}
onDismiss={removeToast}
/>
))}
</div>
</ToastContext.Provider>
);
}