Add special orders admin UI and product specials tools.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
b103e330ef
commit
d63e3a83d5
+5
-5
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
Admin dashboard for شیرینیفروشی بلوط. React + Vite SPA that talks to the NestJS API.
|
Admin dashboard for شیرینیفروشی بلوط. React + Vite SPA that talks to the NestJS API.
|
||||||
|
|
||||||
Repo: `https://git.meshkee.com/BaloutPastry/dashboards.git`
|
Repo: `https://gitea.meshkee.com/balout-pastry/dashboards.git`
|
||||||
API repo: `https://git.meshkee.com/BaloutPastry/backend.git`
|
API repo: `https://gitea.meshkee.com/balout-pastry/backend.git`
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ API repo: `https://git.meshkee.com/BaloutPastry/backend.git`
|
|||||||
## Setup on a new device
|
## Setup on a new device
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.meshkee.com/BaloutPastry/dashboards.git
|
git clone https://gitea.meshkee.com/balout-pastry/dashboards.git
|
||||||
cd dashboards
|
cd dashboards
|
||||||
|
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
@@ -131,6 +131,6 @@ Set `VITE_API_BASE_URL` to the real API URL **before** `npm run build` (it is ba
|
|||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
- Backend docs: clone `BaloutPastry/backend` and read `CONTEXT.md`
|
- Backend docs: clone `balout-pastry/backend` and read `CONTEXT.md`
|
||||||
- Website (storefront): clone `BaloutPastry/website` and read `CONTEXT.md` (dev port **5174**)
|
- Website (storefront): clone `balout-pastry/website` and read `CONTEXT.md` (dev port **5174**)
|
||||||
- Postgres for local API: Docker Compose on host port **5434**
|
- Postgres for local API: Docker Compose on host port **5434**
|
||||||
|
|||||||
+18
@@ -6,6 +6,7 @@ import { CustomerHomePage } from './pages/CustomerHomePage'
|
|||||||
import { ProductsPage } from './pages/ProductsPage'
|
import { ProductsPage } from './pages/ProductsPage'
|
||||||
import { ProductsListPage } from './pages/ProductsListPage'
|
import { ProductsListPage } from './pages/ProductsListPage'
|
||||||
import { ProductDetailsPage } from './pages/ProductDetailsPage'
|
import { ProductDetailsPage } from './pages/ProductDetailsPage'
|
||||||
|
import { ProductSpecialsPage } from './pages/ProductSpecialsPage'
|
||||||
import { CategoriesPage } from './pages/CategoriesPage'
|
import { CategoriesPage } from './pages/CategoriesPage'
|
||||||
import { AddProductPage } from './pages/AddProductPage'
|
import { AddProductPage } from './pages/AddProductPage'
|
||||||
import { UsersPage } from './pages/UsersPage'
|
import { UsersPage } from './pages/UsersPage'
|
||||||
@@ -15,6 +16,7 @@ import { ProfilePage } from './pages/ProfilePage'
|
|||||||
import { CustomerOrdersPage } from './pages/CustomerOrdersPage'
|
import { CustomerOrdersPage } from './pages/CustomerOrdersPage'
|
||||||
import { CustomerDiscountsPage } from './pages/CustomerDiscountsPage'
|
import { CustomerDiscountsPage } from './pages/CustomerDiscountsPage'
|
||||||
import { DiscountsPage } from './pages/DiscountsPage'
|
import { DiscountsPage } from './pages/DiscountsPage'
|
||||||
|
import { SpecialOrdersPage } from './pages/SpecialOrdersPage'
|
||||||
import { CustomerAddressesPage } from './pages/CustomerAddressesPage'
|
import { CustomerAddressesPage } from './pages/CustomerAddressesPage'
|
||||||
import {
|
import {
|
||||||
getAuthUser,
|
getAuthUser,
|
||||||
@@ -114,6 +116,14 @@ function AdminApp() {
|
|||||||
</RequireAdmin>
|
</RequireAdmin>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/products/specials"
|
||||||
|
element={
|
||||||
|
<RequireAdmin>
|
||||||
|
<ProductSpecialsPage />
|
||||||
|
</RequireAdmin>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/products/:productId/edit"
|
path="/products/:productId/edit"
|
||||||
element={
|
element={
|
||||||
@@ -146,6 +156,14 @@ function AdminApp() {
|
|||||||
</RequireAdmin>
|
</RequireAdmin>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/special-orders"
|
||||||
|
element={
|
||||||
|
<RequireAdmin>
|
||||||
|
<SpecialOrdersPage />
|
||||||
|
</RequireAdmin>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/discounts"
|
path="/discounts"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ export function CategoryOptionsModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
|
if (!category) return
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
.root {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1200;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border: none;
|
||||||
|
background: rgba(20, 12, 8, 0.78);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
position: absolute;
|
||||||
|
top: 18px;
|
||||||
|
inset-inline-end: 18px;
|
||||||
|
z-index: 2;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
color: var(--brown);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
z-index: 2;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
color: var(--brown);
|
||||||
|
transform: translateY(-50%);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navPrev {
|
||||||
|
inset-inline-start: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navNext {
|
||||||
|
inset-inline-end: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
max-width: min(920px, 100%);
|
||||||
|
max-height: calc(100vh - 48px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: calc(100vh - 48px);
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.counter {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 12px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
margin: 0;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.root {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navPrev {
|
||||||
|
inset-inline-start: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navNext {
|
||||||
|
inset-inline-end: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { ChevronLeft, ChevronRight, X } from 'lucide-react'
|
||||||
|
import styles from './ImageLightbox.module.css'
|
||||||
|
|
||||||
|
type ImageLightboxProps = {
|
||||||
|
images: string[]
|
||||||
|
index: number
|
||||||
|
alt?: string
|
||||||
|
onClose: () => void
|
||||||
|
onIndexChange: (index: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageLightbox({
|
||||||
|
images,
|
||||||
|
index,
|
||||||
|
alt = '',
|
||||||
|
onClose,
|
||||||
|
onIndexChange,
|
||||||
|
}: ImageLightboxProps) {
|
||||||
|
const safeIndex =
|
||||||
|
images.length === 0
|
||||||
|
? 0
|
||||||
|
: ((index % images.length) + images.length) % images.length
|
||||||
|
const current = images[safeIndex]
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!current) return
|
||||||
|
|
||||||
|
function onKey(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') onClose()
|
||||||
|
if (event.key === 'ArrowLeft') onIndexChange(safeIndex + 1)
|
||||||
|
if (event.key === 'ArrowRight') onIndexChange(safeIndex - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
const prevOverflow = document.body.style.overflow
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKey)
|
||||||
|
document.body.style.overflow = prevOverflow
|
||||||
|
}
|
||||||
|
}, [current, onClose, onIndexChange, safeIndex])
|
||||||
|
|
||||||
|
if (!current) return null
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className={styles.root} role="dialog" aria-modal="true" aria-label="گالری تصاویر">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.backdrop}
|
||||||
|
aria-label="بستن"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.close}
|
||||||
|
aria-label="بستن"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<X size={22} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{images.length > 1 && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.nav} ${styles.navPrev}`}
|
||||||
|
aria-label="قبلی"
|
||||||
|
onClick={() => onIndexChange(safeIndex + 1)}
|
||||||
|
>
|
||||||
|
<ChevronRight size={22} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.nav} ${styles.navNext}`}
|
||||||
|
aria-label="بعدی"
|
||||||
|
onClick={() => onIndexChange(safeIndex - 1)}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={22} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.stage}>
|
||||||
|
<img className={styles.image} src={current} alt={alt || `تصویر ${safeIndex + 1}`} />
|
||||||
|
{images.length > 1 && (
|
||||||
|
<p className={styles.counter}>
|
||||||
|
{safeIndex + 1} / {images.length}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -84,6 +84,7 @@ export function OrderLineItemModal({
|
|||||||
|
|
||||||
function handleSubmit(event: React.FormEvent) {
|
function handleSubmit(event: React.FormEvent) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
if (!product) return
|
||||||
const qty = Number(quantity)
|
const qty = Number(quantity)
|
||||||
const isWeight = product.sellUnit === 'kilo'
|
const isWeight = product.sellUnit === 'kilo'
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import { Pencil, SlidersHorizontal, Trash2, Eye } from 'lucide-react'
|
import { Pencil, SlidersHorizontal, Sparkles, Trash2, Eye } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
formatPriceParts,
|
formatPriceParts,
|
||||||
productImageSrc,
|
productImageSrc,
|
||||||
type Product,
|
type Product,
|
||||||
} from '../data/products'
|
} from '../data/products'
|
||||||
|
import { PRODUCT_SPECIALS } from './ProductSpecialsModal'
|
||||||
import styles from './ProductCard.module.css'
|
import styles from './ProductCard.module.css'
|
||||||
|
|
||||||
|
function countSpecialTags(tags: string[] | undefined) {
|
||||||
|
const list = tags ?? []
|
||||||
|
return PRODUCT_SPECIALS.filter((special) =>
|
||||||
|
list.some((tag) => tag === special.tag || tag.includes(special.tag)),
|
||||||
|
).length
|
||||||
|
}
|
||||||
|
|
||||||
type ProductCardProps = {
|
type ProductCardProps = {
|
||||||
product: Product
|
product: Product
|
||||||
index: number
|
index: number
|
||||||
@@ -14,7 +22,9 @@ type ProductCardProps = {
|
|||||||
onView?: (product: Product) => void
|
onView?: (product: Product) => void
|
||||||
onEdit?: (product: Product) => void
|
onEdit?: (product: Product) => void
|
||||||
onOpenOptions?: (product: Product) => void
|
onOpenOptions?: (product: Product) => void
|
||||||
|
onMakeSpecial?: (product: Product) => void
|
||||||
onRemove?: (product: Product) => void
|
onRemove?: (product: Product) => void
|
||||||
|
removeLabel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProductCard({
|
export function ProductCard({
|
||||||
@@ -25,12 +35,18 @@ export function ProductCard({
|
|||||||
onView,
|
onView,
|
||||||
onEdit,
|
onEdit,
|
||||||
onOpenOptions,
|
onOpenOptions,
|
||||||
|
onMakeSpecial,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
removeLabel = 'حذف',
|
||||||
}: ProductCardProps) {
|
}: ProductCardProps) {
|
||||||
const price = formatPriceParts(product)
|
const price = formatPriceParts(product)
|
||||||
const optionCount = new Set(
|
const optionCount = new Set(
|
||||||
(product.options ?? []).map((option) => option.flavorId),
|
(product.options ?? []).map((option) => option.flavorId),
|
||||||
).size
|
).size
|
||||||
|
const specialCount = countSpecialTags(product.tags)
|
||||||
|
const hasControls = Boolean(
|
||||||
|
onView || onEdit || onOpenOptions || onMakeSpecial || onRemove,
|
||||||
|
)
|
||||||
|
|
||||||
function stopAnd(handler?: (product: Product) => void) {
|
function stopAnd(handler?: (product: Product) => void) {
|
||||||
return (event: React.MouseEvent) => {
|
return (event: React.MouseEvent) => {
|
||||||
@@ -72,7 +88,9 @@ export function ProductCard({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{hasControls && (
|
||||||
<div className={styles.controls}>
|
<div className={styles.controls}>
|
||||||
|
{onView && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.iconBtn}
|
className={styles.iconBtn}
|
||||||
@@ -83,7 +101,9 @@ export function ProductCard({
|
|||||||
>
|
>
|
||||||
<Eye size={17} strokeWidth={1.75} />
|
<Eye size={17} strokeWidth={1.75} />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onEdit && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.iconBtn}
|
className={styles.iconBtn}
|
||||||
@@ -94,7 +114,9 @@ export function ProductCard({
|
|||||||
>
|
>
|
||||||
<Pencil size={17} strokeWidth={1.75} />
|
<Pencil size={17} strokeWidth={1.75} />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onOpenOptions && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.iconBtn}
|
className={styles.iconBtn}
|
||||||
@@ -110,18 +132,42 @@ export function ProductCard({
|
|||||||
<span className={styles.badge}>{optionCount}</span>
|
<span className={styles.badge}>{optionCount}</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onMakeSpecial && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.iconBtn}
|
||||||
|
aria-label={
|
||||||
|
specialCount > 0
|
||||||
|
? `ویژه کردن (${specialCount})`
|
||||||
|
: 'ویژه کردن'
|
||||||
|
}
|
||||||
|
data-tooltip="ویژه کردن"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={stopAnd(onMakeSpecial)}
|
||||||
|
>
|
||||||
|
<Sparkles size={17} strokeWidth={1.75} />
|
||||||
|
{specialCount > 0 && (
|
||||||
|
<span className={styles.badge}>{specialCount}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onRemove && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`${styles.iconBtn} ${styles.removeBtn}`}
|
className={`${styles.iconBtn} ${styles.removeBtn}`}
|
||||||
aria-label="حذف"
|
aria-label={removeLabel}
|
||||||
data-tooltip="حذف"
|
data-tooltip={removeLabel}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={stopAnd(onRemove)}
|
onClick={stopAnd(onRemove)}
|
||||||
>
|
>
|
||||||
<Trash2 size={17} strokeWidth={1.75} />
|
<Trash2 size={17} strokeWidth={1.75} />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ export function ProductOptionsModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
|
if (!product) return
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
.modal {
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.productName {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.productEn {
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-family: var(--font-en);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
direction: ltr;
|
||||||
|
text-align: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(155, 44, 44, 0.08);
|
||||||
|
color: #9b2c2c;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(143, 65, 12, 0.12);
|
||||||
|
background: rgba(255, 255, 255, 0.55);
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-align: start;
|
||||||
|
transition:
|
||||||
|
background 0.2s,
|
||||||
|
border-color 0.2s,
|
||||||
|
color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option:hover:not(:disabled) {
|
||||||
|
border-color: rgba(143, 65, 12, 0.28);
|
||||||
|
background: rgba(143, 65, 12, 0.06);
|
||||||
|
color: var(--brown);
|
||||||
|
}
|
||||||
|
|
||||||
|
.option:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionActive {
|
||||||
|
border-color: rgba(143, 65, 12, 0.35);
|
||||||
|
background: rgba(143, 65, 12, 0.08);
|
||||||
|
color: var(--brown);
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionLabel {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionStatus {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--brown);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { useEffect, useId, useState } from 'react'
|
||||||
|
import { Check, X } from 'lucide-react'
|
||||||
|
import type { Product } from '../data/products'
|
||||||
|
import { ApiError } from '../lib/api'
|
||||||
|
import styles from './CategoryModal.module.css'
|
||||||
|
import specialStyles from './ProductSpecialsModal.module.css'
|
||||||
|
|
||||||
|
export const PRODUCT_SPECIALS = [
|
||||||
|
{ tag: 'پیشنهادی', label: 'محصولات پیشنهادی', order: 1 },
|
||||||
|
{ tag: 'پرفروش', label: 'محصولات پر فروش', order: 2 },
|
||||||
|
{ tag: 'آماده', label: 'محصولات آماده ارسال', order: 3 },
|
||||||
|
{ tag: 'جدید', label: 'محصولات جدید', order: 4 },
|
||||||
|
{ tag: 'هدیه', label: 'محصولات مناسب هدیه', order: 5 },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type ProductSpecialTag = (typeof PRODUCT_SPECIALS)[number]['tag']
|
||||||
|
|
||||||
|
type ProductSpecialsModalProps = {
|
||||||
|
open: boolean
|
||||||
|
product: Product | null
|
||||||
|
onClose: () => void
|
||||||
|
onSelect: (productId: string, tag: ProductSpecialTag) => void | Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProductSpecialsModal({
|
||||||
|
open,
|
||||||
|
product,
|
||||||
|
onClose,
|
||||||
|
onSelect,
|
||||||
|
}: ProductSpecialsModalProps) {
|
||||||
|
const titleId = useId()
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [activeTag, setActiveTag] = useState<ProductSpecialTag | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
setSaving(false)
|
||||||
|
setError('')
|
||||||
|
setActiveTag(null)
|
||||||
|
}, [open, product?.id])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape' && !saving) onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [open, saving, onClose])
|
||||||
|
|
||||||
|
if (!open || !product) return null
|
||||||
|
|
||||||
|
const currentTags = product.tags ?? []
|
||||||
|
|
||||||
|
async function handleSelect(tag: ProductSpecialTag) {
|
||||||
|
if (!product || saving) return
|
||||||
|
setSaving(true)
|
||||||
|
setActiveTag(tag)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await onSelect(product.id, tag)
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err instanceof ApiError
|
||||||
|
? err.message
|
||||||
|
: 'افزودن به ویژه ناموفق بود.',
|
||||||
|
)
|
||||||
|
setSaving(false)
|
||||||
|
setActiveTag(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.overlay} onClick={onClose} role="presentation">
|
||||||
|
<div
|
||||||
|
className={`${styles.modal} ${specialStyles.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}>Make it Special</p>
|
||||||
|
<h2 id={titleId} className={styles.title}>
|
||||||
|
ویژه کردن محصول
|
||||||
|
</h2>
|
||||||
|
<p className={specialStyles.productName}>
|
||||||
|
{product.nameFa}
|
||||||
|
<span className={specialStyles.productEn}>{product.nameEn}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.closeBtn}
|
||||||
|
aria-label="بستن"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
<X size={18} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={specialStyles.body}>
|
||||||
|
{error && (
|
||||||
|
<p className={specialStyles.error} role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ul className={specialStyles.list}>
|
||||||
|
{PRODUCT_SPECIALS.map((special) => {
|
||||||
|
const alreadyAdded = currentTags.some(
|
||||||
|
(tag) => tag === special.tag || tag.includes(special.tag),
|
||||||
|
)
|
||||||
|
const isSavingThis = saving && activeTag === special.tag
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={special.tag}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${specialStyles.option} ${
|
||||||
|
alreadyAdded ? specialStyles.optionActive : ''
|
||||||
|
}`}
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => void handleSelect(special.tag)}
|
||||||
|
>
|
||||||
|
<span className={specialStyles.optionLabel}>
|
||||||
|
{special.label}
|
||||||
|
</span>
|
||||||
|
{isSavingThis ? (
|
||||||
|
<span className={specialStyles.optionStatus}>
|
||||||
|
در حال ذخیره...
|
||||||
|
</span>
|
||||||
|
) : alreadyAdded ? (
|
||||||
|
<span className={specialStyles.optionStatus}>
|
||||||
|
<Check size={15} strokeWidth={2} />
|
||||||
|
اضافه شده
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
.modal {
|
||||||
|
max-width: 720px;
|
||||||
|
width: min(720px, calc(100vw - 32px));
|
||||||
|
max-height: min(860px, calc(100vh - 48px));
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 4px 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userBlock {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone {
|
||||||
|
margin: 0;
|
||||||
|
width: max-content;
|
||||||
|
max-width: 100%;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
direction: ltr;
|
||||||
|
text-align: left;
|
||||||
|
unicode-bidi: isolate;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.85;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
text-align: justify;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metaRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbBtn {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border: 1px solid rgba(143, 65, 12, 0.14);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
|
background: rgba(255, 250, 250, 0.8);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbBtn img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbBtn:hover {
|
||||||
|
outline: 2px solid rgba(143, 65, 12, 0.28);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.metaRow {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { useEffect, useId, useState } from 'react'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
formatCellNumber,
|
||||||
|
formatUserName,
|
||||||
|
} from '../data/users'
|
||||||
|
import {
|
||||||
|
specialOrderCategoryLabel,
|
||||||
|
type SpecialOrder,
|
||||||
|
} from '../lib/specialOrdersApi'
|
||||||
|
import { ImageLightbox } from './ImageLightbox'
|
||||||
|
import styles from './CategoryModal.module.css'
|
||||||
|
import localStyles from './SpecialOrderDetailsModal.module.css'
|
||||||
|
|
||||||
|
type SpecialOrderDetailsModalProps = {
|
||||||
|
open: boolean
|
||||||
|
order: SpecialOrder | null
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(iso: string) {
|
||||||
|
const date = new Date(iso)
|
||||||
|
if (Number.isNaN(date.getTime())) return '—'
|
||||||
|
return new Intl.DateTimeFormat('fa-IR', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SpecialOrderDetailsModal({
|
||||||
|
open,
|
||||||
|
order,
|
||||||
|
onClose,
|
||||||
|
}: SpecialOrderDetailsModalProps) {
|
||||||
|
const titleId = useId()
|
||||||
|
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setLightboxIndex(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape' && lightboxIndex === null) onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [open, onClose, lightboxIndex])
|
||||||
|
|
||||||
|
if (!open || !order) return null
|
||||||
|
|
||||||
|
const images = order.images.map((item) => item.url)
|
||||||
|
const userName = formatUserName(order.user) || order.user.name
|
||||||
|
|
||||||
|
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}>Special Order</p>
|
||||||
|
<h2 id={titleId} className={styles.title}>
|
||||||
|
جزئیات سفارش ویژه
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.closeBtn}
|
||||||
|
aria-label="بستن"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<X size={18} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={localStyles.body}>
|
||||||
|
<div className={localStyles.block}>
|
||||||
|
<span className={localStyles.label}>نام</span>
|
||||||
|
<p className={localStyles.value}>{order.name}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={localStyles.metaRow}>
|
||||||
|
<div className={localStyles.block}>
|
||||||
|
<span className={localStyles.label}>دستهبندی</span>
|
||||||
|
<p className={localStyles.value}>
|
||||||
|
{specialOrderCategoryLabel[order.category]}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={localStyles.block}>
|
||||||
|
<span className={localStyles.label}>تاریخ و ساعت</span>
|
||||||
|
<p className={localStyles.value}>{formatDateTime(order.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`${localStyles.block} ${localStyles.userBlock}`}>
|
||||||
|
<p className={localStyles.value}>{userName}</p>
|
||||||
|
<p className={localStyles.phone} dir="ltr">
|
||||||
|
{formatCellNumber(order.user.cellNumber)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={localStyles.block}>
|
||||||
|
<span className={localStyles.label}>توضیحات</span>
|
||||||
|
<p className={localStyles.desc}>
|
||||||
|
{order.description.trim() || '—'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={localStyles.block}>
|
||||||
|
<span className={localStyles.label}>تصاویر</span>
|
||||||
|
{images.length === 0 ? (
|
||||||
|
<p className={localStyles.empty}>تصویری ثبت نشده است.</p>
|
||||||
|
) : (
|
||||||
|
<div className={localStyles.gallery}>
|
||||||
|
{images.map((src, index) => (
|
||||||
|
<button
|
||||||
|
key={`${order.id}-${index}`}
|
||||||
|
type="button"
|
||||||
|
className={localStyles.thumbBtn}
|
||||||
|
onClick={() => setLightboxIndex(index)}
|
||||||
|
aria-label={`باز کردن تصویر ${index + 1}`}
|
||||||
|
>
|
||||||
|
<img src={src} alt={`تصویر ${index + 1}`} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lightboxIndex !== null && (
|
||||||
|
<ImageLightbox
|
||||||
|
images={images}
|
||||||
|
index={lightboxIndex}
|
||||||
|
alt={order.name}
|
||||||
|
onClose={() => setLightboxIndex(null)}
|
||||||
|
onIndexChange={setLightboxIndex}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
.modal {
|
||||||
|
max-width: 720px;
|
||||||
|
width: min(720px, calc(100vw - 32px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colFull {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchAnchor {
|
||||||
|
position: relative;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userDropdown {
|
||||||
|
top: calc(100% + 2px);
|
||||||
|
border: none;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.categoryLabel {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedUser {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clearUser {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--brown);
|
||||||
|
text-decoration: underline;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radioRow {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radioOption {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: var(--field-height);
|
||||||
|
padding: 0 14px;
|
||||||
|
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-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radioOption input {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
accent-color: var(--brown);
|
||||||
|
}
|
||||||
|
|
||||||
|
.radioOption:has(input:checked) {
|
||||||
|
border-color: var(--brown);
|
||||||
|
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||||
|
color: var(--brown);
|
||||||
|
}
|
||||||
|
|
||||||
|
.colFull textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 110px;
|
||||||
|
padding: 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;
|
||||||
|
resize: vertical;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.colFull textarea:focus {
|
||||||
|
border-color: var(--brown);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.9),
|
||||||
|
0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
import { useEffect, useId, useRef, useState } from 'react'
|
||||||
|
import { ChevronDown, Search, X } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
formatCellNumber,
|
||||||
|
formatUserName,
|
||||||
|
type User,
|
||||||
|
} from '../data/users'
|
||||||
|
import { ApiError } from '../lib/api'
|
||||||
|
import {
|
||||||
|
dataUrlToBlob,
|
||||||
|
isLocalImageSrc,
|
||||||
|
uploadMedia,
|
||||||
|
} from '../lib/mediaApi'
|
||||||
|
import {
|
||||||
|
specialOrderCategories,
|
||||||
|
specialOrderCategoryLabel,
|
||||||
|
type SpecialOrder,
|
||||||
|
type SpecialOrderCategory,
|
||||||
|
} from '../lib/specialOrdersApi'
|
||||||
|
import { listUsers } from '../lib/usersApi'
|
||||||
|
import { ImageGallery } from './ImageGallery'
|
||||||
|
import styles from './CategoryModal.module.css'
|
||||||
|
import localStyles from './SpecialOrderModal.module.css'
|
||||||
|
|
||||||
|
export type SpecialOrderFormValues = {
|
||||||
|
userId: string
|
||||||
|
category: SpecialOrderCategory
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
images: Array<{ url: string; storageKey: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpecialOrderModalProps = {
|
||||||
|
open: boolean
|
||||||
|
editing?: SpecialOrder | null
|
||||||
|
onClose: () => void
|
||||||
|
onSubmit: (values: SpecialOrderFormValues) => void | Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SpecialOrderModal({
|
||||||
|
open,
|
||||||
|
editing = null,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: SpecialOrderModalProps) {
|
||||||
|
const titleId = useId()
|
||||||
|
const isEditing = Boolean(editing)
|
||||||
|
const userRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const [user, setUser] = useState<User | SpecialOrder['user'] | null>(null)
|
||||||
|
const [userQuery, setUserQuery] = useState('')
|
||||||
|
const [userOpen, setUserOpen] = useState(false)
|
||||||
|
const [userResults, setUserResults] = useState<User[]>([])
|
||||||
|
const [userLoading, setUserLoading] = useState(false)
|
||||||
|
const [category, setCategory] =
|
||||||
|
useState<SpecialOrderCategory>('specialCake')
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [description, setDescription] = useState('')
|
||||||
|
const [gallery, setGallery] = useState<string[]>([])
|
||||||
|
const [galleryKeys, setGalleryKeys] = useState<Record<string, string>>({})
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
if (editing) {
|
||||||
|
setUser(editing.user)
|
||||||
|
setUserQuery(formatUserName(editing.user) || editing.user.name)
|
||||||
|
setCategory(editing.category)
|
||||||
|
setName(editing.name)
|
||||||
|
setDescription(editing.description)
|
||||||
|
const urls = editing.images.map((item) => item.url)
|
||||||
|
setGallery(urls)
|
||||||
|
setGalleryKeys(
|
||||||
|
Object.fromEntries(
|
||||||
|
editing.images.map((item) => [item.url, item.storageKey]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
setUser(null)
|
||||||
|
setUserQuery('')
|
||||||
|
setCategory('specialCake')
|
||||||
|
setName('')
|
||||||
|
setDescription('')
|
||||||
|
setGallery([])
|
||||||
|
setGalleryKeys({})
|
||||||
|
}
|
||||||
|
setUserOpen(false)
|
||||||
|
setUserResults([])
|
||||||
|
setError('')
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}, [open, editing])
|
||||||
|
|
||||||
|
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])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handlePointerDown(event: MouseEvent) {
|
||||||
|
if (!userRef.current?.contains(event.target as Node)) {
|
||||||
|
setUserOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handlePointerDown)
|
||||||
|
return () => document.removeEventListener('mousedown', handlePointerDown)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !userOpen) return
|
||||||
|
const q = userQuery.trim()
|
||||||
|
if (q.length < 2) {
|
||||||
|
setUserResults([])
|
||||||
|
setUserLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
setUserLoading(true)
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const result = await listUsers({
|
||||||
|
q,
|
||||||
|
role: 'customer',
|
||||||
|
page: 1,
|
||||||
|
pageSize: 12,
|
||||||
|
})
|
||||||
|
if (!cancelled) setUserResults(result.items)
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setUserResults([])
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setUserLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}, 250)
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
window.clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [open, userOpen, userQuery])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
async function resolveGallery() {
|
||||||
|
const items: Array<{ url: string; storageKey: string }> = []
|
||||||
|
for (const src of gallery) {
|
||||||
|
if (!isLocalImageSrc(src)) {
|
||||||
|
const key = galleryKeys[src]
|
||||||
|
if (!key) throw new Error('کلید ذخیرهسازی تصویر یافت نشد')
|
||||||
|
items.push({ url: src, storageKey: key })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const blob = await dataUrlToBlob(src)
|
||||||
|
const uploaded = await uploadMedia('specialOrder', blob)
|
||||||
|
items.push({ url: uploaded.url, storageKey: uploaded.storageKey })
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(event: React.FormEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (isSubmitting) return
|
||||||
|
|
||||||
|
if (!user?.id) {
|
||||||
|
setError('انتخاب کاربر الزامی است')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!name.trim()) {
|
||||||
|
setError('نام الزامی است')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setError('')
|
||||||
|
setIsSubmitting(true)
|
||||||
|
try {
|
||||||
|
const images = await resolveGallery()
|
||||||
|
await onSubmit({
|
||||||
|
userId: user.id,
|
||||||
|
category,
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
images,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err instanceof ApiError
|
||||||
|
? err.message
|
||||||
|
: err instanceof Error
|
||||||
|
? err.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}>Special Orders</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.grid}>
|
||||||
|
<div
|
||||||
|
className={`${styles.field} ${localStyles.colFull}`}
|
||||||
|
ref={userRef}
|
||||||
|
>
|
||||||
|
<label htmlFor="special-order-user">کاربر</label>
|
||||||
|
<div className={localStyles.searchAnchor}>
|
||||||
|
<div className={styles.searchWrap}>
|
||||||
|
<Search size={16} className={styles.searchIcon} />
|
||||||
|
<input
|
||||||
|
id="special-order-user"
|
||||||
|
value={userQuery}
|
||||||
|
onChange={(e) => {
|
||||||
|
setUserQuery(e.target.value)
|
||||||
|
setUser(null)
|
||||||
|
setUserOpen(true)
|
||||||
|
}}
|
||||||
|
onFocus={() => setUserOpen(true)}
|
||||||
|
placeholder="جستجو با نام یا موبایل"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
autoComplete="off"
|
||||||
|
autoFocus={!isEditing}
|
||||||
|
/>
|
||||||
|
<ChevronDown size={16} className={styles.chevron} />
|
||||||
|
</div>
|
||||||
|
{userOpen && userQuery.trim().length >= 2 && (
|
||||||
|
<ul
|
||||||
|
className={`${styles.dropdown} ${localStyles.userDropdown}`}
|
||||||
|
role="listbox"
|
||||||
|
>
|
||||||
|
{userLoading ? (
|
||||||
|
<li className={styles.emptyOption}>در حال جستجو...</li>
|
||||||
|
) : userResults.length === 0 ? (
|
||||||
|
<li className={styles.emptyOption}>کاربری یافت نشد</li>
|
||||||
|
) : (
|
||||||
|
userResults.map((item) => (
|
||||||
|
<li key={item.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.option}
|
||||||
|
onClick={() => {
|
||||||
|
setUser(item)
|
||||||
|
setUserQuery(formatUserName(item))
|
||||||
|
setUserOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className={styles.optionFa}>
|
||||||
|
{formatUserName(item)}
|
||||||
|
</span>
|
||||||
|
<span className={styles.optionEn} dir="ltr">
|
||||||
|
{formatCellNumber(item.cellNumber)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{user ? (
|
||||||
|
<div className={localStyles.selectedRow}>
|
||||||
|
<p className={localStyles.selectedUser} dir="ltr">
|
||||||
|
{formatCellNumber(user.cellNumber)}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={localStyles.clearUser}
|
||||||
|
onClick={() => {
|
||||||
|
setUser(null)
|
||||||
|
setUserQuery('')
|
||||||
|
}}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
حذف کاربر
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className={localStyles.selectedUser}>
|
||||||
|
{userQuery.trim().length > 0 && userQuery.trim().length < 2
|
||||||
|
? 'حداقل ۲ حرف وارد کنید'
|
||||||
|
: 'کاربر را از نتایج جستجو انتخاب کنید'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`${styles.field} ${localStyles.colFull}`}>
|
||||||
|
<span className={localStyles.categoryLabel}>دستهبندی</span>
|
||||||
|
<div className={localStyles.radioRow} role="radiogroup">
|
||||||
|
{specialOrderCategories.map((item) => (
|
||||||
|
<label key={item} className={localStyles.radioOption}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="special-order-category"
|
||||||
|
value={item}
|
||||||
|
checked={category === item}
|
||||||
|
onChange={() => setCategory(item)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
<span>{specialOrderCategoryLabel[item]}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`${styles.field} ${localStyles.colFull}`}>
|
||||||
|
<label htmlFor="special-order-name">نام</label>
|
||||||
|
<input
|
||||||
|
id="special-order-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="مثلاً کیک تولد سفارشی"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`${styles.field} ${localStyles.colFull}`}>
|
||||||
|
<label htmlFor="special-order-desc">توضیحات</label>
|
||||||
|
<textarea
|
||||||
|
id="special-order-desc"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="جزئیات سفارش، سلیقه، زمان تحویل و ..."
|
||||||
|
rows={4}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={localStyles.colFull}>
|
||||||
|
<ImageGallery
|
||||||
|
label="گالری تصاویر"
|
||||||
|
images={gallery}
|
||||||
|
onChange={setGallery}
|
||||||
|
/>
|
||||||
|
</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 ? 'در حال ذخیره...' : isEditing ? 'ذخیره' : 'ثبت'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -40,7 +40,7 @@ export function UserModal({
|
|||||||
}: UserModalProps) {
|
}: UserModalProps) {
|
||||||
const titleId = useId()
|
const titleId = useId()
|
||||||
const isEditing = Boolean(editingUser)
|
const isEditing = Boolean(editingUser)
|
||||||
const [title, setTitle] = useState<UserTitle>(userTitles[0])
|
const [title, setTitle] = useState<UserTitle>('')
|
||||||
const [firstName, setFirstName] = useState('')
|
const [firstName, setFirstName] = useState('')
|
||||||
const [lastName, setLastName] = useState('')
|
const [lastName, setLastName] = useState('')
|
||||||
const [category, setCategory] = useState<UserCategory | ''>('')
|
const [category, setCategory] = useState<UserCategory | ''>('')
|
||||||
@@ -53,13 +53,13 @@ export function UserModal({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
if (editingUser) {
|
if (editingUser) {
|
||||||
setTitle(editingUser.title)
|
setTitle(editingUser.title || '')
|
||||||
setFirstName(editingUser.firstName)
|
setFirstName(editingUser.firstName)
|
||||||
setLastName(editingUser.lastName)
|
setLastName(editingUser.lastName)
|
||||||
setCategory(editingUser.category ?? '')
|
setCategory(editingUser.category ?? '')
|
||||||
setCellNumber(editingUser.cellNumber)
|
setCellNumber(editingUser.cellNumber)
|
||||||
} else {
|
} else {
|
||||||
setTitle(userTitles[0])
|
setTitle('')
|
||||||
setFirstName('')
|
setFirstName('')
|
||||||
setLastName('')
|
setLastName('')
|
||||||
setCategory('')
|
setCategory('')
|
||||||
@@ -195,6 +195,7 @@ export function UserModal({
|
|||||||
onChange={(e) => setTitle(e.target.value as UserTitle)}
|
onChange={(e) => setTitle(e.target.value as UserTitle)}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
|
<option value="">بدون عنوان</option>
|
||||||
{userTitles.map((item) => (
|
{userTitles.map((item) => (
|
||||||
<option key={item} value={item}>
|
<option key={item} value={item}>
|
||||||
{item}
|
{item}
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export type Product = {
|
|||||||
category: ProductCategoryRef
|
category: ProductCategoryRef
|
||||||
intro?: string
|
intro?: string
|
||||||
description?: string
|
description?: string
|
||||||
|
/** null / undefined = ready immediately */
|
||||||
|
preparationHours?: number | null
|
||||||
tags?: string[]
|
tags?: string[]
|
||||||
mainImageUrl: string | null
|
mainImageUrl: string | null
|
||||||
mainImageKey?: string | null
|
mainImageKey?: string | null
|
||||||
|
|||||||
+14
-1
@@ -9,6 +9,7 @@ export type UserTitle =
|
|||||||
| 'خانم دکتر'
|
| 'خانم دکتر'
|
||||||
| 'جناب آقای مهندس'
|
| 'جناب آقای مهندس'
|
||||||
| 'سرکار خانم مهندس'
|
| 'سرکار خانم مهندس'
|
||||||
|
| ''
|
||||||
|
|
||||||
export type User = {
|
export type User = {
|
||||||
id: string
|
id: string
|
||||||
@@ -25,7 +26,7 @@ export type User = {
|
|||||||
|
|
||||||
export const userCategories: UserCategory[] = ['پزشک', 'کسبه', 'مهندس']
|
export const userCategories: UserCategory[] = ['پزشک', 'کسبه', 'مهندس']
|
||||||
|
|
||||||
export const userTitles: UserTitle[] = [
|
export const userTitles: Exclude<UserTitle, ''>[] = [
|
||||||
'سرکار خانم',
|
'سرکار خانم',
|
||||||
'جناب آقای',
|
'جناب آقای',
|
||||||
'جناب دکتر',
|
'جناب دکتر',
|
||||||
@@ -34,6 +35,18 @@ export const userTitles: UserTitle[] = [
|
|||||||
'سرکار خانم مهندس',
|
'سرکار خانم مهندس',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/** Full display name; omits title when none is saved. */
|
||||||
|
export function formatUserName(user: {
|
||||||
|
title?: string | null
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
}) {
|
||||||
|
return [user.title, user.firstName, user.lastName]
|
||||||
|
.map((part) => part?.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
/** Strip honorific title prefix from a full display name. */
|
/** Strip honorific title prefix from a full display name. */
|
||||||
export function stripUserTitle(fullName: string) {
|
export function stripUserTitle(fullName: string) {
|
||||||
const sorted = [...userTitles].sort((a, b) => b.length - a.length)
|
const sorted = [...userTitles].sort((a, b) => b.length - a.length)
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import { apiUpload } from './api'
|
import { apiUpload } from './api'
|
||||||
|
|
||||||
export type MediaKind = 'main' | 'gallery' | 'temp'
|
export type MediaKind = 'main' | 'gallery' | 'temp' | 'specialOrder'
|
||||||
|
|
||||||
export type MediaUploadResult = {
|
export type MediaUploadResult = {
|
||||||
url: string
|
url: string
|
||||||
|
|||||||
+23
-1
@@ -1,11 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
CakeSlice,
|
CakeSlice,
|
||||||
|
ClipboardPen,
|
||||||
FolderTree,
|
FolderTree,
|
||||||
MapPin,
|
MapPin,
|
||||||
Package,
|
Package,
|
||||||
Percent,
|
Percent,
|
||||||
PlusCircle,
|
PlusCircle,
|
||||||
Settings,
|
Settings,
|
||||||
|
Sparkles,
|
||||||
UserRound,
|
UserRound,
|
||||||
Users,
|
Users,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
@@ -60,6 +62,14 @@ export const productTiles: ProductTile[] = [
|
|||||||
href: '/products/categories',
|
href: '/products/categories',
|
||||||
icon: FolderTree,
|
icon: FolderTree,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'products-specials',
|
||||||
|
title: 'محصولات ویژه',
|
||||||
|
titleEn: 'Special Products',
|
||||||
|
description: 'مشاهده و مدیریت محصولات ویژه فروشگاه',
|
||||||
|
href: '/products/specials',
|
||||||
|
icon: Sparkles,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export const navSections: NavSection[] = [
|
export const navSections: NavSection[] = [
|
||||||
@@ -96,6 +106,15 @@ export const navSections: NavSection[] = [
|
|||||||
icon: Package,
|
icon: Package,
|
||||||
items: [],
|
items: [],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'special-orders',
|
||||||
|
title: 'سفارشات ویژه',
|
||||||
|
titleEn: 'Special Orders',
|
||||||
|
description: 'درخواستهای سفارشی کیک و شیرینی بدون پرداخت',
|
||||||
|
href: '/special-orders',
|
||||||
|
icon: ClipboardPen,
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'discounts',
|
id: 'discounts',
|
||||||
title: 'کدهای تخفیف',
|
title: 'کدهای تخفیف',
|
||||||
@@ -118,7 +137,10 @@ export const navSections: NavSection[] = [
|
|||||||
|
|
||||||
/** Header links — settings & discounts stay on home tiles only. */
|
/** Header links — settings & discounts stay on home tiles only. */
|
||||||
export const adminHeaderSections: NavSection[] = navSections.filter(
|
export const adminHeaderSections: NavSection[] = navSections.filter(
|
||||||
(section) => section.id !== 'settings' && section.id !== 'discounts',
|
(section) =>
|
||||||
|
section.id !== 'settings' &&
|
||||||
|
section.id !== 'discounts' &&
|
||||||
|
section.id !== 'special-orders',
|
||||||
)
|
)
|
||||||
|
|
||||||
export const customerNavSections: NavSection[] = [
|
export const customerNavSections: NavSection[] = [
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export type ListProductsParams = {
|
|||||||
categoryId?: string
|
categoryId?: string
|
||||||
minPrice?: number
|
minPrice?: number
|
||||||
maxPrice?: number
|
maxPrice?: number
|
||||||
|
tag?: string
|
||||||
page?: number
|
page?: number
|
||||||
pageSize?: number
|
pageSize?: number
|
||||||
}
|
}
|
||||||
@@ -41,6 +42,8 @@ export type CreateProductPayload = {
|
|||||||
sellUnit: SellUnit
|
sellUnit: SellUnit
|
||||||
intro?: string
|
intro?: string
|
||||||
description?: string
|
description?: string
|
||||||
|
/** null = ready immediately (no prep time) */
|
||||||
|
preparationHours?: number | null
|
||||||
tags?: string[]
|
tags?: string[]
|
||||||
mainImageUrl?: string
|
mainImageUrl?: string
|
||||||
mainImageKey?: string
|
mainImageKey?: string
|
||||||
@@ -54,6 +57,7 @@ function buildQuery(params: ListProductsParams) {
|
|||||||
const search = new URLSearchParams()
|
const search = new URLSearchParams()
|
||||||
if (params.q?.trim()) search.set('q', params.q.trim())
|
if (params.q?.trim()) search.set('q', params.q.trim())
|
||||||
if (params.categoryId) search.set('categoryId', params.categoryId)
|
if (params.categoryId) search.set('categoryId', params.categoryId)
|
||||||
|
if (params.tag?.trim()) search.set('tag', params.tag.trim())
|
||||||
if (params.minPrice !== undefined) search.set('minPrice', String(params.minPrice))
|
if (params.minPrice !== undefined) search.set('minPrice', String(params.minPrice))
|
||||||
if (params.maxPrice !== undefined) search.set('maxPrice', String(params.maxPrice))
|
if (params.maxPrice !== undefined) search.set('maxPrice', String(params.maxPrice))
|
||||||
if (params.page) search.set('page', String(params.page))
|
if (params.page) search.set('page', String(params.page))
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { apiRequest } from './api'
|
||||||
|
|
||||||
|
export type SpecialOrderCategory = 'specialCake' | 'specialPastry'
|
||||||
|
|
||||||
|
export type SpecialOrderImage = {
|
||||||
|
id?: string
|
||||||
|
url: string
|
||||||
|
storageKey: string
|
||||||
|
sortOrder?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SpecialOrderUser = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
cellNumber: string
|
||||||
|
role: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SpecialOrder = {
|
||||||
|
id: string
|
||||||
|
userId: string
|
||||||
|
category: SpecialOrderCategory
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
images: SpecialOrderImage[]
|
||||||
|
user: SpecialOrderUser
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SpecialOrderListResponse = {
|
||||||
|
items: SpecialOrder[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListSpecialOrdersParams = {
|
||||||
|
q?: string
|
||||||
|
userId?: string
|
||||||
|
category?: SpecialOrderCategory
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SpecialOrderPayload = {
|
||||||
|
userId: string
|
||||||
|
category: SpecialOrderCategory
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
images?: Array<{ url: string; storageKey: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateSpecialOrderPayload = Partial<SpecialOrderPayload>
|
||||||
|
|
||||||
|
export const specialOrderCategoryLabel: Record<SpecialOrderCategory, string> = {
|
||||||
|
specialCake: 'کیک ویژه',
|
||||||
|
specialPastry: 'شیرینی ویژه',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const specialOrderCategories: SpecialOrderCategory[] = [
|
||||||
|
'specialCake',
|
||||||
|
'specialPastry',
|
||||||
|
]
|
||||||
|
|
||||||
|
function buildQuery(params: ListSpecialOrdersParams) {
|
||||||
|
const search = new URLSearchParams()
|
||||||
|
if (params.q?.trim()) search.set('q', params.q.trim())
|
||||||
|
if (params.userId) search.set('userId', params.userId)
|
||||||
|
if (params.category) search.set('category', params.category)
|
||||||
|
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 listSpecialOrders(params: ListSpecialOrdersParams = {}) {
|
||||||
|
return apiRequest<SpecialOrderListResponse>(
|
||||||
|
`/special-orders${buildQuery(params)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSpecialOrder(id: string) {
|
||||||
|
return apiRequest<SpecialOrder>(`/special-orders/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSpecialOrder(payload: SpecialOrderPayload) {
|
||||||
|
return apiRequest<SpecialOrder>('/special-orders', {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSpecialOrder(
|
||||||
|
id: string,
|
||||||
|
payload: UpdateSpecialOrderPayload,
|
||||||
|
) {
|
||||||
|
return apiRequest<SpecialOrder>(`/special-orders/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteSpecialOrder(id: string) {
|
||||||
|
return apiRequest<{ ok: true }>(`/special-orders/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
}
|
||||||
+1
-1
@@ -21,7 +21,7 @@ export type ListUsersParams = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type CreateUserPayload = {
|
export type CreateUserPayload = {
|
||||||
title: UserTitle
|
title?: UserTitle
|
||||||
firstName: string
|
firstName: string
|
||||||
lastName: string
|
lastName: string
|
||||||
category?: UserCategory | null
|
category?: UserCategory | null
|
||||||
|
|||||||
@@ -139,6 +139,97 @@
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prepRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prepInputWrap {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 110px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prepInputWrap input {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prepUnit {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputBlurred {
|
||||||
|
filter: blur(2.5px);
|
||||||
|
opacity: 0.55;
|
||||||
|
transition:
|
||||||
|
filter 0.2s,
|
||||||
|
opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prepSwitchWrap {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: var(--field-height);
|
||||||
|
padding: 0 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prepSwitchText {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch {
|
||||||
|
position: relative;
|
||||||
|
width: 42px;
|
||||||
|
height: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(143, 65, 12, 0.18);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switchOn .switchThumb {
|
||||||
|
inset-inline-start: 21px;
|
||||||
|
}
|
||||||
|
|
||||||
.searchWrap {
|
.searchWrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ export function AddProductPage() {
|
|||||||
const [categoryId, setCategoryId] = useState<string | null>(null)
|
const [categoryId, setCategoryId] = useState<string | null>(null)
|
||||||
const [nameFa, setNameFa] = useState('')
|
const [nameFa, setNameFa] = useState('')
|
||||||
const [nameEn, setNameEn] = useState('')
|
const [nameEn, setNameEn] = useState('')
|
||||||
|
const [readyImmediately, setReadyImmediately] = useState(true)
|
||||||
|
const [preparationHours, setPreparationHours] = useState('')
|
||||||
const [sellUnit, setSellUnit] = useState<SellUnit>('unit')
|
const [sellUnit, setSellUnit] = useState<SellUnit>('unit')
|
||||||
const [price, setPrice] = useState('')
|
const [price, setPrice] = useState('')
|
||||||
const [intro, setIntro] = useState('')
|
const [intro, setIntro] = useState('')
|
||||||
@@ -130,6 +132,13 @@ export function AddProductPage() {
|
|||||||
setCategoryId(product.categoryId)
|
setCategoryId(product.categoryId)
|
||||||
setSellUnit(product.sellUnit)
|
setSellUnit(product.sellUnit)
|
||||||
setPrice(String(product.price))
|
setPrice(String(product.price))
|
||||||
|
if (product.preparationHours != null && product.preparationHours > 0) {
|
||||||
|
setReadyImmediately(false)
|
||||||
|
setPreparationHours(String(product.preparationHours))
|
||||||
|
} else {
|
||||||
|
setReadyImmediately(true)
|
||||||
|
setPreparationHours('')
|
||||||
|
}
|
||||||
setIntro(product.intro ?? '')
|
setIntro(product.intro ?? '')
|
||||||
setDescription(product.description ?? '')
|
setDescription(product.description ?? '')
|
||||||
setTags(product.tags ?? [])
|
setTags(product.tags ?? [])
|
||||||
@@ -213,6 +222,15 @@ export function AddProductPage() {
|
|||||||
setError('نام فارسی و انگلیسی الزامی است')
|
setError('نام فارسی و انگلیسی الزامی است')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
let prepHours: number | null = null
|
||||||
|
if (!readyImmediately) {
|
||||||
|
const hours = Number(preparationHours)
|
||||||
|
if (!Number.isInteger(hours) || hours < 1) {
|
||||||
|
setError('زمان آمادهسازی باید حداقل ۱ ساعت باشد')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prepHours = hours
|
||||||
|
}
|
||||||
const numericPrice = parsePriceNumber(price)
|
const numericPrice = parsePriceNumber(price)
|
||||||
if (numericPrice === null || numericPrice <= 0) {
|
if (numericPrice === null || numericPrice <= 0) {
|
||||||
setError('قیمت معتبر الزامی است')
|
setError('قیمت معتبر الزامی است')
|
||||||
@@ -232,6 +250,7 @@ export function AddProductPage() {
|
|||||||
categoryId,
|
categoryId,
|
||||||
price: numericPrice,
|
price: numericPrice,
|
||||||
sellUnit,
|
sellUnit,
|
||||||
|
preparationHours: prepHours,
|
||||||
intro: intro.trim(),
|
intro: intro.trim(),
|
||||||
description,
|
description,
|
||||||
tags,
|
tags,
|
||||||
@@ -424,6 +443,52 @@ export function AddProductPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className={pageStyles.field}>
|
||||||
|
<label htmlFor="product-prep-hours">زمان آمادهسازی</label>
|
||||||
|
<div className={pageStyles.prepRow}>
|
||||||
|
<div className={pageStyles.prepSwitchWrap}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="product-ready-immediately"
|
||||||
|
className={`${pageStyles.switch} ${readyImmediately ? pageStyles.switchOn : ''}`}
|
||||||
|
role="switch"
|
||||||
|
aria-checked={readyImmediately}
|
||||||
|
aria-label="اماده ارسال"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => {
|
||||||
|
setReadyImmediately((prev) => {
|
||||||
|
if (prev) {
|
||||||
|
if (!preparationHours) setPreparationHours('1')
|
||||||
|
}
|
||||||
|
return !prev
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className={pageStyles.switchThumb} />
|
||||||
|
</button>
|
||||||
|
<span className={pageStyles.prepSwitchText}>
|
||||||
|
اماده ارسال
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`${pageStyles.prepInputWrap} ${readyImmediately ? pageStyles.inputBlurred : ''}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="product-prep-hours"
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={1}
|
||||||
|
step={1}
|
||||||
|
placeholder="۱"
|
||||||
|
value={preparationHours}
|
||||||
|
onChange={(e) => setPreparationHours(e.target.value)}
|
||||||
|
disabled={saving || readyImmediately}
|
||||||
|
/>
|
||||||
|
<span className={pageStyles.prepUnit}>ساعت</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={pageStyles.priceRow}>
|
<div className={pageStyles.priceRow}>
|
||||||
<div className={pageStyles.field}>
|
<div className={pageStyles.field}>
|
||||||
<label htmlFor="product-sell-unit">نوع قیمت</label>
|
<label htmlFor="product-sell-unit">نوع قیمت</label>
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
.tabs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: rgba(143, 65, 12, 0.06);
|
||||||
|
border: 1px solid rgba(143, 65, 12, 0.08);
|
||||||
|
animation: fadeUp 0.5s var(--ease-out) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab,
|
||||||
|
.tabActive {
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: calc(var(--radius-sm) - 2px);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.35;
|
||||||
|
transition:
|
||||||
|
background 0.2s,
|
||||||
|
color 0.2s,
|
||||||
|
box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover:not(:disabled) {
|
||||||
|
color: var(--brown);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabActive {
|
||||||
|
color: var(--brown);
|
||||||
|
background: rgba(255, 250, 250, 0.95);
|
||||||
|
box-shadow: 0 4px 12px rgba(143, 65, 12, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.tabs {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.tabs {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab,
|
||||||
|
.tabActive {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
import { ChevronRight } from 'lucide-react'
|
||||||
|
import { Header } from '../components/Header'
|
||||||
|
import { ProductCard } from '../components/ProductCard'
|
||||||
|
import {
|
||||||
|
PRODUCT_SPECIALS,
|
||||||
|
type ProductSpecialTag,
|
||||||
|
} from '../components/ProductSpecialsModal'
|
||||||
|
import type { Product } from '../data/products'
|
||||||
|
import { ApiError } from '../lib/api'
|
||||||
|
import { clearSession } from '../lib/auth'
|
||||||
|
import { redirectToLogin } from '../lib/host'
|
||||||
|
import { listProducts, updateProduct } from '../lib/productsApi'
|
||||||
|
import styles from './HomePage.module.css'
|
||||||
|
import listStyles from './ProductsListPage.module.css'
|
||||||
|
import pageStyles from './ProductSpecialsPage.module.css'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 48
|
||||||
|
|
||||||
|
function matchesSpecialTag(tags: string[] | undefined, tag: ProductSpecialTag) {
|
||||||
|
return (tags ?? []).some((item) => item === tag || item.includes(tag))
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSpecialTag(tags: string[] | undefined, tag: ProductSpecialTag) {
|
||||||
|
return (tags ?? []).filter((item) => item !== tag && !item.includes(tag))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProductSpecialsPage() {
|
||||||
|
const location = useLocation()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [activeTab, setActiveTab] = useState<ProductSpecialTag>(
|
||||||
|
PRODUCT_SPECIALS[0].tag,
|
||||||
|
)
|
||||||
|
const [visible, setVisible] = useState(false)
|
||||||
|
const [products, setProducts] = useState<Product[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const activeSpecial =
|
||||||
|
PRODUCT_SPECIALS.find((item) => item.tag === activeTab) ??
|
||||||
|
PRODUCT_SPECIALS[0]
|
||||||
|
|
||||||
|
const handleAuthError = useCallback((err: unknown) => {
|
||||||
|
if (err instanceof ApiError && err.status === 401) {
|
||||||
|
clearSession()
|
||||||
|
redirectToLogin()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadProducts = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const result = await listProducts({
|
||||||
|
tag: activeTab,
|
||||||
|
page: 1,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
})
|
||||||
|
setProducts(
|
||||||
|
result.items
|
||||||
|
.filter((item) => matchesSpecialTag(item.tags, activeTab))
|
||||||
|
.map((item) => ({
|
||||||
|
...item,
|
||||||
|
options: item.options ?? [],
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
if (handleAuthError(err)) return
|
||||||
|
setProducts([])
|
||||||
|
setError(
|
||||||
|
err instanceof ApiError
|
||||||
|
? err.message
|
||||||
|
: 'بارگذاری محصولات ویژه ناموفق بود.',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [activeTab, handleAuthError])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadProducts()
|
||||||
|
}, [loadProducts])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setVisible(false)
|
||||||
|
const frame = window.requestAnimationFrame(() => setVisible(true))
|
||||||
|
return () => window.cancelAnimationFrame(frame)
|
||||||
|
}, [location.key, products, activeTab])
|
||||||
|
|
||||||
|
function handleView(product: Product) {
|
||||||
|
navigate(`/products/${product.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEdit(product: Product) {
|
||||||
|
navigate(`/products/${product.id}/edit`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemove(product: Product) {
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
`محصول «${product.nameFa}» از «${activeSpecial.label}» حذف شود؟`,
|
||||||
|
)
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
setBusyId(product.id)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await updateProduct(product.id, {
|
||||||
|
tags: removeSpecialTag(product.tags, activeTab),
|
||||||
|
})
|
||||||
|
setProducts((current) =>
|
||||||
|
current.filter((item) => item.id !== product.id),
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
if (handleAuthError(err)) return
|
||||||
|
setError(
|
||||||
|
err instanceof ApiError
|
||||||
|
? err.message
|
||||||
|
: 'حذف از محصولات ویژه ناموفق بود.',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setBusyId(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>
|
||||||
|
Special 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>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={pageStyles.tabs}
|
||||||
|
role="tablist"
|
||||||
|
aria-label="بخشهای ویژه"
|
||||||
|
>
|
||||||
|
{PRODUCT_SPECIALS.slice()
|
||||||
|
.sort((a, b) => a.order - b.order)
|
||||||
|
.map((special) => {
|
||||||
|
const selected = special.tag === activeTab
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={special.tag}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={selected}
|
||||||
|
className={selected ? pageStyles.tabActive : pageStyles.tab}
|
||||||
|
onClick={() => setActiveTab(special.tag)}
|
||||||
|
>
|
||||||
|
{special.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className={listStyles.empty} role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className={listStyles.empty}>در حال بارگذاری...</p>
|
||||||
|
) : products.length === 0 ? (
|
||||||
|
<p className={listStyles.empty}>
|
||||||
|
محصولی در «{activeSpecial.label}» ثبت نشده است.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<section
|
||||||
|
className={listStyles.grid}
|
||||||
|
role="tabpanel"
|
||||||
|
aria-label={activeSpecial.label}
|
||||||
|
>
|
||||||
|
{products.map((product, index) => (
|
||||||
|
<ProductCard
|
||||||
|
key={`${activeTab}-${product.id}`}
|
||||||
|
product={product}
|
||||||
|
index={index}
|
||||||
|
visible={visible}
|
||||||
|
busy={busyId === product.id}
|
||||||
|
onView={handleView}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onRemove={handleRemove}
|
||||||
|
removeLabel="حذف از ویژه"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,6 +5,10 @@ import { Header } from '../components/Header'
|
|||||||
import { ProductCard } from '../components/ProductCard'
|
import { ProductCard } from '../components/ProductCard'
|
||||||
import { PriceInput } from '../components/PriceInput'
|
import { PriceInput } from '../components/PriceInput'
|
||||||
import { ProductOptionsModal } from '../components/ProductOptionsModal'
|
import { ProductOptionsModal } from '../components/ProductOptionsModal'
|
||||||
|
import {
|
||||||
|
ProductSpecialsModal,
|
||||||
|
type ProductSpecialTag,
|
||||||
|
} from '../components/ProductSpecialsModal'
|
||||||
import {
|
import {
|
||||||
flattenCategories,
|
flattenCategories,
|
||||||
type Category,
|
type Category,
|
||||||
@@ -48,6 +52,7 @@ export function ProductsListPage() {
|
|||||||
const [draftFilters, setDraftFilters] = useState<Filters>(emptyFilters)
|
const [draftFilters, setDraftFilters] = useState<Filters>(emptyFilters)
|
||||||
const [appliedFilters, setAppliedFilters] = useState<Filters>(emptyFilters)
|
const [appliedFilters, setAppliedFilters] = useState<Filters>(emptyFilters)
|
||||||
const [optionsProduct, setOptionsProduct] = useState<Product | null>(null)
|
const [optionsProduct, setOptionsProduct] = useState<Product | null>(null)
|
||||||
|
const [specialsProduct, setSpecialsProduct] = useState<Product | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [busyId, setBusyId] = useState<string | null>(null)
|
const [busyId, setBusyId] = useState<string | null>(null)
|
||||||
@@ -161,6 +166,7 @@ export function ProductsListPage() {
|
|||||||
current.filter((item) => item.id !== product.id),
|
current.filter((item) => item.id !== product.id),
|
||||||
)
|
)
|
||||||
if (optionsProduct?.id === product.id) setOptionsProduct(null)
|
if (optionsProduct?.id === product.id) setOptionsProduct(null)
|
||||||
|
if (specialsProduct?.id === product.id) setSpecialsProduct(null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (handleAuthError(err)) return
|
if (handleAuthError(err)) return
|
||||||
setError(
|
setError(
|
||||||
@@ -188,6 +194,34 @@ export function ProductsListPage() {
|
|||||||
setOptionsProduct(null)
|
setOptionsProduct(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleAddSpecial(
|
||||||
|
productId: string,
|
||||||
|
tag: ProductSpecialTag,
|
||||||
|
) {
|
||||||
|
const product =
|
||||||
|
products.find((item) => item.id === productId) ?? specialsProduct
|
||||||
|
if (!product) return
|
||||||
|
|
||||||
|
const existing = product.tags ?? []
|
||||||
|
const alreadyHas = existing.some(
|
||||||
|
(item) => item === tag || item.includes(tag),
|
||||||
|
)
|
||||||
|
const nextTags = alreadyHas ? existing : [...existing, tag]
|
||||||
|
|
||||||
|
if (!alreadyHas) {
|
||||||
|
const updated = await updateProduct(productId, { tags: nextTags })
|
||||||
|
setProducts((current) =>
|
||||||
|
current.map((item) =>
|
||||||
|
item.id === productId
|
||||||
|
? { ...updated, options: updated.options ?? [] }
|
||||||
|
: item,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
setSpecialsProduct(null)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.page}>
|
<div className={styles.page}>
|
||||||
<Header />
|
<Header />
|
||||||
@@ -289,6 +323,7 @@ export function ProductsListPage() {
|
|||||||
onView={handleView}
|
onView={handleView}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onOpenOptions={setOptionsProduct}
|
onOpenOptions={setOptionsProduct}
|
||||||
|
onMakeSpecial={setSpecialsProduct}
|
||||||
onRemove={handleRemove}
|
onRemove={handleRemove}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -316,6 +351,18 @@ export function ProductsListPage() {
|
|||||||
onClose={() => setOptionsProduct(null)}
|
onClose={() => setOptionsProduct(null)}
|
||||||
onSave={handleSaveOptions}
|
onSave={handleSaveOptions}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ProductSpecialsModal
|
||||||
|
open={Boolean(specialsProduct)}
|
||||||
|
product={
|
||||||
|
specialsProduct
|
||||||
|
? (products.find((item) => item.id === specialsProduct.id) ??
|
||||||
|
specialsProduct)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
onClose={() => setSpecialsProduct(null)}
|
||||||
|
onSelect={handleAddSpecial}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ export function ProductsPage() {
|
|||||||
</p>
|
</p>
|
||||||
<h1 className={styles.headline}>محصولات من</h1>
|
<h1 className={styles.headline}>محصولات من</h1>
|
||||||
<p className={styles.lead}>
|
<p className={styles.lead}>
|
||||||
محصولات، ثبت محصول جدید و دستهبندیها را از اینجا مدیریت کنید.
|
محصولات، ثبت محصول جدید، دستهبندیها و محصولات ویژه را از اینجا
|
||||||
|
مدیریت کنید.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function ProfilePage() {
|
|||||||
const [passwordOpen, setPasswordOpen] = useState(false)
|
const [passwordOpen, setPasswordOpen] = useState(false)
|
||||||
const [headerTick, setHeaderTick] = useState(0)
|
const [headerTick, setHeaderTick] = useState(0)
|
||||||
|
|
||||||
const [title, setTitle] = useState<UserTitle>(userTitles[0])
|
const [title, setTitle] = useState<UserTitle>('')
|
||||||
const [firstName, setFirstName] = useState('')
|
const [firstName, setFirstName] = useState('')
|
||||||
const [lastName, setLastName] = useState('')
|
const [lastName, setLastName] = useState('')
|
||||||
const [category, setCategory] = useState<UserCategory | ''>('')
|
const [category, setCategory] = useState<UserCategory | ''>('')
|
||||||
@@ -50,7 +50,7 @@ export function ProfilePage() {
|
|||||||
const [roleLabel, setRoleLabel] = useState('')
|
const [roleLabel, setRoleLabel] = useState('')
|
||||||
|
|
||||||
function applyUser(user: User) {
|
function applyUser(user: User) {
|
||||||
setTitle(user.title)
|
setTitle(user.title || '')
|
||||||
setFirstName(user.firstName)
|
setFirstName(user.firstName)
|
||||||
setLastName(user.lastName)
|
setLastName(user.lastName)
|
||||||
setCategory(user.category ?? '')
|
setCategory(user.category ?? '')
|
||||||
@@ -76,7 +76,7 @@ export function ProfilePage() {
|
|||||||
}
|
}
|
||||||
const fallback = getSession()?.user
|
const fallback = getSession()?.user
|
||||||
if (fallback) {
|
if (fallback) {
|
||||||
setTitle(fallback.title as UserTitle)
|
setTitle((fallback.title as UserTitle) || '')
|
||||||
setFirstName(fallback.firstName)
|
setFirstName(fallback.firstName)
|
||||||
setLastName(fallback.lastName)
|
setLastName(fallback.lastName)
|
||||||
setCellNumber(fallback.cellNumber)
|
setCellNumber(fallback.cellNumber)
|
||||||
@@ -123,7 +123,7 @@ export function ProfilePage() {
|
|||||||
lastName: updated.lastName,
|
lastName: updated.lastName,
|
||||||
cellNumber: updated.cellNumber,
|
cellNumber: updated.cellNumber,
|
||||||
role: updated.role,
|
role: updated.role,
|
||||||
name: updated.name,
|
name: `${updated.firstName} ${updated.lastName}`.trim(),
|
||||||
})
|
})
|
||||||
setHeaderTick((tick) => tick + 1)
|
setHeaderTick((tick) => tick + 1)
|
||||||
setSuccess('اطلاعات پروفایل ذخیره شد.')
|
setSuccess('اطلاعات پروفایل ذخیره شد.')
|
||||||
@@ -219,6 +219,7 @@ export function ProfilePage() {
|
|||||||
onChange={(e) => setTitle(e.target.value as UserTitle)}
|
onChange={(e) => setTitle(e.target.value as UserTitle)}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
>
|
>
|
||||||
|
<option value="">بدون عنوان</option>
|
||||||
{userTitles.map((item) => (
|
{userTitles.map((item) => (
|
||||||
<option key={item} value={item}>
|
<option key={item} value={item}>
|
||||||
{item}
|
{item}
|
||||||
|
|||||||
@@ -79,8 +79,9 @@ export function SettingsPage() {
|
|||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const formId = useId()
|
const formId = useId()
|
||||||
const [districtOptions, setDistrictOptions] =
|
const [districtOptions, setDistrictOptions] = useState<string[]>(() => [
|
||||||
useState<string[]>(fallbackDistricts)
|
...fallbackDistricts,
|
||||||
|
])
|
||||||
const [exceptions, setExceptions] = useState<ShippingExceptionRow[]>([])
|
const [exceptions, setExceptions] = useState<ShippingExceptionRow[]>([])
|
||||||
const [branches, setBranches] = useState<BranchRow[]>([])
|
const [branches, setBranches] = useState<BranchRow[]>([])
|
||||||
const [initialBranchIds, setInitialBranchIds] = useState<string[]>([])
|
const [initialBranchIds, setInitialBranchIds] = useState<string[]>([])
|
||||||
@@ -112,7 +113,9 @@ export function SettingsPage() {
|
|||||||
listBranches(),
|
listBranches(),
|
||||||
])
|
])
|
||||||
setDistrictOptions(
|
setDistrictOptions(
|
||||||
districtList.length > 0 ? districtList : fallbackDistricts,
|
districtList.length > 0
|
||||||
|
? [...districtList]
|
||||||
|
: [...fallbackDistricts],
|
||||||
)
|
)
|
||||||
setExceptions(
|
setExceptions(
|
||||||
shippingList.map((item) => ({
|
shippingList.map((item) => ({
|
||||||
|
|||||||
@@ -0,0 +1,331 @@
|
|||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
padding-bottom: 88px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topBlock {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
position: relative;
|
||||||
|
z-index: 3;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: end;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1 1 180px;
|
||||||
|
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;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input:focus,
|
||||||
|
.field select:focus {
|
||||||
|
border-color: var(--brown);
|
||||||
|
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterBtn {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: auto;
|
||||||
|
height: var(--field-height);
|
||||||
|
padding: 0 18px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-on-dark);
|
||||||
|
background: var(--brown);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterBtn:hover:not(:disabled) {
|
||||||
|
background: var(--brown-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.listWrap {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns:
|
||||||
|
minmax(180px, 1.4fr)
|
||||||
|
minmax(140px, 1.1fr)
|
||||||
|
minmax(120px, 0.9fr)
|
||||||
|
auto;
|
||||||
|
gap: 10px 14px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: rgba(255, 250, 250, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:hover {
|
||||||
|
background: rgba(255, 250, 250, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.identity {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--brown);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
padding: 3px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--brown);
|
||||||
|
background: rgba(143, 65, 12, 0.08);
|
||||||
|
border: 1px solid rgba(143, 65, 12, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userMeta {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metaValue {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone {
|
||||||
|
display: block;
|
||||||
|
width: max-content;
|
||||||
|
max-width: 100%;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
direction: ltr;
|
||||||
|
unicode-bidi: isolate;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1.25;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metaSub {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconBtn:hover {
|
||||||
|
color: var(--brown);
|
||||||
|
background: rgba(143, 65, 12, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconBtn.danger:hover {
|
||||||
|
color: #9b2c2c;
|
||||||
|
background: rgba(155, 44, 44, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
margin: 0;
|
||||||
|
padding: 28px 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageBtn {
|
||||||
|
min-width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 12px;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageBtn:hover:not(:disabled) {
|
||||||
|
color: var(--brown);
|
||||||
|
border-color: rgba(143, 65, 12, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageBtn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
border-color: var(--brown-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageInfo {
|
||||||
|
margin-inline-start: 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab {
|
||||||
|
position: fixed;
|
||||||
|
inset-inline-end: 24px;
|
||||||
|
bottom: 24px;
|
||||||
|
z-index: 20;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--brown);
|
||||||
|
color: var(--text-on-dark);
|
||||||
|
box-shadow: 0 10px 28px rgba(143, 65, 12, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab:hover {
|
||||||
|
background: var(--brown-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab::after {
|
||||||
|
content: 'ثبت سفارش ویژه';
|
||||||
|
position: absolute;
|
||||||
|
inset-inline-end: calc(100% + 10px);
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--brown-deeper);
|
||||||
|
color: var(--text-on-dark);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateX(4px);
|
||||||
|
transition:
|
||||||
|
opacity 0.18s,
|
||||||
|
transform 0.18s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab:hover::after {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.row {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
import { ChevronRight, Eye, Pencil, Plus, Trash2 } from 'lucide-react'
|
||||||
|
import { Header } from '../components/Header'
|
||||||
|
import { SpecialOrderDetailsModal } from '../components/SpecialOrderDetailsModal'
|
||||||
|
import {
|
||||||
|
SpecialOrderModal,
|
||||||
|
type SpecialOrderFormValues,
|
||||||
|
} from '../components/SpecialOrderModal'
|
||||||
|
import {
|
||||||
|
formatCellNumber,
|
||||||
|
formatUserName,
|
||||||
|
} from '../data/users'
|
||||||
|
import { ApiError } from '../lib/api'
|
||||||
|
import { clearSession } from '../lib/auth'
|
||||||
|
import { redirectToLogin } from '../lib/host'
|
||||||
|
import {
|
||||||
|
createSpecialOrder,
|
||||||
|
deleteSpecialOrder,
|
||||||
|
listSpecialOrders,
|
||||||
|
specialOrderCategoryLabel,
|
||||||
|
updateSpecialOrder,
|
||||||
|
type SpecialOrder,
|
||||||
|
type SpecialOrderCategory,
|
||||||
|
} from '../lib/specialOrdersApi'
|
||||||
|
import { formatPrice } from '../utils/price'
|
||||||
|
import styles from './HomePage.module.css'
|
||||||
|
import pageStyles from './SpecialOrdersPage.module.css'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 12
|
||||||
|
|
||||||
|
type Filters = {
|
||||||
|
q: string
|
||||||
|
category: '' | SpecialOrderCategory
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyFilters: Filters = {
|
||||||
|
q: '',
|
||||||
|
category: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCreatedAt(iso: string) {
|
||||||
|
const date = new Date(iso)
|
||||||
|
if (Number.isNaN(date.getTime())) return '—'
|
||||||
|
return new Intl.DateTimeFormat('fa-IR', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SpecialOrdersPage() {
|
||||||
|
const location = useLocation()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [items, setItems] = useState<SpecialOrder[]>([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [draftFilters, setDraftFilters] = useState<Filters>(emptyFilters)
|
||||||
|
const [appliedFilters, setAppliedFilters] = useState<Filters>(emptyFilters)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
|
const [editing, setEditing] = useState<SpecialOrder | null>(null)
|
||||||
|
const [detailsOrder, setDetailsOrder] = useState<SpecialOrder | null>(null)
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const result = await listSpecialOrders({
|
||||||
|
q: appliedFilters.q.trim() || undefined,
|
||||||
|
category: appliedFilters.category || undefined,
|
||||||
|
page,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
})
|
||||||
|
setItems(result.items)
|
||||||
|
setTotal(result.total)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.status === 401) {
|
||||||
|
clearSession()
|
||||||
|
redirectToLogin()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setItems([])
|
||||||
|
setTotal(0)
|
||||||
|
setError(
|
||||||
|
err instanceof ApiError
|
||||||
|
? err.message
|
||||||
|
: 'بارگذاری سفارشات ویژه ناموفق بود.',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [appliedFilters, page, navigate])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (page > totalPages) setPage(totalPages)
|
||||||
|
}, [page, totalPages])
|
||||||
|
|
||||||
|
async function handleSubmit(values: SpecialOrderFormValues) {
|
||||||
|
setBusy(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
if (editing) {
|
||||||
|
await updateSpecialOrder(editing.id, values)
|
||||||
|
} else {
|
||||||
|
await createSpecialOrder(values)
|
||||||
|
}
|
||||||
|
setModalOpen(false)
|
||||||
|
setEditing(null)
|
||||||
|
if (page !== 1 && !editing) {
|
||||||
|
setPage(1)
|
||||||
|
} else {
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.status === 401) {
|
||||||
|
clearSession()
|
||||||
|
redirectToLogin()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(item: SpecialOrder) {
|
||||||
|
if (!window.confirm(`سفارش «${item.name}» حذف شود؟`)) return
|
||||||
|
setBusy(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await deleteSpecialOrder(item.id)
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.status === 401) {
|
||||||
|
clearSession()
|
||||||
|
redirectToLogin()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(
|
||||||
|
err instanceof ApiError ? err.message : 'حذف سفارش ویژه ناموفق بود.',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${styles.page} ${pageStyles.page}`}>
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main className={`${styles.main} ${pageStyles.main}`}>
|
||||||
|
<section
|
||||||
|
key={`welcome-${location.key}`}
|
||||||
|
className={`${styles.welcome} ${pageStyles.topBlock}`}
|
||||||
|
>
|
||||||
|
<span className={styles.enBackdrop} aria-hidden>
|
||||||
|
Special 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>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className={pageStyles.filters}
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setPage(1)
|
||||||
|
setAppliedFilters({
|
||||||
|
q: draftFilters.q.trim(),
|
||||||
|
category: draftFilters.category,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className={pageStyles.field}>
|
||||||
|
<span>جستجو</span>
|
||||||
|
<input
|
||||||
|
value={draftFilters.q}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraftFilters((current) => ({
|
||||||
|
...current,
|
||||||
|
q: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="نام سفارش، کاربر یا موبایل"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className={pageStyles.field}>
|
||||||
|
<span>دستهبندی</span>
|
||||||
|
<select
|
||||||
|
value={draftFilters.category}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraftFilters((current) => ({
|
||||||
|
...current,
|
||||||
|
category: e.target.value as Filters['category'],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">همه</option>
|
||||||
|
<option value="specialCake">کیک ویژه</option>
|
||||||
|
<option value="specialPastry">شیرینی ویژه</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="submit" className={pageStyles.filterBtn} disabled={busy}>
|
||||||
|
اعمال
|
||||||
|
</button>
|
||||||
|
</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>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className={pageStyles.panel}>
|
||||||
|
<p className={pageStyles.empty}>سفارش ویژهای ثبت نشده است.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<section
|
||||||
|
className={pageStyles.panel}
|
||||||
|
aria-label="فهرست سفارشات ویژه"
|
||||||
|
>
|
||||||
|
<ul className={pageStyles.list}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item.id} className={pageStyles.row}>
|
||||||
|
<div className={pageStyles.identity}>
|
||||||
|
<span className={pageStyles.name}>{item.name}</span>
|
||||||
|
<span className={pageStyles.chip}>
|
||||||
|
{specialOrderCategoryLabel[item.category]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`${pageStyles.meta} ${pageStyles.userMeta}`}>
|
||||||
|
<span className={pageStyles.metaValue}>
|
||||||
|
{formatUserName(item.user) || item.user.name}
|
||||||
|
</span>
|
||||||
|
<span className={pageStyles.phone} dir="ltr">
|
||||||
|
{formatCellNumber(item.user.cellNumber)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={pageStyles.meta}>
|
||||||
|
<span className={pageStyles.metaValue}>
|
||||||
|
{formatCreatedAt(item.createdAt)}
|
||||||
|
</span>
|
||||||
|
<span className={pageStyles.metaSub}>
|
||||||
|
{formatPrice(item.images.length)} تصویر
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={pageStyles.actions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={pageStyles.iconBtn}
|
||||||
|
aria-label="جزئیات"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setDetailsOrder(item)}
|
||||||
|
>
|
||||||
|
<Eye size={16} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={pageStyles.iconBtn}
|
||||||
|
aria-label="ویرایش"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(item)
|
||||||
|
setModalOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil size={16} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${pageStyles.iconBtn} ${pageStyles.danger}`}
|
||||||
|
aria-label="حذف"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void handleDelete(item)}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} 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 || busy}
|
||||||
|
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}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setPage(pageNumber)}
|
||||||
|
>
|
||||||
|
{formatPrice(pageNumber)}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={pageStyles.pageBtn}
|
||||||
|
disabled={page >= totalPages || busy}
|
||||||
|
onClick={() =>
|
||||||
|
setPage((current) => Math.min(totalPages, current + 1))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
بعدی
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className={pageStyles.pageInfo}>
|
||||||
|
{formatPrice(total)} سفارش
|
||||||
|
</span>
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={pageStyles.fab}
|
||||||
|
aria-label="ثبت سفارش ویژه"
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(null)
|
||||||
|
setModalOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus size={22} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<SpecialOrderModal
|
||||||
|
open={modalOpen}
|
||||||
|
editing={editing}
|
||||||
|
onClose={() => {
|
||||||
|
if (busy) return
|
||||||
|
setModalOpen(false)
|
||||||
|
setEditing(null)
|
||||||
|
}}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SpecialOrderDetailsModal
|
||||||
|
open={Boolean(detailsOrder)}
|
||||||
|
order={detailsOrder}
|
||||||
|
onClose={() => setDetailsOrder(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
} from '../lib/usersApi'
|
} from '../lib/usersApi'
|
||||||
import {
|
import {
|
||||||
formatCellNumber,
|
formatCellNumber,
|
||||||
|
formatUserName,
|
||||||
isElevatedRole,
|
isElevatedRole,
|
||||||
userRoleLabel,
|
userRoleLabel,
|
||||||
userRoles,
|
userRoles,
|
||||||
@@ -346,7 +347,7 @@ export function UsersPage() {
|
|||||||
>
|
>
|
||||||
<div className={pageStyles.identity}>
|
<div className={pageStyles.identity}>
|
||||||
<span className={pageStyles.name}>
|
<span className={pageStyles.name}>
|
||||||
{user.title} {user.firstName} {user.lastName}
|
{formatUserName(user)}
|
||||||
</span>
|
</span>
|
||||||
<span className={pageStyles.cell} dir="ltr">
|
<span className={pageStyles.cell} dir="ltr">
|
||||||
{formatCellNumber(user.cellNumber)}
|
{formatCellNumber(user.cellNumber)}
|
||||||
@@ -520,9 +521,7 @@ export function UsersPage() {
|
|||||||
<ChangePasswordModal
|
<ChangePasswordModal
|
||||||
open={Boolean(passwordUser)}
|
open={Boolean(passwordUser)}
|
||||||
userName={
|
userName={
|
||||||
passwordUser
|
passwordUser ? formatUserName(passwordUser) : ''
|
||||||
? `${passwordUser.title} ${passwordUser.firstName} ${passwordUser.lastName}`
|
|
||||||
: ''
|
|
||||||
}
|
}
|
||||||
onClose={() => setPasswordUser(null)}
|
onClose={() => setPasswordUser(null)}
|
||||||
onSubmit={(password) => void handlePasswordSubmit(password)}
|
onSubmit={(password) => void handlePasswordSubmit(password)}
|
||||||
|
|||||||
Reference in New Issue
Block a user