Add special orders admin UI and product specials tools.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
b103e330ef
commit
d63e3a83d5
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user