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
+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)}