feat(admin): implement Phase 3 Admin SPA Router and Dual-Token Auth integration

This commit is contained in:
پارسا آقایی 2026-08-06 22:02:39 +03:30
parent 71e19bfe43
commit eaa2621a17
11 changed files with 561 additions and 153 deletions

View File

@ -1,55 +1,27 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { Suspense } from 'react';
import { RouterProvider } from 'react-router-dom';
import { Toaster } from 'react-hot-toast';
import Layout from './components/Layout';
import Dashboard from './pages/Dashboard';
import Login from './pages/Login';
import ProtectedRoute from './components/ProtectedRoute';
import Users from './pages/Users';
import Products from './pages/Products';
import Orders from './pages/Orders';
import Coupons from './pages/Coupons';
import Settings from './pages/Settings';
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';
import { router } from './routes/adminRoutes';
function SuspenseFallback() {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 font-vazir" dir="rtl">
<div className="flex flex-col items-center gap-3">
<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>
);
}
function App() {
return (
<BrowserRouter>
<>
<Toaster position="top-center" toastOptions={{ className: 'font-vazir' }} />
<Routes>
<Route path="/login" element={<Login />} />
<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>
<Suspense fallback={<SuspenseFallback />}>
<RouterProvider router={router} />
</Suspense>
</>
);
}

View File

@ -1,43 +1,75 @@
import { useState, useEffect } from 'react';
import { Navigate, Outlet } from 'react-router-dom';
import api from '../services/api';
import { useAdminAuthStore } from '../store/adminAuthStore';
import type { ApiResponse, AuthResponseData, AdminUser } from '../types/admin';
export default function ProtectedRoute() {
const token = localStorage.getItem('adminToken');
const { isAuthenticated, accessToken, setAuth, clearAuth } = useAdminAuthStore();
const [isValidating, setIsValidating] = useState(true);
const [isValid, setIsValid] = useState(true);
useEffect(() => {
if (!token) {
setIsValid(false);
setIsValidating(false);
return;
}
let isMounted = true;
api.get('/users/profile')
.then(() => {
setIsValid(true);
})
.catch(() => {
localStorage.removeItem('adminToken');
setIsValid(false);
})
.finally(() => {
const validateSession = async () => {
if (isAuthenticated && accessToken) {
setIsValidating(false);
});
}, [token]);
return;
}
if (!token || !isValid) {
return <Navigate to="/login" replace />;
}
try {
const response = await api.post<ApiResponse<AuthResponseData>>('/auth/refresh');
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) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="w-8 h-8 border-4 border-purple-600 border-t-transparent rounded-full animate-spin"></div>
<div className="min-h-screen flex items-center justify-center bg-gray-50 font-vazir" dir="rtl">
<div className="flex flex-col items-center gap-3">
<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>
);
}
if (!useAdminAuthStore.getState().isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <Outlet />;
}

View File

@ -1,7 +1,8 @@
import { useState, useEffect } from 'react';
import { useState, useRef } from 'react';
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 api from '../services/api';
import { useAdminAuthStore } from '../store/adminAuthStore';
const menuGroups = [
{
@ -57,8 +58,10 @@ export default function Sidebar({ isOpen, setIsOpen }: SidebarProps) {
const location = useLocation();
const navigate = useNavigate();
const [newOrdersCount, setNewOrdersCount] = useState(0);
const clearAuth = useAdminAuthStore((state) => state.clearAuth);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
useState(() => {
const fetchOrdersCount = async () => {
try {
const response = await api.get('/admin/dashboard/stats');
@ -70,14 +73,22 @@ export default function Sidebar({ isOpen, setIsOpen }: SidebarProps) {
}
};
fetchOrdersCount();
// Poll stats every 60 seconds
const interval = setInterval(fetchOrdersCount, 60000);
return () => clearInterval(interval);
}, []);
intervalRef.current = setInterval(fetchOrdersCount, 60000);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
});
const handleLogout = () => {
localStorage.removeItem('adminToken');
navigate('/login');
const handleLogout = async () => {
try {
await api.post('/auth/logout');
} catch {
// Ignore logout errors
} finally {
clearAuth();
localStorage.removeItem('adminToken');
navigate('/login');
}
};
return (

View File

@ -2,6 +2,8 @@ import { useState, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Bell, Search, UserCircle, Menu } from 'lucide-react';
import { toast } from 'react-hot-toast';
import api from '../services/api';
import { useAdminAuthStore } from '../store/adminAuthStore';
interface TopbarProps {
toggleMenu: () => void;
@ -28,8 +30,9 @@ export default function Topbar({ toggleMenu }: TopbarProps) {
const [searchQuery, setSearchQuery] = useState('');
const [showResults, setShowResults] = useState(false);
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(() => {
const handleClickOutside = (event: MouseEvent) => {
if (searchContainerRef.current && !searchContainerRef.current.contains(event.target as Node)) {
@ -68,12 +71,23 @@ export default function Topbar({ toggleMenu }: TopbarProps) {
return () => document.removeEventListener('mousedown', handleOutsideClick);
}, []);
const handleLogout = () => {
localStorage.removeItem('adminToken');
toast.success('خروج با موفقیت انجام شد');
navigate('/login');
const handleLogout = async () => {
try {
await api.post('/auth/logout');
} catch {
// Ignore
} finally {
clearAuth();
localStorage.removeItem('adminToken');
toast.success('خروج با موفقیت انجام شد');
navigate('/login');
}
};
const displayName = adminUser
? `${adminUser.firstName || ''} ${adminUser.lastName || ''}`.trim() || adminUser.email || adminUser.mobile
: 'مدیر سیستم';
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">
<div className="flex-1 flex items-center gap-3 max-w-xl" ref={searchContainerRef}>
@ -99,7 +113,6 @@ 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"
/>
{/* Live Search Results Dropdown */}
{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">
{filteredResults.length > 0 ? (
@ -124,7 +137,6 @@ export default function Topbar({ toggleMenu }: TopbarProps) {
</div>
<div className="flex items-center gap-4">
{/* Realtime Notifications (TASK-2.20) */}
<div className="relative" ref={notifRef}>
<button
onClick={() => setShowNotifications(!showNotifications)}
@ -159,15 +171,14 @@ export default function Topbar({ toggleMenu }: TopbarProps) {
)}
</div>
{/* User Profile Dropdown (TASK-2.19) */}
<div className="relative" ref={userMenuRef}>
<button
onClick={() => setShowUserMenu(!showUserMenu)}
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">
<p className="text-sm font-bold text-gray-900">مدیر سیستم</p>
<p className="text-xs font-medium text-purple-600">ادمین ارشد</p>
<p className="text-sm font-bold text-gray-900">{displayName}</p>
<p className="text-xs font-medium text-purple-600">{adminUser?.role || 'ادمین ارشد'}</p>
</div>
<UserCircle className="w-10 h-10 text-purple-600" />
</button>

View File

@ -1,25 +1,36 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Lock, Mail, ChevronLeft } from 'lucide-react';
import { Lock, Mail, Phone, ChevronLeft, KeyRound } from 'lucide-react';
import { toast } from 'react-hot-toast';
import api from '../services/api';
import { useAdminAuthStore } from '../store/adminAuthStore';
import type { ApiResponse, AuthResponseData } from '../types/admin';
export default function Login() {
const [loginMode, setLoginMode] = useState<'password' | 'otp'>('password');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [phoneNumber, setPhoneNumber] = useState('');
const [otpCode, setOtpCode] = useState('');
const [otpSent, setOtpSent] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const navigate = useNavigate();
const setAuth = useAdminAuthStore((state) => state.setAuth);
const handleLogin = async (e: React.FormEvent) => {
const handlePasswordLogin = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
const response = await api.post('/auth/admin-login', { email, password });
if (response.data?.success) {
localStorage.setItem('adminToken', response.data.data.accessToken);
const response = await api.post<ApiResponse<AuthResponseData>>('/auth/admin-login', { email, password });
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('ایمیل یا رمز عبور اشتباه است');
@ -28,6 +39,51 @@ 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 (
<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">
@ -44,54 +100,146 @@ export default function Login() {
<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">
<form className="space-y-6" onSubmit={handleLogin}>
<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>
{/* Toggle Login Mode */}
<div className="flex bg-gray-100 p-1 rounded-xl mb-6">
<button
type="button"
onClick={() => { setLoginMode('password'); setOtpSent(false); }}
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'}`}
>
ورود با رمز عبور
</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>
<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" />
{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>
<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>
<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>
</form>
)}
</div>
</div>
</div>

View File

@ -44,6 +44,8 @@ export interface Product {
videoUrl?: string;
pdfUrl?: string;
symptoms?: Array<{ symptom: string } | string>;
isPreorder?: boolean;
preorderDeposit?: string | number;
}
export default function Products() {
@ -214,8 +216,8 @@ export default function Products() {
videoUrl: product.videoUrl || '',
pdfUrl: product.pdfUrl || '',
symptoms: product.symptoms ? product.symptoms.map((s: { symptom: string } | string) => typeof s === 'string' ? s : s.symptom) : [],
isPreorder: Boolean((product as any).isPreorder),
preorderDeposit: (product as any).preorderDeposit || ''
isPreorder: Boolean(product.isPreorder),
preorderDeposit: product.preorderDeposit || ''
});
} else {
setEditingProduct(null);

View File

@ -6,6 +6,8 @@ import Skeleton from '../components/ui/Skeleton';
import Spinner from '../components/ui/Spinner';
import Pagination from '../components/ui/Pagination';
import type { UserAddress, PetSummary } from '../types/admin';
export interface UserRecord {
id: string;
firstName?: string;
@ -16,8 +18,8 @@ export interface UserRecord {
status?: string;
walletBalance?: number;
createdAt?: string;
addresses?: any[];
pets?: any[];
addresses?: UserAddress[];
pets?: PetSummary[];
}
export default function Users() {
@ -290,7 +292,7 @@ export default function Users() {
<h4 className="font-bold text-gray-900 mb-2">آدرسهای ثبتشده ({viewUser.addresses?.length || 0})</h4>
{viewUser.addresses && viewUser.addresses.length > 0 ? (
<div className="space-y-2 max-h-32 overflow-y-auto">
{viewUser.addresses.map((a: any, idx: number) => (
{viewUser.addresses.map((a: UserAddress, idx: number) => (
<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="text-gray-500 mt-0.5">{a.province}، {a.city}، {a.detail}</p>

View File

@ -0,0 +1,69 @@
import { lazy, 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 />,
},
]);

View File

@ -1,5 +1,8 @@
import axios from 'axios';
import type { AxiosError, InternalAxiosRequestConfig } from 'axios';
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
? import.meta.env.VITE_API_URL.replace(/\/api$/, '')
@ -19,13 +22,31 @@ export interface ApiErrorPayload {
const api = axios.create({
baseURL,
withCredentials: true,
headers: {
'Content-Type': 'application/json',
},
});
api.interceptors.request.use((config) => {
const token = localStorage.getItem('adminToken');
let isRefreshing = false;
let failedQueue: Array<{
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) {
config.headers.Authorization = `Bearer ${token}`;
}
@ -34,20 +55,70 @@ api.interceptors.request.use((config) => {
api.interceptors.response.use(
(response) => response,
(error) => {
const responseData = error.response?.data as ApiErrorPayload | undefined;
async (error: AxiosError<ApiErrorPayload>) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
const responseData = error.response?.data;
const status = error.response?.status;
const farsiMessage = responseData?.message || 'خطایی در ارتباط با سرور رخ داده است.';
if (status === 401) {
localStorage.removeItem('adminToken');
if (window.location.pathname !== '/login') {
toast.error('نشست کاری شما منقضی شده است. لطفاً مجدداً وارد شوید.');
window.location.href = '/login';
if (status === 401 && originalRequest && !originalRequest._retry) {
if (originalRequest.url?.includes('/auth/refresh') || originalRequest.url?.includes('/auth/admin-login') || originalRequest.url?.includes('/auth/verify-otp')) {
useAdminAuthStore.getState().clearAuth();
if (window.location.pathname !== '/login') {
toast.error('نشست کاری شما منقضی شده است. لطفاً مجدداً وارد شوید.');
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('دسترسی شما به این بخش از پنل ادمین مجاز نمی‌باشد.');
} else {
} else if (status !== 401) {
if (responseData?.details && Array.isArray(responseData.details) && responseData.details.length > 0) {
toast.error(responseData.details[0]?.message || farsiMessage);
} else {

View File

@ -0,0 +1,22 @@
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,
}),
}));

View File

@ -0,0 +1,68 @@
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;
}