Add special orders admin UI and product specials tools.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-10-12 23:09:31 +03:30
co-authored by Cursor
parent b103e330ef
commit d63e3a83d5
33 changed files with 2731 additions and 73 deletions
+5 -5
View File
@@ -2,8 +2,8 @@
Admin dashboard for شیرینی‌فروشی بلوط. React + Vite SPA that talks to the NestJS API.
Repo: `https://git.meshkee.com/BaloutPastry/dashboards.git`
API repo: `https://git.meshkee.com/BaloutPastry/backend.git`
Repo: `https://gitea.meshkee.com/balout-pastry/dashboards.git`
API repo: `https://gitea.meshkee.com/balout-pastry/backend.git`
## Stack
@@ -23,7 +23,7 @@ API repo: `https://git.meshkee.com/BaloutPastry/backend.git`
## Setup on a new device
```bash
git clone https://git.meshkee.com/BaloutPastry/dashboards.git
git clone https://gitea.meshkee.com/balout-pastry/dashboards.git
cd dashboards
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
- Backend docs: clone `BaloutPastry/backend` and read `CONTEXT.md`
- Website (storefront): clone `BaloutPastry/website` and read `CONTEXT.md` (dev port **5174**)
- Backend docs: clone `balout-pastry/backend` and read `CONTEXT.md`
- Website (storefront): clone `balout-pastry/website` and read `CONTEXT.md` (dev port **5174**)
- Postgres for local API: Docker Compose on host port **5434**
+18
View File
@@ -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={
+1
View File
@@ -162,6 +162,7 @@ export function CategoryOptionsModal({
}
async function handleSave() {
if (!category) return
setSaving(true)
setError('')
try {
+103
View File
@@ -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;
}
}
+97
View File
@@ -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,
)
}
+1
View File
@@ -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'
+49 -3
View File
@@ -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,7 +88,9 @@ export function ProductCard({
</p>
</div>
{hasControls && (
<div className={styles.controls}>
{onView && (
<button
type="button"
className={styles.iconBtn}
@@ -83,7 +101,9 @@ export function ProductCard({
>
<Eye size={17} strokeWidth={1.75} />
</button>
)}
{onEdit && (
<button
type="button"
className={styles.iconBtn}
@@ -94,7 +114,9 @@ export function ProductCard({
>
<Pencil size={17} strokeWidth={1.75} />
</button>
)}
{onOpenOptions && (
<button
type="button"
className={styles.iconBtn}
@@ -110,18 +132,42 @@ export function ProductCard({
<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="حذف"
data-tooltip="حذف"
aria-label={removeLabel}
data-tooltip={removeLabel}
disabled={busy}
onClick={stopAnd(onRemove)}
>
<Trash2 size={17} strokeWidth={1.75} />
</button>
)}
</div>
)}
</article>
)
}
+1
View File
@@ -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;
}
+155
View File
@@ -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));
}
}
+161
View File
@@ -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}
/>
)}
</>
)
}
+113
View File
@@ -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);
}
+401
View File
@@ -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>
)
}
+4 -3
View File
@@ -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}
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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[] = [
+4
View File
@@ -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))
+111
View File
@@ -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
View File
@@ -21,7 +21,7 @@ export type ListUsersParams = {
}
export type CreateUserPayload = {
title: UserTitle
title?: UserTitle
firstName: string
lastName: string
category?: UserCategory | null
+91
View File
@@ -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;
+65
View File
@@ -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>
+57
View File
@@ -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;
}
}
+212
View File
@@ -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>
)
}
+47
View File
@@ -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>
)
}
+2 -1
View File
@@ -26,7 +26,8 @@ export function ProductsPage() {
</p>
<h1 className={styles.headline}>محصولات من</h1>
<p className={styles.lead}>
محصولات، ثبت محصول جدید و دسته‌بندی‌ها را از اینجا مدیریت کنید.
محصولات، ثبت محصول جدید، دسته‌بندی‌ها و محصولات ویژه را از اینجا
مدیریت کنید.
</p>
</div>
</section>
+5 -4
View File
@@ -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}
+6 -3
View File
@@ -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) => ({
+331
View File
@@ -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;
}
}
+391
View File
@@ -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>
)
}
+3 -4
View File
@@ -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)}