Initial commit of the Mado Motor admin dashboard.

This commit is contained in:
2026-09-03 23:41:56 +03:30
commit b4c7b49bc6
99 changed files with 6785 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
VITE_WEBSITE_URL=
VITE_API_URL=
+28
View File
@@ -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
+8
View File
@@ -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 }]
}
}
+23
View File
@@ -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.
+13
View File
@@ -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>
+1353
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -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"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -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

+68
View File
@@ -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>
)
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 383 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 554 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

+8
View File
@@ -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

+8
View File
@@ -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

+6
View File
@@ -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

+15
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

+1
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

+4
View File
@@ -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

+111
View File
@@ -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
}
+14
View File
@@ -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 />
}
+51
View File
@@ -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;
}
+31
View File
@@ -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>
)
}
+20
View File
@@ -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);
}
+15
View File
@@ -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>
)
}
+167
View File
@@ -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
}
}
+65
View File
@@ -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;
}
+43
View File
@@ -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>
)
}
+50
View File
@@ -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;
}
+73
View File
@@ -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>
)
}
+40
View File
@@ -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;
}
+20
View File
@@ -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>
)
}
+33
View File
@@ -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);
}
+33
View File
@@ -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>
)
}
+321
View File
@@ -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')} تومان`
}
+88
View File
@@ -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,
},
]
+165
View File
@@ -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);
}
+78
View File
@@ -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;
}
}
+50
View File
@@ -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>
)
}
+164
View File
@@ -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;
}
}
+42
View File
@@ -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>
)
}
+223
View File
@@ -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)
}
+155
View File
@@ -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
}
+65
View File
@@ -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'
}
+125
View File
@@ -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)
}
+9
View File
@@ -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)))
}
+11
View File
@@ -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))
}
+27
View File
@@ -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,
}
}
+13
View File
@@ -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>,
)
+115
View File
@@ -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>
)
}
+137
View File
@@ -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>
)
}
+100
View File
@@ -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;
}
}
+115
View File
@@ -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>
)
}
+63
View File
@@ -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>
)
}
+132
View File
@@ -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>
)
}
+118
View File
@@ -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>
)
}
+115
View File
@@ -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>
)
}
+26
View File
@@ -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));
}
}
+15
View File
@@ -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>
)
}
+128
View File
@@ -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>
)
}
+441
View File
@@ -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;
}
}
+88
View File
@@ -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;
}
+92
View File
@@ -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>
)
}
+93
View File
@@ -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>
)
}
+138
View File
@@ -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>
)
}
+189
View File
@@ -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 */
+170
View File
@@ -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>
)
}
+143
View File
@@ -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>
)
}
+116
View File
@@ -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>
)
}
+133
View File
@@ -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>
)
}
+154
View File
@@ -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>
)
}
+26
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -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"]
}
+18
View File
@@ -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',
],
},
})