# بازنویسی صفحه‌ی گرفتن نوبت (`/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ها را پیدا و مهاجرت بده.