Files
nobat724_front/components/appointment/paying/index.js
T
hamedandClaude Opus 5 51c21938eb feat(booking): give the booking flow a dark palette derived from its own colours
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>
2026-08-01 16:33:39 +03:30

217 lines
9.5 KiB
JavaScript
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.
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;