mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 06:42:12 +03:00
feat(advanced): FASE-07 to FASE-09 advanced features
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)
This commit is contained in:
175
src/shared/components/NotificationToast.js
Normal file
175
src/shared/components/NotificationToast.js
Normal file
@@ -0,0 +1,175 @@
|
||||
"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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user