Files
nobat724_front/app/specialties/[slug]/page.js
T
hamedandClaude Fable 5 daf38c8631 feat(maintenance): show maintenance page when the API is in maintenance
The backend now answers 503 with code MAINTENANCE_MODE while maintenance is
on. Without this change a visitor got a red error toast over a broken page
client-side, and a silently empty page server-side, because fetchReq discards
the status and returns null on any failure.

- lib/maintenance.js detects the state by BOTH status 503 and the error code;
  a bare 503 can come from a reverse proxy and is not maintenance
- The axios interceptor checks it before the 401 branch, so a maintenance
  response never triggers the refresh-token path or logs the user out
- fetchReq redirects to /maintenance, with a silentMaintenance opt-out used by
  getStateInfo: that one runs inside generateMetadata and while rendering the
  maintenance page itself, where a redirect is either ineffective or loops
- redirect() works by throwing, so the try/catch blocks in the doctors,
  clinics and specialties pages now rethrow NEXT_REDIRECT instead of
  swallowing it
- clinicApi.js handles 503 too; it previously rendered maintenance as a clinic
  with zero doctors
- The page reuses the existing 404 design and is marked noindex

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 22:01:45 +03:30

147 lines
5.5 KiB
JavaScript

import { cache } from "react";
import { notFound } from "next/navigation";
import Layout from "@/components/layout/StLayout";
import SpecialtyDetailPage from "@/components/specialties/detail";
import specialtiesData from "@/data/specialties.json";
import { getStateInfo } from "@/lib/getStateInfo";
import { getRequestOrigin } from "@/lib/getCanonicalUrl";
import { resolveCityDisplayName } from "@/lib/domainHelpers";
import { buildSpecialtyFaq, buildSpecialtyIntro } from "@/lib/specialtyContent";
import { safeJsonLd } from "@/lib/sanitize";
import { fetchReq } from "@/lib/req";
import { isNextRedirectError } from "@/lib/maintenance";
const API_URL = process.env.NEXT_PUBLIC_API_URL;
const DOCTOR_LIMIT = 12;
const findSpecialty = (slug) =>
specialtiesData.find((item) => item.slug === slug && item.status === 1) ?? null;
// روی هر دامنهٔ شهری این صفحه خودکار «تخصص + همان شهر» است — شهر از host می‌آید.
// cache تا generateMetadata و بدنهٔ صفحه یک بار fetch کنند، نه دو بار.
const getDoctors = cache(async (specialtyId, cityId) => {
try {
const res = await fetchReq(`${API_URL}/api/v1/doctors`, {
params: {
specialty_id: specialtyId,
...(cityId && { city_id: cityId }),
page: 1,
limit: DOCTOR_LIMIT,
},
});
return {
items: res?.data?.data ?? res?.data ?? [],
total: Number(res?.data?.meta?.totalRecords ?? res?.meta?.totalRecords ?? 0),
};
} catch (error) {
// ریدایرکت نکست با throw کار می‌کند؛ بدون این rethrow، ریدایرکت حالت تعمیرات بلعیده می‌شود.
if (isNextRedirectError(error)) throw error;
return { items: [], total: 0 };
}
});
export async function generateMetadata({ params }) {
const { slug } = await params;
const specialty = findSpecialty(slug);
if (!specialty) notFound();
const { matchedCity, isRoot } = await getStateInfo();
const siteName = matchedCity?.site_name || "نوبت 724";
const cityName = resolveCityDisplayName(isRoot ? null : matchedCity);
const title = `متخصص ${specialty.name} در ${cityName} | رزرو نوبت آنلاین | ${siteName}`;
const description = `لیست بهترین پزشکان ${specialty.name} در ${cityName} همراه با نشانی مطب، ساعات کاری و امتیاز بیماران. رزرو نوبت آنلاین متخصص ${specialty.name}.`;
const image = "/assets/images/og-image.png";
// تخصصی که در این شهر پزشکی ندارد فقط یک عنوان و متن قالبی است — thin content.
// ایندکس نمی‌شود (و در sitemap هم نمی‌آید)، ولی follow می‌ماند تا لینک‌های
// داخلی‌اش دنبال شوند. به‌محض افزوده‌شدن اولین پزشک، خودکار ایندکس‌پذیر می‌شود.
const { total } = await getDoctors(specialty.id, isRoot ? null : matchedCity?.id);
return {
title,
description,
...(total === 0 && { robots: { index: false, follow: true } }),
// C1-a — صفحهٔ فرود تخصص per-domain است؛ canonical لایهٔ layout (self) درست است.
openGraph: { title, description, images: [image] },
twitter: { card: "summary_large_image", title, description, images: [image] },
};
}
async function SpecialtyDetail({ params }) {
const { slug } = await params;
const specialty = findSpecialty(slug);
if (!specialty) notFound();
const { matchedCity, isRoot } = await getStateInfo();
const origin = await getRequestOrigin();
const cityName = resolveCityDisplayName(isRoot ? null : matchedCity);
const { items: doctors, total } = await getDoctors(
specialty.id,
isRoot ? null : matchedCity?.id
);
const intro = buildSpecialtyIntro(specialty.name, cityName, total);
const faq = buildSpecialtyFaq(specialty.name, cityName, total);
const pageUrl = `${origin}/specialties/${specialty.slug}`;
const pageJsonLd = {
"@context": "https://schema.org",
"@type": "MedicalWebPage",
"@id": `${pageUrl}#webpage`,
url: pageUrl,
name: `متخصص ${specialty.name} در ${cityName}`,
description: intro.slice(0, 300),
about: { "@type": "MedicalSpecialty", name: specialty.name },
...(doctors.length > 0 && {
mainEntity: {
"@type": "ItemList",
numberOfItems: doctors.length,
itemListElement: doctors.map((doctor, idx) => ({
"@type": "ListItem",
position: idx + 1,
url: `${origin}/doctor/${doctor.uuid}`,
name: doctor.name,
})),
},
}),
};
// FAQPage عمداً یک script مستقل است تا اسکیماهای موجود صفحه را نشکند.
const faqJsonLd = {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: faq.map((item) => ({
"@type": "Question",
name: item.question,
acceptedAnswer: { "@type": "Answer", text: item.answer },
})),
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: safeJsonLd(pageJsonLd) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: safeJsonLd(faqJsonLd) }}
/>
<Layout name="/specialties">
<SpecialtyDetailPage
specialtyName={specialty.name}
cityName={cityName}
doctors={doctors}
intro={intro}
faq={faq}
origin={origin}
/>
</Layout>
</>
);
}
export default SpecialtyDetail;