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"]
|
||||
}
|
||||