feat(appointment): implement service-based booking flow with service selection and slot adaptation
This commit is contained in:
@@ -0,0 +1,107 @@
|
|||||||
|
# نوبتدهی آنلاین بر اساس سرویس (Service-first booking)
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`nobat724_front` (سایت عمومی نوبتدهی).
|
||||||
|
**Cross-repo:** وابسته به پرامپت backend `clinicpro/.claude/prompt/service-based-booking.md` — آن **اول** اجرا شود؛ این پرامپت endpoint و متای آن را مصرف میکند.
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
جریان فعلیِ نوبتگیری آنلاین یک wizard چندمرحلهای است بدون انتخاب سرویس:
|
||||||
|
`/appointment/[doctorId]` → روز → ساعت (اسلات ثابت) → لاگین/OTP → فرم بیمار → پرداخت.
|
||||||
|
اسلاتها از `GET /api/v1/appointment-slots?doctor_uuid&date` میآیند و همه هماندازهاند.
|
||||||
|
|
||||||
|
بکاند حالت جدید «نوبتدهی بر اساس سرویس» را اضافه میکند: مدت نوبت = مجموع مدت سرویسهای انتخابی، و endpoint جدید زمانهای خالیِ کافی را برمیگرداند. سایت باید در این حالت **اول سرویس** را از بیمار بگیرد، سپس فقط زمانهای خالیِ کافی را نشان دهد.
|
||||||
|
|
||||||
|
## هدف / spec انگلیسی
|
||||||
|
|
||||||
|
When a doctor uses `booking_mode = service`, insert a service-selection step **before** the date/time step. The patient picks one or more services; the site fetches candidate start times sized to the summed service duration from the new backend endpoint, shows only those, locks the chosen time via `postAppointment` (with the service items), then continues to the existing login/pay flow. When the doctor is in `slot` mode, the current flow is unchanged. «نوبت آزاد» (reserve) is secretary-only and never shown online.
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `services/response.js` (L52 `getAppointmentSlots`, L57 `getMonthAvailability`, L62 `postAppointment`, L76 `getDoctorServices`) | افزودن `getServiceSlots` + رساندن `service_item_uuids` به postAppointment |
|
||||||
|
| `components/appointment/index.js` (L45 `AppointmentPage`, state L59-62) | افزودن state سرویسهای انتخابی + مدت |
|
||||||
|
| `components/appointment/Container.js` (L42 `elements[]` step router) | افزودن مرحلهٔ انتخاب سرویس در ابتدای wizard (حالت سرویس) |
|
||||||
|
| `app/component/date/dateTime/index.js` (L29-52 fetch اسلات) | در حالت سرویس، فراخوانی endpoint سرویس با مدت مجموع |
|
||||||
|
| `lib/appointmentSlots.js` (L1 `adaptSlots`, L17 `hasAvailable`) | adapter برای پاسخ `start_times` |
|
||||||
|
| `components/appointment/detail/SubmitData.js` (L138-165 payload) | افزودن `service_item_uuids` به appointmentPayload |
|
||||||
|
| `app/appointment/[doctorId]/page.js` (L9, doctor fetch L17) | رساندن `booking_mode` و لیست سرویسها به AppointmentPage |
|
||||||
|
|
||||||
|
## وضعیت فعلی (کد واقعی)
|
||||||
|
|
||||||
|
### fetch اسلات ثابت
|
||||||
|
```js
|
||||||
|
// app/component/date/dateTime/index.js:29
|
||||||
|
request.getAppointmentSlots(doctor.uuid, dateStr).then((res) => {
|
||||||
|
const parsed = adaptSlots(res); // data.sessions[].slots
|
||||||
|
...
|
||||||
|
});
|
||||||
|
```
|
||||||
|
```js
|
||||||
|
// services/response.js:52
|
||||||
|
getAppointmentSlots: (doctor_uuid, date) =>
|
||||||
|
api.get(`api/v1/appointment-slots?doctor_uuid=${doctor_uuid}&date=${date}`),
|
||||||
|
```
|
||||||
|
|
||||||
|
### payload رزرو — بدون سرویس
|
||||||
|
```js
|
||||||
|
// components/appointment/detail/SubmitData.js:138
|
||||||
|
const appointmentPayload = {
|
||||||
|
doctor_uuid, slot_start: selectedSlot.start, slot_end: selectedSlot.end,
|
||||||
|
for_self, patient_national_code, patient_gender, city_id,
|
||||||
|
}; // هیچ service_id ندارد
|
||||||
|
request.postAppointment(appointmentPayload); // POST api/v1/appointment
|
||||||
|
```
|
||||||
|
|
||||||
|
### step router
|
||||||
|
```js
|
||||||
|
// components/appointment/Container.js:42
|
||||||
|
const elements = [Date, Login, Verify, Detail, Paying, SuccessPay, FailedPay];
|
||||||
|
// step: 0 روز/ساعت, 1 لاگین, 2 OTP, 3 فرم, 4 پرداخت ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. سرویس در response.js
|
||||||
|
|
||||||
|
```js
|
||||||
|
// services/response.js
|
||||||
|
getServiceSlots: (doctor_uuid, date, serviceItemUuids) => {
|
||||||
|
const q = serviceItemUuids.map(u => `service_item_uuids[]=${encodeURIComponent(u)}`).join('&');
|
||||||
|
return api.get(`api/v1/appointment-service-slots?doctor_uuid=${doctor_uuid}&date=${date}&${q}`);
|
||||||
|
},
|
||||||
|
```
|
||||||
|
و اجازهٔ `service_item_uuids` در `postAppointment` (فقط pass-through payload).
|
||||||
|
منبع لیست سرویسهای پزشک: `getDoctorServices` (L76, `api/v1/categorys/doctor_services`) یا endpoint سرویسهای واقعیِ کلینیک اگر پزشک ServiceItem دارد — بررسی کن کدام سرویسها `duration_minutes` دارند (فقط همانها قابلانتخاب برای این جریاناند).
|
||||||
|
|
||||||
|
### ۲. تشخیص حالت پزشک
|
||||||
|
|
||||||
|
از پاسخ doctor یا از `getMonthAvailability` مقدار `booking_mode` را بخوان (backend آن را در متای weekly-schedule دارد؛ اگر در پاسخ doctor نبود، از یک فیلد مناسب که backend اضافه میکند). در `AppointmentPage`:
|
||||||
|
- `booking_mode === 'service'` → مرحلهٔ انتخاب سرویس فعال شود.
|
||||||
|
- در غیر این صورت → **دقیقاً** جریان فعلی (هیچ تغییری).
|
||||||
|
|
||||||
|
### ۳. مرحلهٔ انتخاب سرویس (فقط حالت سرویس)
|
||||||
|
|
||||||
|
- کامپوننت جدید `components/appointment/service/index.js`: لیست سرویسهای پزشک با مدت و قیمت؛ انتخاب یک/چند سرویس؛ نمایش «مدت کل» = Σ `duration_minutes`.
|
||||||
|
- در `Container.js` این مرحله را **قبل** از انتخاب روز قرار بده (حالت سرویس) و state سرویسها را در `AppointmentPage` نگهدار.
|
||||||
|
|
||||||
|
### ۴. نمایش فقط زمانهای کافی
|
||||||
|
|
||||||
|
- در `app/component/date/dateTime/index.js`: اگر حالت سرویس است، بهجای `getAppointmentSlots` از `getServiceSlots(uuid, dateStr, selectedServiceUuids)` استفاده کن.
|
||||||
|
- `lib/appointmentSlots.js`: یک adapter برای پاسخ `data.start_times` (آرایهٔ `{start,end,start_time,location_id}`) اضافه کن که همان ساختار مورد انتظارِ لیست ساعت را بسازد (هر مورد یک دکمهٔ زمان). چون همه از قبل «کافی» هستند، `is_available=true`.
|
||||||
|
- اگر `start_times` خالی بود: پیام «برای این سرویس در این روز زمان خالی کافی نیست» + هدایت به روز بعدِ دارای ظرفیت (از `getMonthAvailability` برای فعال/غیرفعال بودن روزها استفاده کن).
|
||||||
|
|
||||||
|
### ۵. قفل زمان هنگام ثبت
|
||||||
|
|
||||||
|
- در `SubmitData.js` به `appointmentPayload` کلید `service_item_uuids: [...]` اضافه کن (حالت سرویس). `slot_start` از انتخاب میآید؛ `slot_end` را backend از مدت سرویس محاسبه میکند (به مقدار کلاینت اعتماد نمیشود) ولی همان `selectedSlot.end` را هم بفرست تا سازگاری حفظ شود.
|
||||||
|
- منطق قفل دو-مرحلهای موجود (`postAppointment` → `expires_at` → شمارش معکوس در `paying/index.js` → redirect به `payment/order`) دستنخورده بماند؛ فقط payload سرویس اضافه میشود. مدیریت خطای 409 (reset به step 0، `SubmitData.js:178`) همان بماند.
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **«نوبت آزاد» آنلاین نیست.** هیچ مسیری برای `is_reserve` در سایت اضافه نکن؛ فقط منشی در پنل. (در سایت «رزرو» فقط برچسب بازاریابی/عنوان است، نه مدل داده — `Container.js:102`, `payment/[uuid]/page.js:206`.)
|
||||||
|
- **حالت اسلاتی دستنخورده:** وقتی `booking_mode !== 'service'`، هیچ کامپوننت/فراخوانیِ فعلی نباید تغییر رفتار بدهد. مرحلهٔ سرویس فقط شرطی render شود.
|
||||||
|
- **فقط سرویسهای دارای مدت:** سرویسی که `duration_minutes` ندارد نباید در این جریان قابلانتخاب باشد (backend هم ۴۲۲ میدهد) — در UI غیرفعال/مخفی کن.
|
||||||
|
- **الگوهای پروژه:** App Router، `await params`؛ فراخوانی API از `services/response.js` (interceptor پاسخ را در `services/api.js:73` به `response.data` تبدیل میکند)؛ تاریخ Jalali با `moment`/`jalali-moment`؛ RTL، فونت Vazir، MUI v5 + Tailwind؛ رشتههای UI فارسی. slug پزشک = `uuid`.
|
||||||
|
- **تست:** به `nobat724-test-suite` پروژه اضافه کن — رندر مرحلهٔ سرویس در حالت سرویس، عدمرندر در حالت اسلاتی، محاسبهٔ مدت کل، adapter `start_times`، افزودن `service_item_uuids` به payload.
|
||||||
@@ -6,9 +6,17 @@ import Hours from "./hours";
|
|||||||
import Tabs from "../../Tabs";
|
import Tabs from "../../Tabs";
|
||||||
import { request } from "@/services/response";
|
import { request } from "@/services/response";
|
||||||
import SendAppo from "./SendAppo";
|
import SendAppo from "./SendAppo";
|
||||||
import { adaptSlots, hasAvailable } from "@/lib/appointmentSlots";
|
import { adaptSlots, adaptServiceSlots, hasAvailable } from "@/lib/appointmentSlots";
|
||||||
|
|
||||||
function DateTime({ date, doctor, setStep, setSelectedSlot, setSelectedDate }) {
|
function DateTime({
|
||||||
|
date,
|
||||||
|
doctor,
|
||||||
|
setStep,
|
||||||
|
setSelectedSlot,
|
||||||
|
setSelectedDate,
|
||||||
|
serviceMode = false,
|
||||||
|
selectedServiceUuids = [],
|
||||||
|
}) {
|
||||||
const [value, setValue] = useState(0);
|
const [value, setValue] = useState(0);
|
||||||
const [hour, setHour] = useState();
|
const [hour, setHour] = useState();
|
||||||
const [appo, setAppo] = useState("تاریخ مورد نظرتان را انتخاب کنید.");
|
const [appo, setAppo] = useState("تاریخ مورد نظرتان را انتخاب کنید.");
|
||||||
@@ -27,14 +35,21 @@ function DateTime({ date, doctor, setStep, setSelectedSlot, setSelectedDate }) {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (date && doctor?.uuid) {
|
if (!date || !doctor?.uuid) return;
|
||||||
|
if (serviceMode && selectedServiceUuids.length === 0) return;
|
||||||
|
|
||||||
setAppo("loading");
|
setAppo("loading");
|
||||||
setHour();
|
setHour();
|
||||||
const dateStr = moment.unix(date).format("YYYY-MM-DD");
|
const dateStr = moment.unix(date).format("YYYY-MM-DD");
|
||||||
request
|
|
||||||
.getAppointmentSlots(doctor.uuid, dateStr)
|
const req = serviceMode
|
||||||
.then((res) => {
|
? request
|
||||||
const parsed = adaptSlots(res);
|
.getServiceSlots(doctor.uuid, dateStr, selectedServiceUuids)
|
||||||
|
.then(adaptServiceSlots)
|
||||||
|
: request.getAppointmentSlots(doctor.uuid, dateStr).then(adaptSlots);
|
||||||
|
|
||||||
|
req
|
||||||
|
.then((parsed) => {
|
||||||
if (!parsed.length) {
|
if (!parsed.length) {
|
||||||
setAppo("در حال حاضر، نوبتی برای این روز موجود نمیباشد.");
|
setAppo("در حال حاضر، نوبتی برای این روز موجود نمیباشد.");
|
||||||
setValue(0);
|
setValue(0);
|
||||||
@@ -48,8 +63,7 @@ function DateTime({ date, doctor, setStep, setSelectedSlot, setSelectedDate }) {
|
|||||||
setAppo("در حال حاضر، نوبتی برای این روز موجود نمیباشد.");
|
setAppo("در حال حاضر، نوبتی برای این روز موجود نمیباشد.");
|
||||||
setValue(0);
|
setValue(0);
|
||||||
});
|
});
|
||||||
}
|
}, [date, doctor?.uuid, serviceMode, selectedServiceUuids]);
|
||||||
}, [date, doctor?.uuid]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Date from "./date";
|
import Date from "./date";
|
||||||
|
import ServiceSelect from "./service";
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
import Content from "./Content";
|
import Content from "./Content";
|
||||||
@@ -38,15 +39,34 @@ function Container({
|
|||||||
setAppointmentId,
|
setAppointmentId,
|
||||||
appointmentExpiresAt,
|
appointmentExpiresAt,
|
||||||
setAppointmentExpiresAt,
|
setAppointmentExpiresAt,
|
||||||
|
// Service-based booking
|
||||||
|
bookingMode,
|
||||||
|
bookingServices,
|
||||||
|
selectedServiceUuids,
|
||||||
|
setSelectedServiceUuids,
|
||||||
}) {
|
}) {
|
||||||
const elements = [
|
const serviceMode = bookingMode === "service";
|
||||||
|
const dateStep =
|
||||||
|
serviceMode && selectedServiceUuids.length === 0 ? (
|
||||||
|
<ServiceSelect
|
||||||
|
services={bookingServices}
|
||||||
|
onContinue={(uuids) => setSelectedServiceUuids(uuids)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<Date
|
<Date
|
||||||
doctor={doctor}
|
doctor={doctor}
|
||||||
setStep={setStep}
|
setStep={setStep}
|
||||||
disabledDates={disabledDates}
|
disabledDates={disabledDates}
|
||||||
setSelectedSlot={setSelectedSlot}
|
setSelectedSlot={setSelectedSlot}
|
||||||
setSelectedDate={setSelectedDate}
|
setSelectedDate={setSelectedDate}
|
||||||
/>,
|
serviceMode={serviceMode}
|
||||||
|
selectedServiceUuids={selectedServiceUuids}
|
||||||
|
onChangeService={() => setSelectedServiceUuids([])}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const elements = [
|
||||||
|
dateStep,
|
||||||
<LogInPage
|
<LogInPage
|
||||||
num={num}
|
num={num}
|
||||||
setNum={setNum}
|
setNum={setNum}
|
||||||
@@ -76,6 +96,7 @@ function Container({
|
|||||||
selectedDate={selectedDate}
|
selectedDate={selectedDate}
|
||||||
setAppointmentId={setAppointmentId}
|
setAppointmentId={setAppointmentId}
|
||||||
setAppointmentExpiresAt={setAppointmentExpiresAt}
|
setAppointmentExpiresAt={setAppointmentExpiresAt}
|
||||||
|
selectedServiceUuids={selectedServiceUuids}
|
||||||
/>,
|
/>,
|
||||||
<Paying
|
<Paying
|
||||||
setStep={setStep}
|
setStep={setStep}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import DateTime from "@/app/component/date/dateTime";
|
import DateTime from "@/app/component/date/dateTime";
|
||||||
|
|
||||||
function Hour({ doctor, setStep, date, locateVisit, isStep, setSelectedSlot, setSelectedDate }) {
|
function Hour({ doctor, setStep, date, locateVisit, isStep, setSelectedSlot, setSelectedDate, serviceMode = false, selectedServiceUuids = [] }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex relative mt-[24px] flex-col items-start gap-[12px] justify-start">
|
<div className="flex relative mt-[24px] flex-col items-start gap-[12px] justify-start">
|
||||||
<p className="text-[#3B3B3B] text-[14px] md:text-[16px] font-bold">
|
<p className="text-[#3B3B3B] text-[14px] md:text-[16px] font-bold">
|
||||||
@@ -13,6 +13,8 @@ function Hour({ doctor, setStep, date, locateVisit, isStep, setSelectedSlot, set
|
|||||||
locateVisit={locateVisit}
|
locateVisit={locateVisit}
|
||||||
setSelectedSlot={setSelectedSlot}
|
setSelectedSlot={setSelectedSlot}
|
||||||
setSelectedDate={setSelectedDate}
|
setSelectedDate={setSelectedDate}
|
||||||
|
serviceMode={serviceMode}
|
||||||
|
selectedServiceUuids={selectedServiceUuids}
|
||||||
/>
|
/>
|
||||||
{!isStep && (
|
{!isStep && (
|
||||||
<div className="absolute left-0 top-0 w-full h-full bg-[rgba(255,255,255,0.84)]"></div>
|
<div className="absolute left-0 top-0 w-full h-full bg-[rgba(255,255,255,0.84)]"></div>
|
||||||
|
|||||||
@@ -2,7 +2,16 @@ import { useState } from "react";
|
|||||||
import Hour from "./hour";
|
import Hour from "./hour";
|
||||||
import Time from "./Time";
|
import Time from "./Time";
|
||||||
|
|
||||||
function Date({ doctor, setStep, disabledDates, setSelectedSlot, setSelectedDate }) {
|
function Date({
|
||||||
|
doctor,
|
||||||
|
setStep,
|
||||||
|
disabledDates,
|
||||||
|
setSelectedSlot,
|
||||||
|
setSelectedDate,
|
||||||
|
serviceMode = false,
|
||||||
|
selectedServiceUuids = [],
|
||||||
|
onChangeService,
|
||||||
|
}) {
|
||||||
const [locateVisit, setLocateVisit] = useState(true);
|
const [locateVisit, setLocateVisit] = useState(true);
|
||||||
const [date, setDate] = useState();
|
const [date, setDate] = useState();
|
||||||
|
|
||||||
@@ -11,6 +20,15 @@ function Date({ doctor, setStep, disabledDates, setSelectedSlot, setSelectedDate
|
|||||||
<div
|
<div
|
||||||
className="p-0 lg:p-[24px] rounded-[8px] border border-solid border-transparent lg:border-[#EFEFEF] bg-transparent lg:bg-[#FFF]"
|
className="p-0 lg:p-[24px] rounded-[8px] border border-solid border-transparent lg:border-[#EFEFEF] bg-transparent lg:bg-[#FFF]"
|
||||||
>
|
>
|
||||||
|
{serviceMode && onChangeService && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onChangeService}
|
||||||
|
className="mb-3 text-[13px] text-[#5559CE] hover:underline"
|
||||||
|
>
|
||||||
|
← تغییر سرویس
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<Time
|
<Time
|
||||||
setDate={setDate}
|
setDate={setDate}
|
||||||
locateVisit={locateVisit}
|
locateVisit={locateVisit}
|
||||||
@@ -26,6 +44,8 @@ function Date({ doctor, setStep, disabledDates, setSelectedSlot, setSelectedDate
|
|||||||
date={date}
|
date={date}
|
||||||
setSelectedSlot={setSelectedSlot}
|
setSelectedSlot={setSelectedSlot}
|
||||||
setSelectedDate={setSelectedDate}
|
setSelectedDate={setSelectedDate}
|
||||||
|
serviceMode={serviceMode}
|
||||||
|
selectedServiceUuids={selectedServiceUuids}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import Cookies from "js-cookie";
|
|||||||
import { setAccessToken } from "@/lib/tokenStore";
|
import { setAccessToken } from "@/lib/tokenStore";
|
||||||
import { useProvince } from "@/context/ProvinceProvider";
|
import { useProvince } from "@/context/ProvinceProvider";
|
||||||
|
|
||||||
function SubmitData({ setStep, data, prevData, setErrors, doctor, isForAnother, selectedSlot, selectedDate, setAppointmentId, setAppointmentExpiresAt }) {
|
function SubmitData({ setStep, data, prevData, setErrors, doctor, isForAnother, selectedSlot, selectedDate, setAppointmentId, setAppointmentExpiresAt, selectedServiceUuids = [] }) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const { cityId } = useProvince();
|
const { cityId } = useProvince();
|
||||||
const newStep = () => setStep((prev) => prev + 1);
|
const newStep = () => setStep((prev) => prev + 1);
|
||||||
@@ -146,6 +146,8 @@ function SubmitData({ setStep, data, prevData, setErrors, doctor, isForAnother,
|
|||||||
patient_gender: data?.gender?.value?.id || data?.gender?.value || "",
|
patient_gender: data?.gender?.value?.id || data?.gender?.value || "",
|
||||||
// شناسهی شهرِ دامنهی جاری؛ backend برای گاردِ پورسانت نماینده استفاده میکند.
|
// شناسهی شهرِ دامنهی جاری؛ backend برای گاردِ پورسانت نماینده استفاده میکند.
|
||||||
city_id: cityId ?? null,
|
city_id: cityId ?? null,
|
||||||
|
// حالت نوبتدهی سرویسی: backend مدت و slot_end را از این سرویسها بازمحاسبه میکند.
|
||||||
|
...(selectedServiceUuids?.length ? { service_item_uuids: selectedServiceUuids } : {}),
|
||||||
...(isForAnother
|
...(isForAnother
|
||||||
? {
|
? {
|
||||||
patient_name: [data?.name?.value, data?.family?.value].filter(Boolean).join(" ").trim(),
|
patient_name: [data?.name?.value, data?.family?.value].filter(Boolean).join(" ").trim(),
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ function Detail({
|
|||||||
selectedDate,
|
selectedDate,
|
||||||
setAppointmentId,
|
setAppointmentId,
|
||||||
setAppointmentExpiresAt,
|
setAppointmentExpiresAt,
|
||||||
|
selectedServiceUuids = [],
|
||||||
}) {
|
}) {
|
||||||
const [insurance, setInsurance] = useState();
|
const [insurance, setInsurance] = useState();
|
||||||
const [supplementaryInsurance, setSupplementaryInsurance] = useState();
|
const [supplementaryInsurance, setSupplementaryInsurance] = useState();
|
||||||
@@ -120,6 +121,7 @@ function Detail({
|
|||||||
selectedDate={selectedDate}
|
selectedDate={selectedDate}
|
||||||
setAppointmentId={setAppointmentId}
|
setAppointmentId={setAppointmentId}
|
||||||
setAppointmentExpiresAt={setAppointmentExpiresAt}
|
setAppointmentExpiresAt={setAppointmentExpiresAt}
|
||||||
|
selectedServiceUuids={selectedServiceUuids}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -61,6 +61,23 @@ function AppointmentPage({ doctor, disabledDates, matchedCity }) {
|
|||||||
const [appointmentId, setAppointmentId] = useState(null);
|
const [appointmentId, setAppointmentId] = useState(null);
|
||||||
const [appointmentExpiresAt, setAppointmentExpiresAt] = useState(null);
|
const [appointmentExpiresAt, setAppointmentExpiresAt] = useState(null);
|
||||||
|
|
||||||
|
// Service-based booking (روش نوبتدهی سرویسی)
|
||||||
|
const [bookingMode, setBookingMode] = useState("slot");
|
||||||
|
const [bookingServices, setBookingServices] = useState([]);
|
||||||
|
const [selectedServiceUuids, setSelectedServiceUuids] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!doctor?.uuid) return;
|
||||||
|
request
|
||||||
|
.getBookingServices(doctor.uuid)
|
||||||
|
.then((res) => {
|
||||||
|
const d = res?.data?.data ?? res?.data ?? {};
|
||||||
|
setBookingMode(d.booking_mode === "service" ? "service" : "slot");
|
||||||
|
setBookingServices(Array.isArray(d.services) ? d.services : []);
|
||||||
|
})
|
||||||
|
.catch(() => setBookingMode("slot"));
|
||||||
|
}, [doctor?.uuid]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchUserData = async () => {
|
const fetchUserData = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -167,6 +184,10 @@ function AppointmentPage({ doctor, disabledDates, matchedCity }) {
|
|||||||
setAppointmentId={setAppointmentId}
|
setAppointmentId={setAppointmentId}
|
||||||
appointmentExpiresAt={appointmentExpiresAt}
|
appointmentExpiresAt={appointmentExpiresAt}
|
||||||
setAppointmentExpiresAt={setAppointmentExpiresAt}
|
setAppointmentExpiresAt={setAppointmentExpiresAt}
|
||||||
|
bookingMode={bookingMode}
|
||||||
|
bookingServices={bookingServices}
|
||||||
|
selectedServiceUuids={selectedServiceUuids}
|
||||||
|
setSelectedServiceUuids={setSelectedServiceUuids}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
function toToman(rials) {
|
||||||
|
if (!rials) return null;
|
||||||
|
return Math.round(rials / 10).toLocaleString("fa-IR");
|
||||||
|
}
|
||||||
|
|
||||||
|
// مرحلهٔ انتخاب سرویس (فقط حالت نوبتدهی سرویسی) — پیش از انتخاب روز.
|
||||||
|
function ServiceSelect({ services = [], onContinue }) {
|
||||||
|
const [draft, setDraft] = useState([]);
|
||||||
|
|
||||||
|
const toggle = (uuid) =>
|
||||||
|
setDraft((prev) =>
|
||||||
|
prev.includes(uuid) ? prev.filter((u) => u !== uuid) : [...prev, uuid]
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalMinutes = services
|
||||||
|
.filter((s) => draft.includes(s.uuid))
|
||||||
|
.reduce((sum, s) => sum + (Number(s.duration_minutes) || 0), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-[520px] mx-auto">
|
||||||
|
<h2 className="text-[16px] font-bold text-[#3B3B3B] mb-4">۱. انتخاب سرویس</h2>
|
||||||
|
|
||||||
|
{services.length === 0 ? (
|
||||||
|
<p className="text-[14px] text-[#7A7A7A]">
|
||||||
|
در حال حاضر سرویسی برای نوبتدهی آنلاین تعریف نشده است.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{services.map((s) => {
|
||||||
|
const active = draft.includes(s.uuid);
|
||||||
|
const toman = toToman(s.price_rials);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={s.uuid}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggle(s.uuid)}
|
||||||
|
className={`flex items-center justify-between gap-3 p-3 rounded-xl border text-right transition-colors ${
|
||||||
|
active
|
||||||
|
? "border-[#5559CE] bg-[#5559CE]/5"
|
||||||
|
: "border-gray-200 bg-white hover:border-[#5559CE]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 min-w-0">
|
||||||
|
<span
|
||||||
|
className={`w-4 h-4 rounded border shrink-0 grid place-items-center ${
|
||||||
|
active ? "border-[#5559CE] bg-[#5559CE]" : "border-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{active && (
|
||||||
|
<span className="w-2 h-2 bg-white rounded-[2px]" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[14px] text-[#3B3B3B] truncate">
|
||||||
|
{s.name}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-2 shrink-0 text-[12px] text-[#7A7A7A]">
|
||||||
|
{s.duration_minutes ? <span>{s.duration_minutes} دقیقه</span> : null}
|
||||||
|
{toman ? <span className="text-[#5559CE]">{toman} تومان</span> : null}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{draft.length > 0 && (
|
||||||
|
<div className="mt-4 text-[13px] text-[#7A7A7A]">
|
||||||
|
مدت کل: <b className="text-[#3B3B3B]">{totalMinutes} دقیقه</b>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={draft.length === 0}
|
||||||
|
onClick={() => onContinue(draft)}
|
||||||
|
className="mt-5 w-full h-[48px] rounded-xl bg-[#5559CE] text-white text-[15px] font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
انتخاب زمان
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ServiceSelect;
|
||||||
@@ -14,6 +14,22 @@ export function adaptSlots(slotsResponse) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// حالت سرویسی: پاسخ appointment-service-slots فقط start_times دارد (همه کافی).
|
||||||
|
// آنها را در یک session قالببندی میکنیم تا مثل حالت اسلاتی رندر شوند.
|
||||||
|
export function adaptServiceSlots(slotsResponse) {
|
||||||
|
const starts =
|
||||||
|
slotsResponse?.data?.start_times ?? slotsResponse?.start_times ?? [];
|
||||||
|
if (!starts.length) return [];
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
start_time: starts[0].start_time,
|
||||||
|
end_time: starts[starts.length - 1].start_time,
|
||||||
|
label: "زمانهای خالی",
|
||||||
|
slots: starts.map((s) => ({ ...s, is_available: true })),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
export function hasAvailable(slots) {
|
export function hasAvailable(slots) {
|
||||||
return Array.isArray(slots) && slots.some((slot) => slot.is_available);
|
return Array.isArray(slots) && slots.some((slot) => slot.is_available);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,16 @@ export const request = {
|
|||||||
params: { year, month },
|
params: { year, month },
|
||||||
...removeTokenHead,
|
...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 }),
|
postAppointment: (data) => api.post(`api/v1/appointment`, data, { requireAuth: true }),
|
||||||
getMyAppointments: (params) => api.get(`api/v1/appointments/user`, { params, requireAuth: true }),
|
getMyAppointments: (params) => api.get(`api/v1/appointments/user`, { params, requireAuth: true }),
|
||||||
getPaymentConfig: () => api.get(`api/v1/payment/config`, { requireAuth: true }),
|
getPaymentConfig: () => api.get(`api/v1/payment/config`, { requireAuth: true }),
|
||||||
|
|||||||
Reference in New Issue
Block a user