Compare commits
No commits in common. "27abaa3e050c306a1af9b348873f876b4afd3d56" and "71e19bfe43bbe0f2089afc88196c929f576c4f62" have entirely different histories.
27abaa3e05
...
71e19bfe43
@ -1,27 +1,55 @@
|
|||||||
import { Suspense } from 'react';
|
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||||
import { RouterProvider } from 'react-router-dom';
|
|
||||||
import { Toaster } from 'react-hot-toast';
|
import { Toaster } from 'react-hot-toast';
|
||||||
import { router } from './routes/adminRoutes';
|
import Layout from './components/Layout';
|
||||||
|
import Dashboard from './pages/Dashboard';
|
||||||
function SuspenseFallback() {
|
import Login from './pages/Login';
|
||||||
return (
|
import ProtectedRoute from './components/ProtectedRoute';
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 font-vazir" dir="rtl">
|
import Users from './pages/Users';
|
||||||
<div className="flex flex-col items-center gap-3">
|
import Products from './pages/Products';
|
||||||
<div className="w-10 h-10 border-4 border-purple-600 border-t-transparent rounded-full animate-spin"></div>
|
import Orders from './pages/Orders';
|
||||||
<span className="text-sm font-bold text-gray-500">در حال بارگذاری ماژول ادمین...</span>
|
import Coupons from './pages/Coupons';
|
||||||
</div>
|
import Settings from './pages/Settings';
|
||||||
</div>
|
import Reports from './pages/Reports';
|
||||||
);
|
import Categories from './pages/Categories';
|
||||||
}
|
import Blogs from './pages/Blogs';
|
||||||
|
import Wiki from './pages/Wiki';
|
||||||
|
import Pets from './pages/Pets';
|
||||||
|
import UITexts from './pages/UITexts';
|
||||||
|
import Media from './pages/Media';
|
||||||
|
import CMS from './pages/CMS';
|
||||||
|
import WholesaleApplications from './pages/WholesaleApplications';
|
||||||
|
import Videos from './pages/Videos';
|
||||||
|
import ContactSubmissions from './pages/ContactSubmissions';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<>
|
<BrowserRouter>
|
||||||
<Toaster position="top-center" toastOptions={{ className: 'font-vazir' }} />
|
<Toaster position="top-center" toastOptions={{ className: 'font-vazir' }} />
|
||||||
<Suspense fallback={<SuspenseFallback />}>
|
<Routes>
|
||||||
<RouterProvider router={router} />
|
<Route path="/login" element={<Login />} />
|
||||||
</Suspense>
|
<Route element={<ProtectedRoute />}>
|
||||||
</>
|
<Route path="/" element={<Layout />}>
|
||||||
|
<Route index element={<Dashboard />} />
|
||||||
|
<Route path="users" element={<Users />} />
|
||||||
|
<Route path="products" element={<Products />} />
|
||||||
|
<Route path="orders" element={<Orders />} />
|
||||||
|
<Route path="coupons" element={<Coupons />} />
|
||||||
|
<Route path="settings" element={<Settings />} />
|
||||||
|
<Route path="reports" element={<Reports />} />
|
||||||
|
<Route path="categories" element={<Categories />} />
|
||||||
|
<Route path="blogs" element={<Blogs />} />
|
||||||
|
<Route path="wiki" element={<Wiki />} />
|
||||||
|
<Route path="videos" element={<Videos />} />
|
||||||
|
<Route path="pets" element={<Pets />} />
|
||||||
|
<Route path="ui-texts" element={<UITexts />} />
|
||||||
|
<Route path="media" element={<Media />} />
|
||||||
|
<Route path="cms" element={<CMS />} />
|
||||||
|
<Route path="wholesale" element={<WholesaleApplications />} />
|
||||||
|
<Route path="contact" element={<ContactSubmissions />} />
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,75 +1,43 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Navigate, Outlet } from 'react-router-dom';
|
import { Navigate, Outlet } from 'react-router-dom';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
import { useAdminAuthStore } from '../store/adminAuthStore';
|
|
||||||
import type { ApiResponse, AuthResponseData, AdminUser } from '../types/admin';
|
|
||||||
|
|
||||||
export default function ProtectedRoute() {
|
export default function ProtectedRoute() {
|
||||||
const { isAuthenticated, accessToken, setAuth, clearAuth } = useAdminAuthStore();
|
const token = localStorage.getItem('adminToken');
|
||||||
const [isValidating, setIsValidating] = useState(true);
|
const [isValidating, setIsValidating] = useState(true);
|
||||||
|
const [isValid, setIsValid] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
if (!token) {
|
||||||
|
setIsValid(false);
|
||||||
|
setIsValidating(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const validateSession = async () => {
|
api.get('/users/profile')
|
||||||
if (isAuthenticated && accessToken) {
|
.then(() => {
|
||||||
|
setIsValid(true);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
localStorage.removeItem('adminToken');
|
||||||
|
setIsValid(false);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
setIsValidating(false);
|
setIsValidating(false);
|
||||||
return;
|
});
|
||||||
}
|
}, [token]);
|
||||||
|
|
||||||
try {
|
if (!token || !isValid) {
|
||||||
const response = await api.post<ApiResponse<AuthResponseData>>('/auth/refresh');
|
return <Navigate to="/login" replace />;
|
||||||
if (response.data?.success && response.data.data?.accessToken) {
|
}
|
||||||
if (isMounted) {
|
|
||||||
setAuth(response.data.data.accessToken, response.data.data.user);
|
|
||||||
setIsValidating(false);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Fallback profile check if token already in memory
|
|
||||||
}
|
|
||||||
|
|
||||||
if (accessToken) {
|
|
||||||
try {
|
|
||||||
const profileRes = await api.get<ApiResponse<AdminUser>>('/users/profile');
|
|
||||||
if (profileRes.data?.data && isMounted) {
|
|
||||||
setAuth(accessToken, profileRes.data.data);
|
|
||||||
setIsValidating(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Token invalid
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isMounted) {
|
|
||||||
clearAuth();
|
|
||||||
setIsValidating(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
validateSession();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isMounted = false;
|
|
||||||
};
|
|
||||||
}, [isAuthenticated, accessToken, setAuth, clearAuth]);
|
|
||||||
|
|
||||||
if (isValidating) {
|
if (isValidating) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 font-vazir" dir="rtl">
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="w-8 h-8 border-4 border-purple-600 border-t-transparent rounded-full animate-spin"></div>
|
||||||
<div className="w-10 h-10 border-4 border-purple-600 border-t-transparent rounded-full animate-spin"></div>
|
|
||||||
<span className="text-sm font-bold text-gray-500">در حال اعتبارسنجی نشست کاری ادمین...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!useAdminAuthStore.getState().isAuthenticated) {
|
|
||||||
return <Navigate to="/login" replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Outlet />;
|
return <Outlet />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { Users, ShoppingCart, Tag, Settings, LogOut, TrendingUp, FolderTree, FileText, BookOpen, Heart, Package, LayoutDashboard, Languages, Image, Video, PhoneCall } from 'lucide-react';
|
import { Users, ShoppingCart, Tag, Settings, LogOut, TrendingUp, FolderTree, FileText, BookOpen, Heart, Package, LayoutDashboard, Languages, Image, Video, PhoneCall } from 'lucide-react';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
import { useAdminAuthStore } from '../store/adminAuthStore';
|
|
||||||
|
|
||||||
const menuGroups = [
|
const menuGroups = [
|
||||||
{
|
{
|
||||||
@ -58,10 +57,8 @@ export default function Sidebar({ isOpen, setIsOpen }: SidebarProps) {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [newOrdersCount, setNewOrdersCount] = useState(0);
|
const [newOrdersCount, setNewOrdersCount] = useState(0);
|
||||||
const clearAuth = useAdminAuthStore((state) => state.clearAuth);
|
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
||||||
|
|
||||||
useState(() => {
|
useEffect(() => {
|
||||||
const fetchOrdersCount = async () => {
|
const fetchOrdersCount = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/admin/dashboard/stats');
|
const response = await api.get('/admin/dashboard/stats');
|
||||||
@ -73,22 +70,14 @@ export default function Sidebar({ isOpen, setIsOpen }: SidebarProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchOrdersCount();
|
fetchOrdersCount();
|
||||||
intervalRef.current = setInterval(fetchOrdersCount, 60000);
|
// Poll stats every 60 seconds
|
||||||
return () => {
|
const interval = setInterval(fetchOrdersCount, 60000);
|
||||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
return () => clearInterval(interval);
|
||||||
};
|
}, []);
|
||||||
});
|
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = () => {
|
||||||
try {
|
localStorage.removeItem('adminToken');
|
||||||
await api.post('/auth/logout');
|
navigate('/login');
|
||||||
} catch {
|
|
||||||
// Ignore logout errors
|
|
||||||
} finally {
|
|
||||||
clearAuth();
|
|
||||||
localStorage.removeItem('adminToken');
|
|
||||||
navigate('/login');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -2,8 +2,6 @@ import { useState, useRef, useEffect } from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Bell, Search, UserCircle, Menu } from 'lucide-react';
|
import { Bell, Search, UserCircle, Menu } from 'lucide-react';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import api from '../services/api';
|
|
||||||
import { useAdminAuthStore } from '../store/adminAuthStore';
|
|
||||||
|
|
||||||
interface TopbarProps {
|
interface TopbarProps {
|
||||||
toggleMenu: () => void;
|
toggleMenu: () => void;
|
||||||
@ -30,9 +28,8 @@ export default function Topbar({ toggleMenu }: TopbarProps) {
|
|||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [showResults, setShowResults] = useState(false);
|
const [showResults, setShowResults] = useState(false);
|
||||||
const searchContainerRef = useRef<HTMLDivElement>(null);
|
const searchContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const clearAuth = useAdminAuthStore((state) => state.clearAuth);
|
|
||||||
const adminUser = useAdminAuthStore((state) => state.adminUser);
|
|
||||||
|
|
||||||
|
// Close search results dropdown on clicking outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
if (searchContainerRef.current && !searchContainerRef.current.contains(event.target as Node)) {
|
if (searchContainerRef.current && !searchContainerRef.current.contains(event.target as Node)) {
|
||||||
@ -71,23 +68,12 @@ export default function Topbar({ toggleMenu }: TopbarProps) {
|
|||||||
return () => document.removeEventListener('mousedown', handleOutsideClick);
|
return () => document.removeEventListener('mousedown', handleOutsideClick);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = () => {
|
||||||
try {
|
localStorage.removeItem('adminToken');
|
||||||
await api.post('/auth/logout');
|
toast.success('خروج با موفقیت انجام شد');
|
||||||
} catch {
|
navigate('/login');
|
||||||
// Ignore
|
|
||||||
} finally {
|
|
||||||
clearAuth();
|
|
||||||
localStorage.removeItem('adminToken');
|
|
||||||
toast.success('خروج با موفقیت انجام شد');
|
|
||||||
navigate('/login');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const displayName = adminUser
|
|
||||||
? `${adminUser.firstName || ''} ${adminUser.lastName || ''}`.trim() || adminUser.email || adminUser.mobile
|
|
||||||
: 'مدیر سیستم';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-4 sm:px-6 sticky top-0 z-40 shadow-sm font-vazir">
|
<header className="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-4 sm:px-6 sticky top-0 z-40 shadow-sm font-vazir">
|
||||||
<div className="flex-1 flex items-center gap-3 max-w-xl" ref={searchContainerRef}>
|
<div className="flex-1 flex items-center gap-3 max-w-xl" ref={searchContainerRef}>
|
||||||
@ -113,6 +99,7 @@ export default function Topbar({ toggleMenu }: TopbarProps) {
|
|||||||
className="w-full bg-gray-50 border border-gray-200 text-gray-900 text-sm rounded-xl focus:ring-purple-500 focus:border-purple-500 block pr-10 p-2.5 outline-none font-bold transition-all"
|
className="w-full bg-gray-50 border border-gray-200 text-gray-900 text-sm rounded-xl focus:ring-purple-500 focus:border-purple-500 block pr-10 p-2.5 outline-none font-bold transition-all"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Live Search Results Dropdown */}
|
||||||
{showResults && searchQuery.trim() !== '' && (
|
{showResults && searchQuery.trim() !== '' && (
|
||||||
<div className="absolute top-full right-0 left-0 mt-2 bg-white border border-gray-100 rounded-xl shadow-2xl z-50 overflow-hidden max-h-60 overflow-y-auto">
|
<div className="absolute top-full right-0 left-0 mt-2 bg-white border border-gray-100 rounded-xl shadow-2xl z-50 overflow-hidden max-h-60 overflow-y-auto">
|
||||||
{filteredResults.length > 0 ? (
|
{filteredResults.length > 0 ? (
|
||||||
@ -137,6 +124,7 @@ export default function Topbar({ toggleMenu }: TopbarProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Realtime Notifications (TASK-2.20) */}
|
||||||
<div className="relative" ref={notifRef}>
|
<div className="relative" ref={notifRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowNotifications(!showNotifications)}
|
onClick={() => setShowNotifications(!showNotifications)}
|
||||||
@ -171,14 +159,15 @@ export default function Topbar({ toggleMenu }: TopbarProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* User Profile Dropdown (TASK-2.19) */}
|
||||||
<div className="relative" ref={userMenuRef}>
|
<div className="relative" ref={userMenuRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowUserMenu(!showUserMenu)}
|
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||||
className="flex items-center gap-3 pl-2 border-l border-gray-200 cursor-pointer hover:opacity-80 transition-opacity"
|
className="flex items-center gap-3 pl-2 border-l border-gray-200 cursor-pointer hover:opacity-80 transition-opacity"
|
||||||
>
|
>
|
||||||
<div className="text-left hidden md:block">
|
<div className="text-left hidden md:block">
|
||||||
<p className="text-sm font-bold text-gray-900">{displayName}</p>
|
<p className="text-sm font-bold text-gray-900">مدیر سیستم</p>
|
||||||
<p className="text-xs font-medium text-purple-600">{adminUser?.role || 'ادمین ارشد'}</p>
|
<p className="text-xs font-medium text-purple-600">ادمین ارشد</p>
|
||||||
</div>
|
</div>
|
||||||
<UserCircle className="w-10 h-10 text-purple-600" />
|
<UserCircle className="w-10 h-10 text-purple-600" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -1,36 +1,25 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Lock, Mail, Phone, ChevronLeft, KeyRound } from 'lucide-react';
|
import { Lock, Mail, ChevronLeft } from 'lucide-react';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
import { useAdminAuthStore } from '../store/adminAuthStore';
|
|
||||||
import type { ApiResponse, AuthResponseData } from '../types/admin';
|
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const [loginMode, setLoginMode] = useState<'password' | 'otp'>('password');
|
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
|
||||||
const [phoneNumber, setPhoneNumber] = useState('');
|
|
||||||
const [otpCode, setOtpCode] = useState('');
|
|
||||||
const [otpSent, setOtpSent] = useState(false);
|
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const setAuth = useAdminAuthStore((state) => state.setAuth);
|
|
||||||
|
|
||||||
const handlePasswordLogin = async (e: React.FormEvent) => {
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.post<ApiResponse<AuthResponseData>>('/auth/admin-login', { email, password });
|
const response = await api.post('/auth/admin-login', { email, password });
|
||||||
if (response.data?.success && response.data.data) {
|
if (response.data?.success) {
|
||||||
setAuth(response.data.data.accessToken, response.data.data.user);
|
localStorage.setItem('adminToken', response.data.data.accessToken);
|
||||||
toast.success('ورود با موفقیت انجام شد');
|
toast.success('ورود با موفقیت انجام شد');
|
||||||
navigate('/');
|
navigate('/');
|
||||||
} else {
|
|
||||||
toast.error('اطلاعات ورود نامعتبر است');
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('ایمیل یا رمز عبور اشتباه است');
|
toast.error('ایمیل یا رمز عبور اشتباه است');
|
||||||
@ -39,51 +28,6 @@ export default function Login() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSendOtp = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!phoneNumber || phoneNumber.length < 10) {
|
|
||||||
toast.error('شماره موبایل معتبر وارد کنید');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await api.post<ApiResponse>('/auth/send-otp', { phoneNumber });
|
|
||||||
if (response.data?.success) {
|
|
||||||
toast.success(response.data.message || 'کد تایید پیامک شد');
|
|
||||||
setOtpSent(true);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.error('خطا در ارسال کد تایید');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleVerifyOtp = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!otpCode || otpCode.length < 4) {
|
|
||||||
toast.error('کد تایید معتبر وارد کنید');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await api.post<ApiResponse<AuthResponseData>>('/auth/verify-otp', { phoneNumber, code: otpCode });
|
|
||||||
if (response.data?.success && response.data.data) {
|
|
||||||
setAuth(response.data.data.accessToken, response.data.data.user);
|
|
||||||
toast.success('ورود موفقیتآمیز بود');
|
|
||||||
navigate('/');
|
|
||||||
} else {
|
|
||||||
toast.error('کد تایید نامعتبر است');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.error('کد تایید اشتباه یا منقضی شده است');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8 font-vazir" dir="rtl">
|
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8 font-vazir" dir="rtl">
|
||||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||||
@ -100,146 +44,54 @@ export default function Login() {
|
|||||||
|
|
||||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||||
<div className="bg-white py-8 px-4 shadow-xl shadow-gray-100 sm:rounded-3xl sm:px-10 border border-gray-100">
|
<div className="bg-white py-8 px-4 shadow-xl shadow-gray-100 sm:rounded-3xl sm:px-10 border border-gray-100">
|
||||||
{/* Toggle Login Mode */}
|
<form className="space-y-6" onSubmit={handleLogin}>
|
||||||
<div className="flex bg-gray-100 p-1 rounded-xl mb-6">
|
<div>
|
||||||
<button
|
<label className="block text-sm font-black text-gray-700">آدرس ایمیل</label>
|
||||||
type="button"
|
<div className="mt-2 relative">
|
||||||
onClick={() => { setLoginMode('password'); setOtpSent(false); }}
|
<div className="absolute inset-y-0 right-0 pl-3 flex items-center pr-3 pointer-events-none">
|
||||||
className={`flex-1 py-2 text-xs font-black rounded-lg transition-all ${loginMode === 'password' ? 'bg-white text-purple-600 shadow-sm' : 'text-gray-500 hover:text-gray-900'}`}
|
<Mail className="h-5 w-5 text-gray-400" />
|
||||||
>
|
|
||||||
ورود با رمز عبور
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => { setLoginMode('otp'); setOtpSent(false); }}
|
|
||||||
className={`flex-1 py-2 text-xs font-black rounded-lg transition-all ${loginMode === 'otp' ? 'bg-white text-purple-600 shadow-sm' : 'text-gray-500 hover:text-gray-900'}`}
|
|
||||||
>
|
|
||||||
ورود با پیامک (OTP)
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loginMode === 'password' ? (
|
|
||||||
<form className="space-y-6" onSubmit={handlePasswordLogin}>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-black text-gray-700">آدرس ایمیل ادمین</label>
|
|
||||||
<div className="mt-2 relative">
|
|
||||||
<div className="absolute inset-y-0 right-0 pl-3 flex items-center pr-3 pointer-events-none">
|
|
||||||
<Mail className="h-5 w-5 text-gray-400" />
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
required
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
className="block w-full pl-3 pr-10 py-3 border border-gray-200 rounded-xl focus:ring-purple-500 focus:border-purple-500 sm:text-sm bg-gray-50 font-bold transition-all"
|
|
||||||
placeholder="admin@canino-iran.com"
|
|
||||||
dir="ltr"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="block w-full pl-3 pr-10 py-3 border border-gray-200 rounded-xl focus:ring-purple-500 focus:border-purple-500 sm:text-sm bg-gray-50 font-bold transition-all"
|
||||||
|
placeholder="admin@canino-iran.com"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-black text-gray-700">رمز عبور</label>
|
|
||||||
<div className="mt-2 relative">
|
|
||||||
<div className="absolute inset-y-0 right-0 pl-3 flex items-center pr-3 pointer-events-none">
|
|
||||||
<Lock className="h-5 w-5 text-gray-400" />
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
required
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
className="block w-full pl-3 pr-10 py-3 border border-gray-200 rounded-xl focus:ring-purple-500 focus:border-purple-500 sm:text-sm bg-gray-50 font-bold transition-all text-left"
|
|
||||||
placeholder="••••••••"
|
|
||||||
dir="ltr"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isLoading}
|
|
||||||
className="w-full flex justify-center items-center gap-2 py-3 px-4 border border-transparent rounded-xl shadow-sm text-sm font-black text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 transition-all disabled:opacity-50 disabled:cursor-wait"
|
|
||||||
>
|
|
||||||
{isLoading ? 'در حال ورود...' : 'ورود به سیستم'}
|
|
||||||
{!isLoading && <ChevronLeft className="w-5 h-5" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{!otpSent ? (
|
|
||||||
<form className="space-y-6" onSubmit={handleSendOtp}>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-black text-gray-700">شماره تلفن همراه</label>
|
|
||||||
<div className="mt-2 relative">
|
|
||||||
<div className="absolute inset-y-0 right-0 pl-3 flex items-center pr-3 pointer-events-none">
|
|
||||||
<Phone className="h-5 w-5 text-gray-400" />
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="tel"
|
|
||||||
required
|
|
||||||
value={phoneNumber}
|
|
||||||
onChange={(e) => setPhoneNumber(e.target.value)}
|
|
||||||
className="block w-full pl-3 pr-10 py-3 border border-gray-200 rounded-xl focus:ring-purple-500 focus:border-purple-500 sm:text-sm bg-gray-50 font-bold transition-all text-left"
|
|
||||||
placeholder="09123456789"
|
|
||||||
dir="ltr"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isLoading}
|
|
||||||
className="w-full flex justify-center items-center gap-2 py-3 px-4 border border-transparent rounded-xl shadow-sm text-sm font-black text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 transition-all disabled:opacity-50 disabled:cursor-wait"
|
|
||||||
>
|
|
||||||
{isLoading ? 'در حال ارسال کد...' : 'ارسال کد تایید پیامکی'}
|
|
||||||
{!isLoading && <ChevronLeft className="w-5 h-5" />}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<form className="space-y-6" onSubmit={handleVerifyOtp}>
|
|
||||||
<div>
|
|
||||||
<div className="flex justify-between items-center mb-1">
|
|
||||||
<label className="block text-sm font-black text-gray-700">کد تایید ۵ رقمی</label>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setOtpSent(false)}
|
|
||||||
className="text-xs text-purple-600 font-bold hover:underline"
|
|
||||||
>
|
|
||||||
تغییر شماره
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 relative">
|
|
||||||
<div className="absolute inset-y-0 right-0 pl-3 flex items-center pr-3 pointer-events-none">
|
|
||||||
<KeyRound className="h-5 w-5 text-gray-400" />
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
maxLength={6}
|
|
||||||
value={otpCode}
|
|
||||||
onChange={(e) => setOtpCode(e.target.value)}
|
|
||||||
className="block w-full pl-3 pr-10 py-3 border border-gray-200 rounded-xl focus:ring-purple-500 focus:border-purple-500 sm:text-sm bg-gray-50 font-bold transition-all text-center tracking-widest text-lg"
|
|
||||||
placeholder="12345"
|
|
||||||
dir="ltr"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isLoading}
|
|
||||||
className="w-full flex justify-center items-center gap-2 py-3 px-4 border border-transparent rounded-xl shadow-sm text-sm font-black text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 transition-all disabled:opacity-50 disabled:cursor-wait"
|
|
||||||
>
|
|
||||||
{isLoading ? 'در حال تایید...' : 'تایید و ورود به پنل'}
|
|
||||||
{!isLoading && <ChevronLeft className="w-5 h-5" />}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-black text-gray-700">رمز عبور</label>
|
||||||
|
<div className="mt-2 relative">
|
||||||
|
<div className="absolute inset-y-0 right-0 pl-3 flex items-center pr-3 pointer-events-none">
|
||||||
|
<Lock className="h-5 w-5 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="block w-full pl-3 pr-10 py-3 border border-gray-200 rounded-xl focus:ring-purple-500 focus:border-purple-500 sm:text-sm bg-gray-50 font-bold transition-all text-left"
|
||||||
|
placeholder="••••••••"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full flex justify-center items-center gap-2 py-3 px-4 border border-transparent rounded-xl shadow-sm text-sm font-black text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 transition-all disabled:opacity-50 disabled:cursor-wait"
|
||||||
|
>
|
||||||
|
{isLoading ? 'در حال ورود...' : 'ورود به سیستم'}
|
||||||
|
{!isLoading && <ChevronLeft className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -44,8 +44,6 @@ export interface Product {
|
|||||||
videoUrl?: string;
|
videoUrl?: string;
|
||||||
pdfUrl?: string;
|
pdfUrl?: string;
|
||||||
symptoms?: Array<{ symptom: string } | string>;
|
symptoms?: Array<{ symptom: string } | string>;
|
||||||
isPreorder?: boolean;
|
|
||||||
preorderDeposit?: string | number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Products() {
|
export default function Products() {
|
||||||
@ -216,8 +214,8 @@ export default function Products() {
|
|||||||
videoUrl: product.videoUrl || '',
|
videoUrl: product.videoUrl || '',
|
||||||
pdfUrl: product.pdfUrl || '',
|
pdfUrl: product.pdfUrl || '',
|
||||||
symptoms: product.symptoms ? product.symptoms.map((s: { symptom: string } | string) => typeof s === 'string' ? s : s.symptom) : [],
|
symptoms: product.symptoms ? product.symptoms.map((s: { symptom: string } | string) => typeof s === 'string' ? s : s.symptom) : [],
|
||||||
isPreorder: Boolean(product.isPreorder),
|
isPreorder: Boolean((product as any).isPreorder),
|
||||||
preorderDeposit: product.preorderDeposit || ''
|
preorderDeposit: (product as any).preorderDeposit || ''
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setEditingProduct(null);
|
setEditingProduct(null);
|
||||||
|
|||||||
@ -6,8 +6,6 @@ import Skeleton from '../components/ui/Skeleton';
|
|||||||
import Spinner from '../components/ui/Spinner';
|
import Spinner from '../components/ui/Spinner';
|
||||||
import Pagination from '../components/ui/Pagination';
|
import Pagination from '../components/ui/Pagination';
|
||||||
|
|
||||||
import type { UserAddress, PetSummary } from '../types/admin';
|
|
||||||
|
|
||||||
export interface UserRecord {
|
export interface UserRecord {
|
||||||
id: string;
|
id: string;
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
@ -18,8 +16,8 @@ export interface UserRecord {
|
|||||||
status?: string;
|
status?: string;
|
||||||
walletBalance?: number;
|
walletBalance?: number;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
addresses?: UserAddress[];
|
addresses?: any[];
|
||||||
pets?: PetSummary[];
|
pets?: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Users() {
|
export default function Users() {
|
||||||
@ -292,7 +290,7 @@ export default function Users() {
|
|||||||
<h4 className="font-bold text-gray-900 mb-2">آدرسهای ثبتشده ({viewUser.addresses?.length || 0})</h4>
|
<h4 className="font-bold text-gray-900 mb-2">آدرسهای ثبتشده ({viewUser.addresses?.length || 0})</h4>
|
||||||
{viewUser.addresses && viewUser.addresses.length > 0 ? (
|
{viewUser.addresses && viewUser.addresses.length > 0 ? (
|
||||||
<div className="space-y-2 max-h-32 overflow-y-auto">
|
<div className="space-y-2 max-h-32 overflow-y-auto">
|
||||||
{viewUser.addresses.map((a: UserAddress, idx: number) => (
|
{viewUser.addresses.map((a: any, idx: number) => (
|
||||||
<div key={idx} className="p-2.5 bg-gray-50 rounded-lg border border-gray-200/60">
|
<div key={idx} className="p-2.5 bg-gray-50 rounded-lg border border-gray-200/60">
|
||||||
<p className="font-bold text-gray-800">{a.title} - {a.receptorName}</p>
|
<p className="font-bold text-gray-800">{a.title} - {a.receptorName}</p>
|
||||||
<p className="text-gray-500 mt-0.5">{a.province}، {a.city}، {a.detail}</p>
|
<p className="text-gray-500 mt-0.5">{a.province}، {a.city}، {a.detail}</p>
|
||||||
|
|||||||
@ -1,70 +0,0 @@
|
|||||||
import { lazy } from 'react';
|
|
||||||
import type { ComponentType } from 'react';
|
|
||||||
import { createBrowserRouter, Navigate } from 'react-router-dom';
|
|
||||||
import Layout from '../components/Layout';
|
|
||||||
import ProtectedRoute from '../components/ProtectedRoute';
|
|
||||||
import Login from '../pages/Login';
|
|
||||||
|
|
||||||
// Lazy loading admin pages
|
|
||||||
const Dashboard = lazy(() => import('../pages/Dashboard'));
|
|
||||||
const Users = lazy(() => import('../pages/Users'));
|
|
||||||
const Products = lazy(() => import('../pages/Products'));
|
|
||||||
const Orders = lazy(() => import('../pages/Orders'));
|
|
||||||
const Coupons = lazy(() => import('../pages/Coupons'));
|
|
||||||
const Settings = lazy(() => import('../pages/Settings'));
|
|
||||||
const Reports = lazy(() => import('../pages/Reports'));
|
|
||||||
const Categories = lazy(() => import('../pages/Categories'));
|
|
||||||
const Blogs = lazy(() => import('../pages/Blogs'));
|
|
||||||
const Wiki = lazy(() => import('../pages/Wiki'));
|
|
||||||
const Pets = lazy(() => import('../pages/Pets'));
|
|
||||||
const UITexts = lazy(() => import('../pages/UITexts'));
|
|
||||||
const Media = lazy(() => import('../pages/Media'));
|
|
||||||
const CMS = lazy(() => import('../pages/CMS'));
|
|
||||||
const WholesaleApplications = lazy(() => import('../pages/WholesaleApplications'));
|
|
||||||
const Videos = lazy(() => import('../pages/Videos'));
|
|
||||||
const ContactSubmissions = lazy(() => import('../pages/ContactSubmissions'));
|
|
||||||
|
|
||||||
export interface AdminRouteConfig {
|
|
||||||
path: string;
|
|
||||||
component: ComponentType;
|
|
||||||
index?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
|
||||||
{
|
|
||||||
path: '/login',
|
|
||||||
element: <Login />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
element: <ProtectedRoute />,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
path: '/',
|
|
||||||
element: <Layout />,
|
|
||||||
children: [
|
|
||||||
{ index: true, element: <Dashboard /> },
|
|
||||||
{ path: 'users/*', element: <Users /> },
|
|
||||||
{ path: 'products/*', element: <Products /> },
|
|
||||||
{ path: 'orders/*', element: <Orders /> },
|
|
||||||
{ path: 'coupons/*', element: <Coupons /> },
|
|
||||||
{ path: 'settings/*', element: <Settings /> },
|
|
||||||
{ path: 'reports/*', element: <Reports /> },
|
|
||||||
{ path: 'categories/*', element: <Categories /> },
|
|
||||||
{ path: 'blogs/*', element: <Blogs /> },
|
|
||||||
{ path: 'wiki/*', element: <Wiki /> },
|
|
||||||
{ path: 'videos/*', element: <Videos /> },
|
|
||||||
{ path: 'pets/*', element: <Pets /> },
|
|
||||||
{ path: 'ui-texts/*', element: <UITexts /> },
|
|
||||||
{ path: 'media/*', element: <Media /> },
|
|
||||||
{ path: 'cms/*', element: <CMS /> },
|
|
||||||
{ path: 'wholesale/*', element: <WholesaleApplications /> },
|
|
||||||
{ path: 'contact/*', element: <ContactSubmissions /> },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '*',
|
|
||||||
element: <Navigate to="/" replace />,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
@ -1,8 +1,5 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import type { AxiosError, InternalAxiosRequestConfig } from 'axios';
|
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { useAdminAuthStore } from '../store/adminAuthStore';
|
|
||||||
import type { ApiResponse, AuthResponseData } from '../types/admin';
|
|
||||||
|
|
||||||
export const BASE_DOMAIN = import.meta.env.VITE_API_URL
|
export const BASE_DOMAIN = import.meta.env.VITE_API_URL
|
||||||
? import.meta.env.VITE_API_URL.replace(/\/api$/, '')
|
? import.meta.env.VITE_API_URL.replace(/\/api$/, '')
|
||||||
@ -22,31 +19,13 @@ export interface ApiErrorPayload {
|
|||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL,
|
baseURL,
|
||||||
withCredentials: true,
|
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
let isRefreshing = false;
|
api.interceptors.request.use((config) => {
|
||||||
let failedQueue: Array<{
|
const token = localStorage.getItem('adminToken');
|
||||||
resolve: (token: string) => void;
|
|
||||||
reject: (reason?: unknown) => void;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
const processQueue = (error: unknown, token: string | null = null) => {
|
|
||||||
failedQueue.forEach((prom) => {
|
|
||||||
if (error) {
|
|
||||||
prom.reject(error);
|
|
||||||
} else if (token) {
|
|
||||||
prom.resolve(token);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
failedQueue = [];
|
|
||||||
};
|
|
||||||
|
|
||||||
api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
|
||||||
const token = useAdminAuthStore.getState().accessToken;
|
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
@ -55,70 +34,20 @@ api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
|||||||
|
|
||||||
api.interceptors.response.use(
|
api.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
async (error: AxiosError<ApiErrorPayload>) => {
|
(error) => {
|
||||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
const responseData = error.response?.data as ApiErrorPayload | undefined;
|
||||||
const responseData = error.response?.data;
|
|
||||||
const status = error.response?.status;
|
const status = error.response?.status;
|
||||||
const farsiMessage = responseData?.message || 'خطایی در ارتباط با سرور رخ داده است.';
|
const farsiMessage = responseData?.message || 'خطایی در ارتباط با سرور رخ داده است.';
|
||||||
|
|
||||||
if (status === 401 && originalRequest && !originalRequest._retry) {
|
if (status === 401) {
|
||||||
if (originalRequest.url?.includes('/auth/refresh') || originalRequest.url?.includes('/auth/admin-login') || originalRequest.url?.includes('/auth/verify-otp')) {
|
localStorage.removeItem('adminToken');
|
||||||
useAdminAuthStore.getState().clearAuth();
|
if (window.location.pathname !== '/login') {
|
||||||
if (window.location.pathname !== '/login') {
|
toast.error('نشست کاری شما منقضی شده است. لطفاً مجدداً وارد شوید.');
|
||||||
toast.error('نشست کاری شما منقضی شده است. لطفاً مجدداً وارد شوید.');
|
window.location.href = '/login';
|
||||||
window.location.href = '/login';
|
|
||||||
}
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
}
|
||||||
|
} else if (status === 403) {
|
||||||
if (isRefreshing) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
failedQueue.push({ resolve, reject });
|
|
||||||
})
|
|
||||||
.then((token) => {
|
|
||||||
originalRequest.headers.Authorization = `Bearer ${token}`;
|
|
||||||
return api(originalRequest);
|
|
||||||
})
|
|
||||||
.catch((err) => Promise.reject(err));
|
|
||||||
}
|
|
||||||
|
|
||||||
originalRequest._retry = true;
|
|
||||||
isRefreshing = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const refreshResponse = await axios.post<ApiResponse<AuthResponseData>>(
|
|
||||||
`${baseURL}/auth/refresh`,
|
|
||||||
{},
|
|
||||||
{ withCredentials: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (refreshResponse.data?.success && refreshResponse.data.data?.accessToken) {
|
|
||||||
const newToken = refreshResponse.data.data.accessToken;
|
|
||||||
const user = refreshResponse.data.data.user;
|
|
||||||
useAdminAuthStore.getState().setAuth(newToken, user);
|
|
||||||
processQueue(null, newToken);
|
|
||||||
|
|
||||||
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
|
||||||
return api(originalRequest);
|
|
||||||
} else {
|
|
||||||
throw new Error('Refresh failed');
|
|
||||||
}
|
|
||||||
} catch (refreshErr) {
|
|
||||||
processQueue(refreshErr, null);
|
|
||||||
useAdminAuthStore.getState().clearAuth();
|
|
||||||
if (window.location.pathname !== '/login') {
|
|
||||||
toast.error('نشست کاری شما منقضی شده است. لطفاً مجدداً وارد شوید.');
|
|
||||||
window.location.href = '/login';
|
|
||||||
}
|
|
||||||
return Promise.reject(refreshErr);
|
|
||||||
} finally {
|
|
||||||
isRefreshing = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === 403) {
|
|
||||||
toast.error('دسترسی شما به این بخش از پنل ادمین مجاز نمیباشد.');
|
toast.error('دسترسی شما به این بخش از پنل ادمین مجاز نمیباشد.');
|
||||||
} else if (status !== 401) {
|
} else {
|
||||||
if (responseData?.details && Array.isArray(responseData.details) && responseData.details.length > 0) {
|
if (responseData?.details && Array.isArray(responseData.details) && responseData.details.length > 0) {
|
||||||
toast.error(responseData.details[0]?.message || farsiMessage);
|
toast.error(responseData.details[0]?.message || farsiMessage);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -1,22 +0,0 @@
|
|||||||
import { create } from 'zustand';
|
|
||||||
import type { AuthState, AdminUser } from '../types/admin';
|
|
||||||
|
|
||||||
export const useAdminAuthStore = create<AuthState>((set) => ({
|
|
||||||
accessToken: null,
|
|
||||||
adminUser: null,
|
|
||||||
isAuthenticated: false,
|
|
||||||
|
|
||||||
setAuth: (accessToken: string, adminUser: AdminUser) =>
|
|
||||||
set({
|
|
||||||
accessToken,
|
|
||||||
adminUser,
|
|
||||||
isAuthenticated: true,
|
|
||||||
}),
|
|
||||||
|
|
||||||
clearAuth: () =>
|
|
||||||
set({
|
|
||||||
accessToken: null,
|
|
||||||
adminUser: null,
|
|
||||||
isAuthenticated: false,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
@ -1,68 +0,0 @@
|
|||||||
export interface AdminUser {
|
|
||||||
id: string;
|
|
||||||
email?: string | null;
|
|
||||||
mobile: string;
|
|
||||||
firstName?: string | null;
|
|
||||||
lastName?: string | null;
|
|
||||||
role: string;
|
|
||||||
walletBalance?: string | number;
|
|
||||||
charityDonationTotal?: string | number;
|
|
||||||
createdAt?: string;
|
|
||||||
updatedAt?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthState {
|
|
||||||
accessToken: string | null;
|
|
||||||
adminUser: AdminUser | null;
|
|
||||||
isAuthenticated: boolean;
|
|
||||||
setAuth: (accessToken: string, adminUser: AdminUser) => void;
|
|
||||||
clearAuth: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SendOtpPayload {
|
|
||||||
phoneNumber: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VerifyOtpPayload {
|
|
||||||
phoneNumber: string;
|
|
||||||
code: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminLoginPayload {
|
|
||||||
email: string;
|
|
||||||
password?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthResponseData {
|
|
||||||
user: AdminUser;
|
|
||||||
accessToken: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApiResponse<T = unknown> {
|
|
||||||
success: boolean;
|
|
||||||
message?: string;
|
|
||||||
data?: T;
|
|
||||||
code?: string;
|
|
||||||
details?: Array<{ field: string; message: string }> | Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserAddress {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
receptorName: string;
|
|
||||||
phone: string;
|
|
||||||
province: string;
|
|
||||||
city: string;
|
|
||||||
detail: string;
|
|
||||||
zipCode: string;
|
|
||||||
isDefault: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PetSummary {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
breed?: string;
|
|
||||||
age?: number;
|
|
||||||
weight?: number;
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user