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>
This commit is contained in:
hamed
2026-06-15 15:35:43 +03:30
co-authored by Claude Opus 4.8
parent e13d1c2eeb
commit 811882ea05
2 changed files with 292 additions and 5 deletions
@@ -0,0 +1,288 @@
# بازنویسی صفحه‌ی گرفتن نوبت (`/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ها را پیدا و مهاجرت بده.
+4 -5
View File
@@ -51,8 +51,6 @@ export const request = {
Authorization: "",
},
}),
getAppointmentNotAvailable: (doctor_id) =>
api.get(`api/v1/appointment/not-available/${doctor_id}`, removeTokenHead),
getAppointmentWeeklySchedule: (uuid) =>
api.get(`api/v1/appointment-settings/weekly-schedule/${uuid}`),
postAppointmentWeeklySchedule: () =>
@@ -61,14 +59,15 @@ export const request = {
api.patch(`api/v1/appointment-settings/weekly-schedule/${uuid}`),
deleteAppointmentWeeklySchedule: (uuid) =>
api.delete(`api/v1/appointment-settings/weekly-schedule/${uuid}`),
getAppointment: (doctor_id, date) =>
getAppointmentSlots: (doctor_uuid, date) =>
api.get(
`api/v1/appointment-slots?date=${date}&doctor_id=${doctor_id}`,
`api/v1/appointment-slots?doctor_uuid=${doctor_uuid}&date=${date}`,
removeTokenHead
),
postAppointment: (data) => api.post(`api/v1/appointment`, data, { requireAuth: true }),
getMyAppointments: (userId, params) => api.get(`api/v1/appointment/my-appointments/${userId}`, { params, requireAuth: true }),
postPayment: (data) => api.post(`api/v1/payment`, data, { requireAuth: true }),
postAppointmentPayment: (data) =>
api.post(`api/v1/payment/appointment`, data, { requireAuth: true }),
getPayment: (uuid) => api.get(`api/v1/payment/${uuid}`, { requireAuth: true }),
getMyPayments: (userId, params) => api.get(`api/v1/payment/my-payments/${userId}`, { params, requireAuth: true }),
postDoctor: (data) => api.post("api/v1/doctor", data),