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 (
+ «{selectedLocation?.title}» در روز انتخابشده نوبتدهی ندارد. + {onChangeLocation && ( + + )} +
+