261 lines
10 KiB
TypeScript
261 lines
10 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
||
import { useNavigate } from 'react-router-dom'
|
||
import { formatPersianNumber } from '../utils/formatNumber'
|
||
import { createCafeSchedule, MatchEvent } from '../services/campaignApi'
|
||
import { useMatchEvents } from '../hooks/useMatchEvents'
|
||
import { formatMatchLabel } from '../utils/matchCountdown'
|
||
import { getCafeUuid } from '../utils/cafe'
|
||
|
||
const ENTRY_FEE_STEP = 10000
|
||
const DESCRIPTION_MAX_LENGTH = 200
|
||
|
||
function toEnglishDigits(value: string) {
|
||
const persianDigits = '۰۱۲۳۴۵۶۷۸۹'
|
||
return value.replace(/[۰-۹]/g, (digit) => String(persianDigits.indexOf(digit)))
|
||
}
|
||
|
||
function ChevronIcon({ className = '' }: { className?: string }) {
|
||
return (
|
||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M15.0898 4.07992L8.56984 10.5999C7.79984 11.3699 7.79984 12.6299 8.56984 13.3999L15.0898 19.9199" />
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
function ChevronDownIcon({ className = '' }: { className?: string }) {
|
||
return (
|
||
<svg className={`h-5 w-5 shrink-0 text-gray-400 transition-transform ${className}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 9l6 6 6-6" />
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
function MinusIcon({ className = '' }: { className?: string }) {
|
||
return (
|
||
<svg className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||
<path d="M3.33398 8H12.6673" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
function PlusIcon({ className = '' }: { className?: string }) {
|
||
return (
|
||
<svg className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||
<path
|
||
d="M7.75 2C7.94891 2 8.13968 2.07902 8.28033 2.21967C8.42098 2.36032 8.5 2.55109 8.5 2.75V7H12.75C12.9489 7 13.1397 7.07902 13.2803 7.21967C13.421 7.36032 13.5 7.55109 13.5 7.75C13.5 7.94891 13.421 8.13968 13.2803 8.28033C13.1397 8.42098 12.9489 8.5 12.75 8.5H8.5V12.75C8.5 12.9489 8.42098 13.1397 8.28033 13.2803C8.13968 13.421 7.94891 13.5 7.75 13.5C7.55109 13.5 7.36032 13.421 7.21967 13.2803C7.07902 13.1397 7 12.9489 7 12.75V8.5H2.75C2.55109 8.5 2.36032 8.42098 2.21967 8.28033C2.07902 8.13968 2 7.94891 2 7.75C2 7.55109 2.07902 7.36032 2.21967 7.21967C2.36032 7.07902 2.55109 7 2.75 7H7V2.75C7 2.55109 7.07902 2.36032 7.21967 2.21967C7.36032 2.07902 7.55109 2 7.75 2Z"
|
||
fill="currentColor"
|
||
/>
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
function MatchSelect({
|
||
value,
|
||
options,
|
||
now,
|
||
onChange,
|
||
}: {
|
||
value: string
|
||
options: MatchEvent[]
|
||
now: Date
|
||
onChange: (uuid: string) => void
|
||
}) {
|
||
const [open, setOpen] = useState(false)
|
||
const selectedMatch = options.find((match) => match.uuid === value)
|
||
|
||
return (
|
||
<div className="relative mt-5">
|
||
<label className="mb-3 block text-right text-lg font-normal text-gray-900">بازی</label>
|
||
<button
|
||
type="button"
|
||
onClick={() => setOpen((o) => !o)}
|
||
className="flex h-14 w-full items-center justify-between rounded-2xl border border-gray-200 px-4 text-right"
|
||
>
|
||
<span className={selectedMatch ? 'text-gray-900' : 'font-light text-[#999999]'}>
|
||
{selectedMatch ? formatMatchLabel(selectedMatch, now) : 'بازی مورد نظر را انتخاب کنید'}
|
||
</span>
|
||
<ChevronDownIcon className={open ? 'rotate-180' : ''} />
|
||
</button>
|
||
|
||
{open && (
|
||
<>
|
||
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
|
||
<div className="absolute inset-x-0 top-full z-20 mt-2 max-h-72 overflow-y-auto rounded-xl bg-white shadow-lg">
|
||
{options.map((match) => (
|
||
<button
|
||
key={match.uuid}
|
||
type="button"
|
||
onClick={() => {
|
||
onChange(match.uuid)
|
||
setOpen(false)
|
||
}}
|
||
className="block w-full border-b border-gray-100 px-4 py-3 text-right text-gray-900 last:border-b-0 hover:bg-gray-50"
|
||
>
|
||
{formatMatchLabel(match, now)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function Stepper({
|
||
value,
|
||
step,
|
||
min = 0,
|
||
suffix,
|
||
editable = false,
|
||
onChange,
|
||
}: {
|
||
value: number
|
||
step: number
|
||
min?: number
|
||
suffix: string
|
||
editable?: boolean
|
||
onChange: (next: number) => void
|
||
}) {
|
||
const atMin = value <= min
|
||
const measureRef = useRef<HTMLSpanElement>(null)
|
||
const [inputWidth, setInputWidth] = useState<number>(50)
|
||
|
||
useEffect(() => {
|
||
if (editable && measureRef.current) {
|
||
setInputWidth(measureRef.current.offsetWidth)
|
||
}
|
||
}, [editable, value])
|
||
|
||
const handleInputChange = (raw: string) => {
|
||
const digitsOnly = toEnglishDigits(raw).replace(/[^\d]/g, '')
|
||
onChange(digitsOnly === '' ? min : Math.max(min, Number(digitsOnly)))
|
||
}
|
||
|
||
return (
|
||
<div dir="ltr" className="flex h-14 w-full items-center justify-between rounded-2xl border border-gray-200 px-4 py-3">
|
||
<button
|
||
type="button"
|
||
onClick={() => onChange(Math.max(min, value - step))}
|
||
disabled={atMin}
|
||
className={`flex h-6 w-6 items-center justify-center ${atMin ? 'cursor-not-allowed text-[#999999]' : 'text-[#223D5D]'}`}
|
||
>
|
||
<MinusIcon className="h-4 w-4" />
|
||
</button>
|
||
|
||
{editable ? (
|
||
<div dir="rtl" className="flex flex-1 gap-2 items-center justify-center">
|
||
<span ref={measureRef} className="invisible absolute font-medium whitespace-pre">
|
||
{formatPersianNumber(value)}
|
||
</span>
|
||
<input
|
||
type="text"
|
||
inputMode="numeric"
|
||
value={formatPersianNumber(value)}
|
||
onChange={(e) => handleInputChange(e.target.value)}
|
||
style={inputWidth ? { width: `${inputWidth}px` } : undefined}
|
||
className="bg-transparent text-left font-medium text-gray-900 focus:outline-none"
|
||
/>
|
||
<span className="shrink-0 font-medium text-gray-900">{suffix}</span>
|
||
</div>
|
||
) : (
|
||
<span dir="rtl" className="text-center font-medium text-gray-900">
|
||
{formatPersianNumber(value)} {suffix}
|
||
</span>
|
||
)}
|
||
|
||
<button type="button" onClick={() => onChange(value + step)} className="flex h-6 w-6 items-center justify-center text-[#223D5D]">
|
||
<PlusIcon className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function DescriptionField({ value, onChange }: { value: string; onChange: (value: string) => void }) {
|
||
return (
|
||
<div className="mt-5">
|
||
<label className="mb-3 block text-right text-lg font-normal text-gray-900">توضیحات (اختیاری)</label>
|
||
<div className="relative rounded-2xl border border-gray-200">
|
||
<textarea
|
||
value={value}
|
||
onChange={(e) => onChange(e.target.value.slice(0, DESCRIPTION_MAX_LENGTH))}
|
||
placeholder="مثال: ورودی شامل نوشیدنی و پاپکورن"
|
||
rows={5}
|
||
className="w-full resize-none rounded-2xl bg-transparent px-4 py-3 text-right placeholder:font-light placeholder:text-[#999999] focus:outline-none"
|
||
/>
|
||
<span dir="ltr" className="absolute left-4 top-3 text-sm text-[#999999]">
|
||
{formatPersianNumber(value.length)}/{formatPersianNumber(DESCRIPTION_MAX_LENGTH)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default function AddEvent() {
|
||
const navigate = useNavigate()
|
||
const [selectedMatchUuid, setSelectedMatchUuid] = useState('')
|
||
const { matches, now } = useMatchEvents()
|
||
const [capacity, setCapacity] = useState(1)
|
||
const [entryFee, setEntryFee] = useState(100000)
|
||
const [description, setDescription] = useState('')
|
||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||
const [error, setError] = useState('')
|
||
|
||
const handleSubmit = async () => {
|
||
setError('')
|
||
setIsSubmitting(true)
|
||
try {
|
||
await createCafeSchedule(getCafeUuid() ?? '', {
|
||
match_event_uuid: selectedMatchUuid,
|
||
total_tickets: capacity,
|
||
ticket_price: String(entryFee),
|
||
description,
|
||
})
|
||
navigate('/owner/events/success')
|
||
} catch {
|
||
setError('ثبت رویداد با خطا مواجه شد. لطفاً دوباره تلاش کنید.')
|
||
} finally {
|
||
setIsSubmitting(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-white pb-24">
|
||
<div className="h-16 w-full bg-gray-200" />
|
||
|
||
<button type="button" onClick={() => navigate(-1)} className="mt-4 flex w-full items-center justify-start gap-2 px-4">
|
||
<ChevronIcon className="h-5 w-5 -scale-x-100 text-gray-900" />
|
||
<span className="text-lg font-bold text-gray-900">افزودن ایونت</span>
|
||
</button>
|
||
|
||
<div className="px-4">
|
||
<MatchSelect value={selectedMatchUuid} options={matches} now={now} onChange={setSelectedMatchUuid} />
|
||
|
||
<div className="mt-5">
|
||
<label className="mb-3 block text-right text-lg font-normal text-gray-900">ظرفیت</label>
|
||
<Stepper value={capacity} step={1} min={1} suffix="نفر" editable onChange={setCapacity} />
|
||
</div>
|
||
|
||
<div className="mt-5">
|
||
<label className="mb-3 block text-right text-lg font-normal text-gray-900">ورودی</label>
|
||
<Stepper value={entryFee} step={ENTRY_FEE_STEP} min={0} suffix="تومان" editable onChange={setEntryFee} />
|
||
</div>
|
||
|
||
<DescriptionField value={description} onChange={setDescription} />
|
||
|
||
{error && <p className="mt-4 text-right text-sm text-red-500">{error}</p>}
|
||
</div>
|
||
|
||
<div className="pointer-events-none fixed inset-x-0 bottom-0 flex justify-end bg-white px-4 py-4">
|
||
<button
|
||
type="button"
|
||
disabled={isSubmitting}
|
||
onClick={handleSubmit}
|
||
className="pointer-events-auto flex items-center gap-2 rounded-full bg-[#223D5D] px-6 py-3 font-semibold text-white disabled:opacity-50"
|
||
>
|
||
{isSubmitting ? 'در حال ارسال...' : 'ادامه'}
|
||
{!isSubmitting && <ChevronIcon className="h-4 w-4" />}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|