diff --git a/.claude/prompt/fix-appointment-booking-flow.md b/.claude/prompt/fix-appointment-booking-flow.md new file mode 100644 index 0000000..426f036 --- /dev/null +++ b/.claude/prompt/fix-appointment-booking-flow.md @@ -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ها را پیدا و مهاجرت بده. diff --git a/services/response.js b/services/response.js index 4a375c0..eb270e2 100644 --- a/services/response.js +++ b/services/response.js @@ -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),