Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion Frontend/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,17 @@
.input-field {
@apply w-full px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent;
}

.sr-only {
@apply absolute w-px h-px p-0 -m-px overflow-hidden whitespace-nowrap border-0;
clip: rect(0, 0, 0, 0);
clip-path: inset(50%);
}
}

@media (prefers-reduced-motion: reduce) {
.animate-pulse-slow,
.animate-bounce-slow {
animation: none;
}
}
}
12 changes: 12 additions & 0 deletions Frontend/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { Metadata } from 'next'
import './globals.css'
import { WalletAccountMenu } from '@/components/WalletAccountMenu'
import TransactionToast from '@/components/TransactionToast'
import { Suspense } from 'react'

export const metadata: Metadata = {
title: 'Vaulty — Save Consistently. Grow Your Wealth.',
Expand All @@ -23,6 +25,16 @@ export default function RootLayout({ children }: { children: React.ReactNode })
</header>

<main>{children}</main>
<Suspense fallback={null}>
<TransactionToast />
</Suspense>
{/* Screen reader announcer for transaction notifications */}
<div
id="sr-announcer"
className="sr-only"
aria-live="polite"
aria-atomic="true"
/>
</body>
</html>
)
Expand Down
98 changes: 98 additions & 0 deletions Frontend/src/components/TransactionStatus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
'use client'

import React from 'react'
import { TransactionStatusType } from '@/types'

interface TransactionStatusProps {
status: TransactionStatusType
message: string
reference?: string
onDismiss?: () => void
}

export const TransactionStatus: React.FC<TransactionStatusProps> = ({
status,
message,
reference,
onDismiss,
}) => {
const statusConfig = {
pending: {
icon: (
<svg className="animate-spin h-5 w-5 text-blue-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
),
bgColor: 'bg-blue-50',
borderColor: 'border-blue-200',
textColor: 'text-blue-800',
label: 'Processing',
},
success: {
icon: (
<svg className="h-5 w-5 text-green-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7"></path>
</svg>
),
bgColor: 'bg-green-50',
borderColor: 'border-green-200',
textColor: 'text-green-800',
label: 'Success',
},
error: {
icon: (
<svg className="h-5 w-5 text-red-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
),
bgColor: 'bg-red-50',
borderColor: 'border-red-200',
textColor: 'text-red-800',
label: 'Error',
},
dismissed: {
icon: null,
bgColor: 'bg-gray-50',
borderColor: 'border-gray-200',
textColor: 'text-gray-500',
label: 'Dismissed',
},
}

const config = statusConfig[status]

if (status === 'dismissed') return null

return (
<div
role="alert"
aria-live={status === 'error' ? 'assertive' : 'polite'}
className={`${config.bgColor} ${config.borderColor} border rounded-lg p-4 shadow-lg mb-2 flex items-start gap-3 min-w-[320px] max-w-md`}
>
<div className="flex-shrink-0 mt-0.5">{config.icon}</div>
<div className="flex-1">
<p className={`font-medium ${config.textColor}`}>{config.label}</p>
<p className={`text-sm ${config.textColor} opacity-90 mt-1`}>{message}</p>
{reference && (
<p className="text-xs text-gray-500 mt-2">
Reference: <span className="font-mono">{reference}</span>
</p>
)}
</div>
{onDismiss && (
<button
onClick={onDismiss}
className="flex-shrink-0 p-1 rounded-md hover:bg-black/5 transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
aria-label="Dismiss notification"
>
<svg className="h-4 w-4 text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
)}
</div>
)
}

export default TransactionStatus
117 changes: 117 additions & 0 deletions Frontend/src/components/TransactionToast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
'use client'

import React, { useEffect, useCallback } from 'react'
import { useAppStore } from '@/stores'
import TransactionStatus from './TransactionStatus'
import { TransactionNotification } from '@/types'

export const TransactionToast: React.FC = () => {
const { transactionNotifications, dismissTransactionNotification, removeTransactionNotification } = useAppStore()

// Filter out dismissed notifications that are older than 5 seconds to clean up
useEffect(() => {
const cleanupInterval = setInterval(() => {
const now = new Date()
transactionNotifications.forEach((notification) => {
if (notification.status === 'dismissed') {
const updatedAt = new Date(notification.updatedAt)
const diff = now.getTime() - updatedAt.getTime()
if (diff > 5000) {
removeTransactionNotification(notification.id)
}
}
})
}, 1000)

return () => clearInterval(cleanupInterval)
}, [transactionNotifications, removeTransactionNotification])

// Auto-dismiss success and error notifications after 5 seconds
useEffect(() => {
const dismissTimeouts: NodeJS.Timeout[] = []

transactionNotifications.forEach((notification) => {
if (notification.status === 'success' || notification.status === 'error') {
const updatedAt = new Date(notification.updatedAt)
const now = new Date()
const diff = now.getTime() - updatedAt.getTime()

if (diff < 100) { // Only set timeout if just updated
const timeout = setTimeout(() => {
dismissTransactionNotification(notification.id)
}, 5000)
dismissTimeouts.push(timeout)
}
}
})

return () => dismissTimeouts.forEach(clearTimeout)
}, [transactionNotifications, dismissTransactionNotification])

// Keyboard dismissal (Escape key)
const handleKeyDown = useCallback((event: KeyboardEvent) => {
if (event.key === 'Escape') {
const activeNotifications = transactionNotifications.filter(
n => n.status !== 'dismissed'
)
if (activeNotifications.length > 0) {
// Dismiss the most recent notification first
const mostRecent = activeNotifications[activeNotifications.length - 1]
dismissTransactionNotification(mostRecent.id)
}
}
}, [transactionNotifications, dismissTransactionNotification])

useEffect(() => {
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [handleKeyDown])

// Screen reader announcements
useEffect(() => {
const announcements: string[] = []
transactionNotifications.forEach((notification) => {
if (notification.status === 'pending') {
announcements.push(`${notification.action}: ${notification.message}`)
} else if (notification.status === 'success') {
announcements.push(`${notification.action} successful: ${notification.message}`)
} else if (notification.status === 'error') {
announcements.push(`${notification.action} failed: ${notification.message}`)
}
})

if (announcements.length > 0) {
const announcer = document.getElementById('sr-announcer')
if (announcer) {
announcer.textContent = announcements.join('. ')
// Clear after announcement
setTimeout(() => {
if (announcer) announcer.textContent = ''
}, 1000)
}
}
}, [transactionNotifications])

const activeNotifications = transactionNotifications.filter(
(notification): notification is TransactionNotification =>
notification.status !== 'dismissed'
)

if (activeNotifications.length === 0) return null

return (
<div className="fixed bottom-4 right-4 z-50 flex flex-col-reverse">
{activeNotifications.map((notification) => (
<TransactionStatus
key={notification.id}
status={notification.status}
message={notification.message}
reference={notification.reference}
onDismiss={() => dismissTransactionNotification(notification.id)}
/>
))}
</div>
)
}

export default TransactionToast
Loading
Loading