import React, { useState, useEffect, useRef, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { Info, AlertTriangle, AlertOctagon, X } from 'lucide-react'; import { useSystemNoticeStore } from '../../store/systemNoticeStore.js'; import type { SystemNoticeDTO } from '../../store/systemNoticeStore.js'; import { useTranslation } from '../../i18n/index.js'; import { isRtlLanguage } from '../../i18n/index.js'; import { runNoticeAction } from './noticeActions.js'; const SEVERITY_ICONS: Record = { info: Info, warn: AlertTriangle, critical: AlertOctagon, }; const SEVERITY = { info: { bg: 'bg-white dark:bg-slate-900', border: 'border-blue-500 dark:border-blue-400', text: 'text-slate-900 dark:text-slate-100', icon: 'text-blue-500 dark:text-blue-400', ariaLive: 'polite' as const, role: 'status' as const, }, warn: { bg: 'bg-amber-50 dark:bg-amber-950', border: 'border-amber-500 dark:border-amber-400', text: 'text-amber-900 dark:text-amber-100', icon: 'text-amber-500 dark:text-amber-400', ariaLive: 'polite' as const, role: 'status' as const, }, critical: { bg: 'bg-rose-50 dark:bg-rose-950', border: 'border-rose-600 dark:border-rose-400', text: 'text-rose-900 dark:text-rose-100', icon: 'text-rose-600 dark:text-rose-400', ariaLive: 'assertive' as const, role: 'alert' as const, }, } as const; interface BannerItemProps { notice: SystemNoticeDTO; onDismiss: () => void; language: string; } function CTALink({ notice, label, onDismiss, }: { notice: SystemNoticeDTO; label: string; onDismiss: () => void; }) { const navigate = useNavigate(); function handleClick() { if (!notice.cta) return; if (notice.cta.kind === 'nav') { navigate(notice.cta.href); if (notice.dismissible) onDismiss(); } else { runNoticeAction(notice.cta.actionId, { navigate }); const actionCta = notice.cta as { kind: 'action'; labelKey: string; actionId: string; dismissOnAction?: boolean }; if (actionCta.dismissOnAction !== false) onDismiss(); } } if (!notice.cta) return null; if (notice.cta.kind === 'nav') { return ( { e.preventDefault(); handleClick(); }} className="underline hover:no-underline font-medium ml-3 shrink-0" > {label} ); } return ( ); } function BannerItem({ notice, onDismiss, language }: BannerItemProps) { const { t } = useTranslation(); const s = SEVERITY[notice.severity] ?? SEVERITY.info; const title = t(notice.titleKey); const body = t(notice.bodyKey); const ctaLabel = notice.cta ? t(notice.cta.labelKey) : null; // Tailwind 3.3+ supports border-s-4 (logical, RTL-aware) const accentBorder = 'border-s-4'; return (
{React.createElement( (SEVERITY_ICONS[notice.severity] ?? Info) as React.ElementType, { size: 20, className: `shrink-0 mt-0.5 ${s.icon}` }, )}
{title} {body !== title && ( {body} )} {ctaLabel && notice.cta && ( )}
{notice.dismissible && ( )}
); } interface AnimatedBannerItemProps { notice: SystemNoticeDTO; onDismiss: () => void; language: string; } function AnimatedBannerItem({ notice, onDismiss, language }: AnimatedBannerItemProps) { const [mounted, setMounted] = useState(false); const prefersReducedMotion = typeof window !== 'undefined' && (window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false); useEffect(() => { if (typeof requestAnimationFrame !== 'undefined') { const id = requestAnimationFrame(() => setMounted(true)); return () => cancelAnimationFrame(id); } setMounted(true); }, []); const transition = prefersReducedMotion ? 'transition-opacity duration-[120ms]' : 'transition-all duration-200 ease-out'; const state = mounted ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-2'; return (
); } interface BannerRendererProps { notices: SystemNoticeDTO[]; } export function BannerRenderer({ notices }: BannerRendererProps) { const { dismiss } = useSystemNoticeStore(); const { language } = useTranslation(); const containerRef = useRef(null); // Show at most 2 highest-priority banners const visible = notices.slice(0, 2); // Report banner stack height for layout reflow useEffect(() => { const el = containerRef.current; if (!el) return; const observer = new ResizeObserver(() => { document.documentElement.style.setProperty('--banner-stack-h', el.offsetHeight + 'px'); }); observer.observe(el); return () => { observer.disconnect(); document.documentElement.style.setProperty('--banner-stack-h', '0px'); }; }, []); if (visible.length === 0) return null; return (
{visible.map((notice, i) => ( {i > 0 &&
} dismiss(notice.id)} language={language} /> ))}
); } interface ToastRendererProps { notices: SystemNoticeDTO[]; } export function ToastRenderer({ notices }: ToastRendererProps) { const { dismiss } = useSystemNoticeStore(); const { t } = useTranslation(); const firedRef = useRef(new Set()); useEffect(() => { for (const notice of notices) { if (firedRef.current.has(notice.id)) continue; firedRef.current.add(notice.id); // Critical should not be a toast — log and skip if (notice.severity === 'critical') { console.warn( `[systemNotices] notice "${notice.id}" is critical but display=toast. ` + 'Should be banner or modal.' ); dismiss(notice.id); continue; } const variantMap: Record = { info: 'info', warn: 'warning' }; const variant = variantMap[notice.severity] ?? 'info'; const titleStr = t(notice.titleKey); const bodyStr = t(notice.bodyKey); const message = bodyStr !== titleStr ? `${titleStr}: ${bodyStr}` : titleStr; const duration = notice.severity === 'warn' ? 9000 : 6000; // Fire the toast, retrying on the next frame if __addToast isn't registered yet // (race between ToastContainer mounting and SystemNoticeHost mounting on cold load). const fireToast = (attempt = 0) => { if (typeof window.__addToast === 'function') { window.__addToast(message, variant as 'info' | 'success' | 'error' | 'warning', duration); } else if (attempt < 10) { requestAnimationFrame(() => fireToast(attempt + 1)); return; // don't schedule dismiss until the toast actually fires } else { console.warn(`[systemNotices] toast "${notice.id}" dropped — __addToast never registered`); } setTimeout(() => dismiss(notice.id), duration + 500); }; fireToast(); } }, [notices]); // eslint-disable-line react-hooks/exhaustive-deps return null; }