From d63e3a83d56fca6340ec2282bd07709f2398dcbd Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Mon, 12 Oct 2026 23:09:31 +0330 Subject: [PATCH] Add special orders admin UI and product specials tools. Co-authored-by: Cursor --- CONTEXT.md | 10 +- src/App.tsx | 18 + src/components/CategoryOptionsModal.tsx | 1 + src/components/ImageLightbox.module.css | 103 +++++ src/components/ImageLightbox.tsx | 97 +++++ src/components/OrderLineItemModal.tsx | 1 + src/components/ProductCard.tsx | 144 ++++--- src/components/ProductOptionsModal.tsx | 1 + .../ProductSpecialsModal.module.css | 101 +++++ src/components/ProductSpecialsModal.tsx | 155 +++++++ .../SpecialOrderDetailsModal.module.css | 109 +++++ src/components/SpecialOrderDetailsModal.tsx | 161 +++++++ src/components/SpecialOrderModal.module.css | 113 +++++ src/components/SpecialOrderModal.tsx | 401 ++++++++++++++++++ src/components/UserModal.tsx | 7 +- src/data/products.ts | 2 + src/data/users.ts | 15 +- src/lib/mediaApi.ts | 2 +- src/lib/nav.ts | 24 +- src/lib/productsApi.ts | 4 + src/lib/specialOrdersApi.ts | 111 +++++ src/lib/usersApi.ts | 2 +- src/pages/AddProductPage.module.css | 91 ++++ src/pages/AddProductPage.tsx | 65 +++ src/pages/ProductSpecialsPage.module.css | 57 +++ src/pages/ProductSpecialsPage.tsx | 212 +++++++++ src/pages/ProductsListPage.tsx | 47 ++ src/pages/ProductsPage.tsx | 3 +- src/pages/ProfilePage.tsx | 9 +- src/pages/SettingsPage.tsx | 9 +- src/pages/SpecialOrdersPage.module.css | 331 +++++++++++++++ src/pages/SpecialOrdersPage.tsx | 391 +++++++++++++++++ src/pages/UsersPage.tsx | 7 +- 33 files changed, 2731 insertions(+), 73 deletions(-) create mode 100644 src/components/ImageLightbox.module.css create mode 100644 src/components/ImageLightbox.tsx create mode 100644 src/components/ProductSpecialsModal.module.css create mode 100644 src/components/ProductSpecialsModal.tsx create mode 100644 src/components/SpecialOrderDetailsModal.module.css create mode 100644 src/components/SpecialOrderDetailsModal.tsx create mode 100644 src/components/SpecialOrderModal.module.css create mode 100644 src/components/SpecialOrderModal.tsx create mode 100644 src/lib/specialOrdersApi.ts create mode 100644 src/pages/ProductSpecialsPage.module.css create mode 100644 src/pages/ProductSpecialsPage.tsx create mode 100644 src/pages/SpecialOrdersPage.module.css create mode 100644 src/pages/SpecialOrdersPage.tsx diff --git a/CONTEXT.md b/CONTEXT.md index 48db3b1..ba31b95 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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** diff --git a/src/App.tsx b/src/App.tsx index 7698fb2..aef7252 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { } /> + + + + } + /> } /> + + + + } + /> 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( +
+ + + {images.length > 1 && ( + <> + + + + )} + +
+ {alt + {images.length > 1 && ( +

+ {safeIndex + 1} / {images.length} +

+ )} +
+
, + document.body, + ) +} diff --git a/src/components/OrderLineItemModal.tsx b/src/components/OrderLineItemModal.tsx index cbf3bc8..00f9381 100644 --- a/src/components/OrderLineItemModal.tsx +++ b/src/components/OrderLineItemModal.tsx @@ -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' diff --git a/src/components/ProductCard.tsx b/src/components/ProductCard.tsx index 03d0252..4982363 100644 --- a/src/components/ProductCard.tsx +++ b/src/components/ProductCard.tsx @@ -1,11 +1,19 @@ -import { Pencil, SlidersHorizontal, Trash2, Eye } from 'lucide-react' +import { Pencil, SlidersHorizontal, Sparkles, Trash2, Eye } from 'lucide-react' import { formatPriceParts, productImageSrc, type Product, } from '../data/products' +import { PRODUCT_SPECIALS } from './ProductSpecialsModal' import styles from './ProductCard.module.css' +function countSpecialTags(tags: string[] | undefined) { + const list = tags ?? [] + return PRODUCT_SPECIALS.filter((special) => + list.some((tag) => tag === special.tag || tag.includes(special.tag)), + ).length +} + type ProductCardProps = { product: Product index: number @@ -14,7 +22,9 @@ type ProductCardProps = { onView?: (product: Product) => void onEdit?: (product: Product) => void onOpenOptions?: (product: Product) => void + onMakeSpecial?: (product: Product) => void onRemove?: (product: Product) => void + removeLabel?: string } export function ProductCard({ @@ -25,12 +35,18 @@ export function ProductCard({ onView, onEdit, onOpenOptions, + onMakeSpecial, onRemove, + removeLabel = 'حذف', }: ProductCardProps) { const price = formatPriceParts(product) const optionCount = new Set( (product.options ?? []).map((option) => option.flavorId), ).size + const specialCount = countSpecialTags(product.tags) + const hasControls = Boolean( + onView || onEdit || onOpenOptions || onMakeSpecial || onRemove, + ) function stopAnd(handler?: (product: Product) => void) { return (event: React.MouseEvent) => { @@ -72,56 +88,86 @@ export function ProductCard({

-
- - - - - )} - - -
+ {onEdit && ( + + )} + + {onOpenOptions && ( + + )} + + {onMakeSpecial && ( + + )} + + {onRemove && ( + + )} + + )} ) } diff --git a/src/components/ProductOptionsModal.tsx b/src/components/ProductOptionsModal.tsx index 4a6298d..7eafd84 100644 --- a/src/components/ProductOptionsModal.tsx +++ b/src/components/ProductOptionsModal.tsx @@ -179,6 +179,7 @@ export function ProductOptionsModal({ } async function handleSave() { + if (!product) return setSaving(true) setError('') try { diff --git a/src/components/ProductSpecialsModal.module.css b/src/components/ProductSpecialsModal.module.css new file mode 100644 index 0000000..b3be653 --- /dev/null +++ b/src/components/ProductSpecialsModal.module.css @@ -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; +} diff --git a/src/components/ProductSpecialsModal.tsx b/src/components/ProductSpecialsModal.tsx new file mode 100644 index 0000000..2f0d95d --- /dev/null +++ b/src/components/ProductSpecialsModal.tsx @@ -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 +} + +export function ProductSpecialsModal({ + open, + product, + onClose, + onSelect, +}: ProductSpecialsModalProps) { + const titleId = useId() + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + const [activeTag, setActiveTag] = useState(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 ( +
+
event.stopPropagation()} + > +
+ +
+
+

Make it Special

+

+ ویژه کردن محصول +

+

+ {product.nameFa} + {product.nameEn} +

+
+ +
+ +
+ {error && ( +

+ {error} +

+ )} + +
    + {PRODUCT_SPECIALS.map((special) => { + const alreadyAdded = currentTags.some( + (tag) => tag === special.tag || tag.includes(special.tag), + ) + const isSavingThis = saving && activeTag === special.tag + + return ( +
  • + +
  • + ) + })} +
+
+
+
+ ) +} diff --git a/src/components/SpecialOrderDetailsModal.module.css b/src/components/SpecialOrderDetailsModal.module.css new file mode 100644 index 0000000..480febb --- /dev/null +++ b/src/components/SpecialOrderDetailsModal.module.css @@ -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)); + } +} diff --git a/src/components/SpecialOrderDetailsModal.tsx b/src/components/SpecialOrderDetailsModal.tsx new file mode 100644 index 0000000..e7aaca0 --- /dev/null +++ b/src/components/SpecialOrderDetailsModal.tsx @@ -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(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 ( + <> +
+
event.stopPropagation()} + > +
+ +
+
+

Special Order

+

+ جزئیات سفارش ویژه +

+
+ +
+ +
+
+ نام +

{order.name}

+
+ +
+
+ دسته‌بندی +

+ {specialOrderCategoryLabel[order.category]} +

+
+
+ تاریخ و ساعت +

{formatDateTime(order.createdAt)}

+
+
+ +
+

{userName}

+

+ {formatCellNumber(order.user.cellNumber)} +

+
+ +
+ توضیحات +

+ {order.description.trim() || '—'} +

+
+ +
+ تصاویر + {images.length === 0 ? ( +

تصویری ثبت نشده است.

+ ) : ( +
+ {images.map((src, index) => ( + + ))} +
+ )} +
+
+
+
+ + {lightboxIndex !== null && ( + setLightboxIndex(null)} + onIndexChange={setLightboxIndex} + /> + )} + + ) +} diff --git a/src/components/SpecialOrderModal.module.css b/src/components/SpecialOrderModal.module.css new file mode 100644 index 0000000..67f3118 --- /dev/null +++ b/src/components/SpecialOrderModal.module.css @@ -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); +} + diff --git a/src/components/SpecialOrderModal.tsx b/src/components/SpecialOrderModal.tsx new file mode 100644 index 0000000..e518dae --- /dev/null +++ b/src/components/SpecialOrderModal.tsx @@ -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 +} + +export function SpecialOrderModal({ + open, + editing = null, + onClose, + onSubmit, +}: SpecialOrderModalProps) { + const titleId = useId() + const isEditing = Boolean(editing) + const userRef = useRef(null) + + const [user, setUser] = useState(null) + const [userQuery, setUserQuery] = useState('') + const [userOpen, setUserOpen] = useState(false) + const [userResults, setUserResults] = useState([]) + const [userLoading, setUserLoading] = useState(false) + const [category, setCategory] = + useState('specialCake') + const [name, setName] = useState('') + const [description, setDescription] = useState('') + const [gallery, setGallery] = useState([]) + const [galleryKeys, setGalleryKeys] = useState>({}) + 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 ( +
{ + if (!isSubmitting) onClose() + }} + role="presentation" + > +
event.stopPropagation()} + > +
+ +
+
+

Special Orders

+

+ {isEditing ? 'ویرایش سفارش ویژه' : 'ثبت سفارش ویژه'} +

+
+ +
+ +
void handleSubmit(e)}> + {error && ( +
+ {error} +
+ )} + +
+
+ +
+
+ + { + setUserQuery(e.target.value) + setUser(null) + setUserOpen(true) + }} + onFocus={() => setUserOpen(true)} + placeholder="جستجو با نام یا موبایل" + disabled={isSubmitting} + autoComplete="off" + autoFocus={!isEditing} + /> + +
+ {userOpen && userQuery.trim().length >= 2 && ( +
    + {userLoading ? ( +
  • در حال جستجو...
  • + ) : userResults.length === 0 ? ( +
  • کاربری یافت نشد
  • + ) : ( + userResults.map((item) => ( +
  • + +
  • + )) + )} +
+ )} +
+ {user ? ( +
+

+ {formatCellNumber(user.cellNumber)} +

+ +
+ ) : ( +

+ {userQuery.trim().length > 0 && userQuery.trim().length < 2 + ? 'حداقل ۲ حرف وارد کنید' + : 'کاربر را از نتایج جستجو انتخاب کنید'} +

+ )} +
+ +
+ دسته‌بندی +
+ {specialOrderCategories.map((item) => ( + + ))} +
+
+ +
+ + setName(e.target.value)} + placeholder="مثلاً کیک تولد سفارشی" + disabled={isSubmitting} + /> +
+ +
+ +