From b37096048ce1b6fd5b924db3527d7759c4191361 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sat, 18 Jul 2026 14:44:49 +0330 Subject: [PATCH] feat(booking): show only locations that can actually be booked that day MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The site offered a "personal practice" for a doctor who has no personal address at all — the schedule existed but its shifts pointed at the clinic's address, so there was nowhere to go. The backend now filters those out; this consumes the filtered contract and adds the per-day dimension. - getBookingLocations takes an optional date and the appointment page refetches on it, merging available_on_date into the existing list rather than replacing it, so browsing the calendar never resets the user's choice. - The browsed day had to be lifted out of the Date step: selectedDate is only set once a slot is confirmed, far too late to drive availability. - A location closed on the chosen day renders disabled with «در این روز نوبت ندارد», and when every location is closed the step says so instead of showing an empty slot list. If the already-selected location closes, a notice appears with a link back to the picker — silently showing nothing was the failure mode worth avoiding. - Doctor profile: workLocation in the Physician JSON-LD is limited to addresses that appear in booking_locations, since schema.org presents them as places a patient can attend. The address card still lists the others — they are real practice details — tagged «بدون نوبت‌دهی آنلاین». Verified end-to-end with a temporary unused address on the test doctor: the visible card listed both and tagged the unused one, while workLocation carried only the bookable one. The row was removed afterwards. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/prompt/booking-locations-day-aware.md | 176 ++++++++++++++++++ app/doctor/[slug]/page.js | 13 +- components/appointment/Container.js | 4 + components/appointment/date/index.js | 25 ++- components/appointment/index.js | 36 ++++ .../appointment/location/LocationSelect.js | 27 ++- .../detailDoctor/cards/locations/Item.js | 11 +- .../detailDoctor/cards/locations/index.js | 6 +- components/doctor/detailDoctor/index.js | 4 +- components/doctor/index.js | 3 +- services/response.js | 7 +- 11 files changed, 294 insertions(+), 18 deletions(-) create mode 100644 .claude/prompt/booking-locations-day-aware.md diff --git a/.claude/prompt/booking-locations-day-aware.md b/.claude/prompt/booking-locations-day-aware.md new file mode 100644 index 0000000..0e2e9e5 --- /dev/null +++ b/.claude/prompt/booking-locations-day-aware.md @@ -0,0 +1,176 @@ +# نمایش محل‌های نوبت‌دهی فقط بر اساس برنامهٔ همان روز + +## پروژه + +`nobat724_front` + +پرامپت همتای backend که **باید اول اجرا شود**: +`clinicpro/.claude/prompt/fix-booking-context-slots-and-phantom-locations.md` + +## زمینه + +صفحهٔ رزرو حالا محل نوبت‌دهی (مطب شخصی / کلینیک) را از +`GET /api/v1/appointment-booking-locations/{doctorUuid}` می‌گیرد و کاربر یکی را انتخاب می‌کند. + +اما این فهرست **وضعیت واقعی رزرو در یک روز مشخص** را نشان نمی‌دهد. برای «دکتر تست» +(`bcabb3a8-cae3-45ec-876c-548f9c1e1569`) گزینهٔ «مطب شخصی» نمایش داده می‌شود، در حالی که در +دیتابیس این پزشک **هیچ آدرس شخصی ثبت‌شده‌ای ندارد** و شیفت برنامهٔ شخصی‌اش هم +`location_id = NULL` است. یعنی محلی که اصلاً قابل رزرو نیست، به بیمار پیشنهاد می‌شود. + +## مشکل / هدف + +قاعدهٔ درست نمایش یک محل: + +1. برای آن محل حداقل یک **آدرس ثبت‌شده** وجود داشته باشد، **و** +2. در برنامهٔ کاری، همان آدرس برای شیفت‌های آن محل **انتخاب شده** باشد، **و** +3. برای **روز انتخاب‌شده** حداقل یک اسلات آزاد داشته باشد. + +اگر هر کدام برقرار نباشد، آن محل نباید به‌عنوان گزینهٔ قابل انتخاب نمایش داده شود — نه در +`/appointment/[doctorId]` و نه در پروفایل پزشک `/doctor/[slug]`. + +شرط‌های ۱ و ۲ در backend اعمال می‌شوند (پرامپت همتا). این پرامپت شرط ۳ و مصرف درست فهرست را +پوشش می‌دهد. + +## قرارداد API بعد از تغییر backend + +``` +GET /api/v1/appointment-booking-locations/{doctorUuid} +GET /api/v1/appointment-booking-locations/{doctorUuid}?date=YYYY-MM-DD +``` + +- بدون `date`: فقط محل‌های **معتبر** (آدرس دارند و شیفت روی آدرس دارند). محل بدون آدرس دیگر + اصلاً برنمی‌گردد. +- با `date`: هر آیتم فیلد `available_on_date` (بولین) می‌گیرد و پاسخ `date` را echo می‌کند. +- `opening_hours` هر آیتم حالا `day_index` و `location_id` هم دارد. + +بقیهٔ قرارداد بدون تغییر: مرتب بر اساس `next_available_at` صعودی، پاسخ double-nested +(`json.data.data`). + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `services/response.js` | `getBookingLocations` — باید `date` بگیرد | +| `components/appointment/index.js` | `AppointmentPage` — نگه‌دارندهٔ `bookingLocations` و `selectedLocation` | +| `components/appointment/Container.js` | مرحلهٔ انتخاب محل / سرویس / تاریخ | +| `components/appointment/location/LocationSelect.js` | کارت‌های انتخاب محل | +| `components/appointment/date/index.js` | مرحلهٔ تاریخ + دکمهٔ «تغییر محل» | +| `app/component/date/datePicker/index.js` | تقویم ماه (`getMonthAvailability`) | +| `app/doctor/[slug]/page.js` | پروفایل پزشک + JSON-LD | +| `components/doctor/detailDoctor/cards/locations/index.js` | کارت «موقعیت مکانی» در پروفایل | + +## وضعیت فعلی + +### فهرست محل‌ها وابسته به روز نیست + +`components/appointment/index.js` — یک‌بار در mount گرفته می‌شود و تا آخر ثابت می‌ماند: + +```js + useEffect(() => { + if (!doctor?.uuid) return; + request + .getBookingLocations(doctor.uuid) + .then((res) => { + const d = res?.data?.data ?? res?.data ?? {}; + const list = Array.isArray(d.booking_locations) ? d.booking_locations : []; + setBookingLocations(list); + ... + }) +``` + +`selectedDate` بعداً در همین کامپوننت ست می‌شود ولی هیچ‌وقت به این فراخوانی برنمی‌گردد. + +### پروفایل پزشک همهٔ آدرس‌ها را نشان می‌دهد + +`app/doctor/[slug]/page.js` — `workLocation` در JSON-LD و کارت «موقعیت مکانی» از +`getDoctorAddresses(doctor.id)` می‌آیند که **همهٔ** آدرس‌ها را برمی‌گرداند، مستقل از اینکه در +برنامهٔ نوبت‌دهی استفاده شده باشند یا نه. + +## وظایف + +### ۱. `date` در لایهٔ سرویس + +`services/response.js`: + +```js + getBookingLocations: (doctor_uuid, date = null) => + api.get(`api/v1/appointment-booking-locations/${doctor_uuid}`, { + params: date ? { date } : {}, + ...removeTokenHead, + }), +``` + +### ۲. واکشی دوباره با تغییر روز + +در `components/appointment/index.js`: + +- فهرست اولیه بدون `date` گرفته شود (برای مرحلهٔ انتخاب محل، قبل از انتخاب روز). +- بعد از انتخاب روز، دوباره با `date` گرفته شود و `available_on_date` روی کارت‌ها اعمال شود. + +`selectedDate` در این کامپوننت **timestamp** است؛ برای پارامتر API باید به `YYYY-MM-DD` تبدیل شود +(از `moment-jalaali` که در پروژه هست استفاده کن، همان الگوی +`app/component/date/dateTime/index.js` که `moment.unix(date).format("YYYY-MM-DD")` می‌زند). + +**حالت مرزی مهم:** اگر روزِ انتخاب‌شده محلِ انتخاب‌شده را غیرفعال کند +(`available_on_date === false`)، نباید بی‌صدا اسلات خالی نشان دهی. یا کاربر را به مرحلهٔ انتخاب +محل برگردان با پیام روشن، یا خودکار به اولین محلِ باز در آن روز سوییچ کن و این جابه‌جایی را +اطلاع بده. **بی‌صدا نگه‌داشتن محلِ بسته = صفحهٔ خالی بدون توضیح.** + +### ۳. UI کارت‌های محل + +`components/appointment/location/LocationSelect.js`: + +- وقتی `available_on_date === false`، کارت غیرفعال شود (`disabled`، `cursor-not-allowed`، + کم‌رنگ) با برچسب «در این روز نوبت ندارد». +- کارت غیرفعال قابل کلیک نباشد. +- اگر **هیچ** محلی در آن روز باز نبود، پیام روشن بده و کاربر را به انتخاب روز دیگر هدایت کن. + +از تم و کامپوننت‌های موجود استفاده کن (MUI v5 + Tailwind، RTL، فونت Vazir) — طراحی جدید نساز. + +### ۴. تقویم ماه هم per-location است + +`app/component/date/datePicker/index.js` الان `clinicUuid` می‌گیرد و `getMonthAvailability` را با +آن صدا می‌زند — این درست است و نیازی به تغییر ندارد. فقط مطمئن شو بعد از سوییچ محل، کش ماه‌ها +پاک می‌شود (همان effect موجود روی `clinicUuid`). + +### ۵. پروفایل پزشک — فقط محل‌های قابل رزرو + +در `app/doctor/[slug]/page.js`: + +- `getBookingLocations` (بدون `date`) را server-side بگیر — همین الان برای `availableService` و + `openingHoursSpecification` گرفته می‌شود. +- کارت «موقعیت مکانی» (`components/doctor/detailDoctor/cards/locations/`) و `workLocation` در + JSON-LD باید **فقط** آدرس‌هایی را نشان دهند که `location_uuid` آن‌ها در `booking_locations` + آمده است. + + ```js + const bookableUuids = new Set( + bookingLocations.map((l) => l.location_uuid).filter(Boolean) + ); + const bookableAddresses = addresses.filter((a) => bookableUuids.has(a.uuid)); + ``` + +- **تصمیم لازم:** آدرسی که ثبت شده ولی در هیچ برنامه‌ای استفاده نشده، اطلاعات واقعی مطب است و + حذف کاملش از پروفایل ممکن است خواسته نباشد. پیشنهاد: در کارت «موقعیت مکانی» نمایش داده شود + ولی بدون دکمهٔ «دریافت نوبت»، و در JSON-LD **نیاید** (چون schema.org آن را قابل مراجعه اعلام + می‌کند). اگر تصمیم دیگری گرفتی، در PR بنویس. + +### ۶. لینک CTA + +`components/doctor/appointmentList/index.js` — اگر پروفایل محل مشخصی را برجسته کرد، CTA همان را +حمل کند: `/appointment/${doctorSlug}?clinic_uuid=${clinicUuid}`. اگر محلی برجسته نشده، پارامتر +ندهد تا صفحهٔ رزرو خودش انتخابگر را نشان دهد (رفتار فعلی، درست است). + +## نکات مهم + +- **backend اول.** تا وقتی فیلتر سمت سرور اعمال نشده، «مطب شخصی» جعلی همچنان برمی‌گردد و کار + فرانت قابل تست نیست. +- پاسخ double-nested: `json?.data?.data ?? json?.data`. +- endpoint عمومی است و `removeTokenHead` می‌گیرد؛ برای رزرو نهایی `{ requireAuth: true }`. +- تاریخ‌ها شمسی با `jalali-moment` / `moment-jalaali`؛ رشته‌های جدید فارسی؛ RTL. +- slug پزشک = `uuid`. +- هر صفحه `generateMetadata` صادر کند و `params` همیشه `await` شود. +- تست دستی: «دکتر تست» (`bcabb3a8-cae3-45ec-876c-548f9c1e1569`) — بعد از فیلتر backend نباید هیچ + گزینهٔ «مطب شخصی» ببیند، فقط کلینیک «علی بهروزی». روزی که کلینیک شیفت ندارد هم باید محل را + غیرفعال نشان دهد. +- بعد از تغییرات: `npm run lint` و `npm run build` هر دو سبز. diff --git a/app/doctor/[slug]/page.js b/app/doctor/[slug]/page.js index 1f7d7a8..1ad6582 100644 --- a/app/doctor/[slug]/page.js +++ b/app/doctor/[slug]/page.js @@ -129,6 +129,14 @@ async function Doctor({ params }) { ? await Promise.all([getDoctorAddresses(doctor.id), getBookingLocations(doctor.uuid)]) : [[], []]; + // آدرسی که در هیچ برنامهٔ نوبت‌دهی استفاده نشده، محل مراجعه نیست. در کارت + // «موقعیت مکانی» می‌ماند (اطلاعات واقعی مطب است) ولی به JSON-LD نمی‌رود، چون + // schema.org آن را محلی قابل‌مراجعه اعلام می‌کند. + const bookableAddressUuids = new Set( + bookingLocations.map((l) => l.location_uuid).filter(Boolean) + ); + const bookableAddresses = addresses.filter((a) => bookableAddressUuids.has(a.uuid)); + // ساعات کاری per-location است؛ با uuid آدرس به هر محل وصل می‌شود. const openingHoursByLocation = bookingLocations.reduce((acc, location) => { if (location.location_uuid && location.opening_hours?.length) { @@ -191,8 +199,8 @@ async function Doctor({ params }) { }), })), }), - ...(addresses.length > 0 && { - workLocation: addresses.map((addr) => ({ + ...(bookableAddresses.length > 0 && { + workLocation: bookableAddresses.map((addr) => ({ "@type": "MedicalClinic", name: addr.clinic_name || addr.name || `مطب دکتر ${doctor.name}`, address: { @@ -258,6 +266,7 @@ async function Doctor({ params }) { comments={comments} rateAggregate={rateAggregate} addresses={addresses} + bookableAddressUuids={[...bookableAddressUuids]} slug={slug} /> diff --git a/components/appointment/Container.js b/components/appointment/Container.js index a0f0ca4..c2fea86 100644 --- a/components/appointment/Container.js +++ b/components/appointment/Container.js @@ -51,6 +51,8 @@ function Container({ locationConfirmed, changeLocation, reopenLocationChoice, + onDateChange, + selectedClosedOnDate, }) { const serviceMode = bookingMode === "service"; const clinicUuid = selectedLocation?.clinic_uuid ?? null; @@ -92,6 +94,8 @@ function Container({ selectedLocation={selectedLocation} clinicUuid={clinicUuid} onChangeLocation={multiLocation ? reopenLocationChoice : null} + onDateChange={onDateChange} + closedOnDate={selectedClosedOnDate} /> ); } diff --git a/components/appointment/date/index.js b/components/appointment/date/index.js index f614cab..5d39259 100644 --- a/components/appointment/date/index.js +++ b/components/appointment/date/index.js @@ -14,9 +14,16 @@ function Date({ selectedLocation = null, clinicUuid = null, onChangeLocation, + onDateChange, + closedOnDate = false, }) { const [date, setDate] = useState(); + const pickDate = (value) => { + setDate(value); + onDateChange?.(value); + }; + return (
)} + {closedOnDate && ( +
+

+ «{selectedLocation?.title}» در روز انتخاب‌شده نوبت‌دهی ندارد. + {onChangeLocation && ( + + )} +

+
+ )}