259 lines
8.7 KiB
TypeScript
259 lines
8.7 KiB
TypeScript
import { useEffect, useState } from "react";
|
||
import { createPortal } from "react-dom";
|
||
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 { toE164 } from "../utils/phone";
|
||
import { setPendingReservation } from "../utils/pendingReservation";
|
||
import { getUserPhone } from "../utils/userPhone";
|
||
import Icon from "./Icon";
|
||
|
||
interface ReservationModalProps {
|
||
isOpen: boolean;
|
||
onClose: () => void;
|
||
cafe: Cafe;
|
||
ticketPrice: number;
|
||
cafeMatchScheduleUuid: string;
|
||
}
|
||
|
||
// TODO: there's no "get the logged-in customer's own wallet uuid" endpoint yet —
|
||
// these are fixed test wallet ids given directly for now, replace once that exists
|
||
const WALLET_UUIDS = ["e7c9d4d1-4d1f-43b2-96f7-4d4a168f480d", "af7d967f-30c0-409b-9066-2549f2da5e5e"];
|
||
|
||
function PaymentRow({
|
||
label,
|
||
amount,
|
||
emphasize = false,
|
||
bottomBorder = false,
|
||
infoIconParam = false,
|
||
}: {
|
||
label: string;
|
||
amount: number;
|
||
emphasize?: boolean;
|
||
bottomBorder?: boolean;
|
||
infoIconParam?: boolean;
|
||
}) {
|
||
return (
|
||
<div
|
||
className={`flex items-center justify-between py-3 ${bottomBorder ? "border-b border-gray-100" : ""}`}
|
||
>
|
||
<div className="flex items-center gap-1">
|
||
<span
|
||
className={
|
||
emphasize ? "font-semibold text-gray-900" : "text-gray-700"
|
||
}
|
||
>
|
||
{label}
|
||
</span>
|
||
{infoIconParam && (
|
||
<Icon name="info" className="h-5 w-5 shrink-0 text-[#376195]" />
|
||
)}
|
||
</div>
|
||
<span
|
||
className={emphasize ? "font-semibold text-gray-900" : "text-gray-900"}
|
||
>
|
||
{formatPersianNumber(amount)} تومان
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function ReservationModal({
|
||
isOpen,
|
||
onClose,
|
||
cafe,
|
||
ticketPrice,
|
||
cafeMatchScheduleUuid,
|
||
}: ReservationModalProps) {
|
||
const navigate = useNavigate();
|
||
const [fullName, setFullName] = useState("");
|
||
const [count, setCount] = useState(1);
|
||
const [walletBalance, setWalletBalance] = useState(0);
|
||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||
const [error, setError] = useState("");
|
||
|
||
useEffect(() => {
|
||
if (!isOpen) return;
|
||
|
||
const previousOverflow = document.body.style.overflow;
|
||
document.body.style.overflow = "hidden";
|
||
return () => {
|
||
document.body.style.overflow = previousOverflow;
|
||
};
|
||
}, [isOpen]);
|
||
|
||
useEffect(() => {
|
||
if (!isOpen) return;
|
||
fetchWalletBalances(WALLET_UUIDS)
|
||
.then((results) => setWalletBalance(results[0]?.balance ?? 0))
|
||
.catch(() => {});
|
||
}, [isOpen]);
|
||
|
||
if (!isOpen) return null;
|
||
|
||
const reservationAmount = ticketPrice * count;
|
||
const payableAmount = Math.max(0, reservationAmount - walletBalance);
|
||
|
||
const handlePay = async () => {
|
||
setError("");
|
||
|
||
if (!fullName.trim()) {
|
||
setError("لطفاً نام و نام خانوادگی را وارد کنید.");
|
||
return;
|
||
}
|
||
|
||
setIsSubmitting(true);
|
||
|
||
const payload = {
|
||
cafe_match_schedule_uuid: cafeMatchScheduleUuid,
|
||
people_count: count,
|
||
full_name: fullName,
|
||
phone_number: toE164(getUserPhone() ?? ""),
|
||
};
|
||
|
||
try {
|
||
if (payableAmount > 0) {
|
||
// reservation creation happens on /payment/success, only once the gateway confirms state=20
|
||
setPendingReservation(payload);
|
||
const appBaseUrl = (import.meta.env.VITE_APP_BASE_URL || window.location.origin).replace(/\/$/, "");
|
||
const { uuid } = await createCharge({
|
||
amount: payableAmount,
|
||
callback_url: `${appBaseUrl}/payment/success`,
|
||
});
|
||
window.location.href = chargePageUrl(uuid);
|
||
} else {
|
||
// wallet already covers the full amount — no gateway involved, land straight on the same success page
|
||
setPendingReservation(payload);
|
||
navigate("/payment/success?state=20");
|
||
}
|
||
} catch {
|
||
setError("اتصال به درگاه پرداخت با خطا مواجه شد. لطفاً دوباره تلاش کنید.");
|
||
setIsSubmitting(false);
|
||
}
|
||
};
|
||
|
||
return createPortal(
|
||
<>
|
||
<div className="fixed inset-0 z-1100 bg-black/70" onClick={onClose} />
|
||
<div className="fixed inset-x-0 bottom-0 z-1100 flex max-h-[92vh] flex-col rounded-t-3xl bg-white">
|
||
<div className="mx-auto mt-3 mb-4 h-1 w-10 shrink-0 rounded-full bg-gray-200" />
|
||
|
||
<div className="overflow-y-auto px-5 pb-6">
|
||
<h2 className="text-right text-lg font-bold text-gray-900">
|
||
{cafe.name}
|
||
</h2>
|
||
<p className="mt-3 mb-5 text-right text-sm text-gray-500">
|
||
{cafe.address}
|
||
</p>
|
||
|
||
<div>
|
||
<label className="mb-2 block text-right font-semibold text-gray-900">
|
||
نام و نام خانوادگی
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={fullName}
|
||
onChange={(e) => setFullName(e.target.value)}
|
||
placeholder="نام و نام خانوادگی"
|
||
className="w-full rounded-2xl border border-gray-200 px-4 py-3 text-right placeholder:font-light placeholder:text-[#999999]"
|
||
/>
|
||
</div>
|
||
|
||
<div className="mt-4">
|
||
<label className="mb-2 block text-right font-semibold text-gray-900">
|
||
شماره تماس
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={getUserPhone() ?? ""}
|
||
readOnly
|
||
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]"
|
||
/>
|
||
</div>
|
||
|
||
<div className="mt-4">
|
||
<label className="mb-2 block text-right font-semibold text-gray-900">
|
||
تعداد
|
||
</label>
|
||
<div
|
||
dir="ltr"
|
||
className="flex w-full items-center justify-between rounded-2xl border border-gray-200 px-4 py-3"
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={() => setCount((prev) => Math.max(1, prev - 1))}
|
||
disabled={count === 1}
|
||
className={`flex h-6 w-6 items-center justify-center ${
|
||
count === 1
|
||
? "cursor-not-allowed text-[#999999]"
|
||
: "text-[#223D5D]"
|
||
}`}
|
||
>
|
||
<Icon name="minus" className="h-4 w-4" />
|
||
</button>
|
||
<span dir="rtl" className="text-center font-medium text-gray-900">
|
||
{formatPersianNumber(count)} نفر
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => setCount((prev) => prev + 1)}
|
||
className="flex h-6 w-6 items-center justify-center text-[#223D5D]"
|
||
>
|
||
<Icon name="plus" className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<h3 className="mt-4 mb-3 text-right font-semibold text-gray-900">
|
||
پرداخت
|
||
</h3>
|
||
<PaymentRow
|
||
infoIconParam
|
||
label="مبلغ رزرو"
|
||
amount={reservationAmount}
|
||
/>
|
||
<PaymentRow
|
||
bottomBorder
|
||
label="موجودی کیف پول"
|
||
amount={walletBalance}
|
||
/>
|
||
<PaymentRow
|
||
label="مبلغ قابل پرداخت"
|
||
amount={payableAmount}
|
||
emphasize
|
||
/>
|
||
|
||
<div className="mt-4 flex items-center justify-end gap-2 rounded-2xl bg-[#ECF2F8] px-4 py-3">
|
||
<Icon name="info" className="h-5 w-5 shrink-0 text-gray-400" />
|
||
<p className="text-right text-sm text-gray-500">
|
||
توجه داشته باشید که مسئولیت رزرو و حضور بهموقع در کافه بر عهده
|
||
کاربر است.
|
||
</p>
|
||
</div>
|
||
</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>}
|
||
<button
|
||
type="button"
|
||
disabled={isSubmitting || !cafeMatchScheduleUuid}
|
||
onClick={handlePay}
|
||
className="w-full rounded-2xl bg-[#223D5D] py-4 text-lg font-semibold text-white disabled:opacity-50"
|
||
>
|
||
{isSubmitting
|
||
? payableAmount > 0
|
||
? "در حال انتقال به درگاه..."
|
||
: "در حال ثبت رزرو..."
|
||
: payableAmount > 0
|
||
? "پرداخت"
|
||
: "پرداخت از کیف پول"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</>,
|
||
document.body,
|
||
);
|
||
}
|