Files
nobat724_front/.claude/prompt/fix-appointment-booking-flow.md
T
hamedandClaude Opus 4.8 811882ea05 fix(appointment): align request layer with real backend contract
- getAppointmentSlots uses doctor_uuid + date (was doctor_id, which the
  backend rejects with دکتر یافت نشد).
- postAppointmentPayment posts to /api/v1/payment/appointment (was the
  nonexistent /api/v1/payment).
- Remove getAppointmentNotAvailable: the not-available route does not
  exist (404); disabled dates come from the slots response.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 15:35:43 +03:30

289 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# بازنویسی صفحه‌ی گرفتن نوبت (`/appointment/[doctorId]`) برای هم‌خوانی با API واقعی
## پروژه
`nobat724_front` — سایت عمومی نوبت‌دهی. این کار **صرفاً frontend** است؛ backend (`clinicpro`) کامل و درست است و **تغییر نمی‌کند**. مشکل این است که کل فلوی نوبت‌گیری روی یک قرارداد API **خیالی/قدیمی** نوشته شده که با endpointهای واقعی `clinicpro` هم‌خوان نیست. همه‌ی endpointهای لازم از قبل در `clinicpro/docs/api/appointment.md` و `clinicpro/docs/api/payment.md` مستند و آماده‌اند.
## زمینه
صفحه‌ی `/appointment/[doctorId]` یک wizard چندمرحله‌ای است:
```
step 0: انتخاب تاریخ + ساعت (Date) → step 1/2: لاگین OTP → step 3: تکمیل اطلاعات (Detail) → step 4: پرداخت (Paying) → step 5/6: success/failed
```
این فلو در ظاهر کامل است ولی عملاً کار نمی‌کند چون **هر چهار فراخوانی اصلی API (اسلات‌ها، رزرو، پروفایل، پرداخت) با قرارداد واقعی backend مغایرت دارند** و علاوه بر آن صفحه از یک endpoint که اصلاً وجود ندارد (`appointment/not-available/{id}`) استفاده می‌کند.
## مشکل / هدف
صفحه‌ی نوبت‌گیری را به API واقعی وصل کن: استخراج درست پاسخ‌ها، پارامترها و بدنه‌های درست، و مپ‌کردن ساختار واقعی اسلات (`sessions[].slots[]`) به UI موجود. هدف این است که یک کاربر واقعی بتواند تاریخ/ساعت را ببیند، اسلات انتخاب کند، نوبت رزرو کند و به درگاه پرداخت برود.
این کار باید **مرحله‌به‌مرحله** انجام شود (یک قابلیت در هر مرحله: پیاده‌سازی → build → تأیید → commit).
## قرارداد واقعی Backend (تأییدشده با curl روی `https://clinic-pro.ddev.site`)
### ۱. اسلات‌ها — `GET /api/v1/appointment-slots?doctor_uuid={uuid}&date={Y-m-d}` (PUBLIC)
- پارامتر `doctor_uuid` (UUID) است، **نه** `doctor_id`. فراخوانی با `doctor_id` خطای `ERR_VALIDATION_002` («دکتر یافت نشد») می‌دهد.
- پاسخ:
```json
{
"success": true,
"data": {
"doctor_uuid": "...",
"date": "2026-06-20",
"sessions": [
{
"start_time": "09:00",
"end_time": "13:00",
"slots": [
{ "start": 1781933400, "end": 1781934600, "start_time": "09:00", "end_time": "09:20", "location_id": 2581, "is_available": true }
]
},
{ "start_time": "15:00", "end_time": "17:00", "slots": [ ... ] }
]
}
}
```
> هیچ کلید `morning`/`evening`/`hours_morning`/`hours_afternoon` وجود ندارد. اسلات کلید `time`/`status` ندارد؛ به‌جایش `start_time` (رشته‌ی HH:MM)، `is_available` (boolean)، و `start`/`end` (Unix timestamp) دارد. شیفت‌ها در آرایه‌ی `sessions` هستند (نه دو دسته‌ی ثابت صبح/عصر).
### ۲. رزرو نوبت — `POST /api/v1/appointment` (AUTH)
بدنه:
```json
{ "doctor_uuid": "...", "slot_start": 1781933400, "slot_end": 1781934600, "note": "" }
```
- `doctor_uuid` (نه `doctor_id``slot_start`/`slot_end` به‌صورت Unix timestamp (همان `start`/`end` اسلات)، `note` اختیاری (نه `info`، نه شیء `slot`).
- پاسخ `201`: `{ success, data: { uuid, status: "pending", price, ... } }` — شناسه‌ی نوبت `data.uuid` است (UUID)، **نه** `data.id`.
### ۳. پرداخت — `POST /api/v1/payment/appointment` (AUTH، باید مالک نوبت باشد)
بدنه:
```json
{ "appointment_uuid": "...", "gateway": "mellat", "frontend_address": "https://.../payment/result" }
```
- مسیر `payment/appointment` است، **نه** `payment`. `gateway` فقط `"mellat"` یا `"sep"`. فیلدهای `payment_method`/`bundle`/`entity_reference_id` وجود ندارند.
- پاسخ `200`: `{ success, data: { payment_uuid, redirect_url, order_id } }` — برای ریدایرکت از `data.redirect_url` استفاده کن (نه ساختن دستی URL).
### ۴. پروفایل کاربر — `GET /api/v1/user-profile/{uuid}` (AUTH)
پاسخ `{ success, data: {...} }`.
### ۵. interceptor مهم (`services/api.js`)
```js
api.interceptors.response.use((response) => response.data, ...)
```
همه‌ی `request.*` **یک‌بار** پاسخ را باز می‌کنند و **بدنه‌ی HTTP** (`{ success, data }`) را برمی‌گردانند. پس داده‌ی واقعی همیشه در `result.data` است. (نکته: تابع server-side `fetchReq` در `lib/req.js` هم `response.data` را برمی‌گرداند؛ ولی صفحه‌ی نوبت با `axiosInstance.get(...)` مستقیم کار می‌کند که باز نمی‌کند → آنجا `.data` لازم است.)
> هشدار double-nesting: پاسخ `GET /api/v1/doctor/{uuid}` سه‌لایه است (`{ success, data: { data: {...} } }`). در `axiosInstance` خام یعنی `res.data.data.data`. (این الگو قبلاً در `app/doctor/[slug]/page.js` با `res.data?.data?.data` رفع شده — همان‌جا را مرجع بگیر.)
## فایل‌های مرتبط
| فایل | نقش | مشکل |
|------|-----|------|
| `app/appointment/[doctorId]/page.js` | server: doctor + disabledDates | `doctor = doctorRes.data` (باید `?.data?.data`)؛ فراخوانی endpoint ناموجود `appointment/not-available/{id}` |
| `services/response.js` | لایه‌ی `request.*` | `getAppointment` با `doctor_id`؛ `postAppointment`/`postPayment`/`getPayment` با مسیر/بدنه‌ی غلط؛ `getAppointmentNotAvailable` به route ناموجود |
| `app/component/date/dateTime/index.js` | fetch اسلات‌ها + آدرس | `getAppointment(doctor.id, date)`؛ خواندن `res.morning`/`res.evening`؛ `res.address` |
| `app/component/date/dateTime/hours/List.js` | grid اسلات‌ها | `appo[value ? "evening" : "morning"]`، `item.time`، `item.status` — هیچ‌کدام در API نیست |
| `app/component/date/dateTime/SendAppo.js` | دکمه‌ی «تایید نوبت» | فقط `setSelectedSlot(hour)` — وابسته به شکل `hour` |
| `components/appointment/Content.js` (در `components/appointment/index.js`) | fetch پروفایل کاربر | خواندن `res.uuid`/`res.name` (باید `res.data.*`) |
| `components/appointment/detail/SubmitData.js` | PATCH/POST پروفایل + POST نوبت | بدنه‌ی نوبت غلط؛ خواندن `appointmentResponse.id` (باید `data.uuid`) |
| `components/appointment/paying/index.js` | POST پرداخت + ریدایرکت | بدنه/مسیر غلط؛ خواندن `response.uuid`؛ ساختن دستی URL درگاه |
| `clinicpro/docs/api/appointment.md` | قرارداد اسلات/رزرو | مرجع — تغییر نمی‌کند |
| `clinicpro/docs/api/payment.md` | قرارداد پرداخت | مرجع — تغییر نمی‌کند |
## وضعیت فعلی (کد واقعی مشکل‌دار)
### `app/appointment/[doctorId]/page.js`
```js
const doctorRes = await axiosInstance.get(`${API_URL}/api/v1/doctor/${doctorId}`);
doctor = doctorRes.data; // ❌ باید doctorRes.data?.data?.data
if (doctor && doctor.id) { // ❌ doctor.id همیشه undefined
const disabledDatesRes = await axiosInstance.get(
`${API_URL}/api/v1/appointment/not-available/${doctor.id}` // ❌ route وجود ندارد (404)
);
disabledDates = disabledDatesRes.data?.data || [];
}
```
### `services/response.js`
```js
getAppointmentNotAvailable: (doctor_id) =>
api.get(`api/v1/appointment/not-available/${doctor_id}`, removeTokenHead), // ❌ route ناموجود
getAppointment: (doctor_id, date) =>
api.get(`api/v1/appointment-slots?date=${date}&doctor_id=${doctor_id}`, removeTokenHead), // ❌ doctor_id
postAppointment: (data) => api.post(`api/v1/appointment`, data, { requireAuth: true }),
postPayment: (data) => api.post(`api/v1/payment`, data, { requireAuth: true }), // ❌ مسیر payment/appointment
getPayment: (uuid) => api.get(`api/v1/payment/${uuid}`, { requireAuth: true }),
```
### `app/component/date/dateTime/index.js`
```js
request.getAppointment(doctor.id, date).then((res) => {
setAppo(res); // ❌ res، نه res.data
const hasAvailableMorning = res.morning?.some(s => s.status === "available"); // ❌ morning/status نیست
const hasAvailableEvening = res.evening?.some(s => s.status === "available");
...
});
// آدرس:
request.getDoctorAddress(hour.location_id).then((res) => setLocationAddress(res.address)); // ❌ res.data.address
```
### `app/component/date/dateTime/hours/List.js`
```js
const list = appo && appo[value ? "evening" : "morning"]; // ❌ کلید وجود ندارد
// ...
disabled={item.status !== "available"} // ❌ is_available
{item.time} // ❌ start_time
```
### `components/appointment/detail/SubmitData.js`
```js
const appointmentPayload = {
doctor_id: doctor.id, // ❌ doctor_uuid
slot: { // ❌ backend شیء slot نمی‌خواهد
time: selectedSlot.time, status: selectedSlot.status,
start_time_timestamp: selectedSlot.start_time_timestamp,
end_time_timestamp: selectedSlot.end_time_timestamp,
duration_per_patient: selectedSlot.duration_per_patient,
location_id: selectedSlot.location_id
},
info: "" // ❌ note
};
const appointmentResponse = await request.postAppointment(appointmentPayload);
if (appointmentResponse?.id) setAppointmentId(appointmentResponse.id); // ❌ data.uuid
```
### `components/appointment/paying/index.js`
```js
const paymentPayload = {
payment_method: selectedBank, // ❌ gateway
bundle: "appointment", // ❌ حذف
entity_reference_id: appointmentId // ❌ appointment_uuid
};
const response = await request.postPayment(paymentPayload);
if (response?.uuid) { // ❌ data.redirect_url
const paymentUrl = `${process.env.NEXT_PUBLIC_API_URL}/payment/${response.uuid}`; // ❌ از redirect_url استفاده کن
window.location.href = paymentUrl;
}
```
### `components/appointment/index.js` (خواندن پروفایل)
```js
const res = await request.getUserProfile(parsedData.uuid);
if (res) {
const newData = { uuid: res.uuid, ... national_code: { value: res.national_code ... } }; // ❌ res.data.*
}
```
## وظایف
اجرای مرحله‌به‌مرحله؛ بعد از هر قابلیت `npm run build` و سپس commit جدا.
### ۱. اصلاح لایه‌ی `request.*` در `services/response.js`
- `getAppointment` را با `doctor_uuid` بازنویسی کن (صفحه با `[doctorId]` کار می‌کند که همان uuid است):
```js
getAppointmentSlots: (doctor_uuid, date) =>
api.get(`api/v1/appointment-slots?doctor_uuid=${doctor_uuid}&date=${date}`, removeTokenHead),
```
- `postAppointment` بدنه‌اش از caller می‌آید (در وظیفه ۴ اصلاح می‌شود) — تغییری در امضای تابع لازم نیست.
- پرداخت را به مسیر و نام درست ببر:
```js
postAppointmentPayment: (data) =>
api.post(`api/v1/payment/appointment`, data, { requireAuth: true }),
```
- `getAppointmentNotAvailable` و تابع/route ناموجود `not-available` را حذف کن (در وظیفه ۲ مصرفش هم حذف می‌شود).
- اسم‌های قدیمی (`getAppointment`, `postPayment`) را اگر جای دیگری مصرف نمی‌شوند حذف کن؛ اگر مصرف می‌شوند، همه‌ی callerها را به نسخه‌ی جدید مهاجرت بده. (با grep بررسی کن.)
### ۲. اصلاح `app/appointment/[doctorId]/page.js`
- استخراج درست doctor: `doctor = doctorRes.data?.data?.data;`
- منطق `disabledDates` و فراخوانی `appointment/not-available/{id}` را **حذف کن** (route وجود ندارد). prop `disabledDates` را یا حذف کن یا `[]` بفرست تا کامپوننت‌های پایین‌دستی نشکنند. تاریخ‌های غیرفعال در همان پاسخ `appointment-slots` (نبودِ session/اسلات available) منعکس می‌شود؛ منطق غیرفعال‌سازی روز را به آن واگذار کن.
- `disabledDates` در `Time`/`SelectDatePicker` مصرف می‌شود — بررسی کن با آرایه‌ی خالی رفتار درستی دارد (همه‌ی روزها قابل‌انتخاب) و crash نمی‌کند.
### ۳. مپ‌کردن ساختار واقعی اسلات به UI — `dateTime/index.js` + `hours/List.js`
- در `dateTime/index.js`: `request.getAppointmentSlots(doctor.uuid, date)` را صدا بزن (نه `doctor.id`؛ پس از وظیفه ۲، `doctor` شیء واقعی است و `uuid` دارد).
- پاسخ در `res.data` است (interceptor). یک adapter بنویس که `data.sessions[]` را به ساختاری که UI می‌خواهد تبدیل کند. **توهم‌سازی صبح/عصر نکن**؛ یا مستقیم روی `sessions` رندر کن، یا اگر می‌خواهی UI تب‌دار صبح/عصر را نگه داری، sessionها را بر اساس `start_time < "12:00"` به صبح/عصر تقسیم کن و این تصمیم را در adapter مستندِ کوچک بگذار.
- در `List.js`: به‌جای `item.time` از `item.start_time`، به‌جای `item.status !== "available"` از `!item.is_available`، و به‌جای کلید `morning`/`evening` از خروجی adapter استفاده کن.
- `setSelectedSlot(hour)` در `SendAppo` باید شیئی نگه دارد که `start` و `end` (Unix) و `location_id` دارد — همان آبجکت اسلات از API. مطمئن شو این فیلدها تا `SubmitData` می‌رسند.
- بخش آدرس: `request.getDoctorAddress(hour.location_id)` پاسخش `res.data` است — `setLocationAddress(res.data?.address)`. (شکل واقعی `getDoctorAddress` را با یک فراخوانی تأیید کن.)
### ۴. اصلاح ساخت نوبت — `components/appointment/detail/SubmitData.js`
- بدنه‌ی درست:
```js
const appointmentPayload = {
doctor_uuid: doctor.uuid,
slot_start: selectedSlot.start,
slot_end: selectedSlot.end,
note: "",
};
const res = await request.postAppointment(appointmentPayload);
const appointmentUuid = res?.data?.uuid;
if (appointmentUuid) setAppointmentId(appointmentUuid); // نام state را appointmentUuid نگه‌دار یا همان appointmentId با مقدار uuid
```
- خطای `409` (اسلات قبلاً رزرو شده، `ERR_CONFLICT_001`) را به پیام فارسی مناسب map کن و کاربر را به مرحله‌ی انتخاب ساعت برگردان.
- بخش PATCH/POST پروفایل قبل از رزرو دست‌نخورده می‌ماند مگر اینکه شکل پاسخ را بشکند — فقط مطمئن شو با interceptor (`res.data`) هم‌خوان است.
### ۵. اصلاح پرداخت — `components/appointment/paying/index.js`
- بدنه و مسیر درست:
```js
const paymentPayload = {
appointment_uuid: appointmentId, // همان uuid نوبت
gateway: selectedBank, // فقط "mellat" یا "sep"
frontend_address: `${window.location.origin}/payment/result`,
};
const res = await request.postAppointmentPayment(paymentPayload);
const redirectUrl = res?.data?.redirect_url;
if (redirectUrl) {
window.location.href = redirectUrl; // از redirect_url خود backend استفاده کن
} else {
setStep((prev) => prev + 1);
}
```
- لیست بانک‌ها (`banks`) را با `gateway`های واقعی هم‌خوان کن: `{ id: "mellat", title: "بانک ملت" }`، `{ id: "sep", title: "سامان (سپ)" }`. مقدار `id` باید دقیقاً `mellat`/`sep` باشد.
- مبلغ hard-code شده‌ی «10،000 تومان» را یا از `price` پاسخ نوبت (`data.price`، ریال) بگیر و با جداکننده‌ی فارسی به ریال/تومان نمایش بده، یا اگر در این مرحله در دسترس نیست، متن مبلغ ثابت را حذف کن (توهم‌سازی مبلغ ممنوع). `price` در پاسخ `POST /api/v1/appointment` هست — می‌توان آن را تا این مرحله پاس داد.
### ۶. اصلاح خواندن پروفایل — `components/appointment/index.js`
- در هر دو `useEffect`، `const res = await request.getUserProfile(parsedData.uuid)` پاسخش `{ success, data }` است → از `res.data` بخوان: `res.data.uuid`, `res.data.national_code`, `res.data.name`, ... . منطق 404 (پروفایل ناموجود → فیلدها قابل‌ویرایش) حفظ شود.
## نکات مهم
- **هیچ تغییری در backend لازم نیست.** اگر به endpoint غایبی برخوردی، **متوقف شو و بپرس** (cross-repo) — داده‌ی جعلی یا route حدسی جایگزین نکن. مشخصاً `appointment/not-available` وجود ندارد و نباید بازسازی شود.
- **interceptor (`services/api.js`):** همه‌ی `request.*` بدنه‌ی `{ success, data }` را برمی‌گردانند → داده در `result.data`. صفحه‌ی server-side با `axiosInstance` خام، doctor را سه‌لایه می‌گیرد (`res.data.data.data`).
- **slug = uuid:** پارامتر route `[doctorId]` در عمل uuid پزشک است؛ همان را به `doctor_uuid` بده، نه `doctor.id` عددی.
- **timestampها Unix هستند:** `slot.start`/`slot.end` مستقیم به‌عنوان `slot_start`/`slot_end` می‌روند؛ تبدیل اضافه نکن.
- **adapter جدا:** برای اسلات‌ها یک تابع map کوچک بنویس (`sessions[]` → ساختار UI) تا کامپوننت نمایشی کم‌تغییر بماند و دیف کوچک شود.
- **Auth:** رزرو و پرداخت `requireAuth` می‌خواهند؛ در 401، `api.js` خودکار logout/redirect می‌کند — این رفتار را حفظ کن. فلوی OTP (step 1/2) دست‌نخورده می‌ماند.
- **Multi-domain/RTL/Jalali:** تاریخ‌ها شمسی، مبالغ ریال با جداکننده‌ی فارسی، `matchedCity` موجود حفظ شود.
- **تست:** بعد از هر قابلیت `npm run build` (ESLint در این پروژه setup نشده؛ build خود type-check را انجام می‌دهد). در صورت امکان فلو را با یک پزشک واقعی (`4a0594b1-008b-478a-a593-259b95d8c2dd`) و تاریخ آینده دستی تست کن: اسلات‌ها باید لود شوند. سپس commit با پیام توصیفی برای همان قابلیت.
- **عدم رگرسیون:** `getAppointment`/`postPayment` قدیمی ممکن است جای دیگری مصرف شوند؛ قبل از حذف/تغییر امضا، با `grep -rn "getAppointment\|postPayment\|getAppointmentNotAvailable" app components services` همه‌ی callerها را پیدا و مهاجرت بده.