The doctor page listed where a doctor works but never when, so search engines had no working hours for any location. booking_locations now carries opening_hours per context, so each MedicalClinic in the Physician JSON-LD gets its own openingHoursSpecification, matched to the address by uuid. Verified against the rendered page: the clinic location emits five shifts with schema.org weekday URLs alongside the availableService entries. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
18 KiB
انتخاب محل نوبتدهی (مطب شخصی / کلینیک) در جریان رزرو
پروژه
nobat724_front — پرامپت همتای backend که قبلاً اجرا شده است:
clinicpro/.claude/prompt/context-separation-clinic-vs-doctor-booking.md
قرارداد API از سمت backend آماده است؛ این پرامپت فقط سمت مصرفکننده را مینویسد.
زمینه
تا پیش از این، هر پزشک در کل سیستم یک برنامهٔ نوبتدهی داشت (weekly_schedules با
UNIQUE(doctor_id)). حالا برنامه per-context است: یک برنامهٔ مطب شخصی + یکی به ازای هر کلینیکی
که پزشک در آن عضو است. ستون clinic_id روی weekly_schedules، date_overrides و holidays
اضافه شده (NULL = مطب شخصی).
سرویسها هم polymorphicاند و بین این دو محیط هرگز مشترک نمیشوند: سرویسهای کلینیک فقط در نوبتدهی کلینیک و سرویسهای شخصی فقط در مطب شخصی. قیمت و مدت سرویس بین محلها متفاوت است.
مشکل / هدف
این یک شکست خاموش است، نه یک قابلیت جدید.
همهٔ endpointهای رزرو حالا پارامتر اختیاری clinic_uuid میگیرند و نبودِ آن یعنی «مطب شخصی» —
نه «هر محلی که پیدا شد». سایت الان هیچجا clinic_uuid نمیفرستد، پس:
- برای پزشکی که فقط در کلینیک کار میکند، سایت هیچ نوبتی نشان نمیدهد (برنامهٔ شخصی ندارد).
- برای پزشکی که هر دو را دارد، سایت فقط ظرفیت مطب شخصی را نشان میدهد و بیمار بدون اطلاع نوبت را در محل اشتباه رزرو میکند.
- در حالت سرویسی،
POST /api/v1/appointmentبا سرویسی که به آن محل تعلق ندارد422میگیرد.
هدف: بیمار محل را آگاهانه انتخاب کند و آن انتخاب تا لحظهٔ ثبت نوبت حمل شود.
قرارداد API (آمادهٔ مصرف)
endpoint جدید
GET /api/v1/appointment-booking-locations/{doctorUuid} (عمومی، بدون auth)
{
"success": true,
"data": {
"doctor_uuid": "550e8400-…",
"booking_locations": [
{
"location_uuid": "0f0b…",
"type": "personal",
"title": "مطب شخصی",
"address": "یزد، خیابان …",
"clinic_uuid": null,
"booking_mode": "slot",
"buffer_minutes": 0,
"services": [],
"next_available_at": 1755000000
},
{
"location_uuid": "7c21…",
"type": "clinic",
"title": "کلینیک علی بهروزی",
"address": "یزد، بلوار …",
"clinic_uuid": "41e325c4-e825-4067-8438-5d828ecaee09",
"booking_mode": "service",
"buffer_minutes": 10,
"services": [
{ "uuid": "…", "name": "ویزیت", "duration_minutes": 20, "price_rials": 500000,
"service_section": { "uuid": "…", "name": "عمومی" } }
],
"next_available_at": 1754900000
}
]
}
}
نکات قرارداد:
- آرایه از قبل مرتب است بر اساس
next_available_atصعودی؛ محلهای بدون ظرفیت آخر میآیند. پسbooking_locations[0]پیشفرضِ درست است — دوباره مرتب نکن. next_available_atمیتواندnullباشد (تا ۳۰ روز آینده ظرفیتی نیست).location_uuidمیتواندnullباشد (آن محیط هنوز آدرس ثبتشده ندارد) — برای انتخاب ازclinic_uuidاستفاده کن، نهlocation_uuid.booking_modeper-location است: یک پزشک میتواند در مطب شخصی اسلاتی و در کلینیک سرویسی باشد.servicesفقط در حالتserviceپُر است.- پاسخ double-nested است (
json.data.data) مثل بقیهٔ endpointها.
پارامتر جدید روی endpointهای موجود
| endpoint | محل پارامتر |
|---|---|
GET /api/v1/appointment-slots |
query clinic_uuid |
GET /api/v1/appointment-service-slots |
query clinic_uuid |
GET /api/v1/appointment-booking-services/{doctorUuid} |
query clinic_uuid |
GET /api/v1/appointment-settings/month-availability/{doctorUuid} |
query clinic_uuid |
POST /api/v1/appointment |
body clinic_uuid |
هر چهار GET مقدار clinic_uuid را در پاسخ echo میکنند تا کلاینت بفهمد کدام محیط جواب داده.
اگر پزشک عضو آن کلینیک نباشد → 404 ERR_VALIDATION_002 («محل نوبتدهی یافت نشد»).
سرویسِ متعلق به محیط دیگر در POST → 422 ERR_VALIDATION_001
(«سرویس انتخابشده به این محل نوبتدهی تعلق ندارد»).
مستندات کامل: clinicpro/docs/api/appointment.md → بخش «Booking context (2026-07)».
فایلهای مرتبط
| فایل | نقش |
|---|---|
services/response.js:52-72 |
همهٔ فراخوانیهای رزرو |
components/appointment/index.js:45 |
AppointmentPage — نگهدارندهٔ کل state رزرو |
components/appointment/Container.js:15-113 |
مسیریابی مرحلهها (prop drilling) |
components/appointment/service/index.js:11 |
ServiceSelect |
components/appointment/date/index.js |
wrapper مرحلهٔ تاریخ (locateVisit مرده) |
app/component/date/datePicker/index.js:24 |
DatePicker — تقویم ماه + getMonthAvailability |
app/component/date/dateTime/index.js:11 |
DateTime — اسلاتها + resolve آدرس |
app/component/date/dateTime/hours/index.js:17-26 |
کارت «آدرس مطب:» |
components/appointment/detail/SubmitData.js:138-160 |
payload نهایی postAppointment |
components/appointment/location/Address.js |
سایدبار آدرسها (نمایشی) |
app/doctor/[slug]/page.js:107-157 |
JSON-LD صفحهٔ پزشک |
components/doctor/appointmentList/index.js:33 |
CTA ورود به رزرو |
lib/appointmentSlots.js |
adaptSlots / adaptServiceSlots |
وضعیت فعلی
۱. هیچ فراخوانیای محل را نمیفرستد
services/response.js:52-72:
getAppointmentSlots: (doctor_uuid, date) =>
api.get(
`api/v1/appointment-slots?doctor_uuid=${doctor_uuid}&date=${date}`,
removeTokenHead
),
getMonthAvailability: (doctor_uuid, year, month) =>
api.get(`api/v1/appointment-settings/month-availability/${doctor_uuid}`, {
params: { year, month },
...removeTokenHead,
}),
getBookingServices: (doctor_uuid) =>
api.get(`api/v1/appointment-booking-services/${doctor_uuid}`, removeTokenHead),
getServiceSlots: (doctor_uuid, date, serviceItemUuids = []) =>
api.get(
`api/v1/appointment-service-slots?doctor_uuid=${doctor_uuid}&date=${date}` +
serviceItemUuids
.map((u) => `&service_item_uuids[]=${encodeURIComponent(u)}`)
.join(""),
removeTokenHead
),
postAppointment: (data) => api.post(`api/v1/appointment`, data, { requireAuth: true }),
۲. آدرس فقط نمایشی است و از doctor.address میآید
app/component/date/dateTime/index.js:33-35 — تنها جایی که آدرس یک اسلات حل میشود:
const locationAddress = hour?.location_id
? doctor?.address?.find((a) => String(a.id) === String(hour.location_id))?.address ?? null
: null;
هیچ محلی قابل انتخاب نیست و هیچ id محلی در state رزرو ذخیره نمیشود.
۳. payload نهایی
components/appointment/detail/SubmitData.js:138-158:
const appointmentPayload = {
doctor_uuid: doctor.uuid,
slot_start: selectedSlot.start,
slot_end: selectedSlot.end,
for_self: !isForAnother,
note: data?.patient_reason?.value || "",
patient_national_code: data?.national_code?.value || "",
patient_gender: data?.gender?.value?.id || data?.gender?.value || "",
city_id: cityId ?? null,
...(selectedServiceUuids?.length ? { service_item_uuids: selectedServiceUuids } : {}),
...(isForAnother ? { patient_name, patient_mobile, patient_reason } : {}),
};
۴. state مرده
components/appointment/date/index.js:15 — locateVisit با doctor?.multiwork گیت شده، مقدار
اولیهاش true است و هیچ فرزندی false نمیکند. عملاً کد مرده است. حذفش کن و جای آن انتخاب
واقعی محل بنشان.
۵. JSON-LD بدون ساعات کاری
app/doctor/[slug]/page.js:107-157 — workLocation وجود دارد ولی هیچ
openingHoursSpecification و هیچ availableService ندارد.
وظایف
۱. لایهٔ سرویس — services/response.js
یک helper بساز تا clinic_uuid تکرار نشود:
const withClinic = (params, clinicUuid) =>
clinicUuid ? { ...params, clinic_uuid: clinicUuid } : params;
و امضاها را گسترش بده (پارامتر آخر، اختیاری، تا هیچ call site موجودی نشکند):
getBookingLocations: (doctor_uuid) =>
api.get(`api/v1/appointment-booking-locations/${doctor_uuid}`, removeTokenHead),
getAppointmentSlots: (doctor_uuid, date, clinic_uuid = null) =>
api.get("api/v1/appointment-slots", {
params: withClinic({ doctor_uuid, date }, clinic_uuid),
...removeTokenHead,
}),
getMonthAvailability: (doctor_uuid, year, month, clinic_uuid = null) =>
api.get(`api/v1/appointment-settings/month-availability/${doctor_uuid}`, {
params: withClinic({ year, month }, clinic_uuid),
...removeTokenHead,
}),
getBookingServices: (doctor_uuid, clinic_uuid = null) =>
api.get(`api/v1/appointment-booking-services/${doctor_uuid}`, {
params: withClinic({}, clinic_uuid),
...removeTokenHead,
}),
getServiceSlots: (doctor_uuid, date, serviceItemUuids = [], clinic_uuid = null) =>
api.get("api/v1/appointment-service-slots", {
params: withClinic({ doctor_uuid, date, "service_item_uuids[]": serviceItemUuids }, clinic_uuid),
...removeTokenHead,
}),
دقت: getAppointmentSlots و getServiceSlots الان query را دستی میچسبانند. با انتقال به
params، سریالسازی آرایهٔ service_item_uuids[] توسط axios انجام میشود — بررسی کن خروجی همچنان
?service_item_uuids[]=a&service_item_uuids[]=b باشد (نه service_item_uuids[0]=a). اگر نبود،
paramsSerializer بده یا همان روش دستی را با افزودن clinic_uuid نگه دار.
۲. state انتخاب محل — components/appointment/index.js
AppointmentPage نگهدارندهٔ state است؛ محل هم همانجا بنشیند:
const [bookingLocations, setBookingLocations] = useState([]);
const [selectedLocation, setSelectedLocation] = useState(null); // یک آیتم از booking_locations
هنگام mount، getBookingLocations(doctor.uuid) را صدا بزن. سپس:
- اگر
?clinic_uuid=یا?location=در URL بود → همان را انتخاب کن. - وگرنه
booking_locations[0](آرایه از قبل بر اساس زودترین نوبت آزاد مرتب است). - اگر آرایه خالی بود → پیام «برای این پزشک نوبتدهی آنلاین فعال نیست» و ادامه نده.
بحرانی: booking_mode و services را از selectedLocation بخوان، نه از فراخوانی جدای
getBookingServices. booking_locations هر دو را از قبل دارد و یک درخواست کمتر میزند. stateهای
bookingMode و bookingServices موجود (index.js:46-67) باید از selectedLocation مشتق شوند:
const bookingMode = selectedLocation?.booking_mode ?? "slot";
const bookingServices = selectedLocation?.services ?? [];
getBookingServices را فقط برای سازگاری در response.js نگه دار ولی از این جریان حذفش کن.
۳. تعویض محل باید state وابسته را پاک کند
وقتی کاربر محل را عوض میکند، اینها باید reset شوند وگرنه ترکیب نامعتبر ساخته میشود (سرویس کلینیک A + اسلات کلینیک B → خطای ۴۲۲ در انتها):
const changeLocation = (loc) => {
setSelectedLocation(loc);
setSelectedServiceUuids([]);
setSelectedSlot(null);
setSelectedDate(null);
setStep(loc.booking_mode === "service" ? SERVICE_STEP : DATE_STEP);
};
۴. UI انتخاب محل
اگر booking_locations.length > 1، یک مرحله/کارت انتخاب محل قبل از انتخاب سرویس و تاریخ
نشان بده. اگر فقط یک محل بود، مرحله را نشان نده و مستقیم انتخابش کن.
هر کارت: title، address، و برچسب زودترین نوبت. برای next_available_at از jalali-moment
استفاده کن (همان الگوی بقیهٔ پروژه) و اگر null بود «فعلاً نوبت خالی ندارد» با ظاهر غیرفعال.
components/appointment/location/Address.js (سایدبار نمایشی) باید محل انتخابشده را برجسته کند.
components/appointment/date/index.js → locateVisit مرده را حذف کن.
الزامی: از کامپوننتها، تم، فونت (Vazir) و MUI v5 + Tailwind موجود استفاده کن — طراحی جدید نساز. RTL رعایت شود.
۵. حمل محل تا لحظهٔ ثبت
app/component/date/datePicker/index.js و app/component/date/dateTime/index.js باید
clinicUuid را prop بگیرند و به فراخوانیها بدهند. DatePicker الان doctorUuid را از
useParams().doctorId میخواند (index.js:25-26) — clinicUuid را بهصورت prop بده، نه از URL.
components/appointment/detail/SubmitData.js:138:
const appointmentPayload = {
doctor_uuid: doctor.uuid,
clinic_uuid: selectedLocation?.clinic_uuid ?? null,
slot_start: selectedSlot.start,
...
};
آدرس نمایشی در app/component/date/dateTime/index.js:33-35 را از selectedLocation.address
بگیر — نه از doctor.address.find(...)، چون location_id عددی است و در محیط کلینیک ممکن است در
doctor.address نباشد.
۶. لینکپذیری و SEO
-
CTA در
components/doctor/appointmentList/index.js:33باید محل را حمل کند وقتی صفحهٔ پزشک محلی را برجسته کرده:/appointment/${doctorSlug}?clinic_uuid=${clinicUuid}. -
app/appointment/[doctorId]/page.jsبایدsearchParamsرا بخواند (وawaitکند طبق App Router). -
app/doctor/[slug]/page.js— JSON-LD را با ساعات کاری هر محل غنی کن.booking_locationsرا server-side بگیر (باfetchReqازlib/req.jsوrevalidateمشابهgetDoctorAddresses) و برای هر محل یکMedicalClinicباopeningHoursSpecificationبساز و بهworkLocationوصل کن. در حالت سرویسی،availableServiceهم ازservicesقابل ساخت است.انجام شد:
booking_locationsحالا فیلدopening_hoursدارد (شیفتهای فعال هفته با نام انگلیسی روز)، پسopeningHoursSpecificationمستقیماً از همان ساخته میشود و به endpoint جدیدی نیاز نیست. -
هر صفحه باید
generateMetadataداشته باشد وparamsهمیشهawaitشود.
۷. سازگاری و حالتهای مرزی
- پزشکی که فقط مطب شخصی دارد → یک آیتم با
type: "personal"؛ UI نباید تغییری حس شود. - پزشکی که فقط کلینیک دارد → قبلاً هیچ نوبتی نشان داده نمیشد؛ حالا باید کار کند. این را حتماً دستی تست کن.
booking_locationsخالی → پیام روشن، نه صفحهٔ خالی.422سرویسِ ناسازگار → پیام فارسی خطای backend را نشان بده (پیام آماده است).
نکات مهم
- این تغییر بدون بهروزرسانی این سایت، ظرفیت واقعی پزشکان کلینیکی را از سایت حذف میکند. اولویت بالا.
- پاسخها double-nested هستند:
json?.data?.data ?? json?.data. - برای auth از
{ requireAuth: true }استفاده کن (کوکیaccess_token→ Bearer). endpointهای اسلات عمومیاند وremoveTokenHeadمیگیرند. - slug پزشک =
uuid. - تاریخها شمسی (
jalali-moment)، رشتههای جدید فارسی. - شهر از subdomain: server با
lib/getStateInfo.js، client باuseProvince().city_idموجود در payload را دست نزن. - تست دستی با کلینیک
41e325c4-e825-4067-8438-5d828ecaee09و «دکتر تست» (09100652121) که در همان کلینیک سرویس bookable دارد. - بعد از تغییرات:
npm run lintوnpm run buildهر دو باید سبز باشند.