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

18 KiB
Raw Permalink Blame History

انتخاب محل نوبت‌دهی (مطب شخصی / کلینیک) در جریان رزرو

پروژه

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_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 («محل نوبت‌دهی یافت نشد»). سرویسِ متعلق به محیط دیگر در POST422 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:15locateVisit با doctor?.multiwork گیت شده، مقدار اولیه‌اش true است و هیچ فرزندی false نمی‌کند. عملاً کد مرده است. حذفش کن و جای آن انتخاب واقعی محل بنشان.

۵. JSON-LD بدون ساعات کاری

app/doctor/[slug]/page.js:107-157workLocation وجود دارد ولی هیچ 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.jslocateVisit مرده را حذف کن.

الزامی: از کامپوننت‌ها، تم، فونت (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 هر دو باید سبز باشند.