Files
dashboards/src/components/SpecialOrderModal.tsx
T

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