Initial commit of the Mado Motor admin dashboard.
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_WEBSITE_URL=
|
||||||
|
VITE_API_URL=
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Mado Motor Dashboard
|
||||||
|
|
||||||
|
React + Vite clone of the admin.madomotor.ir UI.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Dev server:
|
||||||
|
|
||||||
|
- http://manage.madomotor.com:5175
|
||||||
|
- http://localhost:5175
|
||||||
|
|
||||||
|
Local domains are mapped in `/etc/hosts` (see `../scripts/setup-hosts.sh`).
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Auth is local/mock for now (any non-empty mobile + password works).
|
||||||
|
- Pages and routes mirror the live admin; API wiring comes next.
|
||||||
|
- Design tokens: IranYekan / Montserrat, primary `#2c86ed`, RTL layout.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fa" dir="rtl">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/png" href="/src/assets/images/logo.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>مادو موتور | پنل مدیریت</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "madomotor-dashboard",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "oxlint",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8",
|
||||||
|
"react-router-dom": "^7.18.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.13.3",
|
||||||
|
"@types/react": "^19.2.18",
|
||||||
|
"@types/react-dom": "^19.2.4",
|
||||||
|
"@vitejs/plugin-react": "^6.1.0",
|
||||||
|
"oxlint": "^1.79.0",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vite": "^8.2.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,68 @@
|
|||||||
|
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||||
|
import { AuthProvider } from './auth/AuthContext'
|
||||||
|
import RequireAuth from './auth/RequireAuth'
|
||||||
|
import AuthLayout from './layouts/AuthLayout'
|
||||||
|
import AppLayout from './layouts/AppLayout'
|
||||||
|
import LoginPage from './pages/LoginPage'
|
||||||
|
import RegisterPage from './pages/RegisterPage'
|
||||||
|
import ForgotPasswordPage from './pages/ForgotPasswordPage'
|
||||||
|
import DashboardPage from './pages/DashboardPage'
|
||||||
|
import OrdersPage from './pages/OrdersPage'
|
||||||
|
import ProductsPage from './pages/ProductsPage'
|
||||||
|
import ProductFormPage from './pages/ProductFormPage'
|
||||||
|
import CustomersPage from './pages/CustomersPage'
|
||||||
|
import CustomerFormPage from './pages/CustomerFormPage'
|
||||||
|
import ReviewsPage from './pages/ReviewsPage'
|
||||||
|
import BlogListPage from './pages/BlogListPage'
|
||||||
|
import BlogFormPage from './pages/BlogFormPage'
|
||||||
|
import RepresentationRequestsPage from './pages/RepresentationRequestsPage'
|
||||||
|
import ActiveRepresentationsPage from './pages/ActiveRepresentationsPage'
|
||||||
|
import RepresentationFormPage from './pages/RepresentationFormPage'
|
||||||
|
import CallRequestsPage from './pages/CallRequestsPage'
|
||||||
|
import CallRequestFormPage from './pages/CallRequestFormPage'
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/auth" element={<AuthLayout />}>
|
||||||
|
<Route path="login" element={<LoginPage />} />
|
||||||
|
<Route path="register" element={<RegisterPage />} />
|
||||||
|
<Route path="forgot-password" element={<ForgotPasswordPage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route element={<RequireAuth />}>
|
||||||
|
<Route element={<AppLayout />}>
|
||||||
|
<Route path="/" element={<DashboardPage />} />
|
||||||
|
<Route path="/admin/orders" element={<OrdersPage />} />
|
||||||
|
<Route path="/admin/product/product-list" element={<ProductsPage />} />
|
||||||
|
<Route path="/admin/product/add-product" element={<ProductFormPage />} />
|
||||||
|
<Route path="/admin/product/edit-product/:id" element={<ProductFormPage />} />
|
||||||
|
<Route path="/admin/my-customers" element={<CustomersPage />} />
|
||||||
|
<Route path="/admin/my-customers/new" element={<CustomerFormPage />} />
|
||||||
|
<Route path="/admin/my-customers/:id/edit" element={<CustomerFormPage />} />
|
||||||
|
<Route path="/admin/user-reviews" element={<ReviewsPage />} />
|
||||||
|
<Route
|
||||||
|
path="/admin/representation/representation-list"
|
||||||
|
element={<RepresentationRequestsPage />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/representation/active-representation-list"
|
||||||
|
element={<ActiveRepresentationsPage />}
|
||||||
|
/>
|
||||||
|
<Route path="/admin/representation/new" element={<RepresentationFormPage />} />
|
||||||
|
<Route path="/admin/representation/active/new" element={<RepresentationFormPage />} />
|
||||||
|
<Route path="/admin/representation/:id/edit" element={<RepresentationFormPage />} />
|
||||||
|
<Route path="/admin/call-request-list" element={<CallRequestsPage />} />
|
||||||
|
<Route path="/admin/call-request/new" element={<CallRequestFormPage />} />
|
||||||
|
<Route path="/blog-list" element={<BlogListPage />} />
|
||||||
|
<Route path="/add-blog" element={<BlogFormPage />} />
|
||||||
|
<Route path="/edit-blog/:id" element={<BlogFormPage />} />
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</AuthProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 383 B |
|
After Width: | Height: | Size: 271 KiB |
|
After Width: | Height: | Size: 554 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none">
|
||||||
|
<circle cx="100" cy="100" r="72" fill="#d9d9d9"/>
|
||||||
|
<circle cx="100" cy="100" r="58" fill="#bdbdbd"/>
|
||||||
|
<circle cx="100" cy="100" r="22" fill="#fff" stroke="#9e9e9e" stroke-width="6"/>
|
||||||
|
<circle cx="100" cy="70" r="6" fill="#757575"/>
|
||||||
|
<circle cx="126" cy="115" r="6" fill="#757575"/>
|
||||||
|
<circle cx="74" cy="115" r="6" fill="#757575"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 420 B |
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 160" fill="none">
|
||||||
|
<circle cx="48" cy="122" r="22" stroke="#2c86ed" stroke-width="6"/>
|
||||||
|
<circle cx="168" cy="122" r="22" stroke="#2c86ed" stroke-width="6"/>
|
||||||
|
<path d="M70 122h78" stroke="#2c86ed" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<path d="M108 122V70l48-22" stroke="#2c86ed" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<path d="M72 92h42" stroke="#2c86ed" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<circle cx="156" cy="48" r="8" fill="#2c86ed"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 525 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none">
|
||||||
|
<rect x="88" y="22" width="24" height="36" rx="4" fill="#607d8b"/>
|
||||||
|
<rect x="78" y="56" width="44" height="88" rx="8" fill="#90a4ae"/>
|
||||||
|
<path d="M78 70h44M78 82h44M78 94h44M78 106h44M78 118h44M78 130h44" stroke="#546e7a" stroke-width="4"/>
|
||||||
|
<rect x="90" y="144" width="20" height="34" rx="3" fill="#455a64"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 396 B |
@@ -0,0 +1,15 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" fill="none">
|
||||||
|
<circle cx="100" cy="100" r="78" stroke="#2c86ed" stroke-width="10"/>
|
||||||
|
<circle cx="100" cy="100" r="52" stroke="#9cc4f2" stroke-width="8"/>
|
||||||
|
<circle cx="100" cy="100" r="18" fill="#2c86ed"/>
|
||||||
|
<g stroke="#2c86ed" stroke-width="6">
|
||||||
|
<line x1="100" y1="28" x2="100" y2="82"/>
|
||||||
|
<line x1="100" y1="118" x2="100" y2="172"/>
|
||||||
|
<line x1="28" y1="100" x2="82" y2="100"/>
|
||||||
|
<line x1="118" y1="100" x2="172" y2="100"/>
|
||||||
|
<line x1="49" y1="49" x2="87" y2="87"/>
|
||||||
|
<line x1="113" y1="113" x2="151" y2="151"/>
|
||||||
|
<line x1="151" y1="49" x2="113" y2="87"/>
|
||||||
|
<line x1="87" y1="113" x2="49" y2="151"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 696 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg width='46' height='61' viewBox='0 0 46 61' fill='none' xmlns='http://www.w3.org/2000/svg'><path d='M7.53637 15.2976C7.53637 6.86298 14.444 0 22.9336 0C31.4231 0 38.3334 6.86298 38.3334 15.2976C38.3334 23.7322 31.4257 30.5952 22.9336 30.5952C14.4415 30.5952 7.53637 23.7322 7.53637 15.2976ZM10.0919 15.2976C10.0919 22.3332 15.8521 28.0562 22.9336 28.0562C30.015 28.0562 35.7778 22.3332 35.7778 15.2976C35.7778 8.26198 30.0176 2.53902 22.9336 2.53902C15.8496 2.53902 10.0919 8.26198 10.0919 15.2976Z' fill='#2073D2'/><path d='M23 35.6098C10.3193 35.6098 0 45.8598 0 58.461V59.7305C0 60.4313 0.572444 61 1.27778 61C1.98311 61 2.55556 60.4313 2.55556 59.7305V58.461C2.55556 47.2614 11.7274 38.1488 23 38.1488C34.2726 38.1488 43.4444 47.2614 43.4444 58.461V59.7305C43.4444 60.4313 44.0169 61 44.7222 61C45.4276 61 46 60.4313 46 59.7305V58.461C46 45.8598 35.6807 35.6098 23 35.6098Z' fill='#D2D2D2'/></svg>
|
||||||
|
After Width: | Height: | Size: 905 B |
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="46" height="61" viewBox="0 0 46 61" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M7.53637 15.2976C7.53637 6.86298 14.444 0 22.9336 0C31.4231 0 38.3334 6.86298 38.3334 15.2976C38.3334 23.7322 31.4257 30.5952 22.9336 30.5952C14.4415 30.5952 7.53637 23.7322 7.53637 15.2976ZM10.0919 15.2976C10.0919 22.3332 15.8521 28.0562 22.9336 28.0562C30.015 28.0562 35.7778 22.3332 35.7778 15.2976C35.7778 8.26198 30.0176 2.53902 22.9336 2.53902C15.8496 2.53902 10.0919 8.26198 10.0919 15.2976Z" fill="#2073D2"/>
|
||||||
|
<path d="M23 35.6098C10.3193 35.6098 0 45.8598 0 58.461V59.7305C0 60.4313 0.572444 61 1.27778 61C1.98311 61 2.55556 60.4313 2.55556 59.7305V58.461C2.55556 47.2614 11.7274 38.1488 23 38.1488C34.2726 38.1488 43.4444 47.2614 43.4444 58.461V59.7305C43.4444 60.4313 44.0169 61 44.7222 61C45.4276 61 46 60.4313 46 59.7305V58.461C46 45.8598 35.6807 35.6098 23 35.6098Z" fill="#D2D2D2"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 913 B |
@@ -0,0 +1,111 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react'
|
||||||
|
import {
|
||||||
|
login as apiLogin,
|
||||||
|
loginSendCode as apiLoginSendCode,
|
||||||
|
loginVerify as apiLoginVerify,
|
||||||
|
logout as apiLogout,
|
||||||
|
} from '../lib/authApi'
|
||||||
|
import {
|
||||||
|
clearSession,
|
||||||
|
getAuthUser,
|
||||||
|
isElevatedRole,
|
||||||
|
type AuthUser,
|
||||||
|
} from '../lib/auth'
|
||||||
|
import { ApiError } from '../lib/api'
|
||||||
|
import { isValidCellNumber, normalizeCellNumber } from '../lib/phone'
|
||||||
|
|
||||||
|
type AuthContextValue = {
|
||||||
|
user: AuthUser | null
|
||||||
|
isAuthenticated: boolean
|
||||||
|
login: (mobile: string, password: string) => Promise<void>
|
||||||
|
loginSendCode: (mobile: string) => Promise<{ expiresInSeconds: number; resendAfterSeconds: number }>
|
||||||
|
loginVerify: (mobile: string, code: string) => Promise<void>
|
||||||
|
logout: () => Promise<void>
|
||||||
|
reloadUser: () => void
|
||||||
|
isAdmin: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null)
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<AuthUser | null>(() => getAuthUser())
|
||||||
|
|
||||||
|
const login = useCallback(async (mobile: string, password: string) => {
|
||||||
|
const cellNumber = normalizeCellNumber(mobile)
|
||||||
|
if (!cellNumber || !password.trim()) {
|
||||||
|
throw new Error('شماره همراه و رمز عبور را وارد کنید')
|
||||||
|
}
|
||||||
|
if (!isValidCellNumber(cellNumber)) {
|
||||||
|
throw new Error('شماره همراه معتبر نیست')
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const nextUser = await apiLogin(cellNumber, password)
|
||||||
|
setUser(nextUser)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError) throw new Error(err.message)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loginSendCode = useCallback(async (mobile: string) => {
|
||||||
|
const cellNumber = normalizeCellNumber(mobile)
|
||||||
|
if (!isValidCellNumber(cellNumber)) {
|
||||||
|
throw new Error('شماره همراه معتبر نیست')
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await apiLoginSendCode(cellNumber)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError) throw new Error(err.message)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loginVerify = useCallback(async (mobile: string, code: string) => {
|
||||||
|
try {
|
||||||
|
const nextUser = await apiLoginVerify(normalizeCellNumber(mobile), code.trim())
|
||||||
|
setUser(nextUser)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError) throw new Error(err.message)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
await apiLogout()
|
||||||
|
clearSession()
|
||||||
|
setUser(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const reloadUser = useCallback(() => {
|
||||||
|
setUser(getAuthUser())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const value = useMemo<AuthContextValue>(
|
||||||
|
() => ({
|
||||||
|
user,
|
||||||
|
isAuthenticated: Boolean(user),
|
||||||
|
login,
|
||||||
|
loginSendCode,
|
||||||
|
loginVerify,
|
||||||
|
logout,
|
||||||
|
reloadUser,
|
||||||
|
isAdmin: isElevatedRole(user?.role),
|
||||||
|
}),
|
||||||
|
[user, login, loginSendCode, loginVerify, logout, reloadUser],
|
||||||
|
)
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const ctx = useContext(AuthContext)
|
||||||
|
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Navigate, Outlet, useLocation } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../auth/AuthContext'
|
||||||
|
|
||||||
|
export default function RequireAuth() {
|
||||||
|
const { isAuthenticated } = useAuth()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
const redirect = encodeURIComponent(location.pathname + location.search)
|
||||||
|
return <Navigate to={`/auth/login?redirect=${redirect}`} replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Outlet />
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
.dt {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
text-align: center;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 20px 10px 16px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--color-primary);
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dt:hover {
|
||||||
|
border-color: #d8d8d8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dt--disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dt--disabled:hover {
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dt__icon {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
object-fit: contain;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dt h4 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 300;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dt h5 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 300;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
line-height: 1.6;
|
||||||
|
max-width: 150px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import type { DashboardTile } from '../data/tiles'
|
||||||
|
import './DashboardTile.css'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
tile: DashboardTile
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardTileCard({ tile }: Props) {
|
||||||
|
const body = (
|
||||||
|
<>
|
||||||
|
<img src={tile.icon} alt="" className="dt__icon" width={64} height={64} />
|
||||||
|
<h4>{tile.title}</h4>
|
||||||
|
<h5>{tile.subtitle}</h5>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (tile.to) {
|
||||||
|
return (
|
||||||
|
<Link to={tile.to} className="dt">
|
||||||
|
{body}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dt dt--disabled" title="بهزودی">
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
.fab {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 32px;
|
||||||
|
left: max(24px, calc(5vw + 12px));
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 6px 16px rgba(44, 134, 237, 0.35);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab:hover {
|
||||||
|
background: var(--color-primary-deep);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import Icon from './Icon'
|
||||||
|
import './FabButton.css'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
label: string
|
||||||
|
onClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FabButton({ label, onClick }: Props) {
|
||||||
|
return (
|
||||||
|
<button type="button" className="fab" aria-label={label} onClick={onClick}>
|
||||||
|
<Icon name="plus" size={24} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
export type IconName =
|
||||||
|
| 'cart'
|
||||||
|
| 'scooter'
|
||||||
|
| 'user'
|
||||||
|
| 'star'
|
||||||
|
| 'chat'
|
||||||
|
| 'wrench'
|
||||||
|
| 'doc'
|
||||||
|
| 'store'
|
||||||
|
| 'rss'
|
||||||
|
| 'home'
|
||||||
|
| 'power'
|
||||||
|
| 'edit'
|
||||||
|
| 'plus'
|
||||||
|
| 'chevron'
|
||||||
|
| 'trash'
|
||||||
|
| 'info'
|
||||||
|
| 'phone'
|
||||||
|
|
||||||
|
type IconProps = {
|
||||||
|
name: IconName
|
||||||
|
size?: number
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Icon({ name, size = 24, className }: IconProps) {
|
||||||
|
const common = {
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
viewBox: '0 0 24 24',
|
||||||
|
fill: 'none',
|
||||||
|
stroke: 'currentColor',
|
||||||
|
strokeWidth: 1.6,
|
||||||
|
strokeLinecap: 'round' as const,
|
||||||
|
strokeLinejoin: 'round' as const,
|
||||||
|
className,
|
||||||
|
'aria-hidden': true,
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (name) {
|
||||||
|
case 'home':
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M3 10.5 12 3l9 7.5" />
|
||||||
|
<path d="M5 9.5V21h14V9.5" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'power':
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M12 2v10" />
|
||||||
|
<path d="M6.5 5.5a8 8 0 1 0 11 0" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'user':
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<circle cx="12" cy="8" r="3.5" />
|
||||||
|
<path d="M4.5 20a7.5 7.5 0 0 1 15 0" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'cart':
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M3 4h2l1.5 11h11L20 7H7" />
|
||||||
|
<circle cx="9" cy="19" r="1.4" />
|
||||||
|
<circle cx="16" cy="19" r="1.4" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'scooter':
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<circle cx="6.5" cy="17.5" r="2.5" />
|
||||||
|
<circle cx="18" cy="17.5" r="2.5" />
|
||||||
|
<path d="M9 17.5h6.5" />
|
||||||
|
<path d="M12 17.5V9l5-3" />
|
||||||
|
<path d="M8 12h4" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'star':
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="m12 3 2.4 4.9 5.4.8-3.9 3.8.9 5.4L12 15.9 7.2 18l.9-5.4L4.2 8.7l5.4-.8L12 3z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'chat':
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M4 5h16v10H8l-4 4V5z" />
|
||||||
|
<path d="M8 9h8M8 12h5" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'wrench':
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M14.5 5.5a4 4 0 0 0-5.4 5.4L4 16v4h4l5.1-5.1a4 4 0 0 0 1.4-9.4z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'doc':
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M7 3h7l4 4v14H7z" />
|
||||||
|
<path d="M14 3v4h4" />
|
||||||
|
<path d="M9 12h6M9 16h6" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'store':
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M4 9 6 4h12l2 5" />
|
||||||
|
<path d="M4 9v11h16V9" />
|
||||||
|
<path d="M10 20v-6h4v6" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'rss':
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M5 18a1.5 1.5 0 1 0 0.01 0" />
|
||||||
|
<path d="M5 11a7 7 0 0 1 7 7" />
|
||||||
|
<path d="M5 6a12 12 0 0 1 12 12" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'edit':
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M4 20h4l11-11-4-4L4 16v4z" />
|
||||||
|
<path d="m13 7 4 4" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'trash':
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M4 7h16" />
|
||||||
|
<path d="M9 7V5h6v2" />
|
||||||
|
<path d="M6 7l1 14h10l1-14" />
|
||||||
|
<path d="M10 11v6M14 11v6" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'plus':
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M12 5v14M5 12h14" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'chevron':
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="m9 5 7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'info':
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="M12 10v6M12 7h.01" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'phone':
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M6.5 3.5h3l1.5 4-2 1.5a12 12 0 0 0 5 5l1.5-2 4 1.5v3a2 2 0 0 1-2 2A15 15 0 0 1 4.5 5.5a2 2 0 0 1 2-2z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
.page-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title__back {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 8px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
padding: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title__back img {
|
||||||
|
display: block;
|
||||||
|
width: 15px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title__divider {
|
||||||
|
width: 1px;
|
||||||
|
align-self: stretch;
|
||||||
|
min-height: 36px;
|
||||||
|
margin: 8px 20px;
|
||||||
|
background: #e0e0e0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title__content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title__icon {
|
||||||
|
height: auto;
|
||||||
|
margin-left: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title__text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title p {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: gray;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import userIcon from '../assets/images/user-icon.svg'
|
||||||
|
import backChevron from '../assets/images/back-chevron.png'
|
||||||
|
import './PageTitle.css'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
title: string
|
||||||
|
subtitle?: string
|
||||||
|
image?: string
|
||||||
|
imageWidth?: number
|
||||||
|
showBack?: boolean
|
||||||
|
onBack?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PageTitle({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
image = userIcon,
|
||||||
|
imageWidth = 45,
|
||||||
|
showBack = true,
|
||||||
|
onBack,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<div className="page-title">
|
||||||
|
{showBack ? (
|
||||||
|
<button type="button" className="page-title__back" onClick={onBack} aria-label="بازگشت">
|
||||||
|
<img src={backChevron} alt="" width={15} height={22} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showBack ? <span className="page-title__divider" aria-hidden /> : null}
|
||||||
|
|
||||||
|
<div className="page-title__content">
|
||||||
|
{image ? (
|
||||||
|
<img src={image} alt="" className="page-title__icon" style={{ width: imageWidth }} />
|
||||||
|
) : null}
|
||||||
|
<div className="page-title__text">
|
||||||
|
<h1>{title}</h1>
|
||||||
|
{subtitle ? <p>{subtitle}</p> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
.pager {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager__page,
|
||||||
|
.pager__nav {
|
||||||
|
min-width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #e3f2fd;
|
||||||
|
color: #1565c0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-fa);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager__page.is-active {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager__nav:hover:not(:disabled),
|
||||||
|
.pager__page:hover:not(.is-active) {
|
||||||
|
background: #bbdefb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager__nav:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager__nav--next {
|
||||||
|
transform: scaleX(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager__ellipsis {
|
||||||
|
min-width: 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: #9e9e9e;
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import Icon from './Icon'
|
||||||
|
import { toFaDigits } from '../lib/faDigits'
|
||||||
|
import './Pagination.css'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
page: number
|
||||||
|
pageCount: number
|
||||||
|
onPageChange: (page: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function visiblePages(page: number, pageCount: number): Array<number | 'ellipsis'> {
|
||||||
|
if (pageCount <= 7) {
|
||||||
|
return Array.from({ length: pageCount }, (_, i) => i + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const set = new Set([1, pageCount, page, page - 1, page + 1, page - 2, page + 2])
|
||||||
|
const nums = [...set].filter((n) => n >= 1 && n <= pageCount).sort((a, b) => a - b)
|
||||||
|
const out: Array<number | 'ellipsis'> = []
|
||||||
|
for (const n of nums) {
|
||||||
|
if (out.length > 0) {
|
||||||
|
const prev = out[out.length - 1]
|
||||||
|
if (typeof prev === 'number' && n - prev > 1) out.push('ellipsis')
|
||||||
|
}
|
||||||
|
out.push(n)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Pagination({ page, pageCount, onPageChange }: Props) {
|
||||||
|
if (pageCount <= 1) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="pager" aria-label="صفحهبندی">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="pager__nav"
|
||||||
|
aria-label="صفحه قبل"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => onPageChange(page - 1)}
|
||||||
|
>
|
||||||
|
<Icon name="chevron" size={16} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{visiblePages(page, pageCount).map((item, index) =>
|
||||||
|
item === 'ellipsis' ? (
|
||||||
|
<span key={`e-${index}`} className="pager__ellipsis">
|
||||||
|
…
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={item}
|
||||||
|
type="button"
|
||||||
|
className={`pager__page${item === page ? ' is-active' : ''}`}
|
||||||
|
aria-current={item === page ? 'page' : undefined}
|
||||||
|
onClick={() => onPageChange(item)}
|
||||||
|
>
|
||||||
|
{toFaDigits(item)}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="pager__nav pager__nav--next"
|
||||||
|
aria-label="صفحه بعد"
|
||||||
|
disabled={page >= pageCount}
|
||||||
|
onClick={() => onPageChange(page + 1)}
|
||||||
|
>
|
||||||
|
<Icon name="chevron" size={16} />
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0 18px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease, opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--primary {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--primary:hover:not(:disabled) {
|
||||||
|
background: var(--color-primary-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--secondary {
|
||||||
|
background: var(--color-secondary);
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--secondary:hover:not(:disabled) {
|
||||||
|
background: #e4e4e4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||||
|
import './PrimaryButton.css'
|
||||||
|
|
||||||
|
type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||||
|
variant?: 'primary' | 'secondary' | 'ghost'
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PrimaryButton({
|
||||||
|
variant = 'primary',
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
...rest
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<button className={`btn btn--${variant} ${className}`.trim()} {...rest}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
.tf {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf__label {
|
||||||
|
position: absolute;
|
||||||
|
top: -9px;
|
||||||
|
right: 14px;
|
||||||
|
background: var(--color-white);
|
||||||
|
padding: 0 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #777;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf__input {
|
||||||
|
width: 100%;
|
||||||
|
height: 48px;
|
||||||
|
border: 1px solid #d7d7d7;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0 14px;
|
||||||
|
background: #f7fafc;
|
||||||
|
color: var(--color-text);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s ease, background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf__input:focus {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
background: var(--color-white);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import './TextField.css'
|
||||||
|
|
||||||
|
type TextFieldProps = {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
type?: string
|
||||||
|
autoComplete?: string
|
||||||
|
dir?: 'rtl' | 'ltr'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TextField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
type = 'text',
|
||||||
|
autoComplete,
|
||||||
|
dir,
|
||||||
|
}: TextFieldProps) {
|
||||||
|
return (
|
||||||
|
<label className="tf">
|
||||||
|
<span className="tf__label">{label}</span>
|
||||||
|
<input
|
||||||
|
className="tf__input"
|
||||||
|
type={type}
|
||||||
|
value={value}
|
||||||
|
autoComplete={autoComplete}
|
||||||
|
dir={dir}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
import wheelImg from '../assets/images/products/wheel.svg'
|
||||||
|
import brakeImg from '../assets/images/products/brake.svg'
|
||||||
|
import shockImg from '../assets/images/products/shock.svg'
|
||||||
|
import scooterImg from '../assets/images/products/scooter.svg'
|
||||||
|
import blogImg1 from '../assets/images/blogs/blog-1.png'
|
||||||
|
import blogImg2 from '../assets/images/blogs/blog-2.png'
|
||||||
|
|
||||||
|
export type Order = {
|
||||||
|
id: number
|
||||||
|
customerName: string
|
||||||
|
mobile: string
|
||||||
|
date: string
|
||||||
|
itemCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Product = {
|
||||||
|
id: number
|
||||||
|
nameFa: string
|
||||||
|
nameEn: string
|
||||||
|
price: number
|
||||||
|
category: string
|
||||||
|
stock: number
|
||||||
|
image?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Customer = {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
mobile: string
|
||||||
|
city: string
|
||||||
|
ordersCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BlogPost = {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
summary: string
|
||||||
|
author: string
|
||||||
|
image?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RepresentationRequest = {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
mobile: string
|
||||||
|
city: string
|
||||||
|
address: string
|
||||||
|
date: string
|
||||||
|
owner?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Review = {
|
||||||
|
id: number
|
||||||
|
author: string
|
||||||
|
product: string
|
||||||
|
rating: number
|
||||||
|
status: 'pending' | 'approved'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CallRequest = {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
mobile: string
|
||||||
|
date: string
|
||||||
|
note: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mockOrders: Order[] = [
|
||||||
|
{
|
||||||
|
id: 75,
|
||||||
|
customerName: 'علیرضا حسنی',
|
||||||
|
mobile: '09127004945',
|
||||||
|
date: 'پنجشنبه ۳۰ مرداد ۱۴۰۴',
|
||||||
|
itemCount: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 74,
|
||||||
|
customerName: 'علیرضا حسنی',
|
||||||
|
mobile: '09127004945',
|
||||||
|
date: 'پنجشنبه ۳۰ مرداد ۱۴۰۴',
|
||||||
|
itemCount: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 73,
|
||||||
|
customerName: 'علیرضا حسنی',
|
||||||
|
mobile: '09127004945',
|
||||||
|
date: 'پنجشنبه ۳۰ مرداد ۱۴۰۴',
|
||||||
|
itemCount: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 72,
|
||||||
|
customerName: 'علیرضا حسنی',
|
||||||
|
mobile: '09127004945',
|
||||||
|
date: 'پنجشنبه ۳۰ مرداد ۱۴۰۴',
|
||||||
|
itemCount: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 71,
|
||||||
|
customerName: 'علیرضا حسنی',
|
||||||
|
mobile: '09127004945',
|
||||||
|
date: 'پنجشنبه ۳۰ مرداد ۱۴۰۴',
|
||||||
|
itemCount: 2,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const mockProducts: Product[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
nameFa: 'چرخ عقب موتورسیکلت برقی مادو ۲۳۰',
|
||||||
|
nameEn: 'Rear wheel of Mado 230 electric motorcycle',
|
||||||
|
price: 0,
|
||||||
|
stock: 0,
|
||||||
|
category: 'موتورسیکلت',
|
||||||
|
image: wheelImg,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
nameFa: 'دیسک ترمز موتورسیکلت برقی',
|
||||||
|
nameEn: 'Electric motorcycle brake disc',
|
||||||
|
price: 0,
|
||||||
|
stock: 0,
|
||||||
|
category: 'قطعات',
|
||||||
|
image: brakeImg,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
nameFa: 'کمک فنر جلو موتورسیکلت برقی',
|
||||||
|
nameEn: 'Electric motorcycle front shock absorber',
|
||||||
|
price: 0,
|
||||||
|
stock: 0,
|
||||||
|
category: 'قطعات',
|
||||||
|
image: shockImg,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
nameFa: 'کمک فنر عقب موتورسیکلت برقی',
|
||||||
|
nameEn: 'Electric motorcycle rear shock absorber',
|
||||||
|
price: 0,
|
||||||
|
stock: 0,
|
||||||
|
category: 'قطعات',
|
||||||
|
image: shockImg,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
nameFa: 'مادو ۱۳۰',
|
||||||
|
nameEn: 'Mado 130 electric scooter',
|
||||||
|
price: 185000000,
|
||||||
|
stock: 3,
|
||||||
|
category: 'موتورسیکلت',
|
||||||
|
image: scooterImg,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
nameFa: 'مادو ۲۳۰',
|
||||||
|
nameEn: 'Mado 230 electric scooter',
|
||||||
|
price: 245000000,
|
||||||
|
stock: 2,
|
||||||
|
category: 'موتورسیکلت',
|
||||||
|
image: scooterImg,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const mockCustomers: Customer[] = [
|
||||||
|
{ id: 1, name: 'علیرضا حسنی', mobile: '09127004945', city: 'تهران', ordersCount: 12 },
|
||||||
|
{ id: 2, name: 'سارا محمدی', mobile: '09121234567', city: 'اصفهان', ordersCount: 3 },
|
||||||
|
{ id: 3, name: 'رضا کریمی', mobile: '09351234567', city: 'شیراز', ordersCount: 0 },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const mockBlogs: BlogPost[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: 'معرفی مادو ۱۳۰',
|
||||||
|
summary: 'نگاهی به ویژگیهای موتور جدید مادو و آنچه آن را از سایر محصولات متمایز میکند.',
|
||||||
|
author: 'علیرضا حسنی',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: 'نکات ایمنی کلاه کاسکت',
|
||||||
|
summary: 'چگونه از سلامت خود در مسیر محافظت کنیم و چه نکاتی را هنگام خرید کلاه جدی بگیریم.',
|
||||||
|
author: 'علیرضا حسنی',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: 'نگهداری باتری موتور برقی',
|
||||||
|
summary: 'راهنمای ساده برای افزایش عمر باتری و عملکرد بهتر در سفرهای روزانه.',
|
||||||
|
author: 'علیرضا حسنی',
|
||||||
|
image: blogImg1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
title: 'مادو ۲۳۰ در شهر',
|
||||||
|
summary: 'تجربه رانندگی شهری با مادو ۲۳۰ و نکاتی برای انتخاب رنگ و تجهیزات مناسب.',
|
||||||
|
author: 'علیرضا حسنی',
|
||||||
|
image: blogImg2,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const mockRepresentations: RepresentationRequest[] = [
|
||||||
|
{
|
||||||
|
id: 105,
|
||||||
|
name: 'حسین افکاری',
|
||||||
|
mobile: '09152424890',
|
||||||
|
city: 'مشهد',
|
||||||
|
address: 'میدان شهید فهمیده',
|
||||||
|
date: 'جمعه ۰۶ شهریور ۱۴۰۵',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 104,
|
||||||
|
name: 'محمد رضایی',
|
||||||
|
mobile: '09121112233',
|
||||||
|
city: 'الیگودرز',
|
||||||
|
address: 'خیابان امام خمینی',
|
||||||
|
date: 'پنجشنبه ۰۵ شهریور ۱۴۰۵',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 103,
|
||||||
|
name: 'حسین اکبری',
|
||||||
|
mobile: '09123334455',
|
||||||
|
city: 'تبریز',
|
||||||
|
address: 'بلوار آزادی',
|
||||||
|
date: 'چهارشنبه ۰۴ شهریور ۱۴۰۵',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const mockActiveRepresentations: RepresentationRequest[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'حسین خوشحال',
|
||||||
|
owner: 'نماینده کرج',
|
||||||
|
mobile: '09121234567',
|
||||||
|
city: 'کرج',
|
||||||
|
address: 'اندیشه فاز دو',
|
||||||
|
date: '۱۴۰۳/۰۱/۱۰',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'میرایی',
|
||||||
|
owner: 'نماینده بندر ترکمن',
|
||||||
|
mobile: '09117654321',
|
||||||
|
city: 'بندر ترکمن',
|
||||||
|
address: 'گلستان - بندرترکمن',
|
||||||
|
date: '۱۴۰۳/۰۲/۱۵',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: 'جهان سیکلت',
|
||||||
|
owner: 'نماینده بوشهر',
|
||||||
|
mobile: '09171234567',
|
||||||
|
city: 'بوشهر',
|
||||||
|
address: 'بندر دیر، خیابان شهید بهشتی',
|
||||||
|
date: '۱۴۰۳/۰۳/۲۰',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
name: 'نمایندگی کمال آبادی',
|
||||||
|
owner: 'نماینده اراک',
|
||||||
|
mobile: '09181234567',
|
||||||
|
city: 'اراک',
|
||||||
|
address: 'خیابان مشهد، نبش کوچه طالقانی',
|
||||||
|
date: '۱۴۰۳/۰۴/۰۵',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const mockReviews: Review[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
author: 'کاربر مادو',
|
||||||
|
product: 'مادو ۱۳۰',
|
||||||
|
rating: 5,
|
||||||
|
status: 'approved',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
author: 'رضا',
|
||||||
|
product: 'مادو ۲۳۰',
|
||||||
|
rating: 4,
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const mockCallRequests: CallRequest[] = [
|
||||||
|
{
|
||||||
|
id: 592,
|
||||||
|
name: 'احسان خیرخواه',
|
||||||
|
mobile: '09038600949',
|
||||||
|
date: 'پنجشنبه، ۱۲ شهریور ماه ۱۴۰۵',
|
||||||
|
note: 'درخواست مشاوره خرید',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 591,
|
||||||
|
name: 'مصطفی صالحی',
|
||||||
|
mobile: '09121234567',
|
||||||
|
date: 'چهارشنبه، ۱۱ شهریور ماه ۱۴۰۵',
|
||||||
|
note: 'سوال درباره نمایندگی',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 590,
|
||||||
|
name: 'مینا احمدی',
|
||||||
|
mobile: '09120001122',
|
||||||
|
date: 'سهشنبه، ۱۰ شهریور ماه ۱۴۰۵',
|
||||||
|
note: 'پیگیری سفارش',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 589,
|
||||||
|
name: 'علی نوری',
|
||||||
|
mobile: '09350001122',
|
||||||
|
date: 'دوشنبه، ۹ شهریور ماه ۱۴۰۵',
|
||||||
|
note: 'درخواست تماس',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 588,
|
||||||
|
name: 'سارا محمدی',
|
||||||
|
mobile: '09123334455',
|
||||||
|
date: 'یکشنبه، ۸ شهریور ماه ۱۴۰۵',
|
||||||
|
note: 'مشاوره فنی',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function formatToman(price: number): string {
|
||||||
|
return `${price.toLocaleString('en-US')} تومان`
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import cart from '../assets/images/tiles/cart.png'
|
||||||
|
import scooter from '../assets/images/tiles/scooter.png'
|
||||||
|
import user from '../assets/images/tiles/user.svg'
|
||||||
|
import star from '../assets/images/tiles/star.png'
|
||||||
|
import chat from '../assets/images/tiles/chat.png'
|
||||||
|
import wrench from '../assets/images/tiles/wrench.png'
|
||||||
|
import doc from '../assets/images/tiles/doc.png'
|
||||||
|
import store from '../assets/images/tiles/store.png'
|
||||||
|
import rss from '../assets/images/tiles/rss.png'
|
||||||
|
|
||||||
|
export type DashboardTile = {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
subtitle: string
|
||||||
|
to?: string
|
||||||
|
icon: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const adminTiles: DashboardTile[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: 'سفارشات مادو موتور',
|
||||||
|
subtitle: 'سفارشات وبسایت',
|
||||||
|
to: '/admin/orders',
|
||||||
|
icon: cart,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: 'موتور ها و قطعات',
|
||||||
|
subtitle: 'همه ی محصولات من',
|
||||||
|
to: '/admin/product/product-list',
|
||||||
|
icon: scooter,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: 'مشتریان من',
|
||||||
|
subtitle: 'سرمایه های مادو موتور',
|
||||||
|
to: '/admin/my-customers',
|
||||||
|
icon: user,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
title: 'بررسی های کاربران',
|
||||||
|
subtitle: 'نظرات تخصصی کاربران',
|
||||||
|
to: '/admin/user-reviews',
|
||||||
|
icon: star,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
title: 'انتقادات و شکایات مشتریان',
|
||||||
|
subtitle: 'نظر کاربران برای ما مهم است',
|
||||||
|
icon: chat,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
title: 'خدمات پس از فروش',
|
||||||
|
subtitle: 'تعهد و احترام ما به مشتریان',
|
||||||
|
icon: wrench,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
title: 'درخواست های نمایندگی',
|
||||||
|
subtitle: 'درخواست های کاربران از سرتاسر ایران',
|
||||||
|
to: '/admin/representation/representation-list',
|
||||||
|
icon: doc,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
title: 'نمایندگی های فعال',
|
||||||
|
subtitle: 'نمایندگی های ما در سراسر ایران',
|
||||||
|
to: '/admin/representation/active-representation-list',
|
||||||
|
icon: store,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 9,
|
||||||
|
title: 'اخبار و مطالب',
|
||||||
|
subtitle: 'هر خبر یا مطلبی که مربوط به مادو موتور می باشد.',
|
||||||
|
to: '/blog-list',
|
||||||
|
icon: rss,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
title: 'درخواست های تماس من',
|
||||||
|
subtitle: 'درخواست های تماس کاربران از سرتاسر ایران',
|
||||||
|
to: '/admin/call-request-list',
|
||||||
|
icon: store,
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: 'IranYekan';
|
||||||
|
src: url('./assets/fonts/iranyekan/woff/iranyekanweblight.woff') format('woff');
|
||||||
|
font-weight: 300;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'IranYekan';
|
||||||
|
src: url('./assets/fonts/iranyekan/woff/iranyekanwebmedium.woff') format('woff');
|
||||||
|
font-weight: 500;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'IranYekan';
|
||||||
|
src: url('./assets/fonts/iranyekan/woff/iranyekanwebbold.woff') format('woff');
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Montserrat';
|
||||||
|
src: url('./assets/fonts/montserrat/Montserrat-Light.woff') format('woff');
|
||||||
|
font-weight: 300;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Montserrat';
|
||||||
|
src: url('./assets/fonts/montserrat/Montserrat-Regular.woff') format('woff');
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Montserrat';
|
||||||
|
src: url('./assets/fonts/montserrat/montserrat-bold.woff') format('woff');
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--color-primary: #2c86ed;
|
||||||
|
--color-primary-deep: #2073d2;
|
||||||
|
--color-navy: #2b43b2;
|
||||||
|
--color-text: #1a1a1a;
|
||||||
|
--color-muted: #9c9c9c;
|
||||||
|
--color-secondary: #f0f0f0;
|
||||||
|
--color-border: #e6e6e6;
|
||||||
|
--color-white: #ffffff;
|
||||||
|
--color-orange: #f5a623;
|
||||||
|
--color-page: #eef2f6;
|
||||||
|
--font-fa: 'IranYekan', Tahoma, sans-serif;
|
||||||
|
--font-en: 'Montserrat', sans-serif;
|
||||||
|
--radius: 8px;
|
||||||
|
--radius-lg: 14px;
|
||||||
|
--shadow: 0 8px 28px rgba(20, 36, 76, 0.08);
|
||||||
|
--header-h: 72px;
|
||||||
|
--max-width: 1180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: var(--font-fa);
|
||||||
|
font-weight: 300;
|
||||||
|
color: var(--color-text);
|
||||||
|
background: #eef2f6;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-image: url('./assets/images/auth-bg.jpg');
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
background-size: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Matches original .pl — 90% centered frame */
|
||||||
|
.app-frame {
|
||||||
|
width: 90%;
|
||||||
|
margin: 1rem auto 0;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
margin-top: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 0 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--color-white);
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.12), 0 4px 5px 0 rgba(0, 0, 0, 0.08);
|
||||||
|
padding: 24px;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel.products-page {
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.en {
|
||||||
|
font-family: var(--font-en);
|
||||||
|
font-weight: 300;
|
||||||
|
direction: ltr;
|
||||||
|
unicode-bidi: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
.app-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 18px 24px;
|
||||||
|
background: var(--color-white);
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.12), 0 4px 5px 0 rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header__nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header__nav a {
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header__nav a.active {
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header__icon-link {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header__icon-link.active {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header__logo {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header__logo img {
|
||||||
|
height: 42px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header__icon-btn {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #9a9a9a;
|
||||||
|
cursor: pointer;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header__icon-btn:hover {
|
||||||
|
color: var(--color-primary);
|
||||||
|
background: #f5f8fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.app-header__nav a:not(.app-header__icon-link) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../auth/AuthContext'
|
||||||
|
import Icon from '../components/Icon'
|
||||||
|
import logo from '../assets/images/logo.png'
|
||||||
|
import './AppLayout.css'
|
||||||
|
|
||||||
|
export default function AppLayout() {
|
||||||
|
const { logout } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-shell">
|
||||||
|
<div className="app-frame">
|
||||||
|
<header className="app-header">
|
||||||
|
<nav className="app-header__nav">
|
||||||
|
<NavLink to="/" end className="app-header__icon-link" aria-label="خانه">
|
||||||
|
<Icon name="home" size={22} />
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/admin/product/product-list">موتورها و قطعات</NavLink>
|
||||||
|
<NavLink to="/admin/orders">سفارشات مادو</NavLink>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<Link to="/" className="app-header__logo">
|
||||||
|
<img src={logo} alt="مادو موتور" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="app-header__actions">
|
||||||
|
<button type="button" className="app-header__icon-btn" aria-label="پروفایل">
|
||||||
|
<Icon name="user" size={22} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="app-header__icon-btn"
|
||||||
|
aria-label="خروج"
|
||||||
|
onClick={() => {
|
||||||
|
void logout().then(() => navigate('/auth/login'))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="power" size={22} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="app-main">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
.auth-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
background-image: url('../assets/images/auth-bg.jpg');
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
background-size: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
position: relative;
|
||||||
|
width: 90%;
|
||||||
|
min-height: 80%;
|
||||||
|
margin: auto;
|
||||||
|
background: var(--color-white);
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow:
|
||||||
|
0 3px 14px 2px rgba(0, 0, 0, 0.12),
|
||||||
|
0 8px 10px 1px rgba(0, 0, 0, 0.14);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 28px 32px 32px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Center vertical divider — starts below logo, stops above bottom */
|
||||||
|
.auth-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 100px;
|
||||||
|
bottom: 32px;
|
||||||
|
left: 50%;
|
||||||
|
width: 1px;
|
||||||
|
background: #ececec;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card__logo {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
width: fit-content;
|
||||||
|
margin-inline: auto;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card__logo img {
|
||||||
|
width: 153px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card__body {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
align-items: stretch;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card__promo {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px 40px;
|
||||||
|
text-align: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card__image {
|
||||||
|
width: min(100%, 350px);
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card__copy h3 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 300;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card__copy h3 span {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card__copy h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 300;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card__copy strong {
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.15em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card__form {
|
||||||
|
padding: 12px 48px 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card__form-inner {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1199px) {
|
||||||
|
.auth-card {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.auth-page {
|
||||||
|
height: auto;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
width: 100%;
|
||||||
|
min-height: auto;
|
||||||
|
padding: 20px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card__body {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card__form {
|
||||||
|
border-bottom: 1px solid #ececec;
|
||||||
|
padding: 20px 12px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card__form-inner {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card__image {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Outlet } from 'react-router-dom'
|
||||||
|
import loginPromo from '../assets/images/login-promo.png'
|
||||||
|
import logo from '../assets/images/logo.png'
|
||||||
|
import './AuthLayout.css'
|
||||||
|
|
||||||
|
const WEBSITE_URL = import.meta.env.VITE_WEBSITE_URL || 'http://madomotor.com:5174'
|
||||||
|
|
||||||
|
export default function AuthLayout() {
|
||||||
|
return (
|
||||||
|
<div className="auth-page">
|
||||||
|
<div className="auth-card">
|
||||||
|
<div className="auth-card__logo">
|
||||||
|
<a href={WEBSITE_URL}>
|
||||||
|
<img src={logo} alt="مادو موتور" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="auth-card__body">
|
||||||
|
{/* Form first so RTL places it on the right */}
|
||||||
|
<div className="auth-card__form">
|
||||||
|
<div className="auth-card__form-inner">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="auth-card__promo">
|
||||||
|
<img src={loginPromo} alt="" className="auth-card__image" />
|
||||||
|
<div className="auth-card__copy">
|
||||||
|
<h3>
|
||||||
|
چقدر برای <span>سلامتی</span> خود ارزش قائل اید؟
|
||||||
|
</h3>
|
||||||
|
<h2>
|
||||||
|
یک عدد <strong>کــــــــــلاه کـــــــــاسکت</strong>
|
||||||
|
<br />
|
||||||
|
هدیه ی ما برای ســــلامتی شما.
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { apiRequest, apiUpload } from './api'
|
||||||
|
|
||||||
|
export type Product = {
|
||||||
|
id: string
|
||||||
|
nameFa: string
|
||||||
|
nameEn: string
|
||||||
|
price: number
|
||||||
|
stock: number
|
||||||
|
category: string
|
||||||
|
imageUrl: string | null
|
||||||
|
imageKey: string | null
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Customer = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
cellNumber: string
|
||||||
|
city: string
|
||||||
|
disabled: boolean
|
||||||
|
ordersCount: number
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Order = {
|
||||||
|
id: number
|
||||||
|
customerName: string
|
||||||
|
customerPhone: string
|
||||||
|
itemCount: number
|
||||||
|
status: string
|
||||||
|
createdAt: string
|
||||||
|
items?: Array<{
|
||||||
|
id: string
|
||||||
|
nameFa: string
|
||||||
|
quantity: number
|
||||||
|
unitPrice: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BlogPost = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
summary: string
|
||||||
|
body: string
|
||||||
|
author: string
|
||||||
|
imageUrl: string | null
|
||||||
|
imageKey: string | null
|
||||||
|
published: boolean
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Representation = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
owner: string
|
||||||
|
mobile: string
|
||||||
|
city: string
|
||||||
|
address: string
|
||||||
|
status: 'pending' | 'active' | 'rejected'
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Review = {
|
||||||
|
id: string
|
||||||
|
author: string
|
||||||
|
product: string
|
||||||
|
rating: number
|
||||||
|
body: string
|
||||||
|
status: 'pending' | 'approved' | 'rejected'
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CallRequest = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
mobile: string
|
||||||
|
note: string
|
||||||
|
handled: boolean
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MediaUploadResult = {
|
||||||
|
url: string
|
||||||
|
storageKey: string
|
||||||
|
storageDisk: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFaDate(iso: string): string {
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat('fa-IR', {
|
||||||
|
weekday: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
}).format(new Date(iso))
|
||||||
|
} catch {
|
||||||
|
return iso
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Products
|
||||||
|
export const listProducts = () => apiRequest<Product[]>('/products')
|
||||||
|
export const getProduct = (id: string) => apiRequest<Product>(`/products/${id}`)
|
||||||
|
export const createProduct = (body: Partial<Product> & { nameFa: string; price: number }) =>
|
||||||
|
apiRequest<Product>('/products', { method: 'POST', body })
|
||||||
|
export const updateProduct = (id: string, body: Partial<Product>) =>
|
||||||
|
apiRequest<Product>(`/products/${id}`, { method: 'PATCH', body })
|
||||||
|
export const deleteProduct = (id: string) =>
|
||||||
|
apiRequest<{ ok: true }>(`/products/${id}`, { method: 'DELETE' })
|
||||||
|
|
||||||
|
// Customers
|
||||||
|
export const listCustomers = () => apiRequest<Customer[]>('/customers')
|
||||||
|
export const getCustomer = (id: string) => apiRequest<Customer>(`/customers/${id}`)
|
||||||
|
export const createCustomer = (body: {
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
cellNumber: string
|
||||||
|
city?: string
|
||||||
|
password: string
|
||||||
|
}) => apiRequest<Customer>('/customers', { method: 'POST', body })
|
||||||
|
export const updateCustomer = (
|
||||||
|
id: string,
|
||||||
|
body: { name?: string; city?: string; disabled?: boolean },
|
||||||
|
) => apiRequest<Customer>(`/customers/${id}`, { method: 'PATCH', body })
|
||||||
|
export const deleteCustomer = (id: string) =>
|
||||||
|
apiRequest<{ ok: true }>(`/customers/${id}`, { method: 'DELETE' })
|
||||||
|
|
||||||
|
// Orders
|
||||||
|
export const listOrders = () => apiRequest<Order[]>('/orders')
|
||||||
|
export const getOrder = (id: number | string) => apiRequest<Order>(`/orders/${id}`)
|
||||||
|
export const updateOrderStatus = (id: number | string, status: string) =>
|
||||||
|
apiRequest<Order>(`/orders/${id}/status`, { method: 'PATCH', body: { status } })
|
||||||
|
|
||||||
|
// Blogs
|
||||||
|
export const listBlogs = () => apiRequest<BlogPost[]>('/blogs')
|
||||||
|
export const getBlog = (id: string) => apiRequest<BlogPost>(`/blogs/${id}`)
|
||||||
|
export const createBlog = (body: {
|
||||||
|
title: string
|
||||||
|
summary?: string
|
||||||
|
body?: string
|
||||||
|
author?: string
|
||||||
|
imageUrl?: string
|
||||||
|
imageKey?: string
|
||||||
|
published?: boolean
|
||||||
|
}) => apiRequest<BlogPost>('/blogs', { method: 'POST', body })
|
||||||
|
export const updateBlog = (
|
||||||
|
id: string,
|
||||||
|
body: Partial<{
|
||||||
|
title: string
|
||||||
|
summary: string
|
||||||
|
body: string
|
||||||
|
author: string
|
||||||
|
imageUrl: string | null
|
||||||
|
imageKey: string | null
|
||||||
|
published: boolean
|
||||||
|
}>,
|
||||||
|
) => apiRequest<BlogPost>(`/blogs/${id}`, { method: 'PATCH', body })
|
||||||
|
export const deleteBlog = (id: string) =>
|
||||||
|
apiRequest<{ ok: true }>(`/blogs/${id}`, { method: 'DELETE' })
|
||||||
|
|
||||||
|
// Representations
|
||||||
|
export const listRepresentations = (status?: 'pending' | 'active' | 'rejected') =>
|
||||||
|
apiRequest<Representation[]>(
|
||||||
|
status ? `/representations?status=${status}` : '/representations',
|
||||||
|
)
|
||||||
|
export const createRepresentation = (
|
||||||
|
body: {
|
||||||
|
name: string
|
||||||
|
owner?: string
|
||||||
|
mobile: string
|
||||||
|
city: string
|
||||||
|
address?: string
|
||||||
|
},
|
||||||
|
asActive = false,
|
||||||
|
) =>
|
||||||
|
apiRequest<Representation>(asActive ? '/representations/active' : '/representations', {
|
||||||
|
method: 'POST',
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
export const updateRepresentation = (
|
||||||
|
id: string,
|
||||||
|
body: Partial<{ name: string; owner: string; mobile: string; city: string; address: string }>,
|
||||||
|
) => apiRequest<Representation>(`/representations/${id}`, { method: 'PATCH', body })
|
||||||
|
export const approveRepresentation = (id: string) =>
|
||||||
|
apiRequest<Representation>(`/representations/${id}/approve`, { method: 'POST' })
|
||||||
|
export const rejectRepresentation = (id: string) =>
|
||||||
|
apiRequest<Representation>(`/representations/${id}/reject`, { method: 'POST' })
|
||||||
|
export const deleteRepresentation = (id: string) =>
|
||||||
|
apiRequest<{ ok: true }>(`/representations/${id}`, { method: 'DELETE' })
|
||||||
|
|
||||||
|
// Reviews
|
||||||
|
export const listReviews = () => apiRequest<Review[]>('/reviews')
|
||||||
|
export const approveReview = (id: string) =>
|
||||||
|
apiRequest<Review>(`/reviews/${id}/approve`, { method: 'POST' })
|
||||||
|
export const rejectReview = (id: string) =>
|
||||||
|
apiRequest<Review>(`/reviews/${id}/reject`, { method: 'POST' })
|
||||||
|
export const deleteReview = (id: string) =>
|
||||||
|
apiRequest<{ ok: true }>(`/reviews/${id}`, { method: 'DELETE' })
|
||||||
|
|
||||||
|
// Call requests
|
||||||
|
export const listCallRequests = () => apiRequest<CallRequest[]>('/call-requests')
|
||||||
|
export const createCallRequest = (body: { name: string; mobile: string; note?: string }) =>
|
||||||
|
apiRequest<CallRequest>('/call-requests', { method: 'POST', body })
|
||||||
|
export const markCallHandled = (id: string) =>
|
||||||
|
apiRequest<CallRequest>(`/call-requests/${id}/handled`, { method: 'PATCH' })
|
||||||
|
export const deleteCallRequest = (id: string) =>
|
||||||
|
apiRequest<{ ok: true }>(`/call-requests/${id}`, { method: 'DELETE' })
|
||||||
|
|
||||||
|
// Media
|
||||||
|
export async function uploadMedia(
|
||||||
|
kind: 'main' | 'gallery' | 'blog' | 'temp',
|
||||||
|
file: File,
|
||||||
|
): Promise<MediaUploadResult> {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
return apiUpload<MediaUploadResult>(`/media/upload?kind=${kind}`, formData)
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { getAccessToken, getRefreshToken, setSession, clearSession } from './auth'
|
||||||
|
|
||||||
|
const API_BASE_URL =
|
||||||
|
import.meta.env.VITE_API_URL?.replace(/\/$/, '') ?? 'http://api.madomotor.com:3001'
|
||||||
|
|
||||||
|
const API_PREFIX = `${API_BASE_URL}/api/v1`
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number
|
||||||
|
|
||||||
|
constructor(status: number, message: string) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
this.status = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type RequestOptions = {
|
||||||
|
method?: string
|
||||||
|
body?: unknown
|
||||||
|
auth?: boolean
|
||||||
|
skipRefresh?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
let refreshInFlight: Promise<boolean> | null = null
|
||||||
|
|
||||||
|
function parseErrorMessage(payload: unknown, fallback: string) {
|
||||||
|
if (!payload || typeof payload !== 'object') return fallback
|
||||||
|
const message = (payload as { message?: unknown }).message
|
||||||
|
if (typeof message === 'string' && message.trim()) return message
|
||||||
|
if (Array.isArray(message)) {
|
||||||
|
const parts = message.filter((item) => typeof item === 'string')
|
||||||
|
if (parts.length > 0) return parts.join('، ')
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryRefreshSession(): Promise<boolean> {
|
||||||
|
if (refreshInFlight) return refreshInFlight
|
||||||
|
|
||||||
|
refreshInFlight = (async () => {
|
||||||
|
const refreshToken = getRefreshToken()
|
||||||
|
if (!refreshToken) {
|
||||||
|
clearSession()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_PREFIX}/auth/refresh`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ refreshToken }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const payload = await response.json().catch(() => null)
|
||||||
|
if (!response.ok) {
|
||||||
|
clearSession()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
setSession({
|
||||||
|
accessToken: payload.accessToken,
|
||||||
|
refreshToken: payload.refreshToken,
|
||||||
|
user: payload.user,
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
clearSession()
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
refreshInFlight = null
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
return refreshInFlight
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiRequest<T>(
|
||||||
|
path: string,
|
||||||
|
options: RequestOptions = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const { method = 'GET', body, auth = true, skipRefresh = false } = options
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {}
|
||||||
|
if (body !== undefined) {
|
||||||
|
headers['Content-Type'] = 'application/json'
|
||||||
|
}
|
||||||
|
if (auth) {
|
||||||
|
const token = getAccessToken()
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_PREFIX}${path}`, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 401 && auth && !skipRefresh) {
|
||||||
|
const refreshed = await tryRefreshSession()
|
||||||
|
if (refreshed) {
|
||||||
|
return apiRequest<T>(path, { ...options, skipRefresh: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 204) {
|
||||||
|
return undefined as T
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await response.json().catch(() => null)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiError(
|
||||||
|
response.status,
|
||||||
|
parseErrorMessage(payload, 'خطایی رخ داد'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload as T
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiUpload<T>(
|
||||||
|
path: string,
|
||||||
|
formData: FormData,
|
||||||
|
options: { auth?: boolean; skipRefresh?: boolean } = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const { auth = true, skipRefresh = false } = options
|
||||||
|
const headers: Record<string, string> = {}
|
||||||
|
if (auth) {
|
||||||
|
const token = getAccessToken()
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_PREFIX}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 401 && auth && !skipRefresh) {
|
||||||
|
const refreshed = await tryRefreshSession()
|
||||||
|
if (refreshed) {
|
||||||
|
return apiUpload<T>(path, formData, { ...options, skipRefresh: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await response.json().catch(() => null)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiError(
|
||||||
|
response.status,
|
||||||
|
parseErrorMessage(payload, 'خطایی رخ داد'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return payload as T
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
const AUTH_KEY = 'mado-dashboard-auth'
|
||||||
|
|
||||||
|
export type UserRole = 'Admin' | 'User' | 'Agent' | 'superAdmin'
|
||||||
|
|
||||||
|
export type AuthUser = {
|
||||||
|
id: string
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
cellNumber: string
|
||||||
|
role: UserRole
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuthSession = {
|
||||||
|
accessToken: string
|
||||||
|
refreshToken: string
|
||||||
|
user: AuthUser
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSession(value: unknown): value is AuthSession {
|
||||||
|
if (!value || typeof value !== 'object') return false
|
||||||
|
const session = value as Partial<AuthSession>
|
||||||
|
return (
|
||||||
|
typeof session.accessToken === 'string' &&
|
||||||
|
typeof session.refreshToken === 'string' &&
|
||||||
|
!!session.user &&
|
||||||
|
typeof session.user === 'object' &&
|
||||||
|
typeof session.user.id === 'string'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSession(): AuthSession | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(AUTH_KEY)
|
||||||
|
if (!raw) return null
|
||||||
|
const parsed = JSON.parse(raw) as unknown
|
||||||
|
return isSession(parsed) ? parsed : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSession(session: AuthSession) {
|
||||||
|
localStorage.setItem(AUTH_KEY, JSON.stringify(session))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSession() {
|
||||||
|
localStorage.removeItem(AUTH_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAccessToken(): string | null {
|
||||||
|
return getSession()?.accessToken ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRefreshToken(): string | null {
|
||||||
|
return getSession()?.refreshToken ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuthUser(): AuthUser | null {
|
||||||
|
return getSession()?.user ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isElevatedRole(role: UserRole | undefined | null) {
|
||||||
|
return role === 'Admin' || role === 'superAdmin'
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { apiRequest } from './api'
|
||||||
|
import { clearSession, getRefreshToken, setSession, type AuthUser } from './auth'
|
||||||
|
|
||||||
|
export type AuthTokensResponse = {
|
||||||
|
accessToken: string
|
||||||
|
refreshToken: string
|
||||||
|
user: AuthUser
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OtpSendCodeResponse = {
|
||||||
|
ok: true
|
||||||
|
expiresInSeconds: number
|
||||||
|
resendAfterSeconds: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAuthSession(data: AuthTokensResponse) {
|
||||||
|
setSession({
|
||||||
|
accessToken: data.accessToken,
|
||||||
|
refreshToken: data.refreshToken,
|
||||||
|
user: data.user,
|
||||||
|
})
|
||||||
|
return data.user
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(cellNumber: string, password: string) {
|
||||||
|
const data = await apiRequest<AuthTokensResponse>('/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
auth: false,
|
||||||
|
body: { cellNumber, password },
|
||||||
|
})
|
||||||
|
return setAuthSession(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginSendCode(cellNumber: string) {
|
||||||
|
return apiRequest<OtpSendCodeResponse>('/auth/login/send-code', {
|
||||||
|
method: 'POST',
|
||||||
|
auth: false,
|
||||||
|
body: { cellNumber },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginVerify(cellNumber: string, code: string) {
|
||||||
|
const data = await apiRequest<AuthTokensResponse>('/auth/login/verify', {
|
||||||
|
method: 'POST',
|
||||||
|
auth: false,
|
||||||
|
body: { cellNumber, code },
|
||||||
|
})
|
||||||
|
return setAuthSession(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function forgotPasswordSendCode(cellNumber: string) {
|
||||||
|
return apiRequest<OtpSendCodeResponse>('/auth/forgot-password/send-code', {
|
||||||
|
method: 'POST',
|
||||||
|
auth: false,
|
||||||
|
body: { cellNumber },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function forgotPasswordVerify(cellNumber: string, code: string) {
|
||||||
|
return apiRequest<{ ok: true }>('/auth/forgot-password/verify', {
|
||||||
|
method: 'POST',
|
||||||
|
auth: false,
|
||||||
|
body: { cellNumber, code },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function forgotPasswordReset(input: {
|
||||||
|
cellNumber: string
|
||||||
|
code: string
|
||||||
|
newPassword: string
|
||||||
|
}) {
|
||||||
|
const data = await apiRequest<AuthTokensResponse>('/auth/forgot-password/reset', {
|
||||||
|
method: 'POST',
|
||||||
|
auth: false,
|
||||||
|
body: input,
|
||||||
|
})
|
||||||
|
return setAuthSession(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerSendCode(input: {
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
cellNumber: string
|
||||||
|
password: string
|
||||||
|
}) {
|
||||||
|
return apiRequest<OtpSendCodeResponse>('/auth/register/send-code', {
|
||||||
|
method: 'POST',
|
||||||
|
auth: false,
|
||||||
|
body: input,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerVerify(cellNumber: string, code: string) {
|
||||||
|
const data = await apiRequest<AuthTokensResponse>('/auth/register/verify', {
|
||||||
|
method: 'POST',
|
||||||
|
auth: false,
|
||||||
|
body: { cellNumber, code },
|
||||||
|
})
|
||||||
|
return setAuthSession(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout() {
|
||||||
|
const refreshToken = getRefreshToken()
|
||||||
|
try {
|
||||||
|
if (refreshToken) {
|
||||||
|
await apiRequest('/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
auth: false,
|
||||||
|
skipRefresh: true,
|
||||||
|
body: { refreshToken },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Always clear local session
|
||||||
|
} finally {
|
||||||
|
clearSession()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadMedia(kind: 'main' | 'gallery' | 'blog' | 'temp', file: File) {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
const { apiUpload } = await import('./api')
|
||||||
|
return apiUpload<{ url: string; storageKey: string }>('/media/upload?kind=' + kind, formData)
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
const FA_DIGITS = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'] as const
|
||||||
|
|
||||||
|
export function toFaDigits(value: string | number): string {
|
||||||
|
return String(value).replace(/\d/g, (d) => FA_DIGITS[Number(d)])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toEnDigits(value: string): string {
|
||||||
|
return value.replace(/[۰-۹]/g, (d) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d)))
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export function normalizeCellNumber(raw: string): string {
|
||||||
|
let digits = raw.replace(/\D/g, '')
|
||||||
|
if (digits.startsWith('0098')) digits = digits.slice(4)
|
||||||
|
else if (digits.startsWith('98')) digits = digits.slice(2)
|
||||||
|
if (digits.startsWith('9') && digits.length === 10) digits = `0${digits}`
|
||||||
|
return digits
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidCellNumber(raw: string): boolean {
|
||||||
|
return /^09\d{9}$/.test(normalizeCellNumber(raw))
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
export const LIST_PAGE_SIZE = 10
|
||||||
|
|
||||||
|
export function usePagination<T>(items: T[], pageSize = LIST_PAGE_SIZE) {
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const pageCount = Math.max(1, Math.ceil(items.length / pageSize) || 1)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage((current) => Math.min(current, pageCount))
|
||||||
|
}, [pageCount])
|
||||||
|
|
||||||
|
const pageItems = useMemo(() => {
|
||||||
|
const start = (page - 1) * pageSize
|
||||||
|
return items.slice(start, start + pageSize)
|
||||||
|
}, [items, page, pageSize])
|
||||||
|
|
||||||
|
return {
|
||||||
|
page,
|
||||||
|
setPage,
|
||||||
|
pageCount,
|
||||||
|
pageItems,
|
||||||
|
pageSize,
|
||||||
|
total: items.length,
|
||||||
|
offset: (page - 1) * pageSize,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import PageTitle from '../components/PageTitle'
|
||||||
|
import FabButton from '../components/FabButton'
|
||||||
|
import Icon from '../components/Icon'
|
||||||
|
import Pagination from '../components/Pagination'
|
||||||
|
import { ApiError } from '../lib/api'
|
||||||
|
import {
|
||||||
|
deleteRepresentation,
|
||||||
|
listRepresentations,
|
||||||
|
type Representation,
|
||||||
|
} from '../lib/adminApi'
|
||||||
|
import storeIcon from '../assets/images/tiles/store.png'
|
||||||
|
import { usePagination } from '../lib/usePagination'
|
||||||
|
import './ListPages.css'
|
||||||
|
|
||||||
|
export default function ActiveRepresentationsPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [items, setItems] = useState<Representation[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
setItems(await listRepresentations('active'))
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'بارگذاری نمایندگیها ناموفق بود')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function onDelete(id: string) {
|
||||||
|
if (!window.confirm('این نمایندگی حذف شود؟')) return
|
||||||
|
try {
|
||||||
|
await deleteRepresentation(id)
|
||||||
|
setItems((prev) => prev.filter((item) => item.id !== id))
|
||||||
|
} catch (err) {
|
||||||
|
window.alert(err instanceof ApiError ? err.message : 'حذف ناموفق بود')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { page, setPage, pageCount, pageItems, offset } = usePagination(items)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel list-page">
|
||||||
|
<PageTitle
|
||||||
|
title="نمایندگی های فعال"
|
||||||
|
subtitle="نمایندگی های ما در سراسر ایران"
|
||||||
|
image={storeIcon}
|
||||||
|
imageWidth={45}
|
||||||
|
onBack={() => navigate('/')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error ? <p className="empty">{error}</p> : null}
|
||||||
|
{loading ? <p className="empty">در حال بارگذاری...</p> : null}
|
||||||
|
|
||||||
|
{!loading && !error ? (
|
||||||
|
<>
|
||||||
|
<div className="list customer-list">
|
||||||
|
{items.length === 0 ? <p className="empty">نمایندگی فعالی نیست</p> : null}
|
||||||
|
{pageItems.map((item, index) => (
|
||||||
|
<article key={item.id} className="request-card request-card--active">
|
||||||
|
<div className="en request-card__index">{offset + index + 1}</div>
|
||||||
|
|
||||||
|
<div className="request-card__person">
|
||||||
|
<strong>{item.name}</strong>
|
||||||
|
<div className="request-card__mobile">{item.owner || item.mobile}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="request-card__place">
|
||||||
|
<div className="place-chip">
|
||||||
|
<strong>{item.city}</strong>
|
||||||
|
<span>{item.address}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="order-row__actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-action icon-action--blue"
|
||||||
|
aria-label="ویرایش"
|
||||||
|
onClick={() => navigate(`/admin/representation/${item.id}/edit`)}
|
||||||
|
>
|
||||||
|
<Icon name="edit" size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-action icon-action--red"
|
||||||
|
aria-label="حذف"
|
||||||
|
onClick={() => void onDelete(item.id)}
|
||||||
|
>
|
||||||
|
<Icon name="trash" size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Pagination page={page} pageCount={pageCount} onPageChange={setPage} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<FabButton
|
||||||
|
label="افزودن نمایندگی"
|
||||||
|
onClick={() => navigate('/admin/representation/active/new')}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { useEffect, useState, type FormEvent } from 'react'
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import PageTitle from '../components/PageTitle'
|
||||||
|
import TextField from '../components/TextField'
|
||||||
|
import PrimaryButton from '../components/PrimaryButton'
|
||||||
|
import { useAuth } from '../auth/AuthContext'
|
||||||
|
import { ApiError } from '../lib/api'
|
||||||
|
import { createBlog, getBlog, updateBlog, uploadMedia } from '../lib/adminApi'
|
||||||
|
import './ListPages.css'
|
||||||
|
|
||||||
|
export default function BlogFormPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { id } = useParams()
|
||||||
|
const { user } = useAuth()
|
||||||
|
const isEdit = Boolean(id)
|
||||||
|
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
title: '',
|
||||||
|
summary: '',
|
||||||
|
content: '',
|
||||||
|
author: user?.name ?? '',
|
||||||
|
})
|
||||||
|
const [imageUrl, setImageUrl] = useState<string | null>(null)
|
||||||
|
const [imageKey, setImageKey] = useState<string | null>(null)
|
||||||
|
const [loading, setLoading] = useState(isEdit)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const post = await getBlog(id)
|
||||||
|
setForm({
|
||||||
|
title: post.title,
|
||||||
|
summary: post.summary,
|
||||||
|
content: post.body,
|
||||||
|
author: post.author,
|
||||||
|
})
|
||||||
|
setImageUrl(post.imageUrl)
|
||||||
|
setImageKey(post.imageKey)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'بارگذاری مطلب ناموفق بود')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}, [id])
|
||||||
|
|
||||||
|
function update(key: keyof typeof form, value: string) {
|
||||||
|
setForm((prev) => ({ ...prev, [key]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onImageChange(file: File | null) {
|
||||||
|
if (!file) return
|
||||||
|
try {
|
||||||
|
const uploaded = await uploadMedia('blog', file)
|
||||||
|
setImageUrl(uploaded.url)
|
||||||
|
setImageKey(uploaded.storageKey)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'آپلود تصویر ناموفق بود')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setSaving(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
title: form.title.trim(),
|
||||||
|
summary: form.summary.trim(),
|
||||||
|
body: form.content.trim(),
|
||||||
|
author: form.author.trim() || user?.name || '',
|
||||||
|
imageUrl: imageUrl ?? undefined,
|
||||||
|
imageKey: imageKey ?? undefined,
|
||||||
|
}
|
||||||
|
if (isEdit && id) {
|
||||||
|
await updateBlog(id, payload)
|
||||||
|
} else {
|
||||||
|
await createBlog(payload)
|
||||||
|
}
|
||||||
|
navigate('/blog-list')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'ذخیره ناموفق بود')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel list-page">
|
||||||
|
<PageTitle
|
||||||
|
title={isEdit ? 'ویرایش مطلب' : 'افزودن مطلب'}
|
||||||
|
subtitle="عنوان، خلاصه و متن مطلب را وارد کنید"
|
||||||
|
onBack={() => navigate('/blog-list')}
|
||||||
|
/>
|
||||||
|
{loading ? <p className="empty">در حال بارگذاری...</p> : null}
|
||||||
|
{!loading ? (
|
||||||
|
<form className="form-grid" onSubmit={(e) => void onSubmit(e)}>
|
||||||
|
<div className="full">
|
||||||
|
<TextField label="عنوان" value={form.title} onChange={(v) => update('title', v)} />
|
||||||
|
</div>
|
||||||
|
<div className="full">
|
||||||
|
<TextField label="خلاصه" value={form.summary} onChange={(v) => update('summary', v)} />
|
||||||
|
</div>
|
||||||
|
<div className="full">
|
||||||
|
<TextField label="متن" value={form.content} onChange={(v) => update('content', v)} />
|
||||||
|
</div>
|
||||||
|
<div className="full">
|
||||||
|
<TextField label="نویسنده" value={form.author} onChange={(v) => update('author', v)} />
|
||||||
|
</div>
|
||||||
|
<div className="full">
|
||||||
|
<label className="filters__label">تصویر</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp"
|
||||||
|
onChange={(e) => void onImageChange(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
{imageUrl ? (
|
||||||
|
<img src={imageUrl} alt="" style={{ marginTop: 12, maxWidth: 220, borderRadius: 8 }} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{error ? <p className="login__error full">{error}</p> : null}
|
||||||
|
<div className="full" style={{ display: 'flex', gap: 12 }}>
|
||||||
|
<PrimaryButton type="submit" disabled={saving}>
|
||||||
|
ذخیره
|
||||||
|
</PrimaryButton>
|
||||||
|
<PrimaryButton type="button" variant="secondary" onClick={() => navigate('/blog-list')}>
|
||||||
|
انصراف
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
.blog-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
min-height: 180px;
|
||||||
|
border: 1px solid #e6e6e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-card__media {
|
||||||
|
flex: 0 0 42%;
|
||||||
|
max-width: 42%;
|
||||||
|
min-height: 100%;
|
||||||
|
background: #f3f3f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-card__media img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-card__body {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-card__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #222;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-card__summary {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-card__footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-card__author {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #efefef;
|
||||||
|
color: #666;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-card__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.icon-action {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.blog-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-card--media {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-card__media {
|
||||||
|
flex: none;
|
||||||
|
max-width: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 180px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import PageTitle from '../components/PageTitle'
|
||||||
|
import FabButton from '../components/FabButton'
|
||||||
|
import Icon from '../components/Icon'
|
||||||
|
import Pagination from '../components/Pagination'
|
||||||
|
import { ApiError } from '../lib/api'
|
||||||
|
import { deleteBlog, listBlogs, type BlogPost } from '../lib/adminApi'
|
||||||
|
import { usePagination } from '../lib/usePagination'
|
||||||
|
import rssIcon from '../assets/images/tiles/rss.png'
|
||||||
|
import './ListPages.css'
|
||||||
|
import './BlogListPage.css'
|
||||||
|
|
||||||
|
export default function BlogListPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [posts, setPosts] = useState<BlogPost[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
setPosts(await listBlogs())
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'بارگذاری مطالب ناموفق بود')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function onDelete(id: string) {
|
||||||
|
if (!window.confirm('این مطلب حذف شود؟')) return
|
||||||
|
try {
|
||||||
|
await deleteBlog(id)
|
||||||
|
setPosts((prev) => prev.filter((p) => p.id !== id))
|
||||||
|
} catch (err) {
|
||||||
|
window.alert(err instanceof ApiError ? err.message : 'حذف ناموفق بود')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { page, setPage, pageCount, pageItems } = usePagination(posts)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel list-page">
|
||||||
|
<PageTitle
|
||||||
|
title="مطالب و اخبار مادو موتور"
|
||||||
|
subtitle="آنچه میخواهیم مردم درباره ی ما بدانند."
|
||||||
|
image={rssIcon}
|
||||||
|
imageWidth={45}
|
||||||
|
onBack={() => navigate('/')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error ? <p className="empty">{error}</p> : null}
|
||||||
|
{loading ? <p className="empty">در حال بارگذاری...</p> : null}
|
||||||
|
|
||||||
|
{!loading && !error ? (
|
||||||
|
<>
|
||||||
|
<div className="blog-grid">
|
||||||
|
{posts.length === 0 ? <p className="empty">مطلبی ثبت نشده است</p> : null}
|
||||||
|
{pageItems.map((post) => (
|
||||||
|
<article
|
||||||
|
key={post.id}
|
||||||
|
className={`blog-card${post.imageUrl ? ' blog-card--media' : ''}`}
|
||||||
|
>
|
||||||
|
{post.imageUrl ? (
|
||||||
|
<div className="blog-card__media">
|
||||||
|
<img src={post.imageUrl} alt="" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="blog-card__body">
|
||||||
|
<h3 className="blog-card__title">{post.title}</h3>
|
||||||
|
<p className="blog-card__summary">{post.summary}</p>
|
||||||
|
|
||||||
|
<div className="blog-card__footer">
|
||||||
|
<div className="blog-card__author">
|
||||||
|
<Icon name="user" size={14} />
|
||||||
|
<span>{post.author || '—'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="blog-card__actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-action icon-action--red"
|
||||||
|
aria-label="حذف"
|
||||||
|
onClick={() => void onDelete(post.id)}
|
||||||
|
>
|
||||||
|
<Icon name="trash" size={16} />
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
to={`/edit-blog/${post.id}`}
|
||||||
|
className="icon-action icon-action--blue"
|
||||||
|
aria-label="ویرایش"
|
||||||
|
>
|
||||||
|
<Icon name="edit" size={16} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Pagination page={page} pageCount={pageCount} onPageChange={setPage} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<FabButton label="مطلب جدید" onClick={() => navigate('/add-blog')} />
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import PageTitle from '../components/PageTitle'
|
||||||
|
import TextField from '../components/TextField'
|
||||||
|
import PrimaryButton from '../components/PrimaryButton'
|
||||||
|
import { ApiError } from '../lib/api'
|
||||||
|
import { createCallRequest } from '../lib/adminApi'
|
||||||
|
import './ListPages.css'
|
||||||
|
|
||||||
|
export default function CallRequestFormPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [form, setForm] = useState({ name: '', mobile: '', note: '' })
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
function update(key: keyof typeof form, value: string) {
|
||||||
|
setForm((prev) => ({ ...prev, [key]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setSaving(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await createCallRequest({
|
||||||
|
name: form.name.trim(),
|
||||||
|
mobile: form.mobile.trim(),
|
||||||
|
note: form.note.trim(),
|
||||||
|
})
|
||||||
|
navigate('/admin/call-request-list')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'ذخیره ناموفق بود')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel list-page">
|
||||||
|
<PageTitle
|
||||||
|
title="درخواست تماس جدید"
|
||||||
|
subtitle="اطلاعات تماس را وارد کنید"
|
||||||
|
onBack={() => navigate('/admin/call-request-list')}
|
||||||
|
/>
|
||||||
|
<form className="form-grid" onSubmit={(e) => void onSubmit(e)}>
|
||||||
|
<TextField label="نام" value={form.name} onChange={(v) => update('name', v)} />
|
||||||
|
<TextField label="شماره همراه" value={form.mobile} onChange={(v) => update('mobile', v)} dir="ltr" />
|
||||||
|
<div className="full">
|
||||||
|
<TextField label="یادداشت" value={form.note} onChange={(v) => update('note', v)} />
|
||||||
|
</div>
|
||||||
|
{error ? <p className="login__error full">{error}</p> : null}
|
||||||
|
<div className="full" style={{ display: 'flex', gap: 12 }}>
|
||||||
|
<PrimaryButton type="submit" disabled={saving}>
|
||||||
|
ذخیره
|
||||||
|
</PrimaryButton>
|
||||||
|
<PrimaryButton type="button" variant="secondary" onClick={() => navigate('/admin/call-request-list')}>
|
||||||
|
انصراف
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import PageTitle from '../components/PageTitle'
|
||||||
|
import FabButton from '../components/FabButton'
|
||||||
|
import Icon from '../components/Icon'
|
||||||
|
import Pagination from '../components/Pagination'
|
||||||
|
import { ApiError } from '../lib/api'
|
||||||
|
import {
|
||||||
|
deleteCallRequest,
|
||||||
|
formatFaDate,
|
||||||
|
listCallRequests,
|
||||||
|
markCallHandled,
|
||||||
|
type CallRequest,
|
||||||
|
} from '../lib/adminApi'
|
||||||
|
import storeIcon from '../assets/images/tiles/store.png'
|
||||||
|
import { usePagination } from '../lib/usePagination'
|
||||||
|
import './ListPages.css'
|
||||||
|
|
||||||
|
export default function CallRequestsPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [items, setItems] = useState<CallRequest[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
setItems(await listCallRequests())
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'بارگذاری درخواستها ناموفق بود')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function onHandled(id: string) {
|
||||||
|
try {
|
||||||
|
await markCallHandled(id)
|
||||||
|
setItems((prev) =>
|
||||||
|
prev.map((item) => (item.id === id ? { ...item, handled: true } : item)),
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
window.alert(err instanceof ApiError ? err.message : 'عملیات ناموفق بود')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(id: string) {
|
||||||
|
if (!window.confirm('این درخواست حذف شود؟')) return
|
||||||
|
try {
|
||||||
|
await deleteCallRequest(id)
|
||||||
|
setItems((prev) => prev.filter((item) => item.id !== id))
|
||||||
|
} catch (err) {
|
||||||
|
window.alert(err instanceof ApiError ? err.message : 'حذف ناموفق بود')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { page, setPage, pageCount, pageItems, offset } = usePagination(items)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel list-page">
|
||||||
|
<PageTitle
|
||||||
|
title="درخواست های تماس من"
|
||||||
|
subtitle="درخواست های تماس کاربران از سرتاسر ایران"
|
||||||
|
image={storeIcon}
|
||||||
|
imageWidth={45}
|
||||||
|
onBack={() => navigate('/')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error ? <p className="empty">{error}</p> : null}
|
||||||
|
{loading ? <p className="empty">در حال بارگذاری...</p> : null}
|
||||||
|
|
||||||
|
{!loading && !error ? (
|
||||||
|
<>
|
||||||
|
<div className="list customer-list">
|
||||||
|
{items.length === 0 ? <p className="empty">درخواستی ثبت نشده است</p> : null}
|
||||||
|
{pageItems.map((item, index) => (
|
||||||
|
<article key={item.id} className="request-card request-card--call">
|
||||||
|
<div className="en request-card__index">{offset + index + 1}</div>
|
||||||
|
|
||||||
|
<div className="request-card__person">
|
||||||
|
<strong>{item.name}</strong>
|
||||||
|
<div className="en request-card__mobile">{item.mobile}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="request-card__id">
|
||||||
|
<div className="en request-card__id-label">
|
||||||
|
requestID: <span>{item.id.slice(-6)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="request-card__date">{formatFaDate(item.createdAt)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="order-row__actions">
|
||||||
|
<button type="button" className="icon-action icon-action--blue" aria-label="تماس">
|
||||||
|
<Icon name="phone" size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-action icon-action--teal"
|
||||||
|
aria-label="افزودن"
|
||||||
|
onClick={() => void onHandled(item.id)}
|
||||||
|
disabled={item.handled}
|
||||||
|
>
|
||||||
|
<Icon name="plus" size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-action icon-action--red"
|
||||||
|
aria-label="حذف"
|
||||||
|
onClick={() => void onDelete(item.id)}
|
||||||
|
>
|
||||||
|
<Icon name="trash" size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Pagination page={page} pageCount={pageCount} onPageChange={setPage} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<FabButton
|
||||||
|
label="درخواست تماس جدید"
|
||||||
|
onClick={() => navigate('/admin/call-request/new')}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { useEffect, useState, type FormEvent } from 'react'
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import PageTitle from '../components/PageTitle'
|
||||||
|
import TextField from '../components/TextField'
|
||||||
|
import PrimaryButton from '../components/PrimaryButton'
|
||||||
|
import { ApiError } from '../lib/api'
|
||||||
|
import { createCustomer, getCustomer, updateCustomer } from '../lib/adminApi'
|
||||||
|
import './ListPages.css'
|
||||||
|
|
||||||
|
export default function CustomerFormPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { id } = useParams()
|
||||||
|
const isEdit = Boolean(id)
|
||||||
|
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
cellNumber: '',
|
||||||
|
city: '',
|
||||||
|
password: '',
|
||||||
|
})
|
||||||
|
const [loading, setLoading] = useState(isEdit)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const customer = await getCustomer(id)
|
||||||
|
setForm({
|
||||||
|
firstName: customer.firstName,
|
||||||
|
lastName: customer.lastName,
|
||||||
|
cellNumber: customer.cellNumber,
|
||||||
|
city: customer.city,
|
||||||
|
password: '',
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'بارگذاری مشتری ناموفق بود')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}, [id])
|
||||||
|
|
||||||
|
function update(key: keyof typeof form, value: string) {
|
||||||
|
setForm((prev) => ({ ...prev, [key]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setSaving(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
if (isEdit && id) {
|
||||||
|
await updateCustomer(id, {
|
||||||
|
name: `${form.firstName} ${form.lastName}`.trim(),
|
||||||
|
city: form.city.trim(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await createCustomer({
|
||||||
|
firstName: form.firstName.trim(),
|
||||||
|
lastName: form.lastName.trim(),
|
||||||
|
cellNumber: form.cellNumber.trim(),
|
||||||
|
city: form.city.trim(),
|
||||||
|
password: form.password || '1234',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
navigate('/admin/my-customers')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'ذخیره ناموفق بود')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel list-page">
|
||||||
|
<PageTitle
|
||||||
|
title={isEdit ? 'ویرایش مشتری' : 'افزودن مشتری'}
|
||||||
|
subtitle="اطلاعات مشتری را وارد کنید"
|
||||||
|
onBack={() => navigate('/admin/my-customers')}
|
||||||
|
/>
|
||||||
|
{loading ? <p className="empty">در حال بارگذاری...</p> : null}
|
||||||
|
{!loading ? (
|
||||||
|
<form className="form-grid" onSubmit={(e) => void onSubmit(e)}>
|
||||||
|
<TextField label="نام" value={form.firstName} onChange={(v) => update('firstName', v)} />
|
||||||
|
<TextField label="نام خانوادگی" value={form.lastName} onChange={(v) => update('lastName', v)} />
|
||||||
|
<TextField
|
||||||
|
label="شماره همراه"
|
||||||
|
value={form.cellNumber}
|
||||||
|
onChange={(v) => update('cellNumber', v)}
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
<TextField label="شهر" value={form.city} onChange={(v) => update('city', v)} />
|
||||||
|
{!isEdit ? (
|
||||||
|
<TextField
|
||||||
|
label="رمز عبور"
|
||||||
|
value={form.password}
|
||||||
|
onChange={(v) => update('password', v)}
|
||||||
|
type="password"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{error ? <p className="login__error full">{error}</p> : null}
|
||||||
|
<div className="full" style={{ display: 'flex', gap: 12 }}>
|
||||||
|
<PrimaryButton type="submit" disabled={saving}>
|
||||||
|
ذخیره
|
||||||
|
</PrimaryButton>
|
||||||
|
<PrimaryButton type="button" variant="secondary" onClick={() => navigate('/admin/my-customers')}>
|
||||||
|
انصراف
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import PageTitle from '../components/PageTitle'
|
||||||
|
import FabButton from '../components/FabButton'
|
||||||
|
import Icon from '../components/Icon'
|
||||||
|
import Pagination from '../components/Pagination'
|
||||||
|
import { ApiError } from '../lib/api'
|
||||||
|
import { deleteCustomer, listCustomers, type Customer } from '../lib/adminApi'
|
||||||
|
import { usePagination } from '../lib/usePagination'
|
||||||
|
import userIcon from '../assets/images/tiles/user.svg'
|
||||||
|
import './ListPages.css'
|
||||||
|
|
||||||
|
export default function CustomersPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [customers, setCustomers] = useState<Customer[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
setCustomers(await listCustomers())
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'بارگذاری مشتریان ناموفق بود')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function onDelete(id: string) {
|
||||||
|
if (!window.confirm('این مشتری حذف شود؟')) return
|
||||||
|
try {
|
||||||
|
await deleteCustomer(id)
|
||||||
|
setCustomers((prev) => prev.filter((c) => c.id !== id))
|
||||||
|
} catch (err) {
|
||||||
|
window.alert(err instanceof ApiError ? err.message : 'حذف ناموفق بود')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { page, setPage, pageCount, pageItems } = usePagination(customers)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel list-page">
|
||||||
|
<PageTitle
|
||||||
|
title="مشتریان من"
|
||||||
|
subtitle="تمام مشتریانی که ثبت نام کرده اند"
|
||||||
|
image={userIcon}
|
||||||
|
imageWidth={45}
|
||||||
|
onBack={() => navigate('/')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error ? <p className="empty">{error}</p> : null}
|
||||||
|
{loading ? <p className="empty">در حال بارگذاری...</p> : null}
|
||||||
|
|
||||||
|
{!loading && !error ? (
|
||||||
|
<>
|
||||||
|
<div className="list customer-list">
|
||||||
|
{customers.length === 0 ? <p className="empty">مشتری ثبت نشده است</p> : null}
|
||||||
|
{pageItems.map((customer) => (
|
||||||
|
<article key={customer.id} className="customer-card">
|
||||||
|
<div className="customer-card__info">
|
||||||
|
<strong>{customer.name}</strong>
|
||||||
|
<div className="en customer-card__mobile">{customer.cellNumber}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="customer-card__city">{customer.city || '—'}</div>
|
||||||
|
|
||||||
|
<div className="customer-card__orders">
|
||||||
|
<div className="order-chip" aria-label={`${customer.ordersCount} سفارش`}>
|
||||||
|
<span className="en order-chip__count">{customer.ordersCount}</span>
|
||||||
|
<span className="order-chip__label">
|
||||||
|
مجموع
|
||||||
|
<br />
|
||||||
|
سفارشات
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="order-row__actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-action icon-action--red"
|
||||||
|
aria-label="حذف"
|
||||||
|
onClick={() => void onDelete(customer.id)}
|
||||||
|
>
|
||||||
|
<Icon name="trash" size={16} />
|
||||||
|
</button>
|
||||||
|
<button type="button" className="icon-action icon-action--orange" aria-label="ارسال پیام کوتاه">
|
||||||
|
<Icon name="chat" size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-action icon-action--blue"
|
||||||
|
aria-label="ویرایش"
|
||||||
|
onClick={() => navigate(`/admin/my-customers/${customer.id}/edit`)}
|
||||||
|
>
|
||||||
|
<Icon name="edit" size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Pagination page={page} pageCount={pageCount} onPageChange={setPage} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<FabButton label="افزودن مشتری" onClick={() => navigate('/admin/my-customers/new')} />
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
.dashboard {
|
||||||
|
padding: 8px 8px 16px;
|
||||||
|
height: 100%;
|
||||||
|
align-items: flex-start;
|
||||||
|
align-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard__grid {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
|
gap: 4px;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.dashboard__grid {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.dashboard__grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import DashboardTileCard from '../components/DashboardTile'
|
||||||
|
import { adminTiles } from '../data/tiles'
|
||||||
|
import './DashboardPage.css'
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
return (
|
||||||
|
<section className="panel dashboard">
|
||||||
|
<div className="dashboard__grid">
|
||||||
|
{adminTiles.map((tile) => (
|
||||||
|
<DashboardTileCard key={tile.id} tile={tile} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../auth/AuthContext'
|
||||||
|
import TextField from '../components/TextField'
|
||||||
|
import PrimaryButton from '../components/PrimaryButton'
|
||||||
|
import Icon from '../components/Icon'
|
||||||
|
import userIcon from '../assets/images/user-icon.svg'
|
||||||
|
import { ApiError } from '../lib/api'
|
||||||
|
import {
|
||||||
|
forgotPasswordReset,
|
||||||
|
forgotPasswordSendCode,
|
||||||
|
forgotPasswordVerify,
|
||||||
|
} from '../lib/authApi'
|
||||||
|
import { isValidCellNumber, normalizeCellNumber } from '../lib/phone'
|
||||||
|
import './LoginPage.css'
|
||||||
|
|
||||||
|
const WEBSITE_URL = import.meta.env.VITE_WEBSITE_URL || 'http://madomotor.com:5174'
|
||||||
|
|
||||||
|
type Step = 'mobile' | 'code' | 'password'
|
||||||
|
|
||||||
|
export default function ForgotPasswordPage() {
|
||||||
|
const { reloadUser } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [step, setStep] = useState<Step>('mobile')
|
||||||
|
const [mobile, setMobile] = useState('')
|
||||||
|
const [code, setCode] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [passwordConfirmation, setPasswordConfirmation] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const cellNumber = normalizeCellNumber(mobile)
|
||||||
|
|
||||||
|
async function onSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
if (step === 'mobile') {
|
||||||
|
if (!isValidCellNumber(cellNumber)) {
|
||||||
|
throw new Error('شماره همراه معتبر نیست')
|
||||||
|
}
|
||||||
|
await forgotPasswordSendCode(cellNumber)
|
||||||
|
setStep('code')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step === 'code') {
|
||||||
|
await forgotPasswordVerify(cellNumber, code.trim())
|
||||||
|
setStep('password')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password !== passwordConfirmation) {
|
||||||
|
throw new Error('رمز عبور و تکرار آن یکسان نیستند')
|
||||||
|
}
|
||||||
|
await forgotPasswordReset({
|
||||||
|
cellNumber,
|
||||||
|
code: code.trim(),
|
||||||
|
newPassword: password,
|
||||||
|
})
|
||||||
|
reloadUser()
|
||||||
|
navigate('/', { replace: true })
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError || err instanceof Error ? err.message : 'خطا در بازیابی رمز')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login">
|
||||||
|
<div className="login__heading">
|
||||||
|
<a href={WEBSITE_URL} className="login__back" aria-label="بازگشت به صفحه اصلی">
|
||||||
|
<Icon name="chevron" size={18} />
|
||||||
|
</a>
|
||||||
|
<img src={userIcon} alt="" className="login__user-icon" />
|
||||||
|
<div className="login__heading-text">
|
||||||
|
<h1>بازیابی رمز عبور</h1>
|
||||||
|
<p>
|
||||||
|
{step === 'mobile' && 'شماره همراه خود را وارد کنید'}
|
||||||
|
{step === 'code' && `کد ارسالشده به ${cellNumber} را وارد کنید`}
|
||||||
|
{step === 'password' && 'رمز عبور جدید را انتخاب کنید'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form className="login__form" onSubmit={onSubmit}>
|
||||||
|
{step === 'mobile' ? (
|
||||||
|
<TextField label="شماره ی همراه" value={mobile} onChange={setMobile} type="tel" dir="ltr" />
|
||||||
|
) : null}
|
||||||
|
{step === 'code' ? (
|
||||||
|
<TextField label="کد تایید" value={code} onChange={setCode} type="tel" dir="ltr" />
|
||||||
|
) : null}
|
||||||
|
{step === 'password' ? (
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
label="رمز عبور جدید"
|
||||||
|
value={password}
|
||||||
|
onChange={setPassword}
|
||||||
|
type="password"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="تکرار رمز عبور"
|
||||||
|
value={passwordConfirmation}
|
||||||
|
onChange={setPasswordConfirmation}
|
||||||
|
type="password"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{error ? <p className="login__error">{error}</p> : null}
|
||||||
|
|
||||||
|
<div className="login__actions">
|
||||||
|
<Link to="/auth/login" className="btn btn--secondary">
|
||||||
|
بازگشت
|
||||||
|
</Link>
|
||||||
|
<PrimaryButton type="submit" disabled={loading}>
|
||||||
|
{step === 'mobile' && 'ارسال کد تایید'}
|
||||||
|
{step === 'code' && 'ثبت'}
|
||||||
|
{step === 'password' && 'تغییر رمز'}
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,441 @@
|
|||||||
|
.list-page {
|
||||||
|
padding-top: 24px;
|
||||||
|
padding-bottom: 88px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-row {
|
||||||
|
display: grid;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 10px 4px;
|
||||||
|
border-bottom: 1px solid #efefef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-row {
|
||||||
|
grid-template-columns: 1.4fr 1.2fr 0.7fr auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.3fr 1.2fr auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border: 1px solid #e6e6e6;
|
||||||
|
border-right: 3px solid var(--color-primary);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-card__id,
|
||||||
|
.order-card__customer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-card__meta {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-card__meta span {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-card__date,
|
||||||
|
.order-card__mobile {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-card__customer strong {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-card__items {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-row__id {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-row__id-num {
|
||||||
|
font-size: 34px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-primary);
|
||||||
|
line-height: 1;
|
||||||
|
min-width: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-row__meta {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-row__date {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-row__customer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-row__customer strong {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-row__customer span {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-row__count {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-row__count strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 30px;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-row__count span {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-row__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-action {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-action svg {
|
||||||
|
stroke-width: 2.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-action:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-action--red {
|
||||||
|
background: #fce4ec;
|
||||||
|
color: #e91e63;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-action--red:hover {
|
||||||
|
background: #f8bbd0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-action--blue {
|
||||||
|
background: #e3f2fd;
|
||||||
|
color: #1565c0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-action--blue:hover {
|
||||||
|
background: #bbdefb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-action--orange {
|
||||||
|
background: #fff3e0;
|
||||||
|
color: #ef6c00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-action--orange:hover {
|
||||||
|
background: #ffe0b2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-action--teal {
|
||||||
|
background: #e0f7fa;
|
||||||
|
color: #00838f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-action--teal:hover {
|
||||||
|
background: #b2ebf2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: end;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters__label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-row,
|
||||||
|
.simple-row {
|
||||||
|
grid-template-columns: 1.5fr 1fr 1fr auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-list {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.6fr 1fr 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border: 1px solid #e6e6e6;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-card__info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-card__info strong {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-card__mobile {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #888;
|
||||||
|
text-align: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-card__city {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-card__orders {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
justify-self: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: max-content;
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: #e3f2fd;
|
||||||
|
color: #1976d2;
|
||||||
|
line-height: 1.15;
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-chip__count {
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-chip__label {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-align: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr 1.2fr 1.6fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border: 1px solid #e6e6e6;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-card--active {
|
||||||
|
grid-template-columns: auto 1.2fr 1.8fr auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-card--call {
|
||||||
|
grid-template-columns: auto 1.2fr 1.4fr auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-card__index {
|
||||||
|
font-size: 34px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-primary);
|
||||||
|
line-height: 1;
|
||||||
|
min-width: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-card__id,
|
||||||
|
.request-card__person {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-card__id-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-card__id-label span {
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-card__date,
|
||||||
|
.request-card__mobile {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-card__person strong {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-card__place {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
max-width: 100%;
|
||||||
|
width: max-content;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #e3f2fd;
|
||||||
|
color: #1976d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-chip strong {
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-chip span {
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-row strong,
|
||||||
|
.simple-row strong {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-row .en,
|
||||||
|
.simple-row .muted {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
background: #eef5ff;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge--pending {
|
||||||
|
background: #fff4e5;
|
||||||
|
color: #c47a00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 40px 0;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__error {
|
||||||
|
color: #d32f2f;
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid .full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.order-row,
|
||||||
|
.product-row,
|
||||||
|
.simple-row,
|
||||||
|
.customer-card,
|
||||||
|
.request-card,
|
||||||
|
.order-card {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-row__actions,
|
||||||
|
.list-row .order-row__actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
.login__heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__user-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__heading-text {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__heading h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__heading p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__back {
|
||||||
|
color: #c5c5c5;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 36px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__back:hover {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__form {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1.15fr;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 4px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__actions .btn {
|
||||||
|
width: 100%;
|
||||||
|
height: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__actions .btn--secondary {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
.login__actions .btn--secondary {
|
||||||
|
margin-left: 4rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__forgot {
|
||||||
|
display: block;
|
||||||
|
margin-top: 22px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__submit-icon {
|
||||||
|
display: inline-grid;
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__error {
|
||||||
|
margin: 0;
|
||||||
|
color: #d32f2f;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { Link, Navigate, useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../auth/AuthContext'
|
||||||
|
import TextField from '../components/TextField'
|
||||||
|
import PrimaryButton from '../components/PrimaryButton'
|
||||||
|
import Icon from '../components/Icon'
|
||||||
|
import userIcon from '../assets/images/user-icon.svg'
|
||||||
|
import './LoginPage.css'
|
||||||
|
|
||||||
|
const WEBSITE_URL = import.meta.env.VITE_WEBSITE_URL || 'http://madomotor.com:5174'
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const { login, isAuthenticated } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [params] = useSearchParams()
|
||||||
|
const [mobile, setMobile] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
return <Navigate to={params.get('redirect') || '/'} replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
await login(mobile, password)
|
||||||
|
navigate(params.get('redirect') || '/', { replace: true })
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'ورود ناموفق بود')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login">
|
||||||
|
<div className="login__heading">
|
||||||
|
<a href={WEBSITE_URL} className="login__back" aria-label="بازگشت به صفحه اصلی">
|
||||||
|
<Icon name="chevron" size={18} />
|
||||||
|
</a>
|
||||||
|
<img src={userIcon} alt="" className="login__user-icon" />
|
||||||
|
<div className="login__heading-text">
|
||||||
|
<h1>حساب کاربری من</h1>
|
||||||
|
<p>ورود به محیط مدیریت شما</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="login__form" onSubmit={onSubmit}>
|
||||||
|
<TextField
|
||||||
|
label="شماره ی همراه"
|
||||||
|
value={mobile}
|
||||||
|
onChange={setMobile}
|
||||||
|
type="tel"
|
||||||
|
autoComplete="username"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="کلمه ی عبور"
|
||||||
|
value={password}
|
||||||
|
onChange={setPassword}
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error ? <p className="login__error">{error}</p> : null}
|
||||||
|
|
||||||
|
<div className="login__actions">
|
||||||
|
{/* Register first → right in RTL; Login second → left (matches original) */}
|
||||||
|
<Link to="/auth/register" className="btn btn--secondary">
|
||||||
|
<Icon name="plus" size={14} />
|
||||||
|
ثبت نام
|
||||||
|
</Link>
|
||||||
|
<PrimaryButton type="submit" disabled={loading}>
|
||||||
|
ورود به سیستم
|
||||||
|
<span className="login__submit-icon">
|
||||||
|
<Icon name="chevron" size={14} />
|
||||||
|
</span>
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<Link to="/auth/forgot-password" className="login__forgot">
|
||||||
|
حساب کاربری دارم، ولی رمز عبورم را فراموش کرده ام
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import PageTitle from '../components/PageTitle'
|
||||||
|
import FabButton from '../components/FabButton'
|
||||||
|
import Icon from '../components/Icon'
|
||||||
|
import Pagination from '../components/Pagination'
|
||||||
|
import { ApiError } from '../lib/api'
|
||||||
|
import { formatFaDate, listOrders, type Order } from '../lib/adminApi'
|
||||||
|
import { usePagination } from '../lib/usePagination'
|
||||||
|
import cartIcon from '../assets/images/tiles/cart.png'
|
||||||
|
import './ListPages.css'
|
||||||
|
|
||||||
|
export default function OrdersPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [orders, setOrders] = useState<Order[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
setOrders(await listOrders())
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'بارگذاری سفارشات ناموفق بود')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const { page, setPage, pageCount, pageItems } = usePagination(orders)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel list-page">
|
||||||
|
<PageTitle
|
||||||
|
title="سفارشات مادو موتور"
|
||||||
|
subtitle="تمام سفارشاتی که کاربران ثبت کرده اند"
|
||||||
|
image={cartIcon}
|
||||||
|
imageWidth={45}
|
||||||
|
onBack={() => navigate('/')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error ? <p className="empty">{error}</p> : null}
|
||||||
|
{loading ? <p className="empty">در حال بارگذاری...</p> : null}
|
||||||
|
|
||||||
|
{!loading && !error ? (
|
||||||
|
<>
|
||||||
|
<div className="list customer-list">
|
||||||
|
{orders.length === 0 ? <p className="empty">سفارشی ثبت نشده است</p> : null}
|
||||||
|
{pageItems.map((order) => (
|
||||||
|
<article key={order.id} className="order-card">
|
||||||
|
<div className="order-card__id">
|
||||||
|
<div className="en order-card__meta">
|
||||||
|
OrderID <span>{order.id}</span>
|
||||||
|
</div>
|
||||||
|
<div className="order-card__date">{formatFaDate(order.createdAt)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="order-card__customer">
|
||||||
|
<strong>{order.customerName}</strong>
|
||||||
|
<div className="en order-card__mobile">{order.customerPhone}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="order-card__items">
|
||||||
|
<div className="order-chip" aria-label={`${order.itemCount} آیتم`}>
|
||||||
|
<span className="en order-chip__count">{order.itemCount}</span>
|
||||||
|
<span className="order-chip__label">
|
||||||
|
مجموع
|
||||||
|
<br />
|
||||||
|
آیتم ها
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="order-row__actions">
|
||||||
|
<button type="button" className="icon-action icon-action--orange" aria-label="پیام">
|
||||||
|
<Icon name="chat" size={16} />
|
||||||
|
</button>
|
||||||
|
<button type="button" className="icon-action icon-action--blue" aria-label="جزئیات">
|
||||||
|
<Icon name="edit" size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Pagination page={page} pageCount={pageCount} onPageChange={setPage} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<FabButton label="ثبت سفارش جدید" />
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { useEffect, useState, type FormEvent } from 'react'
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import PageTitle from '../components/PageTitle'
|
||||||
|
import TextField from '../components/TextField'
|
||||||
|
import PrimaryButton from '../components/PrimaryButton'
|
||||||
|
import { ApiError } from '../lib/api'
|
||||||
|
import {
|
||||||
|
createProduct,
|
||||||
|
getProduct,
|
||||||
|
updateProduct,
|
||||||
|
uploadMedia,
|
||||||
|
} from '../lib/adminApi'
|
||||||
|
import './ListPages.css'
|
||||||
|
|
||||||
|
export default function ProductFormPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { id } = useParams()
|
||||||
|
const isEdit = Boolean(id)
|
||||||
|
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
nameFa: '',
|
||||||
|
nameEn: '',
|
||||||
|
price: '',
|
||||||
|
stock: '0',
|
||||||
|
category: '',
|
||||||
|
})
|
||||||
|
const [imageUrl, setImageUrl] = useState<string | null>(null)
|
||||||
|
const [imageKey, setImageKey] = useState<string | null>(null)
|
||||||
|
const [loading, setLoading] = useState(isEdit)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const product = await getProduct(id)
|
||||||
|
setForm({
|
||||||
|
nameFa: product.nameFa,
|
||||||
|
nameEn: product.nameEn,
|
||||||
|
price: String(product.price),
|
||||||
|
stock: String(product.stock),
|
||||||
|
category: product.category,
|
||||||
|
})
|
||||||
|
setImageUrl(product.imageUrl)
|
||||||
|
setImageKey(product.imageKey)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'بارگذاری محصول ناموفق بود')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}, [id])
|
||||||
|
|
||||||
|
function update(key: keyof typeof form, value: string) {
|
||||||
|
setForm((prev) => ({ ...prev, [key]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onImageChange(file: File | null) {
|
||||||
|
if (!file) return
|
||||||
|
try {
|
||||||
|
const uploaded = await uploadMedia('main', file)
|
||||||
|
setImageUrl(uploaded.url)
|
||||||
|
setImageKey(uploaded.storageKey)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'آپلود تصویر ناموفق بود')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setSaving(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
nameFa: form.nameFa.trim(),
|
||||||
|
nameEn: form.nameEn.trim(),
|
||||||
|
category: form.category.trim(),
|
||||||
|
price: Number(form.price) || 0,
|
||||||
|
stock: Number(form.stock) || 0,
|
||||||
|
imageUrl: imageUrl ?? undefined,
|
||||||
|
imageKey: imageKey ?? undefined,
|
||||||
|
}
|
||||||
|
if (isEdit && id) {
|
||||||
|
await updateProduct(id, payload)
|
||||||
|
} else {
|
||||||
|
await createProduct(payload)
|
||||||
|
}
|
||||||
|
navigate('/admin/product/product-list')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'ذخیره ناموفق بود')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel list-page">
|
||||||
|
<PageTitle
|
||||||
|
title={isEdit ? 'ویرایش محصول' : 'ثبت محصول جدید'}
|
||||||
|
subtitle="نام محصول، قیمت و جزئیات را وارد کنید"
|
||||||
|
onBack={() => navigate('/admin/product/product-list')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{loading ? <p className="empty">در حال بارگذاری...</p> : null}
|
||||||
|
|
||||||
|
{!loading ? (
|
||||||
|
<form className="form-grid" onSubmit={(e) => void onSubmit(e)}>
|
||||||
|
<TextField label="نام محصول - فارسی" value={form.nameFa} onChange={(v) => update('nameFa', v)} />
|
||||||
|
<TextField label="نام محصول - انگلیسی" value={form.nameEn} onChange={(v) => update('nameEn', v)} dir="ltr" />
|
||||||
|
<TextField label="دسته بندی" value={form.category} onChange={(v) => update('category', v)} />
|
||||||
|
<TextField label="قیمت محصول - تومان" value={form.price} onChange={(v) => update('price', v)} dir="ltr" />
|
||||||
|
<TextField label="موجودی" value={form.stock} onChange={(v) => update('stock', v)} dir="ltr" />
|
||||||
|
<div className="full">
|
||||||
|
<label className="filters__label">تصویر محصول</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp"
|
||||||
|
onChange={(e) => void onImageChange(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
{imageUrl ? (
|
||||||
|
<img src={imageUrl} alt="" style={{ marginTop: 12, maxWidth: 180, borderRadius: 8 }} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{error ? <p className="login__error full">{error}</p> : null}
|
||||||
|
<div className="full" style={{ display: 'flex', gap: 12 }}>
|
||||||
|
<PrimaryButton type="submit" disabled={saving}>
|
||||||
|
{isEdit ? 'ذخیره تغییرات' : 'ثبت محصول'}
|
||||||
|
</PrimaryButton>
|
||||||
|
<PrimaryButton type="button" variant="secondary" onClick={() => navigate('/admin/product/product-list')}>
|
||||||
|
انصراف
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
.products-page {
|
||||||
|
position: relative;
|
||||||
|
padding: 0;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info banner */
|
||||||
|
.products-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
background: #e3f2fd;
|
||||||
|
color: #1565c0;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.products-banner__icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background: var(--color-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.products-banner__excel {
|
||||||
|
margin-right: auto;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filters */
|
||||||
|
.products-filters {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters__label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #444;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.products-filter-select,
|
||||||
|
.products-filter-input {
|
||||||
|
height: 38px;
|
||||||
|
border: 1px solid #d7d7d7;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #555;
|
||||||
|
background: #fff;
|
||||||
|
outline: none;
|
||||||
|
min-width: 160px;
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.products-filter-select:focus,
|
||||||
|
.products-filter-input:focus {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card grid */
|
||||||
|
.products-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 12px 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.products-grid {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.products-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
background: #fff;
|
||||||
|
transition: box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card:hover {
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
z-index: 1;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card__img {
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
background: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card__img img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card__body {
|
||||||
|
padding: 12px 14px 8px;
|
||||||
|
text-align: center;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card__body h4 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card__body p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9e9e9e;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card__footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-top: 1px solid #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card__actions .icon-action {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card__price {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card__price .en {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card__price small {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #9e9e9e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.products-page .pager {
|
||||||
|
padding: 4px 16px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* FAB lives in shared FabButton component */
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import Icon from '../components/Icon'
|
||||||
|
import FabButton from '../components/FabButton'
|
||||||
|
import Pagination from '../components/Pagination'
|
||||||
|
import { ApiError } from '../lib/api'
|
||||||
|
import { deleteProduct, listProducts, type Product } from '../lib/adminApi'
|
||||||
|
import { usePagination } from '../lib/usePagination'
|
||||||
|
import scooterIcon from '../assets/images/tiles/scooter.png'
|
||||||
|
import './ListPages.css'
|
||||||
|
import './ProductsPage.css'
|
||||||
|
|
||||||
|
type Filters = {
|
||||||
|
category: string
|
||||||
|
minPrice: string
|
||||||
|
maxPrice: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProductsPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [products, setProducts] = useState<Product[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [filters, setFilters] = useState<Filters>({ category: '', minPrice: '', maxPrice: '' })
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
setProducts(await listProducts())
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'بارگذاری محصولات ناموفق بود')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const categories = useMemo(
|
||||||
|
() => [...new Set(products.map((p) => p.category).filter(Boolean))],
|
||||||
|
[products],
|
||||||
|
)
|
||||||
|
|
||||||
|
const filtered = useMemo(
|
||||||
|
() =>
|
||||||
|
products.filter((p) => {
|
||||||
|
if (filters.category && p.category !== filters.category) return false
|
||||||
|
if (filters.minPrice && p.price < Number(filters.minPrice)) return false
|
||||||
|
if (filters.maxPrice && p.price > Number(filters.maxPrice)) return false
|
||||||
|
return true
|
||||||
|
}),
|
||||||
|
[products, filters],
|
||||||
|
)
|
||||||
|
|
||||||
|
const { page, setPage, pageCount, pageItems } = usePagination(filtered)
|
||||||
|
|
||||||
|
async function onDelete(id: string) {
|
||||||
|
if (!window.confirm('این محصول حذف شود؟')) return
|
||||||
|
try {
|
||||||
|
await deleteProduct(id)
|
||||||
|
setProducts((prev) => prev.filter((p) => p.id !== id))
|
||||||
|
} catch (err) {
|
||||||
|
window.alert(err instanceof ApiError ? err.message : 'حذف ناموفق بود')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel products-page">
|
||||||
|
<div className="products-banner">
|
||||||
|
<span className="products-banner__icon">
|
||||||
|
<Icon name="info" size={20} />
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
شما می توانید لیست قیمت و موجودی محصولات را به صورت فایل اکسل مرور کنید
|
||||||
|
</span>
|
||||||
|
<button className="btn btn--primary products-banner__excel" type="button">
|
||||||
|
<Icon name="plus" size={14} />
|
||||||
|
بارگذاری فایل اکسل
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="products-filters">
|
||||||
|
<span className="filters__label">فیلترها :</span>
|
||||||
|
<select
|
||||||
|
className="products-filter-select"
|
||||||
|
value={filters.category}
|
||||||
|
onChange={(e) => setFilters((f) => ({ ...f, category: e.target.value }))}
|
||||||
|
>
|
||||||
|
<option value="">دسته بندی محصول</option>
|
||||||
|
{categories.map((c) => (
|
||||||
|
<option key={c} value={c}>{c}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
className="products-filter-input"
|
||||||
|
placeholder="حداقل قیمت - تومان"
|
||||||
|
type="number"
|
||||||
|
dir="ltr"
|
||||||
|
value={filters.minPrice}
|
||||||
|
onChange={(e) => setFilters((f) => ({ ...f, minPrice: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="products-filter-input"
|
||||||
|
placeholder="حداکثر قیمت - تومان"
|
||||||
|
type="number"
|
||||||
|
dir="ltr"
|
||||||
|
value={filters.maxPrice}
|
||||||
|
onChange={(e) => setFilters((f) => ({ ...f, maxPrice: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <p className="empty">{error}</p> : null}
|
||||||
|
{loading ? <p className="empty">در حال بارگذاری...</p> : null}
|
||||||
|
|
||||||
|
{!loading && !error ? (
|
||||||
|
<>
|
||||||
|
<div className="products-grid">
|
||||||
|
{filtered.length === 0 ? <p className="empty">محصولی ثبت نشده است</p> : null}
|
||||||
|
{pageItems.map((product) => (
|
||||||
|
<article key={product.id} className="product-card">
|
||||||
|
<div className="product-card__img">
|
||||||
|
<img src={product.imageUrl || scooterIcon} alt={product.nameFa} />
|
||||||
|
</div>
|
||||||
|
<div className="product-card__body">
|
||||||
|
<h4>{product.nameFa}</h4>
|
||||||
|
<p className="en">{product.nameEn}</p>
|
||||||
|
</div>
|
||||||
|
<div className="product-card__footer">
|
||||||
|
<div className="product-card__price">
|
||||||
|
<span className="en">{product.price.toLocaleString('en-US')}</span>
|
||||||
|
<small>تومان</small>
|
||||||
|
</div>
|
||||||
|
<div className="product-card__actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-action icon-action--red"
|
||||||
|
aria-label="حذف"
|
||||||
|
onClick={() => void onDelete(product.id)}
|
||||||
|
>
|
||||||
|
<Icon name="trash" size={15} />
|
||||||
|
</button>
|
||||||
|
<button type="button" className="icon-action icon-action--orange" aria-label="جزئیات">
|
||||||
|
<Icon name="doc" size={15} />
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
to={`/admin/product/edit-product/${product.id}`}
|
||||||
|
className="icon-action icon-action--blue"
|
||||||
|
aria-label="ویرایش"
|
||||||
|
>
|
||||||
|
<Icon name="edit" size={15} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Pagination page={page} pageCount={pageCount} onPageChange={setPage} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<FabButton
|
||||||
|
label="ثبت محصول جدید"
|
||||||
|
onClick={() => navigate('/admin/product/add-product')}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../auth/AuthContext'
|
||||||
|
import TextField from '../components/TextField'
|
||||||
|
import PrimaryButton from '../components/PrimaryButton'
|
||||||
|
import Icon from '../components/Icon'
|
||||||
|
import userIcon from '../assets/images/user-icon.svg'
|
||||||
|
import { ApiError } from '../lib/api'
|
||||||
|
import { registerSendCode, registerVerify } from '../lib/authApi'
|
||||||
|
import { isValidCellNumber, normalizeCellNumber } from '../lib/phone'
|
||||||
|
import './LoginPage.css'
|
||||||
|
|
||||||
|
const WEBSITE_URL = import.meta.env.VITE_WEBSITE_URL || 'http://madomotor.com:5174'
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
const { reloadUser } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [step, setStep] = useState<'form' | 'code'>('form')
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
mobile: '',
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
password: '',
|
||||||
|
passwordConfirmation: '',
|
||||||
|
code: '',
|
||||||
|
})
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
function update(key: keyof typeof form, value: string) {
|
||||||
|
setForm((prev) => ({ ...prev, [key]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
const cellNumber = normalizeCellNumber(form.mobile)
|
||||||
|
try {
|
||||||
|
if (step === 'form') {
|
||||||
|
if (!form.firstName.trim() || !form.lastName.trim()) {
|
||||||
|
throw new Error('نام و نام خانوادگی را وارد کنید')
|
||||||
|
}
|
||||||
|
if (!isValidCellNumber(cellNumber)) {
|
||||||
|
throw new Error('شماره همراه معتبر نیست')
|
||||||
|
}
|
||||||
|
if (form.password.length < 4) {
|
||||||
|
throw new Error('رمز عبور باید حداقل ۴ کاراکتر باشد')
|
||||||
|
}
|
||||||
|
if (form.password !== form.passwordConfirmation) {
|
||||||
|
throw new Error('رمز عبور و تکرار آن یکسان نیستند')
|
||||||
|
}
|
||||||
|
await registerSendCode({
|
||||||
|
firstName: form.firstName.trim(),
|
||||||
|
lastName: form.lastName.trim(),
|
||||||
|
cellNumber,
|
||||||
|
password: form.password,
|
||||||
|
})
|
||||||
|
setStep('code')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await registerVerify(cellNumber, form.code.trim())
|
||||||
|
reloadUser()
|
||||||
|
navigate('/', { replace: true })
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError || err instanceof Error ? err.message : 'ثبت نام ناموفق بود')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login">
|
||||||
|
<div className="login__heading">
|
||||||
|
<a href={WEBSITE_URL} className="login__back" aria-label="بازگشت به صفحه اصلی">
|
||||||
|
<Icon name="chevron" size={18} />
|
||||||
|
</a>
|
||||||
|
<img src={userIcon} alt="" className="login__user-icon" />
|
||||||
|
<div className="login__heading-text">
|
||||||
|
<h1>ثبت نام</h1>
|
||||||
|
<p>
|
||||||
|
{step === 'form'
|
||||||
|
? 'ایجاد حساب کاربری جدید'
|
||||||
|
: `کد ارسالشده به ${normalizeCellNumber(form.mobile)} را وارد کنید`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form className="login__form" onSubmit={onSubmit}>
|
||||||
|
{step === 'form' ? (
|
||||||
|
<>
|
||||||
|
<TextField label="نام" value={form.firstName} onChange={(v) => update('firstName', v)} />
|
||||||
|
<TextField
|
||||||
|
label="نام خانوادگی"
|
||||||
|
value={form.lastName}
|
||||||
|
onChange={(v) => update('lastName', v)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="شماره ی همراه"
|
||||||
|
value={form.mobile}
|
||||||
|
onChange={(v) => update('mobile', v)}
|
||||||
|
type="tel"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="رمز عبور"
|
||||||
|
value={form.password}
|
||||||
|
onChange={(v) => update('password', v)}
|
||||||
|
type="password"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="تکرار رمز عبور"
|
||||||
|
value={form.passwordConfirmation}
|
||||||
|
onChange={(v) => update('passwordConfirmation', v)}
|
||||||
|
type="password"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<TextField
|
||||||
|
label="کد تایید"
|
||||||
|
value={form.code}
|
||||||
|
onChange={(v) => update('code', v)}
|
||||||
|
type="tel"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error ? <p className="login__error">{error}</p> : null}
|
||||||
|
|
||||||
|
<div className="login__actions">
|
||||||
|
<Link to="/auth/login" className="btn btn--secondary">
|
||||||
|
ورود به سیستم
|
||||||
|
</Link>
|
||||||
|
<PrimaryButton type="submit" disabled={loading}>
|
||||||
|
{step === 'form' ? 'ارسال کد تایید' : 'ثبت نام'}
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { useEffect, useState, type FormEvent } from 'react'
|
||||||
|
import { useNavigate, useParams, useLocation } from 'react-router-dom'
|
||||||
|
import PageTitle from '../components/PageTitle'
|
||||||
|
import TextField from '../components/TextField'
|
||||||
|
import PrimaryButton from '../components/PrimaryButton'
|
||||||
|
import { ApiError } from '../lib/api'
|
||||||
|
import {
|
||||||
|
createRepresentation,
|
||||||
|
listRepresentations,
|
||||||
|
updateRepresentation,
|
||||||
|
} from '../lib/adminApi'
|
||||||
|
import './ListPages.css'
|
||||||
|
|
||||||
|
export default function RepresentationFormPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { id } = useParams()
|
||||||
|
const location = useLocation()
|
||||||
|
const asActive = location.pathname.includes('/active')
|
||||||
|
const isEdit = Boolean(id)
|
||||||
|
const backTo = asActive
|
||||||
|
? '/admin/representation/active-representation-list'
|
||||||
|
: '/admin/representation/representation-list'
|
||||||
|
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
name: '',
|
||||||
|
owner: '',
|
||||||
|
mobile: '',
|
||||||
|
city: '',
|
||||||
|
address: '',
|
||||||
|
})
|
||||||
|
const [loading, setLoading] = useState(isEdit)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const items = await listRepresentations()
|
||||||
|
const item = items.find((row) => row.id === id)
|
||||||
|
if (!item) throw new Error('نمایندگی یافت نشد')
|
||||||
|
setForm({
|
||||||
|
name: item.name,
|
||||||
|
owner: item.owner,
|
||||||
|
mobile: item.mobile,
|
||||||
|
city: item.city,
|
||||||
|
address: item.address,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'بارگذاری ناموفق بود')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}, [id])
|
||||||
|
|
||||||
|
function update(key: keyof typeof form, value: string) {
|
||||||
|
setForm((prev) => ({ ...prev, [key]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setSaving(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
owner: form.owner.trim(),
|
||||||
|
mobile: form.mobile.trim(),
|
||||||
|
city: form.city.trim(),
|
||||||
|
address: form.address.trim(),
|
||||||
|
}
|
||||||
|
if (isEdit && id) {
|
||||||
|
await updateRepresentation(id, payload)
|
||||||
|
} else {
|
||||||
|
await createRepresentation(payload, asActive)
|
||||||
|
}
|
||||||
|
navigate(backTo)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'ذخیره ناموفق بود')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel list-page">
|
||||||
|
<PageTitle
|
||||||
|
title={isEdit ? 'ویرایش نمایندگی' : asActive ? 'افزودن نمایندگی فعال' : 'درخواست نمایندگی جدید'}
|
||||||
|
subtitle="اطلاعات نمایندگی را وارد کنید"
|
||||||
|
onBack={() => navigate(backTo)}
|
||||||
|
/>
|
||||||
|
{loading ? <p className="empty">در حال بارگذاری...</p> : null}
|
||||||
|
{!loading ? (
|
||||||
|
<form className="form-grid" onSubmit={(e) => void onSubmit(e)}>
|
||||||
|
<TextField label="نام" value={form.name} onChange={(v) => update('name', v)} />
|
||||||
|
<TextField label="مسئول / توضیح" value={form.owner} onChange={(v) => update('owner', v)} />
|
||||||
|
<TextField label="شماره همراه" value={form.mobile} onChange={(v) => update('mobile', v)} dir="ltr" />
|
||||||
|
<TextField label="شهر" value={form.city} onChange={(v) => update('city', v)} />
|
||||||
|
<div className="full">
|
||||||
|
<TextField label="آدرس" value={form.address} onChange={(v) => update('address', v)} />
|
||||||
|
</div>
|
||||||
|
{error ? <p className="login__error full">{error}</p> : null}
|
||||||
|
<div className="full" style={{ display: 'flex', gap: 12 }}>
|
||||||
|
<PrimaryButton type="submit" disabled={saving}>
|
||||||
|
ذخیره
|
||||||
|
</PrimaryButton>
|
||||||
|
<PrimaryButton type="button" variant="secondary" onClick={() => navigate(backTo)}>
|
||||||
|
انصراف
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import PageTitle from '../components/PageTitle'
|
||||||
|
import FabButton from '../components/FabButton'
|
||||||
|
import Icon from '../components/Icon'
|
||||||
|
import Pagination from '../components/Pagination'
|
||||||
|
import { ApiError } from '../lib/api'
|
||||||
|
import {
|
||||||
|
approveRepresentation,
|
||||||
|
deleteRepresentation,
|
||||||
|
formatFaDate,
|
||||||
|
listRepresentations,
|
||||||
|
type Representation,
|
||||||
|
} from '../lib/adminApi'
|
||||||
|
import docIcon from '../assets/images/tiles/doc.png'
|
||||||
|
import { usePagination } from '../lib/usePagination'
|
||||||
|
import './ListPages.css'
|
||||||
|
|
||||||
|
export default function RepresentationRequestsPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [items, setItems] = useState<Representation[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
setItems(await listRepresentations('pending'))
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'بارگذاری درخواستها ناموفق بود')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function onApprove(id: string) {
|
||||||
|
try {
|
||||||
|
await approveRepresentation(id)
|
||||||
|
setItems((prev) => prev.filter((item) => item.id !== id))
|
||||||
|
} catch (err) {
|
||||||
|
window.alert(err instanceof ApiError ? err.message : 'تایید ناموفق بود')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(id: string) {
|
||||||
|
if (!window.confirm('این درخواست حذف شود؟')) return
|
||||||
|
try {
|
||||||
|
await deleteRepresentation(id)
|
||||||
|
setItems((prev) => prev.filter((item) => item.id !== id))
|
||||||
|
} catch (err) {
|
||||||
|
window.alert(err instanceof ApiError ? err.message : 'حذف ناموفق بود')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { page, setPage, pageCount, pageItems, offset } = usePagination(items)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel list-page">
|
||||||
|
<PageTitle
|
||||||
|
title="درخواست های نمایندگی"
|
||||||
|
subtitle="درخواست های کاربران از سرتاسر ایران"
|
||||||
|
image={docIcon}
|
||||||
|
imageWidth={45}
|
||||||
|
onBack={() => navigate('/')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error ? <p className="empty">{error}</p> : null}
|
||||||
|
{loading ? <p className="empty">در حال بارگذاری...</p> : null}
|
||||||
|
|
||||||
|
{!loading && !error ? (
|
||||||
|
<>
|
||||||
|
<div className="list customer-list">
|
||||||
|
{items.length === 0 ? <p className="empty">درخواستی ثبت نشده است</p> : null}
|
||||||
|
{pageItems.map((item, index) => (
|
||||||
|
<article key={item.id} className="request-card">
|
||||||
|
<div className="en request-card__index">{offset + index + 1}</div>
|
||||||
|
|
||||||
|
<div className="request-card__id">
|
||||||
|
<div className="en request-card__id-label">
|
||||||
|
requestID <span>{item.id.slice(-6)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="request-card__date">{formatFaDate(item.createdAt)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="request-card__person">
|
||||||
|
<strong>{item.name}</strong>
|
||||||
|
<div className="en request-card__mobile">{item.mobile}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="request-card__place">
|
||||||
|
<div className="place-chip">
|
||||||
|
<strong>{item.city}</strong>
|
||||||
|
<span>{item.address}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="order-row__actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-action icon-action--teal"
|
||||||
|
aria-label="تایید"
|
||||||
|
onClick={() => void onApprove(item.id)}
|
||||||
|
>
|
||||||
|
<Icon name="plus" size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-action icon-action--red"
|
||||||
|
aria-label="حذف"
|
||||||
|
onClick={() => void onDelete(item.id)}
|
||||||
|
>
|
||||||
|
<Icon name="trash" size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Pagination page={page} pageCount={pageCount} onPageChange={setPage} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<FabButton
|
||||||
|
label="درخواست نمایندگی جدید"
|
||||||
|
onClick={() => navigate('/admin/representation/new')}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import PageTitle from '../components/PageTitle'
|
||||||
|
import FabButton from '../components/FabButton'
|
||||||
|
import Icon from '../components/Icon'
|
||||||
|
import Pagination from '../components/Pagination'
|
||||||
|
import { ApiError } from '../lib/api'
|
||||||
|
import {
|
||||||
|
approveReview,
|
||||||
|
deleteReview,
|
||||||
|
listReviews,
|
||||||
|
rejectReview,
|
||||||
|
type Review,
|
||||||
|
} from '../lib/adminApi'
|
||||||
|
import starIcon from '../assets/images/tiles/star.png'
|
||||||
|
import { usePagination } from '../lib/usePagination'
|
||||||
|
import './ListPages.css'
|
||||||
|
|
||||||
|
export default function ReviewsPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [reviews, setReviews] = useState<Review[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
setReviews(await listReviews())
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'بارگذاری نظرات ناموفق بود')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function onApprove(id: string) {
|
||||||
|
try {
|
||||||
|
const updated = await approveReview(id)
|
||||||
|
setReviews((prev) => prev.map((r) => (r.id === id ? updated : r)))
|
||||||
|
} catch (err) {
|
||||||
|
window.alert(err instanceof ApiError ? err.message : 'تایید ناموفق بود')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onReject(id: string) {
|
||||||
|
try {
|
||||||
|
const updated = await rejectReview(id)
|
||||||
|
setReviews((prev) => prev.map((r) => (r.id === id ? updated : r)))
|
||||||
|
} catch (err) {
|
||||||
|
window.alert(err instanceof ApiError ? err.message : 'رد ناموفق بود')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(id: string) {
|
||||||
|
if (!window.confirm('این نظر حذف شود؟')) return
|
||||||
|
try {
|
||||||
|
await deleteReview(id)
|
||||||
|
setReviews((prev) => prev.filter((r) => r.id !== id))
|
||||||
|
} catch (err) {
|
||||||
|
window.alert(err instanceof ApiError ? err.message : 'حذف ناموفق بود')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { page, setPage, pageCount, pageItems } = usePagination(reviews)
|
||||||
|
|
||||||
|
function statusLabel(status: Review['status']) {
|
||||||
|
if (status === 'pending') return 'در انتظار تایید'
|
||||||
|
if (status === 'approved') return 'تایید شده'
|
||||||
|
return 'رد شده'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel list-page">
|
||||||
|
<PageTitle
|
||||||
|
title="بررسی های من"
|
||||||
|
subtitle="نظرات تخصصی کاربران"
|
||||||
|
image={starIcon}
|
||||||
|
imageWidth={45}
|
||||||
|
onBack={() => navigate('/')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error ? <p className="empty">{error}</p> : null}
|
||||||
|
{loading ? <p className="empty">در حال بارگذاری...</p> : null}
|
||||||
|
|
||||||
|
{!loading && !error ? (
|
||||||
|
<>
|
||||||
|
<div className="list">
|
||||||
|
{reviews.length === 0 ? <p className="empty">نظری ثبت نشده است</p> : null}
|
||||||
|
{pageItems.map((review) => (
|
||||||
|
<article key={review.id} className="list-row simple-row">
|
||||||
|
<div>
|
||||||
|
<strong>نویسنده ی بررسی : {review.author}</strong>
|
||||||
|
<div className="muted">{review.product}</div>
|
||||||
|
</div>
|
||||||
|
<div>امتیاز {review.rating} از 5</div>
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
className={`badge ${
|
||||||
|
review.status === 'pending'
|
||||||
|
? 'badge--pending'
|
||||||
|
: review.status === 'rejected'
|
||||||
|
? 'badge--pending'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{statusLabel(review.status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="order-row__actions">
|
||||||
|
{review.status === 'pending' ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-action icon-action--teal"
|
||||||
|
aria-label="تایید"
|
||||||
|
onClick={() => void onApprove(review.id)}
|
||||||
|
>
|
||||||
|
<Icon name="plus" size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-action icon-action--orange"
|
||||||
|
aria-label="رد"
|
||||||
|
onClick={() => void onReject(review.id)}
|
||||||
|
>
|
||||||
|
<Icon name="chat" size={16} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-action icon-action--red"
|
||||||
|
aria-label="حذف"
|
||||||
|
onClick={() => void onDelete(review.id)}
|
||||||
|
>
|
||||||
|
<Icon name="trash" size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Pagination page={page} pageCount={pageCount} onPageChange={setPage} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<FabButton label="افزودن بررسی" />
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023", "DOM"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"allowArbitraryExtensions": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"module": "nodenext",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: true,
|
||||||
|
port: 5175,
|
||||||
|
strictPort: true,
|
||||||
|
allowedHosts: [
|
||||||
|
'localhost',
|
||||||
|
'127.0.0.1',
|
||||||
|
'manage.madomotor.com',
|
||||||
|
'admin.madomotor.ir',
|
||||||
|
'madomotor.ir',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||