diff --git a/app/appointment/[doctorId]/page.js b/app/appointment/[doctorId]/page.js index 82be7d6..047c09a 100644 --- a/app/appointment/[doctorId]/page.js +++ b/app/appointment/[doctorId]/page.js @@ -6,8 +6,9 @@ export const metadata = { robots: { index: false, follow: false }, }; -async function Appointment({ params }) { +async function Appointment({ params, searchParams }) { const { doctorId } = await params; + const { clinic_uuid: clinicUuid } = (await searchParams) ?? {}; const { matchedCity } = await getStateInfo(); const API_URL = process.env.NEXT_PUBLIC_API_URL; @@ -23,6 +24,7 @@ async function Appointment({ params }) { doctor={doctor} disabledDates={[]} matchedCity={matchedCity} + initialClinicUuid={typeof clinicUuid === "string" ? clinicUuid : null} /> ); } diff --git a/app/component/date/datePicker/index.js b/app/component/date/datePicker/index.js index 7bccd58..51331c2 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 }) { +function DatePicker({ setDate, clinicUuid = null }) { const params = useParams(); const doctorUuid = params?.doctorId; @@ -34,6 +34,15 @@ function DatePicker({ setDate }) { const secondMonth = moment(baseMonth).add(1, "jMonth"); + // هر محل، روزهای غیرفعال و وضعیت نوبت‌دهی آنلاین خودش را دارد؛ کش ماه‌ها باید + // با تعویض محل دور ریخته شود وگرنه تقویم محل قبلی باقی می‌ماند. + useEffect(() => { + loadedMonths.current = new Set(); + setDisabledSet(new Set()); + setOnlineEnabled(true); + setAutoSelected(false); + }, [clinicUuid]); + useEffect(() => { if (!doctorUuid) return; @@ -47,7 +56,7 @@ function DatePicker({ setDate }) { targets.forEach(({ year, month }) => { loadedMonths.current.add(`${year}-${month}`); request - .getMonthAvailability(doctorUuid, year, month) + .getMonthAvailability(doctorUuid, year, month, clinicUuid) .then((res) => { const data = res?.data ?? {}; if (data.online_booking_enabled === false) setOnlineEnabled(false); @@ -64,7 +73,7 @@ function DatePicker({ setDate }) { loadedMonths.current.delete(`${year}-${month}`); }); }); - }, [doctorUuid, baseMonth]); + }, [doctorUuid, baseMonth, clinicUuid]); 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 0fb6c7d..3364f2d 100644 --- a/app/component/date/dateTime/index.js +++ b/app/component/date/dateTime/index.js @@ -16,6 +16,8 @@ function DateTime({ setSelectedDate, serviceMode = false, selectedServiceUuids = [], + clinicUuid = null, + selectedLocation = null, }) { const [value, setValue] = useState(0); const [hour, setHour] = useState(); @@ -30,9 +32,13 @@ function DateTime({ setValue(newValue); }; - const locationAddress = hour?.location_id - ? doctor?.address?.find((a) => String(a.id) === String(hour.location_id))?.address ?? null - : null; + // آدرس از محل انتخاب‌شده می‌آید، نه از doctor.address: در محیط کلینیک ممکن است + // آدرس اصلاً در فهرست آدرس‌های شخصی پزشک نباشد. + const locationAddress = + selectedLocation?.address ?? + (hour?.location_id + ? doctor?.address?.find((a) => String(a.id) === String(hour.location_id))?.address ?? null + : null); useEffect(() => { if (!date || !doctor?.uuid) return; @@ -44,9 +50,9 @@ function DateTime({ const req = serviceMode ? request - .getServiceSlots(doctor.uuid, dateStr, selectedServiceUuids) + .getServiceSlots(doctor.uuid, dateStr, selectedServiceUuids, clinicUuid) .then(adaptServiceSlots) - : request.getAppointmentSlots(doctor.uuid, dateStr).then(adaptSlots); + : request.getAppointmentSlots(doctor.uuid, dateStr, clinicUuid).then(adaptSlots); req .then((parsed) => { @@ -63,7 +69,7 @@ function DateTime({ setAppo("در حال حاضر، نوبتی برای این روز موجود نمی‌باشد."); setValue(0); }); - }, [date, doctor?.uuid, serviceMode, selectedServiceUuids]); + }, [date, doctor?.uuid, serviceMode, selectedServiceUuids, clinicUuid]); return (
diff --git a/app/doctor/[slug]/page.js b/app/doctor/[slug]/page.js index 1b7eccb..5680b47 100644 --- a/app/doctor/[slug]/page.js +++ b/app/doctor/[slug]/page.js @@ -36,6 +36,26 @@ const getDoctorAddresses = cache(async (doctorId) => { } }); +/** + * محل‌های نوبت‌دهی پزشک (مطب شخصی + هر کلینیک). هر محل روش نوبت‌دهی و سرویس‌های + * خودش را دارد؛ سرویس‌ها بین محل‌ها مشترک نیستند. + */ +const getBookingLocations = cache(async (doctorUuid) => { + if (!doctorUuid) return []; + try { + const res = await fetch( + `${API_URL}/api/v1/appointment-booking-locations/${doctorUuid}`, + { next: { revalidate: 3600, tags: [`booking-locations-${doctorUuid}`] } } + ); + if (!res.ok) return []; + const json = await res.json(); + const data = json?.data?.data ?? json?.data ?? {}; + return Array.isArray(data.booking_locations) ? data.booking_locations : []; + } catch { + return []; + } +}); + export async function generateMetadata({ params }) { const { slug } = await params; const { matchedCity } = await getStateInfo(); @@ -105,7 +125,19 @@ async function Doctor({ params }) { } catch (error) {} } - const addresses = doctor ? await getDoctorAddresses(doctor.id) : []; + const [addresses, bookingLocations] = doctor + ? await Promise.all([getDoctorAddresses(doctor.id), getBookingLocations(doctor.uuid)]) + : [[], []]; + + // سرویس‌های قابل رزرو، تجمیع‌شده از همهٔ محل‌ها و یکتاشده بر اساس uuid. + const bookableServices = Object.values( + bookingLocations + .flatMap((location) => location.services ?? []) + .reduce((acc, service) => { + if (service?.uuid && !acc[service.uuid]) acc[service.uuid] = service; + return acc; + }, {}) + ); const specialtyNames = doctor?.specialties?.map((s) => s.name).join(" و ") || ""; const isUnclaimed = doctor?.owner_status !== "claimed"; @@ -142,6 +174,15 @@ async function Doctor({ params }) { ...(doctor.social_media && { sameAs: Object.values(doctor.social_media).filter(Boolean), }), + ...(bookableServices.length > 0 && { + availableService: bookableServices.map((service) => ({ + "@type": "MedicalProcedure", + name: service.name, + ...(service.duration_minutes && { + estimatedDuration: `PT${service.duration_minutes}M`, + }), + })), + }), ...(addresses.length > 0 && { workLocation: addresses.map((addr) => ({ "@type": "MedicalClinic", diff --git a/components/appointment/Container.js b/components/appointment/Container.js index a437b42..a0f0ca4 100644 --- a/components/appointment/Container.js +++ b/components/appointment/Container.js @@ -1,5 +1,6 @@ import Date from "./date"; import ServiceSelect from "./service"; +import LocationSelect from "./location/LocationSelect"; // Components import Content from "./Content"; @@ -44,15 +45,41 @@ function Container({ bookingServices, selectedServiceUuids, setSelectedServiceUuids, + // Booking location (مطب شخصی / کلینیک) + bookingLocations, + selectedLocation, + locationConfirmed, + changeLocation, + reopenLocationChoice, }) { const serviceMode = bookingMode === "service"; - const dateStep = - serviceMode && selectedServiceUuids.length === 0 ? ( + const clinicUuid = selectedLocation?.clinic_uuid ?? null; + const multiLocation = bookingLocations.length > 1; + + let dateStep; + if (bookingLocations.length === 0) { + dateStep = ( +

+ نوبت‌دهی آنلاین برای این پزشک فعال نیست. +

+ ); + } else if (multiLocation && !locationConfirmed) { + dateStep = ( + + ); + } else if (serviceMode && selectedServiceUuids.length === 0) { + dateStep = ( setSelectedServiceUuids(uuids)} /> - ) : ( + ); + } else { + dateStep = ( setSelectedServiceUuids([])} + selectedLocation={selectedLocation} + clinicUuid={clinicUuid} + onChangeLocation={multiLocation ? reopenLocationChoice : null} /> ); + } + const elements = [ dateStep, @@ -97,6 +129,7 @@ function Container({ setAppointmentId={setAppointmentId} setAppointmentExpiresAt={setAppointmentExpiresAt} selectedServiceUuids={selectedServiceUuids} + clinicUuid={clinicUuid} />, {elements[step]} diff --git a/components/appointment/Content.js b/components/appointment/Content.js index 32b7e9a..703f504 100644 --- a/components/appointment/Content.js +++ b/components/appointment/Content.js @@ -1,13 +1,13 @@ import Information from "./information"; import Location from "./location"; -function Content({ doctor, step, setStep, children, isFirst, disableSide, selectedSlot, selectedDate }) { +function Content({ doctor, step, setStep, children, isFirst, disableSide, selectedSlot, selectedDate, selectedLocation }) { return (
{isFirst ? ( - + ) : ( ; +function SelectDatePicker({ setDate, disabledDates, clinicUuid }) { + return ; } export default SelectDatePicker; diff --git a/components/appointment/date/Time/index.js b/components/appointment/date/Time/index.js index 6a75bad..b11d6a1 100644 --- a/components/appointment/date/Time/index.js +++ b/components/appointment/date/Time/index.js @@ -1,12 +1,12 @@ import SelectDatePicker from "./SelectDatePicker"; -function Time({ isStep, setDate, disabledDates }) { +function Time({ isStep, setDate, disabledDates, clinicUuid }) { return (

1. انتخاب روز

- + {!isStep && (
)} diff --git a/components/appointment/date/hour/index.js b/components/appointment/date/hour/index.js index 9ea94ec..778365b 100644 --- a/components/appointment/date/hour/index.js +++ b/components/appointment/date/hour/index.js @@ -1,6 +1,6 @@ import DateTime from "@/app/component/date/dateTime"; -function Hour({ doctor, setStep, date, locateVisit, isStep, setSelectedSlot, setSelectedDate, serviceMode = false, selectedServiceUuids = [] }) { +function Hour({ doctor, setStep, date, isStep, setSelectedSlot, setSelectedDate, serviceMode = false, selectedServiceUuids = [], clinicUuid = null, selectedLocation = null }) { return (

@@ -10,11 +10,12 @@ function Hour({ doctor, setStep, date, locateVisit, isStep, setSelectedSlot, set date={date} doctor={doctor} setStep={setStep} - locateVisit={locateVisit} setSelectedSlot={setSelectedSlot} setSelectedDate={setSelectedDate} serviceMode={serviceMode} selectedServiceUuids={selectedServiceUuids} + clinicUuid={clinicUuid} + selectedLocation={selectedLocation} /> {!isStep && (

diff --git a/components/appointment/date/index.js b/components/appointment/date/index.js index 8a9b1c0..f614cab 100644 --- a/components/appointment/date/index.js +++ b/components/appointment/date/index.js @@ -11,8 +11,10 @@ function Date({ serviceMode = false, selectedServiceUuids = [], onChangeService, + selectedLocation = null, + clinicUuid = null, + onChangeLocation, }) { - const [locateVisit, setLocateVisit] = useState(true); const [date, setDate] = useState(); return ( @@ -20,25 +22,36 @@ function Date({
- {serviceMode && onChangeService && ( - + {(onChangeLocation || (serviceMode && onChangeService)) && ( +
+ {onChangeLocation && ( + + )} + {serviceMode && onChangeService && ( + + )} +
)}
diff --git a/components/appointment/detail/SubmitData.js b/components/appointment/detail/SubmitData.js index 5a6532f..6b6db13 100644 --- a/components/appointment/detail/SubmitData.js +++ b/components/appointment/detail/SubmitData.js @@ -7,7 +7,7 @@ import Cookies from "js-cookie"; import { setAccessToken } from "@/lib/tokenStore"; import { useProvince } from "@/context/ProvinceProvider"; -function SubmitData({ setStep, data, prevData, setErrors, doctor, isForAnother, selectedSlot, selectedDate, setAppointmentId, setAppointmentExpiresAt, selectedServiceUuids = [] }) { +function SubmitData({ setStep, data, prevData, setErrors, doctor, isForAnother, selectedSlot, selectedDate, setAppointmentId, setAppointmentExpiresAt, selectedServiceUuids = [], clinicUuid = null }) { const [loading, setLoading] = useState(false); const { cityId } = useProvince(); const newStep = () => setStep((prev) => prev + 1); @@ -137,6 +137,8 @@ function SubmitData({ setStep, data, prevData, setErrors, doctor, isForAnother, if (doctor && selectedSlot) { const appointmentPayload = { doctor_uuid: doctor.uuid, + // محل نوبت‌دهی؛ null یعنی مطب شخصی. بدون این، نوبت در محل اشتباه ثبت می‌شود. + clinic_uuid: clinicUuid, slot_start: selectedSlot.start, slot_end: selectedSlot.end, for_self: !isForAnother, diff --git a/components/appointment/detail/index.js b/components/appointment/detail/index.js index 9c99554..57e132a 100644 --- a/components/appointment/detail/index.js +++ b/components/appointment/detail/index.js @@ -21,6 +21,7 @@ function Detail({ setAppointmentId, setAppointmentExpiresAt, selectedServiceUuids = [], + clinicUuid = null, }) { const [insurance, setInsurance] = useState(); const [supplementaryInsurance, setSupplementaryInsurance] = useState(); @@ -122,6 +123,7 @@ function Detail({ setAppointmentId={setAppointmentId} setAppointmentExpiresAt={setAppointmentExpiresAt} selectedServiceUuids={selectedServiceUuids} + clinicUuid={clinicUuid} />
diff --git a/components/appointment/index.js b/components/appointment/index.js index f5bc846..36f3bc9 100644 --- a/components/appointment/index.js +++ b/components/appointment/index.js @@ -42,7 +42,7 @@ function buildProfileData(profile, phone) { }; } -function AppointmentPage({ doctor, disabledDates, matchedCity }) { +function AppointmentPage({ doctor, disabledDates, matchedCity, initialClinicUuid = null }) { const [step, setStep] = useState(0); const [isForAnother, setIsForAnother] = useState(false); const [isLoading, setIsLoading] = useState(true); @@ -62,21 +62,51 @@ function AppointmentPage({ doctor, disabledDates, matchedCity }) { const [appointmentExpiresAt, setAppointmentExpiresAt] = useState(null); // Service-based booking (روش نوبت‌دهی سرویسی) - const [bookingMode, setBookingMode] = useState("slot"); - const [bookingServices, setBookingServices] = useState([]); const [selectedServiceUuids, setSelectedServiceUuids] = useState([]); + // محل نوبت‌دهی: مطب شخصی یا یکی از کلینیک‌هایی که پزشک در آن برنامه دارد. + const [bookingLocations, setBookingLocations] = useState([]); + const [selectedLocation, setSelectedLocation] = useState(null); + const [locationConfirmed, setLocationConfirmed] = useState(false); + const [locationsLoading, setLocationsLoading] = useState(true); + useEffect(() => { if (!doctor?.uuid) return; request - .getBookingServices(doctor.uuid) + .getBookingLocations(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 : []); + const list = Array.isArray(d.booking_locations) ? d.booking_locations : []; + setBookingLocations(list); + + // آرایه از قبل بر اساس زودترین نوبت آزاد مرتب است؛ دوباره مرتبش نکن. + const fromUrl = initialClinicUuid + ? list.find((l) => l.clinic_uuid === initialClinicUuid) + : null; + setSelectedLocation(fromUrl ?? list[0] ?? null); + setLocationConfirmed(Boolean(fromUrl) || list.length <= 1); }) - .catch(() => setBookingMode("slot")); - }, [doctor?.uuid]); + .catch(() => setBookingLocations([])) + .finally(() => setLocationsLoading(false)); + }, [doctor?.uuid, initialClinicUuid]); + + // روش نوبت‌دهی و سرویس‌ها per-location هستند: یک پزشک می‌تواند در مطب شخصی + // اسلاتی و در کلینیک سرویسی باشد. + const bookingMode = selectedLocation?.booking_mode === "service" ? "service" : "slot"; + const bookingServices = selectedLocation?.services ?? []; + + // تعویض محل، انتخاب‌های وابسته را باطل می‌کند؛ وگرنه ترکیب سرویسِ یک محل با + // اسلات محل دیگر ساخته می‌شود و ثبت نوبت با ۴۲۲ رد می‌شود. + const changeLocation = (location) => { + setSelectedLocation(location); + setLocationConfirmed(true); + setSelectedServiceUuids([]); + setSelectedSlot(null); + setSelectedDate(null); + }; + + // بازگشت به مرحلهٔ انتخاب محل + const reopenLocationChoice = () => setLocationConfirmed(false); useEffect(() => { const fetchUserData = async () => { @@ -147,7 +177,7 @@ function AppointmentPage({ doctor, disabledDates, matchedCity }) { }, [step]); - if (isLoading) { + if (isLoading || locationsLoading) { return (
@@ -188,6 +218,11 @@ function AppointmentPage({ doctor, disabledDates, matchedCity }) { bookingServices={bookingServices} selectedServiceUuids={selectedServiceUuids} setSelectedServiceUuids={setSelectedServiceUuids} + bookingLocations={bookingLocations} + selectedLocation={selectedLocation} + locationConfirmed={locationConfirmed} + changeLocation={changeLocation} + reopenLocationChoice={reopenLocationChoice} /> ); } diff --git a/components/appointment/location/Address.js b/components/appointment/location/Address.js index af1a7cd..e791cde 100644 --- a/components/appointment/location/Address.js +++ b/components/appointment/location/Address.js @@ -1,4 +1,21 @@ -function Address({ doctor }) { +/** + * آدرس‌های پزشک در سایدبار. وقتی محل نوبت‌دهی انتخاب شده باشد، فقط همان محل نشان + * داده می‌شود تا با اسلات‌های نمایش‌داده‌شده هم‌خوان بماند. + */ +function Address({ doctor, selectedLocation }) { + if (selectedLocation) { + return ( +
    +
  • +
    +

    {`${selectedLocation.title}: `}

    +

    {selectedLocation.address || "—"}

    +
    +
  • +
+ ); + } + return (
    {doctor?.address?.map((item, idx) => ( diff --git a/components/appointment/location/LocationSelect.js b/components/appointment/location/LocationSelect.js new file mode 100644 index 0000000..d8c39e3 --- /dev/null +++ b/components/appointment/location/LocationSelect.js @@ -0,0 +1,71 @@ +"use client"; + +import moment from "moment-jalaali"; + +function nextAvailableLabel(timestamp) { + if (!timestamp) return null; + return `اولین نوبت آزاد: ${moment.unix(timestamp).format("jD jMMMM")}`; +} + +/** + * انتخاب محل نوبت‌دهی — پیش از انتخاب سرویس و روز. + * + * فهرست از سمت سرور بر اساس زودترین نوبت آزاد مرتب شده، پس اولین آیتم پیش‌فرض + * درست است و اینجا دوباره مرتب نمی‌شود. + */ +function LocationSelect({ locations = [], selected, onSelect }) { + return ( +
    +

    ۱. انتخاب محل نوبت‌دهی

    + +
    + {locations.map((location) => { + const key = location.clinic_uuid ?? location.location_uuid ?? "personal"; + const active = + (selected?.clinic_uuid ?? null) === (location.clinic_uuid ?? null); + const nextLabel = nextAvailableLabel(location.next_available_at); + + return ( + + ); + })} +
    +
    + ); +} + +export default LocationSelect; diff --git a/components/appointment/location/index.js b/components/appointment/location/index.js index 34e7a0e..617ab98 100644 --- a/components/appointment/location/index.js +++ b/components/appointment/location/index.js @@ -1,7 +1,7 @@ import Address from "./Address"; import Head from "./Head"; -function Location({ doctor }) { +function Location({ doctor, selectedLocation }) { return (
    -
    +
    ); diff --git a/services/response.js b/services/response.js index 6cca86d..bbe66be 100644 --- a/services/response.js +++ b/services/response.js @@ -1,6 +1,14 @@ import { removeTokenHead } from "@/helper"; import api from "./api"; +/** + * محل نوبت‌دهی. نبودِ clinic_uuid یعنی «مطب شخصی» — نه «هر محلی که پیدا شد». + * پزشک می‌تواند هم‌زمان برنامهٔ شخصی و برنامهٔ کلینیک داشته باشد و این دو داده‌ی + * جدا دارند. + */ +const clinicQuery = (clinicUuid, prefix = "&") => + clinicUuid ? `${prefix}clinic_uuid=${encodeURIComponent(clinicUuid)}` : ""; + export const request = { sendCode: (mobile, altcha, domain) => api.post( @@ -49,24 +57,31 @@ export const request = { api.patch(`api/v1/appointment-settings/weekly-schedule/${uuid}`), deleteAppointmentWeeklySchedule: (uuid) => api.delete(`api/v1/appointment-settings/weekly-schedule/${uuid}`), - getAppointmentSlots: (doctor_uuid, date) => + 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?doctor_uuid=${doctor_uuid}&date=${date}`, + `api/v1/appointment-slots?doctor_uuid=${doctor_uuid}&date=${date}` + + clinicQuery(clinic_uuid), removeTokenHead ), - getMonthAvailability: (doctor_uuid, year, month) => + getMonthAvailability: (doctor_uuid, year, month, clinic_uuid = null) => api.get(`api/v1/appointment-settings/month-availability/${doctor_uuid}`, { - params: { year, month }, + params: { year, month, ...(clinic_uuid ? { clinic_uuid } : {}) }, ...removeTokenHead, }), - getBookingServices: (doctor_uuid) => - api.get(`api/v1/appointment-booking-services/${doctor_uuid}`, removeTokenHead), - getServiceSlots: (doctor_uuid, date, serviceItemUuids = []) => + getBookingServices: (doctor_uuid, clinic_uuid = null) => + api.get( + `api/v1/appointment-booking-services/${doctor_uuid}${clinicQuery(clinic_uuid, "?")}`, + removeTokenHead + ), + getServiceSlots: (doctor_uuid, date, serviceItemUuids = [], clinic_uuid = null) => api.get( `api/v1/appointment-service-slots?doctor_uuid=${doctor_uuid}&date=${date}` + serviceItemUuids .map((u) => `&service_item_uuids[]=${encodeURIComponent(u)}`) - .join(""), + .join("") + + clinicQuery(clinic_uuid), removeTokenHead ), postAppointment: (data) => api.post(`api/v1/appointment`, data, { requireAuth: true }),