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
2 changes: 2 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
{
}
30 changes: 30 additions & 0 deletions apps/frontend/app/contexts/ToastContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
'use client';

import React, { createContext, useContext, useState, useCallback } from 'react';

export type ToastType = 'success' | 'error' | 'warning' | 'info';

export interface Toast {
id: string;
message: string;
type: ToastType;
duration?: number;
}

interface ToastContextType {
toasts: Toast[];
addToast: (message: string, type: ToastType, duration?: number) => void;
removeToast: (id: string) => void;
}

const ToastContext = createContext<ToastContextType | undefined>(undefined);

export const useToast = () => {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
};

export default ToastContext;
33 changes: 33 additions & 0 deletions apps/frontend/app/contexts/ToastProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
'use client';

import React, { useState, useCallback } from 'react';
import ToastContext, { Toast, ToastType } from './ToastContext';
import ToastContainer from '@/component/ui/ToastContainer';

export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);

const addToast = useCallback((message: string, type: ToastType, duration: number = 5000) => {
const id = Math.random().toString(36).substring(2, 9);
const newToast: Toast = { id, message, type, duration };

setToasts((prev) => [...prev, newToast]);

if (duration > 0) {
setTimeout(() => {
removeToast(id);
}, duration);
}
}, []);

Check warning on line 21 in apps/frontend/app/contexts/ToastProvider.tsx

View workflow job for this annotation

GitHub Actions / Build and Test

React Hook useCallback has a missing dependency: 'removeToast'. Either include it or remove the dependency array

const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
}, []);

return (
<ToastContext.Provider value={{ toasts, addToast, removeToast }}>
{children}
<ToastContainer toasts={toasts} onRemove={removeToast} />
</ToastContext.Provider>
);
}
32 changes: 32 additions & 0 deletions apps/frontend/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,35 @@
@apply bg-background text-foreground;
}
}

@layer utilities {
@keyframes toast-enter {
from {
opacity: 0;
transform: translateX(100%);
}
to {
opacity: 1;
transform: translateX(0);
}
}

@keyframes toast-exit {
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(100%);
}
}

.animate-toast-enter {
animation: toast-enter 0.3s ease-out;
}

.animate-toast-exit {
animation: toast-exit 0.3s ease-in;
}
}
5 changes: 4 additions & 1 deletion apps/frontend/component/Providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import React, { useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ToastProvider } from '@/app/contexts/ToastProvider';

export default function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient({
Expand All @@ -15,7 +16,9 @@ export default function Providers({ children }: { children: React.ReactNode }) {

return (
<QueryClientProvider client={queryClient}>
{children}
<ToastProvider>
{children}
</ToastProvider>
</QueryClientProvider>
);
}
101 changes: 101 additions & 0 deletions apps/frontend/component/ui/TOAST_README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Toast Notification System

A global toast notification system for user feedback throughout the application.

## Features

- ✅ Four notification types: success, error, warning, info
- ✅ Auto-dismiss with configurable duration (default: 5 seconds)
- ✅ Manual dismiss option
- ✅ Smooth animations (slide in/out)
- ✅ Accessible (ARIA labels, keyboard support)
- ✅ Stacked notifications
- ✅ Easy-to-use hook API

## Usage

### Basic Usage

```tsx
import { useToast } from "@/hooks/useToast";

function MyComponent() {
const toast = useToast();

const handleSuccess = () => {
toast.success("Operation completed successfully!");
};

const handleError = () => {
toast.error("Something went wrong!");
};

const handleWarning = () => {
toast.warning("Please review your input.");
};

const handleInfo = () => {
toast.info("New updates available.");
};

return <button onClick={handleSuccess}>Show Success Toast</button>;
}
```

### Custom Duration

```tsx
// Toast will stay for 10 seconds
toast.success("This message stays longer", 10000);

// Toast will not auto-dismiss (duration = 0)
toast.error("Manual dismiss only", 0);
```

### Manual Dismiss

```tsx
const toastId = toast.info("Processing...");

// Later, dismiss manually
toast.dismiss(toastId);
```

## API Reference

### `useToast()` Hook

Returns an object with the following methods:

- `success(message: string, duration?: number)` - Show success toast
- `error(message: string, duration?: number)` - Show error toast
- `warning(message: string, duration?: number)` - Show warning toast
- `info(message: string, duration?: number)` - Show info toast
- `dismiss(id: string)` - Manually dismiss a toast

### Parameters

- `message` (string, required): The message to display
- `duration` (number, optional): Duration in milliseconds before auto-dismiss. Default: 5000. Set to 0 to disable auto-dismiss.

## Integration

The toast system is already integrated into the app via `Providers.tsx`. No additional setup is required.

## Accessibility

- Uses ARIA live regions for screen reader announcements
- Keyboard accessible dismiss buttons
- Proper focus management
- Semantic HTML with appropriate roles

## Styling

Toast notifications are positioned at the top-right of the screen and stack vertically. Each toast type has distinct colors:

- Success: Green
- Error: Red
- Warning: Yellow
- Info: Blue

Animations are defined in `app/globals.css` and can be customized as needed.
95 changes: 95 additions & 0 deletions apps/frontend/component/ui/Toast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
'use client';

import React, { useEffect, useState } from 'react';
import { Toast as ToastType } from '@/app/contexts/ToastContext';

interface ToastProps {
toast: ToastType;
onRemove: (id: string) => void;
}

const Toast: React.FC<ToastProps> = ({ toast, onRemove }) => {
const [isExiting, setIsExiting] = useState(false);

const handleDismiss = () => {
setIsExiting(true);
setTimeout(() => {
onRemove(toast.id);
}, 300);
};

const getToastStyles = () => {
const baseStyles = 'flex items-start gap-3 p-4 rounded-lg shadow-lg border min-w-[320px] max-w-[420px]';

switch (toast.type) {
case 'success':
return `${baseStyles} bg-green-50 border-green-200 text-green-800`;
case 'error':
return `${baseStyles} bg-red-50 border-red-200 text-red-800`;
case 'warning':
return `${baseStyles} bg-yellow-50 border-yellow-200 text-yellow-800`;
case 'info':
return `${baseStyles} bg-blue-50 border-blue-200 text-blue-800`;
default:
return `${baseStyles} bg-gray-50 border-gray-200 text-gray-800`;
}
};

const getIcon = () => {
switch (toast.type) {
case 'success':
return (
<svg className="w-5 h-5 shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clipRule="evenodd" />
</svg>
);
case 'error':
return (
<svg className="w-5 h-5 shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clipRule="evenodd" />
</svg>
);
case 'warning':
return (
<svg className="w-5 h-5 shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path fillRule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clipRule="evenodd" />
</svg>
);
case 'info':
return (
<svg className="w-5 h-5 shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clipRule="evenodd" />
</svg>
);
}
};

const animationClass = isExiting
? 'animate-toast-exit'
: 'animate-toast-enter';

return (
<div
className={`${getToastStyles()} ${animationClass}`}
role="alert"
aria-live="assertive"
aria-atomic="true"
>
{getIcon()}
<div className="flex-1">
<p className="text-sm font-medium">{toast.message}</p>
</div>
<button
onClick={handleDismiss}
className="shrink-0 inline-flex text-current hover:opacity-70 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-current rounded"
aria-label="Dismiss notification"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
</svg>
</button>
</div>
);
};

export default Toast;
28 changes: 28 additions & 0 deletions apps/frontend/component/ui/ToastContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
'use client';

import React from 'react';
import Toast from './Toast';
import { Toast as ToastType } from '@/app/contexts/ToastContext';

interface ToastContainerProps {
toasts: ToastType[];
onRemove: (id: string) => void;
}

const ToastContainer: React.FC<ToastContainerProps> = ({ toasts, onRemove }) => {
return (
<div
className="fixed top-4 right-4 z-50 flex flex-col gap-3 pointer-events-none"
aria-live="polite"
aria-atomic="false"
>
{toasts.map((toast) => (
<div key={toast.id} className="pointer-events-auto">
<Toast toast={toast} onRemove={onRemove} />
</div>
))}
</div>
);
};

export default ToastContainer;
Loading