diff --git a/.claude/prompt/public-resource-booking-ui.md b/.claude/prompt/public-resource-booking-ui.md
new file mode 100644
index 0000000..545e6c5
--- /dev/null
+++ b/.claude/prompt/public-resource-booking-ui.md
@@ -0,0 +1,303 @@
+# رزرو آنلاین منبعمحور در جریان نوبتگیری
+
+## پروژه
+
+`nobat724_front` (سایت عمومی).
+
+پرامپت همتا در backend: `clinicpro/.claude/prompt/public-resource-booking-api.md`.
+**آن باید اول اجرا و merge شود** — این پرامپت سه اندپوینتی را مصرف میکند که آنجا ساخته میشوند.
+
+## زمینه
+
+در کلینیکپرو، «منبع» هر چیزی است که ممکن است اشغال باشد: پزشک، اپراتور، دستگاه، اتاق، یونیت.
+هر منبع تقویم مستقل خودش را دارد و سرویسهایی که ارائه میدهد، با مدت و قیمتِ اختصاصیِ همان منبع،
+روی خودش تعریف میشوند. مالک کلینیک با توگل «نمایش در نوبتدهی آنلاین» روی هر سرویس تعیین میکند
+که آن سرویس در سایت دیده شود یا نه.
+
+جریان فعلیِ نوبتگیری سایت فقط پزشکمحور است: محل → (در حالت سرویسی) سرویس → روز → ساعت.
+
+## مشکل / هدف
+
+بیمار نمیتواند روی یک دستگاه یا اتاقِ مشخص نوبت بگیرد. هدف: افزودن یک مرحلهٔ «انتخاب منبع» به
+همان جریان، بدون شکستن مسیر فعلی.
+
+**تصمیم دامنه (تأییدشده):**
+
+- نقطهٔ ورود فقط `/appointment/[doctorId]` است. صفحهٔ کلینیک در این تسک دست نمیخورد.
+- فقط منابعِ همان پزشک نمایش داده میشوند (backend خودش فیلتر میکند؛ سایت فیلتر اضافه نمیزند).
+- اگر پزشک در آن محل هیچ منبعی نداشته باشد، **هیچ تغییری در تجربهٔ فعلی دیده نمیشود** —
+ مرحلهٔ منبع اصلاً رندر نمیشود.
+
+## معیار پذیرش
+
+- ✅ موفق:
+ - پزشکی که در محل انتخابشده منبعِ قابل رزرو دارد: بعد از تأیید محل، مرحلهٔ «انتخاب منبع»
+ دیده میشود با کارت «نوبت با پزشک» بهعلاوهٔ یک کارت به ازای هر منبع.
+ - انتخاب یک منبع → فهرست سرویسهای **همان منبع** با مدت و قیمتِ همان منبع → تقویم → ساعتها
+ از `appointment-resource-slots` → ثبت نوبت با `resource_uuid` در payload → پاسخ ۲۰۱ و رفتن
+ به مرحلهٔ پرداخت، مثل جریان فعلی.
+ - انتخاب «نوبت با پزشک» → دقیقاً جریان امروز، بدون هیچ تفاوتی.
+- ❌ خطا:
+ - خطای شبکه یا ۴۲۲ روی `getBookingResources` → مرحلهٔ منبع رندر نشود و جریان پزشکمحور ادامه
+ یابد. صفحه نباید سفید شود یا در حالت loading گیر کند.
+ - ثبت نوبت با ۴۰۹ (منبع در آن لحظه پر شد) → پیام فارسی به کاربر و برگشت به مرحلهٔ ساعت،
+ نه خطای خام.
+- ⚠️ مرزی:
+ - پزشک بدون منبع → صفر تغییر در UI فعلی (این را صریح تست کن، نه فرض).
+ - منبعی که در روز انتخابشده شیفت ندارد → پیام «در حال حاضر، نوبتی برای این روز موجود نمیباشد.»
+ مثل امروز، نه فهرست خالی و بیتوضیح.
+ - تعویض محل بعد از انتخاب منبع → منبع و سرویس و ساعتِ انتخابشده باید باطل شوند. امروز
+ `changeLocation` همین کار را برای سرویس و اسلات میکند؛ منبع هم باید به آن اضافه شود.
+ - تعویض منبع بعد از انتخاب سرویس → سرویسها باطل شوند (سرویسِ منبع الف روی منبع ب معتبر نیست
+ و backend با ۴۲۲ رد میکند).
+ - منبعِ با ظرفیت بیش از ۱: ساعتها ممکن است با نوبتِ موجود همپوشان باشند — این درست است و
+ سایت نباید فیلتر اضافه بزند.
+
+## فایلهای مرتبط
+
+| فایل | نقش |
+|------|-----|
+| `services/response.js` | wrapper همهٔ فراخوانیهای API |
+| `components/appointment/index.js` | state جریان نوبت: محل، سرویس، اسلات |
+| `components/appointment/Container.js` | تصمیمِ اینکه کدام مرحله رندر شود |
+| `components/appointment/location/LocationSelect.js` | الگوی مرجعِ ظاهرِ کارت انتخاب |
+| `components/appointment/service/index.js` | مرحلهٔ انتخاب سرویس |
+| `components/appointment/date/index.js`, `date/hour/index.js` | مرحلهٔ روز و ساعت |
+| `app/component/date/dateTime/index.js` | فراخوانیِ اسلاتها |
+| `app/component/date/datePicker/index.js` | تقویم و روزهای غیرفعال ماه |
+| `components/appointment/detail/SubmitData.js` | ساخت payload و `postAppointment` |
+| `lib/appointmentSlots.js` | `adaptServiceSlots` — همان شکل `start_times` |
+
+## وضعیت فعلی
+
+انتخاب مرحله در `Container.js` یک زنجیرهٔ `if/else` است:
+
+```jsx
+// components/appointment/Container.js
+let dateStep;
+if (bookingLocations.length === 0) {
+ dateStep = (
+
+ نوبتدهی آنلاین برای این پزشک فعال نیست.
+
+ );
+} else if (multiLocation && !locationConfirmed) {
+ dateStep = (
+
+ );
+} else if (serviceMode && selectedServiceUuids.length === 0) {
+ dateStep = (
+ setSelectedServiceUuids(uuids)} />
+ );
+} else {
+ dateStep = ( );
+}
+```
+
+باطلسازی با تعویض محل:
+
+```jsx
+// components/appointment/index.js
+const changeLocation = (location) => {
+ setSelectedLocation(location);
+ setLocationConfirmed(true);
+ setSelectedServiceUuids([]);
+ setSelectedSlot(null);
+ setSelectedDate(null);
+};
+```
+
+اسلاتها امروز فقط دو حالت دارند:
+
+```jsx
+// app/component/date/dateTime/index.js
+const req = serviceMode
+ ? request
+ .getServiceSlots(doctor.uuid, dateStr, selectedServiceUuids, clinicUuid)
+ .then(adaptServiceSlots)
+ : request.getAppointmentSlots(doctor.uuid, dateStr, clinicUuid).then(adaptSlots);
+```
+
+و روزهای غیرفعال تقویم فقط پزشکمحورند:
+
+```jsx
+// app/component/date/datePicker/index.js
+request
+ .getMonthAvailability(doctorUuid, year, month, clinicUuid)
+```
+
+payload ثبت نوبت هنوز `resource_uuid` ندارد:
+
+```jsx
+// components/appointment/detail/SubmitData.js
+const appointmentPayload = {
+ doctor_uuid: doctor.uuid,
+ clinic_uuid: clinicUuid,
+ slot_start: selectedSlot.start,
+ slot_end: selectedSlot.end,
+ // ...
+ ...(selectedServiceUuids?.length ? { service_item_uuids: selectedServiceUuids } : {}),
+```
+
+## وظایف
+
+### ۱. سه wrapper جدید در `services/response.js`
+
+کنار همتاهای پزشکمحور، با همان سبک و همان `removeTokenHead` (این اندپوینتها عمومیاند):
+
+```js
+getBookingResources: (doctor_uuid, clinic_uuid = null) =>
+ api.get(
+ `api/v1/appointment-booking-resources/${doctor_uuid}${clinicQuery(clinic_uuid, "?")}`,
+ removeTokenHead
+ ),
+getResourceSlots: (resource_uuid, date, serviceItemUuids = []) =>
+ api.get(
+ `api/v1/appointment-resource-slots?resource_uuid=${resource_uuid}&date=${date}` +
+ serviceItemUuids
+ .map((u) => `&service_item_uuids[]=${encodeURIComponent(u)}`)
+ .join(""),
+ removeTokenHead
+ ),
+getResourceMonthAvailability: (resource_uuid, year, month, serviceItemUuids = []) =>
+ api.get(`api/v1/appointment-resource-month-availability/${resource_uuid}`, {
+ params: { year, month },
+ ...removeTokenHead,
+ }),
+```
+
+نکته: `service_item_uuids[]` آرایه است و `params` اکسیوس آن را با `[]` سریالایز نمیکند — به همین
+دلیل `getServiceSlots` موجود هم دستی رشته میسازد. برای `month-availability` هم همان کار را بکن
+(رشتهٔ query دستی)، وگرنه backend سرویسها را نمیبیند و مدت را نمیتواند حساب کند.
+
+**نحوه تست:** در devtools، هر سه فراخوانی باید بدون هدر `Authorization` بروند و ۲۰۰ بگیرند.
+
+### ۲. state منبع در `components/appointment/index.js`
+
+```jsx
+const [resources, setResources] = useState([]);
+const [selectedResource, setSelectedResource] = useState(null); // null = نوبت با پزشک
+const [resourceConfirmed, setResourceConfirmed] = useState(false);
+```
+
+- با تغییر `selectedLocation`، منابع همان محل گرفته شوند:
+ `request.getBookingResources(doctor.uuid, selectedLocation?.clinic_uuid ?? null)`.
+ خطا → `setResources([])` (جریان فعلی ادامه یابد؛ سکوت عمدی است، نه فراموشی — کامنتش را بنویس).
+- `changeLocation` باید `selectedResource`، `resourceConfirmed` و `resources` را هم باطل کند.
+- تابع `changeResource(resource)` که `selectedServiceUuids`، `selectedSlot`، `selectedDate` را
+ صفر میکند و `resourceConfirmed = true` میگذارد.
+- وقتی `resources.length === 0`، مقدار `resourceConfirmed` باید از ابتدا `true` باشد تا مرحله
+ اصلاً رندر نشود.
+- سرویسهایی که به `ServiceSelect` میروند: اگر منبع انتخاب شده، `selectedResource.services`؛
+ وگرنه همان `selectedLocation?.services` امروز.
+- وقتی منبع انتخاب شده، جریان **همیشه** سرویسی است (منبع اسلاتِ ثابت ندارد)، حتی اگر
+ `booking_mode` آن محل `slot` باشد.
+
+**نحوه تست:** با `09390039833 / 09390039833` در پنل `/admin` یک منبع بساز، به آن یک سرویس با
+«نمایش در نوبتدهی آنلاین» روشن وصل کن، سپس صفحهٔ نوبتِ همان پزشک را در
+`http://yazd-nobat.localhost:3000/appointment/` باز کن.
+بعد همان سرویس را در پنل خاموش کن و رفرش بزن — مرحلهٔ منبع باید ناپدید شود.
+
+### ۳. کامپوننت `components/appointment/resource/index.js`
+
+کارتهای انتخاب، **دقیقاً با ظاهر و کلاسهای `LocationSelect.js` و `ServiceSelect`** — طراحی
+جدید نساز، رنگ جدید معرفی نکن. رنگهای موجود: `#5559CE` (اصلی)، `#3B3B3B` (متن)، `#7A7A7A`
+(متن ثانویه)، `#EFEFEF` (خط).
+
+محتوای هر کارت:
+
+- کارت اول همیشه: «نوبت با پزشک» با زیرعنوانِ کوتاه — انتخابش یعنی `changeResource(null)`.
+- هر منبع: `name` بهعنوان عنوان، `type.name` بهعنوان برچسبِ نوع، تعداد سرویسها و کوتاهترین
+ مدت بهعنوان زیرنویس.
+- عنوان مرحله: «۱. انتخاب نوع نوبت» — و در نتیجه سرویس «۲.» و ساعت «۳.» میشود. شمارههای
+ موجود در `ServiceSelect` و `Hour` باید هماهنگ شوند، وگرنه دو مرحله هر دو «۱.» میشوند.
+
+در `Container.js` این مرحله **بعد از** تأیید محل و **قبل از** انتخاب سرویس بنشیند:
+
+```jsx
+} else if (resources.length > 0 && !resourceConfirmed) {
+ dateStep = (
+
+ );
+} else if (serviceMode && selectedServiceUuids.length === 0) {
+```
+
+و در مرحلهٔ روز، دکمهٔ «← تغییر نوع نوبت» کنار «تغییر محل» و «تغییر سرویس» موجود در
+`components/appointment/date/index.js` اضافه شود (همان سبک، همان کلاس).
+
+**نحوه تست:** هر سه دکمهٔ بازگشت را بزن و مطمئن شو انتخابهای پاییندستی پاک میشوند — مثلاً
+بعد از «تغییر نوع نوبت»، سرویس قبلی نباید هنوز انتخاب باشد.
+
+### ۴. اسلاتها و تقویمِ منبع
+
+در `app/component/date/dateTime/index.js` یک شاخهٔ سوم:
+
+```jsx
+const req = resourceUuid
+ ? request
+ .getResourceSlots(resourceUuid, dateStr, selectedServiceUuids)
+ .then(adaptServiceSlots)
+ : serviceMode
+ ? request.getServiceSlots(...).then(adaptServiceSlots)
+ : request.getAppointmentSlots(...).then(adaptSlots);
+```
+
+`adaptServiceSlots` بدون تغییر کار میکند: پاسخ منبع هم `start_times` با همان شکل
+`{start, end, start_time, end_time}` میدهد. **`lib/appointmentSlots.js` را دست نزن.**
+
+`resourceUuid` باید از `Container` تا `DateTime` رد شود:
+`Date` → `Hour` → `DateTime`. همان زنجیرهای که `clinicUuid` امروز طی میکند.
+
+در `app/component/date/datePicker/index.js`، وقتی `resourceUuid` داده شده،
+`getResourceMonthAvailability(resourceUuid, year, month, selectedServiceUuids)` جای
+`getMonthAvailability` بنشیند. کشِ `loadedMonths` باید با تغییر `resourceUuid` هم پاک شود —
+دقیقاً همان `useEffect`ی که امروز برای `clinicUuid` هست:
+
+```jsx
+useEffect(() => {
+ loadedMonths.current = new Set();
+ setDisabledSet(new Set());
+ setOnlineEnabled(true);
+ setAutoSelected(false);
+}, [clinicUuid]); // ← resourceUuid هم به آرایهٔ وابستگی اضافه شود
+```
+
+**نحوه تست:** یک منبع با شیفتِ فقط دوشنبه بساز. در تقویم سایت، همهٔ روزها جز دوشنبهها باید
+غیرفعال باشند. روی یک دوشنبه بزن و ساعتها را ببین.
+
+### ۵. `resource_uuid` در payload ثبت نوبت
+
+در `components/appointment/detail/SubmitData.js`:
+
+```jsx
+// نوبتِ منبعمحور: backend مدت و slot_end را از زنجیرهٔ حلِ همین منبع بازمحاسبه
+// میکند، پس عددِ کلاینت فقط پیشنهاد است.
+...(resourceUuid ? { resource_uuid: resourceUuid } : {}),
+```
+
+`resourceUuid` باید از `Container` به `Detail` و از آنجا به `SubmitData` برسد — همان مسیری که
+`selectedServiceUuids` و `clinicUuid` امروز طی میکنند.
+
+مدیریت ۴۰۹: پاسخ backend در این حالت `{ success: false, errors: [{ code, message, field: 'resource_uuid' }] }`
+است. پیام فارسیِ همان errors نمایش داده شود و کاربر به مرحلهٔ ساعت برگردد.
+
+**نحوه تست (سناریوی کامل):**
+۱. در پنل، منبع + سرویسِ روشن بساز.
+۲. در سایت نوبت بگیر تا مرحلهٔ پرداخت.
+۳. در پنل `/admin/appointments` نوبت را ببین: باید فیلد منبع را نشان دهد.
+۴. برای تست ۴۰۹: همان بازه را از پنل روی همان منبع رزرو کن، بعد در تبِ سایت ثبت را بزن.
+
+## نکات مهم
+
+- **صفر تغییر برای پزشک بدون منبع.** هر تغییری که مسیر فعلی را حتی یک کلیک عوض کند، اشتباه است.
+ این را با یک پزشکِ بدون منبع دستی تست کن، نه با خواندن کد.
+- **طراحی جدید ممنوع.** مرحلهٔ منبع باید از کامپوننتها، کلاسها و رنگهای موجود ساخته شود.
+ `LocationSelect.js` مرجع ظاهر است.
+- RTL، فارسی، تقویم جلالی — مثل بقیهٔ جریان. عدد مدت و قیمت با `toLocaleString("fa-IR")` مثل
+ `toToman()` موجود در `ServiceSelect`.
+- **باطلسازیِ آبشاری** یک قاعده است نه سهتا استثنا: محل → منبع → سرویس → روز → ساعت. تغییر هر
+ سطح، همهٔ سطوح پایینترش را پاک میکند. اگر این را در یک تابع متمرکز کنی، جای سه `setState`
+ پراکنده، خطای بعدی خودش را نشان میدهد.
+- **`durations[]` نفرست.** backend در مسیر عمومی آن را نمیپذیرد؛ override مدت ابزار پنل است.
+- **cross-repo:** تغییر شکل پاسخ در backend اینجا خطای build نمیدهد. بعد از هر تغییر در
+ `clinicpro`، این سه فراخوانی را دستی بررسی کن.
diff --git a/app/component/date/datePicker/index.js b/app/component/date/datePicker/index.js
index 97c3b7f..b7f6b62 100644
--- a/app/component/date/datePicker/index.js
+++ b/app/component/date/datePicker/index.js
@@ -21,7 +21,7 @@ function gregorianMonthsOf(jalaliMonth) {
});
}
-function DatePicker({ setDate, clinicUuid = null }) {
+function DatePicker({ setDate, clinicUuid = null, resourceUuid = null, selectedServiceUuids = [] }) {
const params = useParams();
const doctorUuid = params?.doctorId;
@@ -36,15 +36,20 @@ function DatePicker({ setDate, clinicUuid = null }) {
// هر محل، روزهای غیرفعال و وضعیت نوبتدهی آنلاین خودش را دارد؛ کش ماهها باید
// با تعویض محل دور ریخته شود وگرنه تقویم محل قبلی باقی میماند.
+ //
+ // منبع هم همینطور: تقویم هر دستگاه مالِ خودش است، و مدتِ سرویسهای انتخابشده
+ // تعیین میکند کدام روز جا دارد — پس تغییر هرکدام یعنی کشِ باطل.
+ const serviceKey = selectedServiceUuids.join(",");
+
useEffect(() => {
loadedMonths.current = new Set();
setDisabledSet(new Set());
setOnlineEnabled(true);
setAutoSelected(false);
- }, [clinicUuid]);
+ }, [clinicUuid, resourceUuid, serviceKey]);
useEffect(() => {
- if (!doctorUuid) return;
+ if (!doctorUuid && !resourceUuid) return;
const targets = [
...gregorianMonthsOf(baseMonth),
@@ -55,8 +60,13 @@ function DatePicker({ setDate, clinicUuid = null }) {
targets.forEach(({ year, month }) => {
loadedMonths.current.add(`${year}-${month}`);
- request
- .getMonthAvailability(doctorUuid, year, month, clinicUuid)
+ // منبع پرچمِ `online_booking_enabled` ندارد — آن روی برنامهٔ هفتگیِ پزشک است.
+ // نبودِ فیلد یعنی «روشن»، و شرطِ پایینتر فقط با `false` صریح واکنش نشان میدهد.
+ const req = resourceUuid
+ ? request.getResourceMonthAvailability(resourceUuid, year, month, selectedServiceUuids)
+ : request.getMonthAvailability(doctorUuid, year, month, clinicUuid);
+
+ req
.then((res) => {
const data = res?.data ?? {};
if (data.online_booking_enabled === false) setOnlineEnabled(false);
@@ -73,7 +83,7 @@ function DatePicker({ setDate, clinicUuid = null }) {
loadedMonths.current.delete(`${year}-${month}`);
});
});
- }, [doctorUuid, baseMonth, clinicUuid]);
+ }, [doctorUuid, baseMonth, clinicUuid, resourceUuid, serviceKey]);
const isDisabled = (date) => {
const day = moment(date).startOf("day");
diff --git a/app/component/date/dateTime/index.js b/app/component/date/dateTime/index.js
index 3364f2d..8229701 100644
--- a/app/component/date/dateTime/index.js
+++ b/app/component/date/dateTime/index.js
@@ -17,6 +17,7 @@ function DateTime({
serviceMode = false,
selectedServiceUuids = [],
clinicUuid = null,
+ resourceUuid = null,
selectedLocation = null,
}) {
const [value, setValue] = useState(0);
@@ -48,11 +49,17 @@ function DateTime({
setHour();
const dateStr = moment.unix(date).format("YYYY-MM-DD");
- const req = serviceMode
+ // منبع تقویم خودش را دارد، پس زمانهایش از اندپوینت خودش میآید نه از برنامهٔ
+ // هفتگی پزشک. شکل پاسخ همان `start_times` است، پس adapter مشترک میماند.
+ const req = resourceUuid
? request
- .getServiceSlots(doctor.uuid, dateStr, selectedServiceUuids, clinicUuid)
+ .getResourceSlots(resourceUuid, dateStr, selectedServiceUuids)
.then(adaptServiceSlots)
- : request.getAppointmentSlots(doctor.uuid, dateStr, clinicUuid).then(adaptSlots);
+ : serviceMode
+ ? request
+ .getServiceSlots(doctor.uuid, dateStr, selectedServiceUuids, clinicUuid)
+ .then(adaptServiceSlots)
+ : request.getAppointmentSlots(doctor.uuid, dateStr, clinicUuid).then(adaptSlots);
req
.then((parsed) => {
@@ -69,7 +76,7 @@ function DateTime({
setAppo("در حال حاضر، نوبتی برای این روز موجود نمیباشد.");
setValue(0);
});
- }, [date, doctor?.uuid, serviceMode, selectedServiceUuids, clinicUuid]);
+ }, [date, doctor?.uuid, serviceMode, selectedServiceUuids, clinicUuid, resourceUuid]);
return (