feat: replace native confirm() dialogs with custom modal component

- Add ModalProvider context and useConfirm hook (confirm-modal.tsx)
- Support danger/warning/info variants with icons and colors
- Animated backdrop and dialog with CSS keyframes
- Accessible: aria-modal, role=dialog, ESC to close, auto-focus
- Replace all 6 native confirm() calls across dashboard pages
- Integrate ModalProvider in app providers
This commit is contained in:
keyhan
2026-04-07 23:38:55 +03:30
parent 5979b48a61
commit ac5278d73b
10 changed files with 222 additions and 14 deletions
+157
View File
@@ -0,0 +1,157 @@
'use client';
import { createContext, useContext, useState, useCallback, useRef, useEffect } from 'react';
import { AlertTriangle, Trash2, Info, X } from 'lucide-react';
/* ─── Types ────────────────────────────────────────────── */
type ModalVariant = 'danger' | 'warning' | 'info';
interface ConfirmOptions {
title: string;
message: string;
confirmText?: string;
cancelText?: string;
variant?: ModalVariant;
}
interface ModalContextType {
confirm: (options: ConfirmOptions) => Promise<boolean>;
}
/* ─── Context ──────────────────────────────────────────── */
const ModalContext = createContext<ModalContextType | null>(null);
export function useConfirm() {
const ctx = useContext(ModalContext);
if (!ctx) throw new Error('useConfirm must be used within ModalProvider');
return ctx.confirm;
}
/* ─── Provider ─────────────────────────────────────────── */
export function ModalProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<(ConfirmOptions & { open: boolean }) | null>(null);
const resolveRef = useRef<((value: boolean) => void) | null>(null);
const cancelBtnRef = useRef<HTMLButtonElement>(null);
const confirm = useCallback((options: ConfirmOptions): Promise<boolean> => {
return new Promise((resolve) => {
resolveRef.current = resolve;
setState({ ...options, open: true });
});
}, []);
const handleClose = useCallback((result: boolean) => {
setState(null);
resolveRef.current?.(result);
resolveRef.current = null;
}, []);
// Close on Escape key
useEffect(() => {
if (!state?.open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') handleClose(false);
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [state?.open, handleClose]);
// Auto-focus cancel button when modal opens
useEffect(() => {
if (state?.open) {
setTimeout(() => cancelBtnRef.current?.focus(), 50);
}
}, [state?.open]);
const variant = state?.variant || 'danger';
const variantStyles = {
danger: {
icon: <Trash2 className="w-6 h-6 text-red-600" />,
iconBg: 'bg-red-100',
confirmBtn: 'btn-danger',
},
warning: {
icon: <AlertTriangle className="w-6 h-6 text-amber-600" />,
iconBg: 'bg-amber-100',
confirmBtn: 'bg-amber-600 text-white hover:bg-amber-700 focus:ring-amber-500',
},
info: {
icon: <Info className="w-6 h-6 text-primary-600" />,
iconBg: 'bg-primary-100',
confirmBtn: 'btn-primary',
},
};
const style = variantStyles[variant];
return (
<ModalContext.Provider value={{ confirm }}>
{children}
{/* Modal */}
{state?.open && (
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/40 backdrop-blur-sm animate-modal-backdrop"
onClick={() => handleClose(false)}
/>
{/* Dialog */}
<div
className="relative bg-white rounded-2xl shadow-2xl max-w-md w-full p-0 animate-modal-enter"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
{/* Close button */}
<button
onClick={() => handleClose(false)}
className="absolute top-4 right-4 p-1 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors"
>
<X className="w-4 h-4" />
</button>
<div className="p-6 pb-0">
{/* Icon */}
<div className={`w-12 h-12 rounded-xl ${style.iconBg} flex items-center justify-center mb-4`}>
{style.icon}
</div>
{/* Title */}
<h3 id="modal-title" className="text-lg font-bold text-gray-900 mb-2">
{state.title}
</h3>
{/* Message */}
<p className="text-sm text-gray-500 leading-relaxed whitespace-pre-line">
{state.message}
</p>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-3 p-6">
<button
ref={cancelBtnRef}
onClick={() => handleClose(false)}
className="btn-secondary"
>
{state.cancelText || 'Cancel'}
</button>
<button
onClick={() => handleClose(true)}
className={`inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl font-semibold text-sm transition-all duration-150 shadow-sm hover:shadow-md active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-offset-2 ${style.confirmBtn}`}
>
{state.confirmText || 'Confirm'}
</button>
</div>
</div>
</div>
)}
</ModalContext.Provider>
);
}
+4 -1
View File
@@ -5,6 +5,7 @@ import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import { useEffect, useState } from 'react';
import { useAuthStore } from '@/lib/store';
import { ModalProvider } from './confirm-modal';
const queryClient = new QueryClient({
defaultOptions: {
@@ -28,7 +29,9 @@ export function Providers({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
<ModalProvider>
{children}
</ModalProvider>
<ToastContainer position="top-right" autoClose={3000} hideProgressBar={false} closeOnClick pauseOnHover theme="light" />
</QueryClientProvider>
);