Initial commit of Balout Pastry admin dashboards.
Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_BASE_URL=http://localhost:3100/api/v1
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# env
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# Balout Pastry — Dashboards (setup context)
|
||||||
|
|
||||||
|
Admin dashboard for شیرینیفروشی بلوط. React + Vite SPA that talks to the NestJS API.
|
||||||
|
|
||||||
|
Repo: `https://git.meshkee.com/BaloutPastry/dashboards.git`
|
||||||
|
API repo: `https://git.meshkee.com/BaloutPastry/backend.git`
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- React 19 + TypeScript + Vite 8
|
||||||
|
- React Router 7
|
||||||
|
- Lucide icons
|
||||||
|
- Auth via JWT (access + refresh) stored in `localStorage`
|
||||||
|
- API client: `src/lib/api.ts` → `VITE_API_BASE_URL`
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js **20+** (LTS recommended)
|
||||||
|
- npm
|
||||||
|
- Running **Backend** API on port **3100** (see backend `README.md`)
|
||||||
|
- A `superAdmin` / `admin` user in the API database
|
||||||
|
|
||||||
|
## Setup on a new device
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.meshkee.com/BaloutPastry/dashboards.git
|
||||||
|
cd dashboards
|
||||||
|
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit if API is not on localhost:3100
|
||||||
|
# VITE_API_BASE_URL=http://localhost:3100/api/v1
|
||||||
|
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open **http://localhost:5173**
|
||||||
|
|
||||||
|
### Backend must be up first
|
||||||
|
|
||||||
|
From the backend repo (separate clone):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
npm install
|
||||||
|
npm run db:up
|
||||||
|
npx prisma migrate deploy
|
||||||
|
npm run create-super-admin -- --phone 09120000000 --password secret123
|
||||||
|
npm run start:dev # http://localhost:3100/api/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
Then log into the dashboard with that phone/password.
|
||||||
|
|
||||||
|
CORS: backend `CORS_ORIGIN` should include `http://localhost:5173` (default in `.env.example`).
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
| Command | What it does |
|
||||||
|
|---------|----------------|
|
||||||
|
| `npm run dev` | Vite dev server → `http://localhost:5173` |
|
||||||
|
| `npm run build` | Typecheck + production build → `dist/` |
|
||||||
|
| `npm run preview` | Serve production build locally |
|
||||||
|
| `npm run lint` | Oxlint |
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
| Variable | Required | Default | Notes |
|
||||||
|
|----------|----------|---------|-------|
|
||||||
|
| `VITE_API_BASE_URL` | no | `http://localhost:3100/api/v1` | Must match backend base URL including `/api/v1` |
|
||||||
|
|
||||||
|
Copy from `.env.example`. Do **not** commit `.env`.
|
||||||
|
|
||||||
|
After changing env vars, restart `npm run dev` (Vite only reads them at startup).
|
||||||
|
|
||||||
|
## App routes
|
||||||
|
|
||||||
|
| Path | Page |
|
||||||
|
|------|------|
|
||||||
|
| `/login` | Admin login (mobile + password) |
|
||||||
|
| `/` | Home |
|
||||||
|
| `/products` | Products hub |
|
||||||
|
| `/products/list` | Product list |
|
||||||
|
| `/products/new` | Add product |
|
||||||
|
| `/products/categories` | Categories |
|
||||||
|
| `/users` | Users |
|
||||||
|
| `/orders` | Orders |
|
||||||
|
| `/settings` | Settings (shipping, branches) |
|
||||||
|
|
||||||
|
Protected routes redirect to `/login` when there is no session.
|
||||||
|
|
||||||
|
## Auth / session notes
|
||||||
|
|
||||||
|
- Login: `POST /auth/login` with `cellNumber` + `password`
|
||||||
|
- Only `admin` and `superAdmin` can use this dashboard
|
||||||
|
- Tokens live in `localStorage` (`src/lib/auth.ts`)
|
||||||
|
- `apiRequest` refreshes access token on `401` via `/auth/refresh`
|
||||||
|
- Logout clears the session
|
||||||
|
|
||||||
|
## Project layout (important paths)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
lib/api.ts HTTP client + ApiError
|
||||||
|
lib/auth.ts Session / tokens
|
||||||
|
lib/authApi.ts Login / refresh / logout / me
|
||||||
|
lib/*Api.ts Domain API helpers
|
||||||
|
pages/ Route pages
|
||||||
|
components/ Modals, header, shared UI
|
||||||
|
data/ Shared types & constants (titles, roles, …)
|
||||||
|
utils/ Helpers (price, digits, …)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
# Serve dist/ behind nginx/Caddy, or:
|
||||||
|
npm run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `VITE_API_BASE_URL` to the real API URL **before** `npm run build` (it is baked into the bundle).
|
||||||
|
|
||||||
|
## Common issues
|
||||||
|
|
||||||
|
| Symptom | Likely cause |
|
||||||
|
|---------|----------------|
|
||||||
|
| «اتصال به سرور» / save failed | Backend not running, or wrong `VITE_API_BASE_URL` |
|
||||||
|
| Login works then immediate logout | Refresh token rejected; check JWT secrets / DB |
|
||||||
|
| CORS errors in browser | Backend `CORS_ORIGIN` missing dashboard origin |
|
||||||
|
| Empty users/products | API up but empty DB, or not logged in as admin |
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- Backend docs: clone `BaloutPastry/backend` and read its `README.md`
|
||||||
|
- Postgres for local API: Docker Compose on host port **5434**
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fa" dir="rtl">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>بلوط | پنل مدیریت</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "balout-pastry",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "oxlint",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"lucide-react": "^1.28.0",
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8",
|
||||||
|
"react-easy-crop": "^6.2.3",
|
||||||
|
"react-router-dom": "^7.18.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.13.3",
|
||||||
|
"@types/react": "^19.2.17",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.4",
|
||||||
|
"oxlint": "^1.75.0",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vite": "^8.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||||
|
<rect width="64" height="64" rx="14" fill="#8f410c"/>
|
||||||
|
<ellipse cx="32" cy="38" rx="16" ry="14" fill="#d9a07a"/>
|
||||||
|
<ellipse cx="32" cy="28" rx="10" ry="8" fill="#a85a28"/>
|
||||||
|
<path d="M32 14c0 6 4 10 4 14s-2 6-4 6-4-2-4-6 4-8 4-14z" fill="#4a2106"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 332 B |
@@ -0,0 +1,105 @@
|
|||||||
|
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||||
|
import { LoginPage } from './pages/LoginPage'
|
||||||
|
import { HomePage } from './pages/HomePage'
|
||||||
|
import { ProductsPage } from './pages/ProductsPage'
|
||||||
|
import { ProductsListPage } from './pages/ProductsListPage'
|
||||||
|
import { CategoriesPage } from './pages/CategoriesPage'
|
||||||
|
import { AddProductPage } from './pages/AddProductPage'
|
||||||
|
import { UsersPage } from './pages/UsersPage'
|
||||||
|
import { OrdersPage } from './pages/OrdersPage'
|
||||||
|
import { SettingsPage } from './pages/SettingsPage'
|
||||||
|
import { isAuthenticated } from './lib/auth'
|
||||||
|
|
||||||
|
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||||
|
if (!isAuthenticated()) {
|
||||||
|
return <Navigate to="/login" replace />
|
||||||
|
}
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
||||||
|
function RedirectIfAuth({ children }: { children: React.ReactNode }) {
|
||||||
|
if (isAuthenticated()) {
|
||||||
|
return <Navigate to="/" replace />
|
||||||
|
}
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/login"
|
||||||
|
element={
|
||||||
|
<RedirectIfAuth>
|
||||||
|
<LoginPage />
|
||||||
|
</RedirectIfAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<HomePage />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/products"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<ProductsPage />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/products/list"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<ProductsListPage />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/products/new"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<AddProductPage />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/products/categories"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<CategoriesPage />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/users"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<UsersPage />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/orders"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<OrdersPage />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/settings"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<SettingsPage />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
.overlay {
|
||||||
|
z-index: 90;
|
||||||
|
background: rgba(42, 24, 16, 0.35);
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { useEffect, useId, useState } from 'react'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { PriceInput } from './PriceInput'
|
||||||
|
import { parsePriceNumber } from '../utils/price'
|
||||||
|
import styles from './CategoryModal.module.css'
|
||||||
|
import nestedStyles from './AmountPriceModal.module.css'
|
||||||
|
|
||||||
|
export type AmountPriceValues = {
|
||||||
|
amount: string
|
||||||
|
price: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AmountPriceModalProps = {
|
||||||
|
open: boolean
|
||||||
|
title?: string
|
||||||
|
initialValues?: AmountPriceValues | null
|
||||||
|
onClose: () => void
|
||||||
|
onSubmit: (values: AmountPriceValues) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AmountPriceModal({
|
||||||
|
open,
|
||||||
|
title = 'مقدار و قیمت',
|
||||||
|
initialValues = null,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: AmountPriceModalProps) {
|
||||||
|
const titleId = useId()
|
||||||
|
const [amount, setAmount] = useState('')
|
||||||
|
const [price, setPrice] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
setAmount(initialValues?.amount ?? '')
|
||||||
|
setPrice(initialValues?.price ?? '')
|
||||||
|
setError('')
|
||||||
|
}, [open, initialValues])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.stopPropagation()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [open, onClose])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
function handleSubmit(event: React.FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!amount.trim()) {
|
||||||
|
setError('مقدار الزامی است')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const numericPrice = parsePriceNumber(price)
|
||||||
|
if (numericPrice === null || numericPrice < 0) {
|
||||||
|
setError('قیمت معتبر وارد کنید')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit({
|
||||||
|
amount: amount.trim(),
|
||||||
|
price: String(numericPrice),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${styles.overlay} ${nestedStyles.overlay}`}
|
||||||
|
onClick={onClose}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={styles.modal}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className={styles.shine} aria-hidden />
|
||||||
|
|
||||||
|
<div className={styles.header}>
|
||||||
|
<div>
|
||||||
|
<p className={styles.eyebrow}>Amount & Price</p>
|
||||||
|
<h2 id={titleId} className={styles.title}>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.closeBtn}
|
||||||
|
aria-label="بستن"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<X size={18} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className={styles.form} onSubmit={handleSubmit}>
|
||||||
|
{error && (
|
||||||
|
<div className={styles.error} role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="flavor-amount">مقدار</label>
|
||||||
|
<input
|
||||||
|
id="flavor-amount"
|
||||||
|
type="text"
|
||||||
|
placeholder="مثال: ۱ کیلو / نیم کیلو / ۱ واحد"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="flavor-price">قیمت (تومان)</label>
|
||||||
|
<PriceInput
|
||||||
|
id="flavor-price"
|
||||||
|
placeholder="۲۵۰٬۰۰۰"
|
||||||
|
value={price}
|
||||||
|
onChange={setPrice}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.cancelBtn}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
انصراف
|
||||||
|
</button>
|
||||||
|
<button type="submit" className={styles.submitBtn}>
|
||||||
|
ذخیره
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
|
import { ChevronLeft, type LucideIcon } from 'lucide-react'
|
||||||
|
import styles from '../pages/HomePage.module.css'
|
||||||
|
|
||||||
|
export type AnimatedTile = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
titleEn: string
|
||||||
|
description: string
|
||||||
|
href: string
|
||||||
|
icon: LucideIcon
|
||||||
|
}
|
||||||
|
|
||||||
|
type AnimatedTilesProps = {
|
||||||
|
tiles: AnimatedTile[]
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnimatedTiles({ tiles, label }: AnimatedTilesProps) {
|
||||||
|
const location = useLocation()
|
||||||
|
const [visible, setVisible] = useState(false)
|
||||||
|
const [entered, setEntered] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setVisible(false)
|
||||||
|
setEntered(false)
|
||||||
|
const frame = window.requestAnimationFrame(() => {
|
||||||
|
setVisible(true)
|
||||||
|
})
|
||||||
|
const enterMs = 700 + Math.max(0, tiles.length - 1) * 100
|
||||||
|
const clearDelay = window.setTimeout(() => {
|
||||||
|
setEntered(true)
|
||||||
|
}, enterMs)
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(frame)
|
||||||
|
window.clearTimeout(clearDelay)
|
||||||
|
}
|
||||||
|
}, [location.key, tiles.length])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={styles.grid} aria-label={label}>
|
||||||
|
{tiles.map((tile, index) => {
|
||||||
|
const Icon = tile.icon
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={`${location.key}-${tile.id}`}
|
||||||
|
to={tile.href}
|
||||||
|
className={`${styles.tile} ${visible ? styles.tileVisible : ''}`}
|
||||||
|
style={{
|
||||||
|
transitionDelay:
|
||||||
|
visible && !entered ? `${index * 0.1}s` : '0s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className={styles.tileSurface}>
|
||||||
|
<span className={styles.tileShine} aria-hidden />
|
||||||
|
<Icon className={styles.tileIcon} size={40} strokeWidth={1.5} />
|
||||||
|
<span className={styles.tileBody}>
|
||||||
|
<span className={styles.tileEn} aria-hidden>
|
||||||
|
{tile.titleEn}
|
||||||
|
</span>
|
||||||
|
<span className={styles.tileTitle}>{tile.title}</span>
|
||||||
|
<span className={styles.tileDesc}>{tile.description}</span>
|
||||||
|
</span>
|
||||||
|
<span className={styles.tileArrow} aria-hidden>
|
||||||
|
<ChevronLeft size={18} strokeWidth={1.75} />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
.item {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 56px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: rgba(255, 250, 250, 0.55);
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowClickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowClickable:hover {
|
||||||
|
background: rgba(255, 250, 250, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:hover {
|
||||||
|
background: rgba(255, 250, 250, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.childRow {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.childRow:hover {
|
||||||
|
background: rgba(143, 65, 12, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapseIcon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapseIconEmpty {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron {
|
||||||
|
transition: transform 0.25s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevronOpen {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.titles {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nameFa {
|
||||||
|
font-size: 0.98rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--brown);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nameEn {
|
||||||
|
font-family: var(--font-en);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
direction: ltr;
|
||||||
|
text-align: end;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconBtn {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition:
|
||||||
|
color 0.2s,
|
||||||
|
background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconBtn:hover {
|
||||||
|
color: var(--brown);
|
||||||
|
background: rgba(143, 65, 12, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconBtn::after {
|
||||||
|
content: attr(data-tooltip);
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
left: 50%;
|
||||||
|
z-index: 6;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--brown-deeper);
|
||||||
|
color: var(--text-on-dark);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 400;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translate(-50%, 4px);
|
||||||
|
transition:
|
||||||
|
opacity 0.18s,
|
||||||
|
transform 0.18s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconBtn:hover::after,
|
||||||
|
.iconBtn:focus-visible::after {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeBtn:hover {
|
||||||
|
color: #9b2c2c;
|
||||||
|
background: rgba(155, 44, 44, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeBtn:hover::after {
|
||||||
|
background: #9b2c2c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.children {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 4px;
|
||||||
|
margin-inline-start: 32px;
|
||||||
|
padding: 0;
|
||||||
|
padding-inline-start: 14px;
|
||||||
|
border-inline-start: 2px solid rgba(143, 65, 12, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.chevron,
|
||||||
|
.iconBtn::after {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
ChevronLeft,
|
||||||
|
FolderPlus,
|
||||||
|
Pencil,
|
||||||
|
SlidersHorizontal,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import type { Category } from '../data/categories'
|
||||||
|
import styles from './CategoryItem.module.css'
|
||||||
|
|
||||||
|
type CategoryItemProps = {
|
||||||
|
category: Category
|
||||||
|
depth?: number
|
||||||
|
onAddChild: (parentId: string) => void
|
||||||
|
onEdit: (category: Category) => void
|
||||||
|
onOpenOptions: (category: Category) => void
|
||||||
|
onRemove: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CategoryItem({
|
||||||
|
category,
|
||||||
|
depth = 0,
|
||||||
|
onAddChild,
|
||||||
|
onEdit,
|
||||||
|
onOpenOptions,
|
||||||
|
onRemove,
|
||||||
|
}: CategoryItemProps) {
|
||||||
|
const hasChildren = category.children.length > 0
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
function toggleOpen() {
|
||||||
|
if (!hasChildren) return
|
||||||
|
setOpen((value) => !value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className={styles.item}>
|
||||||
|
<div
|
||||||
|
className={`${styles.row} ${depth > 0 ? styles.childRow : ''} ${hasChildren ? styles.rowClickable : ''}`}
|
||||||
|
role={hasChildren ? 'button' : undefined}
|
||||||
|
tabIndex={hasChildren ? 0 : undefined}
|
||||||
|
aria-expanded={hasChildren ? open : undefined}
|
||||||
|
onClick={toggleOpen}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (!hasChildren) return
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault()
|
||||||
|
toggleOpen()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`${styles.collapseIcon} ${!hasChildren ? styles.collapseIconEmpty : ''}`}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{hasChildren && (
|
||||||
|
<ChevronLeft
|
||||||
|
size={18}
|
||||||
|
strokeWidth={1.75}
|
||||||
|
className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className={styles.titles}>
|
||||||
|
<span className={styles.nameFa}>{category.nameFa}</span>
|
||||||
|
<span className={styles.nameEn}>{category.nameEn}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={styles.controls}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onKeyDown={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.iconBtn}
|
||||||
|
aria-label="افزودن زیرشاخه"
|
||||||
|
data-tooltip="افزودن زیرشاخه"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(true)
|
||||||
|
onAddChild(category.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FolderPlus size={17} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.iconBtn}
|
||||||
|
aria-label="ویرایش"
|
||||||
|
data-tooltip="ویرایش"
|
||||||
|
onClick={() => onEdit(category)}
|
||||||
|
>
|
||||||
|
<Pencil size={17} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.iconBtn}
|
||||||
|
aria-label="گزینهها"
|
||||||
|
data-tooltip="گزینهها"
|
||||||
|
onClick={() => onOpenOptions(category)}
|
||||||
|
>
|
||||||
|
<SlidersHorizontal size={17} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.iconBtn} ${styles.removeBtn}`}
|
||||||
|
aria-label="حذف"
|
||||||
|
data-tooltip="حذف"
|
||||||
|
onClick={() => onRemove(category.id)}
|
||||||
|
>
|
||||||
|
<Trash2 size={17} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasChildren && open && (
|
||||||
|
<ul className={styles.children}>
|
||||||
|
{category.children.map((child) => (
|
||||||
|
<CategoryItem
|
||||||
|
key={child.id}
|
||||||
|
category={child}
|
||||||
|
depth={depth + 1}
|
||||||
|
onAddChild={onAddChild}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onOpenOptions={onOpenOptions}
|
||||||
|
onRemove={onRemove}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 80;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: rgba(42, 24, 16, 0.28);
|
||||||
|
backdrop-filter: blur(14px) saturate(1.15);
|
||||||
|
-webkit-backdrop-filter: blur(14px) saturate(1.15);
|
||||||
|
animation: fadeOverlay 0.25s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 460px;
|
||||||
|
padding: 28px 26px 24px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: color-mix(in srgb, var(--bg) 92%, transparent);
|
||||||
|
backdrop-filter: blur(28px) saturate(1.35);
|
||||||
|
-webkit-backdrop-filter: blur(28px) saturate(1.35);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.75);
|
||||||
|
box-shadow:
|
||||||
|
0 24px 60px rgba(143, 65, 12, 0.18),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.85);
|
||||||
|
overflow: hidden;
|
||||||
|
animation: riseIn 0.35s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shine {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
145deg,
|
||||||
|
rgba(255, 255, 255, 0.55) 0%,
|
||||||
|
rgba(255, 255, 255, 0.12) 40%,
|
||||||
|
transparent 70%
|
||||||
|
);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
font-family: var(--font-en);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--brown);
|
||||||
|
opacity: 0.45;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
direction: ltr;
|
||||||
|
text-align: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--brown);
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition:
|
||||||
|
color 0.2s,
|
||||||
|
background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeBtn:hover {
|
||||||
|
color: var(--brown);
|
||||||
|
background: rgba(143, 65, 12, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input {
|
||||||
|
width: 100%;
|
||||||
|
height: var(--field-height);
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid rgba(143, 65, 12, 0.14);
|
||||||
|
background: rgba(255, 250, 250, 0.88);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 400;
|
||||||
|
outline: none;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75);
|
||||||
|
transition:
|
||||||
|
border-color 0.2s,
|
||||||
|
box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input:focus {
|
||||||
|
border-color: var(--brown);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.9),
|
||||||
|
0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input:disabled {
|
||||||
|
opacity: 0.72;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchWrap {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchIcon {
|
||||||
|
position: absolute;
|
||||||
|
inset-inline-start: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron {
|
||||||
|
position: absolute;
|
||||||
|
inset-inline-end: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchWrap input {
|
||||||
|
padding-inline: 38px 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
inset-inline: 0;
|
||||||
|
z-index: 20;
|
||||||
|
max-height: 220px;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 6px;
|
||||||
|
list-style: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: #fffdfc;
|
||||||
|
backdrop-filter: none;
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
box-shadow: 0 14px 34px rgba(143, 65, 12, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.option {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
text-align: start;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option:hover,
|
||||||
|
.optionActive {
|
||||||
|
background: rgba(143, 65, 12, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionFa {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionEn {
|
||||||
|
font-family: var(--font-en);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
direction: ltr;
|
||||||
|
text-align: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyOption {
|
||||||
|
padding: 12px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #8b2e2e;
|
||||||
|
background: rgba(180, 60, 60, 0.08);
|
||||||
|
border: 1px solid rgba(180, 60, 60, 0.22);
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelBtn,
|
||||||
|
.submitBtn {
|
||||||
|
height: 42px;
|
||||||
|
padding: 0 18px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition:
|
||||||
|
background 0.2s,
|
||||||
|
transform 0.2s,
|
||||||
|
color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelBtn {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: rgba(255, 250, 250, 0.7);
|
||||||
|
border: 1px solid rgba(143, 65, 12, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelBtn:hover {
|
||||||
|
color: var(--brown);
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitBtn {
|
||||||
|
color: var(--text-on-dark);
|
||||||
|
background: var(--brown);
|
||||||
|
box-shadow: 0 8px 18px rgba(143, 65, 12, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitBtn:hover {
|
||||||
|
background: var(--brown-dark);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeOverlay {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes riseIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(18px) scale(0.98);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.modal {
|
||||||
|
padding: 24px 18px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelBtn,
|
||||||
|
.submitBtn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import { useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||||
|
import { ChevronDown, Search, X } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
findCategory,
|
||||||
|
findParentId,
|
||||||
|
flattenCategories,
|
||||||
|
type Category,
|
||||||
|
} from '../data/categories'
|
||||||
|
import styles from './CategoryModal.module.css'
|
||||||
|
|
||||||
|
export type CategoryFormValues = {
|
||||||
|
id?: string
|
||||||
|
parentId: string | null
|
||||||
|
nameFa: string
|
||||||
|
nameEn: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type CategoryModalProps = {
|
||||||
|
open: boolean
|
||||||
|
categories: Category[]
|
||||||
|
initialParentId?: string | null
|
||||||
|
editingCategory?: Category | null
|
||||||
|
onClose: () => void
|
||||||
|
onSubmit: (values: CategoryFormValues) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CategoryModal({
|
||||||
|
open,
|
||||||
|
categories,
|
||||||
|
initialParentId = null,
|
||||||
|
editingCategory = null,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: CategoryModalProps) {
|
||||||
|
const titleId = useId()
|
||||||
|
const isEditing = Boolean(editingCategory)
|
||||||
|
const [parentId, setParentId] = useState<string | null>(initialParentId)
|
||||||
|
const [nameFa, setNameFa] = useState('')
|
||||||
|
const [nameEn, setNameEn] = useState('')
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [dropdownOpen, setDropdownOpen] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const searchRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const options = useMemo(() => {
|
||||||
|
const flat = flattenCategories(categories)
|
||||||
|
if (!editingCategory) return flat
|
||||||
|
return flat.filter((option) => option.id !== editingCategory.id)
|
||||||
|
}, [categories, editingCategory])
|
||||||
|
|
||||||
|
const filteredOptions = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase()
|
||||||
|
if (!q) return options
|
||||||
|
return options.filter(
|
||||||
|
(option) =>
|
||||||
|
option.labelFa.includes(query.trim()) ||
|
||||||
|
option.labelEn.toLowerCase().includes(q),
|
||||||
|
)
|
||||||
|
}, [options, query])
|
||||||
|
|
||||||
|
const selectedParent = parentId ? findCategory(categories, parentId) : null
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
if (editingCategory) {
|
||||||
|
const currentParent = findParentId(categories, editingCategory.id)
|
||||||
|
setParentId(currentParent ?? null)
|
||||||
|
setNameFa(editingCategory.nameFa)
|
||||||
|
setNameEn(editingCategory.nameEn)
|
||||||
|
} else {
|
||||||
|
setParentId(initialParentId)
|
||||||
|
setNameFa('')
|
||||||
|
setNameEn('')
|
||||||
|
}
|
||||||
|
setQuery('')
|
||||||
|
setDropdownOpen(false)
|
||||||
|
setError('')
|
||||||
|
}, [open, initialParentId, editingCategory, categories])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerDown(event: MouseEvent) {
|
||||||
|
if (!searchRef.current?.contains(event.target as Node)) {
|
||||||
|
setDropdownOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
document.addEventListener('mousedown', handlePointerDown)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
document.removeEventListener('mousedown', handlePointerDown)
|
||||||
|
}
|
||||||
|
}, [open, onClose])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
function handleSubmit(event: React.FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!nameFa.trim()) {
|
||||||
|
setError('نام فارسی الزامی است')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!nameEn.trim()) {
|
||||||
|
setError('نام انگلیسی الزامی است')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit({
|
||||||
|
id: editingCategory?.id,
|
||||||
|
parentId,
|
||||||
|
nameFa: nameFa.trim(),
|
||||||
|
nameEn: nameEn.trim(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.overlay} onClick={onClose} role="presentation">
|
||||||
|
<div
|
||||||
|
className={styles.modal}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className={styles.shine} aria-hidden />
|
||||||
|
|
||||||
|
<div className={styles.header}>
|
||||||
|
<div>
|
||||||
|
<p className={styles.eyebrow}>Categories</p>
|
||||||
|
<h2 id={titleId} className={styles.title}>
|
||||||
|
{isEditing ? 'ویرایش دستهبندی' : 'افزودن دستهبندی'}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.closeBtn}
|
||||||
|
aria-label="بستن"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<X size={18} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className={styles.form} onSubmit={handleSubmit}>
|
||||||
|
{error && (
|
||||||
|
<div className={styles.error} role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.field} ref={searchRef}>
|
||||||
|
<label htmlFor="category-parent">والد</label>
|
||||||
|
<div className={styles.searchWrap}>
|
||||||
|
<Search size={16} className={styles.searchIcon} />
|
||||||
|
<input
|
||||||
|
id="category-parent"
|
||||||
|
type="text"
|
||||||
|
placeholder="جستجوی دستهبندی والد..."
|
||||||
|
value={
|
||||||
|
dropdownOpen
|
||||||
|
? query
|
||||||
|
: selectedParent
|
||||||
|
? `${selectedParent.nameFa} — ${selectedParent.nameEn}`
|
||||||
|
: query
|
||||||
|
}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQuery(e.target.value)
|
||||||
|
setDropdownOpen(true)
|
||||||
|
if (parentId) setParentId(null)
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
setDropdownOpen(true)
|
||||||
|
setQuery('')
|
||||||
|
}}
|
||||||
|
autoComplete="off"
|
||||||
|
disabled={isEditing}
|
||||||
|
/>
|
||||||
|
<ChevronDown size={16} className={styles.chevron} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dropdownOpen && !isEditing && (
|
||||||
|
<ul className={styles.dropdown} role="listbox">
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.option} ${!parentId ? styles.optionActive : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setParentId(null)
|
||||||
|
setQuery('')
|
||||||
|
setDropdownOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className={styles.optionFa}>بدون والد (ریشه)</span>
|
||||||
|
<span className={styles.optionEn}>Root category</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{filteredOptions.length === 0 ? (
|
||||||
|
<li className={styles.emptyOption}>موردی یافت نشد</li>
|
||||||
|
) : (
|
||||||
|
filteredOptions.map((option) => (
|
||||||
|
<li key={option.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.option} ${parentId === option.id ? styles.optionActive : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setParentId(option.id)
|
||||||
|
setQuery('')
|
||||||
|
setDropdownOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className={styles.optionFa}>{option.labelFa}</span>
|
||||||
|
<span className={styles.optionEn}>{option.labelEn}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="category-name-fa">نام فارسی</label>
|
||||||
|
<input
|
||||||
|
id="category-name-fa"
|
||||||
|
type="text"
|
||||||
|
placeholder="مثال: شیرینی خشک"
|
||||||
|
value={nameFa}
|
||||||
|
onChange={(e) => setNameFa(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="category-name-en">نام انگلیسی</label>
|
||||||
|
<input
|
||||||
|
id="category-name-en"
|
||||||
|
type="text"
|
||||||
|
dir="ltr"
|
||||||
|
placeholder="Example: Dry Cookies"
|
||||||
|
value={nameEn}
|
||||||
|
onChange={(e) => setNameEn(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.cancelBtn}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
انصراف
|
||||||
|
</button>
|
||||||
|
<button type="submit" className={styles.submitBtn}>
|
||||||
|
ذخیره
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
.modal {
|
||||||
|
max-width: min(720px, 100%);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.categoryName {
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.categoryEn {
|
||||||
|
font-family: var(--font-en);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-muted);
|
||||||
|
direction: ltr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
max-height: min(62vh, 560px);
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-inline-end: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select {
|
||||||
|
width: 100%;
|
||||||
|
height: var(--field-height);
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid rgba(143, 65, 12, 0.14);
|
||||||
|
background-color: rgba(255, 250, 250, 0.88);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 400;
|
||||||
|
outline: none;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75);
|
||||||
|
transition:
|
||||||
|
border-color 0.2s,
|
||||||
|
box-shadow 0.2s;
|
||||||
|
appearance: none;
|
||||||
|
background-image: linear-gradient(45deg, transparent 50%, var(--text-muted) 50%),
|
||||||
|
linear-gradient(135deg, var(--text-muted) 50%, transparent 50%);
|
||||||
|
background-position:
|
||||||
|
calc(100% - 22px) calc(50% - 3px),
|
||||||
|
calc(100% - 16px) calc(50% - 3px);
|
||||||
|
background-size: 6px 6px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
padding-inline-end: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select:focus {
|
||||||
|
border-color: var(--brown);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.9),
|
||||||
|
0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
:dir(rtl) .select {
|
||||||
|
background-position:
|
||||||
|
16px calc(50% - 3px),
|
||||||
|
22px calc(50% - 3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.listHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listTitle {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--brown);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subTitle {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--brown);
|
||||||
|
}
|
||||||
|
|
||||||
|
.addBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid rgba(143, 65, 12, 0.22);
|
||||||
|
transition:
|
||||||
|
color 0.2s,
|
||||||
|
background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addBtn:hover {
|
||||||
|
color: var(--brown);
|
||||||
|
background: rgba(143, 65, 12, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.addOptionBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1.5px solid rgba(143, 65, 12, 0.22);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--brown);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition:
|
||||||
|
background 0.2s,
|
||||||
|
border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addOptionBtn:hover {
|
||||||
|
border-color: rgba(143, 65, 12, 0.4);
|
||||||
|
background: rgba(143, 65, 12, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.addAmountCard {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1.5px solid rgba(143, 65, 12, 0.22);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--brown);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition:
|
||||||
|
background 0.2s,
|
||||||
|
border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addAmountCard:hover {
|
||||||
|
border-color: rgba(143, 65, 12, 0.4);
|
||||||
|
background: rgba(143, 65, 12, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flavorList {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flavorBlock {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1.5px solid rgba(143, 65, 12, 0.22);
|
||||||
|
background: rgba(255, 250, 250, 0.72);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flavorTop {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flavorTop .field {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flavorActions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.srOnly {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 18px 14px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px dashed rgba(143, 65, 12, 0.2);
|
||||||
|
background: rgba(255, 250, 250, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
list-style: none;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(min(168px, 100%), 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addAmountItem {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1.5px solid rgba(143, 65, 12, 0.22);
|
||||||
|
background: rgba(255, 252, 252, 0.95);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
|
||||||
|
transition:
|
||||||
|
border-color 0.2s,
|
||||||
|
background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:hover,
|
||||||
|
.row:focus-within {
|
||||||
|
border-color: rgba(143, 65, 12, 0.4);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowText {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.35;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1.35;
|
||||||
|
flex-shrink: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowActions {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: rgba(251, 243, 243, 0.88);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.18s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:hover .rowActions,
|
||||||
|
.row:focus-within .rowActions {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconBtn {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition:
|
||||||
|
color 0.2s,
|
||||||
|
background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconBtn:hover {
|
||||||
|
color: var(--brown);
|
||||||
|
background: rgba(143, 65, 12, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowActions .iconBtn {
|
||||||
|
background: rgba(255, 250, 250, 0.95);
|
||||||
|
border: 1px solid rgba(143, 65, 12, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowActions .iconBtn:hover {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeBtn:hover {
|
||||||
|
color: #9b2c2c;
|
||||||
|
background: rgba(155, 44, 44, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowActions .removeBtn:hover {
|
||||||
|
background: rgba(255, 250, 250, 0.95);
|
||||||
|
border-color: rgba(155, 44, 44, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconBtn[data-tooltip]::after {
|
||||||
|
content: attr(data-tooltip);
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 6px);
|
||||||
|
left: 50%;
|
||||||
|
right: auto;
|
||||||
|
z-index: 3;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(42, 24, 16, 0.9);
|
||||||
|
color: #fffafa;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowActions .iconBtn[data-tooltip]::after {
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
bottom: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconBtn[data-tooltip]:hover::after,
|
||||||
|
.iconBtn[data-tooltip]:focus-visible::after {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.flavorTop {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flavorActions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.row,
|
||||||
|
.rowActions,
|
||||||
|
.iconBtn {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
import { useEffect, useId, useState } from 'react'
|
||||||
|
import { Copy, Pencil, Plus, Trash2, X } from 'lucide-react'
|
||||||
|
import type { Category } from '../data/categories'
|
||||||
|
import {
|
||||||
|
createFlavorBlock,
|
||||||
|
createFlavorEntry,
|
||||||
|
flavors,
|
||||||
|
formatPrice,
|
||||||
|
type CategoryFlavorOptions,
|
||||||
|
type FlavorBlock,
|
||||||
|
} from '../data/flavors'
|
||||||
|
import {
|
||||||
|
AmountPriceModal,
|
||||||
|
type AmountPriceValues,
|
||||||
|
} from './AmountPriceModal'
|
||||||
|
import styles from './CategoryModal.module.css'
|
||||||
|
import optionStyles from './CategoryOptionsModal.module.css'
|
||||||
|
|
||||||
|
type CategoryOptionsModalProps = {
|
||||||
|
open: boolean
|
||||||
|
category: Category | null
|
||||||
|
initialOptions?: CategoryFlavorOptions
|
||||||
|
onClose: () => void
|
||||||
|
onSave: (categoryId: string, options: CategoryFlavorOptions) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type EntryDraft = {
|
||||||
|
blockId: string
|
||||||
|
mode: 'create' | 'edit'
|
||||||
|
entryId?: string
|
||||||
|
values: AmountPriceValues | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CategoryOptionsModal({
|
||||||
|
open,
|
||||||
|
category,
|
||||||
|
initialOptions = [],
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
}: CategoryOptionsModalProps) {
|
||||||
|
const titleId = useId()
|
||||||
|
const [blocks, setBlocks] = useState<FlavorBlock[]>([])
|
||||||
|
const [draft, setDraft] = useState<EntryDraft | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !category) return
|
||||||
|
const cloned = structuredClone(initialOptions)
|
||||||
|
setBlocks(cloned.length > 0 ? cloned : [createFlavorBlock()])
|
||||||
|
setDraft(null)
|
||||||
|
// Reset only when the dialog opens for a category
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, category?.id])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape' && !draft) onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [open, draft, onClose])
|
||||||
|
|
||||||
|
if (!open || !category) return null
|
||||||
|
|
||||||
|
function updateBlock(
|
||||||
|
blockId: string,
|
||||||
|
updater: (block: FlavorBlock) => FlavorBlock,
|
||||||
|
) {
|
||||||
|
setBlocks((current) =>
|
||||||
|
current.map((block) => (block.id === blockId ? updater(block) : block)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddFlavor() {
|
||||||
|
setBlocks((current) => [...current, createFlavorBlock()])
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDuplicateFlavor(block: FlavorBlock) {
|
||||||
|
setBlocks((current) => [
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
...structuredClone(block),
|
||||||
|
id: createFlavorBlock().id,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemoveFlavor(blockId: string) {
|
||||||
|
setBlocks((current) => {
|
||||||
|
const next = current.filter((block) => block.id !== blockId)
|
||||||
|
return next.length > 0 ? next : [createFlavorBlock()]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEntrySubmit(values: AmountPriceValues) {
|
||||||
|
if (!draft) return
|
||||||
|
const price = Number(values.price)
|
||||||
|
|
||||||
|
updateBlock(draft.blockId, (block) => {
|
||||||
|
if (draft.mode === 'edit' && draft.entryId) {
|
||||||
|
return {
|
||||||
|
...block,
|
||||||
|
entries: block.entries.map((entry) =>
|
||||||
|
entry.id === draft.entryId
|
||||||
|
? { ...entry, amount: values.amount, price }
|
||||||
|
: entry,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...block,
|
||||||
|
entries: [...block.entries, createFlavorEntry(values.amount, price)],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
setDraft(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemoveEntry(blockId: string, entryId: string) {
|
||||||
|
updateBlock(blockId, (block) => ({
|
||||||
|
...block,
|
||||||
|
entries: block.entries.filter((entry) => entry.id !== entryId),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
onSave(category.id, blocks)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.overlay} onClick={onClose} role="presentation">
|
||||||
|
<div
|
||||||
|
className={`${styles.modal} ${optionStyles.modal}`}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className={styles.shine} aria-hidden />
|
||||||
|
|
||||||
|
<div className={styles.header}>
|
||||||
|
<div>
|
||||||
|
<p className={styles.eyebrow}>Flavor Duplicator</p>
|
||||||
|
<h2 id={titleId} className={styles.title}>
|
||||||
|
آپشنها
|
||||||
|
</h2>
|
||||||
|
<p className={optionStyles.categoryName}>
|
||||||
|
{category.nameFa}
|
||||||
|
<span className={optionStyles.categoryEn}>
|
||||||
|
{category.nameEn}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.closeBtn}
|
||||||
|
aria-label="بستن"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<X size={18} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={optionStyles.body}>
|
||||||
|
<ul className={optionStyles.flavorList}>
|
||||||
|
{blocks.map((block) => (
|
||||||
|
<li key={block.id} className={optionStyles.flavorBlock}>
|
||||||
|
<div className={optionStyles.flavorTop}>
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label
|
||||||
|
className={optionStyles.srOnly}
|
||||||
|
htmlFor={`category-flavor-${block.id}`}
|
||||||
|
>
|
||||||
|
طعم
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id={`category-flavor-${block.id}`}
|
||||||
|
className={optionStyles.select}
|
||||||
|
value={block.flavorId}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateBlock(block.id, (current) => ({
|
||||||
|
...current,
|
||||||
|
flavorId: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{flavors.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.nameFa} — {item.nameEn}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={optionStyles.flavorActions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={optionStyles.iconBtn}
|
||||||
|
aria-label="تکثیر طعم"
|
||||||
|
data-tooltip="تکثیر طعم"
|
||||||
|
onClick={() => handleDuplicateFlavor(block)}
|
||||||
|
>
|
||||||
|
<Copy size={16} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${optionStyles.iconBtn} ${optionStyles.removeBtn}`}
|
||||||
|
aria-label="حذف طعم"
|
||||||
|
data-tooltip="حذف طعم"
|
||||||
|
onClick={() => handleRemoveFlavor(block.id)}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className={optionStyles.list}>
|
||||||
|
{block.entries.map((entry) => (
|
||||||
|
<li key={entry.id} className={optionStyles.row}>
|
||||||
|
<div className={optionStyles.rowText}>
|
||||||
|
<span className={optionStyles.amount}>
|
||||||
|
{entry.amount}
|
||||||
|
</span>
|
||||||
|
<span className={optionStyles.price}>
|
||||||
|
{formatPrice(entry.price)} تومان
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={optionStyles.rowActions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={optionStyles.iconBtn}
|
||||||
|
aria-label="ویرایش"
|
||||||
|
data-tooltip="ویرایش"
|
||||||
|
onClick={() =>
|
||||||
|
setDraft({
|
||||||
|
blockId: block.id,
|
||||||
|
mode: 'edit',
|
||||||
|
entryId: entry.id,
|
||||||
|
values: {
|
||||||
|
amount: entry.amount,
|
||||||
|
price: String(entry.price),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Pencil size={15} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${optionStyles.iconBtn} ${optionStyles.removeBtn}`}
|
||||||
|
aria-label="حذف"
|
||||||
|
data-tooltip="حذف"
|
||||||
|
onClick={() =>
|
||||||
|
handleRemoveEntry(block.id, entry.id)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 size={15} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
<li className={optionStyles.addAmountItem}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={optionStyles.addAmountCard}
|
||||||
|
onClick={() =>
|
||||||
|
setDraft({
|
||||||
|
blockId: block.id,
|
||||||
|
mode: 'create',
|
||||||
|
values: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Plus size={18} strokeWidth={1.75} />
|
||||||
|
افزودن
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={optionStyles.addOptionBtn}
|
||||||
|
onClick={handleAddFlavor}
|
||||||
|
>
|
||||||
|
<Plus size={18} strokeWidth={1.75} />
|
||||||
|
افزودن آپشن جدید
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.cancelBtn}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
انصراف
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.submitBtn}
|
||||||
|
onClick={handleSave}
|
||||||
|
>
|
||||||
|
ذخیره آپشنها
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AmountPriceModal
|
||||||
|
open={Boolean(draft)}
|
||||||
|
title={
|
||||||
|
draft?.mode === 'edit' ? 'ویرایش مقدار و قیمت' : 'افزودن مقدار و قیمت'
|
||||||
|
}
|
||||||
|
initialValues={draft?.values ?? null}
|
||||||
|
onClose={() => setDraft(null)}
|
||||||
|
onSubmit={handleEntrySubmit}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
.modal {
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userName {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { useEffect, useId, useState } from 'react'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import styles from './CategoryModal.module.css'
|
||||||
|
import localStyles from './ChangePasswordModal.module.css'
|
||||||
|
|
||||||
|
type ChangePasswordModalProps = {
|
||||||
|
open: boolean
|
||||||
|
userName: string
|
||||||
|
onClose: () => void
|
||||||
|
onSubmit: (password: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChangePasswordModal({
|
||||||
|
open,
|
||||||
|
userName,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: ChangePasswordModalProps) {
|
||||||
|
const titleId = useId()
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
setPassword('')
|
||||||
|
setConfirmPassword('')
|
||||||
|
setError('')
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [open, onClose])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
function handleSubmit(event: React.FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!password.trim()) {
|
||||||
|
setError('رمز عبور جدید الزامی است')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError('تکرار رمز عبور مطابقت ندارد')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit(password)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.overlay} onClick={onClose} role="presentation">
|
||||||
|
<div
|
||||||
|
className={`${styles.modal} ${localStyles.modal}`}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className={styles.shine} aria-hidden />
|
||||||
|
|
||||||
|
<div className={styles.header}>
|
||||||
|
<div>
|
||||||
|
<p className={styles.eyebrow}>Password</p>
|
||||||
|
<h2 id={titleId} className={styles.title}>
|
||||||
|
تغییر رمز عبور
|
||||||
|
</h2>
|
||||||
|
{userName && <p className={localStyles.userName}>{userName}</p>}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.closeBtn}
|
||||||
|
aria-label="بستن"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<X size={18} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className={styles.form} onSubmit={handleSubmit}>
|
||||||
|
{error && (
|
||||||
|
<div className={styles.error} role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="new-password">رمز عبور جدید</label>
|
||||||
|
<input
|
||||||
|
id="new-password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
dir="ltr"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="confirm-new-password">تکرار رمز عبور جدید</label>
|
||||||
|
<input
|
||||||
|
id="confirm-new-password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
dir="ltr"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.cancelBtn}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
انصراف
|
||||||
|
</button>
|
||||||
|
<button type="submit" className={styles.submitBtn}>
|
||||||
|
ذخیره
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
.modal {
|
||||||
|
max-width: 720px;
|
||||||
|
max-height: min(90vh, 760px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepLabel {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchField {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchFieldOpen {
|
||||||
|
z-index: 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lines {
|
||||||
|
position: relative;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: rgba(255, 250, 250, 0.72);
|
||||||
|
border: 1px solid rgba(143, 65, 12, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lineBody {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lineName {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lineOption {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--brown);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lineMeta {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition:
|
||||||
|
color 0.2s,
|
||||||
|
background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeBtn:hover {
|
||||||
|
color: #9b2c2c;
|
||||||
|
background: rgba(155, 44, 44, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid rgba(143, 65, 12, 0.14);
|
||||||
|
background: rgba(255, 250, 250, 0.88);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.7;
|
||||||
|
resize: vertical;
|
||||||
|
outline: none;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75);
|
||||||
|
transition:
|
||||||
|
border-color 0.2s,
|
||||||
|
box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note:focus {
|
||||||
|
border-color: var(--brown);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.9),
|
||||||
|
0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: rgba(143, 65, 12, 0.06);
|
||||||
|
border: 1px solid rgba(143, 65, 12, 0.14);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--brown);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deliveryType {
|
||||||
|
position: relative;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deliveryType legend {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeCard {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 14px 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1.5px solid rgba(143, 65, 12, 0.18);
|
||||||
|
background: rgba(255, 250, 250, 0.72);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 0.2s,
|
||||||
|
background 0.2s,
|
||||||
|
color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeCard input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeCard:hover {
|
||||||
|
border-color: rgba(143, 65, 12, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeCardActive {
|
||||||
|
border-color: var(--brown);
|
||||||
|
background: rgba(143, 65, 12, 0.08);
|
||||||
|
color: var(--brown);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select {
|
||||||
|
width: 100%;
|
||||||
|
height: var(--field-height);
|
||||||
|
padding: 0 12px;
|
||||||
|
padding-inline-start: 36px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid rgba(143, 65, 12, 0.14);
|
||||||
|
background-color: rgba(255, 250, 250, 0.88);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 400;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%8f410c' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: left 12px center;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75);
|
||||||
|
transition:
|
||||||
|
border-color 0.2s,
|
||||||
|
box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select:focus {
|
||||||
|
border-color: var(--brown);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.9),
|
||||||
|
0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.addressSection {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addressHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.addAddressBtn {
|
||||||
|
color: var(--brown);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 4px 2px;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addAddressBtn:hover {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addressList {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addressCard {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1.5px solid rgba(143, 65, 12, 0.16);
|
||||||
|
background: rgba(255, 250, 250, 0.72);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 0.2s,
|
||||||
|
background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addressCard input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addressCard:hover {
|
||||||
|
border-color: rgba(143, 65, 12, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.addressCardActive {
|
||||||
|
border-color: var(--brown);
|
||||||
|
background: rgba(143, 65, 12, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.addressName {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--brown);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addressMeta,
|
||||||
|
.addressLine,
|
||||||
|
.addressPhone {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addressLine {
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addressPhone {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
direction: ltr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addressSep {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.newAddress {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px dashed rgba(143, 65, 12, 0.22);
|
||||||
|
background: rgba(255, 250, 250, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.addressActions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.typeRow {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,816 @@
|
|||||||
|
import { useEffect, useId, useRef, useState } from 'react'
|
||||||
|
import { Search, Trash2, X } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
searchProducts,
|
||||||
|
searchUsers,
|
||||||
|
type SearchUserResult,
|
||||||
|
} from '../lib/orderSearch'
|
||||||
|
import { getBranches } from '../lib/branchesStore'
|
||||||
|
import {
|
||||||
|
addUserAddress,
|
||||||
|
getUserAddresses,
|
||||||
|
} from '../lib/userAddressesStore'
|
||||||
|
import { districts } from '../data/districts'
|
||||||
|
import {
|
||||||
|
getItemLineTotal,
|
||||||
|
orderSellUnitLabel,
|
||||||
|
type DeliveryType,
|
||||||
|
type Order,
|
||||||
|
type OrderDelivery,
|
||||||
|
type OrderItem,
|
||||||
|
type ShippingAddress,
|
||||||
|
} from '../data/orders'
|
||||||
|
import type { Product } from '../data/products'
|
||||||
|
import { formatPrice } from '../utils/price'
|
||||||
|
import {
|
||||||
|
OrderLineItemModal,
|
||||||
|
type OrderLineDraft,
|
||||||
|
} from './OrderLineItemModal'
|
||||||
|
import styles from './CategoryModal.module.css'
|
||||||
|
import localStyles from './CreateOrderModal.module.css'
|
||||||
|
|
||||||
|
export type CreateOrderValues = {
|
||||||
|
customer: SearchUserResult
|
||||||
|
items: OrderItem[]
|
||||||
|
note: string
|
||||||
|
delivery: OrderDelivery
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateOrderModalProps = {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onSubmit: (values: CreateOrderValues) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type SelectedLine = OrderLineDraft & { key: string }
|
||||||
|
type Step = 1 | 2
|
||||||
|
|
||||||
|
function lineKey() {
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyAddressForm = {
|
||||||
|
name: '',
|
||||||
|
district: '',
|
||||||
|
address: '',
|
||||||
|
landline: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateOrderModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: CreateOrderModalProps) {
|
||||||
|
const titleId = useId()
|
||||||
|
const userRef = useRef<HTMLDivElement>(null)
|
||||||
|
const productRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const [step, setStep] = useState<Step>(1)
|
||||||
|
|
||||||
|
const [customer, setCustomer] = useState<SearchUserResult | null>(null)
|
||||||
|
const [userQuery, setUserQuery] = useState('')
|
||||||
|
const [userOpen, setUserOpen] = useState(false)
|
||||||
|
const [userResults, setUserResults] = useState<SearchUserResult[]>([])
|
||||||
|
const [userLoading, setUserLoading] = useState(false)
|
||||||
|
|
||||||
|
const [productQuery, setProductQuery] = useState('')
|
||||||
|
const [productOpen, setProductOpen] = useState(false)
|
||||||
|
const [productResults, setProductResults] = useState<Product[]>([])
|
||||||
|
const [productLoading, setProductLoading] = useState(false)
|
||||||
|
|
||||||
|
const [lines, setLines] = useState<SelectedLine[]>([])
|
||||||
|
const [pickingProduct, setPickingProduct] = useState<Product | null>(null)
|
||||||
|
const [note, setNote] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const [deliveryType, setDeliveryType] = useState<DeliveryType>('pickup')
|
||||||
|
const [branchId, setBranchId] = useState('')
|
||||||
|
const [addresses, setAddresses] = useState<ShippingAddress[]>([])
|
||||||
|
const [selectedAddressId, setSelectedAddressId] = useState('')
|
||||||
|
const [addingAddress, setAddingAddress] = useState(false)
|
||||||
|
const [addressForm, setAddressForm] = useState(emptyAddressForm)
|
||||||
|
|
||||||
|
const branches = getBranches()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
setStep(1)
|
||||||
|
setCustomer(null)
|
||||||
|
setUserQuery('')
|
||||||
|
setUserOpen(false)
|
||||||
|
setUserResults([])
|
||||||
|
setProductQuery('')
|
||||||
|
setProductOpen(false)
|
||||||
|
setProductResults([])
|
||||||
|
setLines([])
|
||||||
|
setPickingProduct(null)
|
||||||
|
setNote('')
|
||||||
|
setError('')
|
||||||
|
setDeliveryType('pickup')
|
||||||
|
setBranchId(getBranches()[0]?.id ?? '')
|
||||||
|
setAddresses([])
|
||||||
|
setSelectedAddressId('')
|
||||||
|
setAddingAddress(false)
|
||||||
|
setAddressForm(emptyAddressForm)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !customer) return
|
||||||
|
const list = getUserAddresses(customer.id)
|
||||||
|
setAddresses(list)
|
||||||
|
setSelectedAddressId(list[0]?.id ?? '')
|
||||||
|
setAddingAddress(false)
|
||||||
|
setAddressForm(emptyAddressForm)
|
||||||
|
}, [open, customer])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape' && !pickingProduct) onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [open, pickingProduct, onClose])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handlePointerDown(event: MouseEvent) {
|
||||||
|
const target = event.target as Node
|
||||||
|
if (!userRef.current?.contains(target)) setUserOpen(false)
|
||||||
|
if (!productRef.current?.contains(target)) setProductOpen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handlePointerDown)
|
||||||
|
return () => document.removeEventListener('mousedown', handlePointerDown)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !userOpen || step !== 1) return
|
||||||
|
let cancelled = false
|
||||||
|
setUserLoading(true)
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
void searchUsers(userQuery).then((results) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setUserResults(results)
|
||||||
|
setUserLoading(false)
|
||||||
|
})
|
||||||
|
}, 180)
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
window.clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [open, userOpen, userQuery, step])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !productOpen || step !== 1) return
|
||||||
|
let cancelled = false
|
||||||
|
setProductLoading(true)
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
void searchProducts(productQuery).then((results) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setProductResults(results)
|
||||||
|
setProductLoading(false)
|
||||||
|
})
|
||||||
|
}, 180)
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
window.clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [open, productOpen, productQuery, step])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
const itemCount = lines.reduce((sum, line) => sum + line.quantity, 0)
|
||||||
|
const totalPrice = lines.reduce((sum, line) => {
|
||||||
|
const item: OrderItem = {
|
||||||
|
id: line.key,
|
||||||
|
nameFa: line.product.nameFa,
|
||||||
|
quantity: line.quantity,
|
||||||
|
unitPrice: line.product.price,
|
||||||
|
sellUnit: line.product.sellUnit,
|
||||||
|
options: line.options.map((option) => ({
|
||||||
|
name: option.name,
|
||||||
|
price: option.price,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
return sum + getItemLineTotal(item)
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
function buildItems(): OrderItem[] {
|
||||||
|
return lines.map((line) => ({
|
||||||
|
id: line.key,
|
||||||
|
nameFa: line.product.nameFa,
|
||||||
|
quantity: line.quantity,
|
||||||
|
unitPrice: line.product.price,
|
||||||
|
sellUnit: line.product.sellUnit,
|
||||||
|
options:
|
||||||
|
line.options.length > 0
|
||||||
|
? line.options.map((option) => ({
|
||||||
|
name: option.name,
|
||||||
|
price: option.price,
|
||||||
|
}))
|
||||||
|
: undefined,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLineSubmit(draft: OrderLineDraft) {
|
||||||
|
setLines((current) => [...current, { ...draft, key: lineKey() }])
|
||||||
|
setPickingProduct(null)
|
||||||
|
setProductQuery('')
|
||||||
|
setProductOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleContinue(event: React.FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!customer) {
|
||||||
|
setError('انتخاب کاربر الزامی است')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (lines.length === 0) {
|
||||||
|
setError('حداقل یک محصول اضافه کنید')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError('')
|
||||||
|
setStep(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddAddress(event: React.FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!customer) return
|
||||||
|
if (!addressForm.name.trim()) {
|
||||||
|
setError('نام آدرس الزامی است')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!addressForm.district) {
|
||||||
|
setError('انتخاب منطقه الزامی است')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!addressForm.address.trim()) {
|
||||||
|
setError('آدرس کامل الزامی است')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = addUserAddress(customer.id, {
|
||||||
|
name: addressForm.name.trim(),
|
||||||
|
district: addressForm.district,
|
||||||
|
address: addressForm.address.trim(),
|
||||||
|
landline: addressForm.landline.trim(),
|
||||||
|
})
|
||||||
|
setAddresses(getUserAddresses(customer.id))
|
||||||
|
setSelectedAddressId(created.id)
|
||||||
|
setAddingAddress(false)
|
||||||
|
setAddressForm(emptyAddressForm)
|
||||||
|
setError('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFinalSubmit(event: React.FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!customer) {
|
||||||
|
setError('انتخاب کاربر الزامی است')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deliveryType === 'pickup') {
|
||||||
|
const branch = branches.find((item) => item.id === branchId)
|
||||||
|
if (!branch) {
|
||||||
|
setError('انتخاب شعبه الزامی است')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onSubmit({
|
||||||
|
customer,
|
||||||
|
note: note.trim(),
|
||||||
|
items: buildItems(),
|
||||||
|
delivery: { type: 'pickup', branch: branch.name },
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const selected = addresses.find((item) => item.id === selectedAddressId)
|
||||||
|
if (!selected) {
|
||||||
|
setError('انتخاب یا افزودن آدرس الزامی است')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit({
|
||||||
|
customer,
|
||||||
|
note: note.trim(),
|
||||||
|
items: buildItems(),
|
||||||
|
delivery: { type: 'shipping', shippingAddress: selected },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.overlay} onClick={onClose} role="presentation">
|
||||||
|
<div
|
||||||
|
className={`${styles.modal} ${localStyles.modal}`}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className={styles.shine} aria-hidden />
|
||||||
|
|
||||||
|
<div className={styles.header}>
|
||||||
|
<div>
|
||||||
|
<p className={styles.eyebrow}>New Order</p>
|
||||||
|
<h2 id={titleId} className={styles.title}>
|
||||||
|
ثبت سفارش جدید
|
||||||
|
</h2>
|
||||||
|
<p className={localStyles.stepLabel}>
|
||||||
|
مرحله {step === 1 ? '۱ از ۲ — اقلام سفارش' : '۲ از ۲ — نحوه تحویل'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.closeBtn}
|
||||||
|
aria-label="بستن"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<X size={18} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{step === 1 ? (
|
||||||
|
<form className={styles.form} onSubmit={handleContinue}>
|
||||||
|
{error && (
|
||||||
|
<div className={styles.error} role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`${styles.field} ${localStyles.searchField} ${userOpen ? localStyles.searchFieldOpen : ''}`}
|
||||||
|
ref={userRef}
|
||||||
|
>
|
||||||
|
<label htmlFor="order-user-search">کاربر</label>
|
||||||
|
<div className={styles.searchWrap}>
|
||||||
|
<Search size={16} className={styles.searchIcon} />
|
||||||
|
<input
|
||||||
|
id="order-user-search"
|
||||||
|
type="text"
|
||||||
|
placeholder="جستجوی نام یا شماره موبایل..."
|
||||||
|
value={
|
||||||
|
userOpen
|
||||||
|
? userQuery
|
||||||
|
: customer
|
||||||
|
? `${customer.name} — ${customer.phoneDisplay}`
|
||||||
|
: userQuery
|
||||||
|
}
|
||||||
|
onChange={(e) => {
|
||||||
|
setUserQuery(e.target.value)
|
||||||
|
setUserOpen(true)
|
||||||
|
if (customer) setCustomer(null)
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
setUserOpen(true)
|
||||||
|
setUserQuery('')
|
||||||
|
}}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{userOpen && (
|
||||||
|
<ul className={styles.dropdown} role="listbox">
|
||||||
|
{userLoading ? (
|
||||||
|
<li className={styles.emptyOption}>در حال جستجو...</li>
|
||||||
|
) : userResults.length === 0 ? (
|
||||||
|
<li className={styles.emptyOption}>موردی یافت نشد</li>
|
||||||
|
) : (
|
||||||
|
userResults.map((user) => (
|
||||||
|
<li key={user.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.option} ${customer?.id === user.id ? styles.optionActive : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setCustomer(user)
|
||||||
|
setUserQuery('')
|
||||||
|
setUserOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className={styles.optionFa}>{user.name}</span>
|
||||||
|
<span className={styles.optionEn} dir="ltr">
|
||||||
|
{user.phoneDisplay}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`${styles.field} ${localStyles.searchField} ${productOpen ? localStyles.searchFieldOpen : ''}`}
|
||||||
|
ref={productRef}
|
||||||
|
>
|
||||||
|
<label htmlFor="order-product-search">محصولات</label>
|
||||||
|
<div className={styles.searchWrap}>
|
||||||
|
<Search size={16} className={styles.searchIcon} />
|
||||||
|
<input
|
||||||
|
id="order-product-search"
|
||||||
|
type="text"
|
||||||
|
placeholder="جستجوی محصول..."
|
||||||
|
value={productQuery}
|
||||||
|
onChange={(e) => {
|
||||||
|
setProductQuery(e.target.value)
|
||||||
|
setProductOpen(true)
|
||||||
|
}}
|
||||||
|
onFocus={() => setProductOpen(true)}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{productOpen && (
|
||||||
|
<ul className={styles.dropdown} role="listbox">
|
||||||
|
{productLoading ? (
|
||||||
|
<li className={styles.emptyOption}>در حال جستجو...</li>
|
||||||
|
) : productResults.length === 0 ? (
|
||||||
|
<li className={styles.emptyOption}>موردی یافت نشد</li>
|
||||||
|
) : (
|
||||||
|
productResults.map((product) => (
|
||||||
|
<li key={product.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.option}
|
||||||
|
onClick={() => {
|
||||||
|
setPickingProduct(product)
|
||||||
|
setProductOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className={styles.optionFa}>
|
||||||
|
{product.nameFa}
|
||||||
|
</span>
|
||||||
|
<span className={styles.optionEn}>
|
||||||
|
{formatPrice(product.price)} تومان /{' '}
|
||||||
|
{orderSellUnitLabel[product.sellUnit]}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lines.length > 0 && (
|
||||||
|
<ul className={localStyles.lines}>
|
||||||
|
{lines.map((line) => {
|
||||||
|
const optionsTotal = line.options.reduce(
|
||||||
|
(sum, option) => sum + option.price,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
const lineTotal =
|
||||||
|
line.quantity * (line.product.price + optionsTotal)
|
||||||
|
return (
|
||||||
|
<li key={line.key} className={localStyles.line}>
|
||||||
|
<div className={localStyles.lineBody}>
|
||||||
|
<span className={localStyles.lineName}>
|
||||||
|
{line.product.nameFa}
|
||||||
|
</span>
|
||||||
|
{line.options.map((option) => (
|
||||||
|
<span
|
||||||
|
key={option.id}
|
||||||
|
className={localStyles.lineOption}
|
||||||
|
>
|
||||||
|
{option.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<span className={localStyles.lineMeta}>
|
||||||
|
{formatPrice(line.quantity)}{' '}
|
||||||
|
{orderSellUnitLabel[line.product.sellUnit]} —{' '}
|
||||||
|
{formatPrice(lineTotal)} تومان
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={localStyles.removeBtn}
|
||||||
|
aria-label="حذف محصول"
|
||||||
|
onClick={() =>
|
||||||
|
setLines((current) =>
|
||||||
|
current.filter((item) => item.key !== line.key),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="order-note">یادداشت سفارش</label>
|
||||||
|
<textarea
|
||||||
|
id="order-note"
|
||||||
|
className={localStyles.note}
|
||||||
|
rows={3}
|
||||||
|
placeholder="توضیحات سفارش (اختیاری)"
|
||||||
|
value={note}
|
||||||
|
onChange={(e) => setNote(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(itemCount > 0 || totalPrice > 0) && (
|
||||||
|
<div className={localStyles.summary}>
|
||||||
|
<span>جمع اقلام: {formatPrice(itemCount)}</span>
|
||||||
|
<span>{formatPrice(totalPrice)} تومان</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.cancelBtn}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
انصراف
|
||||||
|
</button>
|
||||||
|
<button type="submit" className={styles.submitBtn}>
|
||||||
|
ادامه
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<form className={styles.form} onSubmit={handleFinalSubmit}>
|
||||||
|
{error && (
|
||||||
|
<div className={styles.error} role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<fieldset className={localStyles.deliveryType}>
|
||||||
|
<legend>نوع تحویل</legend>
|
||||||
|
<div className={localStyles.typeRow}>
|
||||||
|
<label
|
||||||
|
className={`${localStyles.typeCard} ${deliveryType === 'pickup' ? localStyles.typeCardActive : ''}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="delivery-type"
|
||||||
|
checked={deliveryType === 'pickup'}
|
||||||
|
onChange={() => {
|
||||||
|
setDeliveryType('pickup')
|
||||||
|
setError('')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
دریافت در محل
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
className={`${localStyles.typeCard} ${deliveryType === 'shipping' ? localStyles.typeCardActive : ''}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="delivery-type"
|
||||||
|
checked={deliveryType === 'shipping'}
|
||||||
|
onChange={() => {
|
||||||
|
setDeliveryType('shipping')
|
||||||
|
setError('')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
ارسال به
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{deliveryType === 'pickup' ? (
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="order-branch">شعبه</label>
|
||||||
|
<select
|
||||||
|
id="order-branch"
|
||||||
|
className={localStyles.select}
|
||||||
|
value={branchId}
|
||||||
|
onChange={(e) => setBranchId(e.target.value)}
|
||||||
|
>
|
||||||
|
{branches.length === 0 ? (
|
||||||
|
<option value="">شعبهای تعریف نشده</option>
|
||||||
|
) : (
|
||||||
|
branches.map((branch) => (
|
||||||
|
<option key={branch.id} value={branch.id}>
|
||||||
|
{branch.name}
|
||||||
|
{branch.district ? ` — ${branch.district}` : ''}
|
||||||
|
</option>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={localStyles.addressSection}>
|
||||||
|
<div className={localStyles.addressHeader}>
|
||||||
|
<span>آدرس کاربر</span>
|
||||||
|
{!addingAddress && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={localStyles.addAddressBtn}
|
||||||
|
onClick={() => {
|
||||||
|
setAddingAddress(true)
|
||||||
|
setError('')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
افزودن آدرس
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{addresses.length > 0 && !addingAddress && (
|
||||||
|
<ul className={localStyles.addressList}>
|
||||||
|
{addresses.map((address) => (
|
||||||
|
<li key={address.id}>
|
||||||
|
<label
|
||||||
|
className={`${localStyles.addressCard} ${selectedAddressId === address.id ? localStyles.addressCardActive : ''}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="shipping-address"
|
||||||
|
checked={selectedAddressId === address.id}
|
||||||
|
onChange={() => setSelectedAddressId(address.id)}
|
||||||
|
/>
|
||||||
|
<span className={localStyles.addressName}>
|
||||||
|
{address.name}
|
||||||
|
</span>
|
||||||
|
<span className={localStyles.addressSep} aria-hidden>
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
<span className={localStyles.addressMeta}>
|
||||||
|
{address.district}
|
||||||
|
</span>
|
||||||
|
<span className={localStyles.addressSep} aria-hidden>
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
<span className={localStyles.addressLine}>
|
||||||
|
{address.address}
|
||||||
|
</span>
|
||||||
|
{address.landline && (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className={localStyles.addressSep}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={localStyles.addressPhone}
|
||||||
|
dir="ltr"
|
||||||
|
>
|
||||||
|
{address.landline}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(addingAddress || addresses.length === 0) && (
|
||||||
|
<div className={localStyles.newAddress}>
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="addr-name">نام آدرس</label>
|
||||||
|
<input
|
||||||
|
id="addr-name"
|
||||||
|
type="text"
|
||||||
|
placeholder="مثال: منزل / محل کار"
|
||||||
|
value={addressForm.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setAddressForm((current) => ({
|
||||||
|
...current,
|
||||||
|
name: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="addr-district">منطقه</label>
|
||||||
|
<select
|
||||||
|
id="addr-district"
|
||||||
|
className={localStyles.select}
|
||||||
|
value={addressForm.district}
|
||||||
|
onChange={(e) =>
|
||||||
|
setAddressForm((current) => ({
|
||||||
|
...current,
|
||||||
|
district: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">انتخاب منطقه</option>
|
||||||
|
{districts.map((district) => (
|
||||||
|
<option key={district} value={district}>
|
||||||
|
{district}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="addr-full">آدرس</label>
|
||||||
|
<textarea
|
||||||
|
id="addr-full"
|
||||||
|
className={localStyles.note}
|
||||||
|
rows={2}
|
||||||
|
placeholder="آدرس کامل"
|
||||||
|
value={addressForm.address}
|
||||||
|
onChange={(e) =>
|
||||||
|
setAddressForm((current) => ({
|
||||||
|
...current,
|
||||||
|
address: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="addr-landline">تلفن ثابت</label>
|
||||||
|
<input
|
||||||
|
id="addr-landline"
|
||||||
|
type="tel"
|
||||||
|
inputMode="tel"
|
||||||
|
dir="ltr"
|
||||||
|
placeholder="0253..."
|
||||||
|
value={addressForm.landline}
|
||||||
|
onChange={(e) =>
|
||||||
|
setAddressForm((current) => ({
|
||||||
|
...current,
|
||||||
|
landline: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={localStyles.addressActions}>
|
||||||
|
{addresses.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.cancelBtn}
|
||||||
|
onClick={() => {
|
||||||
|
setAddingAddress(false)
|
||||||
|
setAddressForm(emptyAddressForm)
|
||||||
|
setError('')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
انصراف
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.submitBtn}
|
||||||
|
onClick={handleAddAddress}
|
||||||
|
>
|
||||||
|
ذخیره آدرس
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.cancelBtn}
|
||||||
|
onClick={() => {
|
||||||
|
setError('')
|
||||||
|
setStep(1)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
قبلی
|
||||||
|
</button>
|
||||||
|
<button type="submit" className={styles.submitBtn}>
|
||||||
|
ثبت سفارش
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<OrderLineItemModal
|
||||||
|
open={Boolean(pickingProduct)}
|
||||||
|
product={pickingProduct}
|
||||||
|
onClose={() => setPickingProduct(null)}
|
||||||
|
onSubmit={handleLineSubmit}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOrderFromCreateValues(
|
||||||
|
values: CreateOrderValues,
|
||||||
|
nextId: string,
|
||||||
|
): Order {
|
||||||
|
const itemCount = values.items.reduce((sum, item) => sum + item.quantity, 0)
|
||||||
|
const totalPrice = values.items.reduce(
|
||||||
|
(sum, item) => sum + getItemLineTotal(item),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: nextId,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
customerName: values.customer.name,
|
||||||
|
customerPhone: values.customer.phone,
|
||||||
|
itemCount,
|
||||||
|
totalPrice,
|
||||||
|
status: 'pending',
|
||||||
|
delivery: values.delivery,
|
||||||
|
items: values.items,
|
||||||
|
note: values.note || undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
.header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 40;
|
||||||
|
min-height: var(--header-height);
|
||||||
|
backdrop-filter: blur(20px) saturate(1.2);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(1.2);
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgba(255, 250, 250, 0.9) 0%,
|
||||||
|
rgba(251, 243, 243, 0.78) 100%
|
||||||
|
);
|
||||||
|
border-bottom: 1px solid var(--glass-border-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset-inline: 0;
|
||||||
|
bottom: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
rgba(143, 65, 12, 0.35),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inner {
|
||||||
|
max-width: 1120px;
|
||||||
|
margin: 0 auto;
|
||||||
|
min-height: var(--header-height);
|
||||||
|
padding: 10px 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 48px;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navList {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navItem {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navTrigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition:
|
||||||
|
color 0.2s,
|
||||||
|
background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navTrigger:hover,
|
||||||
|
.navTriggerOpen {
|
||||||
|
color: var(--brown);
|
||||||
|
background: rgba(143, 65, 12, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron {
|
||||||
|
transition: transform 0.2s var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevronOpen {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
inset-inline-start: 0;
|
||||||
|
min-width: 200px;
|
||||||
|
padding: 8px;
|
||||||
|
list-style: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: rgba(255, 250, 250, 0.96);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
-webkit-backdrop-filter: blur(18px);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
box-shadow: 0 14px 34px rgba(143, 65, 12, 0.12);
|
||||||
|
animation: fadeUp 0.2s var(--ease-out) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdownLink {
|
||||||
|
display: block;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition:
|
||||||
|
color 0.2s,
|
||||||
|
background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdownLink:hover {
|
||||||
|
color: var(--brown);
|
||||||
|
background: rgba(143, 65, 12, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userName {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
max-width: 120px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoutBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 6px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoutBtn:hover {
|
||||||
|
opacity: 0.72;
|
||||||
|
color: var(--brown);
|
||||||
|
}
|
||||||
|
|
||||||
|
.srOnly {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.inner {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
order: 3;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navList {
|
||||||
|
justify-content: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navTrigger span:not(:first-child) {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.userName {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoutBtn span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navTrigger {
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { useEffect, useId, useRef, useState } from 'react'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { ChevronDown, LogOut } from 'lucide-react'
|
||||||
|
import { clearSession, getSession } from '../lib/auth'
|
||||||
|
import { logout } from '../lib/authApi'
|
||||||
|
import { navSections } from '../lib/nav'
|
||||||
|
import logo from '../images/Logo-balout-256.png'
|
||||||
|
import styles from './Header.module.css'
|
||||||
|
|
||||||
|
type HeaderProps = {
|
||||||
|
showLogout?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header({ showLogout = true }: HeaderProps) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const session = getSession()
|
||||||
|
const [openId, setOpenId] = useState<string | null>(null)
|
||||||
|
const navRef = useRef<HTMLElement>(null)
|
||||||
|
const navLabelId = useId()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handlePointerDown(event: MouseEvent) {
|
||||||
|
if (!navRef.current?.contains(event.target as Node)) {
|
||||||
|
setOpenId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') setOpenId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handlePointerDown)
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handlePointerDown)
|
||||||
|
document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
try {
|
||||||
|
await logout()
|
||||||
|
} catch {
|
||||||
|
clearSession()
|
||||||
|
}
|
||||||
|
navigate('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSection(id: string) {
|
||||||
|
setOpenId((current) => (current === id ? null : id))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className={styles.header}>
|
||||||
|
<div className={styles.inner}>
|
||||||
|
<Link to="/" className={styles.brand} onClick={() => setOpenId(null)}>
|
||||||
|
<img src={logo} alt="بلوط" className={styles.logo} />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<nav
|
||||||
|
ref={navRef}
|
||||||
|
className={styles.nav}
|
||||||
|
aria-labelledby={navLabelId}
|
||||||
|
>
|
||||||
|
<span id={navLabelId} className={styles.srOnly}>
|
||||||
|
منوی اصلی
|
||||||
|
</span>
|
||||||
|
<ul className={styles.navList}>
|
||||||
|
{navSections.map((section) => {
|
||||||
|
const Icon = section.icon
|
||||||
|
const isOpen = openId === section.id
|
||||||
|
const hasDropdown = section.items.length > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={section.id} className={styles.navItem}>
|
||||||
|
{hasDropdown ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.navTrigger} ${isOpen ? styles.navTriggerOpen : ''}`}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
onClick={() => toggleSection(section.id)}
|
||||||
|
>
|
||||||
|
<Icon size={18} strokeWidth={1.75} />
|
||||||
|
<span>{section.title}</span>
|
||||||
|
<ChevronDown
|
||||||
|
size={16}
|
||||||
|
strokeWidth={1.75}
|
||||||
|
className={`${styles.chevron} ${isOpen ? styles.chevronOpen : ''}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<ul className={styles.dropdown} role="menu">
|
||||||
|
{section.items.map((item) => (
|
||||||
|
<li key={item.href} role="none">
|
||||||
|
<Link
|
||||||
|
to={item.href}
|
||||||
|
role="menuitem"
|
||||||
|
className={styles.dropdownLink}
|
||||||
|
onClick={() => setOpenId(null)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
to={section.href}
|
||||||
|
className={styles.navTrigger}
|
||||||
|
onClick={() => setOpenId(null)}
|
||||||
|
>
|
||||||
|
<Icon size={18} strokeWidth={1.75} />
|
||||||
|
<span>{section.title}</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{showLogout && (
|
||||||
|
<div className={styles.actions}>
|
||||||
|
{session && (
|
||||||
|
<span className={styles.userName}>{session.user.name}</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.logoutBtn}
|
||||||
|
onClick={handleLogout}
|
||||||
|
aria-label="خروج"
|
||||||
|
>
|
||||||
|
<LogOut size={18} strokeWidth={1.75} />
|
||||||
|
<span>خروج</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
.wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadZone {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
max-width: 320px;
|
||||||
|
border: 2px dashed rgba(143, 65, 12, 0.28);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: rgba(255, 250, 250, 0.55);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 0.2s,
|
||||||
|
background 0.2s,
|
||||||
|
color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadZone:hover {
|
||||||
|
border-color: var(--brown);
|
||||||
|
background: rgba(143, 65, 12, 0.05);
|
||||||
|
color: var(--brown);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
background: var(--bg-mid);
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewImg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeBtn {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
inset-inline-start: 8px;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(42, 24, 16, 0.65);
|
||||||
|
color: white;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeBtn:hover {
|
||||||
|
background: #9b2c2c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropPanel {
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(255, 250, 250, 0.7);
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropArea {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
background: #2a1810;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropControls {
|
||||||
|
padding: 14px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoomLabel {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoomLabel input {
|
||||||
|
flex: 1;
|
||||||
|
accent-color: var(--brown);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropActions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelBtn,
|
||||||
|
.applyBtn {
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelBtn {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelBtn:hover {
|
||||||
|
background: rgba(143, 65, 12, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.applyBtn {
|
||||||
|
color: var(--text-on-dark);
|
||||||
|
background: var(--brown);
|
||||||
|
}
|
||||||
|
|
||||||
|
.applyBtn:hover {
|
||||||
|
background: var(--brown-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.applyBtn:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changeBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--brown);
|
||||||
|
border: 1px solid rgba(143, 65, 12, 0.25);
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changeBtn:hover {
|
||||||
|
background: rgba(143, 65, 12, 0.06);
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import Cropper, { type Area } from 'react-easy-crop'
|
||||||
|
import { ImagePlus, X } from 'lucide-react'
|
||||||
|
import { getCroppedImage } from '../utils/cropImage'
|
||||||
|
import styles from './ImageCropper.module.css'
|
||||||
|
|
||||||
|
type ImageCropperProps = {
|
||||||
|
value: string | null
|
||||||
|
onChange: (value: string | null) => void
|
||||||
|
aspect?: number
|
||||||
|
uploadLabel?: string
|
||||||
|
hint?: string
|
||||||
|
changeLabel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageCropper({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
aspect = 1,
|
||||||
|
uploadLabel = 'آپلود تصویر اصلی',
|
||||||
|
hint = 'برای برش مربعی ۱:۱ کلیک کنید',
|
||||||
|
changeLabel = 'تغییر تصویر',
|
||||||
|
}: ImageCropperProps) {
|
||||||
|
const [imageSrc, setImageSrc] = useState<string | null>(null)
|
||||||
|
const [crop, setCrop] = useState({ x: 0, y: 0 })
|
||||||
|
const [zoom, setZoom] = useState(1)
|
||||||
|
const [croppedArea, setCroppedArea] = useState<Area | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCrop({ x: 0, y: 0 })
|
||||||
|
setZoom(1)
|
||||||
|
setCroppedArea(null)
|
||||||
|
}, [aspect])
|
||||||
|
|
||||||
|
const onCropComplete = useCallback((_: Area, pixels: Area) => {
|
||||||
|
setCroppedArea(pixels)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
setImageSrc(reader.result as string)
|
||||||
|
setCrop({ x: 0, y: 0 })
|
||||||
|
setZoom(1)
|
||||||
|
setCroppedArea(null)
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
e.target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyCrop() {
|
||||||
|
if (!imageSrc || !croppedArea) return
|
||||||
|
const cropped = await getCroppedImage(imageSrc, croppedArea, 'jpeg')
|
||||||
|
onChange(cropped)
|
||||||
|
setImageSrc(null)
|
||||||
|
setZoom(1)
|
||||||
|
setCrop({ x: 0, y: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelCrop() {
|
||||||
|
setImageSrc(null)
|
||||||
|
setZoom(1)
|
||||||
|
setCrop({ x: 0, y: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrapper}>
|
||||||
|
{value && !imageSrc && (
|
||||||
|
<div className={styles.preview}>
|
||||||
|
<img src={value} alt="پیشنمایش تصویر" className={styles.previewImg} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.removeBtn}
|
||||||
|
onClick={() => onChange(null)}
|
||||||
|
aria-label="حذف تصویر"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!value && !imageSrc && (
|
||||||
|
<label className={styles.uploadZone}>
|
||||||
|
<ImagePlus size={28} />
|
||||||
|
<span>{uploadLabel}</span>
|
||||||
|
<span className={styles.hint}>{hint}</span>
|
||||||
|
<input type="file" accept="image/*" onChange={handleFile} hidden />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{imageSrc && (
|
||||||
|
<div className={styles.cropPanel}>
|
||||||
|
<div className={styles.cropArea}>
|
||||||
|
<Cropper
|
||||||
|
image={imageSrc}
|
||||||
|
crop={crop}
|
||||||
|
zoom={zoom}
|
||||||
|
aspect={aspect}
|
||||||
|
onCropChange={setCrop}
|
||||||
|
onZoomChange={setZoom}
|
||||||
|
onCropComplete={onCropComplete}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.cropControls}>
|
||||||
|
<label className={styles.zoomLabel}>
|
||||||
|
بزرگنمایی
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={1}
|
||||||
|
max={3}
|
||||||
|
step={0.05}
|
||||||
|
value={zoom}
|
||||||
|
onChange={(e) => setZoom(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className={styles.cropActions}>
|
||||||
|
<button type="button" className={styles.cancelBtn} onClick={cancelCrop}>
|
||||||
|
انصراف
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.applyBtn}
|
||||||
|
onClick={() => void applyCrop()}
|
||||||
|
disabled={!croppedArea}
|
||||||
|
>
|
||||||
|
اعمال برش
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{value && !imageSrc && (
|
||||||
|
<label className={styles.changeBtn}>
|
||||||
|
{changeLabel}
|
||||||
|
<input type="file" accept="image/*" onChange={handleFile} hidden />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
.wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header > label:first-child {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.addBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid rgba(143, 65, 12, 0.22);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
color 0.2s,
|
||||||
|
background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addBtn:hover {
|
||||||
|
color: var(--brown);
|
||||||
|
background: rgba(143, 65, 12, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px dashed rgba(143, 65, 12, 0.2);
|
||||||
|
background: rgba(255, 250, 250, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
background: var(--bg-mid);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeBtn {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
inset-inline-start: 6px;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(42, 24, 16, 0.65);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeBtn:hover {
|
||||||
|
background: #9b2c2c;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { ImagePlus, X } from 'lucide-react'
|
||||||
|
import styles from './ImageGallery.module.css'
|
||||||
|
|
||||||
|
type ImageGalleryProps = {
|
||||||
|
label: string
|
||||||
|
images: string[]
|
||||||
|
onChange: (images: string[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageGallery({ label, images, onChange }: ImageGalleryProps) {
|
||||||
|
function handleFiles(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const files = Array.from(e.target.files ?? [])
|
||||||
|
if (files.length === 0) return
|
||||||
|
|
||||||
|
Promise.all(
|
||||||
|
files.map(
|
||||||
|
(file) =>
|
||||||
|
new Promise<string>((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => resolve(reader.result as string)
|
||||||
|
reader.onerror = reject
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).then((loaded) => {
|
||||||
|
onChange([...images, ...loaded])
|
||||||
|
})
|
||||||
|
|
||||||
|
e.target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeImage(index: number) {
|
||||||
|
onChange(images.filter((_, i) => i !== index))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<label>{label}</label>
|
||||||
|
<label className={styles.addBtn}>
|
||||||
|
<ImagePlus size={16} />
|
||||||
|
افزودن تصویر
|
||||||
|
<input type="file" accept="image/*" multiple hidden onChange={handleFiles} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{images.length === 0 ? (
|
||||||
|
<p className={styles.empty}>هنوز تصویری به گالری اضافه نشده است.</p>
|
||||||
|
) : (
|
||||||
|
<div className={styles.grid}>
|
||||||
|
{images.map((image, index) => (
|
||||||
|
<div key={`${index}-${image.slice(0, 24)}`} className={styles.item}>
|
||||||
|
<img src={image} alt={`گالری ${index + 1}`} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.removeBtn}
|
||||||
|
aria-label="حذف تصویر"
|
||||||
|
onClick={() => removeImage(index)}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
.modal {
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerText {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
text-align: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orderId {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-family: var(--font-en);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--brown);
|
||||||
|
direction: ltr;
|
||||||
|
text-align: right;
|
||||||
|
unicode-bidi: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 14px 18px;
|
||||||
|
margin: 0 0 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta dt {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta dd {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delivery {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone {
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemsTitle {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--brown);
|
||||||
|
}
|
||||||
|
|
||||||
|
.items {
|
||||||
|
position: relative;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
padding: 12px 12px 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: rgba(255, 250, 250, 0.72);
|
||||||
|
border: 1px solid rgba(143, 65, 12, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(72px, auto) auto;
|
||||||
|
gap: 10px 14px;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemRow + .itemRow {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemName {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemAmount {
|
||||||
|
justify-self: center;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemPrice {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemOption {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--brown);
|
||||||
|
line-height: 1.4;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemOptionCalc {
|
||||||
|
grid-column: 3;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: end;
|
||||||
|
unicode-bidi: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemFooter {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px dashed rgba(143, 65, 12, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemTotal {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--brown);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemsSummary {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: rgba(143, 65, 12, 0.06);
|
||||||
|
border: 1px solid rgba(143, 65, 12, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryItems {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryPrice {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--brown);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
position: relative;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: rgba(143, 65, 12, 0.05);
|
||||||
|
border: 1px solid rgba(143, 65, 12, 0.12);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note span {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--brown);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.meta {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { useEffect, useId } from 'react'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
deliveryTypeLabel,
|
||||||
|
formatOrderDateTime,
|
||||||
|
formatShippingAddress,
|
||||||
|
orderSellUnitLabel,
|
||||||
|
orderStatusLabel,
|
||||||
|
type Order,
|
||||||
|
} from '../data/orders'
|
||||||
|
import { formatCellNumber } from '../data/users'
|
||||||
|
import { formatPrice } from '../utils/price'
|
||||||
|
import styles from './CategoryModal.module.css'
|
||||||
|
import localStyles from './OrderDetailsModal.module.css'
|
||||||
|
|
||||||
|
type OrderDetailsModalProps = {
|
||||||
|
open: boolean
|
||||||
|
order: Order | null
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrderDetailsModal({
|
||||||
|
open,
|
||||||
|
order,
|
||||||
|
onClose,
|
||||||
|
}: OrderDetailsModalProps) {
|
||||||
|
const titleId = useId()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [open, onClose])
|
||||||
|
|
||||||
|
if (!open || !order) return null
|
||||||
|
|
||||||
|
const when = formatOrderDateTime(order.createdAt)
|
||||||
|
const deliveryDetail =
|
||||||
|
order.delivery.type === 'shipping'
|
||||||
|
? formatShippingAddress(order.delivery.shippingAddress)
|
||||||
|
: order.delivery.branch
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.overlay} onClick={onClose} role="presentation">
|
||||||
|
<div
|
||||||
|
className={`${styles.modal} ${localStyles.modal}`}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className={styles.shine} aria-hidden />
|
||||||
|
|
||||||
|
<div className={styles.header}>
|
||||||
|
<div className={localStyles.headerText}>
|
||||||
|
<p className={styles.eyebrow}>Order Details</p>
|
||||||
|
<h2 id={titleId} className={styles.title}>
|
||||||
|
جزئیات سفارش
|
||||||
|
</h2>
|
||||||
|
<p className={localStyles.orderId} dir="ltr">
|
||||||
|
{order.id}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.closeBtn}
|
||||||
|
aria-label="بستن"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<X size={18} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className={localStyles.meta}>
|
||||||
|
<div>
|
||||||
|
<dt>تاریخ و ساعت</dt>
|
||||||
|
<dd>
|
||||||
|
{when.date}
|
||||||
|
{when.time ? ` — ${when.time}` : ''}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>سفارشدهنده</dt>
|
||||||
|
<dd>
|
||||||
|
{order.customerName}
|
||||||
|
<span className={localStyles.phone} dir="ltr">
|
||||||
|
{formatCellNumber(order.customerPhone)}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>وضعیت</dt>
|
||||||
|
<dd>{orderStatusLabel[order.status]}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>جمع کل</dt>
|
||||||
|
<dd>{formatPrice(order.totalPrice)} تومان</dd>
|
||||||
|
</div>
|
||||||
|
<div className={localStyles.delivery}>
|
||||||
|
<dt>{deliveryTypeLabel[order.delivery.type]}</dt>
|
||||||
|
<dd>{deliveryDetail}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<h3 className={localStyles.itemsTitle}>اقلام سفارش</h3>
|
||||||
|
<ul className={localStyles.items}>
|
||||||
|
{order.items.map((item) => {
|
||||||
|
const productTotal = item.quantity * item.unitPrice
|
||||||
|
const itemOptions = item.options ?? []
|
||||||
|
const optionTotal = itemOptions.reduce(
|
||||||
|
(sum, option) => sum + item.quantity * option.price,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
const lineTotal = productTotal + optionTotal
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={item.id} className={localStyles.item}>
|
||||||
|
<div className={localStyles.itemRow}>
|
||||||
|
<span className={localStyles.itemName}>{item.nameFa}</span>
|
||||||
|
<span className={localStyles.itemAmount}>
|
||||||
|
{formatPrice(item.quantity)}{' '}
|
||||||
|
{orderSellUnitLabel[item.sellUnit]}
|
||||||
|
</span>
|
||||||
|
<span className={localStyles.itemPrice}>
|
||||||
|
{formatPrice(productTotal)} تومان
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{itemOptions.map((option) => (
|
||||||
|
<div key={`${item.id}-${option.name}`} className={localStyles.itemRow}>
|
||||||
|
<span className={localStyles.itemOption}>
|
||||||
|
{option.name}
|
||||||
|
</span>
|
||||||
|
<span className={localStyles.itemOptionCalc} dir="ltr">
|
||||||
|
{formatPrice(item.quantity)}×{' '}
|
||||||
|
{formatPrice(option.price)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className={localStyles.itemFooter}>
|
||||||
|
<span className={localStyles.itemTotal}>
|
||||||
|
{formatPrice(lineTotal)} تومان
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className={localStyles.itemsSummary}>
|
||||||
|
<span className={localStyles.summaryItems}>
|
||||||
|
جمع اقلام: {formatPrice(order.itemCount)}
|
||||||
|
</span>
|
||||||
|
<span className={localStyles.summaryPrice}>
|
||||||
|
{formatPrice(order.totalPrice)} تومان
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{order.note && (
|
||||||
|
<p className={localStyles.note}>
|
||||||
|
<span>یادداشت:</span> {order.note}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
.modal {
|
||||||
|
max-width: 720px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiSelect {
|
||||||
|
position: relative;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiSelect legend {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionList {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid rgba(143, 65, 12, 0.14);
|
||||||
|
background: rgba(255, 250, 250, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.modal {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionList {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionChip {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1.5px solid rgba(143, 65, 12, 0.16);
|
||||||
|
background: rgba(255, 250, 250, 0.88);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 0.2s,
|
||||||
|
background 0.2s,
|
||||||
|
color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionChip:hover {
|
||||||
|
border-color: rgba(143, 65, 12, 0.32);
|
||||||
|
background: rgba(143, 65, 12, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionChipActive {
|
||||||
|
border-color: var(--brown);
|
||||||
|
background: rgba(143, 65, 12, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionCheck {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 1px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1.5px solid rgba(143, 65, 12, 0.28);
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text-on-dark);
|
||||||
|
transition:
|
||||||
|
background 0.2s,
|
||||||
|
border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionChipActive .optionCheck {
|
||||||
|
border-color: var(--brown);
|
||||||
|
background: var(--brown);
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionText {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionName {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionChipActive .optionName {
|
||||||
|
color: var(--brown);
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionPrice {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedHint {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--brown);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
position: relative;
|
||||||
|
margin: 4px 0 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--brown);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import { useEffect, useId, useState } from 'react'
|
||||||
|
import { Check, X } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
getProductOrderOptions,
|
||||||
|
type ProductOptionChoice,
|
||||||
|
} from '../lib/orderSearch'
|
||||||
|
import { orderSellUnitLabel } from '../data/orders'
|
||||||
|
import type { Product } from '../data/products'
|
||||||
|
import { formatPrice } from '../utils/price'
|
||||||
|
import styles from './CategoryModal.module.css'
|
||||||
|
import nestedStyles from './AmountPriceModal.module.css'
|
||||||
|
import localStyles from './OrderLineItemModal.module.css'
|
||||||
|
|
||||||
|
export type OrderLineDraft = {
|
||||||
|
product: Product
|
||||||
|
quantity: number
|
||||||
|
options: ProductOptionChoice[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrderLineItemModalProps = {
|
||||||
|
open: boolean
|
||||||
|
product: Product | null
|
||||||
|
initial?: OrderLineDraft | null
|
||||||
|
onClose: () => void
|
||||||
|
onSubmit: (draft: OrderLineDraft) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrderLineItemModal({
|
||||||
|
open,
|
||||||
|
product,
|
||||||
|
initial = null,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: OrderLineItemModalProps) {
|
||||||
|
const titleId = useId()
|
||||||
|
const [quantity, setQuantity] = useState('1')
|
||||||
|
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const options = product ? getProductOrderOptions(product) : []
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !product) return
|
||||||
|
setQuantity(initial ? String(initial.quantity) : '1')
|
||||||
|
setSelectedIds(initial?.options.map((option) => option.id) ?? [])
|
||||||
|
setError('')
|
||||||
|
}, [open, product, initial])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.stopPropagation()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [open, onClose])
|
||||||
|
|
||||||
|
if (!open || !product) return null
|
||||||
|
|
||||||
|
const selectedOptions = options.filter((option) =>
|
||||||
|
selectedIds.includes(option.id),
|
||||||
|
)
|
||||||
|
const optionsTotal = selectedOptions.reduce(
|
||||||
|
(sum, option) => sum + option.price,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
const qtyNumber = Number(quantity)
|
||||||
|
const linePreview =
|
||||||
|
Number.isFinite(qtyNumber) && qtyNumber > 0
|
||||||
|
? qtyNumber * (product.price + optionsTotal)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
function toggleOption(optionId: string) {
|
||||||
|
setSelectedIds((current) =>
|
||||||
|
current.includes(optionId)
|
||||||
|
? current.filter((id) => id !== optionId)
|
||||||
|
: [...current, optionId],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit(event: React.FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
const qty = Number(quantity)
|
||||||
|
if (!Number.isFinite(qty) || qty <= 0) {
|
||||||
|
setError('تعداد معتبر وارد کنید')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit({
|
||||||
|
product,
|
||||||
|
quantity: qty,
|
||||||
|
options: selectedOptions,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${styles.overlay} ${nestedStyles.overlay}`}
|
||||||
|
onClick={onClose}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`${styles.modal} ${localStyles.modal}`}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className={styles.shine} aria-hidden />
|
||||||
|
|
||||||
|
<div className={styles.header}>
|
||||||
|
<div>
|
||||||
|
<p className={styles.eyebrow}>Add Product</p>
|
||||||
|
<h2 id={titleId} className={styles.title}>
|
||||||
|
{product.nameFa}
|
||||||
|
</h2>
|
||||||
|
<p className={localStyles.meta}>
|
||||||
|
{formatPrice(product.price)} تومان /{' '}
|
||||||
|
{orderSellUnitLabel[product.sellUnit]}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.closeBtn}
|
||||||
|
aria-label="بستن"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<X size={18} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className={styles.form} onSubmit={handleSubmit}>
|
||||||
|
{error && (
|
||||||
|
<div className={styles.error} role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="order-line-qty">
|
||||||
|
تعداد ({orderSellUnitLabel[product.sellUnit]})
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="order-line-qty"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
step={1}
|
||||||
|
inputMode="numeric"
|
||||||
|
dir="ltr"
|
||||||
|
value={quantity}
|
||||||
|
onChange={(e) => setQuantity(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{options.length > 0 && (
|
||||||
|
<fieldset className={localStyles.multiSelect}>
|
||||||
|
<legend>گزینهها (چندتایی)</legend>
|
||||||
|
<div
|
||||||
|
className={localStyles.optionList}
|
||||||
|
role="group"
|
||||||
|
aria-label="گزینههای محصول"
|
||||||
|
>
|
||||||
|
{options.map((option) => {
|
||||||
|
const checked = selectedIds.includes(option.id)
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={option.id}
|
||||||
|
className={`${localStyles.optionChip} ${checked ? localStyles.optionChipActive : ''}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className={localStyles.checkbox}
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => toggleOption(option.id)}
|
||||||
|
/>
|
||||||
|
<span className={localStyles.optionCheck} aria-hidden>
|
||||||
|
{checked ? <Check size={14} strokeWidth={2.25} /> : null}
|
||||||
|
</span>
|
||||||
|
<span className={localStyles.optionText}>
|
||||||
|
<span className={localStyles.optionName}>
|
||||||
|
{option.name}
|
||||||
|
</span>
|
||||||
|
{option.price > 0 && (
|
||||||
|
<span className={localStyles.optionPrice}>
|
||||||
|
+{formatPrice(option.price)} تومان
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{selectedOptions.length > 0 && (
|
||||||
|
<p className={localStyles.selectedHint}>
|
||||||
|
{formatPrice(selectedOptions.length)} گزینه انتخاب شده
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</fieldset>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className={localStyles.preview}>
|
||||||
|
جمع این قلم: {formatPrice(linePreview)} تومان
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.cancelBtn}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
انصراف
|
||||||
|
</button>
|
||||||
|
<button type="submit" className={styles.submitBtn}>
|
||||||
|
افزودن به سفارش
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
.orderId {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-family: var(--font-en);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
direction: ltr;
|
||||||
|
text-align: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusList {
|
||||||
|
position: relative;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusOption {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1.5px solid rgba(143, 65, 12, 0.22);
|
||||||
|
background: rgba(255, 250, 250, 0.72);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: start;
|
||||||
|
transition:
|
||||||
|
color 0.2s,
|
||||||
|
background 0.2s,
|
||||||
|
border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusOption:hover {
|
||||||
|
color: var(--brown);
|
||||||
|
border-color: rgba(143, 65, 12, 0.4);
|
||||||
|
background: rgba(143, 65, 12, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusOptionActive {
|
||||||
|
color: var(--brown);
|
||||||
|
border-color: var(--brown);
|
||||||
|
background: rgba(143, 65, 12, 0.08);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { useEffect, useId } from 'react'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
orderStatuses,
|
||||||
|
type OrderStatus,
|
||||||
|
} from '../data/orders'
|
||||||
|
import styles from './CategoryModal.module.css'
|
||||||
|
import localStyles from './OrderStatusModal.module.css'
|
||||||
|
|
||||||
|
type OrderStatusModalProps = {
|
||||||
|
open: boolean
|
||||||
|
orderId: string
|
||||||
|
currentStatus: OrderStatus
|
||||||
|
onClose: () => void
|
||||||
|
onSubmit: (status: OrderStatus) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrderStatusModal({
|
||||||
|
open,
|
||||||
|
orderId,
|
||||||
|
currentStatus,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: OrderStatusModalProps) {
|
||||||
|
const titleId = useId()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [open, onClose])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.overlay} onClick={onClose} role="presentation">
|
||||||
|
<div
|
||||||
|
className={styles.modal}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className={styles.shine} aria-hidden />
|
||||||
|
|
||||||
|
<div className={styles.header}>
|
||||||
|
<div>
|
||||||
|
<p className={styles.eyebrow}>Order Status</p>
|
||||||
|
<h2 id={titleId} className={styles.title}>
|
||||||
|
تغییر وضعیت سفارش
|
||||||
|
</h2>
|
||||||
|
<p className={localStyles.orderId} dir="ltr">
|
||||||
|
{orderId}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.closeBtn}
|
||||||
|
aria-label="بستن"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<X size={18} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul
|
||||||
|
className={localStyles.statusList}
|
||||||
|
role="listbox"
|
||||||
|
aria-label="وضعیت سفارش"
|
||||||
|
>
|
||||||
|
{orderStatuses.map((item) => (
|
||||||
|
<li key={item.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={currentStatus === item.id}
|
||||||
|
className={`${localStyles.statusOption} ${currentStatus === item.id ? localStyles.statusOptionActive : ''}`}
|
||||||
|
onClick={() => onSubmit(item.id)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
height: var(--field-height);
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid rgba(143, 65, 12, 0.14);
|
||||||
|
background: rgba(255, 250, 250, 0.88);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 400;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-align: left;
|
||||||
|
outline: none;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75);
|
||||||
|
transition:
|
||||||
|
border-color 0.2s,
|
||||||
|
box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
border-color: var(--brown);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.9),
|
||||||
|
0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { InputHTMLAttributes } from 'react'
|
||||||
|
import { formatPriceInput, parsePriceDigits } from '../utils/price'
|
||||||
|
import styles from './PriceInput.module.css'
|
||||||
|
|
||||||
|
type PriceInputProps = Omit<
|
||||||
|
InputHTMLAttributes<HTMLInputElement>,
|
||||||
|
'type' | 'value' | 'onChange' | 'inputMode'
|
||||||
|
> & {
|
||||||
|
value: string
|
||||||
|
onChange: (digits: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PriceInput({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
className,
|
||||||
|
placeholder = '۰',
|
||||||
|
...rest
|
||||||
|
}: PriceInputProps) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
{...rest}
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="off"
|
||||||
|
dir="ltr"
|
||||||
|
className={[styles.input, className].filter(Boolean).join(' ')}
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={formatPriceInput(value)}
|
||||||
|
onChange={(event) => onChange(parsePriceDigits(event.target.value))}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
@keyframes cardEnter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(40px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--glass-bg-strong);
|
||||||
|
backdrop-filter: blur(22px) saturate(1.2);
|
||||||
|
-webkit-backdrop-filter: blur(22px) saturate(1.2);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
box-shadow:
|
||||||
|
var(--glass-shadow),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.75);
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(40px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardVisible {
|
||||||
|
animation: cardEnter 0.7s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageWrap {
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-mid);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: transform 0.45s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover .image {
|
||||||
|
transform: scale(1.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 16px 16px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nameFa {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--brown);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nameEn {
|
||||||
|
font-family: var(--font-en);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
direction: ltr;
|
||||||
|
text-align: end;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
align-self: flex-end;
|
||||||
|
margin-top: 8px;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priceAmount {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priceLabel {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: auto;
|
||||||
|
padding: 12px 12px 14px;
|
||||||
|
border-top: 1px solid rgba(143, 65, 12, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconBtn {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition:
|
||||||
|
color 0.2s,
|
||||||
|
background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -2px;
|
||||||
|
inset-inline-end: -2px;
|
||||||
|
z-index: 2;
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--brown);
|
||||||
|
color: var(--text-on-dark);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 16px;
|
||||||
|
text-align: center;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconBtn:hover {
|
||||||
|
color: var(--brown);
|
||||||
|
background: rgba(143, 65, 12, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconBtn::after {
|
||||||
|
content: attr(data-tooltip);
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
left: 50%;
|
||||||
|
z-index: 6;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--brown-deeper);
|
||||||
|
color: var(--text-on-dark);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 400;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translate(-50%, 4px);
|
||||||
|
transition:
|
||||||
|
opacity 0.18s,
|
||||||
|
transform 0.18s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconBtn:hover::after,
|
||||||
|
.iconBtn:focus-visible::after {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeBtn:hover {
|
||||||
|
color: #9b2c2c;
|
||||||
|
background: rgba(155, 44, 44, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeBtn:hover::after {
|
||||||
|
background: #9b2c2c;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.card {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardVisible {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconBtn::after {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { Pencil, SlidersHorizontal, Trash2 } from 'lucide-react'
|
||||||
|
import { formatPriceParts, type Product } from '../data/products'
|
||||||
|
import styles from './ProductCard.module.css'
|
||||||
|
|
||||||
|
type ProductCardProps = {
|
||||||
|
product: Product
|
||||||
|
index: number
|
||||||
|
visible: boolean
|
||||||
|
onEdit?: (product: Product) => void
|
||||||
|
onOpenOptions?: (product: Product) => void
|
||||||
|
onRemove?: (product: Product) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProductCard({
|
||||||
|
product,
|
||||||
|
index,
|
||||||
|
visible,
|
||||||
|
onEdit,
|
||||||
|
onOpenOptions,
|
||||||
|
onRemove,
|
||||||
|
}: ProductCardProps) {
|
||||||
|
const price = formatPriceParts(product)
|
||||||
|
const optionCount = new Set(
|
||||||
|
(product.options ?? []).map((option) => option.flavorId),
|
||||||
|
).size
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className={`${styles.card} ${visible ? styles.cardVisible : ''}`}
|
||||||
|
style={{ animationDelay: visible ? `${index * 0.08}s` : '0s' }}
|
||||||
|
>
|
||||||
|
<div className={styles.imageWrap}>
|
||||||
|
<img
|
||||||
|
src={product.image}
|
||||||
|
alt={product.nameFa}
|
||||||
|
className={styles.image}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.body}>
|
||||||
|
<h2 className={styles.nameFa}>{product.nameFa}</h2>
|
||||||
|
<p className={styles.nameEn}>{product.nameEn}</p>
|
||||||
|
<p className={styles.price}>
|
||||||
|
<span className={styles.priceAmount}>{price.amount}</span>
|
||||||
|
<span className={styles.priceLabel}>{price.label}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.controls}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.iconBtn}
|
||||||
|
aria-label="ویرایش"
|
||||||
|
data-tooltip="ویرایش"
|
||||||
|
onClick={() => onEdit?.(product)}
|
||||||
|
>
|
||||||
|
<Pencil size={17} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.iconBtn}
|
||||||
|
aria-label={
|
||||||
|
optionCount > 0 ? `گزینهها (${optionCount})` : 'گزینهها'
|
||||||
|
}
|
||||||
|
data-tooltip="گزینهها"
|
||||||
|
onClick={() => onOpenOptions?.(product)}
|
||||||
|
>
|
||||||
|
<SlidersHorizontal size={17} strokeWidth={1.75} />
|
||||||
|
{optionCount > 0 && (
|
||||||
|
<span className={styles.badge}>{optionCount}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.iconBtn} ${styles.removeBtn}`}
|
||||||
|
aria-label="حذف"
|
||||||
|
data-tooltip="حذف"
|
||||||
|
onClick={() => onRemove?.(product)}
|
||||||
|
>
|
||||||
|
<Trash2 size={17} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
.picker {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker .field {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addSelectedBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: var(--field-height);
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1.5px solid rgba(143, 65, 12, 0.22);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--brown);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition:
|
||||||
|
background 0.2s,
|
||||||
|
border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addSelectedBtn:hover:not(:disabled) {
|
||||||
|
border-color: rgba(143, 65, 12, 0.4);
|
||||||
|
background: rgba(143, 65, 12, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.addSelectedBtn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.picker {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addSelectedBtn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
import { useEffect, useId, useMemo, useState } from 'react'
|
||||||
|
import { Pencil, Plus, Trash2, X } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
createProductOptionValue,
|
||||||
|
type Product,
|
||||||
|
type ProductOptionValue,
|
||||||
|
} from '../data/products'
|
||||||
|
import { flavors, formatPrice, type FlavorBlock } from '../data/flavors'
|
||||||
|
import { getSelectableCategoryOptions } from '../lib/categoryOptionsStore'
|
||||||
|
import {
|
||||||
|
AmountPriceModal,
|
||||||
|
type AmountPriceValues,
|
||||||
|
} from './AmountPriceModal'
|
||||||
|
import styles from './CategoryModal.module.css'
|
||||||
|
import optionStyles from './CategoryOptionsModal.module.css'
|
||||||
|
import productOptionStyles from './ProductOptionsModal.module.css'
|
||||||
|
|
||||||
|
type ProductOptionsModalProps = {
|
||||||
|
open: boolean
|
||||||
|
product: Product | null
|
||||||
|
onClose: () => void
|
||||||
|
onSave: (productId: string, options: ProductOptionValue[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type EntryDraft = {
|
||||||
|
mode: 'create' | 'edit'
|
||||||
|
entryId?: string
|
||||||
|
flavorId: string
|
||||||
|
values: AmountPriceValues | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProductOptionsModal({
|
||||||
|
open,
|
||||||
|
product,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
}: ProductOptionsModalProps) {
|
||||||
|
const titleId = useId()
|
||||||
|
const [values, setValues] = useState<ProductOptionValue[]>([])
|
||||||
|
const [selectedBlockId, setSelectedBlockId] = useState('')
|
||||||
|
const [draft, setDraft] = useState<EntryDraft | null>(null)
|
||||||
|
|
||||||
|
const availableOptions = useMemo(() => {
|
||||||
|
if (!product) return []
|
||||||
|
return getSelectableCategoryOptions(product.category)
|
||||||
|
}, [product, open])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !product) return
|
||||||
|
setValues(structuredClone(product.options ?? []))
|
||||||
|
const first = getSelectableCategoryOptions(product.category)[0]
|
||||||
|
setSelectedBlockId(first?.id ?? '')
|
||||||
|
setDraft(null)
|
||||||
|
}, [open, product])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape' && !draft) onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [open, draft, onClose])
|
||||||
|
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const map = new Map<string, ProductOptionValue[]>()
|
||||||
|
for (const value of values) {
|
||||||
|
const list = map.get(value.flavorId) ?? []
|
||||||
|
list.push(value)
|
||||||
|
map.set(value.flavorId, list)
|
||||||
|
}
|
||||||
|
return Array.from(map.entries())
|
||||||
|
}, [values])
|
||||||
|
|
||||||
|
const selectedBlock: FlavorBlock | undefined = availableOptions.find(
|
||||||
|
(item) => item.id === selectedBlockId,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!open || !product) return null
|
||||||
|
|
||||||
|
function flavorLabel(flavorId: string) {
|
||||||
|
return flavors.find((item) => item.id === flavorId)?.nameFa ?? flavorId
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddSelectedOption() {
|
||||||
|
const block = availableOptions.find((item) => item.id === selectedBlockId)
|
||||||
|
if (!block || block.entries.length === 0) return
|
||||||
|
|
||||||
|
const nextValues = block.entries.map((entry) =>
|
||||||
|
createProductOptionValue(block.flavorId, entry.amount, entry.price),
|
||||||
|
)
|
||||||
|
|
||||||
|
setValues((current) => [
|
||||||
|
...current.filter((item) => item.flavorId !== block.flavorId),
|
||||||
|
...nextValues,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEntrySubmit(formValues: AmountPriceValues) {
|
||||||
|
if (!draft) return
|
||||||
|
const price = Number(formValues.price)
|
||||||
|
|
||||||
|
if (draft.mode === 'edit' && draft.entryId) {
|
||||||
|
setValues((current) =>
|
||||||
|
current.map((item) =>
|
||||||
|
item.id === draft.entryId
|
||||||
|
? { ...item, amount: formValues.amount, price }
|
||||||
|
: item,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
setValues((current) => [
|
||||||
|
...current,
|
||||||
|
createProductOptionValue(draft.flavorId, formValues.amount, price),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
setDraft(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemoveValue(id: string) {
|
||||||
|
setValues((current) => current.filter((item) => item.id !== id))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemoveFlavor(flavorId: string) {
|
||||||
|
setValues((current) => current.filter((item) => item.flavorId !== flavorId))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.overlay} onClick={onClose} role="presentation">
|
||||||
|
<div
|
||||||
|
className={`${styles.modal} ${optionStyles.modal}`}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className={styles.shine} aria-hidden />
|
||||||
|
|
||||||
|
<div className={styles.header}>
|
||||||
|
<div>
|
||||||
|
<p className={styles.eyebrow}>Product Options</p>
|
||||||
|
<h2 id={titleId} className={styles.title}>
|
||||||
|
آپشنها
|
||||||
|
</h2>
|
||||||
|
<p className={optionStyles.categoryName}>
|
||||||
|
{product.nameFa}
|
||||||
|
<span className={optionStyles.categoryEn}>
|
||||||
|
{product.nameEn}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.closeBtn}
|
||||||
|
aria-label="بستن"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<X size={18} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={optionStyles.body}>
|
||||||
|
{availableOptions.length === 0 ? (
|
||||||
|
<p className={optionStyles.empty}>
|
||||||
|
برای دسته «{product.category}» هنوز آپشنی در دستهبندیها ثبت
|
||||||
|
نشده است.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className={productOptionStyles.picker}>
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label className={optionStyles.srOnly} htmlFor="product-option">
|
||||||
|
انتخاب آپشن
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="product-option"
|
||||||
|
className={optionStyles.select}
|
||||||
|
value={selectedBlockId}
|
||||||
|
onChange={(e) => setSelectedBlockId(e.target.value)}
|
||||||
|
>
|
||||||
|
{availableOptions.map((block) => {
|
||||||
|
const flavor = flavors.find(
|
||||||
|
(item) => item.id === block.flavorId,
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<option key={block.id} value={block.id}>
|
||||||
|
{flavor
|
||||||
|
? `${flavor.nameFa} — ${flavor.nameEn}`
|
||||||
|
: block.flavorId}
|
||||||
|
{` (${block.entries.length})`}
|
||||||
|
</option>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={productOptionStyles.addSelectedBtn}
|
||||||
|
onClick={handleAddSelectedOption}
|
||||||
|
disabled={!selectedBlock}
|
||||||
|
>
|
||||||
|
<Plus size={16} strokeWidth={1.75} />
|
||||||
|
افزودن آپشن
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{grouped.length === 0 ? (
|
||||||
|
<p className={optionStyles.empty}>
|
||||||
|
هنوز آپشنی به این محصول اضافه نشده است.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className={optionStyles.flavorList}>
|
||||||
|
{grouped.map(([flavorId, entries]) => (
|
||||||
|
<li key={flavorId} className={optionStyles.flavorBlock}>
|
||||||
|
<div className={productOptionStyles.groupHeader}>
|
||||||
|
<h3 className={optionStyles.subTitle}>
|
||||||
|
{flavorLabel(flavorId)}
|
||||||
|
</h3>
|
||||||
|
<div className={optionStyles.flavorActions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={optionStyles.iconBtn}
|
||||||
|
aria-label="افزودن مقدار"
|
||||||
|
data-tooltip="افزودن مقدار"
|
||||||
|
onClick={() =>
|
||||||
|
setDraft({
|
||||||
|
mode: 'create',
|
||||||
|
flavorId,
|
||||||
|
values: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Plus size={16} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${optionStyles.iconBtn} ${optionStyles.removeBtn}`}
|
||||||
|
aria-label="حذف آپشن"
|
||||||
|
data-tooltip="حذف آپشن"
|
||||||
|
onClick={() => handleRemoveFlavor(flavorId)}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className={optionStyles.list}>
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<li key={entry.id} className={optionStyles.row}>
|
||||||
|
<div className={optionStyles.rowText}>
|
||||||
|
<span className={optionStyles.amount}>
|
||||||
|
{entry.amount}
|
||||||
|
</span>
|
||||||
|
<span className={optionStyles.price}>
|
||||||
|
{formatPrice(entry.price)} تومان
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={optionStyles.rowActions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={optionStyles.iconBtn}
|
||||||
|
aria-label="ویرایش"
|
||||||
|
data-tooltip="ویرایش"
|
||||||
|
onClick={() =>
|
||||||
|
setDraft({
|
||||||
|
mode: 'edit',
|
||||||
|
entryId: entry.id,
|
||||||
|
flavorId: entry.flavorId,
|
||||||
|
values: {
|
||||||
|
amount: entry.amount,
|
||||||
|
price: String(entry.price),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Pencil size={15} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${optionStyles.iconBtn} ${optionStyles.removeBtn}`}
|
||||||
|
aria-label="حذف"
|
||||||
|
data-tooltip="حذف"
|
||||||
|
onClick={() => handleRemoveValue(entry.id)}
|
||||||
|
>
|
||||||
|
<Trash2 size={15} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
<li className={optionStyles.addAmountItem}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={optionStyles.addAmountCard}
|
||||||
|
onClick={() =>
|
||||||
|
setDraft({
|
||||||
|
mode: 'create',
|
||||||
|
flavorId,
|
||||||
|
values: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Plus size={18} strokeWidth={1.75} />
|
||||||
|
افزودن
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.cancelBtn}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
انصراف
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.submitBtn}
|
||||||
|
onClick={() => onSave(product.id, values)}
|
||||||
|
>
|
||||||
|
ذخیره آپشنها
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AmountPriceModal
|
||||||
|
open={Boolean(draft)}
|
||||||
|
title={
|
||||||
|
draft?.mode === 'edit' ? 'ویرایش مقدار و قیمت' : 'افزودن مقدار و قیمت'
|
||||||
|
}
|
||||||
|
initialValues={draft?.values ?? null}
|
||||||
|
onClose={() => setDraft(null)}
|
||||||
|
onSubmit={handleEntrySubmit}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
.wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap > label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.box {
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid rgba(143, 65, 12, 0.14);
|
||||||
|
background: rgba(255, 250, 250, 0.88);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid rgba(143, 65, 12, 0.1);
|
||||||
|
background: rgba(251, 243, 243, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition:
|
||||||
|
color 0.2s,
|
||||||
|
background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolBtn:hover {
|
||||||
|
color: var(--brown);
|
||||||
|
background: rgba(143, 65, 12, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorWrap {
|
||||||
|
position: relative;
|
||||||
|
min-height: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
inset-inline-start: 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor {
|
||||||
|
min-height: 160px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
outline: none;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor:focus {
|
||||||
|
background: rgba(255, 255, 255, 0.45);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { Bold, Italic, List, ListOrdered, Underline } from 'lucide-react'
|
||||||
|
import styles from './RichTextArea.module.css'
|
||||||
|
|
||||||
|
type RichTextAreaProps = {
|
||||||
|
id?: string
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
onChange: (html: string) => void
|
||||||
|
placeholder?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RichTextArea({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'توضیحات را بنویسید...',
|
||||||
|
}: RichTextAreaProps) {
|
||||||
|
const editorRef = useRef<HTMLDivElement>(null)
|
||||||
|
const seeded = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editorRef.current || seeded.current) return
|
||||||
|
editorRef.current.innerHTML = value
|
||||||
|
seeded.current = true
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
function runCommand(command: string) {
|
||||||
|
editorRef.current?.focus()
|
||||||
|
document.execCommand(command, false)
|
||||||
|
onChange(editorRef.current?.innerHTML ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const isEmpty = !value || value === '<br>' || value === '<div><br></div>'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
<label htmlFor={id}>{label}</label>
|
||||||
|
<div className={styles.box}>
|
||||||
|
<div className={styles.toolbar}>
|
||||||
|
<button type="button" className={styles.toolBtn} onClick={() => runCommand('bold')} aria-label="پررنگ">
|
||||||
|
<Bold size={15} />
|
||||||
|
</button>
|
||||||
|
<button type="button" className={styles.toolBtn} onClick={() => runCommand('italic')} aria-label="ایتالیک">
|
||||||
|
<Italic size={15} />
|
||||||
|
</button>
|
||||||
|
<button type="button" className={styles.toolBtn} onClick={() => runCommand('underline')} aria-label="زیرخط">
|
||||||
|
<Underline size={15} />
|
||||||
|
</button>
|
||||||
|
<button type="button" className={styles.toolBtn} onClick={() => runCommand('insertUnorderedList')} aria-label="لیست">
|
||||||
|
<List size={15} />
|
||||||
|
</button>
|
||||||
|
<button type="button" className={styles.toolBtn} onClick={() => runCommand('insertOrderedList')} aria-label="لیست شمارهدار">
|
||||||
|
<ListOrdered size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.editorWrap}>
|
||||||
|
{isEmpty && (
|
||||||
|
<span className={styles.placeholder} aria-hidden>
|
||||||
|
{placeholder}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
id={id}
|
||||||
|
ref={editorRef}
|
||||||
|
className={styles.editor}
|
||||||
|
contentEditable
|
||||||
|
role="textbox"
|
||||||
|
aria-multiline="true"
|
||||||
|
aria-label={label}
|
||||||
|
suppressContentEditableWarning
|
||||||
|
onInput={() => onChange(editorRef.current?.innerHTML ?? '')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
.userName {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.roleList {
|
||||||
|
position: relative;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roleOption {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1.5px solid rgba(143, 65, 12, 0.22);
|
||||||
|
background: rgba(255, 250, 250, 0.72);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: start;
|
||||||
|
transition:
|
||||||
|
color 0.2s,
|
||||||
|
background 0.2s,
|
||||||
|
border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roleOption:hover {
|
||||||
|
color: var(--brown);
|
||||||
|
border-color: rgba(143, 65, 12, 0.4);
|
||||||
|
background: rgba(143, 65, 12, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.roleOptionActive {
|
||||||
|
color: var(--brown);
|
||||||
|
border-color: var(--brown);
|
||||||
|
background: rgba(143, 65, 12, 0.08);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { useEffect, useId } from 'react'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { userRoles, type UserRole } from '../data/users'
|
||||||
|
import styles from './CategoryModal.module.css'
|
||||||
|
import localStyles from './RoleModal.module.css'
|
||||||
|
|
||||||
|
type RoleModalProps = {
|
||||||
|
open: boolean
|
||||||
|
userName: string
|
||||||
|
currentRole: UserRole
|
||||||
|
onClose: () => void
|
||||||
|
onSubmit: (role: UserRole) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoleModal({
|
||||||
|
open,
|
||||||
|
userName,
|
||||||
|
currentRole,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: RoleModalProps) {
|
||||||
|
const titleId = useId()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [open, onClose])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
function handleSelect(role: UserRole) {
|
||||||
|
onSubmit(role)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.overlay} onClick={onClose} role="presentation">
|
||||||
|
<div
|
||||||
|
className={styles.modal}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className={styles.shine} aria-hidden />
|
||||||
|
|
||||||
|
<div className={styles.header}>
|
||||||
|
<div>
|
||||||
|
<p className={styles.eyebrow}>User Role</p>
|
||||||
|
<h2 id={titleId} className={styles.title}>
|
||||||
|
تغییر نقش
|
||||||
|
</h2>
|
||||||
|
<p className={localStyles.userName}>{userName}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.closeBtn}
|
||||||
|
aria-label="بستن"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<X size={18} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className={localStyles.roleList} role="listbox" aria-label="نقشها">
|
||||||
|
{userRoles.map((item) => (
|
||||||
|
<li key={item.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={currentRole === item.id}
|
||||||
|
className={`${localStyles.roleOption} ${currentRole === item.id ? localStyles.roleOptionActive : ''}`}
|
||||||
|
onClick={() => handleSelect(item.id)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
.wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap > label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.box {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: var(--field-height);
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid rgba(143, 65, 12, 0.14);
|
||||||
|
background: rgba(255, 250, 250, 0.88);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(143, 65, 12, 0.08);
|
||||||
|
color: var(--brown);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove {
|
||||||
|
display: inline-flex;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove:hover {
|
||||||
|
color: #9b2c2c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 140px;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import styles from './TagInput.module.css'
|
||||||
|
|
||||||
|
type TagInputProps = {
|
||||||
|
label: string
|
||||||
|
tags: string[]
|
||||||
|
onChange: (tags: string[]) => void
|
||||||
|
placeholder?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TagInput({
|
||||||
|
label,
|
||||||
|
tags,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'تگ را بنویسید و Enter بزنید',
|
||||||
|
}: TagInputProps) {
|
||||||
|
const [draft, setDraft] = useState('')
|
||||||
|
|
||||||
|
function addTag(raw: string) {
|
||||||
|
const tag = raw.trim()
|
||||||
|
if (!tag || tags.includes(tag)) return
|
||||||
|
onChange([...tags, tag])
|
||||||
|
setDraft('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTag(tag: string) {
|
||||||
|
onChange(tags.filter((item) => item !== tag))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
<label>{label}</label>
|
||||||
|
<div className={styles.box}>
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<span key={tag} className={styles.tag}>
|
||||||
|
{tag}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.remove}
|
||||||
|
aria-label={`حذف ${tag}`}
|
||||||
|
onClick={() => removeTag(tag)}
|
||||||
|
>
|
||||||
|
<X size={13} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={styles.input}
|
||||||
|
value={draft}
|
||||||
|
placeholder={tags.length === 0 ? placeholder : ''}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
addTag(draft)
|
||||||
|
}
|
||||||
|
if (e.key === 'Backspace' && !draft && tags.length > 0) {
|
||||||
|
removeTag(tags[tags.length - 1])
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
.modal {
|
||||||
|
max-width: 920px;
|
||||||
|
width: min(920px, calc(100vw - 32px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.field select {
|
||||||
|
width: 100%;
|
||||||
|
height: var(--field-height);
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid rgba(143, 65, 12, 0.14);
|
||||||
|
background: rgba(255, 250, 250, 0.88);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
outline: none;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field select:focus {
|
||||||
|
border-color: var(--brown);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.9),
|
||||||
|
0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowPrimary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowSecondary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colTitle {
|
||||||
|
grid-column: span 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colFirstName {
|
||||||
|
grid-column: span 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colLastName {
|
||||||
|
grid-column: span 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.rowPrimary {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colTitle,
|
||||||
|
.colFirstName,
|
||||||
|
.colLastName {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowSecondary {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
import { useEffect, useId, useState } from 'react'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { ApiError } from '../lib/api'
|
||||||
|
import {
|
||||||
|
userCategories,
|
||||||
|
userTitles,
|
||||||
|
type User,
|
||||||
|
type UserCategory,
|
||||||
|
type UserTitle,
|
||||||
|
} from '../data/users'
|
||||||
|
import { toEnglishDigits } from '../utils/price'
|
||||||
|
import styles from './CategoryModal.module.css'
|
||||||
|
import localStyles from './UserModal.module.css'
|
||||||
|
|
||||||
|
export type UserFormValues = {
|
||||||
|
title: UserTitle
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
category: UserCategory | null
|
||||||
|
cellNumber: string
|
||||||
|
password?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserModalProps = {
|
||||||
|
open: boolean
|
||||||
|
editingUser?: User | null
|
||||||
|
onClose: () => void
|
||||||
|
onSubmit: (values: UserFormValues) => void | Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCellNumber(value: string) {
|
||||||
|
return toEnglishDigits(value).replace(/\D/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserModal({
|
||||||
|
open,
|
||||||
|
editingUser = null,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: UserModalProps) {
|
||||||
|
const titleId = useId()
|
||||||
|
const isEditing = Boolean(editingUser)
|
||||||
|
const [title, setTitle] = useState<UserTitle>(userTitles[0])
|
||||||
|
const [firstName, setFirstName] = useState('')
|
||||||
|
const [lastName, setLastName] = useState('')
|
||||||
|
const [category, setCategory] = useState<UserCategory | ''>('')
|
||||||
|
const [cellNumber, setCellNumber] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
if (editingUser) {
|
||||||
|
setTitle(editingUser.title)
|
||||||
|
setFirstName(editingUser.firstName)
|
||||||
|
setLastName(editingUser.lastName)
|
||||||
|
setCategory(editingUser.category ?? '')
|
||||||
|
setCellNumber(editingUser.cellNumber)
|
||||||
|
} else {
|
||||||
|
setTitle(userTitles[0])
|
||||||
|
setFirstName('')
|
||||||
|
setLastName('')
|
||||||
|
setCategory('')
|
||||||
|
setCellNumber('')
|
||||||
|
}
|
||||||
|
setPassword('')
|
||||||
|
setConfirmPassword('')
|
||||||
|
setError('')
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}, [open, editingUser])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape' && !isSubmitting) onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [open, onClose, isSubmitting])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
async function handleSubmit(event: React.FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (isSubmitting) return
|
||||||
|
|
||||||
|
if (!firstName.trim()) {
|
||||||
|
setError('نام الزامی است')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!lastName.trim()) {
|
||||||
|
setError('نام خانوادگی الزامی است')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedCell = normalizeCellNumber(cellNumber)
|
||||||
|
if (!/^09\d{9}$/.test(normalizedCell)) {
|
||||||
|
setError('لطفاً شماره موبایل معتبر وارد کنید (مثال: 09121234567)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEditing) {
|
||||||
|
if (!password.trim()) {
|
||||||
|
setError('رمز عبور الزامی است')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (password.length < 4) {
|
||||||
|
setError('رمز عبور باید حداقل ۴ کاراکتر باشد')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError('تکرار رمز عبور مطابقت ندارد')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setError('')
|
||||||
|
setIsSubmitting(true)
|
||||||
|
try {
|
||||||
|
await onSubmit({
|
||||||
|
title,
|
||||||
|
firstName: firstName.trim(),
|
||||||
|
lastName: lastName.trim(),
|
||||||
|
category: category || null,
|
||||||
|
cellNumber: normalizedCell,
|
||||||
|
...(isEditing ? {} : { password }),
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
err instanceof ApiError
|
||||||
|
? err.message
|
||||||
|
: err instanceof Error && /failed to fetch|networkerror/i.test(err.message)
|
||||||
|
? 'اتصال به سرور برقرار نشد. مطمئن شوید بکاند در حال اجراست.'
|
||||||
|
: 'ذخیره کاربر ناموفق بود.'
|
||||||
|
setError(message)
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.overlay}
|
||||||
|
onClick={() => {
|
||||||
|
if (!isSubmitting) onClose()
|
||||||
|
}}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`${styles.modal} ${localStyles.modal}`}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className={styles.shine} aria-hidden />
|
||||||
|
|
||||||
|
<div className={styles.header}>
|
||||||
|
<div>
|
||||||
|
<p className={styles.eyebrow}>Users</p>
|
||||||
|
<h2 id={titleId} className={styles.title}>
|
||||||
|
{isEditing ? 'ویرایش کاربر' : 'افزودن کاربر'}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.closeBtn}
|
||||||
|
aria-label="بستن"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
<X size={18} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className={styles.form} onSubmit={(e) => void handleSubmit(e)}>
|
||||||
|
{error && (
|
||||||
|
<div className={styles.error} role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={localStyles.rowPrimary}>
|
||||||
|
<div
|
||||||
|
className={`${styles.field} ${localStyles.field} ${localStyles.colTitle}`}
|
||||||
|
>
|
||||||
|
<label htmlFor="user-title">عنوان</label>
|
||||||
|
<select
|
||||||
|
id="user-title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value as UserTitle)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{userTitles.map((item) => (
|
||||||
|
<option key={item} value={item}>
|
||||||
|
{item}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`${styles.field} ${localStyles.field} ${localStyles.colFirstName}`}
|
||||||
|
>
|
||||||
|
<label htmlFor="user-first-name">نام</label>
|
||||||
|
<input
|
||||||
|
id="user-first-name"
|
||||||
|
type="text"
|
||||||
|
placeholder="مثال: سارا"
|
||||||
|
value={firstName}
|
||||||
|
onChange={(e) => setFirstName(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`${styles.field} ${localStyles.field} ${localStyles.colLastName}`}
|
||||||
|
>
|
||||||
|
<label htmlFor="user-last-name">نام خانوادگی</label>
|
||||||
|
<input
|
||||||
|
id="user-last-name"
|
||||||
|
type="text"
|
||||||
|
placeholder="مثال: محمدی"
|
||||||
|
value={lastName}
|
||||||
|
onChange={(e) => setLastName(e.target.value)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={localStyles.rowSecondary}>
|
||||||
|
<div className={`${styles.field} ${localStyles.field}`}>
|
||||||
|
<label htmlFor="user-category">دسته (اختیاری)</label>
|
||||||
|
<select
|
||||||
|
id="user-category"
|
||||||
|
value={category}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCategory(e.target.value as UserCategory | '')
|
||||||
|
}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
<option value="">بدون دسته</option>
|
||||||
|
{userCategories.map((item) => (
|
||||||
|
<option key={item} value={item}>
|
||||||
|
{item}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="user-cell">شماره موبایل</label>
|
||||||
|
<input
|
||||||
|
id="user-cell"
|
||||||
|
type="tel"
|
||||||
|
inputMode="tel"
|
||||||
|
dir="ltr"
|
||||||
|
placeholder="09121234567"
|
||||||
|
value={cellNumber}
|
||||||
|
onChange={(e) => setCellNumber(e.target.value)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isEditing && (
|
||||||
|
<div className={localStyles.rowSecondary}>
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="user-password">رمز عبور</label>
|
||||||
|
<input
|
||||||
|
id="user-password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
dir="ltr"
|
||||||
|
placeholder="حداقل ۴ کاراکتر"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="user-password-confirm">تکرار رمز عبور</label>
|
||||||
|
<input
|
||||||
|
id="user-password-confirm"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
dir="ltr"
|
||||||
|
placeholder="تکرار رمز عبور"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.cancelBtn}
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
انصراف
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={styles.submitBtn}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'در حال ذخیره...' : 'ذخیره'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
export type Category = {
|
||||||
|
id: string
|
||||||
|
nameFa: string
|
||||||
|
nameEn: string
|
||||||
|
children: Category[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const initialCategories: Category[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
nameFa: 'شیرینی',
|
||||||
|
nameEn: 'Pastries',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: '1-1',
|
||||||
|
nameFa: 'شیرینی خشک',
|
||||||
|
nameEn: 'Dry Cookies',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: '1-1-1',
|
||||||
|
nameFa: 'شیرینی پسته',
|
||||||
|
nameEn: 'Pistachio Cookie',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '1-1-2',
|
||||||
|
nameFa: 'شیرینی گردو',
|
||||||
|
nameEn: 'Walnut Cookie',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '1-2',
|
||||||
|
nameFa: 'شیرینی تر',
|
||||||
|
nameEn: 'Soft Pastries',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
nameFa: 'کیک',
|
||||||
|
nameEn: 'Cakes',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: '2-1',
|
||||||
|
nameFa: 'کیک شکلاتی',
|
||||||
|
nameEn: 'Chocolate Cake',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2-2',
|
||||||
|
nameFa: 'چیزکیک',
|
||||||
|
nameEn: 'Cheesecake',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
nameFa: 'نان',
|
||||||
|
nameEn: 'Bread',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: '3-1',
|
||||||
|
nameFa: 'کروسان',
|
||||||
|
nameEn: 'Croissant',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
nameFa: 'باقلوا',
|
||||||
|
nameEn: 'Baklava',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function createCategory(nameFa: string, nameEn: string): Category {
|
||||||
|
return {
|
||||||
|
id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||||
|
nameFa,
|
||||||
|
nameEn,
|
||||||
|
children: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addChildCategory(
|
||||||
|
categories: Category[],
|
||||||
|
parentId: string,
|
||||||
|
child: Category,
|
||||||
|
): Category[] {
|
||||||
|
return categories.map((category) => {
|
||||||
|
if (category.id === parentId) {
|
||||||
|
return { ...category, children: [...category.children, child] }
|
||||||
|
}
|
||||||
|
if (category.children.length > 0) {
|
||||||
|
return {
|
||||||
|
...category,
|
||||||
|
children: addChildCategory(category.children, parentId, child),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return category
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeCategory(
|
||||||
|
categories: Category[],
|
||||||
|
targetId: string,
|
||||||
|
): Category[] {
|
||||||
|
return categories
|
||||||
|
.filter((category) => category.id !== targetId)
|
||||||
|
.map((category) => ({
|
||||||
|
...category,
|
||||||
|
children: removeCategory(category.children, targetId),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flattenCategories(
|
||||||
|
categories: Category[],
|
||||||
|
prefix = '',
|
||||||
|
): { id: string; labelFa: string; labelEn: string }[] {
|
||||||
|
return categories.flatMap((category) => {
|
||||||
|
const labelFa = prefix ? `${prefix} / ${category.nameFa}` : category.nameFa
|
||||||
|
const labelEn = prefix ? `${prefix} / ${category.nameEn}` : category.nameEn
|
||||||
|
return [
|
||||||
|
{ id: category.id, labelFa, labelEn },
|
||||||
|
...flattenCategories(category.children, labelFa),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findCategory(
|
||||||
|
categories: Category[],
|
||||||
|
id: string,
|
||||||
|
): Category | null {
|
||||||
|
for (const category of categories) {
|
||||||
|
if (category.id === id) return category
|
||||||
|
const found = findCategory(category.children, id)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findParentId(
|
||||||
|
categories: Category[],
|
||||||
|
targetId: string,
|
||||||
|
parentId: string | null = null,
|
||||||
|
): string | null | undefined {
|
||||||
|
for (const category of categories) {
|
||||||
|
if (category.id === targetId) return parentId
|
||||||
|
const found = findParentId(category.children, targetId, category.id)
|
||||||
|
if (found !== undefined) return found
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateCategory(
|
||||||
|
categories: Category[],
|
||||||
|
targetId: string,
|
||||||
|
updates: { nameFa: string; nameEn: string },
|
||||||
|
): Category[] {
|
||||||
|
return categories.map((category) => {
|
||||||
|
if (category.id === targetId) {
|
||||||
|
return { ...category, ...updates }
|
||||||
|
}
|
||||||
|
if (category.children.length > 0) {
|
||||||
|
return {
|
||||||
|
...category,
|
||||||
|
children: updateCategory(category.children, targetId, updates),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return category
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/** Districts of Qom used across settings (shipping, branches). */
|
||||||
|
export const districts = [
|
||||||
|
'پردیسان',
|
||||||
|
'جعفریه',
|
||||||
|
'زنبیلآباد',
|
||||||
|
'سالاریه',
|
||||||
|
'صفاییه',
|
||||||
|
'عطاران',
|
||||||
|
'انارستان',
|
||||||
|
'بنیاد',
|
||||||
|
'آذر',
|
||||||
|
'هنرستان',
|
||||||
|
'باجک',
|
||||||
|
'دورشهر',
|
||||||
|
'خیابان ارم',
|
||||||
|
'شهرک قدس',
|
||||||
|
'شهرک امام حسن',
|
||||||
|
'شهرک مهدیه',
|
||||||
|
'قلعه کامکار',
|
||||||
|
'نیروگاه',
|
||||||
|
'حرم',
|
||||||
|
'بلوار امین',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type District = (typeof districts)[number]
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
export type Flavor = {
|
||||||
|
id: string
|
||||||
|
nameFa: string
|
||||||
|
nameEn: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FlavorAmountEntry = {
|
||||||
|
id: string
|
||||||
|
amount: string
|
||||||
|
price: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One selectable flavor instance on a category (select can be duplicated). */
|
||||||
|
export type FlavorBlock = {
|
||||||
|
id: string
|
||||||
|
flavorId: string
|
||||||
|
entries: FlavorAmountEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CategoryFlavorOptions = FlavorBlock[]
|
||||||
|
|
||||||
|
export const flavors: Flavor[] = [
|
||||||
|
{ id: 'chocolate', nameFa: 'شکلات', nameEn: 'Chocolate' },
|
||||||
|
{ id: 'banana', nameFa: 'موز', nameEn: 'Banana' },
|
||||||
|
{ id: 'hazelnut', nameFa: 'فندق', nameEn: 'Hazelnut' },
|
||||||
|
{ id: 'vanilla', nameFa: 'وانیل', nameEn: 'Vanilla' },
|
||||||
|
{ id: 'pistachio', nameFa: 'پسته', nameEn: 'Pistachio' },
|
||||||
|
{ id: 'walnut', nameFa: 'گردو', nameEn: 'Walnut' },
|
||||||
|
{ id: 'strawberry', nameFa: 'توتفرنگی', nameEn: 'Strawberry' },
|
||||||
|
{ id: 'caramel', nameFa: 'کارامل', nameEn: 'Caramel' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function uid() {
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFlavorEntry(
|
||||||
|
amount: string,
|
||||||
|
price: number,
|
||||||
|
): FlavorAmountEntry {
|
||||||
|
return {
|
||||||
|
id: uid(),
|
||||||
|
amount,
|
||||||
|
price,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFlavorBlock(flavorId = flavors[0]?.id ?? ''): FlavorBlock {
|
||||||
|
return {
|
||||||
|
id: uid(),
|
||||||
|
flavorId,
|
||||||
|
entries: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { formatPrice } from '../utils/price'
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import type { SellUnit } from './products'
|
||||||
|
|
||||||
|
export type OrderStatus =
|
||||||
|
| 'pending'
|
||||||
|
| 'confirmed'
|
||||||
|
| 'preparing'
|
||||||
|
| 'ready'
|
||||||
|
| 'delivered'
|
||||||
|
| 'cancelled'
|
||||||
|
|
||||||
|
export type DeliveryType = 'shipping' | 'pickup'
|
||||||
|
|
||||||
|
export type OrderItemOption = {
|
||||||
|
name: string
|
||||||
|
price: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OrderItem = {
|
||||||
|
id: string
|
||||||
|
nameFa: string
|
||||||
|
quantity: number
|
||||||
|
unitPrice: number
|
||||||
|
sellUnit: SellUnit
|
||||||
|
options?: OrderItemOption[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ShippingAddress = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
district: string
|
||||||
|
address: string
|
||||||
|
landline: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OrderDelivery =
|
||||||
|
| {
|
||||||
|
type: 'shipping'
|
||||||
|
shippingAddress: ShippingAddress
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'pickup'
|
||||||
|
branch: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Order = {
|
||||||
|
id: string
|
||||||
|
createdAt: string
|
||||||
|
customerName: string
|
||||||
|
customerPhone: string
|
||||||
|
itemCount: number
|
||||||
|
totalPrice: number
|
||||||
|
status: OrderStatus
|
||||||
|
delivery: OrderDelivery
|
||||||
|
items: OrderItem[]
|
||||||
|
note?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const orderStatuses: { id: OrderStatus; label: string }[] = [
|
||||||
|
{ id: 'pending', label: 'در انتظار بررسی' },
|
||||||
|
{ id: 'confirmed', label: 'تأیید شده' },
|
||||||
|
{ id: 'preparing', label: 'در حال آمادهسازی' },
|
||||||
|
{ id: 'ready', label: 'آماده تحویل' },
|
||||||
|
{ id: 'delivered', label: 'تحویل شده' },
|
||||||
|
{ id: 'cancelled', label: 'لغو شده' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const orderStatusLabel: Record<OrderStatus, string> = {
|
||||||
|
pending: 'در انتظار بررسی',
|
||||||
|
confirmed: 'تأیید شده',
|
||||||
|
preparing: 'در حال آمادهسازی',
|
||||||
|
ready: 'آماده تحویل',
|
||||||
|
delivered: 'تحویل شده',
|
||||||
|
cancelled: 'لغو شده',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deliveryTypeLabel: Record<DeliveryType, string> = {
|
||||||
|
shipping: 'ارسال به',
|
||||||
|
pickup: 'دریافت در محل',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const orderSellUnitLabel: Record<SellUnit, string> = {
|
||||||
|
unit: 'عدد',
|
||||||
|
kilo: 'کیلو',
|
||||||
|
}
|
||||||
|
|
||||||
|
const customers = [
|
||||||
|
{ name: 'سارا محمدی', phone: '09121234567' },
|
||||||
|
{ name: 'علی رضایی', phone: '09129876543' },
|
||||||
|
{ name: 'مریم حسینی', phone: '09351234567' },
|
||||||
|
{ name: 'حسین کریمی', phone: '09123456789' },
|
||||||
|
{ name: 'نازنین احمدی', phone: '09127654321' },
|
||||||
|
{ name: 'رضا نوری', phone: '09361234567' },
|
||||||
|
{ name: 'فاطمه جعفری', phone: '09121112233' },
|
||||||
|
{ name: 'امیر صادقی', phone: '09125556677' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const productNames = [
|
||||||
|
'شیرینی پسته',
|
||||||
|
'کیک شکلاتی',
|
||||||
|
'باقلوا گردو',
|
||||||
|
'نان بربری',
|
||||||
|
'ماکارون',
|
||||||
|
'شیرینی نخودچی',
|
||||||
|
'کیک هویج',
|
||||||
|
'رولت خامه',
|
||||||
|
]
|
||||||
|
|
||||||
|
const optionChoices: OrderItemOption[] = [
|
||||||
|
{ name: 'شکلات اضافه', price: 50000 },
|
||||||
|
{ name: 'بسته کادویی', price: 25000 },
|
||||||
|
{ name: 'نوع شکلات تلخ', price: 40000 },
|
||||||
|
{ name: 'وزن ۱ کیلو', price: 80000 },
|
||||||
|
{ name: 'روکش شکلات', price: 35000 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const branches = ['شعبه مرکزی', 'شعبه صفاییه', 'شعبه نیروی هوایی']
|
||||||
|
|
||||||
|
const shippingAddresses: ShippingAddress[] = [
|
||||||
|
{
|
||||||
|
id: 'addr-1',
|
||||||
|
name: 'منزل',
|
||||||
|
district: 'خیابان ارم',
|
||||||
|
address: 'قم، خیابان ارم، کوچه ۵، پلاک ۱۲',
|
||||||
|
landline: '02531234567',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'addr-2',
|
||||||
|
name: 'محل کار',
|
||||||
|
district: 'بلوار امین',
|
||||||
|
address: 'قم، بلوار امین، مجتمع تجاری نور، واحد ۴',
|
||||||
|
landline: '02537654321',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'addr-3',
|
||||||
|
name: 'منزل',
|
||||||
|
district: 'صفاییه',
|
||||||
|
address: 'قم، خیابان شهید بهشتی، پلاک ۸۷',
|
||||||
|
landline: '02531112233',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'addr-4',
|
||||||
|
name: 'منزل والدین',
|
||||||
|
district: 'حرم',
|
||||||
|
address: 'قم، میدان جهاد، خیابان امام، پلاک ۲۳',
|
||||||
|
landline: '02534445566',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function formatShippingAddress(address: ShippingAddress) {
|
||||||
|
const landline = address.landline
|
||||||
|
? ` — ${address.landline}`
|
||||||
|
: ''
|
||||||
|
return `${address.name} · ${address.district} — ${address.address}${landline}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const statuses: OrderStatus[] = [
|
||||||
|
'pending',
|
||||||
|
'confirmed',
|
||||||
|
'preparing',
|
||||||
|
'ready',
|
||||||
|
'delivered',
|
||||||
|
'cancelled',
|
||||||
|
]
|
||||||
|
|
||||||
|
export function getItemUnitTotal(item: OrderItem) {
|
||||||
|
const optionsTotal = (item.options ?? []).reduce(
|
||||||
|
(sum, option) => sum + option.price,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
return item.unitPrice + optionsTotal
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getItemLineTotal(item: OrderItem) {
|
||||||
|
return item.quantity * getItemUnitTotal(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildItems(seed: number): OrderItem[] {
|
||||||
|
const count = (seed % 4) + 1
|
||||||
|
return Array.from({ length: count }, (_, index) => {
|
||||||
|
const name = productNames[(seed + index) % productNames.length]
|
||||||
|
const quantity = ((seed + index) % 3) + 1
|
||||||
|
const unitPrice = [95000, 120000, 185000, 265000, 310000, 420000][
|
||||||
|
(seed + index) % 6
|
||||||
|
]
|
||||||
|
const sellUnit: SellUnit =
|
||||||
|
name.includes('کیک') || name.includes('باقلوا') ? 'kilo' : 'unit'
|
||||||
|
const withOptions = (seed + index) % 3 !== 0
|
||||||
|
const firstOption = optionChoices[(seed + index) % optionChoices.length]
|
||||||
|
const secondOption =
|
||||||
|
optionChoices[(seed + index + 2) % optionChoices.length]
|
||||||
|
const chosenOptions =
|
||||||
|
(seed + index) % 2 === 0 && firstOption.name !== secondOption.name
|
||||||
|
? [firstOption, secondOption]
|
||||||
|
: [firstOption]
|
||||||
|
return {
|
||||||
|
id: `item-${seed}-${index}`,
|
||||||
|
nameFa: name,
|
||||||
|
quantity,
|
||||||
|
unitPrice,
|
||||||
|
sellUnit,
|
||||||
|
options: withOptions ? chosenOptions : undefined,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDelivery(index: number): OrderDelivery {
|
||||||
|
if (index % 2 === 0) {
|
||||||
|
return {
|
||||||
|
type: 'shipping',
|
||||||
|
shippingAddress: shippingAddresses[index % shippingAddresses.length],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'pickup',
|
||||||
|
branch: branches[index % branches.length],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOrders(): Order[] {
|
||||||
|
return Array.from({ length: 28 }, (_, index) => {
|
||||||
|
const customer = customers[index % customers.length]
|
||||||
|
const items = buildItems(index + 1)
|
||||||
|
const itemCount = items.reduce((sum, item) => sum + item.quantity, 0)
|
||||||
|
const totalPrice = items.reduce(
|
||||||
|
(sum, item) => sum + getItemLineTotal(item),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
const day = String((index % 28) + 1).padStart(2, '0')
|
||||||
|
const hour = String(10 + (index % 10)).padStart(2, '0')
|
||||||
|
const minute = String((index * 7) % 60).padStart(2, '0')
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `BL-${1400 + index}`,
|
||||||
|
createdAt: `2026-07-${day}T${hour}:${minute}:00`,
|
||||||
|
customerName: customer.name,
|
||||||
|
customerPhone: customer.phone,
|
||||||
|
itemCount,
|
||||||
|
totalPrice,
|
||||||
|
status: statuses[index % statuses.length],
|
||||||
|
delivery: buildDelivery(index),
|
||||||
|
items,
|
||||||
|
note: index % 5 === 0 ? 'تحویل در ساعت ۱۷' : undefined,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const orders: Order[] = buildOrders()
|
||||||
|
|
||||||
|
export function formatOrderDateTime(iso: string): { date: string; time: string } {
|
||||||
|
const date = new Date(iso)
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return { date: iso, time: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const datePart = new Intl.DateTimeFormat('fa-IR', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
}).format(date)
|
||||||
|
|
||||||
|
const timePart = new Intl.DateTimeFormat('fa-IR', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(date)
|
||||||
|
|
||||||
|
return { date: datePart, time: timePart }
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import img1010 from '../images/products/IMG_1010.jpeg'
|
||||||
|
import img1031 from '../images/products/IMG_1031.jpeg'
|
||||||
|
import img1035 from '../images/products/IMG_1035.jpeg'
|
||||||
|
import img1039 from '../images/products/IMG_1039.jpeg'
|
||||||
|
import img1040 from '../images/products/IMG_1040.jpeg'
|
||||||
|
import img1043 from '../images/products/IMG_1043.jpeg'
|
||||||
|
|
||||||
|
export type SellUnit = 'unit' | 'kilo'
|
||||||
|
|
||||||
|
export type ProductOptionValue = {
|
||||||
|
id: string
|
||||||
|
flavorId: string
|
||||||
|
amount: string
|
||||||
|
price: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Product = {
|
||||||
|
id: string
|
||||||
|
nameFa: string
|
||||||
|
nameEn: string
|
||||||
|
price: number
|
||||||
|
sellUnit: SellUnit
|
||||||
|
category: string
|
||||||
|
image: string
|
||||||
|
options: ProductOptionValue[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const productCategories = [
|
||||||
|
'شیرینی',
|
||||||
|
'کیک',
|
||||||
|
'باقلوا',
|
||||||
|
'نان',
|
||||||
|
'ماکارون',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
function optionId() {
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createProductOptionValue(
|
||||||
|
flavorId: string,
|
||||||
|
amount: string,
|
||||||
|
price: number,
|
||||||
|
): ProductOptionValue {
|
||||||
|
return {
|
||||||
|
id: optionId(),
|
||||||
|
flavorId,
|
||||||
|
amount,
|
||||||
|
price,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const products: Product[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
nameFa: 'شیرینی پسته',
|
||||||
|
nameEn: 'Pistachio Cookie',
|
||||||
|
price: 185000,
|
||||||
|
sellUnit: 'unit',
|
||||||
|
category: 'شیرینی',
|
||||||
|
image: img1010,
|
||||||
|
options: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
nameFa: 'کیک شکلاتی',
|
||||||
|
nameEn: 'Chocolate Cake',
|
||||||
|
price: 420000,
|
||||||
|
sellUnit: 'kilo',
|
||||||
|
category: 'کیک',
|
||||||
|
image: img1031,
|
||||||
|
options: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
nameFa: 'باقلوا گردو',
|
||||||
|
nameEn: 'Walnut Baklava',
|
||||||
|
price: 265000,
|
||||||
|
sellUnit: 'kilo',
|
||||||
|
category: 'باقلوا',
|
||||||
|
image: img1035,
|
||||||
|
options: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
nameFa: 'نان خامهای',
|
||||||
|
nameEn: 'Cream Puff',
|
||||||
|
price: 95000,
|
||||||
|
sellUnit: 'unit',
|
||||||
|
category: 'نان',
|
||||||
|
image: img1039,
|
||||||
|
options: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
nameFa: 'تارت میوه',
|
||||||
|
nameEn: 'Fruit Tart',
|
||||||
|
price: 310000,
|
||||||
|
sellUnit: 'unit',
|
||||||
|
category: 'کیک',
|
||||||
|
image: img1040,
|
||||||
|
options: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '6',
|
||||||
|
nameFa: 'ماکارون بلوط',
|
||||||
|
nameEn: 'Balout Macaron',
|
||||||
|
price: 150000,
|
||||||
|
sellUnit: 'unit',
|
||||||
|
category: 'ماکارون',
|
||||||
|
image: img1043,
|
||||||
|
options: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '7',
|
||||||
|
nameFa: 'کروسان کرهای',
|
||||||
|
nameEn: 'Butter Croissant',
|
||||||
|
price: 120000,
|
||||||
|
sellUnit: 'unit',
|
||||||
|
category: 'نان',
|
||||||
|
image: img1010,
|
||||||
|
options: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '8',
|
||||||
|
nameFa: 'چیزکیک وانیل',
|
||||||
|
nameEn: 'Vanilla Cheesecake',
|
||||||
|
price: 380000,
|
||||||
|
sellUnit: 'kilo',
|
||||||
|
category: 'کیک',
|
||||||
|
image: img1040,
|
||||||
|
options: [],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const sellUnitLabel: Record<SellUnit, string> = {
|
||||||
|
unit: 'واحد',
|
||||||
|
kilo: 'کیلو',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPriceParts(product: Product): {
|
||||||
|
amount: string
|
||||||
|
label: string
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
amount: product.price.toLocaleString('fa-IR'),
|
||||||
|
label: `تومان - ${sellUnitLabel[product.sellUnit]}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
export type UserRole = 'customer' | 'admin' | 'superAdmin'
|
||||||
|
|
||||||
|
export type UserCategory = 'پزشک' | 'کسبه' | 'مهندس'
|
||||||
|
|
||||||
|
export type UserTitle =
|
||||||
|
| 'سرکار خانم'
|
||||||
|
| 'جناب آقای'
|
||||||
|
| 'جناب دکتر'
|
||||||
|
| 'خانم دکتر'
|
||||||
|
| 'جناب آقای مهندس'
|
||||||
|
| 'سرکار خانم مهندس'
|
||||||
|
|
||||||
|
export type User = {
|
||||||
|
id: string
|
||||||
|
title: UserTitle
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
category: UserCategory | null
|
||||||
|
cellNumber: string
|
||||||
|
totalOrders: number
|
||||||
|
totalTransaction: number
|
||||||
|
role: UserRole
|
||||||
|
disabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const userCategories: UserCategory[] = ['پزشک', 'کسبه', 'مهندس']
|
||||||
|
|
||||||
|
export const userTitles: UserTitle[] = [
|
||||||
|
'سرکار خانم',
|
||||||
|
'جناب آقای',
|
||||||
|
'جناب دکتر',
|
||||||
|
'خانم دکتر',
|
||||||
|
'جناب آقای مهندس',
|
||||||
|
'سرکار خانم مهندس',
|
||||||
|
]
|
||||||
|
|
||||||
|
export const userRoles: { id: UserRole; label: string }[] = [
|
||||||
|
{ id: 'customer', label: 'مشتری' },
|
||||||
|
{ id: 'admin', label: 'ادمین' },
|
||||||
|
{ id: 'superAdmin', label: 'سوپر ادمین' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const userRoleLabel: Record<UserRole, string> = {
|
||||||
|
customer: 'مشتری',
|
||||||
|
admin: 'ادمین',
|
||||||
|
superAdmin: 'سوپر ادمین',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isElevatedRole(role: UserRole) {
|
||||||
|
return role === 'admin' || role === 'superAdmin'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createUser(input: {
|
||||||
|
title: UserTitle
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
category: UserCategory | null
|
||||||
|
cellNumber: string
|
||||||
|
}): User {
|
||||||
|
return {
|
||||||
|
id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||||
|
title: input.title,
|
||||||
|
firstName: input.firstName,
|
||||||
|
lastName: input.lastName,
|
||||||
|
category: input.category,
|
||||||
|
cellNumber: input.cellNumber,
|
||||||
|
totalOrders: 0,
|
||||||
|
totalTransaction: 0,
|
||||||
|
role: 'customer',
|
||||||
|
disabled: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const users: User[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: 'سرکار خانم',
|
||||||
|
firstName: 'سارا',
|
||||||
|
lastName: 'محمدی',
|
||||||
|
category: 'پزشک',
|
||||||
|
cellNumber: '09121234567',
|
||||||
|
totalOrders: 24,
|
||||||
|
totalTransaction: 18500000,
|
||||||
|
role: 'customer',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
title: 'جناب آقای',
|
||||||
|
firstName: 'علی',
|
||||||
|
lastName: 'رضایی',
|
||||||
|
category: 'کسبه',
|
||||||
|
cellNumber: '09131234567',
|
||||||
|
totalOrders: 8,
|
||||||
|
totalTransaction: 4200000,
|
||||||
|
role: 'customer',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
title: 'خانم دکتر',
|
||||||
|
firstName: 'مریم',
|
||||||
|
lastName: 'حسینی',
|
||||||
|
category: 'پزشک',
|
||||||
|
cellNumber: '09191234567',
|
||||||
|
totalOrders: 56,
|
||||||
|
totalTransaction: 48200000,
|
||||||
|
role: 'admin',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
title: 'جناب آقای مهندس',
|
||||||
|
firstName: 'رضا',
|
||||||
|
lastName: 'کریمی',
|
||||||
|
category: 'مهندس',
|
||||||
|
cellNumber: '09351234567',
|
||||||
|
totalOrders: 112,
|
||||||
|
totalTransaction: 128500000,
|
||||||
|
role: 'customer',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
title: 'سرکار خانم',
|
||||||
|
firstName: 'نازنین',
|
||||||
|
lastName: 'احمدی',
|
||||||
|
category: null,
|
||||||
|
cellNumber: '09125551234',
|
||||||
|
totalOrders: 3,
|
||||||
|
totalTransaction: 950000,
|
||||||
|
role: 'customer',
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '6',
|
||||||
|
title: 'جناب دکتر',
|
||||||
|
firstName: 'امیر',
|
||||||
|
lastName: 'نوری',
|
||||||
|
category: 'پزشک',
|
||||||
|
cellNumber: '09187654321',
|
||||||
|
totalOrders: 41,
|
||||||
|
totalTransaction: 27600000,
|
||||||
|
role: 'superAdmin',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '7',
|
||||||
|
title: 'سرکار خانم مهندس',
|
||||||
|
firstName: 'الهام',
|
||||||
|
lastName: 'صادقی',
|
||||||
|
category: 'مهندس',
|
||||||
|
cellNumber: '09012345678',
|
||||||
|
totalOrders: 19,
|
||||||
|
totalTransaction: 9800000,
|
||||||
|
role: 'customer',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '8',
|
||||||
|
title: 'جناب آقای',
|
||||||
|
firstName: 'حسین',
|
||||||
|
lastName: 'موسوی',
|
||||||
|
category: 'کسبه',
|
||||||
|
cellNumber: '09123334455',
|
||||||
|
totalOrders: 1,
|
||||||
|
totalTransaction: 185000,
|
||||||
|
role: 'customer',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '9',
|
||||||
|
title: 'جناب آقای',
|
||||||
|
firstName: 'فاطمه',
|
||||||
|
lastName: 'کاظمی',
|
||||||
|
category: 'کسبه',
|
||||||
|
cellNumber: '09361112233',
|
||||||
|
totalOrders: 67,
|
||||||
|
totalTransaction: 74500000,
|
||||||
|
role: 'customer',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '10',
|
||||||
|
title: 'جناب آقای',
|
||||||
|
firstName: 'مهدی',
|
||||||
|
lastName: 'جعفری',
|
||||||
|
category: null,
|
||||||
|
cellNumber: '09146667788',
|
||||||
|
totalOrders: 12,
|
||||||
|
totalTransaction: 5600000,
|
||||||
|
role: 'customer',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '11',
|
||||||
|
title: 'سرکار خانم',
|
||||||
|
firstName: 'زهرا',
|
||||||
|
lastName: 'امینی',
|
||||||
|
category: 'پزشک',
|
||||||
|
cellNumber: '09129998877',
|
||||||
|
totalOrders: 33,
|
||||||
|
totalTransaction: 21400000,
|
||||||
|
role: 'customer',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '12',
|
||||||
|
title: 'جناب آقای مهندس',
|
||||||
|
firstName: 'پویا',
|
||||||
|
lastName: 'شریفی',
|
||||||
|
category: 'مهندس',
|
||||||
|
cellNumber: '09170001122',
|
||||||
|
totalOrders: 5,
|
||||||
|
totalTransaction: 2100000,
|
||||||
|
role: 'admin',
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '13',
|
||||||
|
title: 'سرکار خانم',
|
||||||
|
firstName: 'نگار',
|
||||||
|
lastName: 'طاهری',
|
||||||
|
category: 'کسبه',
|
||||||
|
cellNumber: '09354445566',
|
||||||
|
totalOrders: 9,
|
||||||
|
totalTransaction: 3700000,
|
||||||
|
role: 'customer',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '14',
|
||||||
|
title: 'جناب آقای',
|
||||||
|
firstName: 'کیان',
|
||||||
|
lastName: 'باقری',
|
||||||
|
category: 'کسبه',
|
||||||
|
cellNumber: '09183332211',
|
||||||
|
totalOrders: 88,
|
||||||
|
totalTransaction: 96500000,
|
||||||
|
role: 'customer',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '15',
|
||||||
|
title: 'خانم دکتر',
|
||||||
|
firstName: 'آیدا',
|
||||||
|
lastName: 'قاسمی',
|
||||||
|
category: 'پزشک',
|
||||||
|
cellNumber: '09125556677',
|
||||||
|
totalOrders: 15,
|
||||||
|
totalTransaction: 8200000,
|
||||||
|
role: 'customer',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '16',
|
||||||
|
title: 'جناب آقای',
|
||||||
|
firstName: 'بهراد',
|
||||||
|
lastName: 'اکبری',
|
||||||
|
category: null,
|
||||||
|
cellNumber: '09014445566',
|
||||||
|
totalOrders: 2,
|
||||||
|
totalTransaction: 640000,
|
||||||
|
role: 'customer',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '17',
|
||||||
|
title: 'سرکار خانم مهندس',
|
||||||
|
firstName: 'مینا',
|
||||||
|
lastName: 'رحیمی',
|
||||||
|
category: 'مهندس',
|
||||||
|
cellNumber: '09196665544',
|
||||||
|
totalOrders: 29,
|
||||||
|
totalTransaction: 15800000,
|
||||||
|
role: 'admin',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '18',
|
||||||
|
title: 'جناب آقای',
|
||||||
|
firstName: 'سامان',
|
||||||
|
lastName: 'نجفی',
|
||||||
|
category: 'کسبه',
|
||||||
|
cellNumber: '09357778899',
|
||||||
|
totalOrders: 6,
|
||||||
|
totalTransaction: 2450000,
|
||||||
|
role: 'customer',
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '19',
|
||||||
|
title: 'سرکار خانم',
|
||||||
|
firstName: 'پریسا',
|
||||||
|
lastName: 'حیدری',
|
||||||
|
category: 'پزشک',
|
||||||
|
cellNumber: '09121112233',
|
||||||
|
totalOrders: 47,
|
||||||
|
totalTransaction: 33200000,
|
||||||
|
role: 'customer',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '20',
|
||||||
|
title: 'جناب آقای مهندس',
|
||||||
|
firstName: 'آرمان',
|
||||||
|
lastName: 'فرهادی',
|
||||||
|
category: 'مهندس',
|
||||||
|
cellNumber: '09188889900',
|
||||||
|
totalOrders: 73,
|
||||||
|
totalTransaction: 81200000,
|
||||||
|
role: 'customer',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '21',
|
||||||
|
title: 'سرکار خانم',
|
||||||
|
firstName: 'لیلا',
|
||||||
|
lastName: 'صالحی',
|
||||||
|
category: null,
|
||||||
|
cellNumber: '09350001122',
|
||||||
|
totalOrders: 4,
|
||||||
|
totalTransaction: 1180000,
|
||||||
|
role: 'customer',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '22',
|
||||||
|
title: 'جناب دکتر',
|
||||||
|
firstName: 'نیما',
|
||||||
|
lastName: 'یوسفی',
|
||||||
|
category: 'پزشک',
|
||||||
|
cellNumber: '09134445566',
|
||||||
|
totalOrders: 18,
|
||||||
|
totalTransaction: 9700000,
|
||||||
|
role: 'superAdmin',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '23',
|
||||||
|
title: 'سرکار خانم',
|
||||||
|
firstName: 'هدیه',
|
||||||
|
lastName: 'مرادی',
|
||||||
|
category: 'کسبه',
|
||||||
|
cellNumber: '09127778899',
|
||||||
|
totalOrders: 11,
|
||||||
|
totalTransaction: 4900000,
|
||||||
|
role: 'customer',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '24',
|
||||||
|
title: 'جناب آقای',
|
||||||
|
firstName: 'سینا',
|
||||||
|
lastName: 'پناهی',
|
||||||
|
category: 'مهندس',
|
||||||
|
cellNumber: '09016667788',
|
||||||
|
totalOrders: 22,
|
||||||
|
totalTransaction: 14600000,
|
||||||
|
role: 'customer',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function formatCellNumber(value: string) {
|
||||||
|
return value.replace(/\D/g, '')
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: 'Doran';
|
||||||
|
src: url('./Doran-Regular.woff') format('woff');
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Doran';
|
||||||
|
src: url('./Doran-Medium.woff') format('woff');
|
||||||
|
font-weight: 500;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Doran';
|
||||||
|
src: url('./Doran-Bold.woff') format('woff');
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'CAILYNE';
|
||||||
|
src: url('./CAILYNE.TTF') format('truetype');
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,142 @@
|
|||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--brown: #8f410c;
|
||||||
|
--brown-dark: #6b3109;
|
||||||
|
--brown-deeper: #4a2106;
|
||||||
|
--brown-soft: #a85a28;
|
||||||
|
--brown-muted: #c47a4a;
|
||||||
|
--brown-light: #d9a07a;
|
||||||
|
|
||||||
|
--bg: #fbf3f3;
|
||||||
|
--bg-light: #fffafa;
|
||||||
|
--bg-mid: #f7eaea;
|
||||||
|
--bg-deep: #f0dede;
|
||||||
|
|
||||||
|
--primary: var(--brown);
|
||||||
|
--primary-rgb: 143 65 12;
|
||||||
|
|
||||||
|
--glass-bg: rgba(255, 250, 250, 0.5);
|
||||||
|
--glass-bg-strong: rgba(255, 250, 250, 0.68);
|
||||||
|
--glass-border: rgba(255, 255, 255, 0.78);
|
||||||
|
--glass-border-soft: rgba(143, 65, 12, 0.14);
|
||||||
|
--glass-shadow: 0 18px 48px rgba(143, 65, 12, 0.12);
|
||||||
|
--glass-shine: linear-gradient(
|
||||||
|
145deg,
|
||||||
|
rgba(255, 255, 255, 0.78) 0%,
|
||||||
|
rgba(255, 255, 255, 0.2) 38%,
|
||||||
|
rgba(255, 255, 255, 0.04) 62%,
|
||||||
|
rgba(255, 255, 255, 0) 100%
|
||||||
|
);
|
||||||
|
|
||||||
|
--text-primary: #111111;
|
||||||
|
--text-secondary: #3a3a3a;
|
||||||
|
--text-muted: #6b6b6b;
|
||||||
|
--text-on-dark: #fffafa;
|
||||||
|
|
||||||
|
--radius: 22px;
|
||||||
|
--radius-sm: 14px;
|
||||||
|
--radius-xs: 10px;
|
||||||
|
--header-height: 72px;
|
||||||
|
|
||||||
|
--font-ui: 'Doran', Tahoma, sans-serif;
|
||||||
|
--font-en: 'CAILYNE', Georgia, serif;
|
||||||
|
|
||||||
|
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
--field-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: var(--bg);
|
||||||
|
background-image:
|
||||||
|
radial-gradient(ellipse 920px 640px at 50% 12%, #ffffff 0%, transparent 55%),
|
||||||
|
radial-gradient(ellipse 720px 520px at 8% 88%, rgba(143, 65, 12, 0.1) 0%, transparent 58%),
|
||||||
|
radial-gradient(ellipse 680px 500px at 94% 80%, rgba(143, 65, 12, 0.08) 0%, transparent 55%),
|
||||||
|
linear-gradient(165deg, var(--bg-light) 0%, var(--bg) 38%, var(--bg-mid) 72%, var(--bg-deep) 100%);
|
||||||
|
background-attachment: fixed;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.22;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||||
|
background-size: 180px;
|
||||||
|
mix-blend-mode: multiply;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(18px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tileEnter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(28px) scale(0.96);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes softGlow {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { getAccessToken, getRefreshToken, setSession, clearSession } from './auth'
|
||||||
|
|
||||||
|
const API_BASE_URL =
|
||||||
|
import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3100/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
|
||||||
|
/** Skip refresh retry (used by refresh itself). */
|
||||||
|
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_BASE_URL}/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_BASE_URL}${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
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { UserRole } from '../data/users'
|
||||||
|
|
||||||
|
const AUTH_KEY = 'balout.admin.auth'
|
||||||
|
|
||||||
|
export type AuthUser = {
|
||||||
|
id: string
|
||||||
|
title: 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.name === 'string' &&
|
||||||
|
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 isAuthenticated(): boolean {
|
||||||
|
return getSession() !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { apiRequest } from './api'
|
||||||
|
import { clearSession, getRefreshToken, setSession, type AuthUser } from './auth'
|
||||||
|
|
||||||
|
export type AuthTokensResponse = {
|
||||||
|
accessToken: string
|
||||||
|
refreshToken: string
|
||||||
|
user: AuthUser
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(cellNumber: string, password: string) {
|
||||||
|
const data = await apiRequest<AuthTokensResponse>('/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
auth: false,
|
||||||
|
body: { cellNumber, password },
|
||||||
|
})
|
||||||
|
setSession({
|
||||||
|
accessToken: data.accessToken,
|
||||||
|
refreshToken: data.refreshToken,
|
||||||
|
user: data.user,
|
||||||
|
})
|
||||||
|
return data.user
|
||||||
|
}
|
||||||
|
|
||||||
|
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 even if logout request fails.
|
||||||
|
} finally {
|
||||||
|
clearSession()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
export type BranchRecord = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
district: string
|
||||||
|
address: string
|
||||||
|
landline: string
|
||||||
|
cellNumber: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'balout-branches'
|
||||||
|
|
||||||
|
const seedBranches: BranchRecord[] = [
|
||||||
|
{
|
||||||
|
id: 'branch-1',
|
||||||
|
name: 'شعبه مرکزی',
|
||||||
|
district: 'حرم',
|
||||||
|
address: 'قم، خیابان ارم، نبش کوچه ۳',
|
||||||
|
landline: '02531230001',
|
||||||
|
cellNumber: '09120000001',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'branch-2',
|
||||||
|
name: 'شعبه صفاییه',
|
||||||
|
district: 'صفاییه',
|
||||||
|
address: 'قم، صفاییه، بلوار شهید بهشتی',
|
||||||
|
landline: '02531230002',
|
||||||
|
cellNumber: '09120000002',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'branch-3',
|
||||||
|
name: 'شعبه نیروی هوایی',
|
||||||
|
district: 'نیروگاه',
|
||||||
|
address: 'قم، شهرک نیروی هوایی، فاز ۲',
|
||||||
|
landline: '02531230003',
|
||||||
|
cellNumber: '09120000003',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function readBranches(): BranchRecord[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!raw) {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(seedBranches))
|
||||||
|
return seedBranches
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(raw) as BranchRecord[]
|
||||||
|
return parsed.length > 0 ? parsed : seedBranches
|
||||||
|
} catch {
|
||||||
|
return seedBranches
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBranches(): BranchRecord[] {
|
||||||
|
return readBranches().filter((branch) => branch.name.trim())
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import {
|
||||||
|
createFlavorBlock,
|
||||||
|
createFlavorEntry,
|
||||||
|
type CategoryFlavorOptions,
|
||||||
|
} from '../data/flavors'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'balout-category-flavor-options'
|
||||||
|
|
||||||
|
type OptionsMap = Record<string, CategoryFlavorOptions>
|
||||||
|
|
||||||
|
function seedOptions(): OptionsMap {
|
||||||
|
return {
|
||||||
|
شیرینی: [
|
||||||
|
{
|
||||||
|
...createFlavorBlock('chocolate'),
|
||||||
|
entries: [
|
||||||
|
createFlavorEntry('۱۰۰ گرم', 25000),
|
||||||
|
createFlavorEntry('۵۰۰ گرم', 50000),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...createFlavorBlock('pistachio'),
|
||||||
|
entries: [
|
||||||
|
createFlavorEntry('۱۵۰ گرم', 45000),
|
||||||
|
createFlavorEntry('۱ کیلو', 280000),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
کیک: [
|
||||||
|
{
|
||||||
|
...createFlavorBlock('vanilla'),
|
||||||
|
entries: [
|
||||||
|
createFlavorEntry('نیم کیلو', 220000),
|
||||||
|
createFlavorEntry('۱ کیلو', 420000),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...createFlavorBlock('chocolate'),
|
||||||
|
entries: [createFlavorEntry('۱ کیلو', 450000)],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStore(): OptionsMap {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!raw) {
|
||||||
|
const seeded = seedOptions()
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(seeded))
|
||||||
|
return seeded
|
||||||
|
}
|
||||||
|
return JSON.parse(raw) as OptionsMap
|
||||||
|
} catch {
|
||||||
|
return seedOptions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStore(map: OptionsMap) {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(map))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllCategoryOptions(): OptionsMap {
|
||||||
|
return readStore()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCategoryOptionsByName(
|
||||||
|
categoryNameFa: string,
|
||||||
|
): CategoryFlavorOptions {
|
||||||
|
return readStore()[categoryNameFa] ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCategoryOptionsByName(
|
||||||
|
categoryNameFa: string,
|
||||||
|
options: CategoryFlavorOptions,
|
||||||
|
) {
|
||||||
|
const map = readStore()
|
||||||
|
map[categoryNameFa] = options
|
||||||
|
writeStore(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeCategoryOptionsByName(categoryNameFa: string) {
|
||||||
|
const map = readStore()
|
||||||
|
delete map[categoryNameFa]
|
||||||
|
writeStore(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Available options that have at least one amount/price value. */
|
||||||
|
export function getSelectableCategoryOptions(categoryNameFa: string) {
|
||||||
|
return getCategoryOptionsByName(categoryNameFa).filter(
|
||||||
|
(block) => block.entries.length > 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import {
|
||||||
|
CakeSlice,
|
||||||
|
FolderTree,
|
||||||
|
Package,
|
||||||
|
PlusCircle,
|
||||||
|
Settings,
|
||||||
|
Users,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
export type NavItem = {
|
||||||
|
label: string
|
||||||
|
href: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NavSection = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
titleEn: string
|
||||||
|
description: string
|
||||||
|
href: string
|
||||||
|
icon: LucideIcon
|
||||||
|
items: NavItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProductTile = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
titleEn: string
|
||||||
|
description: string
|
||||||
|
href: string
|
||||||
|
icon: LucideIcon
|
||||||
|
}
|
||||||
|
|
||||||
|
export const productTiles: ProductTile[] = [
|
||||||
|
{
|
||||||
|
id: 'products-list',
|
||||||
|
title: 'محصولات من',
|
||||||
|
titleEn: 'My Products',
|
||||||
|
description: 'مشاهده و مدیریت همه محصولات فروشگاه',
|
||||||
|
href: '/products/list',
|
||||||
|
icon: CakeSlice,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'products-new',
|
||||||
|
title: 'ثبت محصول جدید',
|
||||||
|
titleEn: 'New Product',
|
||||||
|
description: 'افزودن شیرینی یا محصول تازه به فروشگاه',
|
||||||
|
href: '/products/new',
|
||||||
|
icon: PlusCircle,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'products-categories',
|
||||||
|
title: 'دسته بندی محصولات',
|
||||||
|
titleEn: 'Categories',
|
||||||
|
description: 'مدیریت دستهبندیهای محصولات',
|
||||||
|
href: '/products/categories',
|
||||||
|
icon: FolderTree,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const navSections: NavSection[] = [
|
||||||
|
{
|
||||||
|
id: 'products',
|
||||||
|
title: 'محصولات من',
|
||||||
|
titleEn: 'Products',
|
||||||
|
description: 'مدیریت شیرینیها و محصولات فروشگاه',
|
||||||
|
href: '/products',
|
||||||
|
icon: CakeSlice,
|
||||||
|
items: productTiles.map((tile) => ({
|
||||||
|
label: tile.title,
|
||||||
|
href: tile.href,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'users',
|
||||||
|
title: 'کاربران',
|
||||||
|
titleEn: 'Users',
|
||||||
|
description: 'مدیریت کاربران، نقشها و دسترسیها',
|
||||||
|
href: '/users',
|
||||||
|
icon: Users,
|
||||||
|
items: [
|
||||||
|
{ label: 'همه کاربران', href: '/users' },
|
||||||
|
{ label: 'ثبت کاربر جدید', href: '/users' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'orders',
|
||||||
|
title: 'سفارشات من',
|
||||||
|
titleEn: 'Orders',
|
||||||
|
description: 'پیگیری و مدیریت سفارشها',
|
||||||
|
href: '/orders',
|
||||||
|
icon: Package,
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'settings',
|
||||||
|
title: 'تنظیمات',
|
||||||
|
titleEn: 'Settings',
|
||||||
|
description: 'تنظیمات فروشگاه و حساب کاربری',
|
||||||
|
href: '/settings',
|
||||||
|
icon: Settings,
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { products, type Product } from '../data/products'
|
||||||
|
import { users, formatCellNumber, type User } from '../data/users'
|
||||||
|
import { flavors } from '../data/flavors'
|
||||||
|
import { getSelectableCategoryOptions } from './categoryOptionsStore'
|
||||||
|
|
||||||
|
export type SearchUserResult = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
phone: string
|
||||||
|
phoneDisplay: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProductOptionChoice = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
price: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(ms = 280) {
|
||||||
|
return new Promise((resolve) => window.setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Simulated API user search (`GET /users?q=`). */
|
||||||
|
export async function searchUsers(query: string): Promise<SearchUserResult[]> {
|
||||||
|
await delay()
|
||||||
|
const q = query.trim().toLowerCase()
|
||||||
|
const digits = q.replace(/\D/g, '')
|
||||||
|
|
||||||
|
return users
|
||||||
|
.filter((user) => !user.disabled)
|
||||||
|
.filter((user) => {
|
||||||
|
if (!q) return true
|
||||||
|
const fullName = `${user.firstName} ${user.lastName}`
|
||||||
|
return (
|
||||||
|
fullName.includes(query.trim()) ||
|
||||||
|
user.firstName.includes(query.trim()) ||
|
||||||
|
user.lastName.includes(query.trim()) ||
|
||||||
|
(digits.length > 0 && user.cellNumber.includes(digits))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.slice(0, 12)
|
||||||
|
.map((user) => ({
|
||||||
|
id: user.id,
|
||||||
|
name: `${user.firstName} ${user.lastName}`,
|
||||||
|
phone: user.cellNumber,
|
||||||
|
phoneDisplay: formatCellNumber(user.cellNumber),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Simulated API product search (`GET /products?q=`). */
|
||||||
|
export async function searchProducts(query: string): Promise<Product[]> {
|
||||||
|
await delay()
|
||||||
|
const q = query.trim().toLowerCase()
|
||||||
|
if (!q) return products.slice(0, 12)
|
||||||
|
|
||||||
|
return products
|
||||||
|
.filter(
|
||||||
|
(product) =>
|
||||||
|
product.nameFa.includes(query.trim()) ||
|
||||||
|
product.nameEn.toLowerCase().includes(q) ||
|
||||||
|
product.category.includes(query.trim()),
|
||||||
|
)
|
||||||
|
.slice(0, 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
function flavorLabel(flavorId: string) {
|
||||||
|
return flavors.find((item) => item.id === flavorId)?.nameFa ?? flavorId
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options available when adding a product to an order. */
|
||||||
|
export function getProductOrderOptions(product: Product): ProductOptionChoice[] {
|
||||||
|
if (product.options.length > 0) {
|
||||||
|
return product.options.map((option) => ({
|
||||||
|
id: option.id,
|
||||||
|
name: `${flavorLabel(option.flavorId)} — ${option.amount}`,
|
||||||
|
price: option.price,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return getSelectableCategoryOptions(product.category).flatMap((block) =>
|
||||||
|
block.entries.map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
name: `${flavorLabel(block.flavorId)} — ${entry.amount}`,
|
||||||
|
price: entry.price,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findUserById(id: string): User | undefined {
|
||||||
|
return users.find((user) => user.id === id)
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import type { ShippingAddress } from '../data/orders'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'balout-user-addresses'
|
||||||
|
|
||||||
|
type AddressMap = Record<string, ShippingAddress[]>
|
||||||
|
|
||||||
|
function uid() {
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedForUser(userId: string): ShippingAddress[] {
|
||||||
|
const seeds: Record<string, ShippingAddress[]> = {
|
||||||
|
'1': [
|
||||||
|
{
|
||||||
|
id: 'u1-a1',
|
||||||
|
name: 'منزل',
|
||||||
|
district: 'خیابان ارم',
|
||||||
|
address: 'قم، خیابان ارم، کوچه ۵، پلاک ۱۲',
|
||||||
|
landline: '02531234567',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'u1-a2',
|
||||||
|
name: 'مطب',
|
||||||
|
district: 'بلوار امین',
|
||||||
|
address: 'قم، بلوار امین، ساختمان پزشکان، طبقه ۳',
|
||||||
|
landline: '02537654321',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'2': [
|
||||||
|
{
|
||||||
|
id: 'u2-a1',
|
||||||
|
name: 'منزل',
|
||||||
|
district: 'صفاییه',
|
||||||
|
address: 'قم، صفاییه، خیابان شهید بهشتی، پلاک ۴۵',
|
||||||
|
landline: '02531112233',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
seeds[userId] ?? [
|
||||||
|
{
|
||||||
|
id: `${userId}-default`,
|
||||||
|
name: 'منزل',
|
||||||
|
district: 'حرم',
|
||||||
|
address: 'قم، اطراف حرم، پلاک ۱۰',
|
||||||
|
landline: '',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function readMap(): AddressMap {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!raw) return {}
|
||||||
|
return JSON.parse(raw) as AddressMap
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeMap(map: AddressMap) {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(map))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserAddresses(userId: string): ShippingAddress[] {
|
||||||
|
const map = readMap()
|
||||||
|
if (!map[userId]) {
|
||||||
|
map[userId] = seedForUser(userId)
|
||||||
|
writeMap(map)
|
||||||
|
}
|
||||||
|
return map[userId]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addUserAddress(
|
||||||
|
userId: string,
|
||||||
|
input: Omit<ShippingAddress, 'id'>,
|
||||||
|
): ShippingAddress {
|
||||||
|
const map = readMap()
|
||||||
|
const list = map[userId] ?? seedForUser(userId)
|
||||||
|
const address: ShippingAddress = { ...input, id: uid() }
|
||||||
|
map[userId] = [...list, address]
|
||||||
|
writeMap(map)
|
||||||
|
return address
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import type {
|
||||||
|
User,
|
||||||
|
UserCategory,
|
||||||
|
UserRole,
|
||||||
|
UserTitle,
|
||||||
|
} from '../data/users'
|
||||||
|
import { apiRequest } from './api'
|
||||||
|
|
||||||
|
export type UserListResponse = {
|
||||||
|
items: User[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListUsersParams = {
|
||||||
|
q?: string
|
||||||
|
role?: UserRole | ''
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateUserPayload = {
|
||||||
|
title: UserTitle
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
category?: UserCategory | null
|
||||||
|
cellNumber: string
|
||||||
|
password: string
|
||||||
|
role?: UserRole
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateUserPayload = {
|
||||||
|
title?: UserTitle
|
||||||
|
firstName?: string
|
||||||
|
lastName?: string
|
||||||
|
category?: UserCategory | null
|
||||||
|
cellNumber?: string
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildQuery(params: ListUsersParams) {
|
||||||
|
const search = new URLSearchParams()
|
||||||
|
if (params.q?.trim()) search.set('q', params.q.trim())
|
||||||
|
if (params.role) search.set('role', params.role)
|
||||||
|
if (params.page) search.set('page', String(params.page))
|
||||||
|
if (params.pageSize) search.set('pageSize', String(params.pageSize))
|
||||||
|
const qs = search.toString()
|
||||||
|
return qs ? `?${qs}` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listUsers(params: ListUsersParams = {}) {
|
||||||
|
return apiRequest<UserListResponse>(`/users${buildQuery(params)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createUser(payload: CreateUserPayload) {
|
||||||
|
return apiRequest<User>('/users', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
...payload,
|
||||||
|
category: payload.category ?? undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateUser(id: string, payload: UpdateUserPayload) {
|
||||||
|
return apiRequest<User>(`/users/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateUserRole(id: string, role: UserRole) {
|
||||||
|
return apiRequest<User>(`/users/${id}/role`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { role },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateUserPassword(id: string, password: string) {
|
||||||
|
return apiRequest<User>(`/users/${id}/password`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { password },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteUser(id: string) {
|
||||||
|
return apiRequest<{ ok: true }>(`/users/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import './fonts/fonts.css'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
animation: fadeUp 0.55s var(--ease-out) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.srOnly {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--glass-bg-strong);
|
||||||
|
backdrop-filter: blur(22px) saturate(1.2);
|
||||||
|
-webkit-backdrop-filter: blur(22px) saturate(1.2);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
box-shadow:
|
||||||
|
var(--glass-shadow),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.75);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
padding: 22px 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionDivider {
|
||||||
|
border-top: 1px solid rgba(143, 65, 12, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionBand {
|
||||||
|
border-top: 1px solid rgba(143, 65, 12, 0.12);
|
||||||
|
border-bottom: 1px solid rgba(143, 65, 12, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(220px, 0.85fr) 1.4fr;
|
||||||
|
gap: 28px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--brown);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input,
|
||||||
|
.field textarea,
|
||||||
|
.field select {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid rgba(143, 65, 12, 0.14);
|
||||||
|
background: rgba(255, 250, 250, 0.88);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 400;
|
||||||
|
outline: none;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75);
|
||||||
|
transition:
|
||||||
|
border-color 0.2s,
|
||||||
|
box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input,
|
||||||
|
.field select {
|
||||||
|
height: var(--field-height);
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field select {
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%8f410c' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: left 12px center;
|
||||||
|
padding-inline-end: 12px;
|
||||||
|
padding-inline-start: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field textarea {
|
||||||
|
padding: 10px 12px;
|
||||||
|
resize: vertical;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input::placeholder,
|
||||||
|
.field textarea::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input:focus,
|
||||||
|
.field textarea:focus,
|
||||||
|
.field select:focus {
|
||||||
|
border-color: var(--brown);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.9),
|
||||||
|
0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.priceRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1.2fr;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priceRow .field {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchWrap {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchIcon {
|
||||||
|
position: absolute;
|
||||||
|
inset-inline-start: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron {
|
||||||
|
position: absolute;
|
||||||
|
inset-inline-end: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchWrap input {
|
||||||
|
padding-inline: 38px 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
inset-inline: 0;
|
||||||
|
z-index: 20;
|
||||||
|
max-height: 220px;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 6px;
|
||||||
|
list-style: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: #fffdfc;
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
box-shadow: 0 14px 34px rgba(143, 65, 12, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.option {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
text-align: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option:hover,
|
||||||
|
.optionActive {
|
||||||
|
background: rgba(143, 65, 12, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionFa {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionEn {
|
||||||
|
font-family: var(--font-en);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
direction: ltr;
|
||||||
|
text-align: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyOption {
|
||||||
|
padding: 12px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #8b2e2e;
|
||||||
|
background: rgba(180, 60, 60, 0.08);
|
||||||
|
border: 1px solid rgba(180, 60, 60, 0.22);
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelBtn,
|
||||||
|
.submitBtn {
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 20px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition:
|
||||||
|
background 0.2s,
|
||||||
|
transform 0.2s,
|
||||||
|
color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelBtn {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: rgba(255, 250, 250, 0.7);
|
||||||
|
border: 1px solid rgba(143, 65, 12, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelBtn:hover {
|
||||||
|
color: var(--brown);
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitBtn {
|
||||||
|
color: var(--text-on-dark);
|
||||||
|
background: var(--brown);
|
||||||
|
box-shadow: 0 8px 18px rgba(143, 65, 12, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitBtn:hover {
|
||||||
|
background: var(--brown-dark);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.topGrid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.section {
|
||||||
|
padding: 18px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priceRow {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelBtn,
|
||||||
|
.submitBtn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
import { useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||||
|
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
import { ChevronRight, ChevronDown, Search } from 'lucide-react'
|
||||||
|
import { Header } from '../components/Header'
|
||||||
|
import { ImageCropper } from '../components/ImageCropper'
|
||||||
|
import { ImageGallery } from '../components/ImageGallery'
|
||||||
|
import { PriceInput } from '../components/PriceInput'
|
||||||
|
import { RichTextArea } from '../components/RichTextArea'
|
||||||
|
import { TagInput } from '../components/TagInput'
|
||||||
|
import {
|
||||||
|
findCategory,
|
||||||
|
flattenCategories,
|
||||||
|
initialCategories,
|
||||||
|
} from '../data/categories'
|
||||||
|
import type { SellUnit } from '../data/products'
|
||||||
|
import { parsePriceNumber } from '../utils/price'
|
||||||
|
import styles from './HomePage.module.css'
|
||||||
|
import pageStyles from './AddProductPage.module.css'
|
||||||
|
|
||||||
|
export function AddProductPage() {
|
||||||
|
const location = useLocation()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const formId = useId()
|
||||||
|
|
||||||
|
const [mainImage, setMainImage] = useState<string | null>(null)
|
||||||
|
const [categoryId, setCategoryId] = useState<string | null>(null)
|
||||||
|
const [nameFa, setNameFa] = useState('')
|
||||||
|
const [nameEn, setNameEn] = useState('')
|
||||||
|
const [sellUnit, setSellUnit] = useState<SellUnit>('unit')
|
||||||
|
const [price, setPrice] = useState('')
|
||||||
|
const [intro, setIntro] = useState('')
|
||||||
|
const [description, setDescription] = useState('')
|
||||||
|
const [gallery, setGallery] = useState<string[]>([])
|
||||||
|
const [tags, setTags] = useState<string[]>([])
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const [categoryQuery, setCategoryQuery] = useState('')
|
||||||
|
const [categoryOpen, setCategoryOpen] = useState(false)
|
||||||
|
const categoryRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const categoryOptions = useMemo(
|
||||||
|
() => flattenCategories(initialCategories),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const filteredCategories = useMemo(() => {
|
||||||
|
const q = categoryQuery.trim().toLowerCase()
|
||||||
|
if (!q) return categoryOptions
|
||||||
|
return categoryOptions.filter(
|
||||||
|
(option) =>
|
||||||
|
option.labelFa.includes(categoryQuery.trim()) ||
|
||||||
|
option.labelEn.toLowerCase().includes(q),
|
||||||
|
)
|
||||||
|
}, [categoryOptions, categoryQuery])
|
||||||
|
|
||||||
|
const selectedCategory = categoryId
|
||||||
|
? findCategory(initialCategories, categoryId)
|
||||||
|
: null
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handlePointerDown(event: MouseEvent) {
|
||||||
|
if (!categoryRef.current?.contains(event.target as Node)) {
|
||||||
|
setCategoryOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handlePointerDown)
|
||||||
|
return () => document.removeEventListener('mousedown', handlePointerDown)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function handleSubmit(event: React.FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!mainImage) {
|
||||||
|
setError('تصویر اصلی الزامی است')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!categoryId) {
|
||||||
|
setError('انتخاب دستهبندی الزامی است')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!nameFa.trim() || !nameEn.trim()) {
|
||||||
|
setError('نام فارسی و انگلیسی الزامی است')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const numericPrice = parsePriceNumber(price)
|
||||||
|
if (numericPrice === null || numericPrice <= 0) {
|
||||||
|
setError('قیمت معتبر الزامی است')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setError('')
|
||||||
|
window.alert('محصول با موفقیت ثبت شد (نسخه نمایشی)')
|
||||||
|
navigate('/products/list')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main className={styles.main}>
|
||||||
|
<section key={`welcome-${location.key}`} className={styles.welcome}>
|
||||||
|
<span className={styles.enBackdrop} aria-hidden>
|
||||||
|
New Product
|
||||||
|
</span>
|
||||||
|
<div className={styles.welcomeContent}>
|
||||||
|
<p className={styles.greeting}>
|
||||||
|
<Link to="/products" className={styles.backLink}>
|
||||||
|
<ChevronRight size={18} strokeWidth={1.75} />
|
||||||
|
بازگشت به محصولات
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
<h1 className={styles.headline}>ثبت محصول جدید</h1>
|
||||||
|
<p className={styles.lead}>
|
||||||
|
اطلاعات محصول را تکمیل کنید و ذخیره نمایید.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className={pageStyles.form}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
aria-labelledby={`${formId}-title`}
|
||||||
|
>
|
||||||
|
<h2 id={`${formId}-title`} className={pageStyles.srOnly}>
|
||||||
|
فرم ثبت محصول
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className={pageStyles.error} role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={pageStyles.panel}>
|
||||||
|
<section className={pageStyles.section}>
|
||||||
|
<div className={pageStyles.topGrid}>
|
||||||
|
<div>
|
||||||
|
<h3 className={pageStyles.sectionTitle}>تصویر اصلی</h3>
|
||||||
|
<ImageCropper
|
||||||
|
value={mainImage}
|
||||||
|
onChange={setMainImage}
|
||||||
|
aspect={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className={pageStyles.sectionTitle}>اطلاعات پایه</h3>
|
||||||
|
|
||||||
|
<div className={pageStyles.field} ref={categoryRef}>
|
||||||
|
<label htmlFor="product-category">دستهبندی</label>
|
||||||
|
<div className={pageStyles.searchWrap}>
|
||||||
|
<Search size={16} className={pageStyles.searchIcon} />
|
||||||
|
<input
|
||||||
|
id="product-category"
|
||||||
|
type="text"
|
||||||
|
placeholder="جستجوی دستهبندی..."
|
||||||
|
value={
|
||||||
|
categoryOpen
|
||||||
|
? categoryQuery
|
||||||
|
: selectedCategory
|
||||||
|
? `${selectedCategory.nameFa} — ${selectedCategory.nameEn}`
|
||||||
|
: categoryQuery
|
||||||
|
}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCategoryQuery(e.target.value)
|
||||||
|
setCategoryOpen(true)
|
||||||
|
if (categoryId) setCategoryId(null)
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
setCategoryOpen(true)
|
||||||
|
setCategoryQuery('')
|
||||||
|
}}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<ChevronDown size={16} className={pageStyles.chevron} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{categoryOpen && (
|
||||||
|
<ul className={pageStyles.dropdown} role="listbox">
|
||||||
|
{filteredCategories.length === 0 ? (
|
||||||
|
<li className={pageStyles.emptyOption}>موردی یافت نشد</li>
|
||||||
|
) : (
|
||||||
|
filteredCategories.map((option) => (
|
||||||
|
<li key={option.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${pageStyles.option} ${categoryId === option.id ? pageStyles.optionActive : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setCategoryId(option.id)
|
||||||
|
setCategoryQuery('')
|
||||||
|
setCategoryOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className={pageStyles.optionFa}>
|
||||||
|
{option.labelFa}
|
||||||
|
</span>
|
||||||
|
<span className={pageStyles.optionEn}>
|
||||||
|
{option.labelEn}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={pageStyles.field}>
|
||||||
|
<label htmlFor="product-name-fa">نام فارسی</label>
|
||||||
|
<input
|
||||||
|
id="product-name-fa"
|
||||||
|
type="text"
|
||||||
|
placeholder="مثال: شیرینی پسته"
|
||||||
|
value={nameFa}
|
||||||
|
onChange={(e) => setNameFa(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={pageStyles.field}>
|
||||||
|
<label htmlFor="product-name-en">نام انگلیسی</label>
|
||||||
|
<input
|
||||||
|
id="product-name-en"
|
||||||
|
type="text"
|
||||||
|
dir="ltr"
|
||||||
|
placeholder="Example: Pistachio Cookie"
|
||||||
|
value={nameEn}
|
||||||
|
onChange={(e) => setNameEn(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={pageStyles.priceRow}>
|
||||||
|
<div className={pageStyles.field}>
|
||||||
|
<label htmlFor="product-sell-unit">نوع قیمت</label>
|
||||||
|
<select
|
||||||
|
id="product-sell-unit"
|
||||||
|
value={sellUnit}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSellUnit(e.target.value as SellUnit)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="unit">برای واحد</option>
|
||||||
|
<option value="kilo">برای وزن (کیلو)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={pageStyles.field}>
|
||||||
|
<label htmlFor="product-price">قیمت (تومان)</label>
|
||||||
|
<PriceInput
|
||||||
|
id="product-price"
|
||||||
|
value={price}
|
||||||
|
onChange={setPrice}
|
||||||
|
placeholder="۰"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={pageStyles.field}>
|
||||||
|
<label htmlFor="product-intro">معرفی کوتاه</label>
|
||||||
|
<textarea
|
||||||
|
id="product-intro"
|
||||||
|
rows={3}
|
||||||
|
placeholder="یک معرفی کوتاه از محصول بنویسید..."
|
||||||
|
value={intro}
|
||||||
|
onChange={(e) => setIntro(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={`${pageStyles.section} ${pageStyles.sectionDivider}`}>
|
||||||
|
<RichTextArea
|
||||||
|
id="product-description"
|
||||||
|
label="توضیحات"
|
||||||
|
value={description}
|
||||||
|
onChange={setDescription}
|
||||||
|
placeholder="توضیحات کامل محصول را بنویسید..."
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={`${pageStyles.section} ${pageStyles.sectionBand}`}>
|
||||||
|
<ImageGallery
|
||||||
|
label="گالری تصاویر"
|
||||||
|
images={gallery}
|
||||||
|
onChange={setGallery}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={pageStyles.section}>
|
||||||
|
<TagInput
|
||||||
|
label="تگها"
|
||||||
|
tags={tags}
|
||||||
|
onChange={setTags}
|
||||||
|
placeholder="تگ را بنویسید و Enter بزنید"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={pageStyles.actions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={pageStyles.cancelBtn}
|
||||||
|
onClick={() => navigate('/products')}
|
||||||
|
>
|
||||||
|
انصراف
|
||||||
|
</button>
|
||||||
|
<button type="submit" className={pageStyles.submitBtn}>
|
||||||
|
ذخیره محصول
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
.panel {
|
||||||
|
position: relative;
|
||||||
|
padding: 12px 12px 72px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--glass-bg-strong);
|
||||||
|
backdrop-filter: blur(22px) saturate(1.2);
|
||||||
|
-webkit-backdrop-filter: blur(22px) saturate(1.2);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
box-shadow:
|
||||||
|
var(--glass-shadow),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.75);
|
||||||
|
animation: fadeUp 0.55s var(--ease-out) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 36px 16px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 14px;
|
||||||
|
left: 14px;
|
||||||
|
z-index: 5;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--text-on-dark);
|
||||||
|
background: var(--brown);
|
||||||
|
box-shadow: 0 10px 22px rgba(143, 65, 12, 0.28);
|
||||||
|
transition:
|
||||||
|
background 0.2s,
|
||||||
|
transform 0.2s,
|
||||||
|
box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab:hover {
|
||||||
|
background: var(--brown-dark);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 14px 28px rgba(143, 65, 12, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab::after {
|
||||||
|
content: attr(data-tooltip);
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 10px);
|
||||||
|
left: 50%;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--brown-deeper);
|
||||||
|
color: var(--text-on-dark);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 400;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translate(-50%, 4px);
|
||||||
|
transition:
|
||||||
|
opacity 0.18s,
|
||||||
|
transform 0.18s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab:hover::after,
|
||||||
|
.fab:focus-visible::after {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
|
import { ChevronRight, Plus } from 'lucide-react'
|
||||||
|
import { Header } from '../components/Header'
|
||||||
|
import { CategoryItem } from '../components/CategoryItem'
|
||||||
|
import {
|
||||||
|
CategoryModal,
|
||||||
|
type CategoryFormValues,
|
||||||
|
} from '../components/CategoryModal'
|
||||||
|
import { CategoryOptionsModal } from '../components/CategoryOptionsModal'
|
||||||
|
import {
|
||||||
|
addChildCategory,
|
||||||
|
createCategory,
|
||||||
|
findCategory,
|
||||||
|
initialCategories,
|
||||||
|
removeCategory,
|
||||||
|
updateCategory,
|
||||||
|
type Category,
|
||||||
|
} from '../data/categories'
|
||||||
|
import type { CategoryFlavorOptions } from '../data/flavors'
|
||||||
|
import {
|
||||||
|
getCategoryOptionsByName,
|
||||||
|
removeCategoryOptionsByName,
|
||||||
|
setCategoryOptionsByName,
|
||||||
|
} from '../lib/categoryOptionsStore'
|
||||||
|
import styles from './HomePage.module.css'
|
||||||
|
import pageStyles from './CategoriesPage.module.css'
|
||||||
|
|
||||||
|
export function CategoriesPage() {
|
||||||
|
const location = useLocation()
|
||||||
|
const [categories, setCategories] = useState<Category[]>(initialCategories)
|
||||||
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
|
const [initialParentId, setInitialParentId] = useState<string | null>(null)
|
||||||
|
const [editingCategory, setEditingCategory] = useState<Category | null>(null)
|
||||||
|
const [optionsCategory, setOptionsCategory] = useState<Category | null>(null)
|
||||||
|
|
||||||
|
function openCreateModal(parentId: string | null = null) {
|
||||||
|
setEditingCategory(null)
|
||||||
|
setInitialParentId(parentId)
|
||||||
|
setModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditModal(category: Category) {
|
||||||
|
setEditingCategory(category)
|
||||||
|
setInitialParentId(null)
|
||||||
|
setModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCategoryModal() {
|
||||||
|
setModalOpen(false)
|
||||||
|
setEditingCategory(null)
|
||||||
|
setInitialParentId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit(values: CategoryFormValues) {
|
||||||
|
if (values.id) {
|
||||||
|
const previous = findCategory(categories, values.id)
|
||||||
|
setCategories((current) =>
|
||||||
|
updateCategory(current, values.id!, {
|
||||||
|
nameFa: values.nameFa,
|
||||||
|
nameEn: values.nameEn,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (previous && previous.nameFa !== values.nameFa) {
|
||||||
|
const options = getCategoryOptionsByName(previous.nameFa)
|
||||||
|
if (options.length > 0) {
|
||||||
|
setCategoryOptionsByName(values.nameFa, options)
|
||||||
|
removeCategoryOptionsByName(previous.nameFa)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const category = createCategory(values.nameFa, values.nameEn)
|
||||||
|
setCategories((current) => {
|
||||||
|
if (!values.parentId) return [...current, category]
|
||||||
|
return addChildCategory(current, values.parentId, category)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
closeCategoryModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemove(id: string) {
|
||||||
|
const confirmed = window.confirm('این دستهبندی حذف شود؟')
|
||||||
|
if (!confirmed) return
|
||||||
|
const target = findCategory(categories, id)
|
||||||
|
setCategories((current) => removeCategory(current, id))
|
||||||
|
if (target) removeCategoryOptionsByName(target.nameFa)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSaveOptions(
|
||||||
|
_categoryId: string,
|
||||||
|
options: CategoryFlavorOptions,
|
||||||
|
) {
|
||||||
|
if (!optionsCategory) return
|
||||||
|
setCategoryOptionsByName(optionsCategory.nameFa, options)
|
||||||
|
setOptionsCategory(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main className={styles.main}>
|
||||||
|
<section key={`welcome-${location.key}`} className={styles.welcome}>
|
||||||
|
<span className={styles.enBackdrop} aria-hidden>
|
||||||
|
Categories
|
||||||
|
</span>
|
||||||
|
<div className={styles.welcomeContent}>
|
||||||
|
<p className={styles.greeting}>
|
||||||
|
<Link to="/products" className={styles.backLink}>
|
||||||
|
<ChevronRight size={18} strokeWidth={1.75} />
|
||||||
|
بازگشت به محصولات
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
<h1 className={styles.headline}>دسته بندی محصولات</h1>
|
||||||
|
<p className={styles.lead}>
|
||||||
|
دستهبندیهای والد و فرزند را مدیریت کنید.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={pageStyles.panel} aria-label="فهرست دستهبندیها">
|
||||||
|
{categories.length === 0 ? (
|
||||||
|
<p className={pageStyles.empty}>دستهبندیای وجود ندارد.</p>
|
||||||
|
) : (
|
||||||
|
<ul className={pageStyles.list}>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<CategoryItem
|
||||||
|
key={category.id}
|
||||||
|
category={category}
|
||||||
|
onAddChild={(parentId) => openCreateModal(parentId)}
|
||||||
|
onEdit={openEditModal}
|
||||||
|
onOpenOptions={setOptionsCategory}
|
||||||
|
onRemove={handleRemove}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={pageStyles.fab}
|
||||||
|
onClick={() => openCreateModal(null)}
|
||||||
|
aria-label="افزودن دستهبندی"
|
||||||
|
data-tooltip="افزودن دستهبندی"
|
||||||
|
>
|
||||||
|
<Plus size={22} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<CategoryModal
|
||||||
|
open={modalOpen}
|
||||||
|
categories={categories}
|
||||||
|
initialParentId={initialParentId}
|
||||||
|
editingCategory={editingCategory}
|
||||||
|
onClose={closeCategoryModal}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CategoryOptionsModal
|
||||||
|
open={Boolean(optionsCategory)}
|
||||||
|
category={optionsCategory}
|
||||||
|
initialOptions={
|
||||||
|
optionsCategory
|
||||||
|
? getCategoryOptionsByName(optionsCategory.nameFa)
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
onClose={() => setOptionsCategory(null)}
|
||||||
|
onSave={handleSaveOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1120px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 24px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
padding-block: 12px 4px;
|
||||||
|
animation: fadeUp 0.65s var(--ease-out) both;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enBackdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset-inline-start: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-52%);
|
||||||
|
z-index: 0;
|
||||||
|
font-family: var(--font-en);
|
||||||
|
font-size: clamp(2.6rem, 8vw, 5.2rem);
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 0.95;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--brown);
|
||||||
|
opacity: 0.08;
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
direction: ltr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcomeContent {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.greeting {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headline {
|
||||||
|
font-size: clamp(1.75rem, 3.5vw, 2.35rem);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--brown);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
line-height: 1.35;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead {
|
||||||
|
max-width: 34rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backLink {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backLink:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile {
|
||||||
|
display: block;
|
||||||
|
text-decoration: none;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(40px);
|
||||||
|
transition:
|
||||||
|
opacity 0.7s cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
transform 0.7s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tileVisible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tileVisible:hover {
|
||||||
|
transform: translateY(-6px);
|
||||||
|
transition:
|
||||||
|
opacity 0.7s cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
transform 0.35s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tileVisible:hover .tileSurface {
|
||||||
|
border-color: rgba(143, 65, 12, 0.28);
|
||||||
|
box-shadow: 0 20px 44px rgba(143, 65, 12, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tileSurface {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
min-height: 124px;
|
||||||
|
padding: 22px 20px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
background: var(--glass-bg-strong);
|
||||||
|
backdrop-filter: blur(22px) saturate(1.2);
|
||||||
|
-webkit-backdrop-filter: blur(22px) saturate(1.2);
|
||||||
|
box-shadow:
|
||||||
|
var(--glass-shadow),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.75);
|
||||||
|
transition:
|
||||||
|
border-color 0.35s var(--ease-out),
|
||||||
|
box-shadow 0.35s var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.tile {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tileVisible:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tileSurface {
|
||||||
|
transition:
|
||||||
|
border-color 0.2s,
|
||||||
|
box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile:focus-visible .tileSurface {
|
||||||
|
outline: 2px solid var(--brown);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tileShine {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: var(--glass-shine);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tileIcon {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
color: var(--brown);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tileBody {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tileEn {
|
||||||
|
position: absolute;
|
||||||
|
inset-inline-end: -4px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 0;
|
||||||
|
font-family: var(--font-en);
|
||||||
|
font-size: clamp(1.8rem, 3.2vw, 2.6rem);
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--brown);
|
||||||
|
opacity: 0.09;
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
direction: ltr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tileTitle {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--brown);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tileDesc {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tileArrow {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
opacity: 0.7;
|
||||||
|
transition:
|
||||||
|
transform 0.3s var(--ease-out),
|
||||||
|
opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile:hover .tileArrow {
|
||||||
|
transform: translateX(-4px);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
padding: 28px 16px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tileSurface {
|
||||||
|
min-height: 104px;
|
||||||
|
padding: 18px 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { useLocation } from 'react-router-dom'
|
||||||
|
import { Header } from '../components/Header'
|
||||||
|
import { AnimatedTiles } from '../components/AnimatedTiles'
|
||||||
|
import { getSession } from '../lib/auth'
|
||||||
|
import { navSections } from '../lib/nav'
|
||||||
|
import styles from './HomePage.module.css'
|
||||||
|
|
||||||
|
export function HomePage() {
|
||||||
|
const session = getSession()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main className={styles.main}>
|
||||||
|
<section key={`welcome-${location.key}`} className={styles.welcome}>
|
||||||
|
<span className={styles.enBackdrop} aria-hidden>
|
||||||
|
Balout Pastry Dashboard
|
||||||
|
</span>
|
||||||
|
<div className={styles.welcomeContent}>
|
||||||
|
<p className={styles.greeting}>
|
||||||
|
سلام{session?.user.name ? `، ${session.user.name}` : ''}
|
||||||
|
</p>
|
||||||
|
<h1 className={styles.headline}>به پنل بلوط خوش آمدید</h1>
|
||||||
|
<p className={styles.lead}>
|
||||||
|
از اینجا محصولات، مشتریان و سفارشهای فروشگاه را مدیریت کنید.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<AnimatedTiles tiles={navSections} label="منوی اصلی" />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 28px 20px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ambient {
|
||||||
|
position: absolute;
|
||||||
|
inset: -25%;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 22% 28%, rgba(255, 255, 255, 0.95), transparent 42%),
|
||||||
|
radial-gradient(circle at 78% 72%, rgba(143, 65, 12, 0.16), transparent 46%),
|
||||||
|
radial-gradient(circle at 55% 12%, rgba(251, 243, 243, 0.9), transparent 40%);
|
||||||
|
animation: softGlow 8s ease-in-out infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
padding: 40px 32px 28px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--glass-bg-strong);
|
||||||
|
backdrop-filter: blur(32px) saturate(1.35);
|
||||||
|
-webkit-backdrop-filter: blur(32px) saturate(1.35);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
box-shadow:
|
||||||
|
var(--glass-shadow),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.85),
|
||||||
|
inset 0 -1px 0 rgba(143, 65, 12, 0.04);
|
||||||
|
overflow: hidden;
|
||||||
|
animation: fadeUp 0.7s var(--ease-out) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardShine {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--glass-shine);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 132px;
|
||||||
|
height: auto;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brandTag {
|
||||||
|
margin-top: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--brown);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputWrap {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputIcon {
|
||||||
|
position: absolute;
|
||||||
|
inset-inline-start: 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputWrap input {
|
||||||
|
width: 100%;
|
||||||
|
height: var(--field-height);
|
||||||
|
padding-inline: 44px 44px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: rgba(255, 250, 250, 0.78);
|
||||||
|
border: 1px solid rgba(143, 65, 12, 0.14);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
outline: none;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75);
|
||||||
|
transition:
|
||||||
|
border-color 0.2s,
|
||||||
|
box-shadow 0.2s,
|
||||||
|
background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputWrap input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0.75;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputWrap input[dir='ltr'] {
|
||||||
|
font-family: var(--font-en);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputWrap input:focus {
|
||||||
|
border-color: var(--brown);
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.9),
|
||||||
|
0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.togglePassword {
|
||||||
|
position: absolute;
|
||||||
|
inset-inline-end: 12px;
|
||||||
|
display: flex;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 4px;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.togglePassword:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitBtn {
|
||||||
|
width: 100%;
|
||||||
|
height: 50px;
|
||||||
|
margin-top: 6px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-on-dark);
|
||||||
|
background: var(--brown);
|
||||||
|
box-shadow: 0 10px 22px rgba(143, 65, 12, 0.28);
|
||||||
|
transition:
|
||||||
|
transform 0.25s var(--ease-out),
|
||||||
|
background 0.2s,
|
||||||
|
box-shadow 0.25s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitBtn:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
background: var(--brown-dark);
|
||||||
|
box-shadow: 0 14px 28px rgba(143, 65, 12, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitBtn:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #8b2e2e;
|
||||||
|
background: rgba(180, 60, 60, 0.08);
|
||||||
|
border: 1px solid rgba(180, 60, 60, 0.22);
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin-top: 22px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.card {
|
||||||
|
padding: 32px 22px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 112px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { Eye, EyeOff, Lock, Smartphone } from 'lucide-react'
|
||||||
|
import { ApiError } from '../lib/api'
|
||||||
|
import { login } from '../lib/authApi'
|
||||||
|
import { toEnglishDigits } from '../utils/price'
|
||||||
|
import logo from '../images/Logo-balout-256.png'
|
||||||
|
import styles from './LoginPage.module.css'
|
||||||
|
|
||||||
|
function normalizeCellNumber(value: string) {
|
||||||
|
return toEnglishDigits(value).replace(/\D/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [phone, setPhone] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
const cellNumber = normalizeCellNumber(phone)
|
||||||
|
if (!/^09\d{9}$/.test(cellNumber)) {
|
||||||
|
setError('لطفاً شماره موبایل معتبر وارد کنید (مثال: 09121234567)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (password.length < 4) {
|
||||||
|
setError('رمز عبور باید حداقل ۴ کاراکتر باشد')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true)
|
||||||
|
try {
|
||||||
|
await login(cellNumber, password)
|
||||||
|
navigate('/')
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
err instanceof ApiError
|
||||||
|
? err.message
|
||||||
|
: 'ورود ناموفق بود. اتصال به سرور را بررسی کنید.'
|
||||||
|
setError(message)
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<div className={styles.ambient} aria-hidden />
|
||||||
|
<div className={styles.card}>
|
||||||
|
<div className={styles.cardShine} aria-hidden />
|
||||||
|
|
||||||
|
<div className={styles.brand}>
|
||||||
|
<img src={logo} alt="بلوط" className={styles.logo} />
|
||||||
|
<p className={styles.brandTag}>شیرینیفروشی بلوط</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.intro}>
|
||||||
|
<h1 className={styles.title}>ورود به پنل</h1>
|
||||||
|
<p className={styles.subtitle}>برای مدیریت فروشگاه وارد شوید</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className={styles.form} onSubmit={handleSubmit} autoComplete="off">
|
||||||
|
{error && (
|
||||||
|
<div className={styles.error} role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="login-phone">شماره موبایل</label>
|
||||||
|
<div className={styles.inputWrap}>
|
||||||
|
<Smartphone size={18} className={styles.inputIcon} />
|
||||||
|
<input
|
||||||
|
id="login-phone"
|
||||||
|
type="tel"
|
||||||
|
inputMode="tel"
|
||||||
|
dir="ltr"
|
||||||
|
placeholder="09xxxxxxxxx"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
required
|
||||||
|
disabled={isSubmitting}
|
||||||
|
autoComplete="off"
|
||||||
|
name="login-phone"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="login-password">رمز عبور</label>
|
||||||
|
<div className={styles.inputWrap}>
|
||||||
|
<Lock size={18} className={styles.inputIcon} />
|
||||||
|
<input
|
||||||
|
id="login-password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
placeholder="رمز عبور خود را وارد کنید"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
disabled={isSubmitting}
|
||||||
|
autoComplete="new-password"
|
||||||
|
name="login-password"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.togglePassword}
|
||||||
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
|
aria-label={showPassword ? 'مخفی کردن رمز' : 'نمایش رمز'}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={styles.submitBtn}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'در حال ورود...' : 'ورود'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
.ordersPage {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ordersMain {
|
||||||
|
padding-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topBlock {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listWrap {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--glass-bg-strong);
|
||||||
|
backdrop-filter: blur(22px) saturate(1.2);
|
||||||
|
-webkit-backdrop-filter: blur(22px) saturate(1.2);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
box-shadow:
|
||||||
|
var(--glass-shadow),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.75);
|
||||||
|
animation: fadeUp 0.55s var(--ease-out) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns:
|
||||||
|
minmax(100px, 0.9fr)
|
||||||
|
minmax(100px, 0.95fr)
|
||||||
|
minmax(140px, 1.3fr)
|
||||||
|
80px
|
||||||
|
minmax(110px, 1.1fr)
|
||||||
|
minmax(120px, 1.1fr)
|
||||||
|
auto;
|
||||||
|
gap: 12px 14px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: rgba(255, 250, 250, 0.55);
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:hover {
|
||||||
|
background: rgba(255, 250, 250, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.orderId,
|
||||||
|
.when,
|
||||||
|
.customer,
|
||||||
|
.stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orderId {
|
||||||
|
justify-content: center;
|
||||||
|
align-items: stretch;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.when,
|
||||||
|
.customer {
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idLabel,
|
||||||
|
.statLabel {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.idValue {
|
||||||
|
font-family: var(--font-en);
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--brown);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
line-height: 1.2;
|
||||||
|
direction: ltr;
|
||||||
|
text-align: right;
|
||||||
|
unicode-bidi: isolate;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customerName {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--brown);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customerPhone {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statValue {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBtn {
|
||||||
|
justify-self: stretch;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(143, 65, 12, 0.16);
|
||||||
|
background: rgba(143, 65, 12, 0.06);
|
||||||
|
color: var(--brown);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.3;
|
||||||
|
text-align: center;
|
||||||
|
transition:
|
||||||
|
background 0.2s,
|
||||||
|
border-color 0.2s,
|
||||||
|
color 0.2s,
|
||||||
|
transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBtn:hover {
|
||||||
|
border-color: rgba(143, 65, 12, 0.32);
|
||||||
|
background: rgba(143, 65, 12, 0.1);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status_pending {
|
||||||
|
color: #8a5a12;
|
||||||
|
border-color: rgba(138, 90, 18, 0.22);
|
||||||
|
background: rgba(138, 90, 18, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status_confirmed {
|
||||||
|
color: #2f6b4f;
|
||||||
|
border-color: rgba(47, 107, 79, 0.22);
|
||||||
|
background: rgba(47, 107, 79, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status_preparing {
|
||||||
|
color: #6b4a1f;
|
||||||
|
border-color: rgba(107, 74, 31, 0.22);
|
||||||
|
background: rgba(107, 74, 31, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status_ready {
|
||||||
|
color: #1f5f8a;
|
||||||
|
border-color: rgba(31, 95, 138, 0.22);
|
||||||
|
background: rgba(31, 95, 138, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status_delivered {
|
||||||
|
color: #3d5a40;
|
||||||
|
border-color: rgba(61, 90, 64, 0.22);
|
||||||
|
background: rgba(61, 90, 64, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status_cancelled {
|
||||||
|
color: #9b2c2c;
|
||||||
|
border-color: rgba(155, 44, 44, 0.22);
|
||||||
|
background: rgba(155, 44, 44, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconBtn {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition:
|
||||||
|
color 0.2s,
|
||||||
|
background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconBtn:hover {
|
||||||
|
color: var(--brown);
|
||||||
|
background: rgba(143, 65, 12, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconBtn::after {
|
||||||
|
content: attr(data-tooltip);
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
left: 50%;
|
||||||
|
z-index: 6;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--brown-deeper);
|
||||||
|
color: var(--text-on-dark);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 400;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translate(-50%, 4px);
|
||||||
|
transition:
|
||||||
|
opacity 0.18s,
|
||||||
|
transform 0.18s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconBtn:hover::after,
|
||||||
|
.iconBtn:focus-visible::after {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageBtn {
|
||||||
|
min-width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(143, 65, 12, 0.14);
|
||||||
|
background: rgba(255, 250, 250, 0.75);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition:
|
||||||
|
color 0.2s,
|
||||||
|
background 0.2s,
|
||||||
|
border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageBtn:hover:not(:disabled) {
|
||||||
|
color: var(--brown);
|
||||||
|
border-color: rgba(143, 65, 12, 0.28);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageBtnActive {
|
||||||
|
color: var(--text-on-dark);
|
||||||
|
background: var(--brown);
|
||||||
|
border-color: var(--brown);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageBtnActive:hover:not(:disabled) {
|
||||||
|
color: var(--text-on-dark);
|
||||||
|
background: var(--brown-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageBtn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageInfo {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-inline: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 48px 16px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 28px;
|
||||||
|
left: 28px;
|
||||||
|
z-index: 40;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--text-on-dark);
|
||||||
|
background: var(--brown);
|
||||||
|
box-shadow: 0 12px 28px rgba(143, 65, 12, 0.3);
|
||||||
|
transition:
|
||||||
|
background 0.2s,
|
||||||
|
transform 0.2s,
|
||||||
|
box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab:hover {
|
||||||
|
background: var(--brown-dark);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 16px 32px rgba(143, 65, 12, 0.36);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab::after {
|
||||||
|
content: attr(data-tooltip);
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 10px);
|
||||||
|
left: 50%;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--brown-deeper);
|
||||||
|
color: var(--text-on-dark);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 400;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translate(-50%, 4px);
|
||||||
|
transition:
|
||||||
|
opacity 0.18s,
|
||||||
|
transform 0.18s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab:hover::after,
|
||||||
|
.fab:focus-visible::after {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.row {
|
||||||
|
grid-template-columns: 1fr 1fr 1.2fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat:nth-of-type(1),
|
||||||
|
.controls {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBtn {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.row {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconBtn::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
|
import { ChevronRight, Eye, Plus } from 'lucide-react'
|
||||||
|
import { Header } from '../components/Header'
|
||||||
|
import {
|
||||||
|
buildOrderFromCreateValues,
|
||||||
|
CreateOrderModal,
|
||||||
|
type CreateOrderValues,
|
||||||
|
} from '../components/CreateOrderModal'
|
||||||
|
import { OrderDetailsModal } from '../components/OrderDetailsModal'
|
||||||
|
import { OrderStatusModal } from '../components/OrderStatusModal'
|
||||||
|
import {
|
||||||
|
formatOrderDateTime,
|
||||||
|
orderStatusLabel,
|
||||||
|
orders as initialOrders,
|
||||||
|
type Order,
|
||||||
|
type OrderStatus,
|
||||||
|
} from '../data/orders'
|
||||||
|
import { formatCellNumber } from '../data/users'
|
||||||
|
import { formatPrice } from '../utils/price'
|
||||||
|
import styles from './HomePage.module.css'
|
||||||
|
import pageStyles from './OrdersPage.module.css'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10
|
||||||
|
|
||||||
|
export function OrdersPage() {
|
||||||
|
const location = useLocation()
|
||||||
|
const [orders, setOrders] = useState<Order[]>(initialOrders)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [statusOrder, setStatusOrder] = useState<Order | null>(null)
|
||||||
|
const [detailsOrder, setDetailsOrder] = useState<Order | null>(null)
|
||||||
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(orders.length / PAGE_SIZE))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (page > totalPages) setPage(totalPages)
|
||||||
|
}, [page, totalPages])
|
||||||
|
|
||||||
|
const pageOrders = useMemo(() => {
|
||||||
|
const start = (page - 1) * PAGE_SIZE
|
||||||
|
return orders.slice(start, start + PAGE_SIZE)
|
||||||
|
}, [orders, page])
|
||||||
|
|
||||||
|
function handleStatusSubmit(status: OrderStatus) {
|
||||||
|
if (!statusOrder) return
|
||||||
|
setOrders((current) =>
|
||||||
|
current.map((item) =>
|
||||||
|
item.id === statusOrder.id ? { ...item, status } : item,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
setStatusOrder(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreateOrder(values: CreateOrderValues) {
|
||||||
|
const maxNum = orders.reduce((max, order) => {
|
||||||
|
const n = Number(order.id.replace(/\D/g, ''))
|
||||||
|
return Number.isFinite(n) ? Math.max(max, n) : max
|
||||||
|
}, 1399)
|
||||||
|
const order = buildOrderFromCreateValues(values, `BL-${maxNum + 1}`)
|
||||||
|
setOrders((current) => [order, ...current])
|
||||||
|
setCreateOpen(false)
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${styles.page} ${pageStyles.ordersPage}`}>
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main className={`${styles.main} ${pageStyles.ordersMain}`}>
|
||||||
|
<section
|
||||||
|
key={`welcome-${location.key}`}
|
||||||
|
className={`${styles.welcome} ${pageStyles.topBlock}`}
|
||||||
|
>
|
||||||
|
<span className={styles.enBackdrop} aria-hidden>
|
||||||
|
Orders
|
||||||
|
</span>
|
||||||
|
<div className={styles.welcomeContent}>
|
||||||
|
<p className={styles.greeting}>
|
||||||
|
<Link to="/" className={styles.backLink}>
|
||||||
|
<ChevronRight size={18} strokeWidth={1.75} />
|
||||||
|
بازگشت به صفحه اصلی
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
<h1 className={styles.headline}>سفارشات من</h1>
|
||||||
|
<p className={styles.lead}>
|
||||||
|
فهرست سفارشها را مشاهده و وضعیت آنها را مدیریت کنید.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className={pageStyles.listWrap}>
|
||||||
|
{orders.length === 0 ? (
|
||||||
|
<div className={pageStyles.panel}>
|
||||||
|
<p className={pageStyles.empty}>سفارشی برای نمایش وجود ندارد.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<section className={pageStyles.panel} aria-label="فهرست سفارشها">
|
||||||
|
<ul className={pageStyles.list}>
|
||||||
|
{pageOrders.map((order) => {
|
||||||
|
const when = formatOrderDateTime(order.createdAt)
|
||||||
|
return (
|
||||||
|
<li key={order.id} className={pageStyles.row}>
|
||||||
|
<div className={pageStyles.orderId}>
|
||||||
|
<span className={pageStyles.idValue} dir="ltr">
|
||||||
|
{order.id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={pageStyles.when}>
|
||||||
|
<span className={pageStyles.date}>{when.date}</span>
|
||||||
|
{when.time && (
|
||||||
|
<span className={pageStyles.time} dir="ltr">
|
||||||
|
{when.time}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={pageStyles.customer}>
|
||||||
|
<span className={pageStyles.customerName}>
|
||||||
|
{order.customerName}
|
||||||
|
</span>
|
||||||
|
<span className={pageStyles.customerPhone} dir="ltr">
|
||||||
|
{formatCellNumber(order.customerPhone)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={pageStyles.stat}>
|
||||||
|
<span className={pageStyles.statLabel}>تعداد اقلام</span>
|
||||||
|
<span className={pageStyles.statValue}>
|
||||||
|
{formatPrice(order.itemCount)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={pageStyles.stat}>
|
||||||
|
<span className={pageStyles.statLabel}>مبلغ کل</span>
|
||||||
|
<span className={pageStyles.statValue}>
|
||||||
|
{formatPrice(order.totalPrice)} تومان
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${pageStyles.statusBtn} ${pageStyles[`status_${order.status}`]}`}
|
||||||
|
onClick={() => setStatusOrder(order)}
|
||||||
|
aria-label={`وضعیت: ${orderStatusLabel[order.status]} — تغییر وضعیت`}
|
||||||
|
>
|
||||||
|
{orderStatusLabel[order.status]}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className={pageStyles.controls}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={pageStyles.iconBtn}
|
||||||
|
aria-label="مشاهده جزئیات"
|
||||||
|
data-tooltip="جزئیات"
|
||||||
|
onClick={() => setDetailsOrder(order)}
|
||||||
|
>
|
||||||
|
<Eye size={17} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{orders.length > 0 && (
|
||||||
|
<nav className={pageStyles.pagination} aria-label="صفحهبندی">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={pageStyles.pageBtn}
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
||||||
|
>
|
||||||
|
قبلی
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{Array.from({ length: totalPages }, (_, index) => index + 1).map(
|
||||||
|
(pageNumber) => (
|
||||||
|
<button
|
||||||
|
key={pageNumber}
|
||||||
|
type="button"
|
||||||
|
className={`${pageStyles.pageBtn} ${pageNumber === page ? pageStyles.pageBtnActive : ''}`}
|
||||||
|
aria-current={pageNumber === page ? 'page' : undefined}
|
||||||
|
onClick={() => setPage(pageNumber)}
|
||||||
|
>
|
||||||
|
{formatPrice(pageNumber)}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={pageStyles.pageBtn}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() =>
|
||||||
|
setPage((current) => Math.min(totalPages, current + 1))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
بعدی
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className={pageStyles.pageInfo}>
|
||||||
|
{formatPrice(orders.length)} سفارش
|
||||||
|
</span>
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={pageStyles.fab}
|
||||||
|
aria-label="ثبت سفارش جدید"
|
||||||
|
data-tooltip="ثبت سفارش جدید"
|
||||||
|
onClick={() => setCreateOpen(true)}
|
||||||
|
>
|
||||||
|
<Plus size={22} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<CreateOrderModal
|
||||||
|
open={createOpen}
|
||||||
|
onClose={() => setCreateOpen(false)}
|
||||||
|
onSubmit={handleCreateOrder}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OrderStatusModal
|
||||||
|
open={Boolean(statusOrder)}
|
||||||
|
orderId={statusOrder?.id ?? ''}
|
||||||
|
currentStatus={statusOrder?.status ?? 'pending'}
|
||||||
|
onClose={() => setStatusOrder(null)}
|
||||||
|
onSubmit={handleStatusSubmit}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OrderDetailsModal
|
||||||
|
open={Boolean(detailsOrder)}
|
||||||
|
order={
|
||||||
|
detailsOrder
|
||||||
|
? (orders.find((item) => item.id === detailsOrder.id) ??
|
||||||
|
detailsOrder)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
onClose={() => setDetailsOrder(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
|
import { Header } from '../components/Header'
|
||||||
|
import styles from './HomePage.module.css'
|
||||||
|
|
||||||
|
const titles: Record<string, { fa: string; en: string }> = {
|
||||||
|
'/products/list': { fa: 'محصولات من', en: 'My Products' },
|
||||||
|
'/products/new': { fa: 'ثبت محصول جدید', en: 'New Product' },
|
||||||
|
'/products/categories': { fa: 'دسته بندی محصولات', en: 'Categories' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlaceholderPage() {
|
||||||
|
const { pathname, key } = useLocation()
|
||||||
|
const title = titles[pathname] ?? { fa: 'به زودی', en: 'Coming Soon' }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<Header />
|
||||||
|
<main className={styles.main}>
|
||||||
|
<section key={key} className={styles.welcome}>
|
||||||
|
<span className={styles.enBackdrop} aria-hidden>
|
||||||
|
{title.en}
|
||||||
|
</span>
|
||||||
|
<div className={styles.welcomeContent}>
|
||||||
|
<p className={styles.greeting}>
|
||||||
|
<Link to="/products" className={styles.backLink}>
|
||||||
|
بازگشت به محصولات
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
<h1 className={styles.headline}>{title.fa}</h1>
|
||||||
|
<p className={styles.lead}>این بخش بهزودی آماده میشود.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
.filters {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.3fr 1.3fr 1fr auto;
|
||||||
|
align-items: end;
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--glass-bg-strong);
|
||||||
|
backdrop-filter: blur(22px) saturate(1.2);
|
||||||
|
-webkit-backdrop-filter: blur(22px) saturate(1.2);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
box-shadow:
|
||||||
|
var(--glass-shadow),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.75);
|
||||||
|
animation: fadeUp 0.5s var(--ease-out) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priceFields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field span {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input,
|
||||||
|
.field select {
|
||||||
|
width: 100%;
|
||||||
|
height: var(--field-height);
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid rgba(143, 65, 12, 0.14);
|
||||||
|
background: rgba(255, 250, 250, 0.85);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 400;
|
||||||
|
outline: none;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75);
|
||||||
|
transition:
|
||||||
|
border-color 0.2s,
|
||||||
|
box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input:focus,
|
||||||
|
.field select:focus {
|
||||||
|
border-color: var(--brown);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.9),
|
||||||
|
0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field select {
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%8f410c' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: left 12px center;
|
||||||
|
padding-inline-end: 12px;
|
||||||
|
padding-inline-start: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.applyBtn {
|
||||||
|
height: var(--field-height);
|
||||||
|
padding: 0 22px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-on-dark);
|
||||||
|
background: var(--brown);
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: 0 8px 18px rgba(143, 65, 12, 0.22);
|
||||||
|
transition:
|
||||||
|
background 0.2s,
|
||||||
|
transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.applyBtn:hover {
|
||||||
|
background: var(--brown-dark);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 48px 16px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-muted);
|
||||||
|
animation: fadeUp 0.5s var(--ease-out) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 28px;
|
||||||
|
left: 28px;
|
||||||
|
z-index: 40;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--text-on-dark);
|
||||||
|
background: var(--brown);
|
||||||
|
box-shadow: 0 12px 28px rgba(143, 65, 12, 0.3);
|
||||||
|
transition:
|
||||||
|
background 0.2s,
|
||||||
|
transform 0.2s,
|
||||||
|
box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab:hover {
|
||||||
|
background: var(--brown-dark);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 16px 32px rgba(143, 65, 12, 0.36);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab::after {
|
||||||
|
content: attr(data-tooltip);
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 10px);
|
||||||
|
left: 50%;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--brown-deeper);
|
||||||
|
color: var(--text-on-dark);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 400;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translate(-50%, 4px);
|
||||||
|
transition:
|
||||||
|
opacity 0.18s,
|
||||||
|
transform 0.18s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab:hover::after,
|
||||||
|
.fab:focus-visible::after {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.applyBtn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priceFields {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
|
import { ChevronRight, Plus } from 'lucide-react'
|
||||||
|
import { Header } from '../components/Header'
|
||||||
|
import { ProductCard } from '../components/ProductCard'
|
||||||
|
import { PriceInput } from '../components/PriceInput'
|
||||||
|
import { ProductOptionsModal } from '../components/ProductOptionsModal'
|
||||||
|
import {
|
||||||
|
productCategories,
|
||||||
|
products as initialProducts,
|
||||||
|
type Product,
|
||||||
|
type ProductOptionValue,
|
||||||
|
} from '../data/products'
|
||||||
|
import styles from './HomePage.module.css'
|
||||||
|
import listStyles from './ProductsListPage.module.css'
|
||||||
|
|
||||||
|
type Filters = {
|
||||||
|
name: string
|
||||||
|
minPrice: string
|
||||||
|
maxPrice: string
|
||||||
|
category: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyFilters: Filters = {
|
||||||
|
name: '',
|
||||||
|
minPrice: '',
|
||||||
|
maxPrice: '',
|
||||||
|
category: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProductsListPage() {
|
||||||
|
const location = useLocation()
|
||||||
|
const [visible, setVisible] = useState(false)
|
||||||
|
const [products, setProducts] = useState<Product[]>(initialProducts)
|
||||||
|
const [draftFilters, setDraftFilters] = useState<Filters>(emptyFilters)
|
||||||
|
const [appliedFilters, setAppliedFilters] = useState<Filters>(emptyFilters)
|
||||||
|
const [optionsProduct, setOptionsProduct] = useState<Product | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setVisible(false)
|
||||||
|
const frame = window.requestAnimationFrame(() => setVisible(true))
|
||||||
|
return () => window.cancelAnimationFrame(frame)
|
||||||
|
}, [location.key])
|
||||||
|
|
||||||
|
const filteredProducts = useMemo(() => {
|
||||||
|
const nameQuery = appliedFilters.name.trim().toLowerCase()
|
||||||
|
const min = appliedFilters.minPrice ? Number(appliedFilters.minPrice) : null
|
||||||
|
const max = appliedFilters.maxPrice ? Number(appliedFilters.maxPrice) : null
|
||||||
|
|
||||||
|
return products.filter((product) => {
|
||||||
|
const matchesName =
|
||||||
|
!nameQuery ||
|
||||||
|
product.nameFa.includes(appliedFilters.name.trim()) ||
|
||||||
|
product.nameEn.toLowerCase().includes(nameQuery)
|
||||||
|
|
||||||
|
const matchesMin = min === null || Number.isNaN(min) || product.price >= min
|
||||||
|
const matchesMax = max === null || Number.isNaN(max) || product.price <= max
|
||||||
|
const matchesCategory =
|
||||||
|
!appliedFilters.category || product.category === appliedFilters.category
|
||||||
|
|
||||||
|
return matchesName && matchesMin && matchesMax && matchesCategory
|
||||||
|
})
|
||||||
|
}, [products, appliedFilters])
|
||||||
|
|
||||||
|
function updateDraft<K extends keyof Filters>(key: K, value: Filters[K]) {
|
||||||
|
setDraftFilters((current) => ({ ...current, [key]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleApplyFilters(event: React.FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
setAppliedFilters(draftFilters)
|
||||||
|
setVisible(false)
|
||||||
|
window.requestAnimationFrame(() => setVisible(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemove(product: Product) {
|
||||||
|
setProducts((current) => current.filter((item) => item.id !== product.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEdit(product: Product) {
|
||||||
|
window.alert(`ویرایش «${product.nameFa}» بهزودی فعال میشود.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSaveOptions(
|
||||||
|
productId: string,
|
||||||
|
options: ProductOptionValue[],
|
||||||
|
) {
|
||||||
|
setProducts((current) =>
|
||||||
|
current.map((product) =>
|
||||||
|
product.id === productId ? { ...product, options } : product,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
setOptionsProduct(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main className={styles.main}>
|
||||||
|
<section key={`welcome-${location.key}`} className={styles.welcome}>
|
||||||
|
<span className={styles.enBackdrop} aria-hidden>
|
||||||
|
My Products
|
||||||
|
</span>
|
||||||
|
<div className={styles.welcomeContent}>
|
||||||
|
<p className={styles.greeting}>
|
||||||
|
<Link to="/products" className={styles.backLink}>
|
||||||
|
<ChevronRight size={18} strokeWidth={1.75} />
|
||||||
|
بازگشت به محصولات
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
<h1 className={styles.headline}>محصولات من</h1>
|
||||||
|
<p className={styles.lead}>
|
||||||
|
فهرست محصولات فروشگاه را مشاهده و مدیریت کنید.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className={listStyles.filters}
|
||||||
|
onSubmit={handleApplyFilters}
|
||||||
|
aria-label="فیلتر محصولات"
|
||||||
|
>
|
||||||
|
<label className={listStyles.field}>
|
||||||
|
<span>نام</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="جستجوی نام محصول"
|
||||||
|
value={draftFilters.name}
|
||||||
|
onChange={(e) => updateDraft('name', e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className={listStyles.priceFields}>
|
||||||
|
<label className={listStyles.field}>
|
||||||
|
<span>حداقل قیمت</span>
|
||||||
|
<PriceInput
|
||||||
|
placeholder="از"
|
||||||
|
value={draftFilters.minPrice}
|
||||||
|
onChange={(value) => updateDraft('minPrice', value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className={listStyles.field}>
|
||||||
|
<span>حداکثر قیمت</span>
|
||||||
|
<PriceInput
|
||||||
|
placeholder="تا"
|
||||||
|
value={draftFilters.maxPrice}
|
||||||
|
onChange={(value) => updateDraft('maxPrice', value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className={listStyles.field}>
|
||||||
|
<span>دستهبندی</span>
|
||||||
|
<select
|
||||||
|
value={draftFilters.category}
|
||||||
|
onChange={(e) => updateDraft('category', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">همه دستهها</option>
|
||||||
|
{productCategories.map((category) => (
|
||||||
|
<option key={category} value={category}>
|
||||||
|
{category}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className={listStyles.actions}>
|
||||||
|
<button type="submit" className={listStyles.applyBtn}>
|
||||||
|
اعمال فیلتر
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{filteredProducts.length === 0 ? (
|
||||||
|
<p className={listStyles.empty}>محصولی برای نمایش وجود ندارد.</p>
|
||||||
|
) : (
|
||||||
|
<section className={listStyles.grid} aria-label="فهرست محصولات">
|
||||||
|
{filteredProducts.map((product, index) => (
|
||||||
|
<ProductCard
|
||||||
|
key={`${location.key}-${product.id}-${index}`}
|
||||||
|
product={product}
|
||||||
|
index={index}
|
||||||
|
visible={visible}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onOpenOptions={setOptionsProduct}
|
||||||
|
onRemove={handleRemove}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to="/products/new"
|
||||||
|
className={listStyles.fab}
|
||||||
|
aria-label="ثبت محصول جدید"
|
||||||
|
data-tooltip="ثبت محصول جدید"
|
||||||
|
>
|
||||||
|
<Plus size={22} strokeWidth={1.75} />
|
||||||
|
</Link>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<ProductOptionsModal
|
||||||
|
open={Boolean(optionsProduct)}
|
||||||
|
product={
|
||||||
|
optionsProduct
|
||||||
|
? (products.find((item) => item.id === optionsProduct.id) ??
|
||||||
|
optionsProduct)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
onClose={() => setOptionsProduct(null)}
|
||||||
|
onSave={handleSaveOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
|
import { ChevronRight } from 'lucide-react'
|
||||||
|
import { Header } from '../components/Header'
|
||||||
|
import { AnimatedTiles } from '../components/AnimatedTiles'
|
||||||
|
import { productTiles } from '../lib/nav'
|
||||||
|
import styles from './HomePage.module.css'
|
||||||
|
|
||||||
|
export function ProductsPage() {
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main className={styles.main}>
|
||||||
|
<section key={`welcome-${location.key}`} className={styles.welcome}>
|
||||||
|
<span className={styles.enBackdrop} aria-hidden>
|
||||||
|
Products
|
||||||
|
</span>
|
||||||
|
<div className={styles.welcomeContent}>
|
||||||
|
<p className={styles.greeting}>
|
||||||
|
<Link to="/" className={styles.backLink}>
|
||||||
|
<ChevronRight size={18} strokeWidth={1.75} />
|
||||||
|
بازگشت به صفحه اصلی
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
<h1 className={styles.headline}>محصولات من</h1>
|
||||||
|
<p className={styles.lead}>
|
||||||
|
محصولات، ثبت محصول جدید و دستهبندیها را از اینجا مدیریت کنید.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<AnimatedTiles tiles={productTiles} label="منوی محصولات" />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
.settingsPage {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topBlock {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding-block: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
animation: fadeUp 0.55s var(--ease-out) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--glass-bg-strong);
|
||||||
|
backdrop-filter: blur(22px) saturate(1.2);
|
||||||
|
-webkit-backdrop-filter: blur(22px) saturate(1.2);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
box-shadow:
|
||||||
|
var(--glass-shadow),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.75);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
padding: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--brown);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid rgba(143, 65, 12, 0.14);
|
||||||
|
background: rgba(143, 65, 12, 0.05);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exceptionList {
|
||||||
|
list-style: none;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exceptionCard,
|
||||||
|
.addCardItem {
|
||||||
|
grid-column: span 4;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exceptionCard {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1.5px solid rgba(143, 65, 12, 0.22);
|
||||||
|
background: rgba(255, 250, 250, 0.72);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input,
|
||||||
|
.field select {
|
||||||
|
width: 100%;
|
||||||
|
height: var(--field-height);
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid rgba(143, 65, 12, 0.14);
|
||||||
|
background: rgba(255, 250, 250, 0.88);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
outline: none;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75);
|
||||||
|
transition:
|
||||||
|
border-color 0.2s,
|
||||||
|
box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input:focus,
|
||||||
|
.field select:focus {
|
||||||
|
border-color: var(--brown);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.9),
|
||||||
|
0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field select {
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%238f410c' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: left 12px center;
|
||||||
|
padding-inline-end: 12px;
|
||||||
|
padding-inline-start: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branchList {
|
||||||
|
list-style: none;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branchCard,
|
||||||
|
.branchAddItem {
|
||||||
|
grid-column: span 6;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branchCard {
|
||||||
|
position: relative;
|
||||||
|
padding: 14px;
|
||||||
|
padding-top: 18px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1.5px solid rgba(143, 65, 12, 0.22);
|
||||||
|
background: rgba(255, 250, 250, 0.72);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.branchGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branchAddress {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branchAddItem {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branchAddItem .addCard {
|
||||||
|
min-height: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconBtn {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
left: 8px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition:
|
||||||
|
color 0.2s,
|
||||||
|
background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconBtn:hover {
|
||||||
|
color: var(--brown);
|
||||||
|
background: rgba(143, 65, 12, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconBtn[data-tooltip]::after {
|
||||||
|
content: attr(data-tooltip);
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) translateY(4px);
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(42, 24, 16, 0.92);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition:
|
||||||
|
opacity 0.15s,
|
||||||
|
transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconBtn[data-tooltip]:hover::after {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeBtn:hover {
|
||||||
|
color: #b42318;
|
||||||
|
background: rgba(180, 35, 24, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.addCardItem {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addCard {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 148px;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1.5px solid rgba(143, 65, 12, 0.22);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--brown);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition:
|
||||||
|
background 0.2s,
|
||||||
|
border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addCard:hover {
|
||||||
|
border-color: rgba(143, 65, 12, 0.4);
|
||||||
|
background: rgba(143, 65, 12, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.savedMessage {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveBtn {
|
||||||
|
height: var(--field-height);
|
||||||
|
padding: 0 22px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--brown);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition:
|
||||||
|
background 0.2s,
|
||||||
|
transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveBtn:hover {
|
||||||
|
background: color-mix(in srgb, var(--brown) 88%, #000);
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveBtn:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.exceptionCard,
|
||||||
|
.addCardItem,
|
||||||
|
.branchCard,
|
||||||
|
.branchAddItem {
|
||||||
|
grid-column: span 6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.branchGrid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branchAddress {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.exceptionCard,
|
||||||
|
.addCardItem,
|
||||||
|
.branchCard,
|
||||||
|
.branchAddItem {
|
||||||
|
grid-column: span 12;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
import { useId, useState } from 'react'
|
||||||
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
|
import { ChevronRight, Plus, Trash2 } from 'lucide-react'
|
||||||
|
import { districts } from '../data/districts'
|
||||||
|
import { Header } from '../components/Header'
|
||||||
|
import { PriceInput } from '../components/PriceInput'
|
||||||
|
import styles from './HomePage.module.css'
|
||||||
|
import pageStyles from './SettingsPage.module.css'
|
||||||
|
|
||||||
|
type ShippingException = {
|
||||||
|
id: string
|
||||||
|
district: string
|
||||||
|
price: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Branch = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
district: string
|
||||||
|
address: string
|
||||||
|
landline: string
|
||||||
|
cellNumber: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function createId() {
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function createException(): ShippingException {
|
||||||
|
return {
|
||||||
|
id: createId(),
|
||||||
|
district: '',
|
||||||
|
price: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBranch(): Branch {
|
||||||
|
return {
|
||||||
|
id: createId(),
|
||||||
|
name: '',
|
||||||
|
district: '',
|
||||||
|
address: '',
|
||||||
|
landline: '',
|
||||||
|
cellNumber: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsPage() {
|
||||||
|
const location = useLocation()
|
||||||
|
const formId = useId()
|
||||||
|
const [exceptions, setExceptions] = useState<ShippingException[]>([])
|
||||||
|
const [branches, setBranches] = useState<Branch[]>([])
|
||||||
|
const [savedMessage, setSavedMessage] = useState('')
|
||||||
|
|
||||||
|
function touch() {
|
||||||
|
setSavedMessage('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateException(
|
||||||
|
id: string,
|
||||||
|
patch: Partial<Pick<ShippingException, 'district' | 'price'>>,
|
||||||
|
) {
|
||||||
|
setExceptions((current) =>
|
||||||
|
current.map((item) => (item.id === id ? { ...item, ...patch } : item)),
|
||||||
|
)
|
||||||
|
touch()
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBranch(id: string, patch: Partial<Omit<Branch, 'id'>>) {
|
||||||
|
setBranches((current) =>
|
||||||
|
current.map((item) => (item.id === id ? { ...item, ...patch } : item)),
|
||||||
|
)
|
||||||
|
touch()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave(event: React.FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
setSavedMessage('تنظیمات ذخیره شد (نسخه نمایشی)')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${styles.page} ${pageStyles.settingsPage}`}>
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main className={styles.main}>
|
||||||
|
<section
|
||||||
|
key={`welcome-${location.key}`}
|
||||||
|
className={`${styles.welcome} ${pageStyles.topBlock}`}
|
||||||
|
>
|
||||||
|
<span className={styles.enBackdrop} aria-hidden>
|
||||||
|
Settings
|
||||||
|
</span>
|
||||||
|
<div className={styles.welcomeContent}>
|
||||||
|
<p className={styles.greeting}>
|
||||||
|
<Link to="/" className={styles.backLink}>
|
||||||
|
<ChevronRight size={18} strokeWidth={1.75} />
|
||||||
|
بازگشت به صفحه اصلی
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
<h1 className={styles.headline}>تنظیمات</h1>
|
||||||
|
<p className={styles.lead}>
|
||||||
|
تنظیمات فروشگاه و هزینه ارسال را مدیریت کنید.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<form
|
||||||
|
id={formId}
|
||||||
|
className={pageStyles.form}
|
||||||
|
onSubmit={handleSave}
|
||||||
|
aria-label="تنظیمات فروشگاه"
|
||||||
|
>
|
||||||
|
<section className={pageStyles.panel} aria-labelledby="shipping-title">
|
||||||
|
<div className={pageStyles.section}>
|
||||||
|
<h2 id="shipping-title" className={pageStyles.sectionTitle}>
|
||||||
|
هزینه ارسال
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className={pageStyles.hint}>
|
||||||
|
تمام ارسالها به سرتاسر قم رایگان میباشد مگر شما منطقهای را در
|
||||||
|
زیر مستثنی کنید
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul className={pageStyles.exceptionList}>
|
||||||
|
{exceptions.map((item, index) => (
|
||||||
|
<li key={item.id} className={pageStyles.exceptionCard}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${pageStyles.iconBtn} ${pageStyles.removeBtn}`}
|
||||||
|
aria-label="حذف"
|
||||||
|
data-tooltip="حذف"
|
||||||
|
onClick={() => {
|
||||||
|
setExceptions((current) =>
|
||||||
|
current.filter((row) => row.id !== item.id),
|
||||||
|
)
|
||||||
|
touch()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={15} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className={pageStyles.field}>
|
||||||
|
<label htmlFor={`ship-district-${item.id}`}>منطقه</label>
|
||||||
|
<select
|
||||||
|
id={`ship-district-${item.id}`}
|
||||||
|
value={item.district}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateException(item.id, {
|
||||||
|
district: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
autoFocus={index === exceptions.length - 1}
|
||||||
|
>
|
||||||
|
<option value="">انتخاب منطقه</option>
|
||||||
|
{districts.map((district) => (
|
||||||
|
<option key={district} value={district}>
|
||||||
|
{district}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={pageStyles.field}>
|
||||||
|
<label htmlFor={`price-${item.id}`}>قیمت</label>
|
||||||
|
<PriceInput
|
||||||
|
id={`price-${item.id}`}
|
||||||
|
value={item.price}
|
||||||
|
onChange={(digits) =>
|
||||||
|
updateException(item.id, { price: digits })
|
||||||
|
}
|
||||||
|
placeholder="۰"
|
||||||
|
aria-label={`قیمت منطقه ${index + 1}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<li className={pageStyles.addCardItem}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={pageStyles.addCard}
|
||||||
|
onClick={() => {
|
||||||
|
setExceptions((current) => [...current, createException()])
|
||||||
|
touch()
|
||||||
|
}}
|
||||||
|
aria-label="افزودن منطقه"
|
||||||
|
>
|
||||||
|
<Plus size={22} strokeWidth={1.75} />
|
||||||
|
<span>افزودن منطقه</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={pageStyles.panel} aria-labelledby="branches-title">
|
||||||
|
<div className={pageStyles.section}>
|
||||||
|
<h2 id="branches-title" className={pageStyles.sectionTitle}>
|
||||||
|
شعبهها
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<ul className={pageStyles.branchList}>
|
||||||
|
{branches.map((branch, index) => (
|
||||||
|
<li key={branch.id} className={pageStyles.branchCard}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${pageStyles.iconBtn} ${pageStyles.removeBtn}`}
|
||||||
|
aria-label="حذف شعبه"
|
||||||
|
data-tooltip="حذف"
|
||||||
|
onClick={() => {
|
||||||
|
setBranches((current) =>
|
||||||
|
current.filter((row) => row.id !== branch.id),
|
||||||
|
)
|
||||||
|
touch()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={15} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className={pageStyles.branchGrid}>
|
||||||
|
<div className={pageStyles.field}>
|
||||||
|
<label htmlFor={`branch-name-${branch.id}`}>نام</label>
|
||||||
|
<input
|
||||||
|
id={`branch-name-${branch.id}`}
|
||||||
|
type="text"
|
||||||
|
placeholder="مثال: شعبه مرکزی"
|
||||||
|
value={branch.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateBranch(branch.id, { name: e.target.value })
|
||||||
|
}
|
||||||
|
autoFocus={index === branches.length - 1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={pageStyles.field}>
|
||||||
|
<label htmlFor={`branch-district-${branch.id}`}>
|
||||||
|
منطقه
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id={`branch-district-${branch.id}`}
|
||||||
|
value={branch.district}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateBranch(branch.id, {
|
||||||
|
district: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">انتخاب منطقه</option>
|
||||||
|
{districts.map((district) => (
|
||||||
|
<option key={district} value={district}>
|
||||||
|
{district}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`${pageStyles.field} ${pageStyles.branchAddress}`}
|
||||||
|
>
|
||||||
|
<label htmlFor={`branch-address-${branch.id}`}>
|
||||||
|
آدرس
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`branch-address-${branch.id}`}
|
||||||
|
type="text"
|
||||||
|
placeholder="آدرس کامل شعبه"
|
||||||
|
value={branch.address}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateBranch(branch.id, {
|
||||||
|
address: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={pageStyles.field}>
|
||||||
|
<label htmlFor={`branch-landline-${branch.id}`}>
|
||||||
|
تلفن ثابت
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`branch-landline-${branch.id}`}
|
||||||
|
type="tel"
|
||||||
|
inputMode="tel"
|
||||||
|
dir="ltr"
|
||||||
|
placeholder="02531234567"
|
||||||
|
value={branch.landline}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateBranch(branch.id, {
|
||||||
|
landline: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={pageStyles.field}>
|
||||||
|
<label htmlFor={`branch-cell-${branch.id}`}>
|
||||||
|
شماره موبایل
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`branch-cell-${branch.id}`}
|
||||||
|
type="tel"
|
||||||
|
inputMode="tel"
|
||||||
|
dir="ltr"
|
||||||
|
placeholder="09121234567"
|
||||||
|
value={branch.cellNumber}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateBranch(branch.id, {
|
||||||
|
cellNumber: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<li className={pageStyles.branchAddItem}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={pageStyles.addCard}
|
||||||
|
onClick={() => {
|
||||||
|
setBranches((current) => [...current, createBranch()])
|
||||||
|
touch()
|
||||||
|
}}
|
||||||
|
aria-label="افزودن شعبه"
|
||||||
|
>
|
||||||
|
<Plus size={22} strokeWidth={1.75} />
|
||||||
|
<span>افزودن شعبه</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className={pageStyles.footer}>
|
||||||
|
{savedMessage && (
|
||||||
|
<p className={pageStyles.savedMessage} role="status">
|
||||||
|
{savedMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button type="submit" className={pageStyles.saveBtn}>
|
||||||
|
ذخیره تنظیمات
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,489 @@
|
|||||||
|
.usersPage {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usersMain {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding-bottom: 88px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topBlock {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding-block: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.2fr 1.2fr 1fr auto;
|
||||||
|
align-items: end;
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--glass-bg-strong);
|
||||||
|
backdrop-filter: blur(22px) saturate(1.2);
|
||||||
|
-webkit-backdrop-filter: blur(22px) saturate(1.2);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
box-shadow:
|
||||||
|
var(--glass-shadow),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.75);
|
||||||
|
animation: fadeUp 0.5s var(--ease-out) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field span {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input,
|
||||||
|
.field select {
|
||||||
|
width: 100%;
|
||||||
|
height: var(--field-height);
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid rgba(143, 65, 12, 0.14);
|
||||||
|
background: rgba(255, 250, 250, 0.85);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 400;
|
||||||
|
outline: none;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75);
|
||||||
|
transition:
|
||||||
|
border-color 0.2s,
|
||||||
|
box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input:focus,
|
||||||
|
.field select:focus {
|
||||||
|
border-color: var(--brown);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.9),
|
||||||
|
0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field select {
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%8f410c' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: left 12px center;
|
||||||
|
padding-inline-end: 12px;
|
||||||
|
padding-inline-start: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.applyBtn {
|
||||||
|
height: var(--field-height);
|
||||||
|
padding: 0 22px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-on-dark);
|
||||||
|
background: var(--brown);
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: 0 8px 18px rgba(143, 65, 12, 0.22);
|
||||||
|
transition:
|
||||||
|
background 0.2s,
|
||||||
|
transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.applyBtn:hover {
|
||||||
|
background: var(--brown-dark);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.listWrap {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--glass-bg-strong);
|
||||||
|
backdrop-filter: blur(22px) saturate(1.2);
|
||||||
|
-webkit-backdrop-filter: blur(22px) saturate(1.2);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
box-shadow:
|
||||||
|
var(--glass-shadow),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.75);
|
||||||
|
animation: fadeUp 0.55s var(--ease-out) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(160px, 1.4fr) minmax(120px, 1fr) 90px minmax(120px, 1.1fr) auto;
|
||||||
|
gap: 12px 16px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: rgba(255, 250, 250, 0.55);
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:hover {
|
||||||
|
background: rgba(255, 250, 250, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowDisabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.identity {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-size: 0.98rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--brown);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(143, 65, 12, 0.16);
|
||||||
|
background: rgba(143, 65, 12, 0.05);
|
||||||
|
color: var(--brown);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
direction: ltr;
|
||||||
|
text-align: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statLabel {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statValue {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch {
|
||||||
|
position: relative;
|
||||||
|
width: 42px;
|
||||||
|
height: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(143, 65, 12, 0.18);
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switchOn {
|
||||||
|
background: var(--brown);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switchThumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
inset-inline-start: 3px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fffafa;
|
||||||
|
box-shadow: 0 1px 4px rgba(42, 24, 16, 0.2);
|
||||||
|
transition: inset-inline-start 0.2s var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switchOn .switchThumb {
|
||||||
|
inset-inline-start: 21px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconBtn {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition:
|
||||||
|
color 0.2s,
|
||||||
|
background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconBtn:hover {
|
||||||
|
color: var(--brown);
|
||||||
|
background: rgba(143, 65, 12, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.roleElevated {
|
||||||
|
color: var(--brown);
|
||||||
|
}
|
||||||
|
|
||||||
|
.roleElevated:hover {
|
||||||
|
color: var(--brown-dark);
|
||||||
|
background: rgba(143, 65, 12, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconBtn::after {
|
||||||
|
content: attr(data-tooltip);
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
left: 50%;
|
||||||
|
z-index: 6;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--brown-deeper);
|
||||||
|
color: var(--text-on-dark);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 400;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translate(-50%, 4px);
|
||||||
|
transition:
|
||||||
|
opacity 0.18s,
|
||||||
|
transform 0.18s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconBtn:hover::after,
|
||||||
|
.iconBtn:focus-visible::after {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeBtn:hover {
|
||||||
|
color: #9b2c2c;
|
||||||
|
background: rgba(155, 44, 44, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.removeBtn:hover::after {
|
||||||
|
background: #9b2c2c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageBtn {
|
||||||
|
min-width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(143, 65, 12, 0.14);
|
||||||
|
background: rgba(255, 250, 250, 0.75);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition:
|
||||||
|
color 0.2s,
|
||||||
|
background 0.2s,
|
||||||
|
border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageBtn:hover:not(:disabled) {
|
||||||
|
color: var(--brown);
|
||||||
|
border-color: rgba(143, 65, 12, 0.28);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageBtnActive {
|
||||||
|
color: var(--text-on-dark);
|
||||||
|
background: var(--brown);
|
||||||
|
border-color: var(--brown);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageBtnActive:hover:not(:disabled) {
|
||||||
|
color: var(--text-on-dark);
|
||||||
|
background: var(--brown-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageBtn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageInfo {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-inline: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 48px 16px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 28px;
|
||||||
|
left: 28px;
|
||||||
|
z-index: 40;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--text-on-dark);
|
||||||
|
background: var(--brown);
|
||||||
|
box-shadow: 0 12px 28px rgba(143, 65, 12, 0.3);
|
||||||
|
transition:
|
||||||
|
background 0.2s,
|
||||||
|
transform 0.2s,
|
||||||
|
box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab:hover {
|
||||||
|
background: var(--brown-dark);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 16px 32px rgba(143, 65, 12, 0.36);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab::after {
|
||||||
|
content: attr(data-tooltip);
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 10px);
|
||||||
|
left: 50%;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--brown-deeper);
|
||||||
|
color: var(--text-on-dark);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 400;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translate(-50%, 4px);
|
||||||
|
transition:
|
||||||
|
opacity 0.18s,
|
||||||
|
transform 0.18s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab:hover::after,
|
||||||
|
.fab:focus-visible::after {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(16px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.row {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.identity {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.applyBtn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 620px) {
|
||||||
|
.filters {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
text-align: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.switchThumb,
|
||||||
|
.iconBtn::after {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,525 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
ChevronRight,
|
||||||
|
KeyRound,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
Shield,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Header } from '../components/Header'
|
||||||
|
import { ChangePasswordModal } from '../components/ChangePasswordModal'
|
||||||
|
import { RoleModal } from '../components/RoleModal'
|
||||||
|
import { UserModal, type UserFormValues } from '../components/UserModal'
|
||||||
|
import { ApiError } from '../lib/api'
|
||||||
|
import { clearSession } from '../lib/auth'
|
||||||
|
import {
|
||||||
|
createUser,
|
||||||
|
deleteUser,
|
||||||
|
listUsers,
|
||||||
|
updateUser,
|
||||||
|
updateUserPassword,
|
||||||
|
updateUserRole,
|
||||||
|
} from '../lib/usersApi'
|
||||||
|
import {
|
||||||
|
formatCellNumber,
|
||||||
|
isElevatedRole,
|
||||||
|
userRoleLabel,
|
||||||
|
userRoles,
|
||||||
|
type User,
|
||||||
|
type UserRole,
|
||||||
|
} from '../data/users'
|
||||||
|
import { formatPrice, toEnglishDigits } from '../utils/price'
|
||||||
|
import styles from './HomePage.module.css'
|
||||||
|
import pageStyles from './UsersPage.module.css'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 24
|
||||||
|
|
||||||
|
type Filters = {
|
||||||
|
name: string
|
||||||
|
cellNumber: string
|
||||||
|
role: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyFilters: Filters = {
|
||||||
|
name: '',
|
||||||
|
cellNumber: '',
|
||||||
|
role: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSearchQuery(filters: Filters) {
|
||||||
|
const name = filters.name.trim()
|
||||||
|
const cell = toEnglishDigits(filters.cellNumber).replace(/\D/g, '')
|
||||||
|
if (cell) return cell
|
||||||
|
if (name) return name
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UsersPage() {
|
||||||
|
const location = useLocation()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [users, setUsers] = useState<User[]>([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [draftFilters, setDraftFilters] = useState<Filters>(emptyFilters)
|
||||||
|
const [appliedFilters, setAppliedFilters] = useState<Filters>(emptyFilters)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null)
|
||||||
|
const [roleUser, setRoleUser] = useState<User | null>(null)
|
||||||
|
const [passwordUser, setPasswordUser] = useState<User | null>(null)
|
||||||
|
const [userModalOpen, setUserModalOpen] = useState(false)
|
||||||
|
const [editingUser, setEditingUser] = useState<User | null>(null)
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||||
|
|
||||||
|
const loadUsers = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const result = await listUsers({
|
||||||
|
q: buildSearchQuery(appliedFilters),
|
||||||
|
role: (appliedFilters.role as UserRole) || undefined,
|
||||||
|
page,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
})
|
||||||
|
setUsers(result.items)
|
||||||
|
setTotal(result.total)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.status === 401) {
|
||||||
|
clearSession()
|
||||||
|
navigate('/login', { replace: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setUsers([])
|
||||||
|
setTotal(0)
|
||||||
|
setError(
|
||||||
|
err instanceof ApiError
|
||||||
|
? err.message
|
||||||
|
: 'بارگذاری کاربران ناموفق بود.',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [appliedFilters, page, navigate])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadUsers()
|
||||||
|
}, [loadUsers])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (page > totalPages) setPage(totalPages)
|
||||||
|
}, [page, totalPages])
|
||||||
|
|
||||||
|
function updateDraft<K extends keyof Filters>(key: K, value: Filters[K]) {
|
||||||
|
setDraftFilters((current) => ({ ...current, [key]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleApplyFilters(event: React.FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
setPage(1)
|
||||||
|
setAppliedFilters(draftFilters)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleToggleDisabled(user: User) {
|
||||||
|
setBusyId(user.id)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const updated = await updateUser(user.id, { disabled: !user.disabled })
|
||||||
|
setUsers((current) =>
|
||||||
|
current.map((item) => (item.id === user.id ? updated : item)),
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err instanceof ApiError ? err.message : 'بهروزرسانی وضعیت ناموفق بود.',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setBusyId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEdit(user: User) {
|
||||||
|
setEditingUser(user)
|
||||||
|
setUserModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenAddUser() {
|
||||||
|
setEditingUser(null)
|
||||||
|
setUserModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCloseUserModal() {
|
||||||
|
setUserModalOpen(false)
|
||||||
|
setEditingUser(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUserSubmit(values: UserFormValues) {
|
||||||
|
setError('')
|
||||||
|
if (editingUser) {
|
||||||
|
const updated = await updateUser(editingUser.id, {
|
||||||
|
title: values.title,
|
||||||
|
firstName: values.firstName,
|
||||||
|
lastName: values.lastName,
|
||||||
|
category: values.category,
|
||||||
|
cellNumber: values.cellNumber,
|
||||||
|
})
|
||||||
|
setUsers((current) =>
|
||||||
|
current.map((item) =>
|
||||||
|
item.id === editingUser.id ? updated : item,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
if (!values.password) {
|
||||||
|
throw new Error('رمز عبور الزامی است')
|
||||||
|
}
|
||||||
|
await createUser({
|
||||||
|
title: values.title,
|
||||||
|
firstName: values.firstName,
|
||||||
|
lastName: values.lastName,
|
||||||
|
category: values.category,
|
||||||
|
cellNumber: values.cellNumber,
|
||||||
|
password: values.password,
|
||||||
|
})
|
||||||
|
if (page === 1) {
|
||||||
|
await loadUsers()
|
||||||
|
} else {
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
handleCloseUserModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChangePassword(user: User) {
|
||||||
|
setPasswordUser(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePasswordSubmit(password: string) {
|
||||||
|
if (!passwordUser) return
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await updateUserPassword(passwordUser.id, password)
|
||||||
|
setPasswordUser(null)
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err instanceof ApiError ? err.message : 'تغییر رمز عبور ناموفق بود.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemove(user: User) {
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
`کاربر «${user.firstName} ${user.lastName}» حذف شود؟`,
|
||||||
|
)
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
setBusyId(user.id)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await deleteUser(user.id)
|
||||||
|
if (users.length === 1 && page > 1) {
|
||||||
|
setPage((current) => current - 1)
|
||||||
|
} else {
|
||||||
|
await loadUsers()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'حذف کاربر ناموفق بود.')
|
||||||
|
} finally {
|
||||||
|
setBusyId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRoleSubmit(role: UserRole) {
|
||||||
|
if (!roleUser) return
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const updated = await updateUserRole(roleUser.id, role)
|
||||||
|
setUsers((current) =>
|
||||||
|
current.map((item) => (item.id === roleUser.id ? updated : item)),
|
||||||
|
)
|
||||||
|
setRoleUser(null)
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err instanceof ApiError ? err.message : 'تغییر نقش ناموفق بود.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${styles.page} ${pageStyles.usersPage}`}>
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main className={`${styles.main} ${pageStyles.usersMain}`}>
|
||||||
|
<section
|
||||||
|
key={`welcome-${location.key}`}
|
||||||
|
className={`${styles.welcome} ${pageStyles.topBlock}`}
|
||||||
|
>
|
||||||
|
<span className={styles.enBackdrop} aria-hidden>
|
||||||
|
Users
|
||||||
|
</span>
|
||||||
|
<div className={styles.welcomeContent}>
|
||||||
|
<p className={styles.greeting}>
|
||||||
|
<Link to="/" className={styles.backLink}>
|
||||||
|
<ChevronRight size={18} strokeWidth={1.75} />
|
||||||
|
بازگشت به صفحه اصلی
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
<h1 className={styles.headline}>کاربران</h1>
|
||||||
|
<p className={styles.lead}>
|
||||||
|
فهرست کاربران را مشاهده و مدیریت کنید.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className={pageStyles.filters}
|
||||||
|
onSubmit={handleApplyFilters}
|
||||||
|
aria-label="فیلتر کاربران"
|
||||||
|
>
|
||||||
|
<label className={pageStyles.field}>
|
||||||
|
<span>نام</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="نام یا نام خانوادگی"
|
||||||
|
value={draftFilters.name}
|
||||||
|
onChange={(e) => updateDraft('name', e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className={pageStyles.field}>
|
||||||
|
<span>شماره موبایل</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="tel"
|
||||||
|
placeholder="۰۹۱۲..."
|
||||||
|
value={draftFilters.cellNumber}
|
||||||
|
onChange={(e) => updateDraft('cellNumber', e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className={pageStyles.field}>
|
||||||
|
<span>نقش</span>
|
||||||
|
<select
|
||||||
|
value={draftFilters.role}
|
||||||
|
onChange={(e) => updateDraft('role', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">همه نقشها</option>
|
||||||
|
{userRoles.map((role) => (
|
||||||
|
<option key={role.id} value={role.id}>
|
||||||
|
{role.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className={pageStyles.actions}>
|
||||||
|
<button type="submit" className={pageStyles.applyBtn}>
|
||||||
|
اعمال فیلتر
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className={pageStyles.panel} role="alert">
|
||||||
|
<p className={pageStyles.empty}>{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={pageStyles.listWrap}>
|
||||||
|
{loading ? (
|
||||||
|
<div className={pageStyles.panel}>
|
||||||
|
<p className={pageStyles.empty}>در حال بارگذاری...</p>
|
||||||
|
</div>
|
||||||
|
) : users.length === 0 ? (
|
||||||
|
<div className={pageStyles.panel}>
|
||||||
|
<p className={pageStyles.empty}>کاربری برای نمایش وجود ندارد.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<section className={pageStyles.panel} aria-label="فهرست کاربران">
|
||||||
|
<ul className={pageStyles.list}>
|
||||||
|
{users.map((user) => (
|
||||||
|
<li
|
||||||
|
key={user.id}
|
||||||
|
className={`${pageStyles.row} ${user.disabled ? pageStyles.rowDisabled : ''}`}
|
||||||
|
>
|
||||||
|
<div className={pageStyles.identity}>
|
||||||
|
<span className={pageStyles.name}>
|
||||||
|
{user.title} {user.firstName} {user.lastName}
|
||||||
|
</span>
|
||||||
|
{user.category && (
|
||||||
|
<span className={pageStyles.chip}>{user.category}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className={pageStyles.cell} dir="ltr">
|
||||||
|
{formatCellNumber(user.cellNumber)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className={pageStyles.stat}>
|
||||||
|
<span className={pageStyles.statLabel}>سفارشها</span>
|
||||||
|
<span className={pageStyles.statValue}>
|
||||||
|
{formatPrice(user.totalOrders)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={pageStyles.stat}>
|
||||||
|
<span className={pageStyles.statLabel}>جمع تراکنشها</span>
|
||||||
|
<span className={pageStyles.statValue}>
|
||||||
|
{formatPrice(user.totalTransaction)} تومان
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={pageStyles.controls}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${pageStyles.switch} ${!user.disabled ? pageStyles.switchOn : ''}`}
|
||||||
|
role="switch"
|
||||||
|
aria-checked={!user.disabled}
|
||||||
|
aria-label={
|
||||||
|
user.disabled ? 'فعالسازی کاربر' : 'غیرفعالسازی کاربر'
|
||||||
|
}
|
||||||
|
title={user.disabled ? 'غیرفعال' : 'فعال'}
|
||||||
|
disabled={busyId === user.id}
|
||||||
|
onClick={() => void handleToggleDisabled(user)}
|
||||||
|
>
|
||||||
|
<span className={pageStyles.switchThumb} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={pageStyles.iconBtn}
|
||||||
|
aria-label="ویرایش"
|
||||||
|
data-tooltip="ویرایش"
|
||||||
|
disabled={busyId === user.id}
|
||||||
|
onClick={() => handleEdit(user)}
|
||||||
|
>
|
||||||
|
<Pencil size={17} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={pageStyles.iconBtn}
|
||||||
|
aria-label="تغییر رمز عبور"
|
||||||
|
data-tooltip="تغییر رمز"
|
||||||
|
disabled={busyId === user.id}
|
||||||
|
onClick={() => handleChangePassword(user)}
|
||||||
|
>
|
||||||
|
<KeyRound size={17} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${pageStyles.iconBtn} ${isElevatedRole(user.role) ? pageStyles.roleElevated : ''}`}
|
||||||
|
aria-label={`نقش: ${userRoleLabel[user.role]}`}
|
||||||
|
data-tooltip="نقش"
|
||||||
|
disabled={busyId === user.id}
|
||||||
|
onClick={() => setRoleUser(user)}
|
||||||
|
>
|
||||||
|
<Shield
|
||||||
|
size={17}
|
||||||
|
strokeWidth={1.75}
|
||||||
|
fill={isElevatedRole(user.role) ? 'currentColor' : 'none'}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${pageStyles.iconBtn} ${pageStyles.removeBtn}`}
|
||||||
|
aria-label="حذف"
|
||||||
|
data-tooltip="حذف"
|
||||||
|
disabled={busyId === user.id}
|
||||||
|
onClick={() => void handleRemove(user)}
|
||||||
|
>
|
||||||
|
<Trash2 size={17} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!loading && total > 0 && (
|
||||||
|
<nav className={pageStyles.pagination} aria-label="صفحهبندی">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={pageStyles.pageBtn}
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
||||||
|
>
|
||||||
|
قبلی
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{Array.from({ length: totalPages }, (_, index) => index + 1).map(
|
||||||
|
(pageNumber) => (
|
||||||
|
<button
|
||||||
|
key={pageNumber}
|
||||||
|
type="button"
|
||||||
|
className={`${pageStyles.pageBtn} ${pageNumber === page ? pageStyles.pageBtnActive : ''}`}
|
||||||
|
aria-current={pageNumber === page ? 'page' : undefined}
|
||||||
|
onClick={() => setPage(pageNumber)}
|
||||||
|
>
|
||||||
|
{formatPrice(pageNumber)}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={pageStyles.pageBtn}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() =>
|
||||||
|
setPage((current) => Math.min(totalPages, current + 1))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
بعدی
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className={pageStyles.pageInfo}>
|
||||||
|
{formatPrice(total)} کاربر
|
||||||
|
</span>
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={pageStyles.fab}
|
||||||
|
aria-label="افزودن کاربر"
|
||||||
|
data-tooltip="افزودن کاربر"
|
||||||
|
onClick={handleOpenAddUser}
|
||||||
|
>
|
||||||
|
<Plus size={22} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<RoleModal
|
||||||
|
open={Boolean(roleUser)}
|
||||||
|
userName={
|
||||||
|
roleUser ? `${roleUser.firstName} ${roleUser.lastName}` : ''
|
||||||
|
}
|
||||||
|
currentRole={roleUser?.role ?? 'customer'}
|
||||||
|
onClose={() => setRoleUser(null)}
|
||||||
|
onSubmit={(role) => void handleRoleSubmit(role)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ChangePasswordModal
|
||||||
|
open={Boolean(passwordUser)}
|
||||||
|
userName={
|
||||||
|
passwordUser
|
||||||
|
? `${passwordUser.title} ${passwordUser.firstName} ${passwordUser.lastName}`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
onClose={() => setPasswordUser(null)}
|
||||||
|
onSubmit={(password) => void handlePasswordSubmit(password)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UserModal
|
||||||
|
open={userModalOpen}
|
||||||
|
editingUser={editingUser}
|
||||||
|
onClose={handleCloseUserModal}
|
||||||
|
onSubmit={handleUserSubmit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { Area } from 'react-easy-crop'
|
||||||
|
|
||||||
|
function createImage(url: string): Promise<HTMLImageElement> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const image = new Image()
|
||||||
|
image.addEventListener('load', () => resolve(image))
|
||||||
|
image.addEventListener('error', reject)
|
||||||
|
image.src = url
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCroppedImage(
|
||||||
|
imageSrc: string,
|
||||||
|
pixelCrop: Area,
|
||||||
|
format: 'jpeg' | 'png' = 'jpeg',
|
||||||
|
): Promise<string> {
|
||||||
|
const image = await createImage(imageSrc)
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) throw new Error('Could not get canvas context')
|
||||||
|
|
||||||
|
canvas.width = pixelCrop.width
|
||||||
|
canvas.height = pixelCrop.height
|
||||||
|
|
||||||
|
if (format === 'png') {
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.drawImage(
|
||||||
|
image,
|
||||||
|
pixelCrop.x,
|
||||||
|
pixelCrop.y,
|
||||||
|
pixelCrop.width,
|
||||||
|
pixelCrop.height,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
pixelCrop.width,
|
||||||
|
pixelCrop.height,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (format === 'png') {
|
||||||
|
return canvas.toDataURL('image/png')
|
||||||
|
}
|
||||||
|
|
||||||
|
return canvas.toDataURL('image/jpeg', 0.92)
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
const PERSIAN_DIGITS = '۰۱۲۳۴۵۶۷۸۹'
|
||||||
|
const ARABIC_DIGITS = '٠١٢٣٤٥٦٧٨٩'
|
||||||
|
|
||||||
|
export function toEnglishDigits(value: string) {
|
||||||
|
return value
|
||||||
|
.replace(/[۰-۹]/g, (digit) => String(PERSIAN_DIGITS.indexOf(digit)))
|
||||||
|
.replace(/[٠-٩]/g, (digit) => String(ARABIC_DIGITS.indexOf(digit)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep only digits from any price-like string. */
|
||||||
|
export function parsePriceDigits(value: string): string {
|
||||||
|
return toEnglishDigits(value).replace(/\D/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePriceNumber(value: string): number | null {
|
||||||
|
const digits = parsePriceDigits(value)
|
||||||
|
if (!digits) return null
|
||||||
|
const number = Number(digits)
|
||||||
|
return Number.isNaN(number) ? null : number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Display value for price inputs (Persian thousand separators). */
|
||||||
|
export function formatPriceInput(value: string | number | null | undefined) {
|
||||||
|
if (value === null || value === undefined || value === '') return ''
|
||||||
|
const digits =
|
||||||
|
typeof value === 'number'
|
||||||
|
? String(Math.trunc(Math.abs(value)))
|
||||||
|
: parsePriceDigits(value)
|
||||||
|
if (!digits) return ''
|
||||||
|
return new Intl.NumberFormat('fa-IR').format(Number(digits))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPrice(value: number) {
|
||||||
|
return new Intl.NumberFormat('fa-IR').format(value)
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||