From e6e4c690ff6ee4934c8c60d4e7f22879a1d63b08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Afra=20=E2=80=8C?= Date: Sat, 18 Jul 2026 15:01:48 +0330 Subject: [PATCH 1/3] perf: otp and cafe register fields error handling --- src/pages/CafeOwnerRegister.tsx | 89 +++++++++++++++++++++++---------- src/pages/Login.tsx | 26 ++++++++-- 2 files changed, 84 insertions(+), 31 deletions(-) diff --git a/src/pages/CafeOwnerRegister.tsx b/src/pages/CafeOwnerRegister.tsx index a8abd45..6f296b8 100644 --- a/src/pages/CafeOwnerRegister.tsx +++ b/src/pages/CafeOwnerRegister.tsx @@ -36,6 +36,7 @@ function FormField({ value, onChange, inputDir = "ltr", + error, }: { label: string; placeholder: string; @@ -43,6 +44,7 @@ function FormField({ value: string; onChange: (value: string) => 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="مثال: ۰۹۱۲۳۴۵۶۷۸۹" -- 2.45.3 From 88386ea77ee855c3a489a19e6a196b3aecbe4841 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Afra=20=E2=80=8C?= Date: Sat, 18 Jul 2026 15:06:17 +0330 Subject: [PATCH 2/3] perf: host panel, event adding fields modified --- src/pages/AddEvent.tsx | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/src/pages/AddEvent.tsx b/src/pages/AddEvent.tsx index b4af12b..9b68dbc 100644 --- a/src/pages/AddEvent.tsx +++ b/src/pages/AddEvent.tsx @@ -21,11 +21,13 @@ function MatchSelect({ options, now, onChange, + error, }: { value: string options: MatchEvent[] now: Date onChange: (uuid: string) => void + error?: string }) { const [open, setOpen] = useState(false) const selectedMatch = options.find((match) => match.uuid === value) @@ -38,7 +40,7 @@ function MatchSelect({ type="button" disabled={!hasMatches} onClick={() => setOpen((o) => !o)} - className="flex h-14 w-full items-center justify-between rounded-2xl border border-gray-200 px-4 text-right disabled:opacity-50" + className={`flex h-14 w-full items-center justify-between rounded-2xl border px-4 text-right disabled:opacity-50 ${error ? 'border-red-400' : 'border-gray-200'}`} > {selectedMatch @@ -70,6 +72,7 @@ function MatchSelect({ )} + {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} + />
-- 2.45.3 From c4b4780a4c8f02a84b19c24f4ecb679a537b3f9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Afra=20=E2=80=8C?= Date: Sat, 18 Jul 2026 15:29:21 +0330 Subject: [PATCH 3/3] perf: reservation modal inputs and success reservation modified --- src/components/ReservationModal.tsx | 78 ++++++++++++++++++++++------- src/pages/CafeDetail.tsx | 1 + src/pages/PaymentSuccess.tsx | 51 +++++++++++++++---- src/utils/iranDate.ts | 13 +++++ 4 files changed, 115 insertions(+), 28 deletions(-) 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}

)}