Files
nobat724_front/app/doctor/[slug]/page.js
T
hamedandClaude Opus 4.8 b37096048c feat(booking): show only locations that can actually be booked that day
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>
2026-07-18 14:44:49 +03:30

278 lines
9.6 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)])
: [[], []];
// آدرسی که در هیچ برنامهٔ نوبت‌دهی استفاده نشده، محل مراجعه نیست. در کارت
// «موقعیت مکانی» می‌ماند (اطلاعات واقعی مطب است) ولی به 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: `دکتر ${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 || `مطب دکتر ${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;
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}
bookableAddressUuids={[...bookableAddressUuids]}
slug={slug}
/>
</Layout>
</>
);
}
export default Doctor;