From 59d29cd7c712c452d1c6cd5977d5345a6877dd2f Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Tue, 16 Jun 2026 10:06:56 +0330 Subject: [PATCH] fix(appointment): correct payment flow and account-info prefill - Booking response is double-nested: read appointment uuid/expires_at from res.data.data so the payment countdown and gateway redirect actually fire. - Payment result page (/payment/[uuid]): unwrap res.data.data, use real backend fields (amount_rials, gateway, created_at, type) and statuses (pending/success/failed/canceled/refunded); the "pay" button now re-initiates via postAppointmentPayment instead of building a URL on the API origin. - Add /payment/result interstitial that reads payment_uuid from the gateway callback and forwards to /payment/[uuid]. - Prefill account info correctly in the booking form: name from profile.label, insurances from *_id, gender as string, national_code editable unless approved. Co-Authored-By: Claude Opus 4.8 --- .../prompt/fix-appointment-payment-flow.md | 127 ++++++++++++++++++ app/payment/[uuid]/page.js | 71 +++++----- app/payment/result/page.js | 38 ++++++ components/appointment/detail/SubmitData.js | 5 +- components/appointment/index.js | 19 +-- 5 files changed, 217 insertions(+), 43 deletions(-) create mode 100644 .claude/prompt/fix-appointment-payment-flow.md create mode 100644 app/payment/result/page.js diff --git a/.claude/prompt/fix-appointment-payment-flow.md b/.claude/prompt/fix-appointment-payment-flow.md new file mode 100644 index 0000000..d3755a7 --- /dev/null +++ b/.claude/prompt/fix-appointment-payment-flow.md @@ -0,0 +1,127 @@ +# رفع جریان پرداخت و اطلاعات حساب در نوبت‌گیری آنلاین + +## پروژه + +`nobat724_front` (سایت عمومی). + +> این مشکل **کاملاً frontend** است. بک‌اند `clinicpro` همه‌ی endpointهای لازم را دارد و درست کار می‌کند: `POST /api/v1/appointment` (با `expires_at`)، `GET /api/v1/payment/config` (`test_mode`)، `POST /api/v1/payment/appointment` (`redirect_url` — در حالت تست از `MockGateway`)، `POST|GET /api/v1/payment/callback/{gateway}` (که نوبت را confirm و به `frontend_address` ریدایرکت می‌کند)، و `GET /api/v1/payment/{uuid}` (وضعیت پرداخت). نیازی به تغییر بک‌اند نیست. مرجع قرارداد: `clinicpro/docs/api/payment.md` و `docs/api/appointment.md`. + +## زمینه + +در فرایند نوبت‌گیری آنلاین (`/appointment/[doctorId]`) سه مشکل گزارش شده: (۱) اطلاعات حساب کاربری به‌درستی در فیلدها ست نمی‌شود، (۲) شمارنده‌ی زمان پرداخت کار نمی‌کند، (۳) درگاه پرداخت تست کار نمی‌کند. ریشه‌ی هر سه، **اشتباه در خواندن شکل پاسخ بک‌اند** در سمت frontend است — مخصوصاً پاسخ‌های double-nested بک‌اند. + +یادآوری معماری پاسخ بک‌اند: `BaseController::success(['data' => X])` تولید می‌کند `{ success, data: { data: X } }`. interceptor در `services/api.js` یک بار `response.data` را برمی‌گرداند، پس مقدار واقعی در **`res.data.data`** است (نه `res.data`). + +## مشکل / هدف + +۱. **شمارنده + appointmentId**: پس از `POST /appointment`، فرانت `res.data.uuid` و `res.data.expires_at` را می‌خواند، اما چون پاسخ double-nested است این‌ها `undefined` می‌شوند → `appointmentId` خالی (پرداخت کار نمی‌کند) و `expiresAt` خالی (شمارنده روی مقدار ثابت `PAYMENT_TTL` می‌ماند و واقعی نیست). +۲. **درگاه تست**: درگاه تست خودِ بک‌اند است (`MockGateway`؛ وقتی `payment_test_mode=1`). فرانت باید: `redirect_url` بازگشتی از `POST /payment/appointment` را باز کند (که به callback بک‌اند می‌رود، نوبت را confirm می‌کند و به `frontend_address` با `?payment_uuid=...&status=...` برمی‌گردد). مشکل: `frontend_address` فعلی به `/payment/result` اشاره دارد که **route وجود ندارد**؛ صفحه‌ی واقعی `/payment/[uuid]` است و callback `payment_uuid` را برمی‌گرداند. +۳. **صفحه‌ی نتیجه‌ی پرداخت** (`/payment/[uuid]`): پاسخ `GET /payment/{uuid}` را به‌اشتباه می‌خواند (`setPayment(response)` به‌جای `response.data.data`) و statusها اشتباه‌اند (`completed`/`canceled` به‌جای `success`/`failed`). +۴. **اطلاعات حساب کاربری**: در `buildProfileData` کلیدها اصلاح شد (`label`→نام، `basic_insurance_id`، `gender` رشته، `national_code_approved`)؛ این وظیفه تثبیت/تأیید آن است. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `components/appointment/detail/SubmitData.js` | پس از `postAppointment`، `appointmentId`/`expiresAt` را ست می‌کند — **double-nest باگ** | +| `components/appointment/paying/index.js` | شمارنده (`expiresAt` prop)، `getPaymentConfig`، `postAppointmentPayment` (`redirect_url`)، `frontend_address` | +| `app/payment/[uuid]/page.js` | صفحه‌ی نتیجه‌ی پرداخت — مصرف `getPayment` با شکل اشتباه + statusهای اشتباه | +| `services/response.js` | `getPayment`، `postAppointmentPayment`، `getPaymentConfig` (urlها درست‌اند) | +| `components/appointment/index.js` | `buildProfileData` — اطلاعات حساب (وظیفه ۴، عمدتاً انجام‌شده) | + +## وضعیت فعلی (کد واقعی) + +### باگ ۱ — SubmitData: خواندن نتیجه‌ی نوبت +```js +const res = await request.postAppointment(appointmentPayload); +const appointmentUuid = res?.data?.uuid; // ❌ undefined — باید res.data.data.uuid +if (appointmentUuid) { + setAppointmentId(appointmentUuid); + setAppointmentExpiresAt?.(res?.data?.expires_at ?? null); // ❌ undefined +} +``` +بک‌اند `POST /appointment` پاسخ می‌دهد: `{ success, data: { data: { uuid, slot_start, slot_end, expires_at, status, ... } } }`. + +### باگ ۲ — paying: frontend_address و config +```js +const res = await request.postAppointmentPayment({ + appointment_uuid: appointmentId, + gateway: testMode ? "mellat" : selectedBank, + frontend_address: `${window.location.origin}/payment/result`, // ❌ route وجود ندارد +}); +const redirectUrl = res?.data?.redirect_url; // ❌ باید res.data.data.redirect_url +``` +- `getPaymentConfig` نیز: `res?.data?.test_mode` خوانده می‌شود ولی پاسخ `{ success, data: { test_mode } }` است (config با `success([...])` بدون nest اضافه ساخته شده؛ یعنی `res.data.test_mode` درست است — **این یکی را تأیید کن**، چون `config()` از `$this->success([...])` مستقیم استفاده می‌کند نه `['data'=>...]`). +- callback بک‌اند به `frontend_address` با `?payment_uuid=&status=` برمی‌گردد. + +### باگ ۳ — payment/[uuid]/page.js +```js +const response = await request.getPayment(params.uuid); +setPayment(response); // ❌ باید response.data.data +// ... +const statusMap = { pending:..., completed:..., failed:..., canceled:... }; // ❌ بک‌اند: pending|success|failed|refunded +``` +بک‌اند `GET /payment/{uuid}` → `{ success, data: { data: { uuid, order_id, amount_rials, status, gateway, type, reference_id, appointment_uuid, created_at } } }`. statusهای واقعی: `pending`, `success`, `failed`, `refunded`. مبلغ در `amount_rials` (ریال) است. + +## وظایف + +### ۱. رفع double-nest در SubmitData (شمارنده + appointmentId) +در `components/appointment/detail/SubmitData.js`: +```js +const res = await request.postAppointment(appointmentPayload); +const appt = res?.data?.data; // unwrap درست +const appointmentUuid = appt?.uuid; +if (appointmentUuid) { + setAppointmentId(appointmentUuid); + setAppointmentExpiresAt?.(appt?.expires_at ?? null); +} +``` +> نکته: `expires_at` بک‌اند Unix ثانیه است؛ `paying/index.js` همان را در `computeLeft` با `expiresAt * 1000` به میلی‌ثانیه تبدیل می‌کند — درست است؛ فقط باید مقدار واقعی برسد. + +### ۲. اصلاح frontend_address و redirect_url در paying +در `components/appointment/paying/index.js`، تابع `handlePayment`: +- `frontend_address` را به مسیر معتبری بده که نتیجه را نشان می‌دهد. چون callback `?payment_uuid=...&status=...` را به همان آدرس append می‌کند و صفحه‌ی نتیجه `/payment/[uuid]` است، یک **route نتیجه** لازم است که `payment_uuid` را از query بخواند و کاربر را به نتیجه ببرد. دو گزینه: + - **الف (ساده):** `frontend_address` را روی `${window.location.origin}/payment/result` بگذار و یک صفحه‌ی سبک `app/payment/result/page.js` بساز که `payment_uuid` و `status` را از `searchParams` می‌خواند و به `/payment/${payment_uuid}` ریدایرکت (یا مستقیم وضعیت را نشان) می‌دهد. (پیشنهادی) + - **ب:** اگر صفحه‌ی `[uuid]` را نتیجه‌ی نهایی می‌گیری، می‌توان مستقیم آن را هدف frontend_address نکرد چون uuid را callback append نمی‌کند (فقط `payment_uuid`)؛ پس گزینه الف تمیزتر است. +- `redirect_url` را از `res?.data?.data?.redirect_url` بخوان (double-nest): +```js +const res = await request.postAppointmentPayment({ + appointment_uuid: appointmentId, + gateway: testMode ? "mellat" : selectedBank, + frontend_address: `${window.location.origin}/payment/result`, +}); +const redirectUrl = res?.data?.data?.redirect_url; +if (redirectUrl) window.location.href = redirectUrl; +``` +> در حالت تست، بک‌اند `MockGateway` یک `redirect_url` می‌دهد که مستقیم به callback بک‌اند می‌رود (`/api/v1/payment/callback/mock?...&mock=1&ResCode=0`) و خودِ بک‌اند نوبت را confirm و به `frontend_address` برمی‌گرداند. پس «درگاه تست» کاملاً سمت بک‌اند است و response واقعی می‌دهد — فرانت فقط `redirect_url` را باز می‌کند. **هیچ شبیه‌سازی پرداخت سمت فرانت اضافه نکن.** + +### ۳. تأیید getPaymentConfig +`config()` بک‌اند از `$this->success(['test_mode' => ...])` استفاده می‌کند → پاسخ `{ success, data: { test_mode } }`. پس `res?.data?.test_mode` در `paying/index.js` **درست** است. فقط تأیید کن این مقدار را درست می‌خواند و دکمه بر اساسش «پرداخت آزمایشی»/درگاه را نشان می‌دهد. + +### ۴. اصلاح صفحه‌ی نتیجه `app/payment/[uuid]/page.js` +```js +const response = await request.getPayment(params.uuid); +setPayment(response?.data?.data ?? null); // unwrap درست +``` +- statusها را با مقادیر واقعی بک‌اند هماهنگ کن: `pending` (در انتظار)، `success` (پرداخت موفق)، `failed` (ناموفق)، `refunded` (مسترد). برچسب/رنگ هر کدام. +- مبلغ از `payment.amount_rials` است (ریال) — برای نمایش تومان `/10`. +- روش پرداخت از `payment.gateway` (`mellat`/`sep`/`mock`). +- تاریخ از `payment.created_at` (Unix ثانیه). + +### ۵. صفحه‌ی واسط نتیجه (اگر گزینه الف انتخاب شد) +`app/payment/result/page.js` بساز: +- `payment_uuid` و `status` را از `useSearchParams` بخوان. +- اگر `payment_uuid` بود → `router.replace('/payment/' + payment_uuid)`؛ در غیر این صورت پیام خطا. +- این صفحه فقط یک واسط ریدایرکت سبک است (با لودینگ). + +### ۶. تثبیت اطلاعات حساب کاربری (انجام‌شده — تأیید) +در `components/appointment/index.js` تابع `buildProfileData` قبلاً اصلاح شده: `name` از `profile.label`، `basic_insurance`/`supplementary_insurance` از `*_id` به‌صورت `{id}`، `gender` رشته‌ی `profile.gender`، و `national_code.isEdit = !profile.national_code_approved`. تأیید کن این فیلدها در فرم (`detail/Form.js`) درست نمایش داده می‌شوند و در `SubmitData` درست ارسال می‌شوند (که سازگار است). اگر چیزی جا مانده اصلاح کن. + +## نکات مهم + +- **بک‌اند تغییر نمی‌کند.** همه‌ی endpointها موجود و درست‌اند؛ فقط مصرف frontend اصلاح می‌شود. +- پاسخ‌های `appointment` و `payment/{uuid}` و `payment/appointment` همگی **double-nested** اند (`res.data.data`)؛ ولی `payment/config` نیست (`res.data.test_mode`). به این تفاوت دقت کن. +- شمارنده باید از `expires_at` واقعیِ بک‌اند (Unix ثانیه) تغذیه شود؛ منطق فعلی `computeLeft` درست است، فقط ورودی‌اش باید برسد. +- پرداخت تست = درگاه تست بک‌اند (`MockGateway`)؛ فرانت فقط `redirect_url` را باز می‌کند و نتیجه را از callback/صفحه‌ی نتیجه می‌گیرد. شبیه‌سازی فرانت ممنوع. +- App Router؛ صفحه‌ی نتیجه `"use client"` (به `useSearchParams`/`useParams` نیاز دارد). RTL، فونت Vazir، MUI v5. +- بعد از تغییر: `npm run build` بدون خطا؛ جریان را end-to-end تست کن (نوبت → شمارنده فعال → پرداخت آزمایشی → callback → صفحه‌ی نتیجه با وضعیت `success`). diff --git a/app/payment/[uuid]/page.js b/app/payment/[uuid]/page.js index 28f09da..7d21c78 100644 --- a/app/payment/[uuid]/page.js +++ b/app/payment/[uuid]/page.js @@ -12,6 +12,27 @@ export default function PaymentDetailsPage() { const [payment, setPayment] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [paying, setPaying] = useState(false); + + const handleRetryPayment = async () => { + if (!payment?.appointment_uuid) return; + setPaying(true); + try { + const res = await request.postAppointmentPayment({ + appointment_uuid: payment.appointment_uuid, + gateway: payment.gateway || "mellat", + frontend_address: `${window.location.origin}/payment/result`, + }); + const redirectUrl = res?.data?.redirect_url; + if (redirectUrl) { + window.location.href = redirectUrl; + } else { + setPaying(false); + } + } catch { + setPaying(false); + } + }; useEffect(() => { // بررسی لاگین بودن کاربر @@ -25,7 +46,7 @@ export default function PaymentDetailsPage() { try { setLoading(true); const response = await request.getPayment(params.uuid); - setPayment(response); + setPayment(response?.data?.data ?? null); } catch (err) { setError("خطا در دریافت اطلاعات پرداخت"); console.error("Payment fetch error:", err); @@ -42,9 +63,9 @@ export default function PaymentDetailsPage() { const getStatusLabel = (status) => { const statusMap = { pending: "در انتظار پرداخت", - completed: "پرداخت موفق", + success: "پرداخت موفق", failed: "پرداخت ناموفق", - canceled: "لغو شده", + refunded: "مسترد شده", }; return statusMap[status] || status; }; @@ -52,9 +73,9 @@ export default function PaymentDetailsPage() { const getStatusColor = (status) => { const colorMap = { pending: "text-yellow-600 bg-yellow-50", - completed: "text-green-600 bg-green-50", + success: "text-green-600 bg-green-50", failed: "text-red-600 bg-red-50", - canceled: "text-gray-600 bg-gray-50", + refunded: "text-gray-600 bg-gray-50", }; return colorMap[status] || "text-gray-600 bg-gray-50"; }; @@ -62,7 +83,8 @@ export default function PaymentDetailsPage() { const getPaymentMethodLabel = (method) => { const methodMap = { mellat: "بانک ملت", - saman: "سامان کیش", + sep: "سامان (سپ)", + mock: "درگاه آزمایشی", }; return methodMap[method] || method; }; @@ -78,8 +100,8 @@ export default function PaymentDetailsPage() { }).format(date); }; - const formatAmount = (amount) => { - return new Intl.NumberFormat("fa-IR").format(parseInt(amount)); + const formatAmount = (rials) => { + return new Intl.NumberFormat("fa-IR").format(Math.round(parseInt(rials) / 10)); }; if (loading) { @@ -136,7 +158,7 @@ export default function PaymentDetailsPage() {
مبلغ: - {formatAmount(payment.amount)} تومان + {formatAmount(payment.amount_rials)} تومان
@@ -144,7 +166,7 @@ export default function PaymentDetailsPage() {
روش پرداخت: - {getPaymentMethodLabel(payment.payment_method)} + {getPaymentMethodLabel(payment.gateway)}
@@ -170,17 +192,7 @@ export default function PaymentDetailsPage() {
تاریخ ایجاد: - {formatDate(payment.created)} - -
- - {/* تاریخ آخرین تغییر */} -
- - آخرین بروزرسانی: - - - {formatDate(payment.changed)} + {formatDate(payment.created_at)}
@@ -188,7 +200,7 @@ export default function PaymentDetailsPage() {
نوع سرویس: - {payment.bundle === "appointment" ? "رزرو نوبت" : payment.bundle} + {payment.type === "appointment" ? "رزرو نوبت" : payment.type}
@@ -198,18 +210,11 @@ export default function PaymentDetailsPage() { {payment.status === "pending" ? ( <>