- 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>
19 KiB
بازنویسی صفحهی گرفتن نوبت (/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(«دکتر یافت نشد») میدهد. - پاسخ:
{
"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)
بدنه:
{ "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، باید مالک نوبت باشد)
بدنه:
{ "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)
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
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
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
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
const list = appo && appo[value ? "evening" : "morning"]; // ❌ کلید وجود ندارد
// ...
disabled={item.status !== "available"} // ❌ is_available
{item.time} // ❌ start_time
components/appointment/detail/SubmitData.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
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 (خواندن پروفایل)
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 است):
getAppointmentSlots: (doctor_uuid, date) =>
api.get(`api/v1/appointment-slots?doctor_uuid=${doctor_uuid}&date=${date}`, removeTokenHead),
postAppointmentبدنهاش از caller میآید (در وظیفه ۴ اصلاح میشود) — تغییری در امضای تابع لازم نیست.- پرداخت را به مسیر و نام درست ببر:
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 وجود ندارد). propdisabledDatesرا یا حذف کن یا[]بفرست تا کامپوننتهای پاییندستی نشکنند. تاریخهای غیرفعال در همان پاسخ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
- بدنهی درست:
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
- بدنه و مسیر درست:
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ها را پیدا و مهاجرت بده.