mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 13:22:11 +03:00
FASE-07 — UX & Microinteractions: - Create notificationStore.js (Zustand global toast store) - Create NotificationToast.js (glassmorphism toast UI with ARIA) FASE-08 — LLM Proxy Advanced: - Create policyEngine.js (declarative routing/budget/access policies) - Create cacheLayer.js (LRU cache with content hashing and TTL) FASE-09 — E2E Flow Hardening: - Create streamState.js (SSE stream state machine with TTFB tracking) Tests: 23/23 advanced tests pass (75/75 total across all suites)
176 lines
4.3 KiB
JavaScript
176 lines
4.3 KiB
JavaScript
"use client";
|
||
|
||
/**
|
||
* NotificationToast — FASE-07 UX & Microinteractions
|
||
*
|
||
* Global toast notification component. Renders notifications from the
|
||
* notificationStore as stacked toasts in the top-right corner.
|
||
*
|
||
* Usage: Add <NotificationToast /> to your root layout.
|
||
*/
|
||
|
||
import { useNotificationStore } from "@/store/notificationStore";
|
||
import { useEffect, useState } from "react";
|
||
|
||
const ICONS = {
|
||
success: "✓",
|
||
error: "✕",
|
||
warning: "⚠",
|
||
info: "ℹ",
|
||
};
|
||
|
||
const COLORS = {
|
||
success: {
|
||
bg: "rgba(16, 185, 129, 0.15)",
|
||
border: "rgba(16, 185, 129, 0.4)",
|
||
icon: "#10b981",
|
||
},
|
||
error: {
|
||
bg: "rgba(239, 68, 68, 0.15)",
|
||
border: "rgba(239, 68, 68, 0.4)",
|
||
icon: "#ef4444",
|
||
},
|
||
warning: {
|
||
bg: "rgba(245, 158, 11, 0.15)",
|
||
border: "rgba(245, 158, 11, 0.4)",
|
||
icon: "#f59e0b",
|
||
},
|
||
info: {
|
||
bg: "rgba(59, 130, 246, 0.15)",
|
||
border: "rgba(59, 130, 246, 0.4)",
|
||
icon: "#3b82f6",
|
||
},
|
||
};
|
||
|
||
function Toast({ notification, onDismiss }) {
|
||
const [isExiting, setIsExiting] = useState(false);
|
||
|
||
const handleDismiss = () => {
|
||
setIsExiting(true);
|
||
setTimeout(() => onDismiss(notification.id), 200);
|
||
};
|
||
|
||
const color = COLORS[notification.type] || COLORS.info;
|
||
|
||
return (
|
||
<div
|
||
role="alert"
|
||
aria-live="polite"
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "flex-start",
|
||
gap: "12px",
|
||
padding: "14px 16px",
|
||
borderRadius: "10px",
|
||
backgroundColor: color.bg,
|
||
border: `1px solid ${color.border}`,
|
||
backdropFilter: "blur(12px)",
|
||
boxShadow: "0 8px 32px rgba(0,0,0,0.2)",
|
||
minWidth: "320px",
|
||
maxWidth: "420px",
|
||
animation: isExiting
|
||
? "toastOut 0.2s ease-in forwards"
|
||
: "toastIn 0.3s ease-out forwards",
|
||
transition: "all 0.2s ease",
|
||
}}
|
||
>
|
||
<span
|
||
style={{
|
||
fontSize: "18px",
|
||
color: color.icon,
|
||
fontWeight: "bold",
|
||
lineHeight: 1,
|
||
marginTop: "2px",
|
||
}}
|
||
>
|
||
{ICONS[notification.type]}
|
||
</span>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
{notification.title && (
|
||
<div
|
||
style={{
|
||
fontWeight: 600,
|
||
fontSize: "14px",
|
||
color: "var(--text-primary, #fff)",
|
||
marginBottom: "2px",
|
||
}}
|
||
>
|
||
{notification.title}
|
||
</div>
|
||
)}
|
||
<div
|
||
style={{
|
||
fontSize: "13px",
|
||
color: "var(--text-secondary, #ccc)",
|
||
lineHeight: 1.4,
|
||
}}
|
||
>
|
||
{notification.message}
|
||
</div>
|
||
</div>
|
||
{notification.dismissible && (
|
||
<button
|
||
onClick={handleDismiss}
|
||
aria-label="Dismiss notification"
|
||
style={{
|
||
background: "none",
|
||
border: "none",
|
||
cursor: "pointer",
|
||
color: "var(--text-secondary, #999)",
|
||
fontSize: "16px",
|
||
padding: "0 2px",
|
||
lineHeight: 1,
|
||
opacity: 0.6,
|
||
transition: "opacity 0.15s",
|
||
}}
|
||
onMouseEnter={(e) => (e.target.style.opacity = 1)}
|
||
onMouseLeave={(e) => (e.target.style.opacity = 0.6)}
|
||
>
|
||
×
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function NotificationToast() {
|
||
const { notifications, removeNotification } = useNotificationStore();
|
||
|
||
if (notifications.length === 0) return null;
|
||
|
||
return (
|
||
<>
|
||
<style>{`
|
||
@keyframes toastIn {
|
||
from { opacity: 0; transform: translateX(100%) scale(0.95); }
|
||
to { opacity: 1; transform: translateX(0) scale(1); }
|
||
}
|
||
@keyframes toastOut {
|
||
from { opacity: 1; transform: translateX(0) scale(1); }
|
||
to { opacity: 0; transform: translateX(100%) scale(0.95); }
|
||
}
|
||
`}</style>
|
||
<div
|
||
aria-live="polite"
|
||
aria-atomic="false"
|
||
style={{
|
||
position: "fixed",
|
||
top: "20px",
|
||
right: "20px",
|
||
zIndex: 9999,
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: "10px",
|
||
pointerEvents: "none",
|
||
}}
|
||
>
|
||
{notifications.map((n) => (
|
||
<div key={n.id} style={{ pointerEvents: "auto" }}>
|
||
<Toast notification={n} onDismiss={removeNotification} />
|
||
</div>
|
||
))}
|
||
</div>
|
||
</>
|
||
);
|
||
}
|