Files
nobat724_front/.claude/prompt/booking-locations-multi-context.md
hamedandClaude Opus 4.8 0eb172517f feat(seo): emit openingHoursSpecification per work location
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>
2026-07-18 13:53:28 +03:30

356 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# انتخاب محل نوبت‌دهی (مطب شخصی / کلینیک) در جریان رزرو
## پروژه
`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)
```
```json
{
"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_mode` **per-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`:
```js
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` — تنها جایی که آدرس یک اسلات حل می‌شود:
```js
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`:
```js
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` تکرار نشود:
```js
const withClinic = (params, clinicUuid) =>
clinicUuid ? { ...params, clinic_uuid: clinicUuid } : params;
```
و امضاها را گسترش بده (پارامتر آخر، اختیاری، تا هیچ call site موجودی نشکند):
```js
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 است؛ محل هم همان‌جا بنشیند:
```js
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` مشتق شوند:
```js
const bookingMode = selectedLocation?.booking_mode ?? "slot";
const bookingServices = selectedLocation?.services ?? [];
```
`getBookingServices` را فقط برای سازگاری در `response.js` نگه دار ولی از این جریان حذفش کن.
### ۳. تعویض محل باید state وابسته را پاک کند
وقتی کاربر محل را عوض می‌کند، این‌ها **باید** reset شوند وگرنه ترکیب نامعتبر ساخته می‌شود
(سرویس کلینیک A + اسلات کلینیک B → خطای ۴۲۲ در انتها):
```js
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`:
```js
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` هر دو باید سبز باشند.