feat(booking): show only locations that can actually be booked that day

The site offered a "personal practice" for a doctor who has no personal address
at all — the schedule existed but its shifts pointed at the clinic's address, so
there was nowhere to go. The backend now filters those out; this consumes the
filtered contract and adds the per-day dimension.

- getBookingLocations takes an optional date and the appointment page refetches
  on it, merging available_on_date into the existing list rather than replacing
  it, so browsing the calendar never resets the user's choice.
- The browsed day had to be lifted out of the Date step: selectedDate is only
  set once a slot is confirmed, far too late to drive availability.
- A location closed on the chosen day renders disabled with «در این روز نوبت
  ندارد», and when every location is closed the step says so instead of showing
  an empty slot list. If the already-selected location closes, a notice appears
  with a link back to the picker — silently showing nothing was the failure mode
  worth avoiding.
- Doctor profile: workLocation in the Physician JSON-LD is limited to addresses
  that appear in booking_locations, since schema.org presents them as places a
  patient can attend. The address card still lists the others — they are real
  practice details — tagged «بدون نوبت‌دهی آنلاین».

Verified end-to-end with a temporary unused address on the test doctor: the
visible card listed both and tagged the unused one, while workLocation carried
only the bookable one. The row was removed afterwards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-18 14:44:49 +03:30
co-authored by Claude Opus 4.8
parent f4dd73e55e
commit b37096048c
11 changed files with 294 additions and 18 deletions
+4
View File
@@ -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}
/>
);
}
+24 -1
View File
@@ -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 (
<div className="w-full lg:w-[61%]">
<div
@@ -44,8 +51,24 @@ function Date({
)}
</div>
)}
{closedOnDate && (
<div className="mb-3 rounded-[8px] border border-solid border-[#F17732] bg-[rgba(241,119,50,0.08)] p-[12px]">
<p className="text-[13px] text-[#B4541F] leading-relaxed">
«{selectedLocation?.title}» در روز انتخابشده نوبتدهی ندارد.
{onChangeLocation && (
<button
type="button"
onClick={onChangeLocation}
className="mr-1 font-medium text-[#5559CE] hover:underline"
>
انتخاب محل دیگر
</button>
)}
</p>
</div>
)}
<Time
setDate={setDate}
setDate={pickDate}
isStep
disabledDates={disabledDates}
clinicUuid={clinicUuid}
+36
View File
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import moment from "moment-jalaali";
import { request } from "@/services/response";
import Cookies from "js-cookie";
import Container from "./Container";
@@ -69,6 +70,9 @@ function AppointmentPage({ doctor, disabledDates, matchedCity, initialClinicUuid
const [selectedLocation, setSelectedLocation] = useState(null);
const [locationConfirmed, setLocationConfirmed] = useState(false);
const [locationsLoading, setLocationsLoading] = useState(true);
// روزی که کاربر در تقویم مرور می‌کند (timestamp) — با selectedDate فرق دارد،
// که فقط پس از تأیید اسلات ست می‌شود.
const [browsingDate, setBrowsingDate] = useState(null);
useEffect(() => {
if (!doctor?.uuid) return;
@@ -90,6 +94,32 @@ function AppointmentPage({ doctor, disabledDates, matchedCity, initialClinicUuid
.finally(() => setLocationsLoading(false));
}, [doctor?.uuid, initialClinicUuid]);
// با تغییر روز، فقط پرچم available_on_date تازه می‌شود — انتخاب کاربر دست‌نخورده
// می‌ماند، وگرنه هر بار جابه‌جایی در تقویم مرحله را ریست می‌کرد.
useEffect(() => {
if (!doctor?.uuid || !browsingDate) return;
const date = moment.unix(browsingDate).format("YYYY-MM-DD");
request
.getBookingLocations(doctor.uuid, date)
.then((res) => {
const d = res?.data?.data ?? res?.data ?? {};
const byKey = new Map(
(Array.isArray(d.booking_locations) ? d.booking_locations : []).map((l) => [
l.clinic_uuid ?? "personal",
l.available_on_date,
])
);
setBookingLocations((prev) =>
prev.map((l) => ({
...l,
available_on_date: byKey.get(l.clinic_uuid ?? "personal") ?? false,
}))
);
})
.catch(() => {});
}, [doctor?.uuid, browsingDate]);
// روش نوبت‌دهی و سرویس‌ها per-location هستند: یک پزشک می‌تواند در مطب شخصی
// اسلاتی و در کلینیک سرویسی باشد.
const bookingMode = selectedLocation?.booking_mode === "service" ? "service" : "slot";
@@ -108,6 +138,10 @@ function AppointmentPage({ doctor, disabledDates, matchedCity, initialClinicUuid
// بازگشت به مرحلهٔ انتخاب محل
const reopenLocationChoice = () => setLocationConfirmed(false);
// محلِ انتخاب‌شده در روزِ در حال مرور بسته است. نباید بی‌صدا فهرست خالی نشان داد.
const selectedClosedOnDate =
Boolean(browsingDate) && selectedLocation?.available_on_date === false;
useEffect(() => {
const fetchUserData = async () => {
setIsLoading(true);
@@ -223,6 +257,8 @@ function AppointmentPage({ doctor, disabledDates, matchedCity, initialClinicUuid
locationConfirmed={locationConfirmed}
changeLocation={changeLocation}
reopenLocationChoice={reopenLocationChoice}
onDateChange={setBrowsingDate}
selectedClosedOnDate={selectedClosedOnDate}
/>
);
}
@@ -14,26 +14,39 @@ function nextAvailableLabel(timestamp) {
* درست است و اینجا دوباره مرتب نمی‌شود.
*/
function LocationSelect({ locations = [], selected, onSelect }) {
const anyOpen = locations.some((l) => l.available_on_date !== false);
return (
<div className="w-full max-w-[520px] mx-auto">
<h2 className="text-[16px] font-bold text-[#3B3B3B] mb-4">۱. انتخاب محل نوبتدهی</h2>
{!anyOpen && locations.length > 0 && (
<p className="mb-3 text-[13px] text-[#B4541F]">
در روز انتخابشده هیچکدام از محلها نوبتدهی ندارند. روز دیگری را انتخاب کنید.
</p>
)}
<div className="flex flex-col gap-2">
{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);
// null یعنی روزی انتخاب نشده؛ فقط false به معنی «این روز بسته است».
const closed = location.available_on_date === false;
return (
<button
key={key}
type="button"
onClick={() => onSelect(location)}
disabled={closed}
onClick={() => !closed && onSelect(location)}
className={`w-full text-right p-[16px] rounded-[8px] border border-solid transition-colors ${
active
? "border-[#5559CE] bg-[rgba(85,89,206,0.06)]"
: "border-[#EFEFEF] bg-[#FFF] hover:border-[#C7C9EC]"
closed
? "border-[#EFEFEF] bg-[#FAFAFA] opacity-60 cursor-not-allowed"
: active
? "border-[#5559CE] bg-[rgba(85,89,206,0.06)]"
: "border-[#EFEFEF] bg-[#FFF] hover:border-[#C7C9EC]"
}`}
>
<div className="flex items-center justify-between gap-[8px]">
@@ -55,10 +68,12 @@ function LocationSelect({ locations = [], selected, onSelect }) {
<p
className={`mt-[8px] text-[12px] ${
nextLabel ? "text-[#009D79]" : "text-[#A1A1A1]"
closed ? "text-[#B4541F]" : nextLabel ? "text-[#009D79]" : "text-[#A1A1A1]"
}`}
>
{nextLabel ?? "فعلاً نوبت خالی ندارد"}
{closed
? "در این روز نوبت ندارد"
: nextLabel ?? "فعلاً نوبت خالی ندارد"}
</p>
</button>
);
@@ -11,7 +11,7 @@ import RoutingC from "@/components/icons/RoutingC";
// Leaflet به window نیاز دارد → فقط کلاینت
const MapView = dynamic(() => import("./MapView"), { ssr: false });
function Item({ data }) {
function Item({ data, bookable = true }) {
const [isOpen, setIsOpen] = useState(false);
const [open, setOpen] = useState(false);
@@ -29,7 +29,14 @@ function Item({ data }) {
onClick={() => setIsOpen(!isOpen)}
className="!px-[2px] !py-[4px] !flex !items-center !justify-between !w-full"
>
<p className="text-[#616161] text-[16px] font-medium">{data.name}</p>
<p className="text-[#616161] text-[16px] font-medium">
{data.name}
{!bookable && (
<span className="mr-2 text-[11px] font-normal text-[#A1A1A1]">
(بدون نوبتدهی آنلاین)
</span>
)}
</p>
<div
className={` transition-all duration-500 ${isOpen ? "rotate-180" : "rotate-0"} `}
>
@@ -1,8 +1,10 @@
import CustomLoading from "@/app/component/loading/Custom";
import Item from "./Item";
function Locations({ addresses }) {
function Locations({ addresses, bookableAddressUuids = [] }) {
if (!addresses?.length) return null;
const bookable = new Set(bookableAddressUuids);
return (
<div>
<p className="text-[#525252] text-[16px] md:text-[18px] lg:text-[20px] font-bold">
@@ -12,7 +14,7 @@ function Locations({ addresses }) {
{addresses.map((item, idx) => (
<CustomLoading key={idx} width="full" height={40}>
<li className="w-full">
<Item data={item} />
<Item data={item} bookable={bookable.has(item.uuid)} />
{addresses.length > idx + 1 && (
<span className="w-full h-px bg-[#EFEFEF] block my-[12px] md:my-[14px] lg:my-[16px]"></span>
)}
+2 -2
View File
@@ -5,7 +5,7 @@ import AboutDcotor from "./cards/AboutDcotor";
import Comments from "./cards/comments";
import Locations from "./cards/locations";
function DetailDoctor({ doctor, comments, rateAggregate, addresses }) {
function DetailDoctor({ doctor, comments, rateAggregate, addresses, bookableAddressUuids = [] }) {
return (
<div className="w-full lg:w-[59%]">
<div className="hidden lg:flex">
@@ -14,7 +14,7 @@ function DetailDoctor({ doctor, comments, rateAggregate, addresses }) {
<Title doctor={doctor} />
<Link />
<AboutDcotor doctor={doctor} />
<Locations addresses={addresses} />
<Locations addresses={addresses} bookableAddressUuids={bookableAddressUuids} />
<Comments
doctor={doctor}
comments={comments}
+2 -1
View File
@@ -5,7 +5,7 @@ import DetailDoctor from "./detailDoctor";
import Share from "./detailDoctor/Share";
import CustomLoading from "@/app/component/loading/Custom";
function DoctorPage({ doctor, comments, rateAggregate, addresses, slug }) {
function DoctorPage({ doctor, comments, rateAggregate, addresses, bookableAddressUuids = [], slug }) {
return (
<div className="pt-[92px] sm:pt-[120px] mt:pt-[148px] lg:pt-[176px] mt-[px] padding-responsive">
<div className="flex items-center justify-between">
@@ -39,6 +39,7 @@ function DoctorPage({ doctor, comments, rateAggregate, addresses, slug }) {
comments={comments}
rateAggregate={rateAggregate}
addresses={addresses}
bookableAddressUuids={bookableAddressUuids}
/>
<AppointmentList doctor={doctor} doctorSlug={slug} />
</div>