reservation-front/src/components/ReservationModal.tsx

259 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
);
}