| 1 |
/** |
| 2 |
* Toast Notification Component |
| 3 |
* Displays temporary notifications at the top-right of the screen |
| 4 |
*/ |
| 5 |
|
| 6 |
import React, { |
| 7 |
createContext, |
| 8 |
useContext, |
| 9 |
useState, |
| 10 |
useCallback, |
| 11 |
useEffect, |
| 12 |
} from "react"; |
| 13 |
import { |
| 14 |
X, |
| 15 |
CheckCircle2, |
| 16 |
AlertCircle, |
| 17 |
Info, |
| 18 |
AlertTriangle, |
| 19 |
} from "lucide-react"; |
| 20 |
import { __ } from "../../lib/i18n"; |
| 21 |
|
| 22 |
export type ToastType = "success" | "error" | "warning" | "info"; |
| 23 |
|
| 24 |
export interface Toast { |
| 25 |
id: string; |
| 26 |
message: string; |
| 27 |
type: ToastType; |
| 28 |
duration?: number; |
| 29 |
} |
| 30 |
|
| 31 |
interface ToastContextType { |
| 32 |
showToast: (message: string, type?: ToastType, duration?: number) => void; |
| 33 |
removeToast: (id: string) => void; |
| 34 |
} |
| 35 |
|
| 36 |
const ToastContext = createContext<ToastContextType | undefined>(undefined); |
| 37 |
|
| 38 |
export const useToast = () => { |
| 39 |
const context = useContext(ToastContext); |
| 40 |
if (!context) { |
| 41 |
throw new Error("useToast must be used within ToastProvider"); |
| 42 |
} |
| 43 |
return context; |
| 44 |
}; |
| 45 |
|
| 46 |
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ |
| 47 |
children, |
| 48 |
}) => { |
| 49 |
const [toasts, setToasts] = useState<Toast[]>([]); |
| 50 |
|
| 51 |
const showToast = useCallback( |
| 52 |
(message: string, type: ToastType = "success", duration: number = 4000) => { |
| 53 |
const id = `toast-${Date.now()}-${Math.random()}`; |
| 54 |
const newToast: Toast = { id, message, type, duration }; |
| 55 |
|
| 56 |
setToasts((prev) => [...prev, newToast]); |
| 57 |
|
| 58 |
if (duration > 0) { |
| 59 |
setTimeout(() => { |
| 60 |
removeToast(id); |
| 61 |
}, duration); |
| 62 |
} |
| 63 |
}, |
| 64 |
[], |
| 65 |
); |
| 66 |
|
| 67 |
const removeToast = useCallback((id: string) => { |
| 68 |
setToasts((prev) => prev.filter((toast) => toast.id !== id)); |
| 69 |
}, []); |
| 70 |
|
| 71 |
return ( |
| 72 |
<ToastContext.Provider value={{ showToast, removeToast }}> |
| 73 |
{children} |
| 74 |
<ToastContainer toasts={toasts} onRemove={removeToast} /> |
| 75 |
</ToastContext.Provider> |
| 76 |
); |
| 77 |
}; |
| 78 |
|
| 79 |
interface ToastContainerProps { |
| 80 |
toasts: Toast[]; |
| 81 |
onRemove: (id: string) => void; |
| 82 |
} |
| 83 |
|
| 84 |
const ToastContainer: React.FC<ToastContainerProps> = ({ |
| 85 |
toasts, |
| 86 |
onRemove, |
| 87 |
}) => { |
| 88 |
if (toasts.length === 0) return null; |
| 89 |
|
| 90 |
return ( |
| 91 |
<div className="fixed top-4 right-4 z-[9999] flex flex-col gap-2 pointer-events-none"> |
| 92 |
{toasts.map((toast) => ( |
| 93 |
<ToastItem key={toast.id} toast={toast} onRemove={onRemove} /> |
| 94 |
))} |
| 95 |
</div> |
| 96 |
); |
| 97 |
}; |
| 98 |
|
| 99 |
interface ToastItemProps { |
| 100 |
toast: Toast; |
| 101 |
onRemove: (id: string) => void; |
| 102 |
} |
| 103 |
|
| 104 |
const ToastItem: React.FC<ToastItemProps> = ({ toast, onRemove }) => { |
| 105 |
const [isVisible, setIsVisible] = useState(false); |
| 106 |
|
| 107 |
useEffect(() => { |
| 108 |
// Trigger animation |
| 109 |
setTimeout(() => setIsVisible(true), 10); |
| 110 |
}, []); |
| 111 |
|
| 112 |
const handleRemove = () => { |
| 113 |
setIsVisible(false); |
| 114 |
setTimeout(() => onRemove(toast.id), 300); |
| 115 |
}; |
| 116 |
|
| 117 |
const getIcon = () => { |
| 118 |
switch (toast.type) { |
| 119 |
case "success": |
| 120 |
return <CheckCircle2 className="w-5 h-5 text-green-600" />; |
| 121 |
case "error": |
| 122 |
return <AlertCircle className="w-5 h-5 text-red-600" />; |
| 123 |
case "warning": |
| 124 |
return <AlertTriangle className="w-5 h-5 text-yellow-600" />; |
| 125 |
case "info": |
| 126 |
return <Info className="w-5 h-5 text-blue-600" />; |
| 127 |
default: |
| 128 |
return <Info className="w-5 h-5 text-gray-600" />; |
| 129 |
} |
| 130 |
}; |
| 131 |
|
| 132 |
const getStyles = () => { |
| 133 |
switch (toast.type) { |
| 134 |
case "success": |
| 135 |
return "bg-green-50 border-green-200 text-green-800 dark:bg-green-900/20 dark:border-green-800 dark:text-green-400"; |
| 136 |
case "error": |
| 137 |
return "bg-red-50 border-red-200 text-red-800 dark:bg-red-900/20 dark:border-red-800 dark:text-red-400"; |
| 138 |
case "warning": |
| 139 |
return "bg-yellow-50 border-yellow-200 text-yellow-800 dark:bg-yellow-900/20 dark:border-yellow-800 dark:text-yellow-400"; |
| 140 |
case "info": |
| 141 |
return "bg-blue-50 border-blue-200 text-blue-800 dark:bg-blue-900/20 dark:border-blue-800 dark:text-blue-400"; |
| 142 |
default: |
| 143 |
return "bg-gray-50 border-gray-200 text-gray-800 dark:bg-gray-900/20 dark:border-gray-800 dark:text-gray-400"; |
| 144 |
} |
| 145 |
}; |
| 146 |
|
| 147 |
return ( |
| 148 |
<div |
| 149 |
className={` |
| 150 |
pointer-events-auto |
| 151 |
flex items-start gap-3 |
| 152 |
p-4 rounded-lg border |
| 153 |
shadow-lg backdrop-blur-sm |
| 154 |
min-w-[320px] max-w-[400px] |
| 155 |
transition-all duration-300 ease-out |
| 156 |
${isVisible ? "translate-x-0 opacity-100" : "translate-x-full opacity-0"} |
| 157 |
${getStyles()} |
| 158 |
`} |
| 159 |
> |
| 160 |
<div className="flex-shrink-0 mt-0.5">{getIcon()}</div> |
| 161 |
<div className="flex-1 text-sm font-medium">{toast.message}</div> |
| 162 |
<button |
| 163 |
onClick={handleRemove} |
| 164 |
className="flex-shrink-0 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors" |
| 165 |
aria-label={__("Close", "yatra")} |
| 166 |
> |
| 167 |
<X className="w-4 h-4" /> |
| 168 |
</button> |
| 169 |
</div> |
| 170 |
); |
| 171 |
}; |
| 172 |
|