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 && (