feat(appointment): redesign payment step with test-mode and real amount
Rebuild the payment screen as a clear confirm-and-pay card: appointment summary (doctor, patient, Jalali date/time), a prominent amount row (15,000 تومان from the backend's 150,000 rials), and a countdown with a progress bar tied to the booking's real expires_at. Fetch /api/v1/payment/config: in test mode show a 'درگاه آزمایشی' notice and a 'پرداخت آزمایشی' button (gateway select hidden, since the backend forces MockGateway); otherwise show the bank gateway select. Expired state offers re-selecting a time. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -82,6 +82,8 @@ function Container({
|
|||||||
appointmentId={appointmentId}
|
appointmentId={appointmentId}
|
||||||
expiresAt={appointmentExpiresAt}
|
expiresAt={appointmentExpiresAt}
|
||||||
doctor={doctor}
|
doctor={doctor}
|
||||||
|
data={data}
|
||||||
|
isForAnother={isForAnother}
|
||||||
selectedSlot={selectedSlot}
|
selectedSlot={selectedSlot}
|
||||||
selectedDate={selectedDate}
|
selectedDate={selectedDate}
|
||||||
/>,
|
/>,
|
||||||
|
|||||||
@@ -5,56 +5,59 @@ import { Button, MenuItem, Select, FormControl, InputLabel } from "@mui/material
|
|||||||
import ButtonFixed from "./ButtonFixed";
|
import ButtonFixed from "./ButtonFixed";
|
||||||
import { request } from "@/services/response";
|
import { request } from "@/services/response";
|
||||||
|
|
||||||
function Paying({ setStep, appointmentId, expiresAt, doctor, selectedSlot, selectedDate }) {
|
const APPOINTMENT_AMOUNT_RIALS = 150000;
|
||||||
const [selectedBank, setSelectedBank] = useState("");
|
const PAYMENT_TTL = 900;
|
||||||
|
|
||||||
|
const banks = [
|
||||||
|
{ id: "mellat", title: "بانک ملت" },
|
||||||
|
{ id: "sep", title: "سامان (سپ)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function Paying({ setStep, appointmentId, expiresAt, doctor, data, isForAnother, selectedSlot }) {
|
||||||
|
const [selectedBank, setSelectedBank] = useState("mellat");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [testMode, setTestMode] = useState(null);
|
||||||
|
|
||||||
const computeLeft = () =>
|
const computeLeft = () =>
|
||||||
expiresAt
|
expiresAt
|
||||||
? Math.max(0, Math.floor((expiresAt * 1000 - Date.now()) / 1000))
|
? Math.max(0, Math.floor((expiresAt * 1000 - Date.now()) / 1000))
|
||||||
: 900;
|
: PAYMENT_TTL;
|
||||||
const [timeLeft, setTimeLeft] = useState(computeLeft);
|
const [timeLeft, setTimeLeft] = useState(computeLeft);
|
||||||
|
|
||||||
const banks = [
|
|
||||||
{ id: "mellat", title: "بانک ملت" },
|
|
||||||
{ id: "sep", title: "سامان (سپ)" }
|
|
||||||
];
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => setTimeLeft(computeLeft()), 1000);
|
||||||
setTimeLeft(computeLeft());
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [expiresAt]);
|
}, [expiresAt]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
request
|
||||||
|
.getPaymentConfig()
|
||||||
|
.then((res) => setTestMode(Boolean(res?.data?.test_mode)))
|
||||||
|
.catch(() => setTestMode(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const formatTime = (seconds) => {
|
const formatTime = (seconds) => {
|
||||||
const minutes = Math.floor(seconds / 60);
|
const m = Math.floor(seconds / 60);
|
||||||
const secs = seconds % 60;
|
const s = seconds % 60;
|
||||||
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toman = (APPOINTMENT_AMOUNT_RIALS / 10).toLocaleString("fa-IR");
|
||||||
|
const patientName = isForAnother
|
||||||
|
? [data?.name?.value, data?.family?.value].filter(Boolean).join(" ").trim()
|
||||||
|
: data?.name?.value;
|
||||||
|
const expired = timeLeft === 0;
|
||||||
|
const progress = Math.max(0, Math.min(100, (timeLeft / PAYMENT_TTL) * 100));
|
||||||
|
|
||||||
const handlePayment = async () => {
|
const handlePayment = async () => {
|
||||||
if (!selectedBank) {
|
if (!appointmentId) return;
|
||||||
alert("لطفا بانک خود را انتخاب کنید");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!appointmentId) {
|
|
||||||
alert("شناسه نوبت یافت نشد");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const paymentPayload = {
|
const res = await request.postAppointmentPayment({
|
||||||
appointment_uuid: appointmentId,
|
appointment_uuid: appointmentId,
|
||||||
gateway: selectedBank,
|
gateway: testMode ? "mellat" : selectedBank,
|
||||||
frontend_address: `${window.location.origin}/payment/result`,
|
frontend_address: `${window.location.origin}/payment/result`,
|
||||||
};
|
});
|
||||||
|
|
||||||
const res = await request.postAppointmentPayment(paymentPayload);
|
|
||||||
|
|
||||||
const redirectUrl = res?.data?.redirect_url;
|
const redirectUrl = res?.data?.redirect_url;
|
||||||
if (redirectUrl) {
|
if (redirectUrl) {
|
||||||
window.location.href = redirectUrl;
|
window.location.href = redirectUrl;
|
||||||
@@ -69,89 +72,135 @@ function Paying({ setStep, appointmentId, expiresAt, doctor, selectedSlot, selec
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full lg:w-[59%]">
|
<div className="w-full lg:w-[59%]">
|
||||||
<div
|
<div className="opacity-page bg-[#FFF] border border-solid border-[#EFEFEF] rounded-[12px] p-[16px] md:p-[24px]">
|
||||||
className={`opacity-page bg-[#FFF] border border-solid border-[#EFEFEF]
|
<p className="text-[#3B3B3B] text-[16px] md:text-[20px] font-bold mb-[20px]">
|
||||||
rounded-[8px] p-[12px] sm:p-[16px] md:p-[20px] lg:p-[24px]`}
|
تأیید و پرداخت
|
||||||
>
|
|
||||||
<p className="text-[#3B3B3B] text-[14px] sm:text-[16px] md:text-[18px] lg:text-[20px] font-bold">
|
|
||||||
اطلاعات پرداخت
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{selectedSlot?.start && (
|
{/* ── خلاصه نوبت ── */}
|
||||||
<div className="flex justify-between items-center mt-[16px] text-[14px] md:text-[16px]">
|
<div className="rounded-[10px] border border-[#EFEFEF] bg-[#FAFAFA] p-[16px] flex flex-col gap-[12px]">
|
||||||
<span className="text-[#616161] font-medium">زمان نوبت:</span>
|
{doctor?.name && (
|
||||||
<span className="text-[#3B3B3B] font-bold">
|
<div className="flex justify-between items-center text-[14px] md:text-[15px]">
|
||||||
{moment.unix(selectedSlot.start).locale("fa").format("jD jMMMM jYYYY - HH:mm")}
|
<span className="text-[#9B9B9B]">پزشک</span>
|
||||||
{doctor?.name ? ` با ${doctor.name}` : ""}
|
<span className="text-[#3B3B3B] font-medium">{doctor.name}</span>
|
||||||
</span>
|
</div>
|
||||||
|
)}
|
||||||
|
{patientName && (
|
||||||
|
<div className="flex justify-between items-center text-[14px] md:text-[15px]">
|
||||||
|
<span className="text-[#9B9B9B]">بیمار</span>
|
||||||
|
<span className="text-[#3B3B3B] font-medium">{patientName}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedSlot?.start && (
|
||||||
|
<div className="flex justify-between items-center text-[14px] md:text-[15px]">
|
||||||
|
<span className="text-[#9B9B9B]">زمان نوبت</span>
|
||||||
|
<span className="text-[#3B3B3B] font-medium" dir="rtl">
|
||||||
|
{moment.unix(selectedSlot.start).locale("fa").format("dddd jD jMMMM jYYYY")}
|
||||||
|
{" — ساعت "}
|
||||||
|
{moment.unix(selectedSlot.start).format("HH:mm")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── مبلغ ── */}
|
||||||
|
<div className="flex justify-between items-center mt-[20px] py-[14px] border-y border-[#EFEFEF]">
|
||||||
|
<span className="text-[#616161] text-[14px] md:text-[16px] font-medium">
|
||||||
|
مبلغ قابل پرداخت
|
||||||
|
</span>
|
||||||
|
<span className="text-[#3B3B3B] text-[18px] md:text-[20px] font-bold">
|
||||||
|
{toman} <span className="text-[14px] font-normal text-[#616161]">تومان</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── شمارش معکوس ── */}
|
||||||
|
{!expired && (
|
||||||
|
<div className="mt-[20px]">
|
||||||
|
<div className="flex justify-between items-center mb-[8px]">
|
||||||
|
<span className="text-[#616161] text-[13px] md:text-[14px]">
|
||||||
|
مهلت پرداخت و نگهداری نوبت
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-[14px] md:text-[16px] font-bold tabular-nums ${
|
||||||
|
timeLeft < 60 ? "text-[#E0383B]" : "text-[#5559CE]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{formatTime(timeLeft)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-[6px] rounded-full bg-[#EFEFEF] overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all duration-1000 ease-linear ${
|
||||||
|
timeLeft < 60 ? "bg-[#E0383B]" : "bg-[#5559CE]"
|
||||||
|
}`}
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-[12px] sm:mt-[18px] md:mt-[24px] lg:mt-[30px]">
|
{/* ── درگاه / حالت آزمایشی ── */}
|
||||||
<div className="flex justify-between items-center mb-4">
|
{!expired && (
|
||||||
<p className="text-[#616161] text-[14px] md:text-[16px] font-medium">
|
<div className="mt-[20px]">
|
||||||
زمان باقیمانده:
|
{testMode ? (
|
||||||
</p>
|
<div className="flex items-start gap-[8px] rounded-[10px] border border-[#F4B740] bg-[#FFF8E8] p-[12px]">
|
||||||
<p className={`text-[14px] md:text-[16px] font-bold ${timeLeft < 60 ? 'text-red-500' : 'text-[#525252]'}`}>
|
<span className="text-[18px] leading-none">🧪</span>
|
||||||
{formatTime(timeLeft)}
|
<div>
|
||||||
|
<p className="text-[#8A6100] text-[14px] font-bold">درگاه پرداخت آزمایشی</p>
|
||||||
|
<p className="text-[#8A6100] text-[12px] mt-[2px] leading-[20px]">
|
||||||
|
در این حالت پرداخت واقعی انجام نمیشود و نوبت بهصورت آزمایشی تأیید میگردد.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel id="bank-select-label">انتخاب درگاه پرداخت</InputLabel>
|
||||||
|
<Select
|
||||||
|
labelId="bank-select-label"
|
||||||
|
value={selectedBank}
|
||||||
|
label="انتخاب درگاه پرداخت"
|
||||||
|
onChange={(e) => setSelectedBank(e.target.value)}
|
||||||
|
>
|
||||||
|
{banks.map((bank) => (
|
||||||
|
<MenuItem key={bank.id} value={bank.id}>
|
||||||
|
{bank.title}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{expired && (
|
||||||
|
<div className="mt-[20px] rounded-[10px] border border-[#F3C0C1] bg-[#FDEEEE] p-[14px]">
|
||||||
|
<p className="text-[#E0383B] text-[14px] font-medium">
|
||||||
|
مهلت پرداخت تمام شد و نوبت آزاد شد. لطفاً دوباره زمان نوبت را انتخاب کنید.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormControl fullWidth className="!mb-4">
|
|
||||||
<InputLabel id="bank-select-label">انتخاب بانک</InputLabel>
|
|
||||||
<Select
|
|
||||||
labelId="bank-select-label"
|
|
||||||
value={selectedBank}
|
|
||||||
label="انتخاب بانک"
|
|
||||||
onChange={(e) => setSelectedBank(e.target.value)}
|
|
||||||
>
|
|
||||||
{banks.map((bank) => (
|
|
||||||
<MenuItem key={bank.id} value={bank.id}>
|
|
||||||
{bank.title}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
{/* <div className="flex justify-between items-center">
|
|
||||||
<p className="text-[#616161] text-[14px] md:text-[16px] font-medium">
|
|
||||||
مبلغ پیش پرداخت (بیعانه) :
|
|
||||||
</p>
|
|
||||||
<p className="text-[#525252] text-[14px] md:text-[16px] font-bold">
|
|
||||||
10،000 تومان
|
|
||||||
</p>
|
|
||||||
</div> */}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span className="bg-[#D7D7D7] hidden md:block h-px w-full mt-[32px] mb-[25px]"></span>
|
|
||||||
|
|
||||||
{timeLeft === 0 && (
|
|
||||||
<p className="text-red-500 text-[14px] font-medium mb-3">
|
|
||||||
مهلت پرداخت تمام شد؛ لطفاً دوباره زمان نوبت را انتخاب کنید.
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ButtonFixed>
|
<ButtonFixed>
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end">
|
||||||
{timeLeft === 0 ? (
|
{expired ? (
|
||||||
<Button
|
<Button
|
||||||
className="!gap-[4px] !mr-auto !flex !px-[12px] !p-[7px] md:!py-[9px] !min-w-[157px]"
|
className="!flex !w-full md:!w-fit !mr-auto !gap-[6px] !px-[16px] !py-[10px] !min-w-[180px]"
|
||||||
onClick={() => setStep(0)}
|
onClick={() => setStep(0)}
|
||||||
variant="contained"
|
variant="contained"
|
||||||
>
|
>
|
||||||
<p className="text-[#EFEFEF] text-[16px] font-medium">انتخاب مجدد زمان</p>
|
<span className="text-[#FFF] text-[16px] font-medium">انتخاب مجدد زمان</span>
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
className="!gap-[4px] !mr-auto !flex !px-[12px] !p-[7px] md:!py-[9px] !min-w-[157px]"
|
className="!flex !w-full md:!w-fit !mr-auto !gap-[6px] !px-[16px] !py-[10px] !min-w-[180px]"
|
||||||
onClick={handlePayment}
|
onClick={handlePayment}
|
||||||
variant="contained"
|
variant="contained"
|
||||||
disabled={loading || !selectedBank}
|
disabled={loading || testMode === null}
|
||||||
>
|
>
|
||||||
<p className={`${loading && "opacity-0"} text-[#EFEFEF] text-[16px] font-medium`}>
|
<span className={`text-[#FFF] text-[16px] font-medium ${loading ? "opacity-0" : ""}`}>
|
||||||
پرداخت نهایی
|
{testMode ? "پرداخت آزمایشی" : `پرداخت ${toman} تومان`}
|
||||||
</p>
|
</span>
|
||||||
<ArrowLeftB />
|
{!loading && <ArrowLeftB />}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export const request = {
|
|||||||
getMyAppointments: (userId, params) => api.get(`api/v1/appointment/my-appointments/${userId}`, { params, requireAuth: true }),
|
getMyAppointments: (userId, params) => api.get(`api/v1/appointment/my-appointments/${userId}`, { params, requireAuth: true }),
|
||||||
postAppointmentPayment: (data) =>
|
postAppointmentPayment: (data) =>
|
||||||
api.post(`api/v1/payment/appointment`, data, { requireAuth: true }),
|
api.post(`api/v1/payment/appointment`, data, { requireAuth: true }),
|
||||||
|
getPaymentConfig: () => api.get(`api/v1/payment/config`, { requireAuth: true }),
|
||||||
getPayment: (uuid) => api.get(`api/v1/payment/${uuid}`, { requireAuth: true }),
|
getPayment: (uuid) => api.get(`api/v1/payment/${uuid}`, { requireAuth: true }),
|
||||||
getMyPayments: (userId, params) => api.get(`api/v1/payment/my-payments/${userId}`, { params, requireAuth: true }),
|
getMyPayments: (userId, params) => api.get(`api/v1/payment/my-payments/${userId}`, { params, requireAuth: true }),
|
||||||
postDoctor: (data) => api.post("api/v1/doctor", data),
|
postDoctor: (data) => api.post("api/v1/doctor", data),
|
||||||
|
|||||||
Reference in New Issue
Block a user