feature/WNPT-395 #5
4 changed files with 115 additions and 28 deletions
|
|
@ -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<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(() => {
|
||||
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<string, string> = {};
|
||||
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({
|
|||
<input
|
||||
type="text"
|
||||
value={fullName}
|
||||
onChange={(e) => 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 && (
|
||||
<p className="mt-1 text-right text-sm text-red-500">{fieldErrors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
|
|
@ -174,11 +199,22 @@ export default function ReservationModal({
|
|||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={getUserPhone() ?? ""}
|
||||
readOnly
|
||||
dir="ltr"
|
||||
inputMode="numeric"
|
||||
value={phone}
|
||||
onChange={(e) => {
|
||||
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 && (
|
||||
<p className="mt-1 text-right text-sm text-red-500">{fieldErrors.phone}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
|
|
@ -206,12 +242,20 @@ export default function ReservationModal({
|
|||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCount((prev) => prev + 1)}
|
||||
className="flex h-6 w-6 items-center justify-center text-[#223D5D]"
|
||||
onClick={() => setCount((prev) => Math.min(remainingTickets, prev + 1))}
|
||||
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" />
|
||||
</button>
|
||||
</div>
|
||||
{remainingTickets > 0 && (
|
||||
<p className="mt-1 text-right text-sm text-gray-400">
|
||||
حداکثر {formatPersianNumber(remainingTickets)} نفر
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="mt-4 mb-3 text-right font-semibold text-gray-900">
|
||||
|
|
@ -243,8 +287,8 @@ export default function ReservationModal({
|
|||
</div>
|
||||
|
||||
<div className="sticky inset-x-0 bottom-0 bg-white px-5 pt-3 pb-6">
|
||||
{error && (
|
||||
<p className="mb-2 text-center text-sm text-red-500">{error}</p>
|
||||
{payError && (
|
||||
<p className="mb-2 text-center text-sm text-red-500">{payError}</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
<NavigationSheet
|
||||
isOpen={isNavigationOpen}
|
||||
|
|
|
|||
|
|
@ -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=<code>` 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 (
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="text-gray-500">{label}</span>
|
||||
<span className="font-medium text-gray-900">{value}</span>
|
||||
<div className="flex items-start justify-between gap-4 py-2">
|
||||
<span dir="ltr" className="text-left font-medium text-gray-900 shrink-0">{value}</span>
|
||||
<span className="text-right text-gray-500 shrink-0">{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<ReservationResult | null>(null)
|
||||
const [reservation, setReservation] = useState<MyReservation | null>(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 ? (
|
||||
<>
|
||||
<h2 className="mt-6 text-right font-bold text-gray-900">جزئیات رزرو</h2>
|
||||
<div className="mt-2">
|
||||
<DetailRow label="تعداد" value={`${formatPersianNumber(reservation.people_count)} نفر`} />
|
||||
<div className="mt-2 divide-y divide-gray-100">
|
||||
<DetailRow label="تعداد" value={`${formatPersianNumber(reservation.ticket_count)} نفر`} />
|
||||
<DetailRow label="به نام" value={reservation.full_name} />
|
||||
<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 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)
|
||||
}
|
||||
|
||||
// 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