Compare commits

..

No commits in common. "27abaa3e050c306a1af9b348873f876b4afd3d56" and "71e19bfe43bbe0f2089afc88196c929f576c4f62" have entirely different histories.

11 changed files with 155 additions and 564 deletions

View File

@ -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>
); );
} }

View File

@ -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);
const validateSession = async () => {
if (isAuthenticated && accessToken) {
setIsValidating(false); setIsValidating(false);
return; return;
} }
try { api.get('/users/profile')
const response = await api.post<ApiResponse<AuthResponseData>>('/auth/refresh'); .then(() => {
if (response.data?.success && response.data.data?.accessToken) { setIsValid(true);
if (isMounted) { })
setAuth(response.data.data.accessToken, response.data.data.user); .catch(() => {
localStorage.removeItem('adminToken');
setIsValid(false);
})
.finally(() => {
setIsValidating(false); setIsValidating(false);
} });
return; }, [token]);
}
} catch {
// Fallback profile check if token already in memory
}
if (accessToken) { if (!token || !isValid) {
try { return <Navigate to="/login" replace />;
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 />;
} }

View File

@ -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 {
await api.post('/auth/logout');
} catch {
// Ignore logout errors
} finally {
clearAuth();
localStorage.removeItem('adminToken'); localStorage.removeItem('adminToken');
navigate('/login'); navigate('/login');
}
}; };
return ( return (

View File

@ -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 {
await api.post('/auth/logout');
} catch {
// Ignore
} finally {
clearAuth();
localStorage.removeItem('adminToken'); localStorage.removeItem('adminToken');
toast.success('خروج با موفقیت انجام شد'); toast.success('خروج با موفقیت انجام شد');
navigate('/login'); 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>

View File

@ -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,28 +44,9 @@ 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">
<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>
{loginMode === 'password' ? (
<form className="space-y-6" onSubmit={handlePasswordLogin}>
<div> <div>
<label className="block text-sm font-black text-gray-700">آدرس ایمیل ادمین</label> <label className="block text-sm font-black text-gray-700">آدرس ایمیل</label>
<div className="mt-2 relative"> <div className="mt-2 relative">
<div className="absolute inset-y-0 right-0 pl-3 flex items-center pr-3 pointer-events-none"> <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" /> <Mail className="h-5 w-5 text-gray-400" />
@ -167,79 +92,6 @@ export default function Login() {
</button> </button>
</div> </div>
</form> </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>
</div> </div>
</div> </div>

View File

@ -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);

View File

@ -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>

View File

@ -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 />,
},
]);

View File

@ -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 {

View File

@ -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,
}),
}));

View File

@ -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;
}