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>
267 lines
9.7 KiB
JavaScript
267 lines
9.7 KiB
JavaScript
"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";
|
|
import { safeJsonParse } from "@/lib/sanitize";
|
|
|
|
const defaultData = {
|
|
phone: { value: "", isEdit: false },
|
|
national_code: { value: "", isEdit: true },
|
|
name: { value: "", isEdit: true },
|
|
family: { value: "", isEdit: true },
|
|
gender: { value: "", isEdit: true },
|
|
insurance_id: { value: "", isEdit: true },
|
|
basic_insurance: { value: "", isEdit: true },
|
|
supplementary_insurance: { value: "", isEdit: true },
|
|
};
|
|
|
|
function buildProfileData(profile, phone) {
|
|
return {
|
|
uuid: profile.uuid,
|
|
phone: { value: phone, isEdit: false },
|
|
national_code: {
|
|
value: profile.national_code || "",
|
|
isEdit: !profile.national_code_approved,
|
|
},
|
|
name: { value: profile.label || "", isEdit: true },
|
|
family: { value: profile.family || "", isEdit: true },
|
|
gender: { value: profile.gender || "", isEdit: true },
|
|
insurance_id: { value: profile.insurance_id || "", isEdit: true },
|
|
basic_insurance: {
|
|
value: profile.basic_insurance_id ? { id: profile.basic_insurance_id } : "",
|
|
isEdit: true,
|
|
},
|
|
supplementary_insurance: {
|
|
value: profile.supplementary_insurance_id
|
|
? { id: profile.supplementary_insurance_id }
|
|
: "",
|
|
isEdit: true,
|
|
},
|
|
};
|
|
}
|
|
|
|
function AppointmentPage({ doctor, disabledDates, matchedCity, initialClinicUuid = null }) {
|
|
const [step, setStep] = useState(0);
|
|
const [isForAnother, setIsForAnother] = useState(false);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
|
|
const [prevData, setPrevData] = useState(defaultData);
|
|
const [data, setData] = useState(defaultData);
|
|
|
|
// Login states
|
|
const [num, setNum] = useState("");
|
|
const [uuid, setUuid] = useState("");
|
|
const [isSendMsg, setIsSendMsg] = useState(false);
|
|
|
|
// Appointment slot states
|
|
const [selectedSlot, setSelectedSlot] = useState(null);
|
|
const [selectedDate, setSelectedDate] = useState(null);
|
|
const [appointmentId, setAppointmentId] = useState(null);
|
|
const [appointmentExpiresAt, setAppointmentExpiresAt] = useState(null);
|
|
|
|
// Service-based booking (روش نوبتدهی سرویسی)
|
|
const [selectedServiceUuids, setSelectedServiceUuids] = useState([]);
|
|
|
|
// محل نوبتدهی: مطب شخصی یا یکی از کلینیکهایی که پزشک در آن برنامه دارد.
|
|
const [bookingLocations, setBookingLocations] = useState([]);
|
|
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;
|
|
request
|
|
.getBookingLocations(doctor.uuid)
|
|
.then((res) => {
|
|
const d = res?.data?.data ?? res?.data ?? {};
|
|
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(() => setBookingLocations([]))
|
|
.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";
|
|
const bookingServices = selectedLocation?.services ?? [];
|
|
|
|
// تعویض محل، انتخابهای وابسته را باطل میکند؛ وگرنه ترکیب سرویسِ یک محل با
|
|
// اسلات محل دیگر ساخته میشود و ثبت نوبت با ۴۲۲ رد میشود.
|
|
const changeLocation = (location) => {
|
|
setSelectedLocation(location);
|
|
setLocationConfirmed(true);
|
|
setSelectedServiceUuids([]);
|
|
setSelectedSlot(null);
|
|
setSelectedDate(null);
|
|
};
|
|
|
|
// بازگشت به مرحلهٔ انتخاب محل
|
|
const reopenLocationChoice = () => setLocationConfirmed(false);
|
|
|
|
// محلِ انتخابشده در روزِ در حال مرور بسته است. نباید بیصدا فهرست خالی نشان داد.
|
|
const selectedClosedOnDate =
|
|
Boolean(browsingDate) && selectedLocation?.available_on_date === false;
|
|
|
|
useEffect(() => {
|
|
const fetchUserData = async () => {
|
|
setIsLoading(true);
|
|
|
|
const userInfo = Cookies.get("userInfo");
|
|
const parsedData = safeJsonParse(userInfo);
|
|
const usernameFromCookie = parsedData?.username || "";
|
|
|
|
// ابتدا شماره موبایل را از Cookie ست میکنیم
|
|
if (usernameFromCookie) {
|
|
setData(prev => ({
|
|
...prev,
|
|
phone: { value: usernameFromCookie, isEdit: false },
|
|
}));
|
|
}
|
|
|
|
// سپس اگر uuid داریم، بقیه اطلاعات را از API میگیریم
|
|
if (userInfo && parsedData) {
|
|
try {
|
|
const res = await request.getUserProfile(parsedData.uuid);
|
|
const profile = res?.data?.data;
|
|
|
|
if (profile) {
|
|
const newData = buildProfileData(profile, usernameFromCookie);
|
|
setData(newData);
|
|
setPrevData(newData);
|
|
}
|
|
} catch (error) {
|
|
// در صورت خطا، شماره موبایل حفظ میشود و بقیه فیلدها قابل ویرایش میمانند
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
} else {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchUserData();
|
|
}, []); // فقط یک بار در mount اولیه اجرا میشه
|
|
|
|
// useEffect جداگانه برای زمانی که step تغییر میکنه
|
|
useEffect(() => {
|
|
const refetchUserData = async () => {
|
|
if (step === 3) {
|
|
const userInfo = Cookies.get("userInfo");
|
|
const parsedData = safeJsonParse(userInfo);
|
|
const usernameFromCookie = parsedData?.username || "";
|
|
|
|
if (userInfo && parsedData && !data.uuid) {
|
|
try {
|
|
const res = await request.getUserProfile(parsedData.uuid);
|
|
const profile = res?.data?.data;
|
|
|
|
if (profile) {
|
|
const newData = buildProfileData(profile, usernameFromCookie);
|
|
setData(newData);
|
|
setPrevData(newData);
|
|
}
|
|
} catch (error) {
|
|
// در صورت خطا، فرم خالی قابل ویرایش باقی میماند
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
refetchUserData();
|
|
}, [step]);
|
|
|
|
|
|
if (isLoading || locationsLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center min-h-screen">
|
|
<div className="flex flex-col items-center gap-4">
|
|
<div className="w-12 h-12 border-4 border-[#5559CE] border-t-transparent rounded-full animate-spin"></div>
|
|
<p className="text-[#3B3B3B] text-[16px]">در حال بارگذاری...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Container
|
|
step={step}
|
|
data={data}
|
|
doctor={doctor}
|
|
setData={setData}
|
|
setStep={setStep}
|
|
prevData={prevData}
|
|
matchedCity={matchedCity}
|
|
isForAnother={isForAnother}
|
|
setIsForAnother={setIsForAnother}
|
|
disabledDates={disabledDates}
|
|
num={num}
|
|
setNum={setNum}
|
|
uuid={uuid}
|
|
setUuid={setUuid}
|
|
isSendMsg={isSendMsg}
|
|
setIsSendMsg={setIsSendMsg}
|
|
selectedSlot={selectedSlot}
|
|
setSelectedSlot={setSelectedSlot}
|
|
selectedDate={selectedDate}
|
|
setSelectedDate={setSelectedDate}
|
|
appointmentId={appointmentId}
|
|
setAppointmentId={setAppointmentId}
|
|
appointmentExpiresAt={appointmentExpiresAt}
|
|
setAppointmentExpiresAt={setAppointmentExpiresAt}
|
|
bookingMode={bookingMode}
|
|
bookingServices={bookingServices}
|
|
selectedServiceUuids={selectedServiceUuids}
|
|
setSelectedServiceUuids={setSelectedServiceUuids}
|
|
bookingLocations={bookingLocations}
|
|
selectedLocation={selectedLocation}
|
|
locationConfirmed={locationConfirmed}
|
|
changeLocation={changeLocation}
|
|
reopenLocationChoice={reopenLocationChoice}
|
|
onDateChange={setBrowsingDate}
|
|
selectedClosedOnDate={selectedClosedOnDate}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export default AppointmentPage;
|