From 66a6697ea1475d680ca58e68083f67af5174a0fc Mon Sep 17 00:00:00 2001 From: c0mrade Date: Fri, 6 Feb 2026 20:13:18 +0300 Subject: [PATCH] fix: unify toast notifications and improve visual/behavior - Solid opaque background with colored left border instead of translucent gradient - Max 3 visible toasts with oldest eviction - Telegram safe area support via env(safe-area-inset-*) - Dismiss by clicking anywhere on toast, removed X button - Adaptive width: full-width on mobile, fixed on desktop - AdminEmailTemplates: removed local toast, migrated to useNotify - AdminUsers: migrated from useToast to useNotify --- src/components/Toast.tsx | 114 ++++++++++++++---------------- src/pages/AdminEmailTemplates.tsx | 38 ++-------- src/pages/AdminUsers.tsx | 22 ++---- 3 files changed, 64 insertions(+), 110 deletions(-) diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx index 63864ed..0b85413 100644 --- a/src/components/Toast.tsx +++ b/src/components/Toast.tsx @@ -27,6 +27,8 @@ interface ToastContextType { const ToastContext = createContext(null); +const MAX_VISIBLE = 3; + // eslint-disable-next-line react-refresh/only-export-components export function useToast() { const context = useContext(ToastContext); @@ -40,22 +42,7 @@ export function ToastProvider({ children }: { children: ReactNode }) { const [toasts, setToasts] = useState([]); const timersRef = useRef>>(new Map()); - const showToast = useCallback((options: ToastOptions) => { - const id = Date.now() + Math.random(); // Avoid ID collision - const toast: Toast = { id, duration: 5000, type: 'info', ...options }; - - setToasts((prev) => [...prev, toast]); - - const timer = setTimeout(() => { - setToasts((prev) => prev.filter((t) => t.id !== id)); - timersRef.current.delete(id); - }, toast.duration); - - timersRef.current.set(id, timer); - }, []); - const removeToast = useCallback((id: number) => { - // Clear timer when manually removing const timer = timersRef.current.get(id); if (timer) { clearTimeout(timer); @@ -64,6 +51,37 @@ export function ToastProvider({ children }: { children: ReactNode }) { setToasts((prev) => prev.filter((t) => t.id !== id)); }, []); + const showToast = useCallback( + (options: ToastOptions) => { + const id = Date.now() + Math.random(); + const toast: Toast = { id, duration: 5000, type: 'info', ...options }; + + setToasts((prev) => { + const next = [...prev, toast]; + // Evict oldest toasts beyond the limit + if (next.length > MAX_VISIBLE) { + const evicted = next.slice(0, next.length - MAX_VISIBLE); + for (const old of evicted) { + const timer = timersRef.current.get(old.id); + if (timer) { + clearTimeout(timer); + timersRef.current.delete(old.id); + } + } + return next.slice(-MAX_VISIBLE); + } + return next; + }); + + const timer = setTimeout(() => { + removeToast(id); + }, toast.duration); + + timersRef.current.set(id, timer); + }, + [removeToast], + ); + // Cleanup all timers on unmount useEffect(() => { const timers = timersRef.current; @@ -77,8 +95,8 @@ export function ToastProvider({ children }: { children: ReactNode }) { {children} - {/* Toast Container */} -
+ {/* Toast Container — safe area aware, adaptive width */} +
{toasts.map((toast) => ( removeToast(toast.id)} /> ))} @@ -89,40 +107,34 @@ export function ToastProvider({ children }: { children: ReactNode }) { function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) { const handleClick = () => { - if (toast.onClick) { - toast.onClick(); - onClose(); - } + toast.onClick?.(); + onClose(); }; const typeStyles = { success: { - bg: 'bg-dark-800', - accent: 'bg-gradient-to-r from-success-500/50 to-transparent', - border: 'border-success-500/70', + border: 'border-l-success-500', icon: 'text-success-400', - iconBg: 'bg-success-500/30', + iconBg: 'bg-success-500/20', + progress: 'bg-success-400', }, error: { - bg: 'bg-dark-800', - accent: 'bg-gradient-to-r from-error-500/50 to-transparent', - border: 'border-error-500/70', + border: 'border-l-error-500', icon: 'text-error-400', - iconBg: 'bg-error-500/30', + iconBg: 'bg-error-500/20', + progress: 'bg-error-400', }, warning: { - bg: 'bg-dark-800', - accent: 'bg-gradient-to-r from-warning-500/50 to-transparent', - border: 'border-warning-500/70', + border: 'border-l-warning-500', icon: 'text-warning-400', - iconBg: 'bg-warning-500/30', + iconBg: 'bg-warning-500/20', + progress: 'bg-warning-400', }, info: { - bg: 'bg-dark-800', - accent: 'bg-gradient-to-r from-accent-500/50 to-transparent', - border: 'border-accent-500/70', + border: 'border-l-accent-500', icon: 'text-accent-400', - iconBg: 'bg-accent-500/30', + iconBg: 'bg-accent-500/20', + progress: 'bg-accent-400', }, }; @@ -185,17 +197,14 @@ function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) { return (
- {/* Color accent */} -
-
{/* Icon */}
{toast.icon || defaultIcons[toast.type || 'info']}
@@ -207,31 +216,12 @@ function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) { )}

{toast.message}

- - {/* Close button */} -
{/* Progress bar */}
(null); const textareaRef = useRef(null); const langData: EmailTemplateLanguageData | undefined = detail.languages[activeLang]; @@ -186,11 +182,6 @@ function TemplateEditor({ } }, [activeLang, langData]); - const showToast = useCallback((type: 'success' | 'error' | 'info', message: string) => { - setToast({ type, message }); - setTimeout(() => setToast(null), 3000); - }, []); - // Extract body content from full HTML (strip base template wrapper) const extractBodyContent = useCallback((html: string): string => { // If it's wrapped in the base template, extract just the content div @@ -230,10 +221,10 @@ function TemplateEditor({ queryKey: ['admin', 'email-template', detail.notification_type], }); setIsDirty(false); - showToast('success', t('admin.emailTemplates.saved')); + notify.success(t('admin.emailTemplates.saved')); }, onError: () => { - showToast('error', t('common.error')); + notify.error(t('common.error')); }, }); @@ -246,10 +237,10 @@ function TemplateEditor({ queryKey: ['admin', 'email-template', detail.notification_type], }); setIsDirty(false); - showToast('success', t('admin.emailTemplates.resetted')); + notify.success(t('admin.emailTemplates.resetted')); }, onError: () => { - showToast('error', t('common.error')); + notify.error(t('common.error')); }, }); @@ -260,10 +251,10 @@ function TemplateEditor({ language: activeLang, }), onSuccess: (data) => { - showToast('success', `${t('admin.emailTemplates.testSent')} → ${data.sent_to}`); + notify.success(`${t('admin.emailTemplates.testSent')} → ${data.sent_to}`); }, onError: () => { - showToast('error', t('common.error')); + notify.error(t('common.error')); }, }); @@ -360,7 +351,7 @@ function TemplateEditor({ title={t('admin.emailTemplates.clickToCopy')} onClick={() => { navigator.clipboard.writeText(`{${v}}`); - showToast('success', `Copied {${v}}`); + notify.success(`Copied {${v}}`); }} > {`{${v}}`} @@ -448,21 +439,6 @@ function TemplateEditor({ )}
- - {/* Toast */} - {toast && ( -
- {toast.message} -
- )}
); } diff --git a/src/pages/AdminUsers.tsx b/src/pages/AdminUsers.tsx index 4428f34..1831f93 100644 --- a/src/pages/AdminUsers.tsx +++ b/src/pages/AdminUsers.tsx @@ -2,9 +2,9 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { useNavigate } from 'react-router'; import { useTranslation } from 'react-i18next'; import { useCurrency } from '../hooks/useCurrency'; -import { useToast } from '../components/Toast'; import { adminUsersApi, type UserListItem, type UsersStatsResponse } from '../api/adminUsers'; import { usePlatform } from '../platform/hooks/usePlatform'; +import { useNotify } from '../platform/hooks/useNotify'; // ============ Icons ============ @@ -394,7 +394,7 @@ export default function AdminUsers() { const { t } = useTranslation(); const { formatWithCurrency } = useCurrency(); const navigate = useNavigate(); - const { showToast } = useToast(); + const notify = useNotify(); const { capabilities } = usePlatform(); const [users, setUsers] = useState([]); @@ -492,27 +492,15 @@ export default function AdminUsers() { } if (result.success) { - showToast({ - type: 'success', - title: t('common.success'), - message: t(`admin.users.userActions.success.${action}`), - }); + notify.success(t(`admin.users.userActions.success.${action}`), t('common.success')); loadUsers(); loadStats(); } else { - showToast({ - type: 'error', - title: t('common.error'), - message: result.message || t('admin.users.userActions.error'), - }); + notify.error(result.message || t('admin.users.userActions.error'), t('common.error')); } } catch (error) { console.error('Action failed:', error); - showToast({ - type: 'error', - title: t('common.error'), - message: t('admin.users.userActions.error'), - }); + notify.error(t('admin.users.userActions.error'), t('common.error')); } finally { setActionLoading(false); closeConfirmModal();