- Updated DoctorPage component to accept bookingResources prop for appointment list. - Added serviceQuery function to serialize service item UUIDs for API requests. - Introduced new API endpoints for fetching booking resources and resource slots. - Enhanced tests for new resource-based booking functionality, including resource selection and service availability. - Created ResourceSelect component for selecting appointment types, including doctor and resource options. - Updated appointment submission logic to include resource_uuid in payload when applicable. - Ensured UI reflects changes in booking flow without disrupting existing doctor-centric experience.
358 lines
14 KiB
JavaScript
358 lines
14 KiB
JavaScript
import { cache } from "react";
|
|
import DoctorPage from "@/components/doctor";
|
|
import Layout from "@/components/layout/StLayout";
|
|
import { notFound } from "next/navigation";
|
|
import { getStateInfo } from "@/lib/getStateInfo";
|
|
import { getEntityOrigin } from "@/lib/getCanonicalUrl";
|
|
import { extractEntityCityId, findCityById } from "@/lib/domainHelpers";
|
|
import { buildDoctorFaq } from "@/lib/specialtyContent";
|
|
import { isNoindexDoctor } from "@/lib/entityQuality";
|
|
import { splitSpecialties } from "@/lib/specialtyDisplay";
|
|
import specialtiesData from "@/data/specialties.json";
|
|
import { safeJsonLd } from "@/lib/sanitize";
|
|
import { imageUrl, doctorTitle } from "@/helper";
|
|
|
|
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
|
const FALLBACK_IMG = "/assets/images/og-image.png";
|
|
|
|
const getDoctor = cache(async (slug) => {
|
|
if (!slug || slug === "undefined") return null;
|
|
const res = await fetch(`${API_URL}/api/v1/doctor/${slug}`, {
|
|
next: { revalidate: 3600, tags: [`doctor-${slug}`] },
|
|
});
|
|
if (res.status === 404 || res.status === 400) return null;
|
|
if (!res.ok) throw new Error(`Failed to fetch doctor: ${res.status}`);
|
|
const json = await res.json();
|
|
const doctor = json?.data?.data ?? null;
|
|
// پزشک غیرفعال (ادمین فلگ فعال را خاموش کرده) نباید در سایت نمایش داده شود؛
|
|
// صفحهٔ تکی هم مثل لیست عمومی 404 میشود.
|
|
if (doctor?.is_active === false) return null;
|
|
return doctor;
|
|
});
|
|
|
|
const getDoctorAddresses = cache(async (doctorId) => {
|
|
if (!doctorId) return [];
|
|
try {
|
|
const res = await fetch(`${API_URL}/api/v1/clinic-pro/doctor-addresses/${doctorId}`, {
|
|
next: { revalidate: 3600, tags: [`doctor-addresses-${doctorId}`] },
|
|
});
|
|
if (!res.ok) return [];
|
|
const json = await res.json();
|
|
// پاسخ double-nested است: { success, data: { data: [...] } }
|
|
return json?.data?.data ?? json?.data ?? [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
});
|
|
|
|
/**
|
|
* محلهای نوبتدهی پزشک (مطب شخصی + هر کلینیک). هر محل روش نوبتدهی و سرویسهای
|
|
* خودش را دارد؛ سرویسها بین محلها مشترک نیستند.
|
|
*/
|
|
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 [];
|
|
}
|
|
});
|
|
|
|
/**
|
|
* منابع قابل رزروِ پزشک (دستگاه، اتاق، یونیت) در همهٔ محیطهایش.
|
|
*
|
|
* جدا از محلها گرفته میشود چون منبع تقویم خودش را دارد: پزشکی که برنامهٔ هفتگی ندارد
|
|
* هیچ محلی برنمیگرداند، ولی ممکن است دستگاهش کاملاً قابل رزرو باشد.
|
|
*/
|
|
const getBookingResources = cache(async (doctorUuid) => {
|
|
if (!doctorUuid) return [];
|
|
try {
|
|
const res = await fetch(
|
|
`${API_URL}/api/v1/appointment-booking-resources/${doctorUuid}`,
|
|
{ next: { revalidate: 3600, tags: [`booking-resources-${doctorUuid}`] } }
|
|
);
|
|
if (!res.ok) return [];
|
|
const json = await res.json();
|
|
const data = json?.data?.data ?? json?.data ?? {};
|
|
return Array.isArray(data.resources) ? data.resources : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
});
|
|
|
|
export async function generateMetadata({ params }) {
|
|
const { slug } = await params;
|
|
const { matchedCity } = await getStateInfo();
|
|
const siteName = matchedCity?.site_name || "نوبت 724";
|
|
|
|
const doctor = await getDoctor(slug);
|
|
if (!doctor) notFound();
|
|
|
|
try {
|
|
|
|
const specialtyNames = doctor.specialties?.map((s) => s.name).join(" و ") || "";
|
|
const title = `${doctorTitle(doctor.name)}${specialtyNames ? " | " + specialtyNames : ""} | ${siteName}`;
|
|
const detailText = (doctor.detail || "")
|
|
.replace(/<[^>]*>/g, "")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
// doctor.address آرایهای از آبجکت آدرس است؛ متن آدرس یا نام شهر را میگیریم
|
|
// (الحاق مستقیم آرایه «[object Object]» میساخت).
|
|
const firstAddress = Array.isArray(doctor.address) ? doctor.address[0] : null;
|
|
const addressText = firstAddress?.address || firstAddress?.city?.name || "";
|
|
const description = (
|
|
detailText ||
|
|
`رزرو نوبت آنلاین ${doctorTitle(doctor.name)}${specialtyNames ? " متخصص " + specialtyNames : ""}${addressText ? " | " + addressText : ""}`.trim()
|
|
).slice(0, 160);
|
|
const image = imageUrl(doctor.img?.[0]?.url) || FALLBACK_IMG;
|
|
// C1-b — پزشک به دامنهٔ شهر خودش canonical میشود، روی هر دامنهای که سرو شود.
|
|
const origin = await getEntityOrigin(extractEntityCityId(doctor));
|
|
|
|
return {
|
|
title,
|
|
description,
|
|
alternates: { canonical: `${origin}/doctor/${doctor.uuid}` },
|
|
...(isNoindexDoctor(doctor) && { robots: { index: false, follow: true } }),
|
|
openGraph: {
|
|
title,
|
|
description,
|
|
type: "profile",
|
|
images: [image],
|
|
},
|
|
twitter: {
|
|
card: "summary",
|
|
title,
|
|
description,
|
|
images: [image],
|
|
},
|
|
};
|
|
} catch (error) {
|
|
// بازگشت خاموش، canonical و robots صفحه را بیصدا به fallback لایه میبرد — لاگ لازم است.
|
|
console.error(`[doctor/${slug}] generateMetadata failed:`, error);
|
|
return {};
|
|
}
|
|
}
|
|
|
|
async function Doctor({ params }) {
|
|
const { slug } = await params;
|
|
let comments = null;
|
|
let rateAggregate = { point: 0, satisfaction: 0, averages: [] };
|
|
|
|
const doctor = await getDoctor(slug);
|
|
if (!doctor) notFound();
|
|
|
|
// همان مقصد canonical — تا url/@id و breadcrumb سیگنال متناقض ندهند.
|
|
const origin = await getEntityOrigin(extractEntityCityId(doctor));
|
|
|
|
if (doctor) {
|
|
try {
|
|
const [resComments, resRate] = await Promise.all([
|
|
fetch(`${API_URL}/api/v1/comments/${doctor.uuid}`, { cache: "no-store" }),
|
|
fetch(`${API_URL}/api/v1/rate/${doctor.uuid}`, { cache: "no-store" }),
|
|
]);
|
|
const [jsonComments, jsonRate] = await Promise.all([
|
|
resComments.ok ? resComments.json() : null,
|
|
resRate.ok ? resRate.json() : null,
|
|
]);
|
|
comments = jsonComments?.data?.data;
|
|
rateAggregate = jsonRate?.data?.data ?? rateAggregate;
|
|
} catch (error) {}
|
|
}
|
|
|
|
const [addresses, bookingLocations, bookingResources] = doctor
|
|
? await Promise.all([
|
|
getDoctorAddresses(doctor.id),
|
|
getBookingLocations(doctor.uuid),
|
|
getBookingResources(doctor.uuid),
|
|
])
|
|
: [[], [], []];
|
|
|
|
// آدرسی که در هیچ برنامهٔ نوبتدهی استفاده نشده، محل مراجعه نیست. در کارت
|
|
// «موقعیت مکانی» میماند (اطلاعات واقعی مطب است) ولی به JSON-LD نمیرود، چون
|
|
// schema.org آن را محلی قابلمراجعه اعلام میکند.
|
|
const bookableAddressUuids = new Set(
|
|
bookingLocations.map((l) => l.location_uuid).filter(Boolean)
|
|
);
|
|
const bookableAddresses = addresses.filter((a) => bookableAddressUuids.has(a.uuid));
|
|
|
|
// ساعات کاری per-location است؛ با uuid آدرس به هر محل وصل میشود.
|
|
const openingHoursByLocation = bookingLocations.reduce((acc, location) => {
|
|
if (location.location_uuid && location.opening_hours?.length) {
|
|
acc[location.location_uuid] = location.opening_hours;
|
|
}
|
|
return acc;
|
|
}, {});
|
|
|
|
// سرویسهای قابل رزرو، تجمیعشده از همهٔ محلها و یکتاشده بر اساس 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";
|
|
const jsonLd = doctor
|
|
? {
|
|
"@context": "https://schema.org",
|
|
"@type": "Physician",
|
|
"@id": `${origin}/doctor/${doctor.uuid}#physician`,
|
|
name: doctorTitle(doctor.name),
|
|
url: `${origin}/doctor/${doctor.uuid}`,
|
|
...(specialtyNames && { medicalSpecialty: specialtyNames }),
|
|
...(doctor.img?.[0]?.url && {
|
|
image: {
|
|
"@type": "ImageObject",
|
|
url: imageUrl(doctor.img[0].url),
|
|
},
|
|
}),
|
|
...(!isUnclaimed && Number(doctor.point) > 0 && {
|
|
aggregateRating: {
|
|
"@type": "AggregateRating",
|
|
ratingValue: doctor.point,
|
|
bestRating: "5",
|
|
ratingCount: comments?.length || 1,
|
|
},
|
|
}),
|
|
...(comments?.length > 0 && {
|
|
review: comments.slice(0, 5).map((c) => ({
|
|
"@type": "Review",
|
|
author: { "@type": "Person", name: c.author?.real_name || "بیمار" },
|
|
reviewBody: c.comment,
|
|
datePublished: new Date(c.created * 1000).toISOString(),
|
|
})),
|
|
}),
|
|
...(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`,
|
|
}),
|
|
})),
|
|
}),
|
|
...(bookableAddresses.length > 0 && {
|
|
workLocation: bookableAddresses.map((addr) => ({
|
|
"@type": "MedicalClinic",
|
|
name: addr.clinic_name || addr.name || `مطب ${doctorTitle(doctor.name)}`,
|
|
address: {
|
|
"@type": "PostalAddress",
|
|
streetAddress: addr.address,
|
|
},
|
|
...(addr.telephone && { telephone: addr.telephone }),
|
|
...(openingHoursByLocation[addr.uuid]?.length && {
|
|
openingHoursSpecification: openingHoursByLocation[addr.uuid].map((shift) => ({
|
|
"@type": "OpeningHoursSpecification",
|
|
dayOfWeek: `https://schema.org/${shift.day}`,
|
|
opens: shift.opens,
|
|
closes: shift.closes,
|
|
})),
|
|
}),
|
|
...(addr.map?.latitude && addr.map?.longitude && {
|
|
geo: {
|
|
"@type": "GeoCoordinates",
|
|
latitude: addr.map.latitude,
|
|
longitude: addr.map.longitude,
|
|
},
|
|
}),
|
|
})),
|
|
}),
|
|
}
|
|
: null;
|
|
|
|
// همان تخصصی که سربرگ صفحه نشان میدهد؛ اگر اینجا اولین آیتم آرایه میماند،
|
|
// BreadcrumbList و نمای صفحه دو چیز متفاوت میگفتند.
|
|
const specialtyLabel =
|
|
splitSpecialties(doctor?.specialties).primary?.name || "پزشکان";
|
|
// مقصد تمیز صفحهٔ فرود تخصص؛ اگر تخصص slug نداشت، به لیست پزشکان برمیگردیم.
|
|
const specialtySlug = specialtiesData.find(
|
|
(item) => item.name === specialtyLabel && item.status === 1
|
|
)?.slug;
|
|
const specialtyItem = specialtySlug
|
|
? `${origin}/specialties/${specialtySlug}`
|
|
: `${origin}/doctors`;
|
|
|
|
const breadcrumbJsonLd = doctor
|
|
? {
|
|
"@context": "https://schema.org",
|
|
"@type": "BreadcrumbList",
|
|
itemListElement: [
|
|
{ "@type": "ListItem", position: 1, name: "خانه", item: origin },
|
|
{ "@type": "ListItem", position: 2, name: specialtyLabel, item: specialtyItem },
|
|
// آیتم آخر هم باید `item` داشته باشد (URL خودِ همین صفحه)؛ بدون آن
|
|
// سرچکنسول خطای بحرانی Missing field "item" میدهد.
|
|
{ "@type": "ListItem", position: 3, name: doctorTitle(doctor.name),
|
|
item: `${origin}/doctor/${slug}` },
|
|
],
|
|
}
|
|
: null;
|
|
|
|
// FAQPage عمداً script مستقل است — اسکیماهای Physician/Review/Geo نباید تحت تأثیر باشند.
|
|
const doctorCity = findCityById(extractEntityCityId(doctor));
|
|
const faq = doctor
|
|
? buildDoctorFaq(doctor.name, specialtyNames, doctorCity?.name)
|
|
: [];
|
|
const faqJsonLd = faq.length
|
|
? {
|
|
"@context": "https://schema.org",
|
|
"@type": "FAQPage",
|
|
mainEntity: faq.map((item) => ({
|
|
"@type": "Question",
|
|
name: item.question,
|
|
acceptedAnswer: { "@type": "Answer", text: item.answer },
|
|
})),
|
|
}
|
|
: null;
|
|
|
|
return (
|
|
<>
|
|
{jsonLd && (
|
|
<script
|
|
type="application/ld+json"
|
|
dangerouslySetInnerHTML={{ __html: safeJsonLd(jsonLd) }}
|
|
/>
|
|
)}
|
|
{faqJsonLd && (
|
|
<script
|
|
type="application/ld+json"
|
|
dangerouslySetInnerHTML={{ __html: safeJsonLd(faqJsonLd) }}
|
|
/>
|
|
)}
|
|
{breadcrumbJsonLd && (
|
|
<script
|
|
type="application/ld+json"
|
|
dangerouslySetInnerHTML={{ __html: safeJsonLd(breadcrumbJsonLd) }}
|
|
/>
|
|
)}
|
|
<Layout>
|
|
<DoctorPage
|
|
doctor={doctor}
|
|
comments={comments}
|
|
rateAggregate={rateAggregate}
|
|
addresses={addresses}
|
|
bookableAddressUuids={[...bookableAddressUuids]}
|
|
bookingLocations={bookingLocations}
|
|
bookingResources={bookingResources}
|
|
slug={slug}
|
|
faq={faq}
|
|
/>
|
|
</Layout>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default Doctor;
|