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>
253 lines
8.3 KiB
JavaScript
253 lines
8.3 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 { getRequestOrigin } from "@/lib/getCanonicalUrl";
|
|
import { safeJsonLd } from "@/lib/sanitize";
|
|
import { imageUrl } 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();
|
|
return json?.data?.data ?? null;
|
|
});
|
|
|
|
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 [];
|
|
}
|
|
});
|
|
|
|
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 = `دکتر ${doctor.name}${specialtyNames ? " | " + specialtyNames : ""} | ${siteName}`;
|
|
const detailText = (doctor.detail || "")
|
|
.replace(/<[^>]*>/g, "")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
const description = (
|
|
detailText ||
|
|
`رزرو نوبت آنلاین دکتر ${doctor.name}${specialtyNames ? " متخصص " + specialtyNames : ""}${doctor.address ? " | " + doctor.address : ""}`.trim()
|
|
).slice(0, 160);
|
|
const image = imageUrl(doctor.img?.[0]?.url) || FALLBACK_IMG;
|
|
const isUnclaimed = doctor.owner_status !== "claimed";
|
|
|
|
return {
|
|
title,
|
|
description,
|
|
...(isUnclaimed && { robots: { index: false, follow: true } }),
|
|
openGraph: {
|
|
title,
|
|
description,
|
|
type: "profile",
|
|
images: [image],
|
|
},
|
|
twitter: {
|
|
card: "summary",
|
|
title,
|
|
description,
|
|
images: [image],
|
|
},
|
|
};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
async function Doctor({ params }) {
|
|
const { slug } = await params;
|
|
const origin = await getRequestOrigin();
|
|
let comments = null;
|
|
let rateAggregate = { point: 0, satisfaction: 0, averages: [] };
|
|
|
|
const doctor = await getDoctor(slug);
|
|
if (!doctor) notFound();
|
|
|
|
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] = 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";
|
|
const jsonLd = doctor
|
|
? {
|
|
"@context": "https://schema.org",
|
|
"@type": "Physician",
|
|
"@id": `${origin}/doctor/${doctor.uuid}#physician`,
|
|
name: `دکتر ${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`,
|
|
}),
|
|
})),
|
|
}),
|
|
...(addresses.length > 0 && {
|
|
workLocation: addresses.map((addr) => ({
|
|
"@type": "MedicalClinic",
|
|
name: addr.clinic_name || addr.name || `مطب دکتر ${doctor.name}`,
|
|
address: {
|
|
"@type": "PostalAddress",
|
|
streetAddress: addr.address,
|
|
},
|
|
...(addr.telephone && { telephone: addr.telephone }),
|
|
...(addr.map?.latitude && addr.map?.longitude && {
|
|
geo: {
|
|
"@type": "GeoCoordinates",
|
|
latitude: addr.map.latitude,
|
|
longitude: addr.map.longitude,
|
|
},
|
|
}),
|
|
})),
|
|
}),
|
|
}
|
|
: null;
|
|
|
|
const specialtyLabel = doctor?.specialties?.[0]?.name || "پزشکان";
|
|
const breadcrumbJsonLd = doctor
|
|
? {
|
|
"@context": "https://schema.org",
|
|
"@type": "BreadcrumbList",
|
|
itemListElement: [
|
|
{ "@type": "ListItem", position: 1, name: "خانه", item: origin },
|
|
{
|
|
"@type": "ListItem",
|
|
position: 2,
|
|
name: specialtyLabel,
|
|
item: `${origin}/doctors?specialty=${encodeURIComponent(specialtyLabel)}`,
|
|
},
|
|
{ "@type": "ListItem", position: 3, name: `دکتر ${doctor.name}` },
|
|
],
|
|
}
|
|
: null;
|
|
|
|
return (
|
|
<>
|
|
{jsonLd && (
|
|
<script
|
|
type="application/ld+json"
|
|
dangerouslySetInnerHTML={{ __html: safeJsonLd(jsonLd) }}
|
|
/>
|
|
)}
|
|
{breadcrumbJsonLd && (
|
|
<script
|
|
type="application/ld+json"
|
|
dangerouslySetInnerHTML={{ __html: safeJsonLd(breadcrumbJsonLd) }}
|
|
/>
|
|
)}
|
|
<Layout>
|
|
<DoctorPage
|
|
doctor={doctor}
|
|
comments={comments}
|
|
rateAggregate={rateAggregate}
|
|
addresses={addresses}
|
|
slug={slug}
|
|
/>
|
|
</Layout>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default Doctor;
|