Initial commit of Balout Pastry admin dashboards.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-02 17:23:00 +03:30
co-authored by Cursor
commit af11d96fd0
103 changed files with 14651 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:3100/api/v1
+29
View File
@@ -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?
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+135
View File
@@ -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**
+13
View File
@@ -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>
+1453
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -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"
}
}
+6
View File
@@ -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

+105
View File
@@ -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);
}
+151
View File
@@ -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>
)
}
+74
View File
@@ -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>
)
}
+163
View File
@@ -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;
}
}
+138
View File
@@ -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>
)
}
+303
View File
@@ -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%;
}
}
+267
View File
@@ -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;
}
}
+327
View File
@@ -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);
}
+137
View File
@@ -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>
)
}
+342
View File
@@ -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;
}
}
+816
View File
@@ -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,
}
}
+219
View File
@@ -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;
}
}
+146
View File
@@ -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>
)
}
+163
View File
@@ -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);
}
+143
View File
@@ -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>
)
}
+101
View File
@@ -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));
}
}
+68
View File
@@ -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>
)
}
+212
View File
@@ -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;
}
}
+173
View File
@@ -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;
}
+226
View File
@@ -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);
}
+93
View File
@@ -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>
)
}
+30
View File
@@ -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);
}
+33
View File
@@ -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))}
/>
)
}
+199
View File
@@ -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;
}
}
+87
View File
@@ -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%;
}
}
+347
View File
@@ -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}
/>
</>
)
}
+73
View File
@@ -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);
}
+79
View File
@@ -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>
)
}
+43
View File
@@ -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);
}
+88
View File
@@ -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>
)
}
+60
View File
@@ -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);
}
+67
View File
@@ -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>
)
}
+66
View File
@@ -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;
}
}
+325
View File
@@ -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>
)
}
+177
View File
@@ -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
})
}
+25
View File
@@ -0,0 +1,25 @@
/** Districts of Qom used across settings (shipping, branches). */
export const districts = [
'پردیسان',
'جعفریه',
'زنبیل‌آباد',
'سالاریه',
'صفاییه',
'عطاران',
'انارستان',
'بنیاد',
'آذر',
'هنرستان',
'باجک',
'دورشهر',
'خیابان ارم',
'شهرک قدس',
'شهرک امام حسن',
'شهرک مهدیه',
'قلعه کامکار',
'نیروگاه',
'حرم',
'بلوار امین',
] as const
export type District = (typeof districts)[number]
+56
View File
@@ -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'
+267
View File
@@ -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 }
}
+149
View File
@@ -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]}`,
}
}
+367
View File
@@ -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, '')
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+31
View File
@@ -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;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+142
View File
@@ -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;
}
}
+119
View File
@@ -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
}
+67
View File
@@ -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
}
+40
View File
@@ -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()
}
}
+55
View File
@@ -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())
}
+93
View File
@@ -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,
)
}
+105
View File
@@ -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: [],
},
]
+91
View File
@@ -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)
}
+85
View File
@@ -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
}
+92
View File
@@ -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',
})
}
+14
View File
@@ -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>,
)
+291
View File
@@ -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%;
}
}
+314
View File
@@ -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>
)
}
+82
View File
@@ -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);
}
+173
View File
@@ -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>
)
}
+254
View File
@@ -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;
}
}
+36
View File
@@ -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>
)
}
+224
View File
@@ -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;
}
}
+132
View File
@@ -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>
)
}
+396
View File
@@ -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;
}
}
+249
View File
@@ -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>
)
}
+36
View File
@@ -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>
)
}
+208
View File
@@ -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;
}
}
+216
View File
@@ -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>
)
}
+38
View File
@@ -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>
)
}
+328
View File
@@ -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);
}
}
+349
View File
@@ -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>
)
}
+489
View File
@@ -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;
}
}
+525
View File
@@ -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>
)
}
+46
View File
@@ -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)
}
+35
View File
@@ -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)
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

Some files were not shown because too many files have changed in this diff Show More