Files
hamed 54bebbd41a feat: Implement resource-based appointment booking flow
- 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.
2026-08-09 10:45:52 +03:30

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;