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

+ {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({
-
-
-
-
-
-
+ )}
)
}
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 (
+ -
+ void handleSelect(special.tag)}
+ >
+
+ {special.label}
+
+ {isSavingThis ? (
+
+ در حال ذخیره...
+
+ ) : alreadyAdded ? (
+
+
+ اضافه شده
+
+ ) : null}
+
+
+ )
+ })}
+
+
+
+
+ )
+}
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
+
+ جزئیات سفارش ویژه
+
+
+
+
+
+
+
+
+
+
+
+
+
دستهبندی
+
+ {specialOrderCategoryLabel[order.category]}
+
+
+
+
تاریخ و ساعت
+
{formatDateTime(order.createdAt)}
+
+
+
+
+
{userName}
+
+ {formatCellNumber(order.user.cellNumber)}
+
+
+
+
+
توضیحات
+
+ {order.description.trim() || '—'}
+
+
+
+
+
تصاویر
+ {images.length === 0 ? (
+
تصویری ثبت نشده است.
+ ) : (
+
+ {images.map((src, index) => (
+
setLightboxIndex(index)}
+ aria-label={`باز کردن تصویر ${index + 1}`}
+ >
+
+
+ ))}
+
+ )}
+
+
+
+
+
+ {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 ? 'ویرایش سفارش ویژه' : 'ثبت سفارش ویژه'}
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/UserModal.tsx b/src/components/UserModal.tsx
index 5752234..bea7f7c 100644
--- a/src/components/UserModal.tsx
+++ b/src/components/UserModal.tsx
@@ -40,7 +40,7 @@ export function UserModal({
}: UserModalProps) {
const titleId = useId()
const isEditing = Boolean(editingUser)
- const [title, setTitle] = useState(userTitles[0])
+ const [title, setTitle] = useState('')
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [category, setCategory] = useState('')
@@ -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}
>
+
{userTitles.map((item) => (