feat: enhance payment flow with dynamic URL handling and improved error messaging
This commit is contained in:
@@ -0,0 +1,101 @@
|
|||||||
|
# جریان پرداخت سمت کلاینت — انتقال از طریق بکاند + صفحات نتیجه (Frontend)
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` (سایت عمومی). **cross-repo** — پرامپت همتا (Backend، اول اجرا شود): `clinicpro/.claude/prompt/payment-flow-architecture.md`. قرارداد API توسط این کلاینت مصرف میشود.
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
طبق معمار: هیچ Frontend نباید مستقیم با بانک صحبت کند؛ Backend مرجع است و انتقال به بانک و بازگشت از طریق Backend انجام میشود. بخش زیادی **قبلاً پیاده شده**:
|
||||||
|
|
||||||
|
- `components/appointment/paying/index.js`: درگاهها از `GET /api/v1/payment/config` (`data.gateways` فعال) خوانده میشوند؛ کلیک پرداخت → `POST /api/v1/payment/appointment` (authenticated XHR) → دریافت `pay_url` → `window.location.href = pay_url` (انتقال full-page به بکاند؛ بکاند خودش به بانک میرود).
|
||||||
|
- `frontend_address = ${window.location.origin}/payment/result` → بکاند بعد از callback به همین دامنهٔ مبدأ برمیگردد با `?payment_uuid=..&status=..`.
|
||||||
|
- `app/payment/result/page.js` وضعیت را میخواند و به `/payment/${uuid}` هدایت میکند.
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
طبق spec باید این موارد دقیق و کامل باشند:
|
||||||
|
|
||||||
|
1. **کلاینت هرگز مستقیم به بانک نرود** — فقط به `pay_url` (بکاند). (عمدتاً انجام شده؛ باید تأیید و تثبیت شود.)
|
||||||
|
2. **صفحات نتیجهٔ استاندارد** — `success` / `failed` با نمایش وضعیت پرداخت و لینکهای اقدام، بهجای هدایت خام.
|
||||||
|
3. **سازگاری با تغییر قرارداد Backend** — اگر Backend پاسخ `GET /api/v1/payment/{uuid}` را از double-nested به flat تغییر داد (وظیفهٔ backend)، خواندن در `app/payment/[uuid]/page.js` باید هماهنگ شود.
|
||||||
|
4. **حالت انصراف/شکست** — وقتی `status` برابر `canceled`/`failed` است، پیام مناسب و امکان تلاش مجدد.
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `components/appointment/paying/index.js` | دکمهٔ پرداخت + انتخاب درگاه + هدایت به `pay_url` |
|
||||||
|
| `app/payment/result/page.js` | صفحهٔ بازگشت از بکاند (`?payment_uuid&status`) |
|
||||||
|
| `app/payment/[uuid]/page.js` | نمایش نتیجهٔ پرداخت بر اساس `getPayment` |
|
||||||
|
| `services/response.js` | `postAppointmentPayment`, `getPaymentConfig`, `getPayment` |
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
|
||||||
|
```js
|
||||||
|
// components/appointment/paying/index.js (کلیک پرداخت)
|
||||||
|
const res = await request.postAppointmentPayment({
|
||||||
|
appointment_uuid: appointmentId,
|
||||||
|
gateway: testMode ? "mellat" : selectedBank,
|
||||||
|
frontend_address: `${window.location.origin}/payment/result`,
|
||||||
|
});
|
||||||
|
const payUrl = res?.data?.pay_url || res?.data?.redirect_url;
|
||||||
|
if (payUrl) window.location.href = payUrl; // انتقال full-page به بکاند ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
// app/payment/result/page.js (بازگشت از بکاند)
|
||||||
|
const paymentUuid = searchParams.get("payment_uuid");
|
||||||
|
if (paymentUuid) router.replace(`/payment/${paymentUuid}`);
|
||||||
|
else router.replace("/dashboard?sidebar=2");
|
||||||
|
```
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. تثبیت انتقال از طریق بکاند (بدون تماس مستقیم با بانک)
|
||||||
|
|
||||||
|
مطمئن شو در `handlePayment` هیچ URL بانکی مستقیم باز نمیشود؛ فقط `pay_url`. اگر `pay_url` نبود (پاسخ ناقص)، بهجای رفتن به مرحلهٔ بعد، خطای کاربرپسند نشان بده:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const payUrl = res?.data?.pay_url;
|
||||||
|
if (payUrl) { window.location.href = payUrl; return; }
|
||||||
|
toast/alert("خطا در شروع پرداخت. دوباره تلاش کنید.");
|
||||||
|
setLoading(false);
|
||||||
|
```
|
||||||
|
|
||||||
|
(اتکا به `redirect_url` بانک را حذف کن؛ منبع درست فقط `pay_url` است.)
|
||||||
|
|
||||||
|
### ۲. صفحهٔ نتیجه بر اساس `status`
|
||||||
|
|
||||||
|
`app/payment/result/page.js` علاوه بر `payment_uuid`، پارامتر `status` را هم بخواند و بر اساس آن رفتار کند:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const status = searchParams.get("status"); // success | failed | canceled | pending
|
||||||
|
const paymentUuid = searchParams.get("payment_uuid");
|
||||||
|
if (status === "success" && paymentUuid) router.replace(`/payment/${paymentUuid}`);
|
||||||
|
else router.replace(`/payment/${paymentUuid ?? ""}?status=${status ?? "failed"}`);
|
||||||
|
```
|
||||||
|
|
||||||
|
در `app/payment/[uuid]/page.js` وضعیت را از `getPayment(uuid)` بگیر (source of truth بکاند، نه فقط query) و کارت نتیجه را نشان بده: موفق (سبز، جزئیات نوبت/کدرهگیری)، ناموفق/لغو (قرمز، دکمهٔ «تلاش مجدد» → بازگشت به صفحهٔ رزرو پزشک).
|
||||||
|
|
||||||
|
### ۳. سازگاری با شکل پاسخ `getPayment`
|
||||||
|
|
||||||
|
اگر Backend (وظیفهٔ همتا) پاسخ `GET /api/v1/payment/{uuid}` را flat کرد، خواندن را طوری بنویس که هر دو حالت کار کند:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const data = await request.getPayment(uuid);
|
||||||
|
const payment = data?.data?.data ?? data?.data; // سازگار با flat و nested
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۴. UX انصراف/شکست
|
||||||
|
|
||||||
|
اگر `status !== success`: پیام «پرداخت انجام نشد یا لغو شد» + وضعیت واقعی از `payment.status` + دکمهٔ تلاش مجدد. هیچ اطلاعات حساسی نمایش نده.
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **قرارداد API را از Backend بگیر:** `POST /api/v1/payment/appointment` → `{ payment_uuid, pay_url, order_id }`؛ بازگشت callback → `frontend_address?payment_uuid&status`. (منبع: `clinicpro/docs/api/payment.md`.)
|
||||||
|
- **چرا یک XHR لازم است:** ساخت `Payment` نیازمند احراز مالکیت سفارش است و JWT در کوکیِ همین دامنه است؛ redirect full-page کوکی cross-domain نمیبرد. پس الگوی «XHR authenticated برای ساخت + سپس redirect full-page به `pay_url`» درست و امن است — این را حفظ کن (نه fetch مستقیم به بانک).
|
||||||
|
- App Router؛ صفحات نتیجه `generateMetadata` با `noindex` داشته باشند (صفحهٔ تراکنش نباید ایندکس شود).
|
||||||
|
- استایل MUI v5 + Tailwind، RTL، فونت Vazir، Jalali؛ کلاسهای موجود.
|
||||||
|
- API client فقط از طریق `services/response.js` → `request.*`؛ `getPayment` با `{ requireAuth: true }`.
|
||||||
|
- بعد از تغییر: `npm run build` در `nobat724_front`.
|
||||||
+20
-20
@@ -23,9 +23,9 @@ export default function PaymentDetailsPage() {
|
|||||||
gateway: payment.gateway || "mellat",
|
gateway: payment.gateway || "mellat",
|
||||||
frontend_address: `${window.location.origin}/payment/result`,
|
frontend_address: `${window.location.origin}/payment/result`,
|
||||||
});
|
});
|
||||||
const redirectUrl = res?.data?.redirect_url;
|
const payUrl = res?.data?.pay_url;
|
||||||
if (redirectUrl) {
|
if (payUrl) {
|
||||||
window.location.href = redirectUrl;
|
window.location.href = payUrl;
|
||||||
} else {
|
} else {
|
||||||
setPaying(false);
|
setPaying(false);
|
||||||
}
|
}
|
||||||
@@ -46,7 +46,8 @@ export default function PaymentDetailsPage() {
|
|||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const response = await request.getPayment(params.uuid);
|
const response = await request.getPayment(params.uuid);
|
||||||
setPayment(response?.data?.data ?? null);
|
// سازگار با پاسخ flat (جدید) و double-nested (قدیمی)
|
||||||
|
setPayment(response?.data?.data ?? response?.data ?? null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError("خطا در دریافت اطلاعات پرداخت");
|
setError("خطا در دریافت اطلاعات پرداخت");
|
||||||
console.error("Payment fetch error:", err);
|
console.error("Payment fetch error:", err);
|
||||||
@@ -65,6 +66,7 @@ export default function PaymentDetailsPage() {
|
|||||||
pending: "در انتظار پرداخت",
|
pending: "در انتظار پرداخت",
|
||||||
success: "پرداخت موفق",
|
success: "پرداخت موفق",
|
||||||
failed: "پرداخت ناموفق",
|
failed: "پرداخت ناموفق",
|
||||||
|
canceled: "پرداخت لغو شد",
|
||||||
refunded: "مسترد شده",
|
refunded: "مسترد شده",
|
||||||
};
|
};
|
||||||
return statusMap[status] || status;
|
return statusMap[status] || status;
|
||||||
@@ -75,6 +77,7 @@ export default function PaymentDetailsPage() {
|
|||||||
pending: "text-yellow-600 bg-yellow-50",
|
pending: "text-yellow-600 bg-yellow-50",
|
||||||
success: "text-green-600 bg-green-50",
|
success: "text-green-600 bg-green-50",
|
||||||
failed: "text-red-600 bg-red-50",
|
failed: "text-red-600 bg-red-50",
|
||||||
|
canceled: "text-red-600 bg-red-50",
|
||||||
refunded: "text-gray-600 bg-gray-50",
|
refunded: "text-gray-600 bg-gray-50",
|
||||||
};
|
};
|
||||||
return colorMap[status] || "text-gray-600 bg-gray-50";
|
return colorMap[status] || "text-gray-600 bg-gray-50";
|
||||||
@@ -207,22 +210,19 @@ export default function PaymentDetailsPage() {
|
|||||||
|
|
||||||
{/* دکمهها */}
|
{/* دکمهها */}
|
||||||
<div className="mt-8 flex gap-4">
|
<div className="mt-8 flex gap-4">
|
||||||
{payment.status === "pending" ? (
|
{["pending", "failed", "canceled"].includes(payment.status) &&
|
||||||
<>
|
payment.appointment_uuid ? (
|
||||||
<button
|
<button
|
||||||
onClick={handleRetryPayment}
|
onClick={handleRetryPayment}
|
||||||
disabled={paying || !payment.appointment_uuid}
|
disabled={paying}
|
||||||
className="flex-1 bg-[#5559CE] hover:bg-[#4448b3] disabled:opacity-60 text-white font-bold py-3 px-6 rounded-lg transition-colors"
|
className="flex-1 bg-[#5559CE] hover:bg-[#4448b3] disabled:opacity-60 text-white font-bold py-3 px-6 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
{paying ? "در حال انتقال به درگاه..." : "پرداخت"}
|
{paying
|
||||||
</button>
|
? "در حال انتقال به درگاه..."
|
||||||
<button
|
: payment.status === "pending"
|
||||||
onClick={() => window.location.reload()}
|
? "پرداخت"
|
||||||
className="flex-1 bg-gray-200 hover:bg-gray-300 text-[#3B3B3B] font-bold py-3 px-6 rounded-lg transition-colors"
|
: "تلاش مجدد"}
|
||||||
>
|
</button>
|
||||||
بروزرسانی وضعیت
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push("/dashboard?sidebar=2")}
|
onClick={() => router.push("/dashboard?sidebar=2")}
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ function PaymentResultRedirect() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const paymentUuid = searchParams.get("payment_uuid");
|
const paymentUuid = searchParams.get("payment_uuid");
|
||||||
|
const status = searchParams.get("status");
|
||||||
if (paymentUuid) {
|
if (paymentUuid) {
|
||||||
router.replace(`/payment/${paymentUuid}`);
|
const q = status ? `?status=${status}` : "";
|
||||||
|
router.replace(`/payment/${paymentUuid}${q}`);
|
||||||
} else {
|
} else {
|
||||||
router.replace("/dashboard?sidebar=2");
|
router.replace("/dashboard?sidebar=2");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,15 +61,17 @@ function Paying({ setStep, appointmentId, expiresAt, doctor, data, isForAnother,
|
|||||||
gateway: testMode ? "mellat" : selectedBank,
|
gateway: testMode ? "mellat" : selectedBank,
|
||||||
frontend_address: `${window.location.origin}/payment/result`,
|
frontend_address: `${window.location.origin}/payment/result`,
|
||||||
});
|
});
|
||||||
// به بکاند میرویم؛ آنجا صلاحیت تأیید و به درگاه بانک منتقل میشود.
|
// فقط به بکاند میرویم؛ بکاند صلاحیت را تأیید و به درگاه بانک منتقل میکند.
|
||||||
const payUrl = res?.data?.pay_url || res?.data?.redirect_url;
|
// کلاینت هرگز مستقیم به بانک ریکوست نمیزند.
|
||||||
|
const payUrl = res?.data?.pay_url;
|
||||||
if (payUrl) {
|
if (payUrl) {
|
||||||
window.location.href = payUrl;
|
window.location.href = payUrl;
|
||||||
} else {
|
return;
|
||||||
setStep((prev) => prev + 1);
|
|
||||||
}
|
}
|
||||||
|
throw new Error("pay_url missing");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Payment error:", error);
|
console.error("Payment error:", error);
|
||||||
|
alert("خطا در شروع پرداخت. لطفاً دوباره تلاش کنید.");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user