hasari-api / apps /web /components /ToastProvider.tsx
erdoganpeker's picture
v0.3.0 — multimodal vehicle damage MVP
e327f0d
'use client';
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import { useTranslations } from 'next-intl';
import { CheckCircle2, AlertTriangle, Info, X } from 'lucide-react';
type ToastKind = 'success' | 'error' | 'info';
interface Toast {
id: number;
kind: ToastKind;
message: string;
}
interface ToastContextValue {
toast: (message: string, kind?: ToastKind) => void;
success: (message: string) => void;
error: (message: string) => void;
info: (message: string) => void;
}
const ToastContext = createContext<ToastContextValue | undefined>(undefined);
const KIND_CLASSES: Record<ToastKind, string> = {
success: 'bg-emerald-50 text-emerald-900 ring-emerald-200',
error: 'bg-red-50 text-red-900 ring-red-200',
info: 'bg-slate-50 text-slate-900 ring-slate-200',
};
const KIND_ICON = {
success: CheckCircle2,
error: AlertTriangle,
info: Info,
} as const;
export function ToastProvider({ children }: { children: React.ReactNode }) {
const tCommon = useTranslations('common');
const [toasts, setToasts] = useState<Toast[]>([]);
const dismiss = useCallback((id: number) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const toast = useCallback(
(message: string, kind: ToastKind = 'info') => {
const id = Date.now() + Math.random();
setToasts((prev) => [...prev, { id, kind, message }]);
setTimeout(() => dismiss(id), 4500);
},
[dismiss],
);
const value = useMemo<ToastContextValue>(
() => ({
toast,
success: (m) => toast(m, 'success'),
error: (m) => toast(m, 'error'),
info: (m) => toast(m, 'info'),
}),
[toast],
);
return (
<ToastContext.Provider value={value}>
{children}
<div
aria-live="polite"
aria-atomic="true"
className="pointer-events-none fixed bottom-4 right-4 z-50 flex w-full max-w-sm flex-col gap-2"
>
{toasts.map((t) => {
const Icon = KIND_ICON[t.kind];
return (
<div
key={t.id}
role={t.kind === 'error' ? 'alert' : 'status'}
className={`pointer-events-auto flex items-start gap-2 rounded-xl p-3 text-sm shadow-lg ring-1 ${KIND_CLASSES[t.kind]}`}
>
<Icon className="mt-0.5 h-4 w-4 flex-none" aria-hidden />
<span className="flex-1">{t.message}</span>
<button
type="button"
onClick={() => dismiss(t.id)}
className="flex h-5 w-5 items-center justify-center rounded hover:bg-black/5"
aria-label={tCommon('close')}
>
<X className="h-3.5 w-3.5" aria-hidden />
</button>
</div>
);
})}
</div>
</ToastContext.Provider>
);
}
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) {
// Graceful fallback in case a consumer renders outside the provider
return {
toast: () => undefined,
success: () => undefined,
error: () => undefined,
info: () => undefined,
};
}
return ctx;
}
// Re-export for callers that want a hook-less ergonomic check.
export function useToastMount() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
return mounted;
}