feat: add canonical URL handling and entity quality checks
- Implemented canonical URL strategies for city-specific domains and entities. - Added helper functions for domain and city resolution. - Created tests for canonical URL generation and domain resolution. - Introduced entity quality checks for doctors and clinics to ensure meaningful content. - Developed unique introductory texts for listing pages to avoid duplicate content. - Established robots.txt policies for listing pages to manage indexing based on user filters. - Enhanced specialty content with dynamic introductions and FAQs to improve SEO.
This commit is contained in:
@@ -1,23 +0,0 @@
|
||||
import { Skeleton } from "@mui/material";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="padding-responsive pt-[84px] sm:pt-[110px] md:pt-[140px] lg:pt-[168px]">
|
||||
<Skeleton variant="rounded" width={300} height={16} />
|
||||
<Skeleton variant="rounded" width={400} height={28} className="mt-[24px] sm:mt-[29px] md:mt-[35px] lg:mt-[40px]" />
|
||||
<div className="flex items-center gap-[35px] mt-[16px] sm:mt-[18px] md:mt-[21px] lg:mt-[24px]">
|
||||
<Skeleton variant="rounded" width={120} height={16} />
|
||||
<Skeleton variant="rounded" width={140} height={16} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-[32px] lg:flex-row items-start justify-center gap-[24px] mt-[16px] sm:mt-[19px] md:mt-[21px] lg:mt-[24px]">
|
||||
<div className="w-full flex flex-col gap-3">
|
||||
<Skeleton variant="rounded" height={280} className="!rounded-lg" />
|
||||
<Skeleton variant="rounded" height={20} className="!w-full" />
|
||||
<Skeleton variant="rounded" height={20} className="!w-full" />
|
||||
<Skeleton variant="rounded" height={20} width={300} />
|
||||
</div>
|
||||
<Skeleton variant="rounded" width={320} height={300} className="!rounded-lg shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+18
-6
@@ -4,7 +4,8 @@ import Layout from "@/components/layout/StLayout";
|
||||
import { notFound } from "next/navigation";
|
||||
import { fetchReq } from "@/lib/req";
|
||||
import { getStateInfo } from "@/lib/getStateInfo";
|
||||
import { getRequestOrigin } from "@/lib/getCanonicalUrl";
|
||||
import { getEntityOrigin } from "@/lib/getCanonicalUrl";
|
||||
import { extractEntityCityId, findCityById } from "@/lib/domainHelpers";
|
||||
import { safeJsonLd } from "@/lib/sanitize";
|
||||
import { normalizeBlog, imageUrl } from "@/helper";
|
||||
|
||||
@@ -38,11 +39,14 @@ export async function generateMetadata({ params }) {
|
||||
const description = rawText.slice(0, 160) || blog.title;
|
||||
const image = blog.image_url ? imageUrl(blog.image_url) : FALLBACK_IMG;
|
||||
|
||||
// C1-b — پست شهریافته به دامنهٔ همان شهر canonical میشود؛
|
||||
// پست سراسری (بدون شهر) روی دامنهٔ جاری self-canonical میماند.
|
||||
const origin = await getEntityOrigin(extractEntityCityId(blog));
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
// محتوای بلاگ روی همهی دامنههای شهری یکسان است — تجمیع اعتبار روی دامنهی اصلی
|
||||
alternates: { canonical: `https://nobat724.com/blog/${slug}` },
|
||||
alternates: { canonical: `${origin}/blog/${slug}` },
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
@@ -59,18 +63,22 @@ export async function generateMetadata({ params }) {
|
||||
images: [image],
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error(`[blog/${slug}] generateMetadata failed:`, error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function Blog({ params }) {
|
||||
const { slug } = await params;
|
||||
const origin = await getRequestOrigin();
|
||||
|
||||
const blog = normalizeBlog(await getBlog(slug));
|
||||
if (!blog) notFound();
|
||||
|
||||
const blogCityId = extractEntityCityId(blog);
|
||||
const blogCity = findCityById(blogCityId);
|
||||
const origin = await getEntityOrigin(blogCityId);
|
||||
|
||||
let relatedBlogs = [];
|
||||
const relatedResponse = await fetchReq(
|
||||
`${API_URL}/api/v1/blogs?page=1&limit=6`
|
||||
@@ -90,6 +98,10 @@ async function Blog({ params }) {
|
||||
datePublished: new Date(blog.created * 1000).toISOString(),
|
||||
}),
|
||||
...(blog.author && { author: { "@type": "Person", name: blog.author } }),
|
||||
// پست شهریافته حوزهٔ جغرافیاییاش را اعلام میکند؛ پست سراسری این فیلد را ندارد.
|
||||
...(blogCity && {
|
||||
spatialCoverage: { "@type": "Place", name: blogCity.name },
|
||||
}),
|
||||
}
|
||||
: null;
|
||||
|
||||
@@ -120,7 +132,7 @@ async function Blog({ params }) {
|
||||
/>
|
||||
)}
|
||||
<Layout>
|
||||
<BlogPage blog={blog} blogs={relatedBlogs} />
|
||||
<BlogPage blog={blog} blogs={relatedBlogs} cityName={blogCity?.name} />
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
|
||||
+2
-2
@@ -11,8 +11,8 @@ export async function generateMetadata() {
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
// مقالات روی همهی دامنهها یکساناند — تجمیع روی دامنهی اصلی
|
||||
alternates: { canonical: "https://nobat724.com/blogs" },
|
||||
// C1-a — لیست بلاگ per-domain است (پستهای همان شهر + سراسری)؛
|
||||
// canonical لایهٔ layout (self روی همان دامنه) درست است.
|
||||
openGraph: { title, description, images: [image] },
|
||||
twitter: { card: "summary_large_image", title, description, images: [image] },
|
||||
};
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Skeleton } from "@mui/material";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="pt-[40px] sm:pt-[70px] md:pt-[100px] lg:pt-[136px] mt-[40px] padding-responsive">
|
||||
<div className="flex gap-6">
|
||||
<Skeleton variant="rounded" width={100} height={32} />
|
||||
<Skeleton variant="rounded" width={100} height={32} />
|
||||
<Skeleton variant="rounded" width={100} height={32} />
|
||||
</div>
|
||||
<div className="mt-[24px] sm:mt-[27px] md:mt-[30px] lg:mt-[32px] flex flex-col gap-4">
|
||||
<Skeleton variant="rounded" height={200} className="!rounded-lg" />
|
||||
<Skeleton variant="rounded" height={20} className="!w-full" />
|
||||
<Skeleton variant="rounded" height={20} width={300} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+22
-22
@@ -4,7 +4,9 @@ import Layout from "@/components/layout/StLayout";
|
||||
import { notFound } from "next/navigation";
|
||||
import { fetchReq } from "@/lib/req";
|
||||
import { getStateInfo } from "@/lib/getStateInfo";
|
||||
import { getRequestOrigin } from "@/lib/getCanonicalUrl";
|
||||
import { getEntityOrigin } from "@/lib/getCanonicalUrl";
|
||||
import { extractEntityCityId } from "@/lib/domainHelpers";
|
||||
import { isThinClinic } from "@/lib/entityQuality";
|
||||
import { safeJsonLd } from "@/lib/sanitize";
|
||||
import { imageUrl } from "@/helper";
|
||||
|
||||
@@ -38,9 +40,15 @@ export async function generateMetadata({ params }) {
|
||||
? [imageUrl(clinic.images_clinic[0].url)]
|
||||
: ["/assets/images/og-image.png"];
|
||||
|
||||
// C1-b — کلینیک به دامنهٔ شهر خودش canonical میشود.
|
||||
const origin = await getEntityOrigin(extractEntityCityId(clinic));
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates: { canonical: `${origin}/clinic/${clinic.uuid}` },
|
||||
// H7 — کلینیک غیرفعال/بیمحتوا نباید ایندکس شود (تقارن با پزشک بیمحتوا)
|
||||
...(isThinClinic(clinic) && { robots: { index: false, follow: true } }),
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
@@ -54,7 +62,8 @@ export async function generateMetadata({ params }) {
|
||||
images: image,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error(`[clinic/${slug}] generateMetadata failed:`, error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -74,13 +83,15 @@ function computeClinicRating(doctors) {
|
||||
|
||||
async function Clinic({ params, searchParams }) {
|
||||
const { slug } = await params;
|
||||
const origin = await getRequestOrigin();
|
||||
const sp = await searchParams;
|
||||
const page = Number(sp?.page) || 1;
|
||||
const limit = 50;
|
||||
|
||||
const clinic = await getClinic(slug);
|
||||
if (!clinic) notFound();
|
||||
|
||||
// همان مقصد canonical — تا url/@id و breadcrumb سیگنال متناقض ندهند.
|
||||
const origin = await getEntityOrigin(extractEntityCityId(clinic));
|
||||
const reqDoctors = await fetchReq(
|
||||
`${API_URL}/api/v1/clinic/doctor-list/${slug}?page=${page}&limit=${limit}`
|
||||
);
|
||||
@@ -149,18 +160,6 @@ async function Clinic({ params, searchParams }) {
|
||||
}
|
||||
: null;
|
||||
|
||||
const breadcrumbJsonLd = clinic
|
||||
? {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{ "@type": "ListItem", position: 1, name: "خانه", item: origin },
|
||||
{ "@type": "ListItem", position: 2, name: "کلینیکها", item: `${origin}/clinics` },
|
||||
{ "@type": "ListItem", position: 3, name: clinic.title },
|
||||
],
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{jsonLd && (
|
||||
@@ -169,14 +168,15 @@ async function Clinic({ params, searchParams }) {
|
||||
dangerouslySetInnerHTML={{ __html: safeJsonLd(jsonLd) }}
|
||||
/>
|
||||
)}
|
||||
{breadcrumbJsonLd && (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: safeJsonLd(breadcrumbJsonLd) }}
|
||||
/>
|
||||
)}
|
||||
{/* BreadcrumbList از خودِ کامپوننت Pageguide میآید — یک منبع داده برای بصری و schema */}
|
||||
<Layout>
|
||||
<ClinicPage data={clinic} doctors={doctors} slug={slug} pages={pagedoctors} />
|
||||
<ClinicPage
|
||||
data={clinic}
|
||||
doctors={doctors}
|
||||
slug={slug}
|
||||
pages={pagedoctors}
|
||||
origin={origin}
|
||||
/>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
|
||||
+8
-16
@@ -3,24 +3,14 @@ import ClinicsPage from "@/components/clinics";
|
||||
import Layout from "@/components/layout/StLayout";
|
||||
import { getStateInfo } from "@/lib/getStateInfo";
|
||||
import { buildClinicParams } from "@/helper";
|
||||
|
||||
|
||||
const FILTER_KEYS = ["specialty", "state", "city", "gender", "degree", "active", "sort", "name"];
|
||||
|
||||
// جستجوی داخلی و ترکیب چند فیلتر نباید ایندکس شوند — crawl budget و duplicate
|
||||
function listingRobots(params) {
|
||||
const activeFilters = FILTER_KEYS.filter((key) => params?.[key]);
|
||||
if (params?.name || activeFilters.length >= 2) {
|
||||
return { robots: { index: false, follow: true } };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
import { listingRobots } from "@/lib/listingRobots";
|
||||
|
||||
export async function generateMetadata({ searchParams }) {
|
||||
const awaitedParams = await searchParams;
|
||||
const { matchedCity, matchedState, repContext } = await getStateInfo();
|
||||
const { matchedCity, matchedState, isRoot, repContext } = await getStateInfo();
|
||||
const siteName = matchedCity?.site_name || repContext?.full_name || "نوبت 724";
|
||||
const cityName = matchedCity?.name || matchedState?.name || "";
|
||||
// روی دامنهٔ ریشه نام رکورد («نوبت 724») برند است نه شهر — نباید در Title بنشیند.
|
||||
const cityName = isRoot ? "" : matchedCity?.name || matchedState?.name || "";
|
||||
const title = cityName
|
||||
? `کلینیکهای ${cityName} | جستجو و رزرو نوبت | ${siteName}`
|
||||
: `جستجوی کلینیک و مراکز درمانی | ${siteName}`;
|
||||
@@ -31,7 +21,7 @@ export async function generateMetadata({ searchParams }) {
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
...listingRobots(awaitedParams),
|
||||
...listingRobots(awaitedParams, { matchedCity, matchedState }),
|
||||
openGraph: { title, description, images: [image] },
|
||||
twitter: { card: "summary_large_image", title, description, images: [image] },
|
||||
};
|
||||
@@ -47,7 +37,9 @@ export default async function Clinics({ searchParams }) {
|
||||
let clinics;
|
||||
try {
|
||||
// Params
|
||||
let newSearchParams = awaitedSearchParams;
|
||||
// کپی — awaitedSearchParams همان شیئی است که generateMetadata هم میخواند؛
|
||||
// تزریق city/state روی خودش، سیاست robots را به noindex میبرد.
|
||||
const newSearchParams = { ...awaitedSearchParams };
|
||||
|
||||
// Conditional State — روی دامنهٔ ریشه (nobat724) فیلتر اعمال نمیشود.
|
||||
if (stateParams) newSearchParams.state = stateParams;
|
||||
|
||||
+68
-17
@@ -1,21 +1,72 @@
|
||||
function Pageguide({ list }) {
|
||||
import Link from "next/link";
|
||||
import { safeJsonLd } from "@/lib/sanitize";
|
||||
|
||||
// Breadcrumb واحد سایت: ظاهر دقیقاً همان نسخهٔ قبلی (همان کلاسها، همان جداکنندهٔ «>»)
|
||||
// ولی ساختار سمانتیک درست (nav/ol/a) و BreadcrumbList از همان یک منبع داده تولید میشود
|
||||
// تا نسخهٔ بصری و schema هرگز واگرا نشوند.
|
||||
//
|
||||
// list: Array<string | { name, href }> — آیتم آخر همیشه صفحهٔ جاری است (بدون لینک).
|
||||
|
||||
const ITEM_CLASS = "text-[16px] font-normal";
|
||||
|
||||
function normalize(list) {
|
||||
return list
|
||||
.map((item) => (typeof item === "string" ? { name: item } : item))
|
||||
.filter((item) => item?.name);
|
||||
}
|
||||
|
||||
function Pageguide({ list = [], origin }) {
|
||||
const items = normalize(list);
|
||||
if (items.length === 0) return null;
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: items.map((item, idx) => ({
|
||||
"@type": "ListItem",
|
||||
position: idx + 1,
|
||||
name: item.name,
|
||||
...(item.href && origin && { item: `${origin}${item.href}` }),
|
||||
})),
|
||||
};
|
||||
|
||||
return (
|
||||
<ul className="flex items-center justify-start gap-1">
|
||||
{list.map((item, idx) => (
|
||||
<li key={idx} className="flex items-center justify-start gap-1">
|
||||
<h2
|
||||
className={`text-[16px] font-normal ${ list.length > idx + 1 ? "text-[#9B9B9B]" : "text-[#525252]" } `}
|
||||
>
|
||||
{item}
|
||||
</h2>
|
||||
<span
|
||||
className={` text-[#9B9B9B] text-[16px] font-normal ${list.length > idx + 1 ? "flex" : "hidden"} `}
|
||||
>
|
||||
{">"}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: safeJsonLd(jsonLd) }}
|
||||
/>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol className="flex items-center justify-start gap-1">
|
||||
{items.map((item, idx) => {
|
||||
const isLast = idx === items.length - 1;
|
||||
const colorClass = isLast ? "text-[#525252]" : "text-[#9B9B9B]";
|
||||
return (
|
||||
<li key={idx} className="flex items-center justify-start gap-1">
|
||||
{item.href && !isLast ? (
|
||||
<Link href={item.href} className={`${ITEM_CLASS} ${colorClass}`}>
|
||||
{item.name}
|
||||
</Link>
|
||||
) : (
|
||||
<span
|
||||
className={`${ITEM_CLASS} ${colorClass}`}
|
||||
{...(isLast && { "aria-current": "page" })}
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`text-[#9B9B9B] ${ITEM_CLASS} ${isLast ? "hidden" : "flex"}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{">"}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
function HeadPageList({ children, title, detail }) {
|
||||
// titleTag: صفحاتی که h1 خودشان را جای دیگری دارند «p» میفرستند تا صفحه دو h1 نگیرد.
|
||||
// کلاسها مستقل از تگاند، پس ظاهر در هر دو حالت یکسان است.
|
||||
function HeadPageList({ children, title, detail, titleTag: TitleTag = "h1" }) {
|
||||
return (
|
||||
<div className="bg-head-menu !bg-no-repeat !bg-cover !bg-center">
|
||||
<div
|
||||
className=" padding-responsive flex flex-col items-center gap-[25px] pt-[calc(12px_+_75px_+_32px)] sm:pt-[calc(21px_+_75px_+_45px)] md:pt-[calc(30px_+_75px_+_58px)] lg:pt-[calc(40px_+_75px_+_78px)] pb-[62px] sm:pb-[69px] md:pb-[76px] lg:pb-[85px] "
|
||||
>
|
||||
<h1 className="text-[#3B3B3B] text-[16px] sm:text-[19px] md:text-[22px] lg:text-[24px] text-center font-bold">
|
||||
<TitleTag className="text-[#3B3B3B] text-[16px] sm:text-[19px] md:text-[22px] lg:text-[24px] text-center font-bold">
|
||||
{title}
|
||||
</h1>
|
||||
</TitleTag>
|
||||
<p className="text-[#3B3B3B] text-[14px] md:text-[16px] text-center font-normal">
|
||||
{detail}
|
||||
</p>
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import CircularLoading from "@/app/component/loading/Circular";
|
||||
import CustomLoading from "@/app/component/loading/Custom";
|
||||
import TextLoading from "@/app/component/loading/Text";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="padding-responsive pt-[95px] sm:pt-[120px]">
|
||||
<div className="flex flex-col lg:flex-row items-center justify-start gap-[8px] lg:gap-[32px]">
|
||||
<CircularLoading
|
||||
loading
|
||||
width={100}
|
||||
height={100}
|
||||
className="w-[40px] sm:w-[81px] md:w-[123px] lg:w-[164px] h-[40px] sm:h-[81px] md:h-[123px] lg:h-[164px]"
|
||||
/>
|
||||
<div className="flex flex-col items-center lg:items-start gap-[8px] lg:gap-[16px]">
|
||||
<TextLoading loading width={90} height={20} />
|
||||
<TextLoading loading width={100} height={20} />
|
||||
<div className="flex items-center justify-start gap-11">
|
||||
<TextLoading loading width={60} height={20} />
|
||||
<TextLoading loading width={120} height={20} />
|
||||
</div>
|
||||
<CustomLoading loading width={160} height={25} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+48
-11
@@ -3,7 +3,11 @@ 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 { getEntityOrigin } from "@/lib/getCanonicalUrl";
|
||||
import { extractEntityCityId, findCityById } from "@/lib/domainHelpers";
|
||||
import { buildDoctorFaq } from "@/lib/specialtyContent";
|
||||
import { isThinDoctor } from "@/lib/entityQuality";
|
||||
import specialtiesData from "@/data/specialties.json";
|
||||
import { safeJsonLd } from "@/lib/sanitize";
|
||||
import { imageUrl } from "@/helper";
|
||||
|
||||
@@ -77,12 +81,14 @@ export async function generateMetadata({ params }) {
|
||||
`رزرو نوبت آنلاین دکتر ${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";
|
||||
// C1-b — پزشک به دامنهٔ شهر خودش canonical میشود، روی هر دامنهای که سرو شود.
|
||||
const origin = await getEntityOrigin(extractEntityCityId(doctor));
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
...(isUnclaimed && { robots: { index: false, follow: true } }),
|
||||
alternates: { canonical: `${origin}/doctor/${doctor.uuid}` },
|
||||
...(isThinDoctor(doctor) && { robots: { index: false, follow: true } }),
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
@@ -96,20 +102,24 @@ export async function generateMetadata({ params }) {
|
||||
images: [image],
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
} catch (error) {
|
||||
// بازگشت خاموش، canonical و robots صفحه را بیصدا به fallback لایه میبرد — لاگ لازم است.
|
||||
console.error(`[doctor/${slug}] generateMetadata failed:`, error);
|
||||
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();
|
||||
|
||||
// همان مقصد canonical — تا url/@id و breadcrumb سیگنال متناقض ندهند.
|
||||
const origin = await getEntityOrigin(extractEntityCityId(doctor));
|
||||
|
||||
if (doctor) {
|
||||
try {
|
||||
const [resComments, resRate] = await Promise.all([
|
||||
@@ -229,23 +239,43 @@ async function Doctor({ params }) {
|
||||
: null;
|
||||
|
||||
const specialtyLabel = doctor?.specialties?.[0]?.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: `${origin}/doctors?specialty=${encodeURIComponent(specialtyLabel)}`,
|
||||
},
|
||||
{ "@type": "ListItem", position: 2, name: specialtyLabel, item: specialtyItem },
|
||||
{ "@type": "ListItem", position: 3, name: `دکتر ${doctor.name}` },
|
||||
],
|
||||
}
|
||||
: 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 && (
|
||||
@@ -254,6 +284,12 @@ async function Doctor({ params }) {
|
||||
dangerouslySetInnerHTML={{ __html: safeJsonLd(jsonLd) }}
|
||||
/>
|
||||
)}
|
||||
{faqJsonLd && (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: safeJsonLd(faqJsonLd) }}
|
||||
/>
|
||||
)}
|
||||
{breadcrumbJsonLd && (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
@@ -269,6 +305,7 @@ async function Doctor({ params }) {
|
||||
bookableAddressUuids={[...bookableAddressUuids]}
|
||||
bookingLocations={bookingLocations}
|
||||
slug={slug}
|
||||
faq={faq}
|
||||
/>
|
||||
</Layout>
|
||||
</>
|
||||
|
||||
+8
-16
@@ -3,24 +3,14 @@ import Layout from "@/components/layout/StLayout";
|
||||
import { buildDoctorParams } from "@/helper";
|
||||
import { getStateInfo } from "@/lib/getStateInfo";
|
||||
import { fetchReq } from "@/lib/req";
|
||||
|
||||
|
||||
const FILTER_KEYS = ["specialty", "state", "city", "gender", "degree", "active", "sort", "name"];
|
||||
|
||||
// جستجوی داخلی و ترکیب چند فیلتر نباید ایندکس شوند — crawl budget و duplicate
|
||||
function listingRobots(params) {
|
||||
const activeFilters = FILTER_KEYS.filter((key) => params?.[key]);
|
||||
if (params?.name || activeFilters.length >= 2) {
|
||||
return { robots: { index: false, follow: true } };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
import { listingRobots } from "@/lib/listingRobots";
|
||||
|
||||
export async function generateMetadata({ searchParams }) {
|
||||
const awaitedParams = await searchParams;
|
||||
const { matchedCity, matchedState, repContext } = await getStateInfo();
|
||||
const { matchedCity, matchedState, isRoot, repContext } = await getStateInfo();
|
||||
const siteName = matchedCity?.site_name || repContext?.full_name || "نوبت 724";
|
||||
const cityName = matchedCity?.name || matchedState?.name || "";
|
||||
// روی دامنهٔ ریشه نام رکورد («نوبت 724») برند است نه شهر — نباید در Title بنشیند.
|
||||
const cityName = isRoot ? "" : matchedCity?.name || matchedState?.name || "";
|
||||
const title = cityName
|
||||
? `پزشکان ${cityName} | جستجو و رزرو نوبت | ${siteName}`
|
||||
: `جستجوی پزشک و رزرو نوبت آنلاین | ${siteName}`;
|
||||
@@ -31,7 +21,7 @@ export async function generateMetadata({ searchParams }) {
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
...listingRobots(awaitedParams),
|
||||
...listingRobots(awaitedParams, { matchedCity, matchedState }),
|
||||
openGraph: { title, description, images: [image] },
|
||||
twitter: { card: "summary_large_image", title, description, images: [image] },
|
||||
};
|
||||
@@ -47,7 +37,9 @@ async function Doctors({ searchParams }) {
|
||||
const cityParams = awaitedSearchParams.city;
|
||||
|
||||
try {
|
||||
let newSearchParams = awaitedSearchParams;
|
||||
// کپی — awaitedSearchParams همان شیئی است که generateMetadata هم میخواند؛
|
||||
// تزریق city/state روی خودش، سیاست robots را به noindex میبرد.
|
||||
const newSearchParams = { ...awaitedSearchParams };
|
||||
|
||||
// Conditional State — روی دامنهٔ ریشه (nobat724) فیلتر اعمال نمیشود.
|
||||
if (stateParams) newSearchParams.state = stateParams;
|
||||
|
||||
+70
-11
@@ -3,6 +3,9 @@ import { getBaseUrl } from '../utils/sitemap';
|
||||
import citiesData from '@/data/city.json';
|
||||
import statesData from '@/data/state.json';
|
||||
import { isRootCity } from '@/lib/rootCity';
|
||||
import { extractEntityCityId, findDomainByCityId } from '@/lib/domainHelpers';
|
||||
import { isThinClinic, isThinDoctor } from '@/lib/entityQuality';
|
||||
import specialtiesData from '@/data/specialties.json';
|
||||
|
||||
// sitemap باید در زمان اجرا (per-request) روی سرور production تولید شود، نه در build.
|
||||
// در build کانتینر به API دسترسی شبکه ندارد → fetch failed. force-dynamic این را قطعی میکند.
|
||||
@@ -11,8 +14,11 @@ export const revalidate = 3600;
|
||||
|
||||
const MAIN_DOMAIN = 'nobat724.com';
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
const PAGE_LIMIT = 500;
|
||||
const MAX_PAGES = 40;
|
||||
// backend صفحه را به ۵۰ رکورد سقف میزند و limit بزرگتر را نادیده میگیرد.
|
||||
// شرط توقف باید meta.totalPages باشد، نه «کمتر از limit برگشت» — وگرنه حلقه در
|
||||
// همان صفحهٔ اول میشکند و sitemap روی ۵۰ رکورد بریده میشود.
|
||||
const PAGE_LIMIT = 50;
|
||||
const MAX_PAGES = 200;
|
||||
|
||||
function toSafeDate(value) {
|
||||
if (!value) return null;
|
||||
@@ -78,8 +84,13 @@ async function fetchAllPages(path, extraParams = {}) {
|
||||
const items = Array.isArray(raw) ? raw : Array.isArray(raw?.data) ? raw.data : [];
|
||||
if (items.length === 0) break;
|
||||
results.push(...items);
|
||||
const total = json?.meta?.totalRecords;
|
||||
if (items.length < PAGE_LIMIT || (total && results.length >= Number(total))) break;
|
||||
|
||||
const meta = json?.meta ?? raw?.meta;
|
||||
const totalPages = Number(meta?.totalPages) || 0;
|
||||
const totalRecords = Number(meta?.totalRecords) || 0;
|
||||
if (totalPages && page >= totalPages) break;
|
||||
if (totalRecords && results.length >= totalRecords) break;
|
||||
// بدون meta تنها نشانهٔ پایان، صفحهٔ خالی است (در تکرار بعدی).
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[sitemap] failed to fetch ${path} (page skipped):`, error?.message || error);
|
||||
@@ -95,10 +106,33 @@ function cityFilterParams(scope) {
|
||||
return params;
|
||||
}
|
||||
|
||||
// شهرهایی که دامنهٔ اختصاصی دارند — موجودیتهایشان canonical روی همان دامنه دارند و
|
||||
// نباید در sitemap دامنهٔ اصلی تکرار شوند.
|
||||
const CITY_IDS_WITH_DOMAIN = citiesData
|
||||
.filter((c) => !isRootCity(c) && c.domain)
|
||||
.map((c) => c.id);
|
||||
|
||||
/**
|
||||
* پاسخ لیست پزشکان شهر ندارد؛ پس تفکیک با فیلتر سمت API انجام میشود:
|
||||
* روی دامنهٔ شهری با city_id، و روی دامنهٔ اصلی «همه منهای پزشکانِ شهرهای دامنهدار».
|
||||
*/
|
||||
async function getDoctorsForScope(scope) {
|
||||
if (!scope.isRoot) return fetchAllPages('/api/v1/doctors', cityFilterParams(scope));
|
||||
|
||||
const [all, ...perCity] = await Promise.all([
|
||||
fetchAllPages('/api/v1/doctors'),
|
||||
...CITY_IDS_WITH_DOMAIN.map((cityId) =>
|
||||
fetchAllPages('/api/v1/doctors', { city_id: String(cityId) })
|
||||
),
|
||||
]);
|
||||
const ownedByCityDomain = new Set(perCity.flat().map((d) => d?.uuid).filter(Boolean));
|
||||
return all.filter((d) => !ownedByCityDomain.has(d?.uuid));
|
||||
}
|
||||
|
||||
async function getDoctorUrls(baseUrl, scope) {
|
||||
const doctors = await fetchAllPages('/api/v1/doctors', cityFilterParams(scope));
|
||||
const doctors = await getDoctorsForScope(scope);
|
||||
return doctors
|
||||
.filter((d) => d?.uuid && d?.owner_status === 'claimed')
|
||||
.filter((d) => !isThinDoctor(d))
|
||||
.map((d) =>
|
||||
withLastModified(
|
||||
{
|
||||
@@ -114,7 +148,9 @@ async function getDoctorUrls(baseUrl, scope) {
|
||||
async function getClinicUrls(baseUrl, scope) {
|
||||
const clinics = await fetchAllPages('/api/v1/clinics', cityFilterParams(scope));
|
||||
return clinics
|
||||
.filter((c) => c?.uuid)
|
||||
.filter((c) => !isThinClinic(c))
|
||||
// پاسخ لیست کلینیکها شهر دارد — کلینیکِ شهرِ دامنهدار فقط در sitemap همان دامنه.
|
||||
.filter((c) => !scope.isRoot || !findDomainByCityId(extractEntityCityId(c)))
|
||||
.map((c) =>
|
||||
withLastModified(
|
||||
{
|
||||
@@ -127,10 +163,27 @@ async function getClinicUrls(baseUrl, scope) {
|
||||
);
|
||||
}
|
||||
|
||||
async function getBlogUrls(baseUrl) {
|
||||
// صفحات فرود تخصص per-domain — روی هر دامنه «تخصص + آن شهر» است، پس همهجا self.
|
||||
function getSpecialtyUrls(baseUrl) {
|
||||
return specialtiesData
|
||||
.filter((s) => s?.slug && s.status === 1)
|
||||
.map((s) => ({
|
||||
url: `${baseUrl}/specialties/${s.slug}`,
|
||||
changeFrequency: 'weekly',
|
||||
priority: 0.8,
|
||||
}));
|
||||
}
|
||||
|
||||
// هر پست فقط در sitemap دامنهٔ canonical خودش: پست شهریافته → دامنهٔ آن شهر،
|
||||
// پست سراسری (بدون شهر) → دامنهٔ اصلی.
|
||||
async function getBlogUrls(baseUrl, scope, currentDomain) {
|
||||
const blogs = await fetchAllPages('/api/v1/blogs');
|
||||
return blogs
|
||||
.filter((b) => b?.slug || b?.uuid)
|
||||
.filter((b) => {
|
||||
const cityDomain = findDomainByCityId(extractEntityCityId(b));
|
||||
return cityDomain ? cityDomain === currentDomain : scope.isRoot;
|
||||
})
|
||||
.map((b) =>
|
||||
withLastModified(
|
||||
{
|
||||
@@ -148,15 +201,21 @@ export default async function sitemap() {
|
||||
const domain = await getCurrentDomain();
|
||||
const baseUrl = getBaseUrl(domain);
|
||||
const scope = getCityScope(domain);
|
||||
const normalizedDomain = domain.toLowerCase().replace(/^www\./, '').split(':')[0];
|
||||
|
||||
const [doctorUrls, clinicUrls, blogUrls] = await Promise.all([
|
||||
getDoctorUrls(baseUrl, scope),
|
||||
getClinicUrls(baseUrl, scope),
|
||||
// محتوای بلاگ روی همهی دامنهها یکسان است — فقط دامنهی اصلی آن را در sitemap اعلام میکند
|
||||
scope.isRoot ? getBlogUrls(baseUrl) : Promise.resolve([]),
|
||||
getBlogUrls(baseUrl, scope, normalizedDomain),
|
||||
]);
|
||||
|
||||
const allUrls = [...getStaticPages(baseUrl), ...doctorUrls, ...clinicUrls, ...blogUrls];
|
||||
const allUrls = [
|
||||
...getStaticPages(baseUrl),
|
||||
...getSpecialtyUrls(baseUrl),
|
||||
...doctorUrls,
|
||||
...clinicUrls,
|
||||
...blogUrls,
|
||||
];
|
||||
|
||||
const seen = new Set();
|
||||
return allUrls.filter((item) => {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
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";
|
||||
|
||||
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 میآید.
|
||||
async function getDoctors(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 {
|
||||
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";
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
// 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;
|
||||
@@ -3,9 +3,10 @@ import SpecialtiesPage from "@/components/specialties";
|
||||
import { getStateInfo } from "@/lib/getStateInfo";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const { matchedCity } = await getStateInfo();
|
||||
const { matchedCity, isRoot } = await getStateInfo();
|
||||
const siteName = matchedCity?.site_name || "نوبت 724";
|
||||
const cityName = matchedCity?.name || "";
|
||||
// روی دامنهٔ ریشه نام رکورد («نوبت 724») برند است نه شهر — نباید در Title بنشیند.
|
||||
const cityName = isRoot ? "" : matchedCity?.name || "";
|
||||
const title = cityName
|
||||
? `تخصصهای پزشکی در ${cityName} | ${siteName}`
|
||||
: `تخصصهای پزشکی | ${siteName}`;
|
||||
|
||||
Reference in New Issue
Block a user