diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c5693978..47fa7986 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,13 +1,11 @@ -import React from 'react' -import { BrowserRouter as Router, Routes, Route, useLocation } from 'react-router-dom' -import { AnimatePresence, motion, useReducedMotion } from 'framer-motion' +import React, { Suspense, lazy } from 'react' +import { BrowserRouter as Router, Routes, Route } from 'react-router-dom' import { I18nextProvider } from 'react-i18next' import i18n from './i18n' import { WalletProvider } from './context/WalletContext' import { ToastProvider } from './context/ToastContext' import { NotificationProvider } from './context/NotificationContext' import { ThemeProvider } from './context/ThemeContext' -import { RouteProgressProvider } from './context/RouteProgressContext' import { NotFoundPage } from './pages/NotFoundPage' import AppShell from './components/layout/AppShell' import LandingPage from './pages/LandingPage' @@ -15,6 +13,7 @@ import ErrorBoundary from './components/common/ErrorBoundary' import { ProtectedRoute } from './components/auth/ProtectedRoute' import { CommandPalette } from './components/common/CommandPalette' import { useCommandPalette } from './hooks/useCommandPalette' +import { SkeletonCard } from './components/common/Skeleton' import './components/common/Toast.css' // Lazy-loaded pages @@ -47,7 +46,7 @@ const RouteLoadingFallback: React.FC = () => ( * `` rather than beside it. */ const AppContent: React.FC = () => { - const { isOpen, closePalette, search, recentSearches } = useCommandPalette() + const { isOpen, closePalette, search, recentSearches, runRecentSearch } = useCommandPalette() return ( <> @@ -57,60 +56,62 @@ const AppContent: React.FC = () => { path="/*" element={ - - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - {import.meta.env.DEV && ( - } /> - )} - } /> - + }> + + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + {import.meta.env.DEV && ( + } /> + )} + } /> + + } /> diff --git a/frontend/src/components/agents/TaskSubmissionForm.tsx b/frontend/src/components/agents/TaskSubmissionForm.tsx index 56cf7df7..5c6259fb 100644 --- a/frontend/src/components/agents/TaskSubmissionForm.tsx +++ b/frontend/src/components/agents/TaskSubmissionForm.tsx @@ -8,7 +8,6 @@ import { AlertCircle } from 'lucide-react'; import type { TFunction } from 'i18next'; import { DAGPreview } from './DAGPreview'; import { useTaskSubmit } from '../../hooks/useTaskSubmit'; -import { useToast } from '../../hooks/useToast'; import { useToast } from '../../context/ToastContext'; import { useTaskDraft } from '../../hooks/useTaskDraft'; import { buildLiveDag } from '../../utils/buildLiveDag'; diff --git a/frontend/src/components/landing/Hero.tsx b/frontend/src/components/landing/Hero.tsx index d82b53ef..b3520158 100644 --- a/frontend/src/components/landing/Hero.tsx +++ b/frontend/src/components/landing/Hero.tsx @@ -24,8 +24,11 @@ const Hero: React.FC = () => { const { canvasRef, prefersReducedMotion } = useParticles() return ( -
{!prefersReducedMotion ? ( @@ -56,7 +59,7 @@ const Hero: React.FC = () => { >
{t('landing.hero.badge')} -
+ {/* Headline */} { , ]} /> - + {/* Subtext */} { className="text-base sm:text-lg text-text-secondary max-w-[540px] mx-auto mb-10 leading-[1.6]" > {t('landing.hero.subtitle')} -

+
{/* CTAs */} = ({ isDrawerOpen = false, }) => { const { publicKey, connected, ready, connectionMethod, disconnect } = useWallet() - const { unreadCount } = useNotifications() - const [isNotificationOpen, setIsNotificationOpen] = useState(false) - const bellButtonRef = useRef(null) const { t, i18n } = useTranslation() const location = useLocation() const { mode, setMode } = useTheme() @@ -108,31 +103,7 @@ const TopNav: React.FC = ({
-
- - - setIsNotificationOpen(false)} - anchorRef={bellButtonRef} - /> -
+ {/* Theme toggle: cycles light -> dark -> system */} + + setIsOpen(false)} + anchorRef={bellButtonRef} + /> +
+ ); +}; + +export default NotificationBell; diff --git a/frontend/src/components/notifications/NotificationCenter.css b/frontend/src/components/notifications/NotificationCenter.css index 049bb75b..314564fe 100644 --- a/frontend/src/components/notifications/NotificationCenter.css +++ b/frontend/src/components/notifications/NotificationCenter.css @@ -53,6 +53,12 @@ letter-spacing: 0.04em; } +.notification-panel-actions { + display: flex; + align-items: center; + gap: 8px; +} + .mark-all-read-btn { display: inline-flex; align-items: center; @@ -84,6 +90,24 @@ outline-offset: 2px; } +.clear-all-btn { + display: inline-flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + color: var(--text-secondary, #8a93a3); + cursor: pointer; + padding: 6px; + border-radius: 6px; + transition: all 0.2s ease; +} + +.clear-all-btn:hover { + color: var(--error-red, #ef4444); + background: rgba(239, 68, 68, 0.1); +} + /* Body / List */ .notification-panel-body { max-height: 420px; @@ -181,22 +205,31 @@ transition: all 0.2s ease; } -.notification-icon-wrapper.task { - background: var(--status-success-surface-muted); - border-color: var(--status-success-border); - color: var(--status-success); +.notification-icon-wrapper.task, +.notification-icon-wrapper.task_completed { + background: var(--status-success-surface-muted, rgba(52, 211, 153, 0.12)); + border-color: var(--status-success-border, rgba(52, 211, 153, 0.3)); + color: var(--status-success, #34d399); +} + +.notification-icon-wrapper.task_failed { + background: var(--status-error-surface-muted, rgba(239, 68, 68, 0.12)); + border-color: var(--status-error-border, rgba(239, 68, 68, 0.3)); + color: var(--status-error, #ef4444); } -.notification-icon-wrapper.payment { +.notification-icon-wrapper.payment, +.notification-icon-wrapper.payment_received { background: rgba(251, 191, 36, 0.12); border-color: rgba(251, 191, 36, 0.3); color: #fcd34d; } -.notification-icon-wrapper.agent { - background: var(--accent-surface-muted); - border-color: var(--accent-border); - color: var(--accent-text-strong); +.notification-icon-wrapper.agent, +.notification-icon-wrapper.agent_registered { + background: var(--accent-surface-muted, rgba(139, 92, 246, 0.12)); + border-color: var(--accent-border, rgba(139, 92, 246, 0.3)); + color: var(--accent-text-strong, #a78bfa); } .notification-icon-wrapper.system { @@ -205,6 +238,26 @@ color: var(--accent-info); } +.notification-icon-task { + color: #34d399; +} + +.notification-icon-error { + color: #ef4444; +} + +.notification-icon-payment { + color: #fbbf24; +} + +.notification-icon-agent { + color: #a78bfa; +} + +.notification-icon-system { + color: #38bdf8; +} + .notification-content { flex: 1; min-width: 0; diff --git a/frontend/src/components/notifications/NotificationCenter.test.tsx b/frontend/src/components/notifications/NotificationCenter.test.tsx index 33ba0ec5..ccea0ad6 100644 --- a/frontend/src/components/notifications/NotificationCenter.test.tsx +++ b/frontend/src/components/notifications/NotificationCenter.test.tsx @@ -36,8 +36,9 @@ vi.mock('react-router-dom', async () => { const sampleNotifications: AppNotification[] = [ { id: 'notif-1', - type: 'task', + type: 'task_completed', title: 'Task Completed', + message: 'Task #101 execution has completed.', description: 'Task #101 execution has completed.', timestamp: new Date(Date.now() - 60000).toISOString(), // 1 min ago read: false, @@ -45,8 +46,9 @@ const sampleNotifications: AppNotification[] = [ }, { id: 'notif-2', - type: 'payment', + type: 'payment_received', title: 'Payment Released', + message: 'Payment of 5 XLM released.', description: 'Payment of 5 XLM released.', timestamp: new Date(Date.now() - 3600000).toISOString(), // 1 hour ago read: false, @@ -54,8 +56,9 @@ const sampleNotifications: AppNotification[] = [ }, { id: 'notif-3', - type: 'agent', + type: 'agent_registered', title: 'Agent Status Changed', + message: 'Research agent is now online.', description: 'Research agent is now online.', timestamp: new Date(Date.now() - 86400000).toISOString(), // 1 day ago read: true, @@ -209,6 +212,28 @@ describe('NotificationCenter Component', () => { expect(stored.every((n: AppNotification) => n.read)).toBe(true); }); + test('"Clear all" button clears all notifications from list and localStorage', async () => { + renderTopNavWithProvider(sampleNotifications); + + const bellBtn = screen.getByTestId('notification-bell-btn'); + await act(async () => { + fireEvent.click(bellBtn); + }); + + const clearAllBtn = screen.getByTestId('clear-all-btn'); + expect(clearAllBtn).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(clearAllBtn); + }); + + expect(screen.getByTestId('notification-empty-state')).toBeInTheDocument(); + expect(screen.queryByTestId('notification-badge')).not.toBeInTheDocument(); + + const stored = JSON.parse(localStorage.getItem(NOTIFICATIONS_STORAGE_KEY) || '[]'); + expect(stored).toEqual([]); + }); + test('closes panel when pressing Escape key', async () => { renderTopNavWithProvider(sampleNotifications); @@ -258,10 +283,13 @@ describe('NotificationCenter Component', () => { expect(found2.read).toBe(true); }); - test('real-time events add new notifications to the top of the list', async () => { + test('real-time events add new notifications to the top of the list and trigger toast events', async () => { + const toastSpy = vi.fn(); + window.addEventListener('app-toast', toastSpy); + renderTopNavWithProvider([]); - // Trigger a simulated real-time event via window custom event + // Trigger a simulated real-time task_completed event via window custom event await act(async () => { window.dispatchEvent( new CustomEvent('ai-net-notification', { @@ -279,6 +307,9 @@ describe('NotificationCenter Component', () => { const badge = screen.getByTestId('notification-badge'); expect(badge).toHaveTextContent('1'); + // Toast event should have been dispatched + expect(toastSpy).toHaveBeenCalled(); + // Open panel const bellBtn = screen.getByTestId('notification-bell-btn'); await act(async () => { @@ -287,5 +318,31 @@ describe('NotificationCenter Component', () => { expect(screen.getByText('Task Completed')).toBeInTheDocument(); expect(screen.getByText('Task task-live-999 completed successfully.')).toBeInTheDocument(); + + window.removeEventListener('app-toast', toastSpy); + }); + + test('real-time payment_received events trigger toast notifications', async () => { + const toastSpy = vi.fn(); + window.addEventListener('app-toast', toastSpy); + + renderTopNavWithProvider([]); + + await act(async () => { + window.dispatchEvent( + new CustomEvent('ai-net-notification', { + detail: { + type: 'payment_received', + taskId: 'task-pay-123', + payload: { amount: '10.5', txHash: 'abc12345678' }, + timestamp: new Date().toISOString(), + }, + }) + ); + }); + + expect(toastSpy).toHaveBeenCalled(); + + window.removeEventListener('app-toast', toastSpy); }); }); diff --git a/frontend/src/components/notifications/NotificationCenter.tsx b/frontend/src/components/notifications/NotificationCenter.tsx index c295c105..b7ebef52 100644 --- a/frontend/src/components/notifications/NotificationCenter.tsx +++ b/frontend/src/components/notifications/NotificationCenter.tsx @@ -1,223 +1,10 @@ -import React, { useEffect, useRef, useState, useMemo } from 'react'; -import { motion, AnimatePresence } from 'framer-motion'; -import { CheckCheck, Inbox, Filter } from 'lucide-react'; -import { useNotifications } from '../../hooks/useNotifications'; -import NotificationItem from './NotificationItem'; -import { EmptyState } from '../common/EmptyState'; -import './NotificationCenter.css'; +import React from 'react'; +import { NotificationPanel, type NotificationPanelProps } from './NotificationPanel'; -interface NotificationCenterProps { - isOpen: boolean; - onClose: () => void; - anchorRef?: React.RefObject; -} +export type NotificationCenterProps = NotificationPanelProps; -type NotificationFilter = 'all' | 'task' | 'payment' | 'agent' | 'system'; - -export const NotificationCenter: React.FC = ({ - isOpen, - onClose, - anchorRef, -}) => { - const { notifications, unreadCount, markAsRead, markAllAsRead } = useNotifications(); - const panelRef = useRef(null); - const [filter, setFilter] = useState('all'); - const [expandedGroups, setExpandedGroups] = useState>(new Set(['unread'])); - - const filteredNotifications = useMemo(() => { - return filter === 'all' - ? notifications - : notifications.filter(n => n.type === filter); - }, [notifications, filter]); - - const groupedNotifications = useMemo(() => { - const groups: Record = { - unread: [], - read: [], - }; - - filteredNotifications.forEach(notif => { - if (notif.read) { - groups.read.push(notif); - } else { - groups.unread.push(notif); - } - }); - - return groups; - }, [filteredNotifications]); - - const toggleGroup = (groupKey: string) => { - setExpandedGroups(prev => { - const next = new Set(prev); - if (next.has(groupKey)) { - next.delete(groupKey); - } else { - next.add(groupKey); - } - return next; - }); - }; - - const markGroupAsRead = (groupKey: string) => { - const group = groupedNotifications[groupKey as keyof typeof groupedNotifications]; - group?.forEach(notif => { - if (!notif.read) { - markAsRead(notif.id); - } - }); - }; - - useEffect(() => { - if (!isOpen) return; - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - onClose(); - } - }; - - const handleClickOutside = (e: MouseEvent) => { - const target = e.target as Node; - if (panelRef.current && !panelRef.current.contains(target)) { - // If anchorRef (e.g. bell button) is passed and was clicked, ignore outside click - if (anchorRef?.current && anchorRef.current.contains(target)) { - return; - } - onClose(); - } - }; - - document.addEventListener('keydown', handleKeyDown); - document.addEventListener('mousedown', handleClickOutside); - - return () => { - document.removeEventListener('keydown', handleKeyDown); - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [isOpen, onClose, anchorRef]); - - return ( - - {isOpen && ( - - {/* Header */} -
-
- Inbox - {unreadCount > 0 && ( - - {unreadCount} - - )} -
- - {unreadCount > 0 && ( - - )} -
- - {/* Filters */} -
- {(['all', 'task', 'payment', 'agent', 'system'] as NotificationFilter[]).map(f => ( - - ))} -
- - {/* Body */} -
- {filteredNotifications.length === 0 ? ( -
-
- -
-

No notifications

-

Stay tuned for updates on your tasks and payments.

-
- ) : ( -
- {Object.entries(groupedNotifications).map(([groupKey, group]) => ( - group.length > 0 && ( -
- - )} - - - {expandedGroups.has(groupKey) && ( - - {group.map(notification => ( - - ))} - - )} - -
- ) - ))} -
- )} -
-
- )} -
- ); +export const NotificationCenter: React.FC = (props) => { + return ; }; export default NotificationCenter; diff --git a/frontend/src/components/notifications/NotificationItem.tsx b/frontend/src/components/notifications/NotificationItem.tsx index 9b9af8ca..cfc442ef 100644 --- a/frontend/src/components/notifications/NotificationItem.tsx +++ b/frontend/src/components/notifications/NotificationItem.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { useNavigate } from 'react-router-dom'; import { motion } from 'framer-motion'; -import { CheckCircle2, Coins, Bot, Bell, ChevronRight } from 'lucide-react'; +import { CheckCircle2, AlertCircle, Coins, Bot, Bell, ChevronRight } from 'lucide-react'; import type { AppNotification, NotificationType } from '../../types/notification'; import { formatRelativeTime } from '../../utils/time'; @@ -13,10 +13,15 @@ interface NotificationItemProps { const getNotificationIcon = (type: NotificationType) => { switch (type) { + case 'task_completed': case 'task': return ; + case 'task_failed': + return ; + case 'payment_received': case 'payment': return ; + case 'agent_registered': case 'agent': return ; case 'system': @@ -49,6 +54,8 @@ export const NotificationItem: React.FC = ({ } }; + const messageText = notification.message || notification.description || ''; + return ( = ({ onKeyDown={handleKeyDown} tabIndex={0} role="button" - aria-label={`${notification.title}: ${notification.description}`} + aria-label={`${notification.title}: ${messageText}`} data-testid={`notification-item-${notification.id}`} >
@@ -76,7 +83,7 @@ export const NotificationItem: React.FC = ({
-

{notification.description}

+ {messageText &&

{messageText}

} {notification.link && (
diff --git a/frontend/src/components/notifications/NotificationPanel.tsx b/frontend/src/components/notifications/NotificationPanel.tsx new file mode 100644 index 00000000..20f64fef --- /dev/null +++ b/frontend/src/components/notifications/NotificationPanel.tsx @@ -0,0 +1,139 @@ +import React, { useEffect, useRef } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { CheckCheck, Inbox, Trash2 } from 'lucide-react'; +import { useNotifications } from '../../hooks/useNotifications'; +import NotificationItem from './NotificationItem'; +import './NotificationCenter.css'; + +export interface NotificationPanelProps { + isOpen: boolean; + onClose: () => void; + anchorRef?: React.RefObject; +} + +export const NotificationPanel: React.FC = ({ + isOpen, + onClose, + anchorRef, +}) => { + const { notifications, unreadCount, markAsRead, markAllAsRead, clearAll } = useNotifications(); + const panelRef = useRef(null); + + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } + }; + + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as Node; + if (panelRef.current && !panelRef.current.contains(target)) { + if (anchorRef?.current && anchorRef.current.contains(target)) { + return; + } + onClose(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + document.addEventListener('mousedown', handleClickOutside); + + return () => { + document.removeEventListener('keydown', handleKeyDown); + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen, onClose, anchorRef]); + + // Sort notifications newest first + const sortedNotifications = [...notifications].sort((a, b) => { + return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(); + }); + + return ( + + {isOpen && ( + + {/* Header */} +
+
+ Notifications + {unreadCount > 0 && ( + + {unreadCount} new + + )} +
+ +
+ {unreadCount > 0 && ( + + )} + {notifications.length > 0 && ( + + )} +
+
+ + {/* Body */} +
+ {sortedNotifications.length === 0 ? ( +
+
+ +
+

No notifications yet

+

We'll alert you when tasks update or payments settle.

+
+ ) : ( +
+ + {sortedNotifications.map(notification => ( + + ))} + +
+ )} +
+
+ )} +
+ ); +}; + +export default NotificationPanel; diff --git a/frontend/src/components/notifications/index.ts b/frontend/src/components/notifications/index.ts index df90c7e8..2f9e83ed 100644 --- a/frontend/src/components/notifications/index.ts +++ b/frontend/src/components/notifications/index.ts @@ -1,3 +1,5 @@ +export { default as NotificationBell, NotificationBell as Bell } from './NotificationBell'; +export { default as NotificationPanel, NotificationPanel as Panel } from './NotificationPanel'; export { default as NotificationCenter } from './NotificationCenter'; export { default as NotificationItem } from './NotificationItem'; export * from '../../types/notification'; diff --git a/frontend/src/context/NotificationContext.tsx b/frontend/src/context/NotificationContext.tsx index d2f05cec..90c69ae8 100644 --- a/frontend/src/context/NotificationContext.tsx +++ b/frontend/src/context/NotificationContext.tsx @@ -12,7 +12,11 @@ function loadInitialNotifications(): AppNotification[] { if (!raw) return []; const parsed = JSON.parse(raw); if (Array.isArray(parsed)) { - return parsed; + return parsed.map((item: any) => ({ + ...item, + message: item.message || item.description || '', + description: item.description || item.message || '', + })); } } catch (err) { console.error('Failed to parse notifications from localStorage:', err); @@ -20,6 +24,17 @@ function loadInitialNotifications(): AppNotification[] { return []; } +/** Helper to dispatch an application toast event */ +export function triggerToastNotification(message: string, type: 'success' | 'error' | 'warning' | 'info' = 'info') { + if (typeof window !== 'undefined') { + window.dispatchEvent( + new CustomEvent('app-toast', { + detail: { message, type }, + }) + ); + } +} + export function NotificationProvider({ children }: { children: ReactNode }) { const [notifications, setNotifications] = useState(loadInitialNotifications); const [isConnected, setIsConnected] = useState(false); @@ -54,12 +69,16 @@ export function NotificationProvider({ children }: { children: ReactNode }) { const id = input.id || `notif_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const timestamp = input.timestamp || new Date().toISOString(); const read = input.read ?? false; + const msg = input.message || input.description || ''; + const desc = input.description || input.message || ''; const newNotification: AppNotification = { ...input, id, timestamp, read, + message: msg, + description: desc, }; setNotifications(prev => { @@ -72,11 +91,11 @@ export function NotificationProvider({ children }: { children: ReactNode }) { setNotifications(prev => prev.filter(n => n.id !== id)); }, []); - const clearNotifications = useCallback(() => { + const clearAll = useCallback(() => { setNotifications([]); }, []); - // Helper to map incoming WebSocket / event data to AppNotification + // Helper to map incoming WebSocket / event data to AppNotification and trigger toasts const handleIncomingEvent = useCallback((eventData: any) => { if (!eventData || typeof eventData !== 'object') return; @@ -87,101 +106,131 @@ export function NotificationProvider({ children }: { children: ReactNode }) { const timestamp = eventData.timestamp || new Date().toISOString(); let notification: NewNotificationInput | null = null; + let toastMessage: string | null = null; + let toastType: 'success' | 'error' | 'warning' | 'info' = 'info'; switch (eventType) { case 'task_completed': notification = { - type: 'task', + type: 'task_completed', title: 'Task Completed', + message: `Task ${taskId || ''} completed successfully.`, description: `Task ${taskId || ''} completed successfully.`, link: taskId ? `/tasks/${taskId}` : '/dashboard', timestamp, + metadata: payload, }; + toastMessage = `Task ${taskId || ''} completed successfully.`; + toastType = 'success'; break; case 'task_failed': notification = { - type: 'task', + type: 'task_failed', title: 'Task Failed', + message: `Task ${taskId || ''} failed.${payload?.error ? ` Error: ${payload.error}` : ''}`, description: `Task ${taskId || ''} failed.${payload?.error ? ` Error: ${payload.error}` : ''}`, link: taskId ? `/tasks/${taskId}` : '/dashboard', timestamp, + metadata: payload, }; + toastMessage = `Task ${taskId || ''} failed.`; + toastType = 'error'; break; case 'node_completed': notification = { - type: 'task', + type: 'task_completed', title: 'DAG Node Completed', + message: `Node ${nodeId || 'task'} finished in task ${taskId || ''}.`, description: `Node ${nodeId || 'task'} finished in task ${taskId || ''}.`, link: taskId ? `/tasks/${taskId}` : '/dashboard', timestamp, + metadata: payload, }; break; case 'node_failed': notification = { - type: 'task', + type: 'task_failed', title: 'DAG Node Failed', + message: `Node ${nodeId || 'task'} failed in task ${taskId || ''}.${payload?.error ? ` (${payload.error})` : ''}`, description: `Node ${nodeId || 'task'} failed in task ${taskId || ''}.${payload?.error ? ` (${payload.error})` : ''}`, link: taskId ? `/tasks/${taskId}` : '/dashboard', timestamp, + metadata: payload, }; break; + case 'payment_received': case 'payment_released': notification = { - type: 'payment', - title: 'Payment Confirmed', - description: `Payment released for task ${taskId || ''}.${payload?.txHash ? ` Tx: ${payload.txHash.slice(0, 8)}...` : ''}`, + type: 'payment_received', + title: 'Payment Received', + message: `Payment received for task ${taskId || ''}.${payload?.amount ? ` (${payload.amount} XLM)` : ''}${payload?.txHash ? ` Tx: ${payload.txHash.slice(0, 8)}...` : ''}`, + description: `Payment received for task ${taskId || ''}.${payload?.amount ? ` (${payload.amount} XLM)` : ''}${payload?.txHash ? ` Tx: ${payload.txHash.slice(0, 8)}...` : ''}`, link: '/wallet', timestamp, + metadata: payload, }; + toastMessage = `Payment of ${payload?.amount ? `${payload.amount} XLM` : 'funds'} received.`; + toastType = 'success'; break; case 'payment_locked': notification = { - type: 'payment', + type: 'payment_received', title: 'Payment Escrow Locked', + message: `Payment locked in escrow for task ${taskId || ''}.`, description: `Payment locked in escrow for task ${taskId || ''}.`, link: '/wallet', timestamp, + metadata: payload, }; break; case 'agent_status': case 'agent_registered': notification = { - type: 'agent', - title: 'Agent Status Changed', - description: payload?.message || `Agent ${payload?.agentId || 'status'} updated.`, + type: 'agent_registered', + title: 'Agent Registered', + message: payload?.message || `Agent ${payload?.agentId || payload?.name || 'status'} updated.`, + description: payload?.message || `Agent ${payload?.agentId || payload?.name || 'status'} updated.`, link: '/agents', timestamp, + metadata: payload, }; break; case 'notification': case 'custom_notification': - if (payload?.title && payload?.description) { + if (payload?.title && (payload?.message || payload?.description)) { notification = { type: payload.type || 'system', title: payload.title, - description: payload.description, + message: payload.message || payload.description, + description: payload.description || payload.message, link: payload.link, timestamp, + metadata: payload, }; + if (payload.toast) { + toastMessage = payload.message || payload.description; + toastType = payload.toastType || 'info'; + } } break; default: - // If direct notification shape was sent - if (eventData.title && eventData.description) { + if (eventData.title && (eventData.message || eventData.description)) { notification = { type: eventData.type || 'system', title: eventData.title, - description: eventData.description, + message: eventData.message || eventData.description, + description: eventData.description || eventData.message, link: eventData.link, timestamp, + metadata: eventData.metadata || payload, }; } break; @@ -190,6 +239,10 @@ export function NotificationProvider({ children }: { children: ReactNode }) { if (notification) { addNotification(notification); } + + if (toastMessage) { + triggerToastNotification(toastMessage, toastType); + } }, [addNotification]); // WebSocket connection management @@ -241,12 +294,18 @@ export function NotificationProvider({ children }: { children: ReactNode }) { connect(); - // Also listen for local custom events + // Also listen for local custom events (allows testing & client-side triggers) const handleCustomEvent = (e: Event) => { const customEvent = e as CustomEvent; if (customEvent.detail) { - if (customEvent.detail.title) { + if (customEvent.detail.title && (customEvent.detail.message || customEvent.detail.description)) { addNotification(customEvent.detail); + if (customEvent.detail.type === 'task_completed' || customEvent.detail.type === 'payment_received') { + triggerToastNotification( + customEvent.detail.message || customEvent.detail.description || customEvent.detail.title, + 'success' + ); + } } else { handleIncomingEvent(customEvent.detail); } @@ -274,9 +333,10 @@ export function NotificationProvider({ children }: { children: ReactNode }) { unreadCount, markAsRead, markAllAsRead, + clearAll, + clearNotifications: clearAll, addNotification, removeNotification, - clearNotifications, isConnected, }} > diff --git a/frontend/src/context/ToastContext.tsx b/frontend/src/context/ToastContext.tsx index 6e96fbab..70381f7e 100644 --- a/frontend/src/context/ToastContext.tsx +++ b/frontend/src/context/ToastContext.tsx @@ -1,54 +1,138 @@ -import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; -import type { ReactNode } from 'react'; -import { ToastContainer } from '../components/common/Toast'; +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' +import type { ReactNode } from 'react' +import { ToastContainer } from '../components/common/Toast' export type ToastType = 'success' | 'error' | 'warning' | 'info' export interface ToastAction { - label: string; - onClick: () => void; + label: string + onClick: () => void } export interface Toast { - id: string; - message: string; - type: ToastType; - duration?: number; + id: string + message: string + type: ToastType + duration: number + count?: number + action?: ToastAction + createdAt: number } -interface ToastContextValue { +export interface ToastOptions { + duration?: number + action?: ToastAction +} + +export interface ToastContextValue { toasts: Toast[] - showToast: (message: string, type?: ToastType, duration?: number) => void + showToast: (message: string, type?: ToastType, durationOrOptions?: number | ToastOptions) => string dismissToast: (id: string) => void } const defaultDurations: Record = { success: 5000, info: 5000, - warning: 10000, - error: 10000, + warning: 7000, + error: 8000, } -export const ToastContext = createContext(undefined); +export const ToastContext = createContext(undefined) export function ToastProvider({ children }: { children: ReactNode }) { const [toasts, setToasts] = useState([]) + const timers = useRef>(new Map()) const dismissToast = useCallback((id: string) => { + const t = timers.current.get(id) + if (t) { + window.clearTimeout(t) + timers.current.delete(id) + } setToasts((prev) => prev.filter((toast) => toast.id !== id)) }, []) const showToast = useCallback( - (message: string, type: ToastType = 'info', duration = defaultDurations[type]) => { + (message: string, type: ToastType = 'info', durationOrOptions: number | ToastOptions = defaultDurations[type]) => { + // Normalize options: allow legacy number or {duration, action} + let duration: number + let action: ToastAction | undefined + if (typeof durationOrOptions === 'number') { + duration = durationOrOptions + } else { + duration = durationOrOptions.duration ?? defaultDurations[type] + action = durationOrOptions.action + } + + const trimmed = message.trim() + if (!trimmed) return '' + + // — Group duplicates: same message + type merges and increments count + // Find existing toast with same message/type (case-sensitive) + let grouped = false + let targetId = '' + setToasts((prev) => { + const existingIndex = prev.findIndex((t) => t.message === trimmed && t.type === type) + if (existingIndex !== -1) { + grouped = true + const existing = prev[existingIndex] + targetId = existing.id + // clear old timer so it restarts + const oldTimer = timers.current.get(existing.id) + if (oldTimer) { + window.clearTimeout(oldTimer) + timers.current.delete(existing.id) + } + const updated: Toast = { + ...existing, + count: (existing.count ?? 1) + 1, + duration, + createdAt: Date.now(), + action: action ?? existing.action, + } + // move to end (top of stack visual) for freshness + const next = [...prev] + next.splice(existingIndex, 1) + next.push(updated) + return next + } + return prev + }) + + if (grouped) { + // schedule dismiss for the grouped toast + if (duration > 0) { + const timer = window.setTimeout(() => dismissToast(targetId), duration) + timers.current.set(targetId, timer) + } + return targetId + } + const id = typeof crypto !== 'undefined' && 'randomUUID' in crypto ? crypto.randomUUID() - : Math.random().toString(36).slice(2); + : Math.random().toString(36).slice(2) + + const toast: Toast = { + id, + message: trimmed, + type, + duration, + count: 1, + action, + createdAt: Date.now(), + } - setToasts((prev) => [...prev, { id, message, type, duration }]) + // stacking: keep max 6 visible, drop oldest if exceeded + setToasts((prev) => { + const next = [...prev, toast] + if (next.length > 6) return next.slice(next.length - 6) + return next + }) if (duration > 0) { - window.setTimeout(() => dismissToast(id), duration) + const timer = window.setTimeout(() => dismissToast(id), duration) + timers.current.set(id, timer) } return id @@ -60,36 +144,50 @@ export function ToastProvider({ children }: { children: ReactNode }) { useEffect(() => { const handleExternalToast = (event: Event) => { const customEvent = event as CustomEvent<{ - message?: string; - type?: ToastType; - duration?: number; - }>; - const message = customEvent.detail?.message; - if (!message) return; - - const type = customEvent.detail?.type ?? 'info'; - showToast(message, type, customEvent.detail?.duration ?? defaultDurations[type]); - }; + message?: string + type?: ToastType + duration?: number + action?: ToastAction + }> + const message = customEvent.detail?.message + if (!message) return + const t = customEvent.detail?.type ?? 'info' + const dur = customEvent.detail?.duration ?? defaultDurations[t] + const act = customEvent.detail?.action + showToast(message, t, act ? { duration: dur, action: act } : dur) + } window.addEventListener('app-toast', handleExternalToast as EventListener) return () => window.removeEventListener('app-toast', handleExternalToast as EventListener) }, [showToast]) - const value = useMemo( - () => ({ toasts, showToast, dismissToast }), - [toasts, showToast, dismissToast], - ); + // cleanup timers on unmount + useEffect(() => { + return () => { + timers.current.forEach((timer) => window.clearTimeout(timer)) + timers.current.clear() + } + }, []) + + const value = useMemo(() => ({ toasts, showToast, dismissToast }), [toasts, showToast, dismissToast]) return ( {children} - + ) } export function useToast() { - const context = useContext(ToastContext); - if (!context) throw new Error('useToast must be used within ToastProvider'); - return context; + const ctx = useContext(ToastContext) + if (!ctx) { + // Fallback for isolated tests without provider (see hooks/useToast) + return { + toasts: [], + showToast: () => '', + dismissToast: () => {}, + } as unknown as ToastContextValue + } + return ctx } diff --git a/frontend/src/hooks/useNotifications.ts b/frontend/src/hooks/useNotifications.ts index 4b58a1c9..7fd88c91 100644 --- a/frontend/src/hooks/useNotifications.ts +++ b/frontend/src/hooks/useNotifications.ts @@ -7,9 +7,10 @@ const fallbackValue: NotificationContextValue = { unreadCount: 0, markAsRead: () => {}, markAllAsRead: () => {}, + clearAll: () => {}, + clearNotifications: () => {}, addNotification: () => {}, removeNotification: () => {}, - clearNotifications: () => {}, isConnected: false, }; diff --git a/frontend/src/hooks/useToast.ts b/frontend/src/hooks/useToast.ts index 0a355bfd..e148c03f 100644 --- a/frontend/src/hooks/useToast.ts +++ b/frontend/src/hooks/useToast.ts @@ -1,7 +1,7 @@ import { useContext } from 'react'; -import { ToastContext } from '../context/ToastContext'; +import { ToastContext, type ToastContextValue } from '../context/ToastContext'; -export function useToast() { +export function useToast(): ToastContextValue { const context = useContext(ToastContext); if (!context) { @@ -11,7 +11,7 @@ export function useToast() { toasts: [], showToast: () => '', dismissToast: () => {}, - } as unknown as ReturnType> extends infer T ? T : never + }; } return context; diff --git a/frontend/src/types/notification.ts b/frontend/src/types/notification.ts index 05afb906..e974ffff 100644 --- a/frontend/src/types/notification.ts +++ b/frontend/src/types/notification.ts @@ -1,26 +1,45 @@ -export type NotificationType = 'task' | 'payment' | 'agent' | 'system'; +export type NotificationType = + | 'task_completed' + | 'task_failed' + | 'payment_received' + | 'agent_registered' + | 'task' + | 'payment' + | 'agent' + | 'system'; export interface AppNotification { id: string; type: NotificationType; title: string; - description: string; - timestamp: string; // ISO-8601 string or epoch string + message: string; + description?: string; // backward compatibility + timestamp: string; // ISO-8601 string read: boolean; link?: string; metadata?: Record; } -export type NewNotificationInput = Omit & - Partial>; +export type NewNotificationInput = { + id?: string; + type: NotificationType; + title: string; + message?: string; + description?: string; + timestamp?: string; + read?: boolean; + link?: string; + metadata?: Record; +}; export interface NotificationContextValue { notifications: AppNotification[]; unreadCount: number; markAsRead: (id: string) => void; markAllAsRead: () => void; + clearAll: () => void; + clearNotifications: () => void; // alias for backward compatibility addNotification: (notification: NewNotificationInput) => void; removeNotification: (id: string) => void; - clearNotifications: () => void; isConnected: boolean; }