402 lines
13 KiB
TypeScript
402 lines
13 KiB
TypeScript
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>
|
|
)
|
|
}
|