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:
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = (
|
||||
<p className="w-full max-w-[520px] mx-auto py-[40px] text-center text-[14px] text-[#7A7A7A]">
|
||||
نوبتدهی آنلاین برای این پزشک فعال نیست.
|
||||
</p>
|
||||
);
|
||||
} else if (multiLocation && !locationConfirmed) {
|
||||
dateStep = (
|
||||
<LocationSelect
|
||||
locations={bookingLocations}
|
||||
selected={selectedLocation}
|
||||
onSelect={changeLocation}
|
||||
/>
|
||||
);
|
||||
} else if (serviceMode && selectedServiceUuids.length === 0) {
|
||||
dateStep = (
|
||||
<ServiceSelect
|
||||
services={bookingServices}
|
||||
onContinue={(uuids) => setSelectedServiceUuids(uuids)}
|
||||
/>
|
||||
) : (
|
||||
);
|
||||
} else {
|
||||
dateStep = (
|
||||
<Date
|
||||
doctor={doctor}
|
||||
setStep={setStep}
|
||||
@@ -62,8 +89,13 @@ function Container({
|
||||
serviceMode={serviceMode}
|
||||
selectedServiceUuids={selectedServiceUuids}
|
||||
onChangeService={() => 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}
|
||||
/>,
|
||||
<Paying
|
||||
setStep={setStep}
|
||||
@@ -134,6 +167,7 @@ function Container({
|
||||
step={step}
|
||||
selectedSlot={selectedSlot}
|
||||
selectedDate={selectedDate}
|
||||
selectedLocation={selectedLocation}
|
||||
>
|
||||
{elements[step]}
|
||||
</Content>
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className="flex flex-col lg:flex-row justify-center gap-[12px] sm:gap-[16px] md:gap-[20px] lg:gap-[24px] padding-responsive items-stretch pt-[calc(12px_+_75px_+_32px)] sm:pt-[calc(21px_+_75px_+_45px)] md:pt-[calc(30px_+_75px_+_58px)] lg:pt-[calc(40px_+_75px_+_78px)]"
|
||||
>
|
||||
{isFirst ? (
|
||||
<Location disableSide={disableSide} doctor={doctor} />
|
||||
<Location disableSide={disableSide} doctor={doctor} selectedLocation={selectedLocation} />
|
||||
) : (
|
||||
<Information
|
||||
disableSide={disableSide}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import DatePicker from "@/app/component/date/datePicker";
|
||||
|
||||
function SelectDatePicker({ setDate, disabledDates }) {
|
||||
return <DatePicker setDate={setDate} disabledDates={disabledDates} />;
|
||||
function SelectDatePicker({ setDate, disabledDates, clinicUuid }) {
|
||||
return <DatePicker setDate={setDate} disabledDates={disabledDates} clinicUuid={clinicUuid} />;
|
||||
}
|
||||
|
||||
export default SelectDatePicker;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import SelectDatePicker from "./SelectDatePicker";
|
||||
|
||||
function Time({ isStep, setDate, disabledDates }) {
|
||||
function Time({ isStep, setDate, disabledDates, clinicUuid }) {
|
||||
return (
|
||||
<div className="flex relative mt-[24px] flex-col items-start gap-[19px] justify-start">
|
||||
<p className="text-[#3B3B3B] text-[14px] md:text-[16px] font-bold">
|
||||
1. انتخاب روز
|
||||
</p>
|
||||
<SelectDatePicker setDate={setDate} disabledDates={disabledDates} />
|
||||
<SelectDatePicker setDate={setDate} disabledDates={disabledDates} clinicUuid={clinicUuid} />
|
||||
{!isStep && (
|
||||
<div className="absolute left-0 top-0 w-full h-full bg-[rgba(255,255,255,0.84)]"></div>
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
<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">
|
||||
@@ -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 && (
|
||||
<div className="absolute left-0 top-0 w-full h-full bg-[rgba(255,255,255,0.84)]"></div>
|
||||
|
||||
@@ -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({
|
||||
<div
|
||||
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>
|
||||
{(onChangeLocation || (serviceMode && onChangeService)) && (
|
||||
<div className="mb-3 flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||
{onChangeLocation && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onChangeLocation}
|
||||
className="text-[13px] text-[#5559CE] hover:underline"
|
||||
>
|
||||
← تغییر محل{selectedLocation?.title ? ` (${selectedLocation.title})` : ""}
|
||||
</button>
|
||||
)}
|
||||
{serviceMode && onChangeService && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onChangeService}
|
||||
className="text-[13px] text-[#5559CE] hover:underline"
|
||||
>
|
||||
← تغییر سرویس
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Time
|
||||
setDate={setDate}
|
||||
locateVisit={locateVisit}
|
||||
isStep={doctor?.multiwork ? locateVisit : true}
|
||||
isStep
|
||||
disabledDates={disabledDates}
|
||||
clinicUuid={clinicUuid}
|
||||
/>
|
||||
<Hour
|
||||
isStep={doctor?.multiwork ? locateVisit : true}
|
||||
setLocateVisit={setLocateVisit}
|
||||
locateVisit={locateVisit}
|
||||
isStep
|
||||
setStep={setStep}
|
||||
doctor={doctor}
|
||||
date={date}
|
||||
@@ -46,6 +59,8 @@ function Date({
|
||||
setSelectedDate={setSelectedDate}
|
||||
serviceMode={serviceMode}
|
||||
selectedServiceUuids={selectedServiceUuids}
|
||||
clinicUuid={clinicUuid}
|
||||
selectedLocation={selectedLocation}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,21 @@
|
||||
function Address({ doctor }) {
|
||||
/**
|
||||
* آدرسهای پزشک در سایدبار. وقتی محل نوبتدهی انتخاب شده باشد، فقط همان محل نشان
|
||||
* داده میشود تا با اسلاتهای نمایشدادهشده همخوان بماند.
|
||||
*/
|
||||
function Address({ doctor, selectedLocation }) {
|
||||
if (selectedLocation) {
|
||||
return (
|
||||
<ul className="hidden mt-[4px] lg:flex flex-col">
|
||||
<li>
|
||||
<div className="flex py-[12px] text-[#616161] text-[14px] items-start justify-start">
|
||||
<p className="font-bold min-w-[115px]">{`${selectedLocation.title}: `}</p>
|
||||
<p className="font-normal">{selectedLocation.address || "—"}</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="hidden mt-[4px] lg:flex flex-col">
|
||||
{doctor?.address?.map((item, idx) => (
|
||||
|
||||
@@ -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 (
|
||||
<div className="w-full max-w-[520px] mx-auto">
|
||||
<h2 className="text-[16px] font-bold text-[#3B3B3B] mb-4">۱. انتخاب محل نوبتدهی</h2>
|
||||
|
||||
<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);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => 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]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-[8px]">
|
||||
<p className="text-[14px] md:text-[16px] font-bold text-[#3B3B3B]">
|
||||
{location.title}
|
||||
</p>
|
||||
{location.type === "clinic" && (
|
||||
<span className="text-[11px] text-[#5559CE] bg-[rgba(85,89,206,0.10)] rounded-[4px] px-[6px] py-[2px]">
|
||||
کلینیک
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{location.address && (
|
||||
<p className="mt-[6px] text-[13px] text-[#616161] font-normal">
|
||||
{location.address}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p
|
||||
className={`mt-[8px] text-[12px] ${
|
||||
nextLabel ? "text-[#009D79]" : "text-[#A1A1A1]"
|
||||
}`}
|
||||
>
|
||||
{nextLabel ?? "فعلاً نوبت خالی ندارد"}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LocationSelect;
|
||||
@@ -1,7 +1,7 @@
|
||||
import Address from "./Address";
|
||||
import Head from "./Head";
|
||||
|
||||
function Location({ doctor }) {
|
||||
function Location({ doctor, selectedLocation }) {
|
||||
return (
|
||||
<div className="h-fit lg:h-screen w-full lg:w-[39%]">
|
||||
<div
|
||||
@@ -9,7 +9,7 @@ function Location({ doctor }) {
|
||||
>
|
||||
<Head doctor={doctor} />
|
||||
<span className="block bg-[#EFEFEF] lg:bg-transparent w-full h-px lg:my-[6px] mt-[16px]"></span>
|
||||
<Address doctor={doctor} />
|
||||
<Address doctor={doctor} selectedLocation={selectedLocation} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+23
-8
@@ -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 }),
|
||||
|
||||
Reference in New Issue
Block a user