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
+18
@@ -6,6 +6,7 @@ import { CustomerHomePage } from './pages/CustomerHomePage'
|
||||
import { ProductsPage } from './pages/ProductsPage'
|
||||
import { ProductsListPage } from './pages/ProductsListPage'
|
||||
import { ProductDetailsPage } from './pages/ProductDetailsPage'
|
||||
import { ProductSpecialsPage } from './pages/ProductSpecialsPage'
|
||||
import { CategoriesPage } from './pages/CategoriesPage'
|
||||
import { AddProductPage } from './pages/AddProductPage'
|
||||
import { UsersPage } from './pages/UsersPage'
|
||||
@@ -15,6 +16,7 @@ import { ProfilePage } from './pages/ProfilePage'
|
||||
import { CustomerOrdersPage } from './pages/CustomerOrdersPage'
|
||||
import { CustomerDiscountsPage } from './pages/CustomerDiscountsPage'
|
||||
import { DiscountsPage } from './pages/DiscountsPage'
|
||||
import { SpecialOrdersPage } from './pages/SpecialOrdersPage'
|
||||
import { CustomerAddressesPage } from './pages/CustomerAddressesPage'
|
||||
import {
|
||||
getAuthUser,
|
||||
@@ -114,6 +116,14 @@ function AdminApp() {
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/products/specials"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<ProductSpecialsPage />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/products/:productId/edit"
|
||||
element={
|
||||
@@ -146,6 +156,14 @@ function AdminApp() {
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/special-orders"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<SpecialOrdersPage />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/discounts"
|
||||
element={
|
||||
|
||||
@@ -162,6 +162,7 @@ export function CategoryOptionsModal({
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!category) return
|
||||
setSaving(true)
|
||||
setError('')
|
||||
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) {
|
||||
event.preventDefault()
|
||||
if (!product) return
|
||||
const qty = Number(quantity)
|
||||
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 {
|
||||
formatPriceParts,
|
||||
productImageSrc,
|
||||
type Product,
|
||||
} from '../data/products'
|
||||
import { PRODUCT_SPECIALS } from './ProductSpecialsModal'
|
||||
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 = {
|
||||
product: Product
|
||||
index: number
|
||||
@@ -14,7 +22,9 @@ type ProductCardProps = {
|
||||
onView?: (product: Product) => void
|
||||
onEdit?: (product: Product) => void
|
||||
onOpenOptions?: (product: Product) => void
|
||||
onMakeSpecial?: (product: Product) => void
|
||||
onRemove?: (product: Product) => void
|
||||
removeLabel?: string
|
||||
}
|
||||
|
||||
export function ProductCard({
|
||||
@@ -25,12 +35,18 @@ export function ProductCard({
|
||||
onView,
|
||||
onEdit,
|
||||
onOpenOptions,
|
||||
onMakeSpecial,
|
||||
onRemove,
|
||||
removeLabel = 'حذف',
|
||||
}: ProductCardProps) {
|
||||
const price = formatPriceParts(product)
|
||||
const optionCount = new Set(
|
||||
(product.options ?? []).map((option) => option.flavorId),
|
||||
).size
|
||||
const specialCount = countSpecialTags(product.tags)
|
||||
const hasControls = Boolean(
|
||||
onView || onEdit || onOpenOptions || onMakeSpecial || onRemove,
|
||||
)
|
||||
|
||||
function stopAnd(handler?: (product: Product) => void) {
|
||||
return (event: React.MouseEvent) => {
|
||||
@@ -72,56 +88,86 @@ export function ProductCard({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.controls}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconBtn}
|
||||
aria-label="مشاهده"
|
||||
data-tooltip="مشاهده"
|
||||
disabled={busy}
|
||||
onClick={stopAnd(onView)}
|
||||
>
|
||||
<Eye size={17} strokeWidth={1.75} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconBtn}
|
||||
aria-label="ویرایش"
|
||||
data-tooltip="ویرایش"
|
||||
disabled={busy}
|
||||
onClick={stopAnd(onEdit)}
|
||||
>
|
||||
<Pencil size={17} strokeWidth={1.75} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconBtn}
|
||||
aria-label={
|
||||
optionCount > 0 ? `گزینهها (${optionCount})` : 'گزینهها'
|
||||
}
|
||||
data-tooltip="گزینهها"
|
||||
disabled={busy}
|
||||
onClick={stopAnd(onOpenOptions)}
|
||||
>
|
||||
<SlidersHorizontal size={17} strokeWidth={1.75} />
|
||||
{optionCount > 0 && (
|
||||
<span className={styles.badge}>{optionCount}</span>
|
||||
{hasControls && (
|
||||
<div className={styles.controls}>
|
||||
{onView && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconBtn}
|
||||
aria-label="مشاهده"
|
||||
data-tooltip="مشاهده"
|
||||
disabled={busy}
|
||||
onClick={stopAnd(onView)}
|
||||
>
|
||||
<Eye size={17} strokeWidth={1.75} />
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.iconBtn} ${styles.removeBtn}`}
|
||||
aria-label="حذف"
|
||||
data-tooltip="حذف"
|
||||
disabled={busy}
|
||||
onClick={stopAnd(onRemove)}
|
||||
>
|
||||
<Trash2 size={17} strokeWidth={1.75} />
|
||||
</button>
|
||||
</div>
|
||||
{onEdit && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconBtn}
|
||||
aria-label="ویرایش"
|
||||
data-tooltip="ویرایش"
|
||||
disabled={busy}
|
||||
onClick={stopAnd(onEdit)}
|
||||
>
|
||||
<Pencil size={17} strokeWidth={1.75} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onOpenOptions && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconBtn}
|
||||
aria-label={
|
||||
optionCount > 0 ? `گزینهها (${optionCount})` : 'گزینهها'
|
||||
}
|
||||
data-tooltip="گزینهها"
|
||||
disabled={busy}
|
||||
onClick={stopAnd(onOpenOptions)}
|
||||
>
|
||||
<SlidersHorizontal size={17} strokeWidth={1.75} />
|
||||
{optionCount > 0 && (
|
||||
<span className={styles.badge}>{optionCount}</span>
|
||||
)}
|
||||
</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
|
||||
type="button"
|
||||
className={`${styles.iconBtn} ${styles.removeBtn}`}
|
||||
aria-label={removeLabel}
|
||||
data-tooltip={removeLabel}
|
||||
disabled={busy}
|
||||
onClick={stopAnd(onRemove)}
|
||||
>
|
||||
<Trash2 size={17} strokeWidth={1.75} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -179,6 +179,7 @@ export function ProductOptionsModal({
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!product) return
|
||||
setSaving(true)
|
||||
setError('')
|
||||
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) {
|
||||
const titleId = useId()
|
||||
const isEditing = Boolean(editingUser)
|
||||
const [title, setTitle] = useState<UserTitle>(userTitles[0])
|
||||
const [title, setTitle] = useState<UserTitle>('')
|
||||
const [firstName, setFirstName] = useState('')
|
||||
const [lastName, setLastName] = useState('')
|
||||
const [category, setCategory] = useState<UserCategory | ''>('')
|
||||
@@ -53,13 +53,13 @@ export function UserModal({
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (editingUser) {
|
||||
setTitle(editingUser.title)
|
||||
setTitle(editingUser.title || '')
|
||||
setFirstName(editingUser.firstName)
|
||||
setLastName(editingUser.lastName)
|
||||
setCategory(editingUser.category ?? '')
|
||||
setCellNumber(editingUser.cellNumber)
|
||||
} else {
|
||||
setTitle(userTitles[0])
|
||||
setTitle('')
|
||||
setFirstName('')
|
||||
setLastName('')
|
||||
setCategory('')
|
||||
@@ -195,6 +195,7 @@ export function UserModal({
|
||||
onChange={(e) => setTitle(e.target.value as UserTitle)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<option value="">بدون عنوان</option>
|
||||
{userTitles.map((item) => (
|
||||
<option key={item} value={item}>
|
||||
{item}
|
||||
|
||||
@@ -36,6 +36,8 @@ export type Product = {
|
||||
category: ProductCategoryRef
|
||||
intro?: string
|
||||
description?: string
|
||||
/** null / undefined = ready immediately */
|
||||
preparationHours?: number | null
|
||||
tags?: string[]
|
||||
mainImageUrl: string | null
|
||||
mainImageKey?: string | null
|
||||
|
||||
+14
-1
@@ -9,6 +9,7 @@ export type UserTitle =
|
||||
| 'خانم دکتر'
|
||||
| 'جناب آقای مهندس'
|
||||
| 'سرکار خانم مهندس'
|
||||
| ''
|
||||
|
||||
export type User = {
|
||||
id: string
|
||||
@@ -25,7 +26,7 @@ export type User = {
|
||||
|
||||
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. */
|
||||
export function stripUserTitle(fullName: string) {
|
||||
const sorted = [...userTitles].sort((a, b) => b.length - a.length)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { apiUpload } from './api'
|
||||
|
||||
export type MediaKind = 'main' | 'gallery' | 'temp'
|
||||
export type MediaKind = 'main' | 'gallery' | 'temp' | 'specialOrder'
|
||||
|
||||
export type MediaUploadResult = {
|
||||
url: string
|
||||
|
||||
+23
-1
@@ -1,11 +1,13 @@
|
||||
import {
|
||||
CakeSlice,
|
||||
ClipboardPen,
|
||||
FolderTree,
|
||||
MapPin,
|
||||
Package,
|
||||
Percent,
|
||||
PlusCircle,
|
||||
Settings,
|
||||
Sparkles,
|
||||
UserRound,
|
||||
Users,
|
||||
type LucideIcon,
|
||||
@@ -60,6 +62,14 @@ export const productTiles: ProductTile[] = [
|
||||
href: '/products/categories',
|
||||
icon: FolderTree,
|
||||
},
|
||||
{
|
||||
id: 'products-specials',
|
||||
title: 'محصولات ویژه',
|
||||
titleEn: 'Special Products',
|
||||
description: 'مشاهده و مدیریت محصولات ویژه فروشگاه',
|
||||
href: '/products/specials',
|
||||
icon: Sparkles,
|
||||
},
|
||||
]
|
||||
|
||||
export const navSections: NavSection[] = [
|
||||
@@ -96,6 +106,15 @@ export const navSections: NavSection[] = [
|
||||
icon: Package,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: 'special-orders',
|
||||
title: 'سفارشات ویژه',
|
||||
titleEn: 'Special Orders',
|
||||
description: 'درخواستهای سفارشی کیک و شیرینی بدون پرداخت',
|
||||
href: '/special-orders',
|
||||
icon: ClipboardPen,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: 'discounts',
|
||||
title: 'کدهای تخفیف',
|
||||
@@ -118,7 +137,10 @@ export const navSections: NavSection[] = [
|
||||
|
||||
/** Header links — settings & discounts stay on home tiles only. */
|
||||
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[] = [
|
||||
|
||||
@@ -18,6 +18,7 @@ export type ListProductsParams = {
|
||||
categoryId?: string
|
||||
minPrice?: number
|
||||
maxPrice?: number
|
||||
tag?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
@@ -41,6 +42,8 @@ export type CreateProductPayload = {
|
||||
sellUnit: SellUnit
|
||||
intro?: string
|
||||
description?: string
|
||||
/** null = ready immediately (no prep time) */
|
||||
preparationHours?: number | null
|
||||
tags?: string[]
|
||||
mainImageUrl?: string
|
||||
mainImageKey?: string
|
||||
@@ -54,6 +57,7 @@ function buildQuery(params: ListProductsParams) {
|
||||
const search = new URLSearchParams()
|
||||
if (params.q?.trim()) search.set('q', params.q.trim())
|
||||
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.maxPrice !== undefined) search.set('maxPrice', String(params.maxPrice))
|
||||
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 = {
|
||||
title: UserTitle
|
||||
title?: UserTitle
|
||||
firstName: string
|
||||
lastName: string
|
||||
category?: UserCategory | null
|
||||
|
||||
@@ -139,6 +139,97 @@
|
||||
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 {
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
@@ -53,6 +53,8 @@ export function AddProductPage() {
|
||||
const [categoryId, setCategoryId] = useState<string | null>(null)
|
||||
const [nameFa, setNameFa] = useState('')
|
||||
const [nameEn, setNameEn] = useState('')
|
||||
const [readyImmediately, setReadyImmediately] = useState(true)
|
||||
const [preparationHours, setPreparationHours] = useState('')
|
||||
const [sellUnit, setSellUnit] = useState<SellUnit>('unit')
|
||||
const [price, setPrice] = useState('')
|
||||
const [intro, setIntro] = useState('')
|
||||
@@ -130,6 +132,13 @@ export function AddProductPage() {
|
||||
setCategoryId(product.categoryId)
|
||||
setSellUnit(product.sellUnit)
|
||||
setPrice(String(product.price))
|
||||
if (product.preparationHours != null && product.preparationHours > 0) {
|
||||
setReadyImmediately(false)
|
||||
setPreparationHours(String(product.preparationHours))
|
||||
} else {
|
||||
setReadyImmediately(true)
|
||||
setPreparationHours('')
|
||||
}
|
||||
setIntro(product.intro ?? '')
|
||||
setDescription(product.description ?? '')
|
||||
setTags(product.tags ?? [])
|
||||
@@ -213,6 +222,15 @@ export function AddProductPage() {
|
||||
setError('نام فارسی و انگلیسی الزامی است')
|
||||
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)
|
||||
if (numericPrice === null || numericPrice <= 0) {
|
||||
setError('قیمت معتبر الزامی است')
|
||||
@@ -232,6 +250,7 @@ export function AddProductPage() {
|
||||
categoryId,
|
||||
price: numericPrice,
|
||||
sellUnit,
|
||||
preparationHours: prepHours,
|
||||
intro: intro.trim(),
|
||||
description,
|
||||
tags,
|
||||
@@ -424,6 +443,52 @@ export function AddProductPage() {
|
||||
/>
|
||||
</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.field}>
|
||||
<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 { PriceInput } from '../components/PriceInput'
|
||||
import { ProductOptionsModal } from '../components/ProductOptionsModal'
|
||||
import {
|
||||
ProductSpecialsModal,
|
||||
type ProductSpecialTag,
|
||||
} from '../components/ProductSpecialsModal'
|
||||
import {
|
||||
flattenCategories,
|
||||
type Category,
|
||||
@@ -48,6 +52,7 @@ export function ProductsListPage() {
|
||||
const [draftFilters, setDraftFilters] = useState<Filters>(emptyFilters)
|
||||
const [appliedFilters, setAppliedFilters] = useState<Filters>(emptyFilters)
|
||||
const [optionsProduct, setOptionsProduct] = useState<Product | null>(null)
|
||||
const [specialsProduct, setSpecialsProduct] = useState<Product | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [busyId, setBusyId] = useState<string | null>(null)
|
||||
@@ -161,6 +166,7 @@ export function ProductsListPage() {
|
||||
current.filter((item) => item.id !== product.id),
|
||||
)
|
||||
if (optionsProduct?.id === product.id) setOptionsProduct(null)
|
||||
if (specialsProduct?.id === product.id) setSpecialsProduct(null)
|
||||
} catch (err) {
|
||||
if (handleAuthError(err)) return
|
||||
setError(
|
||||
@@ -188,6 +194,34 @@ export function ProductsListPage() {
|
||||
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 (
|
||||
<div className={styles.page}>
|
||||
<Header />
|
||||
@@ -289,6 +323,7 @@ export function ProductsListPage() {
|
||||
onView={handleView}
|
||||
onEdit={handleEdit}
|
||||
onOpenOptions={setOptionsProduct}
|
||||
onMakeSpecial={setSpecialsProduct}
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
))}
|
||||
@@ -316,6 +351,18 @@ export function ProductsListPage() {
|
||||
onClose={() => setOptionsProduct(null)}
|
||||
onSave={handleSaveOptions}
|
||||
/>
|
||||
|
||||
<ProductSpecialsModal
|
||||
open={Boolean(specialsProduct)}
|
||||
product={
|
||||
specialsProduct
|
||||
? (products.find((item) => item.id === specialsProduct.id) ??
|
||||
specialsProduct)
|
||||
: null
|
||||
}
|
||||
onClose={() => setSpecialsProduct(null)}
|
||||
onSelect={handleAddSpecial}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -26,7 +26,8 @@ export function ProductsPage() {
|
||||
</p>
|
||||
<h1 className={styles.headline}>محصولات من</h1>
|
||||
<p className={styles.lead}>
|
||||
محصولات، ثبت محصول جدید و دستهبندیها را از اینجا مدیریت کنید.
|
||||
محصولات، ثبت محصول جدید، دستهبندیها و محصولات ویژه را از اینجا
|
||||
مدیریت کنید.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -42,7 +42,7 @@ export function ProfilePage() {
|
||||
const [passwordOpen, setPasswordOpen] = useState(false)
|
||||
const [headerTick, setHeaderTick] = useState(0)
|
||||
|
||||
const [title, setTitle] = useState<UserTitle>(userTitles[0])
|
||||
const [title, setTitle] = useState<UserTitle>('')
|
||||
const [firstName, setFirstName] = useState('')
|
||||
const [lastName, setLastName] = useState('')
|
||||
const [category, setCategory] = useState<UserCategory | ''>('')
|
||||
@@ -50,7 +50,7 @@ export function ProfilePage() {
|
||||
const [roleLabel, setRoleLabel] = useState('')
|
||||
|
||||
function applyUser(user: User) {
|
||||
setTitle(user.title)
|
||||
setTitle(user.title || '')
|
||||
setFirstName(user.firstName)
|
||||
setLastName(user.lastName)
|
||||
setCategory(user.category ?? '')
|
||||
@@ -76,7 +76,7 @@ export function ProfilePage() {
|
||||
}
|
||||
const fallback = getSession()?.user
|
||||
if (fallback) {
|
||||
setTitle(fallback.title as UserTitle)
|
||||
setTitle((fallback.title as UserTitle) || '')
|
||||
setFirstName(fallback.firstName)
|
||||
setLastName(fallback.lastName)
|
||||
setCellNumber(fallback.cellNumber)
|
||||
@@ -123,7 +123,7 @@ export function ProfilePage() {
|
||||
lastName: updated.lastName,
|
||||
cellNumber: updated.cellNumber,
|
||||
role: updated.role,
|
||||
name: updated.name,
|
||||
name: `${updated.firstName} ${updated.lastName}`.trim(),
|
||||
})
|
||||
setHeaderTick((tick) => tick + 1)
|
||||
setSuccess('اطلاعات پروفایل ذخیره شد.')
|
||||
@@ -219,6 +219,7 @@ export function ProfilePage() {
|
||||
onChange={(e) => setTitle(e.target.value as UserTitle)}
|
||||
disabled={saving}
|
||||
>
|
||||
<option value="">بدون عنوان</option>
|
||||
{userTitles.map((item) => (
|
||||
<option key={item} value={item}>
|
||||
{item}
|
||||
|
||||
@@ -79,8 +79,9 @@ export function SettingsPage() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const formId = useId()
|
||||
const [districtOptions, setDistrictOptions] =
|
||||
useState<string[]>(fallbackDistricts)
|
||||
const [districtOptions, setDistrictOptions] = useState<string[]>(() => [
|
||||
...fallbackDistricts,
|
||||
])
|
||||
const [exceptions, setExceptions] = useState<ShippingExceptionRow[]>([])
|
||||
const [branches, setBranches] = useState<BranchRow[]>([])
|
||||
const [initialBranchIds, setInitialBranchIds] = useState<string[]>([])
|
||||
@@ -112,7 +113,9 @@ export function SettingsPage() {
|
||||
listBranches(),
|
||||
])
|
||||
setDistrictOptions(
|
||||
districtList.length > 0 ? districtList : fallbackDistricts,
|
||||
districtList.length > 0
|
||||
? [...districtList]
|
||||
: [...fallbackDistricts],
|
||||
)
|
||||
setExceptions(
|
||||
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'
|
||||
import {
|
||||
formatCellNumber,
|
||||
formatUserName,
|
||||
isElevatedRole,
|
||||
userRoleLabel,
|
||||
userRoles,
|
||||
@@ -346,7 +347,7 @@ export function UsersPage() {
|
||||
>
|
||||
<div className={pageStyles.identity}>
|
||||
<span className={pageStyles.name}>
|
||||
{user.title} {user.firstName} {user.lastName}
|
||||
{formatUserName(user)}
|
||||
</span>
|
||||
<span className={pageStyles.cell} dir="ltr">
|
||||
{formatCellNumber(user.cellNumber)}
|
||||
@@ -520,9 +521,7 @@ export function UsersPage() {
|
||||
<ChangePasswordModal
|
||||
open={Boolean(passwordUser)}
|
||||
userName={
|
||||
passwordUser
|
||||
? `${passwordUser.title} ${passwordUser.firstName} ${passwordUser.lastName}`
|
||||
: ''
|
||||
passwordUser ? formatUserName(passwordUser) : ''
|
||||
}
|
||||
onClose={() => setPasswordUser(null)}
|
||||
onSubmit={(password) => void handlePasswordSubmit(password)}
|
||||
|
||||
Reference in New Issue
Block a user