components/appointment/ had every colour hard-coded, so the flow rendered identically in either theme even after the wiring was fixed. It now reads from CSS custom properties. These are not new colours. The :root values are byte-for-byte the hex codes that were already in the components — twenty-one files, mapped one to one — so light mode is unchanged. The dark values are derived from those same colours: surfaces and borders darkened, text inverted, and the two brand colours (the indigo and the orange) lightened rather than replaced, because both lose contrast against a dark surface at their original values. Verified in a headless browser with prefers-color-scheme forced dark: data-theme lands on <html> and --ap-surface resolves to #1b1b20 rather than white. Six one-off colours remain hard-coded — a success green, an error red and similar — each used exactly once and none of them a surface. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
217 lines
9.5 KiB
JavaScript
217 lines
9.5 KiB
JavaScript
import { useState, useEffect } from "react";
|
||
import moment from "moment-jalaali";
|
||
import ArrowLeftB from "@/components/icons/ArrowLeftB";
|
||
import { Button, MenuItem, Select, FormControl, InputLabel } from "@mui/material";
|
||
import ButtonFixed from "./ButtonFixed";
|
||
import { request } from "@/services/response";
|
||
import { formatToman } from "@/lib/money";
|
||
|
||
const PAYMENT_TTL = 900;
|
||
|
||
function Paying({ setStep, appointmentId, expiresAt, doctor, data, isForAnother, selectedSlot }) {
|
||
const [selectedBank, setSelectedBank] = useState("");
|
||
const [gateways, setGateways] = useState([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [testMode, setTestMode] = useState(null);
|
||
const [feeRials, setFeeRials] = useState(null);
|
||
|
||
const computeLeft = () =>
|
||
expiresAt
|
||
? Math.max(0, Math.floor((expiresAt * 1000 - Date.now()) / 1000))
|
||
: PAYMENT_TTL;
|
||
const [timeLeft, setTimeLeft] = useState(computeLeft);
|
||
|
||
useEffect(() => {
|
||
const timer = setInterval(() => setTimeLeft(computeLeft()), 1000);
|
||
return () => clearInterval(timer);
|
||
}, [expiresAt]);
|
||
|
||
useEffect(() => {
|
||
request
|
||
.getPaymentConfig()
|
||
.then((res) => {
|
||
const gws = Array.isArray(res?.data?.gateways) ? res.data.gateways : [];
|
||
setTestMode(Boolean(res?.data?.test_mode));
|
||
setFeeRials(Number(res?.data?.appointment_fee_rials) || 0);
|
||
setGateways(gws);
|
||
setSelectedBank(gws[0]?.name ?? "");
|
||
})
|
||
.catch(() => { setTestMode(false); setFeeRials(0); setGateways([]); });
|
||
}, []);
|
||
|
||
const formatTime = (seconds) => {
|
||
const m = Math.floor(seconds / 60);
|
||
const s = seconds % 60;
|
||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||
};
|
||
|
||
const toman = feeRials != null ? formatToman(feeRials) : "—";
|
||
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 = () => {
|
||
if (!appointmentId) return;
|
||
const gateway = testMode ? "mellat" : selectedBank;
|
||
if (!gateway) return;
|
||
setLoading(true);
|
||
|
||
// بدون هیچ XHR: مرورگر مستقیماً به entry بکاند میرود؛ بکاند صلاحیت را تأیید،
|
||
// Payment را میسازد و به درگاه بانک منتقل میکند.
|
||
const apiBase = process.env.NEXT_PUBLIC_API_URL;
|
||
const ret = encodeURIComponent(`${window.location.origin}/payment/result`);
|
||
window.location.href =
|
||
`${apiBase}/api/v1/payment/order/${appointmentId}` +
|
||
`?gateway=${encodeURIComponent(gateway)}&return=${ret}`;
|
||
};
|
||
|
||
return (
|
||
<div className="w-full lg:w-[59%]">
|
||
<div className="opacity-page bg-[var(--ap-surface)] border border-solid border-[var(--ap-border)] rounded-[12px] p-[16px] md:p-[24px]">
|
||
<p className="text-[var(--ap-text)] text-[16px] md:text-[20px] font-bold mb-[20px]">
|
||
تأیید و پرداخت
|
||
</p>
|
||
|
||
{/* ── خلاصه نوبت ── */}
|
||
<div className="rounded-[10px] border border-[var(--ap-border)] bg-[var(--ap-surface-2)] p-[16px] flex flex-col gap-[12px]">
|
||
{doctor?.name && (
|
||
<div className="flex justify-between items-center text-[14px] md:text-[15px]">
|
||
<span className="text-[var(--ap-text-4)]">پزشک</span>
|
||
<span className="text-[var(--ap-text)] font-medium">{doctor.name}</span>
|
||
</div>
|
||
)}
|
||
{patientName && (
|
||
<div className="flex justify-between items-center text-[14px] md:text-[15px]">
|
||
<span className="text-[var(--ap-text-4)]">بیمار</span>
|
||
<span className="text-[var(--ap-text)] font-medium">{patientName}</span>
|
||
</div>
|
||
)}
|
||
{selectedSlot?.start && (
|
||
<div className="flex justify-between items-center text-[14px] md:text-[15px]">
|
||
<span className="text-[var(--ap-text-4)]">زمان نوبت</span>
|
||
<span className="text-[var(--ap-text)] font-medium" dir="rtl">
|
||
{/* utcOffset(210) = ساعت رسمی ایران (+03:30)، مستقل از tz مرورگر بیمار */}
|
||
{moment.unix(selectedSlot.start).utcOffset(210).locale("fa").format("dddd jD jMMMM jYYYY")}
|
||
{" — ساعت "}
|
||
{moment.unix(selectedSlot.start).utcOffset(210).format("HH:mm")}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* ── مبلغ ── */}
|
||
<div className="flex justify-between items-center mt-[20px] py-[14px] border-y border-[var(--ap-border)]">
|
||
<span className="text-[var(--ap-text-2)] text-[14px] md:text-[16px] font-medium">
|
||
مبلغ قابل پرداخت
|
||
</span>
|
||
<span className="text-[var(--ap-text)] text-[18px] md:text-[20px] font-bold">
|
||
{toman} <span className="text-[14px] font-normal text-[var(--ap-text-2)]">تومان</span>
|
||
</span>
|
||
</div>
|
||
|
||
{/* ── شمارش معکوس ── */}
|
||
{!expired && (
|
||
<div className="mt-[20px]">
|
||
<div className="flex justify-between items-center mb-[8px]">
|
||
<span className="text-[var(--ap-text-2)] text-[13px] md:text-[14px]">
|
||
مهلت پرداخت و نگهداری نوبت
|
||
</span>
|
||
<span
|
||
className={`text-[14px] md:text-[16px] font-bold tabular-nums ${ timeLeft < 60 ? "text-[var(--ap-danger)]" : "text-[var(--ap-primary)]" }`}
|
||
>
|
||
{formatTime(timeLeft)}
|
||
</span>
|
||
</div>
|
||
<div className="w-full h-[6px] rounded-full bg-[var(--ap-border)] overflow-hidden">
|
||
<div
|
||
className={`h-full rounded-full transition-all duration-1000 ease-linear ${ timeLeft < 60 ? "bg-[var(--ap-danger)]" : "bg-[var(--ap-primary)]" }`}
|
||
style={{ width: `${progress}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── درگاه / حالت آزمایشی ── */}
|
||
{!expired && (
|
||
<div className="mt-[20px]">
|
||
{testMode ? (
|
||
<div className="flex items-start gap-[8px] rounded-[10px] border border-[var(--ap-warning)] bg-[var(--ap-surface)8E8] p-[12px]">
|
||
<span className="text-[18px] leading-none">🧪</span>
|
||
<div>
|
||
<p className="text-[var(--ap-warning-text)] text-[14px] font-bold">درگاه پرداخت آزمایشی</p>
|
||
<p className="text-[var(--ap-warning-text)] text-[12px] mt-[2px] leading-[20px]">
|
||
در این حالت پرداخت واقعی انجام نمیشود و نوبت بهصورت آزمایشی تأیید میگردد.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
) : gateways.length === 0 ? (
|
||
<div className="rounded-[10px] border border-[var(--ap-danger-border)] bg-[var(--ap-danger-bg)] p-[14px]">
|
||
<p className="text-[var(--ap-danger)] text-[14px] font-medium">
|
||
درگاه پرداخت فعالی موجود نیست. لطفاً بعداً تلاش کنید.
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<FormControl fullWidth>
|
||
<InputLabel id="bank-select-label">انتخاب درگاه پرداخت</InputLabel>
|
||
<Select
|
||
labelId="bank-select-label"
|
||
value={selectedBank}
|
||
label="انتخاب درگاه پرداخت"
|
||
onChange={(e) => setSelectedBank(e.target.value)}
|
||
>
|
||
{gateways.map((g) => (
|
||
<MenuItem key={g.name} value={g.name}>
|
||
{g.label}
|
||
</MenuItem>
|
||
))}
|
||
</Select>
|
||
</FormControl>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{expired && (
|
||
<div className="mt-[20px] rounded-[10px] border border-[var(--ap-danger-border)] bg-[var(--ap-danger-bg)] p-[14px]">
|
||
<p className="text-[var(--ap-danger)] text-[14px] font-medium">
|
||
مهلت پرداخت تمام شد و نوبت آزاد شد. لطفاً دوباره زمان نوبت را انتخاب کنید.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
<ButtonFixed>
|
||
<div className="flex items-center justify-end">
|
||
{expired ? (
|
||
<Button
|
||
className="!flex !w-full md:!w-fit !mr-auto !gap-[6px] !px-[16px] !py-[10px] !min-w-[180px]"
|
||
onClick={() => setStep(0)}
|
||
variant="contained"
|
||
>
|
||
<span className="text-[var(--ap-surface)] text-[16px] font-medium">انتخاب مجدد زمان</span>
|
||
</Button>
|
||
) : (
|
||
<Button
|
||
className="!flex !w-full md:!w-fit !mr-auto !gap-[6px] !px-[16px] !py-[10px] !min-w-[180px]"
|
||
onClick={handlePayment}
|
||
variant="contained"
|
||
disabled={
|
||
loading || testMode === null || feeRials === null ||
|
||
(!testMode && !selectedBank)
|
||
}
|
||
>
|
||
<span className={`text-[var(--ap-surface)] text-[16px] font-medium ${loading ? "opacity-0" : ""}`}>
|
||
{testMode ? "پرداخت آزمایشی" : `پرداخت ${toman} تومان`}
|
||
</span>
|
||
{!loading && <ArrowLeftB />}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</ButtonFixed>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default Paying;
|