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 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<Toast[]>([]);
|
||||
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) => {
|
||||
// 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 }) {
|
||||
<ToastContext.Provider value={{ showToast }}>
|
||||
{children}
|
||||
|
||||
{/* Toast Container */}
|
||||
<div className="pointer-events-none fixed right-4 top-4 z-[100] flex flex-col gap-3">
|
||||
{/* Toast Container — safe area aware, adaptive width */}
|
||||
<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) => (
|
||||
<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 }) {
|
||||
const handleClick = () => {
|
||||
if (toast.onClick) {
|
||||
toast.onClick();
|
||||
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 (
|
||||
<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}
|
||||
>
|
||||
{/* Color accent */}
|
||||
<div className={`absolute inset-0 ${style.accent}`} />
|
||||
|
||||
<div className="relative p-4">
|
||||
<div className="flex gap-3">
|
||||
{/* Icon */}
|
||||
<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']}
|
||||
</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>
|
||||
</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>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-dark-800/50">
|
||||
<div
|
||||
className={`h-full ${style.icon.replace('text-', 'bg-')} opacity-60`}
|
||||
className={`h-full ${style.progress} opacity-60`}
|
||||
style={{
|
||||
animation: `shrink ${toast.duration}ms linear forwards`,
|
||||
}}
|
||||
|
||||
@@ -169,10 +169,6 @@ function TemplateEditor({
|
||||
const [editSubject, setEditSubject] = useState('');
|
||||
const [editBody, setEditBody] = useState('');
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [toast, setToast] = useState<{
|
||||
type: 'success' | 'error' | 'info';
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(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({
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<UserListItem[]>([]);
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user