feat(booking): let patients choose the booking location

A doctor now has one booking schedule per context — the personal practice plus
one per clinic — and every booking endpoint takes an optional clinic_uuid where
omitting it means the personal practice, not a wildcard. This site sent none, so
a clinic-only doctor showed no availability at all and a doctor working in both
places silently booked into the wrong one.

- services/response.js: getBookingLocations + clinic_uuid on slots,
  service-slots, booking-services and month-availability. The manual query
  building is kept so the service_item_uuids[] serialisation does not change.
- AppointmentPage owns the selected location; booking_mode and services are
  derived from it instead of a separate getBookingServices call, which drops a
  request. Changing location clears the selected service, slot and date, since
  a service from one location cannot be booked into another.
- New LocationSelect step, shown only when there is more than one location.
  The list arrives sorted by earliest free slot, so the first item is the
  default and is not re-sorted here.
- DatePicker drops its month cache when the location changes; otherwise the
  previous location's disabled days stayed on the calendar.
- The slot address now comes from the selected location rather than
  doctor.address, which does not contain clinic addresses.
- clinic_uuid rides through to the appointment payload, and
  /appointment/[doctorId]?clinic_uuid=… preselects a location.
- Doctor page JSON-LD gains availableService from the bookable services.
  openingHoursSpecification still needs a public weekly-hours endpoint.

Removed the dead locateVisit state, which was initialised true and never unset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-18 13:48:31 +03:30
co-authored by Claude Opus 4.8
parent effa264027
commit eba0c6a5ba
17 changed files with 307 additions and 57 deletions
+3 -1
View File
@@ -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}
/>
);
}
+12 -3
View File
@@ -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");
+12 -6
View File
@@ -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 (
<div className="w-full">
+42 -1
View File
@@ -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",