Merge pull request 'feature/WNPT-395' (#5) from feature/WNPT-395 into develop
Reviewed-on: #5
This commit is contained in:
commit
43a6f05045
7 changed files with 226 additions and 61 deletions
|
|
@ -4,7 +4,7 @@ import { useNavigate } from "react-router-dom";
|
||||||
import { Cafe } from "../services/campaignApi";
|
import { Cafe } from "../services/campaignApi";
|
||||||
import { chargePageUrl, createCharge } from "../services/ipgApi";
|
import { chargePageUrl, createCharge } from "../services/ipgApi";
|
||||||
import { fetchWalletBalances } from "../services/walletApi";
|
import { fetchWalletBalances } from "../services/walletApi";
|
||||||
import { formatPersianNumber } from "../utils/formatNumber";
|
import { toEnglishDigits, toPersianDigits, formatPersianNumber } from "../utils/formatNumber";
|
||||||
import { toE164 } from "../utils/phone";
|
import { toE164 } from "../utils/phone";
|
||||||
import { setPendingReservation } from "../utils/pendingReservation";
|
import { setPendingReservation } from "../utils/pendingReservation";
|
||||||
import { getUserPhone } from "../utils/userPhone";
|
import { getUserPhone } from "../utils/userPhone";
|
||||||
|
|
@ -16,6 +16,7 @@ interface ReservationModalProps {
|
||||||
cafe: Cafe;
|
cafe: Cafe;
|
||||||
ticketPrice: number;
|
ticketPrice: number;
|
||||||
cafeMatchScheduleUuid: string;
|
cafeMatchScheduleUuid: string;
|
||||||
|
remainingTickets: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: there's no "get the logged-in customer's own wallet uuid" endpoint yet —
|
// TODO: there's no "get the logged-in customer's own wallet uuid" endpoint yet —
|
||||||
|
|
@ -69,17 +70,32 @@ export default function ReservationModal({
|
||||||
cafe,
|
cafe,
|
||||||
ticketPrice,
|
ticketPrice,
|
||||||
cafeMatchScheduleUuid,
|
cafeMatchScheduleUuid,
|
||||||
|
remainingTickets,
|
||||||
}: ReservationModalProps) {
|
}: ReservationModalProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [fullName, setFullName] = useState("");
|
const [fullName, setFullName] = useState("");
|
||||||
|
const [phone, setPhone] = useState(() => getUserPhone() ?? "");
|
||||||
const [count, setCount] = useState(1);
|
const [count, setCount] = useState(1);
|
||||||
const [walletBalance, setWalletBalance] = useState(0);
|
const [walletBalance, setWalletBalance] = useState(0);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||||
|
const [payError, setPayError] = useState("");
|
||||||
|
|
||||||
|
const clearFieldError = (field: string) =>
|
||||||
|
setFieldErrors((prev) => {
|
||||||
|
if (!prev[field]) return prev;
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[field];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
|
setFullName("");
|
||||||
|
setPhone(getUserPhone() ?? "");
|
||||||
|
setCount(1);
|
||||||
|
setFieldErrors({});
|
||||||
|
setPayError("");
|
||||||
const previousOverflow = document.body.style.overflow;
|
const previousOverflow = document.body.style.overflow;
|
||||||
document.body.style.overflow = "hidden";
|
document.body.style.overflow = "hidden";
|
||||||
return () => {
|
return () => {
|
||||||
|
|
@ -98,12 +114,16 @@ export default function ReservationModal({
|
||||||
|
|
||||||
const reservationAmount = ticketPrice * count;
|
const reservationAmount = ticketPrice * count;
|
||||||
const payableAmount = Math.max(0, reservationAmount - walletBalance);
|
const payableAmount = Math.max(0, reservationAmount - walletBalance);
|
||||||
|
const atMaxCount = remainingTickets > 0 && count >= remainingTickets;
|
||||||
|
|
||||||
const handlePay = async () => {
|
const handlePay = async () => {
|
||||||
setError("");
|
setPayError("");
|
||||||
|
|
||||||
if (!fullName.trim()) {
|
const errors: Record<string, string> = {};
|
||||||
setError("لطفاً نام و نام خانوادگی را وارد کنید.");
|
if (!fullName.trim()) errors.name = "نام و نام خانوادگی را وارد کنید";
|
||||||
|
if (phone.length !== 11) errors.phone = "شماره تلفن باید ۱۱ رقم باشد";
|
||||||
|
if (Object.keys(errors).length > 0) {
|
||||||
|
setFieldErrors(errors);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -113,7 +133,7 @@ export default function ReservationModal({
|
||||||
cafe_match_schedule_uuid: cafeMatchScheduleUuid,
|
cafe_match_schedule_uuid: cafeMatchScheduleUuid,
|
||||||
people_count: count,
|
people_count: count,
|
||||||
full_name: fullName,
|
full_name: fullName,
|
||||||
phone_number: toE164(getUserPhone() ?? ""),
|
phone_number: toE164(phone),
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -134,7 +154,7 @@ export default function ReservationModal({
|
||||||
navigate("/payment/success?state=20");
|
navigate("/payment/success?state=20");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setError(
|
setPayError(
|
||||||
"اتصال به درگاه پرداخت با خطا مواجه شد. لطفاً دوباره تلاش کنید.",
|
"اتصال به درگاه پرداخت با خطا مواجه شد. لطفاً دوباره تلاش کنید.",
|
||||||
);
|
);
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
|
|
@ -162,10 +182,15 @@ export default function ReservationModal({
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={fullName}
|
value={fullName}
|
||||||
onChange={(e) => setFullName(e.target.value)}
|
onChange={(e) => { setFullName(e.target.value); clearFieldError("name"); }}
|
||||||
placeholder="نام و نام خانوادگی"
|
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 && (
|
||||||
|
<p className="mt-1 text-right text-sm text-red-500">{fieldErrors.name}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
|
|
@ -174,11 +199,22 @@ export default function ReservationModal({
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={getUserPhone() ?? ""}
|
dir="ltr"
|
||||||
readOnly
|
inputMode="numeric"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => {
|
||||||
|
const digits = toEnglishDigits(e.target.value).replace(/\D/g, "").slice(0, 11);
|
||||||
|
setPhone(toPersianDigits(digits));
|
||||||
|
clearFieldError("phone");
|
||||||
|
}}
|
||||||
placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹"
|
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 && (
|
||||||
|
<p className="mt-1 text-right text-sm text-red-500">{fieldErrors.phone}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
|
|
@ -206,12 +242,20 @@ export default function ReservationModal({
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setCount((prev) => prev + 1)}
|
onClick={() => setCount((prev) => Math.min(remainingTickets, prev + 1))}
|
||||||
className="flex h-6 w-6 items-center justify-center text-[#223D5D]"
|
disabled={atMaxCount}
|
||||||
|
className={`flex h-6 w-6 items-center justify-center ${
|
||||||
|
atMaxCount ? "cursor-not-allowed text-[#999999]" : "text-[#223D5D]"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<Icon name="plus" className="h-4 w-4" />
|
<Icon name="plus" className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{remainingTickets > 0 && (
|
||||||
|
<p className="mt-1 text-right text-sm text-gray-400">
|
||||||
|
حداکثر {formatPersianNumber(remainingTickets)} نفر
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="mt-4 mb-3 text-right font-semibold text-gray-900">
|
<h3 className="mt-4 mb-3 text-right font-semibold text-gray-900">
|
||||||
|
|
@ -243,8 +287,8 @@ export default function ReservationModal({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sticky inset-x-0 bottom-0 bg-white px-5 pt-3 pb-6">
|
<div className="sticky inset-x-0 bottom-0 bg-white px-5 pt-3 pb-6">
|
||||||
{error && (
|
{payError && (
|
||||||
<p className="mb-2 text-center text-sm text-red-500">{error}</p>
|
<p className="mb-2 text-center text-sm text-red-500">{payError}</p>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,13 @@ function MatchSelect({
|
||||||
options,
|
options,
|
||||||
now,
|
now,
|
||||||
onChange,
|
onChange,
|
||||||
|
error,
|
||||||
}: {
|
}: {
|
||||||
value: string
|
value: string
|
||||||
options: MatchEvent[]
|
options: MatchEvent[]
|
||||||
now: Date
|
now: Date
|
||||||
onChange: (uuid: string) => void
|
onChange: (uuid: string) => void
|
||||||
|
error?: string
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const selectedMatch = options.find((match) => match.uuid === value)
|
const selectedMatch = options.find((match) => match.uuid === value)
|
||||||
|
|
@ -38,7 +40,7 @@ function MatchSelect({
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!hasMatches}
|
disabled={!hasMatches}
|
||||||
onClick={() => setOpen((o) => !o)}
|
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'}`}
|
||||||
>
|
>
|
||||||
<span className={selectedMatch ? 'text-gray-900' : 'font-light text-[#999999]'}>
|
<span className={selectedMatch ? 'text-gray-900' : 'font-light text-[#999999]'}>
|
||||||
{selectedMatch
|
{selectedMatch
|
||||||
|
|
@ -70,6 +72,7 @@ function MatchSelect({
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{error && <p className="mt-1 text-right text-sm text-red-500">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -171,8 +174,24 @@ export default function AddEvent() {
|
||||||
const [entryFee, setEntryFee] = useState(100000)
|
const [entryFee, setEntryFee] = useState(100000)
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
|
const clearFieldError = (field: string) =>
|
||||||
|
setFieldErrors((prev) => {
|
||||||
|
if (!prev[field]) return prev
|
||||||
|
const next = { ...prev }
|
||||||
|
delete next[field]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
const errors: Record<string, string> = {}
|
||||||
|
if (!selectedMatchUuid) errors.match = 'بازی را انتخاب کنید'
|
||||||
|
if (Object.keys(errors).length > 0) {
|
||||||
|
setFieldErrors(errors)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setFieldErrors({})
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
try {
|
try {
|
||||||
await createCafeSchedule(getCafeUuid() ?? '', {
|
await createCafeSchedule(getCafeUuid() ?? '', {
|
||||||
|
|
@ -199,7 +218,13 @@ export default function AddEvent() {
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="px-4">
|
<div className="px-4">
|
||||||
<MatchSelect value={selectedMatchUuid} options={matches} now={now} onChange={setSelectedMatchUuid} />
|
<MatchSelect
|
||||||
|
value={selectedMatchUuid}
|
||||||
|
options={matches}
|
||||||
|
now={now}
|
||||||
|
onChange={(uuid) => { setSelectedMatchUuid(uuid); clearFieldError('match') }}
|
||||||
|
error={fieldErrors.match}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
<label className="mb-3 block text-right text-lg font-normal text-gray-900">ظرفیت</label>
|
<label className="mb-3 block text-right text-lg font-normal text-gray-900">ظرفیت</label>
|
||||||
|
|
|
||||||
|
|
@ -399,6 +399,7 @@ export default function CafeDetail() {
|
||||||
cafe={cafe}
|
cafe={cafe}
|
||||||
ticketPrice={schedule ? Number(schedule.ticket_price) : 0}
|
ticketPrice={schedule ? Number(schedule.ticket_price) : 0}
|
||||||
cafeMatchScheduleUuid={schedule?.schedule_uuid ?? ""}
|
cafeMatchScheduleUuid={schedule?.schedule_uuid ?? ""}
|
||||||
|
remainingTickets={schedule?.remaining_tickets ?? 0}
|
||||||
/>
|
/>
|
||||||
<NavigationSheet
|
<NavigationSheet
|
||||||
isOpen={isNavigationOpen}
|
isOpen={isNavigationOpen}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ function FormField({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
inputDir = "ltr",
|
inputDir = "ltr",
|
||||||
|
error,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
|
|
@ -43,6 +44,7 @@ function FormField({
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
inputDir?: "ltr" | "rtl";
|
inputDir?: "ltr" | "rtl";
|
||||||
|
error?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
|
|
@ -55,11 +57,14 @@ function FormField({
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
placeholder={placeholder}
|
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 && (
|
{error
|
||||||
<p className="mt-1 text-right text-sm text-gray-500">{hint}</p>
|
? <p className="mt-1 text-right text-sm text-red-500">{error}</p>
|
||||||
)}
|
: hint && <p className="mt-1 text-right text-sm text-gray-500">{hint}</p>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -253,10 +258,12 @@ function PhotoUpload({
|
||||||
photos,
|
photos,
|
||||||
onAdd,
|
onAdd,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
error,
|
||||||
}: {
|
}: {
|
||||||
photos: UploadedPhoto[];
|
photos: UploadedPhoto[];
|
||||||
onAdd: (files: File[]) => void;
|
onAdd: (files: File[]) => void;
|
||||||
onRemove: (id: string) => void;
|
onRemove: (id: string) => void;
|
||||||
|
error?: string;
|
||||||
}) {
|
}) {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
@ -351,6 +358,7 @@ function PhotoUpload({
|
||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{error && <p className="mt-1 text-right text-sm text-red-500">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -359,10 +367,12 @@ function DocumentUpload({
|
||||||
doc,
|
doc,
|
||||||
onAdd,
|
onAdd,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
error,
|
||||||
}: {
|
}: {
|
||||||
doc: UploadedDocument | null;
|
doc: UploadedDocument | null;
|
||||||
onAdd: (file: File) => void;
|
onAdd: (file: File) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
|
error?: string;
|
||||||
}) {
|
}) {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
@ -442,6 +452,7 @@ function DocumentUpload({
|
||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{error && <p className="mt-1 text-right text-sm text-red-500">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -494,7 +505,16 @@ export default function CafeOwnerRegister() {
|
||||||
const [photos, setPhotos] = useState<UploadedPhoto[]>([]);
|
const [photos, setPhotos] = useState<UploadedPhoto[]>([]);
|
||||||
const [licenseDoc, setLicenseDoc] = useState<UploadedDocument | null>(null);
|
const [licenseDoc, setLicenseDoc] = useState<UploadedDocument | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||||
|
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
|
// keep refs so the unmount cleanup can revoke any remaining object URLs
|
||||||
const photosRef = useRef(photos);
|
const photosRef = useRef(photos);
|
||||||
|
|
@ -662,24 +682,33 @@ export default function CafeOwnerRegister() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleContinue = async () => {
|
const handleContinue = async () => {
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (!cafeName.trim()) errors.cafeName = "نام کافه الزامی است";
|
||||||
|
if (!address.trim()) errors.address = "آدرس الزامی است";
|
||||||
|
if (!cafePhone.trim()) errors.cafePhone = "شماره تماس الزامی است";
|
||||||
|
if (selectedFeatures.length === 0) errors.features = "حداقل یک ویژگی انتخاب کنید";
|
||||||
|
|
||||||
const stillUploading =
|
const stillUploading =
|
||||||
photos.some((p) => p.status === "uploading") ||
|
photos.some((p) => p.status === "uploading") ||
|
||||||
licenseDoc?.status === "uploading";
|
licenseDoc?.status === "uploading";
|
||||||
|
|
||||||
if (stillUploading) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (!isEditMode) {
|
|
||||||
if (!photos.some((p) => p.status === "done")) {
|
setFieldErrors({});
|
||||||
setError("لطفاً حداقل یک تصویر برای کافه آپلود کنید.");
|
setSubmitError("");
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!licenseDoc || licenseDoc.status !== "done") {
|
|
||||||
setError("لطفاً تصویر مجوز کسب و کار را آپلود کنید.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setError("");
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const mediaItems: CafeMediaItem[] = [
|
const mediaItems: CafeMediaItem[] = [
|
||||||
|
|
@ -719,7 +748,7 @@ export default function CafeOwnerRegister() {
|
||||||
if (!isEditMode) {
|
if (!isEditMode) {
|
||||||
navigate("/owner/register/fail");
|
navigate("/owner/register/fail");
|
||||||
} else {
|
} else {
|
||||||
setError("ذخیره تغییرات با خطا مواجه شد. لطفاً دوباره تلاش کنید.");
|
setSubmitError("ذخیره تغییرات با خطا مواجه شد. لطفاً دوباره تلاش کنید.");
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
|
|
@ -735,7 +764,8 @@ export default function CafeOwnerRegister() {
|
||||||
label="نام کافه"
|
label="نام کافه"
|
||||||
placeholder="نام کافه را وارد کنید"
|
placeholder="نام کافه را وارد کنید"
|
||||||
value={cafeName}
|
value={cafeName}
|
||||||
onChange={setCafeName}
|
onChange={(v) => { setCafeName(v); clearFieldError("cafeName"); }}
|
||||||
|
error={fieldErrors.cafeName}
|
||||||
/>
|
/>
|
||||||
<CitySelectField
|
<CitySelectField
|
||||||
value={effectiveCityUuid}
|
value={effectiveCityUuid}
|
||||||
|
|
@ -746,7 +776,8 @@ export default function CafeOwnerRegister() {
|
||||||
label="آدرس"
|
label="آدرس"
|
||||||
placeholder="موقعیت کافه را با جزئیات وارد کنید"
|
placeholder="موقعیت کافه را با جزئیات وارد کنید"
|
||||||
value={address}
|
value={address}
|
||||||
onChange={setAddress}
|
onChange={(v) => { setAddress(v); clearFieldError("address"); }}
|
||||||
|
error={fieldErrors.address}
|
||||||
/>
|
/>
|
||||||
<CafeLocationMap
|
<CafeLocationMap
|
||||||
label="موقعیت روی نقشه"
|
label="موقعیت روی نقشه"
|
||||||
|
|
@ -758,18 +789,21 @@ export default function CafeOwnerRegister() {
|
||||||
placeholder={`\u2066۰۲۱-۱۲۳۴۵۶۷۸\u2069 :مثال`}
|
placeholder={`\u2066۰۲۱-۱۲۳۴۵۶۷۸\u2069 :مثال`}
|
||||||
hint="شماره را همراه با پیششماره شهر وارد کنید."
|
hint="شماره را همراه با پیششماره شهر وارد کنید."
|
||||||
value={cafePhone}
|
value={cafePhone}
|
||||||
onChange={setCafePhone}
|
onChange={(v) => { setCafePhone(v); clearFieldError("cafePhone"); }}
|
||||||
inputDir="ltr"
|
inputDir="ltr"
|
||||||
|
error={fieldErrors.cafePhone}
|
||||||
/>
|
/>
|
||||||
<PhotoUpload
|
<PhotoUpload
|
||||||
photos={photos}
|
photos={photos}
|
||||||
onAdd={handleAddPhotos}
|
onAdd={(files) => { clearFieldError("photos"); handleAddPhotos(files); }}
|
||||||
onRemove={handleRemovePhoto}
|
onRemove={handleRemovePhoto}
|
||||||
|
error={fieldErrors.photos}
|
||||||
/>
|
/>
|
||||||
<DocumentUpload
|
<DocumentUpload
|
||||||
doc={licenseDoc}
|
doc={licenseDoc}
|
||||||
onAdd={handleAddLicense}
|
onAdd={(file) => { clearFieldError("licenseDoc"); handleAddLicense(file); }}
|
||||||
onRemove={handleRemoveLicense}
|
onRemove={handleRemoveLicense}
|
||||||
|
error={fieldErrors.licenseDoc}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
|
|
@ -782,14 +816,17 @@ export default function CafeOwnerRegister() {
|
||||||
key={feature.uuid}
|
key={feature.uuid}
|
||||||
label={feature.name}
|
label={feature.name}
|
||||||
checked={selectedFeatures.includes(feature.uuid)}
|
checked={selectedFeatures.includes(feature.uuid)}
|
||||||
onToggle={() => toggleFeature(feature.uuid)}
|
onToggle={() => { toggleFeature(feature.uuid); clearFieldError("features"); }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{fieldErrors.features && (
|
||||||
|
<p className="mt-1 text-right text-sm text-red-500">{fieldErrors.features}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{submitError && (
|
||||||
<p className="mt-4 text-right text-sm text-red-500">{error}</p>
|
<p className="mt-4 text-right text-sm text-red-500">{submitError}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,21 @@ import { useEffect, useState } from "react";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
import { requestOtp } from "../services/authApi";
|
import { requestOtp } from "../services/authApi";
|
||||||
import { toPersianDigits } from "../utils/formatNumber";
|
import { toEnglishDigits, toPersianDigits } from "../utils/formatNumber";
|
||||||
import { toE164 } from "../utils/phone";
|
import { toE164 } from "../utils/phone";
|
||||||
import { setUserRole } from "../utils/role";
|
import { setUserRole } from "../utils/role";
|
||||||
|
|
||||||
|
interface ApiErrorResponse {
|
||||||
|
status_message?: string;
|
||||||
|
details?: { message?: Record<string, string[]> };
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
export default function Login() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
@ -21,16 +32,20 @@ export default function Login() {
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
const canContinue = phone.trim().length > 0 && acceptedTerms && !isSubmitting;
|
const canContinue = phone.length === 11 && acceptedTerms && !isSubmitting;
|
||||||
|
|
||||||
const handleContinue = async () => {
|
const handleContinue = async () => {
|
||||||
setError("");
|
setError("");
|
||||||
|
if (phone.length !== 11) {
|
||||||
|
setError("شماره تلفن باید ۱۱ رقم باشد.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const { ttl } = await requestOtp(toE164(phone));
|
const { ttl } = await requestOtp(toE164(phone));
|
||||||
navigate("/login/otp", { state: { phone, ttl, role: isOwnerFlow ? "cafe_owner" : "customer" } });
|
navigate("/login/otp", { state: { phone, ttl, role: isOwnerFlow ? "cafe_owner" : "customer" } });
|
||||||
} catch {
|
} catch (err) {
|
||||||
setError("ارسال کد با خطا مواجه شد. لطفاً دوباره تلاش کنید.");
|
setError(extractApiError(err));
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
|
|
@ -58,7 +73,8 @@ export default function Login() {
|
||||||
enterKeyHint="go"
|
enterKeyHint="go"
|
||||||
value={phone}
|
value={phone}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setPhone(toPersianDigits(e.target.value));
|
const digits = toEnglishDigits(e.target.value).replace(/\D/g, "").slice(0, 11);
|
||||||
|
setPhone(toPersianDigits(digits));
|
||||||
setError("");
|
setError("");
|
||||||
}}
|
}}
|
||||||
placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹"
|
placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹"
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,15 @@ import { useEffect, useRef, useState } from 'react'
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import Icon from '../components/Icon'
|
import Icon from '../components/Icon'
|
||||||
import PaymentFailModal from '../components/PaymentFailModal'
|
import PaymentFailModal from '../components/PaymentFailModal'
|
||||||
import { createReservation, ReservationResult } from '../services/campaignApi'
|
import {
|
||||||
|
createReservation,
|
||||||
|
fetchMyReservationByUuid,
|
||||||
|
MyReservation,
|
||||||
|
} from '../services/campaignApi'
|
||||||
import { formatPersianNumber } from '../utils/formatNumber'
|
import { formatPersianNumber } from '../utils/formatNumber'
|
||||||
import { fromE164 } from '../utils/phone'
|
import { fromE164 } from '../utils/phone'
|
||||||
import { takePendingReservation } from '../utils/pendingReservation'
|
import { takePendingReservation } from '../utils/pendingReservation'
|
||||||
|
import { formatMatchDatetime } from '../utils/iranDate'
|
||||||
|
|
||||||
// the IPG gateway redirects back here as `/payment/success?state=<code>` regardless of
|
// the IPG gateway redirects back here as `/payment/success?state=<code>` regardless of
|
||||||
// whether the charge actually succeeded — `20` is the only code confirmed as "succeeded" so far
|
// 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 }) {
|
function DetailRow({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between py-2">
|
<div className="flex items-start justify-between gap-4 py-2">
|
||||||
<span className="text-gray-500">{label}</span>
|
<span dir="ltr" className="text-left font-medium text-gray-900 shrink-0">{value}</span>
|
||||||
<span className="font-medium text-gray-900">{value}</span>
|
<span className="text-right text-gray-500 shrink-0">{label}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -24,10 +29,8 @@ export default function PaymentSuccess() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams] = useSearchParams()
|
||||||
const isSuccess = searchParams.get('state') === SUCCESS_STATE
|
const isSuccess = searchParams.get('state') === SUCCESS_STATE
|
||||||
const [reservation, setReservation] = useState<ReservationResult | null>(null)
|
const [reservation, setReservation] = useState<MyReservation | null>(null)
|
||||||
const [hasError, setHasError] = useState(false)
|
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
|
const isFail = !isSuccess || hasError
|
||||||
|
|
||||||
// React 18 StrictMode double-invokes effects; the ref persists across the simulated
|
// React 18 StrictMode double-invokes effects; the ref persists across the simulated
|
||||||
|
|
@ -44,7 +47,24 @@ export default function PaymentSuccess() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
createReservation(pending)
|
createReservation(pending)
|
||||||
|
.then((result) =>
|
||||||
|
fetchMyReservationByUuid(result.uuid)
|
||||||
.then(setReservation)
|
.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))
|
.catch(() => setHasError(true))
|
||||||
}, [isSuccess])
|
}, [isSuccess])
|
||||||
|
|
||||||
|
|
@ -58,11 +78,20 @@ export default function PaymentSuccess() {
|
||||||
{reservation ? (
|
{reservation ? (
|
||||||
<>
|
<>
|
||||||
<h2 className="mt-6 text-right font-bold text-gray-900">جزئیات رزرو</h2>
|
<h2 className="mt-6 text-right font-bold text-gray-900">جزئیات رزرو</h2>
|
||||||
<div className="mt-2">
|
<div className="mt-2 divide-y divide-gray-100">
|
||||||
<DetailRow label="تعداد" value={`${formatPersianNumber(reservation.people_count)} نفر`} />
|
<DetailRow label="تعداد" value={`${formatPersianNumber(reservation.ticket_count)} نفر`} />
|
||||||
<DetailRow label="به نام" value={reservation.full_name} />
|
<DetailRow label="به نام" value={reservation.full_name} />
|
||||||
<DetailRow label="شماره تماس" value={fromE164(reservation.phone_number)} />
|
<DetailRow label="شماره تماس" value={fromE164(reservation.phone_number)} />
|
||||||
<DetailRow label="کد رزرو" value={reservation.reserve_code} />
|
{reservation.cafe_name && (
|
||||||
|
<DetailRow label="کافه" value={reservation.cafe_name} />
|
||||||
|
)}
|
||||||
|
{reservation.event_title && (
|
||||||
|
<DetailRow label="بازی" value={reservation.event_title} />
|
||||||
|
)}
|
||||||
|
{reservation.event_datetime && (
|
||||||
|
<DetailRow label="تاریخ و ساعت" value={formatMatchDatetime(reservation.event_datetime)} />
|
||||||
|
)}
|
||||||
|
<DetailRow label="کد رزرو" value={reservation.reservation_code} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex items-center justify-end gap-2 rounded-2xl bg-[#ECF2F8] px-4 py-3">
|
<div className="mt-4 flex items-center justify-end gap-2 rounded-2xl bg-[#ECF2F8] px-4 py-3">
|
||||||
|
|
|
||||||
|
|
@ -66,3 +66,16 @@ export function formatIranKickoffTime(matchDatetimeIso: string): string {
|
||||||
})
|
})
|
||||||
return toPersianDigits(time)
|
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}`
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue