diff --git a/src/components/ReservationModal.tsx b/src/components/ReservationModal.tsx index b968512..39c6b29 100644 --- a/src/components/ReservationModal.tsx +++ b/src/components/ReservationModal.tsx @@ -4,7 +4,7 @@ import { useNavigate } from "react-router-dom"; import { Cafe } from "../services/campaignApi"; import { chargePageUrl, createCharge } from "../services/ipgApi"; import { fetchWalletBalances } from "../services/walletApi"; -import { formatPersianNumber } from "../utils/formatNumber"; +import { toEnglishDigits, toPersianDigits, formatPersianNumber } from "../utils/formatNumber"; import { toE164 } from "../utils/phone"; import { setPendingReservation } from "../utils/pendingReservation"; import { getUserPhone } from "../utils/userPhone"; @@ -16,6 +16,7 @@ interface ReservationModalProps { cafe: Cafe; ticketPrice: number; cafeMatchScheduleUuid: string; + remainingTickets: number; } // TODO: there's no "get the logged-in customer's own wallet uuid" endpoint yet — @@ -69,17 +70,32 @@ export default function ReservationModal({ cafe, ticketPrice, cafeMatchScheduleUuid, + remainingTickets, }: ReservationModalProps) { const navigate = useNavigate(); const [fullName, setFullName] = useState(""); + const [phone, setPhone] = useState(() => getUserPhone() ?? ""); const [count, setCount] = useState(1); const [walletBalance, setWalletBalance] = useState(0); const [isSubmitting, setIsSubmitting] = useState(false); - const [error, setError] = useState(""); + const [fieldErrors, setFieldErrors] = useState>({}); + const [payError, setPayError] = useState(""); + + const clearFieldError = (field: string) => + setFieldErrors((prev) => { + if (!prev[field]) return prev; + const next = { ...prev }; + delete next[field]; + return next; + }); useEffect(() => { if (!isOpen) return; - + setFullName(""); + setPhone(getUserPhone() ?? ""); + setCount(1); + setFieldErrors({}); + setPayError(""); const previousOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; return () => { @@ -98,12 +114,16 @@ export default function ReservationModal({ const reservationAmount = ticketPrice * count; const payableAmount = Math.max(0, reservationAmount - walletBalance); + const atMaxCount = remainingTickets > 0 && count >= remainingTickets; const handlePay = async () => { - setError(""); + setPayError(""); - if (!fullName.trim()) { - setError("لطفاً نام و نام خانوادگی را وارد کنید."); + const errors: Record = {}; + if (!fullName.trim()) errors.name = "نام و نام خانوادگی را وارد کنید"; + if (phone.length !== 11) errors.phone = "شماره تلفن باید ۱۱ رقم باشد"; + if (Object.keys(errors).length > 0) { + setFieldErrors(errors); return; } @@ -113,7 +133,7 @@ export default function ReservationModal({ cafe_match_schedule_uuid: cafeMatchScheduleUuid, people_count: count, full_name: fullName, - phone_number: toE164(getUserPhone() ?? ""), + phone_number: toE164(phone), }; try { @@ -134,7 +154,7 @@ export default function ReservationModal({ navigate("/payment/success?state=20"); } } catch { - setError( + setPayError( "اتصال به درگاه پرداخت با خطا مواجه شد. لطفاً دوباره تلاش کنید.", ); setIsSubmitting(false); @@ -162,10 +182,15 @@ export default function ReservationModal({ setFullName(e.target.value)} + onChange={(e) => { setFullName(e.target.value); clearFieldError("name"); }} placeholder="نام و نام خانوادگی" - className="w-full rounded-2xl border border-gray-200 px-4 py-3 text-right placeholder:font-light placeholder:text-[#999999]" + className={`w-full rounded-2xl border px-4 py-3 text-right placeholder:font-light placeholder:text-[#999999] ${ + fieldErrors.name ? "border-red-400" : "border-gray-200" + }`} /> + {fieldErrors.name && ( +

{fieldErrors.name}

+ )}
@@ -174,11 +199,22 @@ export default function ReservationModal({ { + const digits = toEnglishDigits(e.target.value).replace(/\D/g, "").slice(0, 11); + setPhone(toPersianDigits(digits)); + clearFieldError("phone"); + }} placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹" - className="w-full rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3 text-right placeholder:font-light placeholder:text-[#999999]" + className={`w-full rounded-2xl border px-4 py-3 text-right placeholder:font-light placeholder:text-[#999999] ${ + fieldErrors.phone ? "border-red-400" : "border-gray-200" + }`} /> + {fieldErrors.phone && ( +

{fieldErrors.phone}

+ )}
@@ -206,12 +242,20 @@ export default function ReservationModal({
+ {remainingTickets > 0 && ( +

+ حداکثر {formatPersianNumber(remainingTickets)} نفر +

+ )}

@@ -243,8 +287,8 @@ export default function ReservationModal({
- {error && ( -

{error}

+ {payError && ( +

{payError}

)}
)} + {error &&

{error}

} ) } @@ -171,8 +174,24 @@ export default function AddEvent() { const [entryFee, setEntryFee] = useState(100000) const [description, setDescription] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) + const [fieldErrors, setFieldErrors] = useState>({}) + + const clearFieldError = (field: string) => + setFieldErrors((prev) => { + if (!prev[field]) return prev + const next = { ...prev } + delete next[field] + return next + }) const handleSubmit = async () => { + const errors: Record = {} + if (!selectedMatchUuid) errors.match = 'بازی را انتخاب کنید' + if (Object.keys(errors).length > 0) { + setFieldErrors(errors) + return + } + setFieldErrors({}) setIsSubmitting(true) try { await createCafeSchedule(getCafeUuid() ?? '', { @@ -199,7 +218,13 @@ export default function AddEvent() {
- + { setSelectedMatchUuid(uuid); clearFieldError('match') }} + error={fieldErrors.match} + />
diff --git a/src/pages/CafeDetail.tsx b/src/pages/CafeDetail.tsx index ecc2466..deebdf6 100644 --- a/src/pages/CafeDetail.tsx +++ b/src/pages/CafeDetail.tsx @@ -399,6 +399,7 @@ export default function CafeDetail() { cafe={cafe} ticketPrice={schedule ? Number(schedule.ticket_price) : 0} cafeMatchScheduleUuid={schedule?.schedule_uuid ?? ""} + remainingTickets={schedule?.remaining_tickets ?? 0} /> void; inputDir?: "ltr" | "rtl"; + error?: string; }) { return (
@@ -55,11 +57,14 @@ function FormField({ value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} - className="w-full rounded-2xl border border-gray-200 px-4 py-3 text-right h-14 placeholder:font-light placeholder:text-[#999999]" + className={`w-full rounded-2xl border px-4 py-3 text-right h-14 placeholder:font-light placeholder:text-[#999999] ${ + error ? "border-red-400" : "border-gray-200" + }`} /> - {hint && ( -

{hint}

- )} + {error + ?

{error}

+ : hint &&

{hint}

+ }
); } @@ -253,10 +258,12 @@ function PhotoUpload({ photos, onAdd, onRemove, + error, }: { photos: UploadedPhoto[]; onAdd: (files: File[]) => void; onRemove: (id: string) => void; + error?: string; }) { const inputRef = useRef(null); const [isDragging, setIsDragging] = useState(false); @@ -351,6 +358,7 @@ function PhotoUpload({ e.target.value = ""; }} /> + {error &&

{error}

}
); } @@ -359,10 +367,12 @@ function DocumentUpload({ doc, onAdd, onRemove, + error, }: { doc: UploadedDocument | null; onAdd: (file: File) => void; onRemove: () => void; + error?: string; }) { const inputRef = useRef(null); const [isDragging, setIsDragging] = useState(false); @@ -442,6 +452,7 @@ function DocumentUpload({ e.target.value = ""; }} /> + {error &&

{error}

}
); } @@ -494,7 +505,16 @@ export default function CafeOwnerRegister() { const [photos, setPhotos] = useState([]); const [licenseDoc, setLicenseDoc] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); - const [error, setError] = useState(""); + const [fieldErrors, setFieldErrors] = useState>({}); + const [submitError, setSubmitError] = useState(""); + + const clearFieldError = (field: string) => + setFieldErrors((prev) => { + if (!prev[field]) return prev; + const next = { ...prev }; + delete next[field]; + return next; + }); // keep refs so the unmount cleanup can revoke any remaining object URLs const photosRef = useRef(photos); @@ -662,24 +682,33 @@ export default function CafeOwnerRegister() { }; const handleContinue = async () => { + const errors: Record = {}; + + if (!cafeName.trim()) errors.cafeName = "نام کافه الزامی است"; + if (!address.trim()) errors.address = "آدرس الزامی است"; + if (!cafePhone.trim()) errors.cafePhone = "شماره تماس الزامی است"; + if (selectedFeatures.length === 0) errors.features = "حداقل یک ویژگی انتخاب کنید"; + const stillUploading = photos.some((p) => p.status === "uploading") || licenseDoc?.status === "uploading"; + if (stillUploading) { - setError("لطفاً منتظر بمانید تا آپلود فایل‌ها کامل شود."); + errors.photos = "لطفاً منتظر بمانید تا آپلود فایل‌ها کامل شود"; + } else { + if (!photos.some((p) => p.status === "done")) + errors.photos = "حداقل یک تصویر از کافه الزامی است"; + if (!licenseDoc || licenseDoc.status !== "done") + errors.licenseDoc = "تصویر مجوز کسب و کار الزامی است"; + } + + if (Object.keys(errors).length > 0) { + setFieldErrors(errors); return; } - if (!isEditMode) { - if (!photos.some((p) => p.status === "done")) { - setError("لطفاً حداقل یک تصویر برای کافه آپلود کنید."); - return; - } - if (!licenseDoc || licenseDoc.status !== "done") { - setError("لطفاً تصویر مجوز کسب و کار را آپلود کنید."); - return; - } - } - setError(""); + + setFieldErrors({}); + setSubmitError(""); setIsSubmitting(true); try { const mediaItems: CafeMediaItem[] = [ @@ -719,7 +748,7 @@ export default function CafeOwnerRegister() { if (!isEditMode) { navigate("/owner/register/fail"); } else { - setError("ذخیره تغییرات با خطا مواجه شد. لطفاً دوباره تلاش کنید."); + setSubmitError("ذخیره تغییرات با خطا مواجه شد. لطفاً دوباره تلاش کنید."); } } finally { setIsSubmitting(false); @@ -735,7 +764,8 @@ export default function CafeOwnerRegister() { label="نام کافه" placeholder="نام کافه را وارد کنید" value={cafeName} - onChange={setCafeName} + onChange={(v) => { setCafeName(v); clearFieldError("cafeName"); }} + error={fieldErrors.cafeName} /> { setAddress(v); clearFieldError("address"); }} + error={fieldErrors.address} /> { setCafePhone(v); clearFieldError("cafePhone"); }} inputDir="ltr" + error={fieldErrors.cafePhone} /> { clearFieldError("photos"); handleAddPhotos(files); }} onRemove={handleRemovePhoto} + error={fieldErrors.photos} /> { clearFieldError("licenseDoc"); handleAddLicense(file); }} onRemove={handleRemoveLicense} + error={fieldErrors.licenseDoc} />
@@ -782,14 +816,17 @@ export default function CafeOwnerRegister() { key={feature.uuid} label={feature.name} checked={selectedFeatures.includes(feature.uuid)} - onToggle={() => toggleFeature(feature.uuid)} + onToggle={() => { toggleFeature(feature.uuid); clearFieldError("features"); }} /> ))}
+ {fieldErrors.features && ( +

{fieldErrors.features}

+ )} - {error && ( -

{error}

+ {submitError && ( +

{submitError}

)} diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 7542bab..84e5185 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -2,10 +2,21 @@ import { useEffect, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import Icon from "../components/Icon"; import { requestOtp } from "../services/authApi"; -import { toPersianDigits } from "../utils/formatNumber"; +import { toEnglishDigits, toPersianDigits } from "../utils/formatNumber"; import { toE164 } from "../utils/phone"; import { setUserRole } from "../utils/role"; +interface ApiErrorResponse { + status_message?: string; + details?: { message?: Record }; +} + +function extractApiError(error: unknown): string { + const data = (error as { response?: { data?: ApiErrorResponse } })?.response?.data; + if (data?.status_message) return data.status_message; + return "ارسال کد با خطا مواجه شد. لطفاً دوباره تلاش کنید."; +} + export default function Login() { const navigate = useNavigate(); const location = useLocation(); @@ -21,16 +32,20 @@ export default function Login() { const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(""); - const canContinue = phone.trim().length > 0 && acceptedTerms && !isSubmitting; + const canContinue = phone.length === 11 && acceptedTerms && !isSubmitting; const handleContinue = async () => { setError(""); + if (phone.length !== 11) { + setError("شماره تلفن باید ۱۱ رقم باشد."); + return; + } setIsSubmitting(true); try { const { ttl } = await requestOtp(toE164(phone)); navigate("/login/otp", { state: { phone, ttl, role: isOwnerFlow ? "cafe_owner" : "customer" } }); - } catch { - setError("ارسال کد با خطا مواجه شد. لطفاً دوباره تلاش کنید."); + } catch (err) { + setError(extractApiError(err)); } finally { setIsSubmitting(false); } @@ -58,7 +73,8 @@ export default function Login() { enterKeyHint="go" value={phone} onChange={(e) => { - setPhone(toPersianDigits(e.target.value)); + const digits = toEnglishDigits(e.target.value).replace(/\D/g, "").slice(0, 11); + setPhone(toPersianDigits(digits)); setError(""); }} placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹" diff --git a/src/pages/PaymentSuccess.tsx b/src/pages/PaymentSuccess.tsx index 64b9866..32ec741 100644 --- a/src/pages/PaymentSuccess.tsx +++ b/src/pages/PaymentSuccess.tsx @@ -2,10 +2,15 @@ import { useEffect, useRef, useState } from 'react' import { useNavigate, useSearchParams } from 'react-router-dom' import Icon from '../components/Icon' import PaymentFailModal from '../components/PaymentFailModal' -import { createReservation, ReservationResult } from '../services/campaignApi' +import { + createReservation, + fetchMyReservationByUuid, + MyReservation, +} from '../services/campaignApi' import { formatPersianNumber } from '../utils/formatNumber' import { fromE164 } from '../utils/phone' import { takePendingReservation } from '../utils/pendingReservation' +import { formatMatchDatetime } from '../utils/iranDate' // the IPG gateway redirects back here as `/payment/success?state=` regardless of // whether the charge actually succeeded — `20` is the only code confirmed as "succeeded" so far @@ -13,9 +18,9 @@ const SUCCESS_STATE = '20' function DetailRow({ label, value }: { label: string; value: string }) { return ( -
- {label} - {value} +
+ {value} + {label}
) } @@ -24,10 +29,8 @@ export default function PaymentSuccess() { const navigate = useNavigate() const [searchParams] = useSearchParams() const isSuccess = searchParams.get('state') === SUCCESS_STATE - const [reservation, setReservation] = useState(null) + const [reservation, setReservation] = useState(null) const [hasError, setHasError] = useState(false) - // gateway-reported failure, or a successful payment whose reservation couldn't be created — - // both land on the same fail modal const isFail = !isSuccess || hasError // React 18 StrictMode double-invokes effects; the ref persists across the simulated @@ -44,7 +47,24 @@ export default function PaymentSuccess() { return } createReservation(pending) - .then(setReservation) + .then((result) => + fetchMyReservationByUuid(result.uuid) + .then(setReservation) + .catch(() => { + // full detail fetch failed — synthesise what we have from the create response + setReservation({ + uuid: result.uuid, + reservation_code: result.reserve_code, + ticket_count: result.people_count, + full_name: result.full_name, + phone_number: result.phone_number, + cafe_name: '', + event_title: '', + event_datetime: '', + status: result.status, + }) + }), + ) .catch(() => setHasError(true)) }, [isSuccess]) @@ -58,11 +78,20 @@ export default function PaymentSuccess() { {reservation ? ( <>

جزئیات رزرو

-
- +
+ - + {reservation.cafe_name && ( + + )} + {reservation.event_title && ( + + )} + {reservation.event_datetime && ( + + )} +
diff --git a/src/utils/iranDate.ts b/src/utils/iranDate.ts index 318d51a..0b6dd79 100644 --- a/src/utils/iranDate.ts +++ b/src/utils/iranDate.ts @@ -66,3 +66,16 @@ export function formatIranKickoffTime(matchDatetimeIso: string): string { }) return toPersianDigits(time) } + +// e.g. "پنجشنبه ۲۷ خرداد - ساعت ۲:۰۰" +export function formatMatchDatetime(datetimeIso: string): string { + const date = new Date(datetimeIso) + const weekday = new Intl.DateTimeFormat('fa-IR', { + timeZone: IRAN_TIME_ZONE, + weekday: 'long', + }).format(date) + const { month, day } = getIranJalaliParts(date) + const monthName = PERSIAN_MONTHS[month - 1] ?? '' + const time = formatIranKickoffTime(datetimeIso) + return `${weekday} ${toPersianDigits(String(day))} ${monthName} - ساعت ${time}` +}