mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
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:
@@ -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`,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user