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
This commit is contained in:
c0mrade
2026-02-06 20:13:18 +03:00
parent 6e7eb36f76
commit 66a6697ea1
3 changed files with 64 additions and 110 deletions

View File

@@ -27,6 +27,8 @@ interface ToastContextType {
const ToastContext = createContext<ToastContextType | null>(null); const ToastContext = createContext<ToastContextType | null>(null);
const MAX_VISIBLE = 3;
// eslint-disable-next-line react-refresh/only-export-components // eslint-disable-next-line react-refresh/only-export-components
export function useToast() { export function useToast() {
const context = useContext(ToastContext); const context = useContext(ToastContext);
@@ -40,22 +42,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]); const [toasts, setToasts] = useState<Toast[]>([]);
const timersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map()); const timersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(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) => { const removeToast = useCallback((id: number) => {
// Clear timer when manually removing
const timer = timersRef.current.get(id); const timer = timersRef.current.get(id);
if (timer) { if (timer) {
clearTimeout(timer); clearTimeout(timer);
@@ -64,6 +51,37 @@ export function ToastProvider({ children }: { children: ReactNode }) {
setToasts((prev) => prev.filter((t) => t.id !== id)); 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 // Cleanup all timers on unmount
useEffect(() => { useEffect(() => {
const timers = timersRef.current; const timers = timersRef.current;
@@ -77,8 +95,8 @@ export function ToastProvider({ children }: { children: ReactNode }) {
<ToastContext.Provider value={{ showToast }}> <ToastContext.Provider value={{ showToast }}>
{children} {children}
{/* Toast Container */} {/* Toast Container — safe area aware, adaptive width */}
<div className="pointer-events-none fixed right-4 top-4 z-[100] flex flex-col gap-3"> <div className="pointer-events-none fixed left-4 right-4 top-[calc(1rem+env(safe-area-inset-top,0px))] z-[100] flex flex-col gap-3 sm:left-auto sm:right-[calc(1rem+env(safe-area-inset-right,0px))]">
{toasts.map((toast) => ( {toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} onClose={() => removeToast(toast.id)} /> <ToastItem key={toast.id} toast={toast} onClose={() => removeToast(toast.id)} />
))} ))}
@@ -89,40 +107,34 @@ export function ToastProvider({ children }: { children: ReactNode }) {
function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) { function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
const handleClick = () => { const handleClick = () => {
if (toast.onClick) { toast.onClick?.();
toast.onClick(); onClose();
onClose();
}
}; };
const typeStyles = { const typeStyles = {
success: { success: {
bg: 'bg-dark-800', border: 'border-l-success-500',
accent: 'bg-gradient-to-r from-success-500/50 to-transparent',
border: 'border-success-500/70',
icon: 'text-success-400', icon: 'text-success-400',
iconBg: 'bg-success-500/30', iconBg: 'bg-success-500/20',
progress: 'bg-success-400',
}, },
error: { error: {
bg: 'bg-dark-800', border: 'border-l-error-500',
accent: 'bg-gradient-to-r from-error-500/50 to-transparent',
border: 'border-error-500/70',
icon: 'text-error-400', icon: 'text-error-400',
iconBg: 'bg-error-500/30', iconBg: 'bg-error-500/20',
progress: 'bg-error-400',
}, },
warning: { warning: {
bg: 'bg-dark-800', border: 'border-l-warning-500',
accent: 'bg-gradient-to-r from-warning-500/50 to-transparent',
border: 'border-warning-500/70',
icon: 'text-warning-400', icon: 'text-warning-400',
iconBg: 'bg-warning-500/30', iconBg: 'bg-warning-500/20',
progress: 'bg-warning-400',
}, },
info: { info: {
bg: 'bg-dark-800', border: 'border-l-accent-500',
accent: 'bg-gradient-to-r from-accent-500/50 to-transparent',
border: 'border-accent-500/70',
icon: 'text-accent-400', 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 ( return (
<div <div
className={`pointer-events-auto w-80 sm:w-96 ${style.bg} border ${style.border} animate-slide-in-right overflow-hidden rounded-2xl shadow-2xl shadow-black/40 ${toast.onClick ? 'cursor-pointer hover:scale-[1.02] active:scale-[0.98]' : ''} transition-transform duration-200`} className={`pointer-events-auto w-full cursor-pointer border border-l-4 border-dark-700 ${style.border} animate-slide-in-right overflow-hidden rounded-2xl bg-dark-900 shadow-2xl shadow-black/50 backdrop-blur-xl transition-transform duration-200 hover:scale-[1.02] active:scale-[0.98] sm:max-w-sm`}
onClick={handleClick} onClick={handleClick}
> >
{/* Color accent */}
<div className={`absolute inset-0 ${style.accent}`} />
<div className="relative p-4"> <div className="relative p-4">
<div className="flex gap-3"> <div className="flex gap-3">
{/* Icon */} {/* Icon */}
<div <div
className={`h-10 w-10 flex-shrink-0 rounded-xl ${style.iconBg} flex items-center justify-center ${style.icon}`} className={`flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl ${style.iconBg} ${style.icon}`}
> >
{toast.icon || defaultIcons[toast.type || 'info']} {toast.icon || defaultIcons[toast.type || 'info']}
</div> </div>
@@ -207,31 +216,12 @@ function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
)} )}
<p className="text-sm leading-relaxed text-dark-300">{toast.message}</p> <p className="text-sm leading-relaxed text-dark-300">{toast.message}</p>
</div> </div>
{/* Close button */}
<button
onClick={(e) => {
e.stopPropagation();
onClose();
}}
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-lg text-dark-500 transition-colors hover:bg-dark-700/50 hover:text-dark-300"
>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div> </div>
{/* Progress bar */} {/* Progress bar */}
<div className="absolute bottom-0 left-0 right-0 h-1 bg-dark-800/50"> <div className="absolute bottom-0 left-0 right-0 h-1 bg-dark-800/50">
<div <div
className={`h-full ${style.icon.replace('text-', 'bg-')} opacity-60`} className={`h-full ${style.progress} opacity-60`}
style={{ style={{
animation: `shrink ${toast.duration}ms linear forwards`, animation: `shrink ${toast.duration}ms linear forwards`,
}} }}

View File

@@ -169,10 +169,6 @@ function TemplateEditor({
const [editSubject, setEditSubject] = useState(''); const [editSubject, setEditSubject] = useState('');
const [editBody, setEditBody] = useState(''); const [editBody, setEditBody] = useState('');
const [isDirty, setIsDirty] = useState(false); const [isDirty, setIsDirty] = useState(false);
const [toast, setToast] = useState<{
type: 'success' | 'error' | 'info';
message: string;
} | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const langData: EmailTemplateLanguageData | undefined = detail.languages[activeLang]; const langData: EmailTemplateLanguageData | undefined = detail.languages[activeLang];
@@ -186,11 +182,6 @@ function TemplateEditor({
} }
}, [activeLang, langData]); }, [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) // Extract body content from full HTML (strip base template wrapper)
const extractBodyContent = useCallback((html: string): string => { const extractBodyContent = useCallback((html: string): string => {
// If it's wrapped in the base template, extract just the content div // 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], queryKey: ['admin', 'email-template', detail.notification_type],
}); });
setIsDirty(false); setIsDirty(false);
showToast('success', t('admin.emailTemplates.saved')); notify.success(t('admin.emailTemplates.saved'));
}, },
onError: () => { onError: () => {
showToast('error', t('common.error')); notify.error(t('common.error'));
}, },
}); });
@@ -246,10 +237,10 @@ function TemplateEditor({
queryKey: ['admin', 'email-template', detail.notification_type], queryKey: ['admin', 'email-template', detail.notification_type],
}); });
setIsDirty(false); setIsDirty(false);
showToast('success', t('admin.emailTemplates.resetted')); notify.success(t('admin.emailTemplates.resetted'));
}, },
onError: () => { onError: () => {
showToast('error', t('common.error')); notify.error(t('common.error'));
}, },
}); });
@@ -260,10 +251,10 @@ function TemplateEditor({
language: activeLang, language: activeLang,
}), }),
onSuccess: (data) => { onSuccess: (data) => {
showToast('success', `${t('admin.emailTemplates.testSent')}${data.sent_to}`); notify.success(`${t('admin.emailTemplates.testSent')}${data.sent_to}`);
}, },
onError: () => { onError: () => {
showToast('error', t('common.error')); notify.error(t('common.error'));
}, },
}); });
@@ -360,7 +351,7 @@ function TemplateEditor({
title={t('admin.emailTemplates.clickToCopy')} title={t('admin.emailTemplates.clickToCopy')}
onClick={() => { onClick={() => {
navigator.clipboard.writeText(`{${v}}`); navigator.clipboard.writeText(`{${v}}`);
showToast('success', `Copied {${v}}`); notify.success(`Copied {${v}}`);
}} }}
> >
{`{${v}}`} {`{${v}}`}
@@ -448,21 +439,6 @@ function TemplateEditor({
)} )}
</div> </div>
</div> </div>
{/* Toast */}
{toast && (
<div
className={`fixed left-4 right-4 top-32 z-[100] mx-auto max-w-sm animate-fade-in rounded-xl px-4 py-3 text-center text-sm font-medium shadow-lg sm:left-1/2 sm:right-auto sm:-translate-x-1/2 ${
toast.type === 'success'
? 'bg-success-500 text-white'
: toast.type === 'info'
? 'bg-dark-700 text-dark-100 ring-1 ring-dark-600'
: 'bg-error-500 text-white'
}`}
>
{toast.message}
</div>
)}
</div> </div>
); );
} }

View File

@@ -2,9 +2,9 @@ import { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useCurrency } from '../hooks/useCurrency'; import { useCurrency } from '../hooks/useCurrency';
import { useToast } from '../components/Toast';
import { adminUsersApi, type UserListItem, type UsersStatsResponse } from '../api/adminUsers'; import { adminUsersApi, type UserListItem, type UsersStatsResponse } from '../api/adminUsers';
import { usePlatform } from '../platform/hooks/usePlatform'; import { usePlatform } from '../platform/hooks/usePlatform';
import { useNotify } from '../platform/hooks/useNotify';
// ============ Icons ============ // ============ Icons ============
@@ -394,7 +394,7 @@ export default function AdminUsers() {
const { t } = useTranslation(); const { t } = useTranslation();
const { formatWithCurrency } = useCurrency(); const { formatWithCurrency } = useCurrency();
const navigate = useNavigate(); const navigate = useNavigate();
const { showToast } = useToast(); const notify = useNotify();
const { capabilities } = usePlatform(); const { capabilities } = usePlatform();
const [users, setUsers] = useState<UserListItem[]>([]); const [users, setUsers] = useState<UserListItem[]>([]);
@@ -492,27 +492,15 @@ export default function AdminUsers() {
} }
if (result.success) { if (result.success) {
showToast({ notify.success(t(`admin.users.userActions.success.${action}`), t('common.success'));
type: 'success',
title: t('common.success'),
message: t(`admin.users.userActions.success.${action}`),
});
loadUsers(); loadUsers();
loadStats(); loadStats();
} else { } else {
showToast({ notify.error(result.message || t('admin.users.userActions.error'), t('common.error'));
type: 'error',
title: t('common.error'),
message: result.message || t('admin.users.userActions.error'),
});
} }
} catch (error) { } catch (error) {
console.error('Action failed:', error); console.error('Action failed:', error);
showToast({ notify.error(t('admin.users.userActions.error'), t('common.error'));
type: 'error',
title: t('common.error'),
message: t('admin.users.userActions.error'),
});
} finally { } finally {
setActionLoading(false); setActionLoading(false);
closeConfirmModal(); closeConfirmModal();