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:
hamed
2026-07-19 07:52:50 +03:30
parent 36816eded2
commit fd48b48613
48 changed files with 3951 additions and 733 deletions
-23
View File
@@ -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
View File
@@ -4,7 +4,8 @@ import Layout from "@/components/layout/StLayout";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { fetchReq } from "@/lib/req"; import { fetchReq } from "@/lib/req";
import { getStateInfo } from "@/lib/getStateInfo"; 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 { safeJsonLd } from "@/lib/sanitize";
import { normalizeBlog, imageUrl } from "@/helper"; import { normalizeBlog, imageUrl } from "@/helper";
@@ -38,11 +39,14 @@ export async function generateMetadata({ params }) {
const description = rawText.slice(0, 160) || blog.title; const description = rawText.slice(0, 160) || blog.title;
const image = blog.image_url ? imageUrl(blog.image_url) : FALLBACK_IMG; const image = blog.image_url ? imageUrl(blog.image_url) : FALLBACK_IMG;
// C1-b — پست شهریافته به دامنهٔ همان شهر canonical می‌شود؛
// پست سراسری (بدون شهر) روی دامنهٔ جاری self-canonical می‌ماند.
const origin = await getEntityOrigin(extractEntityCityId(blog));
return { return {
title, title,
description, description,
// محتوای بلاگ روی همه‌ی دامنه‌های شهری یکسان است — تجمیع اعتبار روی دامنه‌ی اصلی alternates: { canonical: `${origin}/blog/${slug}` },
alternates: { canonical: `https://nobat724.com/blog/${slug}` },
openGraph: { openGraph: {
title, title,
description, description,
@@ -59,18 +63,22 @@ export async function generateMetadata({ params }) {
images: [image], images: [image],
}, },
}; };
} catch { } catch (error) {
console.error(`[blog/${slug}] generateMetadata failed:`, error);
return {}; return {};
} }
} }
async function Blog({ params }) { async function Blog({ params }) {
const { slug } = await params; const { slug } = await params;
const origin = await getRequestOrigin();
const blog = normalizeBlog(await getBlog(slug)); const blog = normalizeBlog(await getBlog(slug));
if (!blog) notFound(); if (!blog) notFound();
const blogCityId = extractEntityCityId(blog);
const blogCity = findCityById(blogCityId);
const origin = await getEntityOrigin(blogCityId);
let relatedBlogs = []; let relatedBlogs = [];
const relatedResponse = await fetchReq( const relatedResponse = await fetchReq(
`${API_URL}/api/v1/blogs?page=1&limit=6` `${API_URL}/api/v1/blogs?page=1&limit=6`
@@ -90,6 +98,10 @@ async function Blog({ params }) {
datePublished: new Date(blog.created * 1000).toISOString(), datePublished: new Date(blog.created * 1000).toISOString(),
}), }),
...(blog.author && { author: { "@type": "Person", name: blog.author } }), ...(blog.author && { author: { "@type": "Person", name: blog.author } }),
// پست شهریافته حوزهٔ جغرافیایی‌اش را اعلام می‌کند؛ پست سراسری این فیلد را ندارد.
...(blogCity && {
spatialCoverage: { "@type": "Place", name: blogCity.name },
}),
} }
: null; : null;
@@ -120,7 +132,7 @@ async function Blog({ params }) {
/> />
)} )}
<Layout> <Layout>
<BlogPage blog={blog} blogs={relatedBlogs} /> <BlogPage blog={blog} blogs={relatedBlogs} cityName={blogCity?.name} />
</Layout> </Layout>
</> </>
); );
+2 -2
View File
@@ -11,8 +11,8 @@ export async function generateMetadata() {
return { return {
title, title,
description, description,
// مقالات روی همه‌ی دامنه‌ها یکسان‌اند — تجمیع روی دامنه‌ی اصلی // C1-a — لیست بلاگ per-domain است (پست‌های همان شهر + سراسری)؛
alternates: { canonical: "https://nobat724.com/blogs" }, // canonical لایهٔ layout (self روی همان دامنه) درست است.
openGraph: { title, description, images: [image] }, openGraph: { title, description, images: [image] },
twitter: { card: "summary_large_image", title, description, images: [image] }, twitter: { card: "summary_large_image", title, description, images: [image] },
}; };
-18
View File
@@ -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
View File
@@ -4,7 +4,9 @@ import Layout from "@/components/layout/StLayout";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { fetchReq } from "@/lib/req"; import { fetchReq } from "@/lib/req";
import { getStateInfo } from "@/lib/getStateInfo"; 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 { safeJsonLd } from "@/lib/sanitize";
import { imageUrl } from "@/helper"; import { imageUrl } from "@/helper";
@@ -38,9 +40,15 @@ export async function generateMetadata({ params }) {
? [imageUrl(clinic.images_clinic[0].url)] ? [imageUrl(clinic.images_clinic[0].url)]
: ["/assets/images/og-image.png"]; : ["/assets/images/og-image.png"];
// C1-b — کلینیک به دامنهٔ شهر خودش canonical می‌شود.
const origin = await getEntityOrigin(extractEntityCityId(clinic));
return { return {
title, title,
description, description,
alternates: { canonical: `${origin}/clinic/${clinic.uuid}` },
// H7 — کلینیک غیرفعال/بی‌محتوا نباید ایندکس شود (تقارن با پزشک بی‌محتوا)
...(isThinClinic(clinic) && { robots: { index: false, follow: true } }),
openGraph: { openGraph: {
title, title,
description, description,
@@ -54,7 +62,8 @@ export async function generateMetadata({ params }) {
images: image, images: image,
}, },
}; };
} catch { } catch (error) {
console.error(`[clinic/${slug}] generateMetadata failed:`, error);
return {}; return {};
} }
} }
@@ -74,13 +83,15 @@ function computeClinicRating(doctors) {
async function Clinic({ params, searchParams }) { async function Clinic({ params, searchParams }) {
const { slug } = await params; const { slug } = await params;
const origin = await getRequestOrigin();
const sp = await searchParams; const sp = await searchParams;
const page = Number(sp?.page) || 1; const page = Number(sp?.page) || 1;
const limit = 50; const limit = 50;
const clinic = await getClinic(slug); const clinic = await getClinic(slug);
if (!clinic) notFound(); if (!clinic) notFound();
// همان مقصد canonical — تا url/@id و breadcrumb سیگنال متناقض ندهند.
const origin = await getEntityOrigin(extractEntityCityId(clinic));
const reqDoctors = await fetchReq( const reqDoctors = await fetchReq(
`${API_URL}/api/v1/clinic/doctor-list/${slug}?page=${page}&limit=${limit}` `${API_URL}/api/v1/clinic/doctor-list/${slug}?page=${page}&limit=${limit}`
); );
@@ -149,18 +160,6 @@ async function Clinic({ params, searchParams }) {
} }
: null; : 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 ( return (
<> <>
{jsonLd && ( {jsonLd && (
@@ -169,14 +168,15 @@ async function Clinic({ params, searchParams }) {
dangerouslySetInnerHTML={{ __html: safeJsonLd(jsonLd) }} dangerouslySetInnerHTML={{ __html: safeJsonLd(jsonLd) }}
/> />
)} )}
{breadcrumbJsonLd && ( {/* BreadcrumbList از خودِ کامپوننت Pageguide می‌آید — یک منبع داده برای بصری و schema */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: safeJsonLd(breadcrumbJsonLd) }}
/>
)}
<Layout> <Layout>
<ClinicPage data={clinic} doctors={doctors} slug={slug} pages={pagedoctors} /> <ClinicPage
data={clinic}
doctors={doctors}
slug={slug}
pages={pagedoctors}
origin={origin}
/>
</Layout> </Layout>
</> </>
); );
+8 -16
View File
@@ -3,24 +3,14 @@ import ClinicsPage from "@/components/clinics";
import Layout from "@/components/layout/StLayout"; import Layout from "@/components/layout/StLayout";
import { getStateInfo } from "@/lib/getStateInfo"; import { getStateInfo } from "@/lib/getStateInfo";
import { buildClinicParams } from "@/helper"; import { buildClinicParams } from "@/helper";
import { listingRobots } from "@/lib/listingRobots";
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 {};
}
export async function generateMetadata({ searchParams }) { export async function generateMetadata({ searchParams }) {
const awaitedParams = await 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 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 const title = cityName
? `کلینیک‌های ${cityName} | جستجو و رزرو نوبت | ${siteName}` ? `کلینیک‌های ${cityName} | جستجو و رزرو نوبت | ${siteName}`
: `جستجوی کلینیک و مراکز درمانی | ${siteName}`; : `جستجوی کلینیک و مراکز درمانی | ${siteName}`;
@@ -31,7 +21,7 @@ export async function generateMetadata({ searchParams }) {
return { return {
title, title,
description, description,
...listingRobots(awaitedParams), ...listingRobots(awaitedParams, { matchedCity, matchedState }),
openGraph: { title, description, images: [image] }, openGraph: { title, description, images: [image] },
twitter: { card: "summary_large_image", 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; let clinics;
try { try {
// Params // Params
let newSearchParams = awaitedSearchParams; // کپی — awaitedSearchParams همان شیئی است که generateMetadata هم می‌خواند؛
// تزریق city/state روی خودش، سیاست robots را به noindex می‌برد.
const newSearchParams = { ...awaitedSearchParams };
// Conditional State — روی دامنهٔ ریشه (nobat724) فیلتر اعمال نمی‌شود. // Conditional State — روی دامنهٔ ریشه (nobat724) فیلتر اعمال نمی‌شود.
if (stateParams) newSearchParams.state = stateParams; if (stateParams) newSearchParams.state = stateParams;
+62 -11
View File
@@ -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 (
<>
<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 ( 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"> <li key={idx} className="flex items-center justify-start gap-1">
<h2 {item.href && !isLast ? (
className={`text-[16px] font-normal ${ list.length > idx + 1 ? "text-[#9B9B9B]" : "text-[#525252]" } `} <Link href={item.href} className={`${ITEM_CLASS} ${colorClass}`}>
> {item.name}
{item} </Link>
</h2> ) : (
<span <span
className={` text-[#9B9B9B] text-[16px] font-normal ${list.length > idx + 1 ? "flex" : "hidden"} `} className={`${ITEM_CLASS} ${colorClass}`}
{...(isLast && { "aria-current": "page" })}
>
{item.name}
</span>
)}
<span
className={`text-[#9B9B9B] ${ITEM_CLASS} ${isLast ? "hidden" : "flex"}`}
aria-hidden="true"
> >
{">"} {">"}
</span> </span>
</li> </li>
))} );
</ul> })}
</ol>
</nav>
</>
); );
} }
+5 -3
View File
@@ -1,12 +1,14 @@
function HeadPageList({ children, title, detail }) { // titleTag: صفحاتی که h1 خودشان را جای دیگری دارند «p» می‌فرستند تا صفحه دو h1 نگیرد.
// کلاس‌ها مستقل از تگ‌اند، پس ظاهر در هر دو حالت یکسان است.
function HeadPageList({ children, title, detail, titleTag: TitleTag = "h1" }) {
return ( return (
<div className="bg-head-menu !bg-no-repeat !bg-cover !bg-center"> <div className="bg-head-menu !bg-no-repeat !bg-cover !bg-center">
<div <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] " 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} {title}
</h1> </TitleTag>
<p className="text-[#3B3B3B] text-[14px] md:text-[16px] text-center font-normal"> <p className="text-[#3B3B3B] text-[14px] md:text-[16px] text-center font-normal">
{detail} {detail}
</p> </p>
-27
View File
@@ -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
View File
@@ -3,7 +3,11 @@ import DoctorPage from "@/components/doctor";
import Layout from "@/components/layout/StLayout"; import Layout from "@/components/layout/StLayout";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getStateInfo } from "@/lib/getStateInfo"; 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 { safeJsonLd } from "@/lib/sanitize";
import { imageUrl } from "@/helper"; import { imageUrl } from "@/helper";
@@ -77,12 +81,14 @@ export async function generateMetadata({ params }) {
`رزرو نوبت آنلاین دکتر ${doctor.name}${specialtyNames ? " متخصص " + specialtyNames : ""}${doctor.address ? " | " + doctor.address : ""}`.trim() `رزرو نوبت آنلاین دکتر ${doctor.name}${specialtyNames ? " متخصص " + specialtyNames : ""}${doctor.address ? " | " + doctor.address : ""}`.trim()
).slice(0, 160); ).slice(0, 160);
const image = imageUrl(doctor.img?.[0]?.url) || FALLBACK_IMG; 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 { return {
title, title,
description, description,
...(isUnclaimed && { robots: { index: false, follow: true } }), alternates: { canonical: `${origin}/doctor/${doctor.uuid}` },
...(isThinDoctor(doctor) && { robots: { index: false, follow: true } }),
openGraph: { openGraph: {
title, title,
description, description,
@@ -96,20 +102,24 @@ export async function generateMetadata({ params }) {
images: [image], images: [image],
}, },
}; };
} catch { } catch (error) {
// بازگشت خاموش، canonical و robots صفحه را بی‌صدا به fallback لایه می‌برد — لاگ لازم است.
console.error(`[doctor/${slug}] generateMetadata failed:`, error);
return {}; return {};
} }
} }
async function Doctor({ params }) { async function Doctor({ params }) {
const { slug } = await params; const { slug } = await params;
const origin = await getRequestOrigin();
let comments = null; let comments = null;
let rateAggregate = { point: 0, satisfaction: 0, averages: [] }; let rateAggregate = { point: 0, satisfaction: 0, averages: [] };
const doctor = await getDoctor(slug); const doctor = await getDoctor(slug);
if (!doctor) notFound(); if (!doctor) notFound();
// همان مقصد canonical — تا url/@id و breadcrumb سیگنال متناقض ندهند.
const origin = await getEntityOrigin(extractEntityCityId(doctor));
if (doctor) { if (doctor) {
try { try {
const [resComments, resRate] = await Promise.all([ const [resComments, resRate] = await Promise.all([
@@ -229,23 +239,43 @@ async function Doctor({ params }) {
: null; : null;
const specialtyLabel = doctor?.specialties?.[0]?.name || "پزشکان"; 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 const breadcrumbJsonLd = doctor
? { ? {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "BreadcrumbList", "@type": "BreadcrumbList",
itemListElement: [ itemListElement: [
{ "@type": "ListItem", position: 1, name: "خانه", item: origin }, { "@type": "ListItem", position: 1, name: "خانه", item: origin },
{ { "@type": "ListItem", position: 2, name: specialtyLabel, item: specialtyItem },
"@type": "ListItem",
position: 2,
name: specialtyLabel,
item: `${origin}/doctors?specialty=${encodeURIComponent(specialtyLabel)}`,
},
{ "@type": "ListItem", position: 3, name: `دکتر ${doctor.name}` }, { "@type": "ListItem", position: 3, name: `دکتر ${doctor.name}` },
], ],
} }
: null; : 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 ( return (
<> <>
{jsonLd && ( {jsonLd && (
@@ -254,6 +284,12 @@ async function Doctor({ params }) {
dangerouslySetInnerHTML={{ __html: safeJsonLd(jsonLd) }} dangerouslySetInnerHTML={{ __html: safeJsonLd(jsonLd) }}
/> />
)} )}
{faqJsonLd && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: safeJsonLd(faqJsonLd) }}
/>
)}
{breadcrumbJsonLd && ( {breadcrumbJsonLd && (
<script <script
type="application/ld+json" type="application/ld+json"
@@ -269,6 +305,7 @@ async function Doctor({ params }) {
bookableAddressUuids={[...bookableAddressUuids]} bookableAddressUuids={[...bookableAddressUuids]}
bookingLocations={bookingLocations} bookingLocations={bookingLocations}
slug={slug} slug={slug}
faq={faq}
/> />
</Layout> </Layout>
</> </>
+8 -16
View File
@@ -3,24 +3,14 @@ import Layout from "@/components/layout/StLayout";
import { buildDoctorParams } from "@/helper"; import { buildDoctorParams } from "@/helper";
import { getStateInfo } from "@/lib/getStateInfo"; import { getStateInfo } from "@/lib/getStateInfo";
import { fetchReq } from "@/lib/req"; import { fetchReq } from "@/lib/req";
import { listingRobots } from "@/lib/listingRobots";
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 {};
}
export async function generateMetadata({ searchParams }) { export async function generateMetadata({ searchParams }) {
const awaitedParams = await 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 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 const title = cityName
? `پزشکان ${cityName} | جستجو و رزرو نوبت | ${siteName}` ? `پزشکان ${cityName} | جستجو و رزرو نوبت | ${siteName}`
: `جستجوی پزشک و رزرو نوبت آنلاین | ${siteName}`; : `جستجوی پزشک و رزرو نوبت آنلاین | ${siteName}`;
@@ -31,7 +21,7 @@ export async function generateMetadata({ searchParams }) {
return { return {
title, title,
description, description,
...listingRobots(awaitedParams), ...listingRobots(awaitedParams, { matchedCity, matchedState }),
openGraph: { title, description, images: [image] }, openGraph: { title, description, images: [image] },
twitter: { card: "summary_large_image", 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; const cityParams = awaitedSearchParams.city;
try { try {
let newSearchParams = awaitedSearchParams; // کپی — awaitedSearchParams همان شیئی است که generateMetadata هم می‌خواند؛
// تزریق city/state روی خودش، سیاست robots را به noindex می‌برد.
const newSearchParams = { ...awaitedSearchParams };
// Conditional State — روی دامنهٔ ریشه (nobat724) فیلتر اعمال نمی‌شود. // Conditional State — روی دامنهٔ ریشه (nobat724) فیلتر اعمال نمی‌شود.
if (stateParams) newSearchParams.state = stateParams; if (stateParams) newSearchParams.state = stateParams;
+70 -11
View File
@@ -3,6 +3,9 @@ import { getBaseUrl } from '../utils/sitemap';
import citiesData from '@/data/city.json'; import citiesData from '@/data/city.json';
import statesData from '@/data/state.json'; import statesData from '@/data/state.json';
import { isRootCity } from '@/lib/rootCity'; 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. // sitemap باید در زمان اجرا (per-request) روی سرور production تولید شود، نه در build.
// در build کانتینر به API دسترسی شبکه ندارد → fetch failed. force-dynamic این را قطعی می‌کند. // در build کانتینر به API دسترسی شبکه ندارد → fetch failed. force-dynamic این را قطعی می‌کند.
@@ -11,8 +14,11 @@ export const revalidate = 3600;
const MAIN_DOMAIN = 'nobat724.com'; const MAIN_DOMAIN = 'nobat724.com';
const API_URL = process.env.NEXT_PUBLIC_API_URL; const API_URL = process.env.NEXT_PUBLIC_API_URL;
const PAGE_LIMIT = 500; // backend صفحه را به ۵۰ رکورد سقف می‌زند و limit بزرگ‌تر را نادیده می‌گیرد.
const MAX_PAGES = 40; // شرط توقف باید meta.totalPages باشد، نه «کمتر از limit برگشت» — وگرنه حلقه در
// همان صفحهٔ اول می‌شکند و sitemap روی ۵۰ رکورد بریده می‌شود.
const PAGE_LIMIT = 50;
const MAX_PAGES = 200;
function toSafeDate(value) { function toSafeDate(value) {
if (!value) return null; 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 : []; const items = Array.isArray(raw) ? raw : Array.isArray(raw?.data) ? raw.data : [];
if (items.length === 0) break; if (items.length === 0) break;
results.push(...items); 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) { } catch (error) {
console.error(`[sitemap] failed to fetch ${path} (page skipped):`, error?.message || error); console.error(`[sitemap] failed to fetch ${path} (page skipped):`, error?.message || error);
@@ -95,10 +106,33 @@ function cityFilterParams(scope) {
return params; 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) { async function getDoctorUrls(baseUrl, scope) {
const doctors = await fetchAllPages('/api/v1/doctors', cityFilterParams(scope)); const doctors = await getDoctorsForScope(scope);
return doctors return doctors
.filter((d) => d?.uuid && d?.owner_status === 'claimed') .filter((d) => !isThinDoctor(d))
.map((d) => .map((d) =>
withLastModified( withLastModified(
{ {
@@ -114,7 +148,9 @@ async function getDoctorUrls(baseUrl, scope) {
async function getClinicUrls(baseUrl, scope) { async function getClinicUrls(baseUrl, scope) {
const clinics = await fetchAllPages('/api/v1/clinics', cityFilterParams(scope)); const clinics = await fetchAllPages('/api/v1/clinics', cityFilterParams(scope));
return clinics return clinics
.filter((c) => c?.uuid) .filter((c) => !isThinClinic(c))
// پاسخ لیست کلینیک‌ها شهر دارد — کلینیکِ شهرِ دامنه‌دار فقط در sitemap همان دامنه.
.filter((c) => !scope.isRoot || !findDomainByCityId(extractEntityCityId(c)))
.map((c) => .map((c) =>
withLastModified( 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'); const blogs = await fetchAllPages('/api/v1/blogs');
return blogs return blogs
.filter((b) => b?.slug || b?.uuid) .filter((b) => b?.slug || b?.uuid)
.filter((b) => {
const cityDomain = findDomainByCityId(extractEntityCityId(b));
return cityDomain ? cityDomain === currentDomain : scope.isRoot;
})
.map((b) => .map((b) =>
withLastModified( withLastModified(
{ {
@@ -148,15 +201,21 @@ export default async function sitemap() {
const domain = await getCurrentDomain(); const domain = await getCurrentDomain();
const baseUrl = getBaseUrl(domain); const baseUrl = getBaseUrl(domain);
const scope = getCityScope(domain); const scope = getCityScope(domain);
const normalizedDomain = domain.toLowerCase().replace(/^www\./, '').split(':')[0];
const [doctorUrls, clinicUrls, blogUrls] = await Promise.all([ const [doctorUrls, clinicUrls, blogUrls] = await Promise.all([
getDoctorUrls(baseUrl, scope), getDoctorUrls(baseUrl, scope),
getClinicUrls(baseUrl, scope), getClinicUrls(baseUrl, scope),
// محتوای بلاگ روی همه‌ی دامنه‌ها یکسان است — فقط دامنه‌ی اصلی آن را در sitemap اعلام می‌کند getBlogUrls(baseUrl, scope, normalizedDomain),
scope.isRoot ? getBlogUrls(baseUrl) : Promise.resolve([]),
]); ]);
const allUrls = [...getStaticPages(baseUrl), ...doctorUrls, ...clinicUrls, ...blogUrls]; const allUrls = [
...getStaticPages(baseUrl),
...getSpecialtyUrls(baseUrl),
...doctorUrls,
...clinicUrls,
...blogUrls,
];
const seen = new Set(); const seen = new Set();
return allUrls.filter((item) => { return allUrls.filter((item) => {
+135
View File
@@ -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 -2
View File
@@ -3,9 +3,10 @@ import SpecialtiesPage from "@/components/specialties";
import { getStateInfo } from "@/lib/getStateInfo"; import { getStateInfo } from "@/lib/getStateInfo";
export async function generateMetadata() { export async function generateMetadata() {
const { matchedCity } = await getStateInfo(); const { matchedCity, isRoot } = await getStateInfo();
const siteName = matchedCity?.site_name || "نوبت 724"; const siteName = matchedCity?.site_name || "نوبت 724";
const cityName = matchedCity?.name || ""; // روی دامنهٔ ریشه نام رکورد («نوبت 724») برند است نه شهر — نباید در Title بنشیند.
const cityName = isRoot ? "" : matchedCity?.name || "";
const title = cityName const title = cityName
? `تخصص‌های پزشکی در ${cityName} | ${siteName}` ? `تخصص‌های پزشکی در ${cityName} | ${siteName}`
: `تخصص‌های پزشکی | ${siteName}`; : `تخصص‌های پزشکی | ${siteName}`;
+13 -1
View File
@@ -1,7 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { convertTimestampToJalali } from "@/helper"; import { convertTimestampToJalali } from "@/helper";
function Head({ data }) { function Head({ data, cityName }) {
const firstTag = data?.tag?.[0]; const firstTag = data?.tag?.[0];
const createdDate = data?.created ? convertTimestampToJalali(data.created) : ""; const createdDate = data?.created ? convertTimestampToJalali(data.created) : "";
@@ -34,6 +34,18 @@ function Head({ data }) {
</h1> </h1>
</div> </div>
<div className="flex mt-[16px] sm:mt-[18px] md:mt-[21px] lg:mt-[24px] items-center justify-start gap-[35px]"> <div className="flex mt-[16px] sm:mt-[18px] md:mt-[21px] lg:mt-[24px] items-center justify-start gap-[35px]">
{/* شهر پست در HTML قابل‌مشاهده — نه فقط در متادیتا. برچسب با همان
تایپوگرافی «نویسنده/تاریخ» تا با بقیهٔ سربرگ هم‌خانواده بماند. */}
{cityName && (
<div className="flex items-center justify-start gap-1">
<p className="text-[#9B9B9B] text-[11px] md:text-[13px] lg:text-[14px] font-normal">
مخصوص شهر:
</p>
<p className="text-[#9B9B9B] text-[11px] md:text-[14px] lg:text-[16px] font-medium">
{cityName}
</p>
</div>
)}
{data?.author && ( {data?.author && (
<div className="flex items-center justify-start gap-1"> <div className="flex items-center justify-start gap-1">
<p className="text-[#9B9B9B] text-[11px] md:text-[13px] lg:text-[14px] font-normal"> <p className="text-[#9B9B9B] text-[11px] md:text-[13px] lg:text-[14px] font-normal">
+2 -2
View File
@@ -2,10 +2,10 @@ import Detail from "./detail";
import Head from "./head"; import Head from "./head";
import RelatedContent from "./relatedContent"; import RelatedContent from "./relatedContent";
function BlogPage({ blog, blogs }) { function BlogPage({ blog, blogs, cityName }) {
return ( return (
<div className="padding-responsive pt-[84px] sm:pt-[110px] md:pt-[140px] lg:pt-[168px]"> <div className="padding-responsive pt-[84px] sm:pt-[110px] md:pt-[140px] lg:pt-[168px]">
<Head data={blog} /> <Head data={blog} cityName={cityName} />
<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="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]">
<Detail data={blog} /> <Detail data={blog} />
<RelatedContent data={blogs} /> <RelatedContent data={blogs} />
@@ -1,11 +1,12 @@
import TextLoading from "@/app/component/loading/Text"; import TextLoading from "@/app/component/loading/Text";
import React from "react"; import React from "react";
function ContentDetail({ head, children }) { // عنوان بخش‌ها h2 است؛ فقط نام خود کلینیک h1 می‌گیرد (as="h1"). کلاس‌ها یکسان — بدون تغییر بصری.
function ContentDetail({ head, children, as: Heading = "h2" }) {
return ( return (
<div className="my-[24px]"> <div className="my-[24px]">
<TextLoading width={200} height={20}> <TextLoading width={200} height={20}>
<h1 className="text-[#3B3B3B] text-[20px] font-bold">{head}</h1> <Heading className="text-[#3B3B3B] text-[20px] font-bold">{head}</Heading>
</TextLoading> </TextLoading>
<div className="mt-[16px]">{children}</div> <div className="mt-[16px]">{children}</div>
</div> </div>
+1 -1
View File
@@ -9,7 +9,7 @@ function About({ data }) {
return ( return (
<div className="opacity-page transition-all duration-500"> <div className="opacity-page transition-all duration-500">
<Images data={data} /> <Images data={data} />
<ContentDetail head={data.title}> <ContentDetail head={data.title} as="h1">
<TextDetail data={data} /> <TextDetail data={data} />
</ContentDetail> </ContentDetail>
<ContentDetail head="بیمه های طرف قرارداد"> <ContentDetail head="بیمه های طرف قرارداد">
+12 -1
View File
@@ -6,10 +6,11 @@ import About from "./components/about";
import Contact from "./components/contact"; import Contact from "./components/contact";
import Doctors from "./components/doctors"; import Doctors from "./components/doctors";
import Tabs from "@/app/component/Tabs"; import Tabs from "@/app/component/Tabs";
import Pageguide from "@/app/component/Pageguide";
const listTab = ["درباره کلینیک", "اطلاعات تماس", "پزشک ها"]; const listTab = ["درباره کلینیک", "اطلاعات تماس", "پزشک ها"];
function ClinicPage({ data, doctors,slug ,pages}) { function ClinicPage({ data, doctors, slug, pages, origin }) {
const [value, setValue] = useState(0); const [value, setValue] = useState(0);
const handleChange = (_, newValue) => setValue(newValue); const handleChange = (_, newValue) => setValue(newValue);
@@ -22,6 +23,16 @@ function ClinicPage({ data, doctors,slug ,pages}) {
return ( return (
<div className="pt-[40px] sm:pt-[70px] md:pt-[100px] lg:pt-[136px] mt-[40px] padding-responsive"> <div className="pt-[40px] sm:pt-[70px] md:pt-[100px] lg:pt-[136px] mt-[40px] padding-responsive">
<div className="mb-[20px]">
<Pageguide
origin={origin}
list={[
{ name: "خانه", href: "/" },
{ name: "کلینیک ها", href: "/clinics" },
{ name: data?.title },
]}
/>
</div>
<Tabs <Tabs
style={{ style={{
".MuiTabs-indicator": { ".MuiTabs-indicator": {
+8 -1
View File
@@ -12,6 +12,8 @@ import { useSearchParams } from "next/navigation";
import states from "@/data/state.json"; import states from "@/data/state.json";
import cities from "@/data/city.json"; import cities from "@/data/city.json";
import { isRootCity } from "@/lib/rootCity"; import { isRootCity } from "@/lib/rootCity";
import { resolveCityDisplayName } from "@/lib/domainHelpers";
import { buildClinicsIntro } from "@/lib/listingIntro";
function ClinicsPage({ clinics, matchedCity, matchedState }) { function ClinicsPage({ clinics, matchedCity, matchedState }) {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -35,7 +37,7 @@ function ClinicsPage({ clinics, matchedCity, matchedState }) {
return ( return (
<div> <div>
<HeadPageList <HeadPageList
title="لیست کلینیک ها و درمانگاه های تخصصی" title={`کلینیکها و مراکز درمانی ${resolveCityDisplayName(matchedCity)}`}
detail="در قسمت زیر می توانید کلینیک مورد نظر خود را جستجو نمایید" detail="در قسمت زیر می توانید کلینیک مورد نظر خود را جستجو نمایید"
> >
<SearchBar <SearchBar
@@ -48,6 +50,11 @@ function ClinicsPage({ clinics, matchedCity, matchedState }) {
setFilteredClinics={setFilteredClinics} setFilteredClinics={setFilteredClinics}
/> />
</HeadPageList> </HeadPageList>
<div className="padding-responsive pt-[24px]">
<p className="text-[#525252] text-[14px] md:text-[16px] font-normal leading-8 text-justify">
{buildClinicsIntro(resolveCityDisplayName(matchedCity))}
</p>
</div>
<List <List
limit={12} limit={12}
page={page} page={page}
+5 -1
View File
@@ -15,7 +15,11 @@ function List({
selected, selected,
setFilteredClinics, setFilteredClinics,
}) { }) {
const listPage = ["کلینیک ها", selected?.city?.name || ""]; const listPage = [
{ name: "خانه", href: "/" },
{ name: "کلینیک ها", href: "/clinics" },
{ name: selected?.city?.name || "" },
];
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const router = useRouter(); const router = useRouter();
+11 -5
View File
@@ -4,8 +4,16 @@ import ClaimProfileSection from "./claim";
import DetailDoctor from "./detailDoctor"; import DetailDoctor from "./detailDoctor";
import Share from "./detailDoctor/Share"; import Share from "./detailDoctor/Share";
import CustomLoading from "@/app/component/loading/Custom"; import CustomLoading from "@/app/component/loading/Custom";
import Faq from "@/components/specialties/detail/Faq";
import specialtiesData from "@/data/specialties.json";
function DoctorPage({ doctor, comments, rateAggregate, addresses, bookableAddressUuids = [], bookingLocations = [], slug }) { // مقصد تمیز صفحهٔ فرود تخصص؛ اگر تخصص slug نداشت، لیست پزشکان با کوئری قدیمی.
const specialtyHref = (name) => {
const slug = specialtiesData.find((s) => s.name === name && s.status === 1)?.slug;
return slug ? `/specialties/${slug}` : `/doctors?specialty=${encodeURIComponent(name)}`;
};
function DoctorPage({ doctor, comments, rateAggregate, addresses, bookableAddressUuids = [], bookingLocations = [], slug, faq = [] }) {
return ( return (
<div className="pt-[92px] sm:pt-[120px] mt:pt-[148px] lg:pt-[176px] mt-[px] padding-responsive"> <div className="pt-[92px] sm:pt-[120px] mt:pt-[148px] lg:pt-[176px] mt-[px] padding-responsive">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -14,10 +22,7 @@ function DoctorPage({ doctor, comments, rateAggregate, addresses, bookableAddres
<h2 className="text-[#9B9B9B] text-nowrap flex items-center"> <h2 className="text-[#9B9B9B] text-nowrap flex items-center">
{doctor?.specialties?.map((item, idx) => ( {doctor?.specialties?.map((item, idx) => (
<span key={item.id ?? item.name} className="flex items-center"> <span key={item.id ?? item.name} className="flex items-center">
<Link <Link href={specialtyHref(item.name)} className="hover:text-[#525252]">
href={`/doctors?specialty=${encodeURIComponent(item.name)}`}
className="hover:text-[#525252]"
>
{item.name} {item.name}
</Link> </Link>
<span className="mx-1"> <span className="mx-1">
@@ -44,6 +49,7 @@ function DoctorPage({ doctor, comments, rateAggregate, addresses, bookableAddres
<AppointmentList doctor={doctor} doctorSlug={slug} bookingLocations={bookingLocations} /> <AppointmentList doctor={doctor} doctorSlug={slug} bookingLocations={bookingLocations} />
</div> </div>
<ClaimProfileSection doctor={doctor} /> <ClaimProfileSection doctor={doctor} />
<Faq items={faq} />
</div> </div>
); );
} }
+3 -2
View File
@@ -20,12 +20,13 @@ function FilterLocate({ filter, sendReq, setFilter, setDataInURL }) {
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
> >
<LocationD /> <LocationD />
<h1 className="text-[#525252] text-[16px] font-semibold"> {/* متن دکمهٔ انتخاب موقعیت، عنوان صفحه نیست — تگ عوض شد، کلاس‌ها دست‌نخورده */}
<span className="text-[#525252] text-[16px] font-semibold">
{" "} {" "}
{filter?.state?.name || "استان"} {filter?.state?.name || "استان"}
{" / "} {" / "}
{filter?.city?.name || "شهر"} {filter?.city?.name || "شهر"}
</h1> </span>
</Button> </Button>
</ModalSearchCity> </ModalSearchCity>
); );
+9
View File
@@ -8,6 +8,8 @@ import specialties from "@/data/specialties.json";
import states from "@/data/state.json"; import states from "@/data/state.json";
import cities from "@/data/city.json"; import cities from "@/data/city.json";
import { isRootCity } from "@/lib/rootCity"; import { isRootCity } from "@/lib/rootCity";
import { resolveCityDisplayName } from "@/lib/domainHelpers";
import { buildDoctorsIntro } from "@/lib/listingIntro";
function DoctorsPage({ matchedCity, matchedState, list }) { function DoctorsPage({ matchedCity, matchedState, list }) {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -50,6 +52,13 @@ function DoctorsPage({ matchedCity, matchedState, list }) {
return ( return (
<div className="pt-[95px] sm:pt-[120px] padding-responsive"> <div className="pt-[95px] sm:pt-[120px] padding-responsive">
{/* h1 واقعی صفحه — تایپوگرافی همان عنوان صفحات لیست (HeadPageList) */}
<h1 className="text-[#3B3B3B] text-[16px] sm:text-[19px] md:text-[22px] lg:text-[24px] text-center font-bold">
پزشکان و متخصصان {resolveCityDisplayName(matchedCity)} | رزرو نوبت آنلاین
</h1>
<p className="text-[#525252] text-[14px] md:text-[16px] font-normal leading-8 mt-[16px] text-justify">
{buildDoctorsIntro(resolveCityDisplayName(matchedCity))}
</p>
<Head <Head
limit={12} limit={12}
filter={filter} filter={filter}
+7 -4
View File
@@ -7,10 +7,13 @@ async function TextHeader() {
return ( return (
<div className="z-[50]"> <div className="z-[50]">
<h1 className="text-[#F17732] text-center text-[20px] sm:text-[24px] md:text-[28px] lg:text-[32px] font-bold"> {/* h1 صفحهٔ اصلی روی شعار می‌نشیند، نه روی خط برند: شعار هر شهر از city.json
کیوردمحور و شهرمحور است («پزشک یاسوج، آنلاین کنار شماست») ولی «با نوبت 724»
فقط برند است. تگ‌ها جابه‌جا شدند و کلاس‌ها عیناً حفظ — ظاهر تغییر نکرده. */}
<p className="text-[#F17732] text-center text-[20px] sm:text-[24px] md:text-[28px] lg:text-[32px] font-bold">
با {matchedCity?.site_name} با {matchedCity?.site_name}
</h1> </p>
<h2 <h1
className="text-[#525252] text-center mt-2.5 text-[16px] sm:text-[25px] md:text-[31px] lg:text-[36px] font-bold flex items-center gap-0.5 sm:gap-1 md:gap-2" className="text-[#525252] text-center mt-2.5 text-[16px] sm:text-[25px] md:text-[31px] lg:text-[36px] font-bold flex items-center gap-0.5 sm:gap-1 md:gap-2"
> >
<div className="max-w-[18px] sm:max-w-[22px]"> <div className="max-w-[18px] sm:max-w-[22px]">
@@ -20,7 +23,7 @@ async function TextHeader() {
<div className="max-w-[18px] sm:max-w-[22px]"> <div className="max-w-[18px] sm:max-w-[22px]">
<AText /> <AText />
</div> </div>
</h2> </h1>
</div> </div>
); );
} }
@@ -0,0 +1,33 @@
"use client";
import { useState } from "react";
import ItemDoctor from "@/app/component/ItemDoctor";
// همان گرید و همان کارت پزشکِ /doctors — فقط بدون ماشین فیلتر، چون این صفحه
// از قبل روی «تخصص + شهر» فیلتر شده و لیستش SSR می‌آید.
function DoctorGrid({ doctors: initialDoctors = [] }) {
const [doctors, setDoctors] = useState(initialDoctors);
if (!doctors.length) {
return (
<p className="text-center py-10 text-gray-500">
دکتری برای نمایش وجود ندارد.
</p>
);
}
return (
<ul className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-[24px]">
{doctors.map((doctor, idx) => (
<ItemDoctor
key={doctor.id}
doctor={doctor}
priority={idx < 3}
setDoctors={setDoctors}
/>
))}
</ul>
);
}
export default DoctorGrid;
+30
View File
@@ -0,0 +1,30 @@
// بلوک سوالات متداول — همان منبع دادهٔ FAQPage schema، تا متن دیده‌شده و schema واگرا نشوند.
// استایل با کارت‌های موجود سایت هم‌خانواده است (bg سفید، rounded-lg، border روشن).
function Faq({ items = [] }) {
if (!items.length) return null;
return (
<section className="mt-[48px]">
<h2 className="text-[#3B3B3B] text-[16px] sm:text-[19px] md:text-[22px] lg:text-[24px] font-bold mb-[24px]">
سوالات متداول
</h2>
<div className="flex flex-col gap-[16px]">
{items.map((item) => (
<details
key={item.question}
className="p-4 rounded-lg bg-[#FFF] border border-[#EFEFEF]"
>
<summary className="text-[#3B3B3B] text-[16px] font-bold cursor-pointer">
{item.question}
</summary>
<p className="text-[#525252] text-[14px] md:text-[16px] font-normal mt-[12px] leading-8">
{item.answer}
</p>
</details>
))}
</div>
</section>
);
}
export default Faq;
+36
View File
@@ -0,0 +1,36 @@
import Pageguide from "@/app/component/Pageguide";
import DoctorGrid from "./DoctorGrid";
import Faq from "./Faq";
// چیدمان عمداً هم‌خانوادهٔ /doctors است: همان padding-responsive، همان فاصلهٔ بالای صفحه،
// همان تایپوگرافی عنوان و همان گرید کارت پزشک — کاربر نباید حس کند وارد بخش دیگری شده.
function SpecialtyDetailPage({ specialtyName, cityName, doctors, intro, faq, origin }) {
return (
<div className="pt-[95px] sm:pt-[120px] padding-responsive">
<Pageguide
origin={origin}
list={[
{ name: "خانه", href: "/" },
{ name: "تخصص‌ها", href: "/specialties" },
{ name: specialtyName },
]}
/>
<h1 className="text-[#3B3B3B] text-[16px] sm:text-[19px] md:text-[22px] lg:text-[24px] text-center font-bold mt-[24px]">
متخصص {specialtyName} در {cityName}
</h1>
<p className="text-[#525252] text-[14px] md:text-[16px] font-normal leading-8 mt-[16px] text-justify">
{intro}
</p>
<div className="mt-[24px] sm:mt-[37px] md:mt-[50px] lg:mt-[64px]">
<DoctorGrid doctors={doctors} />
</div>
<Faq items={faq} />
</div>
);
}
export default SpecialtyDetailPage;
+1
View File
@@ -29,6 +29,7 @@ function SpecialtiesPage({ cityId, cityName, stateName }) {
return ( return (
<> <>
<HeadPageList <HeadPageList
titleTag="p"
title="تخصص مورد نظر شما چیست؟" title="تخصص مورد نظر شما چیست؟"
detail="تخصص مورد نظرتان را جستجو کرده و یا از لیست زیر انتخاب نمایید:" detail="تخصص مورد نظرتان را جستجو کرده و یا از لیست زیر انتخاب نمایید:"
> >
+1 -1
View File
@@ -7,7 +7,7 @@ function List({ specialties = [], cityName, stateName }) {
<div className="flex items-center justify-center gap-[12px] pt-[24px] mb-[40px]"> <div className="flex items-center justify-center gap-[12px] pt-[24px] mb-[40px]">
<MenuS /> <MenuS />
<h1 className="text-[#3B3B3B] text-[16px] sm:text-[19px] md:text-[22px] lg:text-[24px] font-bold"> <h1 className="text-[#3B3B3B] text-[16px] sm:text-[19px] md:text-[22px] lg:text-[24px] font-bold">
لیست تخصص ها لیست تخصصهای پزشکی در {cityName || "ایران"}
</h1> </h1>
</div> </div>
{specialties.length === 0 ? ( {specialties.length === 0 ? (
+9 -1
View File
@@ -368,11 +368,19 @@
"366": "Community 366", "366": "Community 366",
"367": "Community 367", "367": "Community 367",
"368": "Community 368", "368": "Community 368",
"369": "Community 369",
"370": "Community 370",
"371": "Community 371",
"372": "Community 372",
"373": "Community 373", "373": "Community 373",
"374": "Community 374", "374": "Community 374",
"375": "Community 375", "375": "Community 375",
"376": "Community 376", "376": "Community 376",
"377": "Community 377", "377": "Community 377",
"378": "Community 378", "378": "Community 378",
"379": "Community 379" "379": "Community 379",
"380": "Community 380",
"381": "Community 381",
"382": "Community 382",
"383": "Community 383"
} }
+102 -59
View File
@@ -1,16 +1,16 @@
# Graph Report - nobat724_front (2026-07-18) # Graph Report - nobat724_front (2026-07-19)
## Corpus Check ## Corpus Check
- 607 files · ~553,226 words - 619 files · ~559,739 words
- Verdict: corpus is large enough that graph structure adds value. - Verdict: corpus is large enough that graph structure adds value.
## Summary ## Summary
- 2187 nodes · 2167 edges · 376 communities (346 shown, 30 thin omitted) - 2261 nodes · 2301 edges · 384 communities (351 shown, 33 thin omitted)
- Extraction: 95% EXTRACTED · 5% INFERRED · 0% AMBIGUOUS · INFERRED: 100 edges (avg confidence: 0.8) - Extraction: 94% EXTRACTED · 6% INFERRED · 0% AMBIGUOUS · INFERRED: 137 edges (avg confidence: 0.8)
- Token cost: 0 input · 0 output - Token cost: 0 input · 0 output
## Graph Freshness ## Graph Freshness
- Built from commit: `b3709604` - Built from commit: `36816ede`
- Run `git rev-parse HEAD` and compare to check if the graph is stale. - Run `git rev-parse HEAD` and compare to check if the graph is stale.
- Run `graphify update .` after code changes (no API cost). - Run `graphify update .` after code changes (no API cost).
@@ -135,6 +135,10 @@
- [[_COMMUNITY_Community 121|Community 121]] - [[_COMMUNITY_Community 121|Community 121]]
- [[_COMMUNITY_Community 122|Community 122]] - [[_COMMUNITY_Community 122|Community 122]]
- [[_COMMUNITY_Community 124|Community 124]] - [[_COMMUNITY_Community 124|Community 124]]
- [[_COMMUNITY_Community 125|Community 125]]
- [[_COMMUNITY_Community 126|Community 126]]
- [[_COMMUNITY_Community 128|Community 128]]
- [[_COMMUNITY_Community 140|Community 140]]
- [[_COMMUNITY_Community 195|Community 195]] - [[_COMMUNITY_Community 195|Community 195]]
- [[_COMMUNITY_Community 339|Community 339]] - [[_COMMUNITY_Community 339|Community 339]]
- [[_COMMUNITY_Community 347|Community 347]] - [[_COMMUNITY_Community 347|Community 347]]
@@ -143,6 +147,10 @@
- [[_COMMUNITY_Community 366|Community 366]] - [[_COMMUNITY_Community 366|Community 366]]
- [[_COMMUNITY_Community 367|Community 367]] - [[_COMMUNITY_Community 367|Community 367]]
- [[_COMMUNITY_Community 368|Community 368]] - [[_COMMUNITY_Community 368|Community 368]]
- [[_COMMUNITY_Community 369|Community 369]]
- [[_COMMUNITY_Community 370|Community 370]]
- [[_COMMUNITY_Community 371|Community 371]]
- [[_COMMUNITY_Community 372|Community 372]]
- [[_COMMUNITY_Community 373|Community 373]] - [[_COMMUNITY_Community 373|Community 373]]
- [[_COMMUNITY_Community 374|Community 374]] - [[_COMMUNITY_Community 374|Community 374]]
- [[_COMMUNITY_Community 375|Community 375]] - [[_COMMUNITY_Community 375|Community 375]]
@@ -150,63 +158,66 @@
- [[_COMMUNITY_Community 377|Community 377]] - [[_COMMUNITY_Community 377|Community 377]]
- [[_COMMUNITY_Community 378|Community 378]] - [[_COMMUNITY_Community 378|Community 378]]
- [[_COMMUNITY_Community 379|Community 379]] - [[_COMMUNITY_Community 379|Community 379]]
- [[_COMMUNITY_Community 380|Community 380]]
- [[_COMMUNITY_Community 381|Community 381]]
- [[_COMMUNITY_Community 382|Community 382]]
## God Nodes (most connected - your core abstractions) ## God Nodes (most connected - your core abstractions)
1. `getStateInfo()` - 24 edges 1. `getStateInfo()` - 26 edges
2. `🏥 نوبت724 (Nobat724) - سیستم نوبت‌دهی آنلاین پزشکی` - 19 edges 2. `🏥 نوبت724 (Nobat724) - سیستم نوبت‌دهی آنلاین پزشکی` - 19 edges
3. `تقسیم ممیزی SEO به پرامپت‌های کوچک اجرایی — nobat724` - 17 edges 3. `تقسیم ممیزی SEO به پرامپت‌های کوچک اجرایی — nobat724` - 17 edges
4. `imageUrl()` - 15 edges 4. `imageUrl()` - 15 edges
5. `Dashboard()` - 10 edges 5. `Doctor()` - 11 edges
6. `بهینه‌سازی SEO تک‌تک شهرها در city.json (سطح حرفه‌ای)` - 10 edges 6. `Blog()` - 10 edges
7. `بازطراحی حرفه‌ای پوستر اشتراک‌گذاری پزشک (دانلود)` - 10 edges 7. `Dashboard()` - 10 edges
8. `وظایف` - 10 edges 8. `بهینه‌سازی SEO تک‌تک شهرها در city.json (سطح حرفه‌ای)` - 10 edges
9. `وظایف` - 10 edges 9. `بازطراحی حرفه‌ای پوستر اشتراک‌گذاری پزشک (دانلود)` - 10 edges
10. `وظایف` - 10 edges 10. `وظایف` - 10 edges
## Surprising Connections (you probably didn't know these) ## Surprising Connections (you probably didn't know these)
- `Dashboard()` --calls--> `safeJsonParse()` [INFERRED]
app/dashboard/page.js → lib/sanitize.js
- `Doctor()` --calls--> `buildDoctorFaq()` [INFERRED]
app/doctor/[slug]/page.js → lib/specialtyContent.js
- `SpecialtyDetail()` --calls--> `buildSpecialtyFaq()` [INFERRED]
app/specialties/[slug]/page.js → lib/specialtyContent.js
- `AboutUsPage()` --calls--> `getStateInfo()` [INFERRED] - `AboutUsPage()` --calls--> `getStateInfo()` [INFERRED]
components/aboutUs/index.js → lib/getStateInfo.js components/aboutUs/index.js → lib/getStateInfo.js
- `ContactUsPage()` --calls--> `getStateInfo()` [INFERRED] - `ContactUsPage()` --calls--> `getStateInfo()` [INFERRED]
components/contactUs/index.js → lib/getStateInfo.js components/contactUs/index.js → lib/getStateInfo.js
- `SearchBar()` --calls--> `getStateInfo()` [INFERRED]
components/home/search/index.js → lib/getStateInfo.js
- `StLayout()` --calls--> `getStateInfo()` [INFERRED]
components/layout/StLayout.js → lib/getStateInfo.js
- `generateMetadata()` --calls--> `getStateInfo()` [INFERRED]
app/about-us/page.js → lib/getStateInfo.js
## Import Cycles ## Import Cycles
- None detected. - None detected.
## Communities (376 total, 30 thin omitted) ## Communities (384 total, 33 thin omitted)
### Community 0 - "Community 0" ### Community 0 - "Community 0"
Cohesion: 0.05 Cohesion: 0.05
Nodes (27): BgGray(), ButtonMenu(), Col(), ModalLogout(), Footer(), head, LogoNamad(), Namads (+19 more) Nodes (27): BgGray(), ButtonMenu(), Col(), ModalLogout(), Footer(), head, LogoNamad(), Namads (+19 more)
### Community 1 - "Community 1" ### Community 1 - "Community 1"
Cohesion: 0.24 Cohesion: 0.36
Nodes (8): Head(), convertTimestampToJalali(), convertTimestampToTime(), ButtonData(), DetailLg(), DetailSm(), Head(), Card() Nodes (5): convertTimestampToTime(), ButtonData(), DetailLg(), DetailSm(), Head()
### Community 2 - "Community 2" ### Community 2 - "Community 2"
Cohesion: 0.04 Cohesion: 0.04
Nodes (48): dependencies, altcha, aos, axios, @casl/ability, @casl/react, date-fns, dayjs (+40 more) Nodes (48): dependencies, altcha, aos, axios, @casl/ability, @casl/react, date-fns, dayjs (+40 more)
### Community 3 - "Community 3" ### Community 3 - "Community 3"
Cohesion: 0.24 Cohesion: 0.21
Nodes (14): getCurrentDomain(), robots(), cityFilterParams(), fetchAllPages(), getBlogUrls(), getCityScope(), getClinicUrls(), getCurrentDomain() (+6 more) Nodes (17): getCurrentDomain(), robots(), CITY_IDS_WITH_DOMAIN, cityFilterParams(), fetchAllPages(), getBlogUrls(), getCityScope(), getClinicUrls() (+9 more)
### Community 4 - "Community 4" ### Community 4 - "Community 4"
Cohesion: 0.07 Cohesion: 0.07
Nodes (21): ContentDetail(), About(), ListBime(), Services(), Specialties(), TextDetail(), listTab, AddComment() (+13 more) Nodes (21): ContentDetail(), About(), ListBime(), Services(), Specialties(), TextDetail(), listTab, AddComment() (+13 more)
### Community 5 - "Community 5" ### Community 5 - "Community 5"
Cohesion: 0.07 Cohesion: 0.06
Nodes (23): AppointmentList(), ItemAppointment(), AboutDcotor(), ClaimProfileSection(), currentUserMobile(), initialForm, DetailDoctor(), Link() (+15 more) Nodes (26): bookingState(), DAY_NAMES, formatJalali(), AppointmentList(), ItemAppointment(), AboutDcotor(), ClaimProfileSection(), currentUserMobile() (+18 more)
### Community 6 - "Community 6" ### Community 6 - "Community 6"
Cohesion: 0.12 Cohesion: 0.08
Nodes (10): Dashboard(), metadata, defineAbilitiesFor(), getUser(), buildPatientUser(), safeJsonLd(), safeJsonParse(), getServerAccessToken() (+2 more) Nodes (13): Dashboard(), metadata, defineAbilitiesFor(), getUser(), buildPatientUser(), getServerAccessToken(), clearAccessToken(), LogIn() (+5 more)
### Community 8 - "Community 8" ### Community 8 - "Community 8"
Cohesion: 0.07 Cohesion: 0.07
@@ -293,8 +304,8 @@ Cohesion: 0.12
Nodes (16): دیپلوی nobat724_front روی Liara (پلتفرم Next.js), زمینه, فایل‌های مرتبط, مشکل / هدف, نکات مهم, وضعیت فعلی, وظایف, پروژه (+8 more) Nodes (16): دیپلوی nobat724_front روی Liara (پلتفرم Next.js), زمینه, فایل‌های مرتبط, مشکل / هدف, نکات مهم, وضعیت فعلی, وظایف, پروژه (+8 more)
### Community 29 - "Community 29" ### Community 29 - "Community 29"
Cohesion: 0.12 Cohesion: 0.08
Nodes (10): generateMetadata(), generateMetadata(), Appointment(), metadata, fetchSiteContext(), getStateInfo(), siteContextCache, ContentLogin() (+2 more) Nodes (20): generateMetadata(), generateMetadata(), Clinics(), generateMetadata(), Appointment(), metadata, Doctors(), generateMetadata() (+12 more)
### Community 30 - "Community 30" ### Community 30 - "Community 30"
Cohesion: 0.12 Cohesion: 0.12
@@ -357,12 +368,12 @@ Cohesion: 0.14
Nodes (13): `hours/List.js`, آبجکت اسلات (از `adaptSlots` / API), تأیید و سخت‌سازی غیرفعال‌بودن اسلات‌های گذشته در صفحه‌ی نوبت, زمینه, فایل‌های مرتبط, مشکل / هدف, نکات مهم, وضعیت فعلی (کد واقعی) (+5 more) Nodes (13): `hours/List.js`, آبجکت اسلات (از `adaptSlots` / API), تأیید و سخت‌سازی غیرفعال‌بودن اسلات‌های گذشته در صفحه‌ی نوبت, زمینه, فایل‌های مرتبط, مشکل / هدف, نکات مهم, وضعیت فعلی (کد واقعی) (+5 more)
### Community 45 - "Community 45" ### Community 45 - "Community 45"
Cohesion: 0.29 Cohesion: 0.23
Nodes (6): generateMetadata(), imageUrl(), normalizeBlog(), ItemClinic(), Blog(), getBlog Nodes (16): generateMetadata(), generateMetadata(), generateMetadata(), imageUrl(), normalizeBlog(), extractEntityCityId(), getEntityOrigin(), Blog() (+8 more)
### Community 46 - "Community 46" ### Community 46 - "Community 46"
Cohesion: 0.09 Cohesion: 0.22
Nodes (18): formatToman(), rialToToman(), Messages(), Message(), Paying(), Card(), PAYMENT_STATUS, TYPE_LABELS (+10 more) Nodes (6): Messages(), Message(), Head(), listTab, UserAccountPage(), NotfoundDashboard()
### Community 47 - "Community 47" ### Community 47 - "Community 47"
Cohesion: 0.15 Cohesion: 0.15
@@ -382,7 +393,7 @@ Nodes (12): زمینه, فایل‌های مرتبط, مشکل / هدف, نما
### Community 51 - "Community 51" ### Community 51 - "Community 51"
Cohesion: 0.06 Cohesion: 0.06
Nodes (20): ClinicsPage(), ProvinceContext, ProvinceProvider(), Probe(), useProvince(), Content(), Form(), SubmitData() (+12 more) Nodes (21): ProvinceContext, ProvinceProvider(), Probe(), useProvince(), Content(), Form(), SubmitData(), EditField() (+13 more)
### Community 52 - "Community 52" ### Community 52 - "Community 52"
Cohesion: 0.24 Cohesion: 0.24
@@ -457,16 +468,16 @@ Cohesion: 0.20
Nodes (9): رفع جهت تبدیل تاریخ تولد هنگام ایجاد پروفایل, زمینه, فایل‌های مرتبط, مشکل / هدف, نکات مهم, وضعیت فعلی (کد واقعی), وظایف, پروژه (+1 more) Nodes (9): رفع جهت تبدیل تاریخ تولد هنگام ایجاد پروفایل, زمینه, فایل‌های مرتبط, مشکل / هدف, نکات مهم, وضعیت فعلی (کد واقعی), وظایف, پروژه (+1 more)
### Community 70 - "Community 70" ### Community 70 - "Community 70"
Cohesion: 0.25 Cohesion: 0.13
Nodes (7): Clinics(), FILTER_KEYS, generateMetadata(), listingRobots(), buildClinicParams(), axiosInstance, fetchReq() Nodes (13): ClinicsPage(), AutoComplete(), ButtonFilter(), Content(), ModalSearchCity(), AutoSearch(), FilterButton(), SearchBar() (+5 more)
### Community 71 - "Community 71" ### Community 71 - "Community 71"
Cohesion: 0.22 Cohesion: 0.22
Nodes (7): degree, Form(), gender, degree, Form(), gender, filterList() Nodes (7): degree, Form(), gender, degree, Form(), gender, filterList()
### Community 72 - "Community 72" ### Community 72 - "Community 72"
Cohesion: 0.39 Cohesion: 0.27
Nodes (8): generateMetadata(), RootLayout(), buildCanonicalPath(), buildCanonicalUrl(), buildMainCanonicalUrl(), getCanonicalUrl(), getRequestOrigin(), normalizeHost() Nodes (13): generateMetadata(), buildCanonicalPath(), buildCanonicalQuery(), buildCanonicalUrl(), buildMainCanonicalUrl(), buildSpecialtyCanonicalPath(), CANONICAL_QUERY_KEYS, getCanonicalUrl() (+5 more)
### Community 73 - "Community 73" ### Community 73 - "Community 73"
Cohesion: 0.32 Cohesion: 0.32
@@ -481,8 +492,8 @@ Cohesion: 0.29
Nodes (7): Authentication & Authorization, Development Tools, Frontend Framework, State Management & Data Fetching, UI/UX, 🏗️ معماری و تکنولوژی, کتابخانه‌های تخصصی Nodes (7): Authentication & Authorization, Development Tools, Frontend Framework, State Management & Data Fetching, UI/UX, 🏗️ معماری و تکنولوژی, کتابخانه‌های تخصصی
### Community 76 - "Community 76" ### Community 76 - "Community 76"
Cohesion: 0.07 Cohesion: 0.33
Nodes (20): Allergies(), ItemAllergie(), HeadTab(), setNewData(), DetailUser(), Disease(), FamilyHistory(), ItemHistory() (+12 more) Nodes (5): HeadTab(), setNewData(), DetailUser(), Disease(), IsTurnsDetails()
### Community 79 - "Community 79" ### Community 79 - "Community 79"
Cohesion: 0.17 Cohesion: 0.17
@@ -498,16 +509,20 @@ Nodes (11): رفع خطای `TypeError: fetch failed` در sitemap هنگام د
### Community 83 - "Community 83" ### Community 83 - "Community 83"
Cohesion: 0.24 Cohesion: 0.24
Nodes (6): STATUS_LABELS, Head(), listTab, Turns(), List(), STATUS_LABELS Nodes (7): Card(), STATUS_LABELS, Head(), listTab, Turns(), List(), STATUS_LABELS
### Community 86 - "Community 86" ### Community 86 - "Community 86"
Cohesion: 0.40 Cohesion: 0.21
Nodes (5): Doctors(), FILTER_KEYS, generateMetadata(), listingRobots(), buildDoctorParams() Nodes (10): generateMetadata(), DoctorsPage(), findCityById(), findDomainByCityId(), resolveCityDisplayName(), isRootCity(), ListDoctors(), findSpecialty() (+2 more)
### Community 87 - "Community 87" ### Community 87 - "Community 87"
Cohesion: 0.33 Cohesion: 0.33
Nodes (6): 1. افزودن صفحه جدید, 2. افزودن API Endpoint جدید, 3. افزودن کامپوننت جدید, 4. کار با تاریخ شمسی, 5. استفاده از Context, 📝 نکات توسعه Nodes (6): 1. افزودن صفحه جدید, 2. افزودن API Endpoint جدید, 3. افزودن کامپوننت جدید, 4. کار با تاریخ شمسی, 5. استفاده از Context, 📝 نکات توسعه
### Community 89 - "Community 89"
Cohesion: 0.28
Nodes (4): Head(), convertTimestampToJalali(), RelatedContent(), Item()
### Community 90 - "Community 90" ### Community 90 - "Community 90"
Cohesion: 0.50 Cohesion: 0.50
Nodes (4): buildDays(), InlineJalaliMonth(), MONTHS, WEEKDAYS Nodes (4): buildDays(), InlineJalaliMonth(), MONTHS, WEEKDAYS
@@ -560,6 +575,22 @@ Nodes (4): Metadata داینامیک بر اساس شهر, ساختار دامن
Cohesion: 0.67 Cohesion: 0.67
Nodes (3): Feature Components, Layout Components, 🧩 کامپوننت‌های اصلی Nodes (3): Feature Components, Layout Components, 🧩 کامپوننت‌های اصلی
### Community 125 - "Community 125"
Cohesion: 0.14
Nodes (13): زمینه, فایل‌های مرتبط, مشکل / هدف, نوار موبایل — `components/doctor/appointmentList/index.js:20-24`, نکات مهم, وضعیت فعلی, وضعیت «نوبت‌دهی فعال/غیرفعال» پروفایل پزشک از booking_locations, وظایف (+5 more)
### Community 126 - "Community 126"
Cohesion: 0.33
Nodes (7): bodyVariants(), buildDoctorFaq(), buildSpecialtyFaq(), buildSpecialtyIntro(), closingVariants(), openingVariants(), pick()
### Community 128 - "Community 128"
Cohesion: 0.40
Nodes (3): Allergies(), ItemAllergie(), ModalAddAlergie()
### Community 140 - "Community 140"
Cohesion: 0.33
Nodes (5): RootLayout(), normalize(), Pageguide(), safeJsonLd(), safeJsonParse()
### Community 365 - "Community 365" ### Community 365 - "Community 365"
Cohesion: 0.08 Cohesion: 0.08
Nodes (23): endpoint جدید, انتخاب محل نوبت‌دهی (مطب شخصی / کلینیک) در جریان رزرو, زمینه, فایل‌های مرتبط, قرارداد API (آمادهٔ مصرف), مشکل / هدف, نکات مهم, وضعیت فعلی (+15 more) Nodes (23): endpoint جدید, انتخاب محل نوبت‌دهی (مطب شخصی / کلینیک) در جریان رزرو, زمینه, فایل‌های مرتبط, قرارداد API (آمادهٔ مصرف), مشکل / هدف, نکات مهم, وضعیت فعلی (+15 more)
@@ -569,12 +600,24 @@ Cohesion: 0.11
Nodes (17): P10 — بلاگ شهر-محور (C4), P11 — FAQPage schema و متن مقدمهٔ یکتا (M1 + M2), P1 — بررسی robots.txt (پیش‌نیاز اعتبارسنجی), P2 — هلپرهای مشترک (زیرساخت), P3 — رفع noindex سیستمیک صفحات لیست (C5 — بدترین باگ), P4 — استراتژی canonical سه‌دسته + تصمیم pagination (C1-a/b/c + M3-B), P5 — بازسازی sitemap (C2), P6 — صفحات موجودیت: Soft-404 و noindex کلینیک خالی (H4 + H7) (+9 more) Nodes (17): P10 — بلاگ شهر-محور (C4), P11 — FAQPage schema و متن مقدمهٔ یکتا (M1 + M2), P1 — بررسی robots.txt (پیش‌نیاز اعتبارسنجی), P2 — هلپرهای مشترک (زیرساخت), P3 — رفع noindex سیستمیک صفحات لیست (C5 — بدترین باگ), P4 — استراتژی canonical سه‌دسته + تصمیم pagination (C1-a/b/c + M3-B), P5 — بازسازی sitemap (C2), P6 — صفحات موجودیت: Soft-404 و noindex کلینیک خالی (H4 + H7) (+9 more)
### Community 367 - "Community 367" ### Community 367 - "Community 367"
Cohesion: 0.15 Cohesion: 0.40
Nodes (5): clearAccessToken(), api, handleSessionExpired(), request, removeToken() Nodes (3): FamilyHistory(), ItemHistory(), ModalAddHistory()
### Community 368 - "Community 368" ### Community 368 - "Community 368"
Cohesion: 0.70 Cohesion: 0.40
Nodes (4): generateMetadata(), Clinic(), computeClinicRating(), getClinic Nodes (3): Medications(), ItemMedication(), ModalAddMedications()
### Community 369 - "Community 369"
Cohesion: 0.40
Nodes (3): ModalAddRelatives(), Relatives(), ItemRelatives()
### Community 370 - "Community 370"
Cohesion: 0.40
Nodes (3): ModalAddSurgeries(), Surgeries(), ItemSurgeries()
### Community 372 - "Community 372"
Cohesion: 0.60
Nodes (4): hasRealName(), isThinClinic(), isThinDoctor(), PLACEHOLDER_NAMES
### Community 373 - "Community 373" ### Community 373 - "Community 373"
Cohesion: 0.12 Cohesion: 0.12
@@ -597,28 +640,28 @@ Cohesion: 0.36
Nodes (4): SearchBar(), ButtonFilter(), Fields(), RedirectLink() Nodes (4): SearchBar(), ButtonFilter(), Fields(), RedirectLink()
### Community 378 - "Community 378" ### Community 378 - "Community 378"
Cohesion: 0.60 Cohesion: 0.40
Nodes (5): generateMetadata(), Doctor(), getBookingLocations, getDoctor, getDoctorAddresses Nodes (3): KOHGILUYEH, scope, YASUJ
## Knowledge Gaps ## Knowledge Gaps
- **793 isolated node(s):** `metadata`, `FILTER_KEYS`, `fallbackLabels`, `MaterialUISwitch`, `fixedIconData` (+788 more) - **815 isolated node(s):** `metadata`, `fallbackLabels`, `MaterialUISwitch`, `fixedIconData`, `metadata` (+810 more)
These have ≤1 connection - possible missing edges or undocumented components. These have ≤1 connection - possible missing edges or undocumented components.
- **30 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes. - **33 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
## Suggested Questions ## Suggested Questions
_Questions this graph is uniquely positioned to answer:_ _Questions this graph is uniquely positioned to answer:_
- **Why does `imageUrl()` connect `Community 45` to `Community 0`, `Community 4`, `Community 7`, `Community 368`, `Community 81`, `Community 55`, `Community 56`, `Community 89`, `Community 378`?** - **Why does `imageUrl()` connect `Community 45` to `Community 0`, `Community 4`, `Community 7`, `Community 81`, `Community 55`, `Community 56`, `Community 89`, `Community 380`?**
_High betweenness centrality (0.037) - this node is a cross-community bridge._ _High betweenness centrality (0.038) - this node is a cross-community bridge._
- **Why does `getStateInfo()` connect `Community 29` to `Community 0`, `Community 67`, `Community 70`, `Community 6`, `Community 72`, `Community 45`, `Community 14`, `Community 16`, `Community 368`, `Community 51`, `Community 86`, `Community 377`, `Community 378`?** - **Why does `getStateInfo()` connect `Community 29` to `Community 0`, `Community 67`, `Community 6`, `Community 72`, `Community 140`, `Community 45`, `Community 14`, `Community 16`, `Community 86`, `Community 377`?**
_High betweenness centrality (0.034) - this node is a cross-community bridge._ _High betweenness centrality (0.034) - this node is a cross-community bridge._
- **Why does `isRootCity()` connect `Community 51` to `Community 3`, `Community 29`?** - **Why does `isRootCity()` connect `Community 86` to `Community 51`, `Community 3`, `Community 29`, `Community 70`?**
_High betweenness centrality (0.016) - this node is a cross-community bridge._ _High betweenness centrality (0.023) - this node is a cross-community bridge._
- **Are the 22 inferred relationships involving `getStateInfo()` (e.g. with `generateMetadata()` and `AboutUsPage()`) actually correct?** - **Are the 24 inferred relationships involving `getStateInfo()` (e.g. with `generateMetadata()` and `AboutUsPage()`) actually correct?**
_`getStateInfo()` has 22 INFERRED edges - model-reasoned connections that need verification._ _`getStateInfo()` has 24 INFERRED edges - model-reasoned connections that need verification._
- **Are the 14 inferred relationships involving `imageUrl()` (e.g. with `generateMetadata()` and `generateMetadata()`) actually correct?** - **Are the 14 inferred relationships involving `imageUrl()` (e.g. with `generateMetadata()` and `generateMetadata()`) actually correct?**
_`imageUrl()` has 14 INFERRED edges - model-reasoned connections that need verification._ _`imageUrl()` has 14 INFERRED edges - model-reasoned connections that need verification._
- **What connects `metadata`, `FILTER_KEYS`, `fallbackLabels` to the rest of the system?** - **What connects `metadata`, `fallbackLabels`, `MaterialUISwitch` to the rest of the system?**
_793 weakly-connected nodes found - possible documentation gaps or missing edges._ _815 weakly-connected nodes found - possible documentation gaps or missing edges._
- **Should `Community 0` be split into smaller, more focused modules?** - **Should `Community 0` be split into smaller, more focused modules?**
_Cohesion score 0.05263157894736842 - nodes in this community are weakly interconnected._ _Cohesion score 0.05263157894736842 - nodes in this community are weakly interconnected._
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2543 -391
View File
File diff suppressed because it is too large Load Diff
+135 -75
View File
@@ -34,14 +34,9 @@
"ast_hash": "b4fd1efd2e3de5280cd81eb855f99aa0", "ast_hash": "b4fd1efd2e3de5280cd81eb855f99aa0",
"semantic_hash": "" "semantic_hash": ""
}, },
"app/blog/[slug]/loading.js": {
"mtime": 1783776875.6702917,
"ast_hash": "016627769c04cbebb73b4b3cfb0d23c6",
"semantic_hash": ""
},
"app/blog/[slug]/page.js": { "app/blog/[slug]/page.js": {
"mtime": 1783788127.1066604, "mtime": 1784407027.0477514,
"ast_hash": "8a7dd8b28cc39eadd631e4e93cd2c9a2", "ast_hash": "3ef2ccdd2e5e6f053e46597a16e25c26",
"semantic_hash": "" "semantic_hash": ""
}, },
"app/blogs/loading.js": { "app/blogs/loading.js": {
@@ -50,18 +45,13 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"app/blogs/page.js": { "app/blogs/page.js": {
"mtime": 1783788127.1070502, "mtime": 1784406632.8936884,
"ast_hash": "c3171fa9e851483e1cf602dbf0cb61a1", "ast_hash": "29bdf4c9f75d6efdcf03f153fb9e10c1",
"semantic_hash": ""
},
"app/clinic/[slug]/loading.js": {
"mtime": 1783776875.6710782,
"ast_hash": "14e0d7de5c9a363ea15b7e93cee5184f",
"semantic_hash": "" "semantic_hash": ""
}, },
"app/clinic/[slug]/page.js": { "app/clinic/[slug]/page.js": {
"mtime": 1783788127.107519, "mtime": 1784407022.8888147,
"ast_hash": "4f60ebf11d1b5372c11ec251f5813353", "ast_hash": "a0fa950517333d4dfbd2cf07173297e5",
"semantic_hash": "" "semantic_hash": ""
}, },
"app/clinics/loading.js": { "app/clinics/loading.js": {
@@ -70,8 +60,8 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"app/clinics/page.js": { "app/clinics/page.js": {
"mtime": 1783788127.1081054, "mtime": 1784406347.6483262,
"ast_hash": "4812011bfb3102ac75cf14833b65f5f3", "ast_hash": "5bba3feb0c956e665183bcd1ec40c6ac",
"semantic_hash": "" "semantic_hash": ""
}, },
"app/component/AnimationTextHead.js": { "app/component/AnimationTextHead.js": {
@@ -130,8 +120,8 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"app/component/Pageguide.js": { "app/component/Pageguide.js": {
"mtime": 1783776875.673467, "mtime": 1784406207.271761,
"ast_hash": "0300a0cab556d111d5c5b8f4a87e9018", "ast_hash": "9d7f746731e37d418ca709cbf67dd0fe",
"semantic_hash": "" "semantic_hash": ""
}, },
"app/component/Pagination.js": { "app/component/Pagination.js": {
@@ -305,8 +295,8 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"app/component/head/index.js": { "app/component/head/index.js": {
"mtime": 1783776875.6768641, "mtime": 1784406294.1020348,
"ast_hash": "e821e9f2887d9a99a6d61b79394ebbdd", "ast_hash": "82dc3526e0ee2be2ecd34b6b1fb17b31",
"semantic_hash": "" "semantic_hash": ""
}, },
"app/component/loading/Circular.js": { "app/component/loading/Circular.js": {
@@ -414,14 +404,9 @@
"ast_hash": "55e4510b0ffae397bde137577f541559", "ast_hash": "55e4510b0ffae397bde137577f541559",
"semantic_hash": "" "semantic_hash": ""
}, },
"app/doctor/[slug]/loading.js": {
"mtime": 1783776875.67893,
"ast_hash": "0162c799a2207f950e524d2775162b84",
"semantic_hash": ""
},
"app/doctor/[slug]/page.js": { "app/doctor/[slug]/page.js": {
"mtime": 1784372994.657654, "mtime": 1784407284.5098457,
"ast_hash": "b895af9c7f69b7ee5cfa72afb54157a8", "ast_hash": "db96a84c61baa865908cdee7d6d4e897",
"semantic_hash": "" "semantic_hash": ""
}, },
"app/doctors/loading.js": { "app/doctors/loading.js": {
@@ -430,8 +415,8 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"app/doctors/page.js": { "app/doctors/page.js": {
"mtime": 1783788127.109245, "mtime": 1784406341.5437217,
"ast_hash": "89750247d7b8c327fcaaca9b7bd21e17", "ast_hash": "6540db05ff1cc0de7d0e1c55e5ae3471",
"semantic_hash": "" "semantic_hash": ""
}, },
"app/error.js": { "app/error.js": {
@@ -485,13 +470,13 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"app/sitemap.js": { "app/sitemap.js": {
"mtime": 1783837670.0674648, "mtime": 1784407686.3603573,
"ast_hash": "87d7e8351aeaf78013c8f1e073e32bff", "ast_hash": "a0279f915ade8ee96f23023100d5dc1a",
"semantic_hash": "" "semantic_hash": ""
}, },
"app/specialties/page.js": { "app/specialties/page.js": {
"mtime": 1783788127.1106946, "mtime": 1784406352.8949425,
"ast_hash": "4a7b55c186bacc3fe56649ba43559ac0", "ast_hash": "ae805b4462ea5a3b0be07fae1b113182",
"semantic_hash": "" "semantic_hash": ""
}, },
"app/theme/theme.js": { "app/theme/theme.js": {
@@ -515,7 +500,7 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"components/appointment/Container.js": { "components/appointment/Container.js": {
"mtime": 1784372894.7871401, "mtime": 1784391828.218286,
"ast_hash": "8cb736f55e88a28042211b0a6035739f", "ast_hash": "8cb736f55e88a28042211b0a6035739f",
"semantic_hash": "" "semantic_hash": ""
}, },
@@ -555,7 +540,7 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"components/appointment/detail/SubmitData.js": { "components/appointment/detail/SubmitData.js": {
"mtime": 1784369779.2972658, "mtime": 1784391828.219858,
"ast_hash": "c2f66995b3be5c3608bec79c5392674c", "ast_hash": "c2f66995b3be5c3608bec79c5392674c",
"semantic_hash": "" "semantic_hash": ""
}, },
@@ -570,7 +555,7 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"components/appointment/index.js": { "components/appointment/index.js": {
"mtime": 1784372880.5497036, "mtime": 1784391828.2210534,
"ast_hash": "9e19a1e1b6ecb5f32b040fc6e5e65688", "ast_hash": "9e19a1e1b6ecb5f32b040fc6e5e65688",
"semantic_hash": "" "semantic_hash": ""
}, },
@@ -615,7 +600,7 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"components/appointment/successPay/index.js": { "components/appointment/successPay/index.js": {
"mtime": 1783776875.6890368, "mtime": 1784391828.2219212,
"ast_hash": "8e6daf759ebf175178eb070c03e77f28", "ast_hash": "8e6daf759ebf175178eb070c03e77f28",
"semantic_hash": "" "semantic_hash": ""
}, },
@@ -645,13 +630,13 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"components/blog/head/index.js": { "components/blog/head/index.js": {
"mtime": 1783776875.689889, "mtime": 1784406682.6597717,
"ast_hash": "815d7aaefc944ccc3307ed1e3a88b7dd", "ast_hash": "2eab10468e5fce355b3b4ac944aa2a8a",
"semantic_hash": "" "semantic_hash": ""
}, },
"components/blog/index.js": { "components/blog/index.js": {
"mtime": 1760953552.2328737, "mtime": 1784406695.3540223,
"ast_hash": "46848c06aa6bf6b4456ba026b547d9d0", "ast_hash": "554031757727365554d14e77fd91449a",
"semantic_hash": "" "semantic_hash": ""
}, },
"components/blog/relatedContent/Item.js": { "components/blog/relatedContent/Item.js": {
@@ -695,8 +680,8 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"components/clinic/components/about/ContentDetail.js": { "components/clinic/components/about/ContentDetail.js": {
"mtime": 1760953552.2337117, "mtime": 1784406329.917917,
"ast_hash": "b6a8ef012a217eac89c90b0433253c29", "ast_hash": "ee2ec0cb42264f573e20f4bf95d475f5",
"semantic_hash": "" "semantic_hash": ""
}, },
"components/clinic/components/about/ListBime.js": { "components/clinic/components/about/ListBime.js": {
@@ -740,8 +725,8 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"components/clinic/components/about/index.js": { "components/clinic/components/about/index.js": {
"mtime": 1760953552.2343261, "mtime": 1784406333.9955986,
"ast_hash": "cf9cedf1c7ff66c2be4a252ce67641c1", "ast_hash": "d7f7f6437879967221185d3b02a6f5bb",
"semantic_hash": "" "semantic_hash": ""
}, },
"components/clinic/components/contact/ContentDetail.js": { "components/clinic/components/contact/ContentDetail.js": {
@@ -865,8 +850,8 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"components/clinic/index.js": { "components/clinic/index.js": {
"mtime": 1764813617.2930207, "mtime": 1784406236.7860522,
"ast_hash": "9040f0915df145d14b69e97a034e9c32", "ast_hash": "591faaba172c34e9941f65aed342ce42",
"semantic_hash": "" "semantic_hash": ""
}, },
"components/clinics/head/AutoSearch.js": { "components/clinics/head/AutoSearch.js": {
@@ -910,8 +895,8 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"components/clinics/index.js": { "components/clinics/index.js": {
"mtime": 1783776875.6953976, "mtime": 1784406767.4070415,
"ast_hash": "f06fbe6a0d4ae74e3b7ad91f0023af94", "ast_hash": "4da5edf0ad30361e217b56759a7e6cb0",
"semantic_hash": "" "semantic_hash": ""
}, },
"components/clinics/list/ItemClinic.js": { "components/clinics/list/ItemClinic.js": {
@@ -920,8 +905,8 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"components/clinics/list/index.js": { "components/clinics/list/index.js": {
"mtime": 1783776875.6957505, "mtime": 1784406215.5236862,
"ast_hash": "94309b5c74e5b249f91909700fd98718", "ast_hash": "d74c2cf770f7a2eb7cc9bf31357bb0b4",
"semantic_hash": "" "semantic_hash": ""
}, },
"components/common/InlineJalaliMonth.js": { "components/common/InlineJalaliMonth.js": {
@@ -1275,13 +1260,13 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"components/doctor/appointmentList/ItemAppointment.js": { "components/doctor/appointmentList/ItemAppointment.js": {
"mtime": 1783776875.7039776, "mtime": 1784379062.7296646,
"ast_hash": "4122e2d8e034376e9fec85aba74cc311", "ast_hash": "7d4054f02a1066a0f749c878e96c3a51",
"semantic_hash": "" "semantic_hash": ""
}, },
"components/doctor/appointmentList/index.js": { "components/doctor/appointmentList/index.js": {
"mtime": 1783776875.704231, "mtime": 1784379037.3930647,
"ast_hash": "c0f474f78abf0e43b0779315ce514128", "ast_hash": "852922a8c3c20031afa776b03cf116ef",
"semantic_hash": "" "semantic_hash": ""
}, },
"components/doctor/appointmentList/modal/index.js": { "components/doctor/appointmentList/modal/index.js": {
@@ -1370,8 +1355,8 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"components/doctor/index.js": { "components/doctor/index.js": {
"mtime": 1784373003.075006, "mtime": 1784406821.2247164,
"ast_hash": "5ac231f0d8d5d450450c548f8c4ef558", "ast_hash": "104b3b1ca3276bd1adb0a9b21d0c0cc1",
"semantic_hash": "" "semantic_hash": ""
}, },
"components/doctor/poster/PosterLight.js": { "components/doctor/poster/PosterLight.js": {
@@ -1395,8 +1380,8 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"components/doctors/head/FilterLocate.js": { "components/doctors/head/FilterLocate.js": {
"mtime": 1760953552.2439659, "mtime": 1784406269.086647,
"ast_hash": "58d248e3c46201f47464c8b9748e0dbd", "ast_hash": "802760f7ca9d22f809dfd93cb5961326",
"semantic_hash": "" "semantic_hash": ""
}, },
"components/doctors/head/index.js": { "components/doctors/head/index.js": {
@@ -1405,8 +1390,8 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"components/doctors/index.js": { "components/doctors/index.js": {
"mtime": 1784014948.7366664, "mtime": 1784406756.4799144,
"ast_hash": "2cbc3523c140670b5b28330dadc591da", "ast_hash": "42c5dcc1c26fd4b24ab8ef9751f4f036",
"semantic_hash": "" "semantic_hash": ""
}, },
"components/doctors/listDoctors/index.js": { "components/doctors/listDoctors/index.js": {
@@ -1500,8 +1485,8 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"components/home/TextHeader.js": { "components/home/TextHeader.js": {
"mtime": 1783776875.712511, "mtime": 1784406262.2477946,
"ast_hash": "9e2b62254d4d2b345f260736c82f7a1c", "ast_hash": "8201cbb392459c065a388fb49387e20e",
"semantic_hash": "" "semantic_hash": ""
}, },
"components/home/index.js": { "components/home/index.js": {
@@ -2500,18 +2485,18 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"components/specialties/index.js": { "components/specialties/index.js": {
"mtime": 1783776875.721483, "mtime": 1784406300.7337966,
"ast_hash": "3974ee8a727b469cced3f740d2767b4a", "ast_hash": "1a62094048ae0e3700ba0564247de886",
"semantic_hash": "" "semantic_hash": ""
}, },
"components/specialties/list/ItemSpecialties.js": { "components/specialties/list/ItemSpecialties.js": {
"mtime": 1783776875.7216527, "mtime": 1784406551.3291972,
"ast_hash": "b5893cfe7f6c5c41a61a721d7c817fee", "ast_hash": "63d9537e1db9afa8f533e42eb9328ca2",
"semantic_hash": "" "semantic_hash": ""
}, },
"components/specialties/list/index.js": { "components/specialties/list/index.js": {
"mtime": 1783776875.7218535, "mtime": 1784406305.8913972,
"ast_hash": "27b0dae4c89e8a6a86f8bcef50841e46", "ast_hash": "845165cec7a0f15a4ab5a6c2cb056dc4",
"semantic_hash": "" "semantic_hash": ""
}, },
"constans/index.js": { "constans/index.js": {
@@ -2605,8 +2590,8 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"lib/getCanonicalUrl.js": { "lib/getCanonicalUrl.js": {
"mtime": 1783776875.7578259, "mtime": 1784407010.2048862,
"ast_hash": "71161f91c45f487c53ef50e4c114147f", "ast_hash": "382b422d12683c05d27032df15d46bd5",
"semantic_hash": "" "semantic_hash": ""
}, },
"lib/getStateInfo.js": { "lib/getStateInfo.js": {
@@ -2695,8 +2680,8 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"proxy.js": { "proxy.js": {
"mtime": 1783776875.7619658, "mtime": 1784405767.8895226,
"ast_hash": "21f794f5fc7902cb191878f2c473c29b", "ast_hash": "bf1fcfe5ec3952788994411a3910ea50",
"semantic_hash": "" "semantic_hash": ""
}, },
"services/api.js": { "services/api.js": {
@@ -3483,5 +3468,80 @@
"mtime": 1784371536.5668268, "mtime": 1784371536.5668268,
"ast_hash": "9ef9470308b23df8f93dae9b76d33d91", "ast_hash": "9ef9470308b23df8f93dae9b76d33d91",
"semantic_hash": "" "semantic_hash": ""
},
"app/specialties/[slug]/page.js": {
"mtime": 1784406541.9613838,
"ast_hash": "9bcbf931390b23e3159e7a0eac913688",
"semantic_hash": ""
},
"components/doctor/appointmentList/bookingState.js": {
"mtime": 1784379017.0738394,
"ast_hash": "21d4c5671791aed50246b158116683d8",
"semantic_hash": ""
},
"components/specialties/detail/DoctorGrid.js": {
"mtime": 1784406425.8105416,
"ast_hash": "b1b846c9a9160a1f461a9958d69b30f5",
"semantic_hash": ""
},
"components/specialties/detail/Faq.js": {
"mtime": 1784406509.289054,
"ast_hash": "ba065f63dadc37c0dcd9d0a40f817282",
"semantic_hash": ""
},
"components/specialties/detail/index.js": {
"mtime": 1784406519.0040174,
"ast_hash": "9fa7eef69222ea971d829772150cb141",
"semantic_hash": ""
},
"lib/canonicalStrategy.test.js": {
"mtime": 1784405945.6683788,
"ast_hash": "2071f0a5b2e217101a01a02e3c0617d3",
"semantic_hash": ""
},
"lib/domainHelpers.js": {
"mtime": 1784406639.1672857,
"ast_hash": "90adb0743935709743883af7c9225688",
"semantic_hash": ""
},
"lib/domainHelpers.test.js": {
"mtime": 1784405652.7269192,
"ast_hash": "fc23deafe26cf454e8c96e57c3a0f049",
"semantic_hash": ""
},
"lib/entityQuality.js": {
"mtime": 1784406054.8168738,
"ast_hash": "51fb7d01b553143993b6bb4dab0c2724",
"semantic_hash": ""
},
"lib/listingIntro.js": {
"mtime": 1784407866.2714968,
"ast_hash": "41f72b224d43827ed1e76a400e6db122",
"semantic_hash": ""
},
"lib/listingRobots.js": {
"mtime": 1784405698.0589795,
"ast_hash": "c80b07a640e35536acf44404dde1076f",
"semantic_hash": ""
},
"lib/listingRobots.test.js": {
"mtime": 1784405747.7086,
"ast_hash": "c85cc1f9006477adc1bc0fce070b74ae",
"semantic_hash": ""
},
"lib/specialtyContent.js": {
"mtime": 1784407570.458993,
"ast_hash": "c723f71b8073175c55ef1f155c75c3b8",
"semantic_hash": ""
},
"lib/specialtyContent.test.js": {
"mtime": 1784407873.0701578,
"ast_hash": "5312548dd11cc5207a3f53bd272c2d3b",
"semantic_hash": ""
},
".claude/prompt/doctor-profile-booking-state-from-locations.md": {
"mtime": 1784377204.9349537,
"ast_hash": "5c45fd5c8cc1d2a002c6bbf3d8c8066f",
"semantic_hash": ""
} }
} }
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import {
buildCanonicalQuery,
getEntityCanonical,
} from "@/lib/getCanonicalUrl";
const YASUJ_CITY_ID = 123;
const ROOT_CITY_ID = 600;
describe("buildCanonicalQuery — تصمیم M3-B", () => {
it("صفحهٔ ۲+ در canonical می‌ماند تا ایندکس‌پذیر بماند", () => {
expect(buildCanonicalQuery("?page=2")).toBe("?page=2");
expect(buildCanonicalQuery("?page=38")).toBe("?page=38");
});
it("صفحهٔ ۱ حذف می‌شود (URL متعارف بدون page)", () => {
expect(buildCanonicalQuery("?page=1")).toBe("");
expect(buildCanonicalQuery("")).toBe("");
});
it("فیلتر و sort و جستجو از canonical حذف می‌شوند", () => {
expect(buildCanonicalQuery("?specialty=x&gender=woman&sort=point&name=a")).toBe("");
});
it("page در کنار فیلترها فقط page را نگه می‌دارد", () => {
expect(buildCanonicalQuery("?specialty=x&page=3")).toBe("?page=3");
});
});
describe("getEntityCanonical — C1-b", () => {
it("موجودیت به دامنهٔ شهر خودش canonical می‌شود، نه دامنهٔ سرو‌کننده", () => {
expect(getEntityCanonical(YASUJ_CITY_ID, "/doctor/abc", "nobat724.com")).toBe(
"https://yasuj-nobat.ir/doctor/abc"
);
});
it("روی دامنهٔ خودِ شهر self-canonical است", () => {
expect(getEntityCanonical(YASUJ_CITY_ID, "/clinic/abc", "yasuj-nobat.ir")).toBe(
"https://yasuj-nobat.ir/clinic/abc"
);
});
it("شهر بدون دامنه → self-canonical (هرگز canonical شکسته)", () => {
expect(getEntityCanonical(999999, "/doctor/abc", "nobat724.com")).toBe(
"https://nobat724.com/doctor/abc"
);
expect(getEntityCanonical(null, "/doctor/abc", "tehran-nobat.ir")).toBe(
"https://tehran-nobat.ir/doctor/abc"
);
});
it("رکورد ریشه شهر نیست → self-canonical", () => {
expect(getEntityCanonical(ROOT_CITY_ID, "/doctor/abc", "yazd-nobat.ir")).toBe(
"https://yazd-nobat.ir/doctor/abc"
);
});
});
+57
View File
@@ -0,0 +1,57 @@
// هلپرهای مشترک نگاشت «شهر ↔ دامنه» و نام نمایشی شهر.
// این ماژول pure است (بدون next/headers) تا در client، server، sitemap و تست قابل import باشد.
import citiesData from "@/data/city.json";
import { isRootCity } from "@/lib/rootCity";
const ROOT_DISPLAY_NAME = "ایران";
/**
* دامنهٔ شهریِ متناظر با یک city_id.
* رکورد ریشه (id:600 → nobat724.com) هرگز به‌عنوان «شهر» برنمی‌گردد.
* @returns {string|null} مثلا "yasuj-nobat.ir" یا null
*/
export function findDomainByCityId(cityId) {
if (cityId == null || cityId === "") return null;
const id = Number(cityId);
if (!Number.isFinite(id)) return null;
const city = citiesData.find((c) => Number(c.id) === id);
if (!city || isRootCity(city) || !city.domain) return null;
return city.domain;
}
/** رکورد شهر بر اساس id — رکورد ریشه شهر نیست. */
export function findCityById(cityId) {
if (cityId == null || cityId === "") return null;
const city = citiesData.find((c) => Number(c.id) === Number(cityId));
return city && !isRootCity(city) ? city : null;
}
/**
* نام قابل‌نمایش شهر برای H1/Title.
* روی دامنهٔ ریشه نام رکورد («نوبت 724») یک برند است نه شهر — به‌جای آن «ایران».
*/
export function resolveCityDisplayName(cityInfo) {
if (!cityInfo || isRootCity(cityInfo)) return ROOT_DISPLAY_NAME;
return cityInfo.name || ROOT_DISPLAY_NAME;
}
/**
* city_id یک موجودیت را از شکل‌های مختلف پاسخ API بیرون می‌کشد:
* - کلینیک: city: [{ id }]
* - پزشک: city خالی است و شهر واقعی داخل address[].city.id می‌نشیند
* - بلاگ: city_id مسطح
* اولین شهرِ دارای دامنهٔ اختصاصی برنده است (پزشک چند-شهری → یک شهر اصلی).
*/
export function extractEntityCityId(entity) {
if (!entity) return null;
const candidates = [
entity.city_id,
...(Array.isArray(entity.city) ? entity.city.map((c) => c?.id) : [entity.city?.id]),
...(Array.isArray(entity.address) ? entity.address.map((a) => a?.city?.id) : []),
].filter((id) => id != null);
const withDomain = candidates.find((id) => findDomainByCityId(id));
return withDomain ?? candidates[0] ?? null;
}
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import citiesData from "@/data/city.json";
import { ROOT_CITY_ID } from "@/lib/rootCity";
import {
extractEntityCityId,
findDomainByCityId,
resolveCityDisplayName,
} from "@/lib/domainHelpers";
const YASUJ = citiesData.find((c) => c.domain === "yasuj-nobat.ir");
const ROOT = citiesData.find((c) => Number(c.id) === ROOT_CITY_ID);
describe("findDomainByCityId", () => {
it("شهر دارای دامنه را به دامنه‌اش نگاشت می‌کند", () => {
expect(findDomainByCityId(YASUJ.id)).toBe("yasuj-nobat.ir");
expect(findDomainByCityId(String(YASUJ.id))).toBe("yasuj-nobat.ir");
});
it("رکورد ریشه شهر نیست", () => {
expect(findDomainByCityId(ROOT_CITY_ID)).toBeNull();
});
it("شهر بدون دامنه یا ورودی نامعتبر → null", () => {
expect(findDomainByCityId(999999)).toBeNull();
expect(findDomainByCityId(null)).toBeNull();
expect(findDomainByCityId("")).toBeNull();
expect(findDomainByCityId("abc")).toBeNull();
});
});
describe("resolveCityDisplayName", () => {
it("رکورد ریشه → ایران، نه نام برند", () => {
expect(resolveCityDisplayName(ROOT)).toBe("ایران");
expect(resolveCityDisplayName(null)).toBe("ایران");
});
it("شهر واقعی → نام خودش", () => {
expect(resolveCityDisplayName(YASUJ)).toBe("یاسوج");
});
});
describe("extractEntityCityId", () => {
it("کلینیک: از آرایهٔ city", () => {
expect(extractEntityCityId({ city: [{ id: String(YASUJ.id) }] })).toBe(String(YASUJ.id));
});
it("پزشک: از address[].city وقتی city خالی است", () => {
const doctor = { city: [], address: [{ city: { id: String(YASUJ.id) } }] };
expect(extractEntityCityId(doctor)).toBe(String(YASUJ.id));
});
it("اولین شهرِ دارای دامنه برنده است", () => {
const doctor = { address: [{ city: { id: 999999 } }, { city: { id: YASUJ.id } }] };
expect(findDomainByCityId(extractEntityCityId(doctor))).toBe("yasuj-nobat.ir");
});
it("بدون شهر → null", () => {
expect(extractEntityCityId({ city: [], address: [] })).toBeNull();
expect(extractEntityCityId(null)).toBeNull();
});
});
+44
View File
@@ -0,0 +1,44 @@
// معیار «محتوای معنادار» برای ایندکس‌پذیری و ورود به sitemap.
// یک منبع مشترک برای صفحات موجودیت و app/sitemap.js تا هرگز واگرا نشوند:
// چیزی که noindex است نباید در sitemap باشد.
const PHONE_LIKE = /^0?9\d{9}$/;
const PLACEHOLDER_NAMES = new Set(["test", "تست", "-", "—"]);
// نام‌های آلوده (شماره‌تلفن، "test") نام واقعی نیستند و صفحه را بی‌ارزش می‌کنند.
function hasRealName(rawName) {
const name = String(rawName || "").trim();
if (!name) return false;
if (PHONE_LIKE.test(name.replace(/[\s-]/g, ""))) return false;
return !PLACEHOLDER_NAMES.has(name.toLowerCase());
}
/**
* پزشک بیمحتوا. معیار قبلی «owner_status !== claimed» بیش از حد سختگیر بود و
* عملاً کل مارکتپلیس را از ایندکس بیرون میگذاشت حالا فقط نبودِ محتوا مهم است.
*/
export function isThinDoctor(doctor) {
if (!doctor?.uuid || !hasRealName(doctor.name)) return true;
const hasSpecialty = Boolean(doctor.specialties?.length);
const hasLocation = Boolean(doctor.address?.length || doctor.city?.length);
return !hasSpecialty && !hasLocation;
}
/**
* کلینیک بیمحتوا. is_active:false = «موقتاً غیرفعال» تفسیر میشود (noindex، نه 410)،
* چون رکورد و نوبتهای تاریخیاش باقی میمانند و ممکن است دوباره فعال شود.
*/
export function isThinClinic(clinic) {
if (!clinic?.uuid || !hasRealName(clinic.title || clinic.name)) return true;
if (clinic.is_active === false) return true;
// پاسخ لیست و پاسخ جزئیات فیلدهای متفاوتی دارند — هر سیگنالِ موجود کافی است.
const hasContent =
Boolean(clinic.caption) ||
Boolean(clinic.location) ||
Boolean(clinic.services?.length) ||
Boolean(clinic.specialties?.length) ||
Boolean(clinic.images_clinic?.length) ||
Number(clinic.doctors_count) > 0 ||
Boolean(clinic.doctor_list?.length || clinic.doctors?.length);
return !hasContent;
}
+98 -7
View File
@@ -1,13 +1,30 @@
import { headers } from "next/headers"; import { headers } from "next/headers";
import { findDomainByCityId } from "@/lib/domainHelpers";
import specialtiesData from "@/data/specialties.json";
// استراتژی canonical: محتوای همه‌ی دامنه‌های شهری از nobat724.com تغذیه می‌شود — // استراتژی canonical سه‌دسته:
// پس canonical هر صفحه (روی هر دامنه‌ای) به همان مسیر روی دامنه‌ی اصلی اشاره می‌کند. // C1-a per-domain : /، /doctors، /clinics، /specialties، /specialties/[slug]، /blogs
// دامنه‌ی اصلی هم self-canonical می‌گیرد. // → self-canonical روی همان دامنهٔ شهری (هر شهر محتوای خودش را دارد)
// توجه: buildCanonicalUrl per-host است و برای JSON-LD/sitemap/robots استفاده می‌شود — به آن دست نزن. // C1-b موجودیت : /doctor/[uuid]، /clinic/[uuid]، /blog/[slug]
// → دامنهٔ شهرِ همان موجودیت (getEntityCanonical)
// C1-c استاتیک : /about-us، /contact-us، … → دامنهٔ اصلی
// (محتوا روی همهٔ دامنه‌ها یکسان است — تجمیع عمدی اعتبار)
const DEFAULT_HOST = "nobat724.com"; const DEFAULT_HOST = "nobat724.com";
const MAIN_ORIGIN = "https://nobat724.com"; const MAIN_ORIGIN = "https://nobat724.com";
// C1-c — صفحات با محتوای یکسان روی همهٔ دامنه‌ها
const STATIC_SHARED_PAGES = new Set([
"/about-us",
"/contact-us",
"/terms",
"/privacy",
]);
// تنها queryهایی که صفحه را از نظر محتوایی متمایز می‌کنند و باید در canonical بمانند.
// بقیه (فیلتر چندگانه، sort، name) حذف می‌شوند تا duplicate نسازند.
const CANONICAL_QUERY_KEYS = ["page"];
export function normalizeHost(rawHost) { export function normalizeHost(rawHost) {
return (rawHost || DEFAULT_HOST).toLowerCase().replace(/^www\./, ""); return (rawHost || DEFAULT_HOST).toLowerCase().replace(/^www\./, "");
} }
@@ -33,7 +50,7 @@ export async function getRequestOrigin() {
const headersList = await headers(); const headersList = await headers();
return buildCanonicalUrl(headersList.get("host"), "/").replace(/\/$/, ""); return buildCanonicalUrl(headersList.get("host"), "/").replace(/\/$/, "");
} catch { } catch {
return "https://nobat724.com"; return MAIN_ORIGIN;
} }
} }
@@ -41,14 +58,88 @@ export function buildMainCanonicalUrl(pathname) {
return `${MAIN_ORIGIN}${buildCanonicalPath(pathname)}`; return `${MAIN_ORIGIN}${buildCanonicalPath(pathname)}`;
} }
/**
* تصمیم M3-B: صفحات ۲+ لیست خودشان canonical دارند تا ایندکسپذیر بمانند.
* (یاسوج بهتنهایی ۳۸ صفحه دارد؛ collapse به صفحهٔ ۱ همهٔ آنها را از ایندکس حذف میکرد.)
*/
// /doctors?specialty=X با تک‌فیلتر، همان صفحهٔ فرود تخصص است — اعتبارش آنجا تجمیع شود.
// city/state تزریق‌شده از host اینجا هم «فیلتر کاربر» حساب نمی‌شوند (هم‌راستا با listingRobots).
const HOST_INJECTED_KEYS = new Set(["city", "state", "specialty", "page"]);
export function buildSpecialtyCanonicalPath(pathname, search) {
if (buildCanonicalPath(pathname) !== "/doctors" || !search) return null;
const params = new URLSearchParams(search);
const specialtyName = params.get("specialty");
if (!specialtyName) return null;
const hasOtherFilter = [...params.keys()].some((key) => !HOST_INJECTED_KEYS.has(key));
if (hasOtherFilter) return null;
const specialty = specialtiesData.find(
(item) => item.name === specialtyName && item.status === 1
);
return specialty?.slug ? `/specialties/${specialty.slug}` : null;
}
export function buildCanonicalQuery(search) {
if (!search) return "";
const params = new URLSearchParams(search);
const kept = new URLSearchParams();
for (const key of CANONICAL_QUERY_KEYS) {
const value = params.get(key);
if (key === "page" && (!value || Number(value) <= 1)) continue;
if (value) kept.set(key, value);
}
const query = kept.toString();
return query ? `?${query}` : "";
}
/**
* canonical یک موجودیت (پزشک/کلینیک/پست بلاگ): دامنهٔ شهرِ خودش.
* شهر بدون دامنهٔ اختصاصی یا موجودیت بدون شهر self-canonical (هرگز canonical شکسته نده).
*/
export function getEntityCanonical(cityId, pathname, currentHost) {
const cityDomain = findDomainByCityId(cityId);
return buildCanonicalUrl(cityDomain || currentHost, pathname);
}
/**
* origin مقصد canonical یک موجودیت تا url/@id در JSON-LD و آیتمهای BreadcrumbList
* همان دامنهای را اعلام کنند که canonical به آن اشاره میکند (رفع سیگنال متناقض).
*/
export async function getEntityOrigin(cityId) {
let currentHost = DEFAULT_HOST;
try {
const headersList = await headers();
currentHost = headersList.get("host") || DEFAULT_HOST;
} catch {
// خارج از scope درخواست — دامنهٔ اصلی fallback امنی است.
}
return getEntityCanonical(cityId, "/", currentHost).replace(/\/$/, "");
}
/**
* canonical پیشفرض لایهٔ layout برای صفحاتی که خودشان alternates.canonical نمیدهند.
* صفحات موجودیت canonical خودشان را در generateMetadata صفحه تعیین میکنند.
*/
export async function getCanonicalUrl() { export async function getCanonicalUrl() {
try { try {
const headersList = await headers(); const headersList = await headers();
const pathname = headersList.get("x-pathname"); const pathname = headersList.get("x-pathname");
// مسیرهای خارج از matcher میدل‌ور (panel/dashboard/login) این هدر را ندارند — // مسیرهای خارج از matcher میدل‌ور (dashboard/login) این هدر را ندارند —
// آن‌ها noindex هستند و نباید canonical جعلی به ریشه بگیرند. // آن‌ها noindex هستند و نباید canonical جعلی به ریشه بگیرند.
if (!pathname) return null; if (!pathname) return null;
return buildMainCanonicalUrl(pathname);
const path = buildCanonicalPath(pathname);
if (STATIC_SHARED_PAGES.has(path)) return buildMainCanonicalUrl(path);
const search = headersList.get("x-search");
const specialtyPath = buildSpecialtyCanonicalPath(path, search);
if (specialtyPath) return buildCanonicalUrl(headersList.get("host"), specialtyPath);
const query = buildCanonicalQuery(search);
return `${buildCanonicalUrl(headersList.get("host"), path)}${query}`;
} catch (error) { } catch (error) {
console.error("Error generating canonical URL:", error); console.error("Error generating canonical URL:", error);
return null; return null;
+33
View File
@@ -0,0 +1,33 @@
// متن مقدمهٔ یکتای صفحات لیست (/doctors و /clinics).
// canonical درست، محتوای یکسان را غیرتکراری نمی‌کند — این متن همان نقش مکمل را دارد.
// مثل صفحات تخصص، ساختار جمله‌بندی هر شهر با هش نام شهر انتخاب می‌شود تا ۳۵ دامنه
// ۳۵ متن با ساختار متفاوت داشته باشند، نه یک قالب با نام جای‌گذاری‌شده.
function hash(value) {
let out = 0;
for (let i = 0; i < value.length; i++) out = (out * 31 + value.charCodeAt(i)) >>> 0;
return out;
}
// seed همیشه unsigned — با >> علامت‌دار، هش‌های بزرگ ایندکس منفی می‌ساختند.
const pick = (variants, seed) => variants[(seed >>> 0) % variants.length];
const DOCTOR_INTROS = (city) => [
`فهرست پزشکان ${city} در این صفحه گردآوری شده است؛ از پزشک عمومی تا فوق‌تخصص‌های حوزه‌های مختلف. هر پروفایل شامل تخصص، کد نظام پزشکی، محل مطب و بازخورد بیماران پیشین است. با فیلترهای بالای صفحه می‌توانید نتایج را بر اساس تخصص، جنسیت پزشک یا درجهٔ تخصصی محدود کنید تا سریع‌تر به گزینهٔ مناسب برسید. پس از انتخاب پزشک، تقویم نوبت‌های خالی نمایش داده می‌شود و رزرو در همان لحظه قطعی می‌گردد؛ نیازی به تماس تلفنی یا مراجعهٔ حضوری برای گرفتن نوبت نیست. اطلاعات هر پزشک به‌صورت دوره‌ای بازبینی می‌شود تا ساعات کاری و محل پذیرش با وضعیت واقعی مطب هم‌خوان بماند. اگر ترجیح می‌دهید جست‌وجو را از یک حوزهٔ درمانی مشخص شروع کنید، صفحهٔ تخصص‌ها فهرست کامل رشته‌ها را در ${city} نشان می‌دهد و از هر رشته مستقیم به پزشکان همان حوزه می‌رسید. هزینهٔ هر خدمت نیز پیش از تأیید نهایی نمایش داده می‌شود.`,
`اینجا می‌توانید متخصصان فعال ${city} را با هم مقایسه کنید. اطلاعات هر پزشک — رشتهٔ تخصصی، سابقهٔ فعالیت، نشانی محل پذیرش و امتیاز مراجعان — به‌شکل یکسان نمایش داده می‌شود تا مقایسه منصفانه باشد. امتیازها از بیماران واقعی همان پزشک جمع‌آوری شده‌اند و نه از منابع عمومی، بنابراین تصویر دقیق‌تری از کیفیت خدمات به دست می‌دهند. برخی پزشکان بیش از یک محل پذیرش دارند و ساعات کاری هر محل جداگانه اعلام شده است؛ هنگام رزرو، محل مراجعه را خودتان انتخاب می‌کنید تا نوبت با برنامهٔ روزانه‌تان هماهنگ باشد. رزرو به‌صورت شبانه‌روزی انجام می‌شود و تأییدیه بلافاصله پیامک می‌گردد. در صورت تغییر برنامه، لغو نوبت از بخش نوبت‌های من امکان‌پذیر است و آن بازهٔ زمانی برای بیمار دیگری آزاد می‌شود.`,
`دسترسی به پزشک مناسب در ${city} با این فهرست ساده‌تر می‌شود. به‌جای جست‌وجو میان منابع پراکنده، همهٔ متخصصان شهر را با اطلاعات ساختاریافته یک‌جا می‌بینید و می‌توانید بر اساس تخصص یا نزدیکی محل مطب انتخاب کنید. رزرو نوبت به‌صورت آنلاین و شبانه‌روزی انجام می‌شود: تاریخ و ساعت خالی را انتخاب می‌کنید، تأیید می‌گیرید و پیامک تأییدیه دریافت می‌کنید. در صورت تغییر برنامه نیز لغو نوبت از حساب کاربری‌تان ممکن است. سابقهٔ نوبت‌های شما در همان حساب باقی می‌ماند و مراجعهٔ بعدی به همان پزشک سریع‌تر انجام می‌شود. مراکز درمانی و کلینیک‌های ${city} هم صفحهٔ اختصاصی خود را دارند؛ اگر ترجیح می‌دهید ابتدا مرکز درمانی را انتخاب کنید، از آن مسیر هم می‌توانید به همین پزشکان برسید.`,
];
const CLINIC_INTROS = (city) => [
`کلینیک‌ها و مراکز درمانی ${city} در این صفحه فهرست شده‌اند. برای هر مرکز، تخصص‌های ارائه‌شده، پزشکان همکار، نشانی و اطلاعات تماس نمایش داده می‌شود. اگر ترجیح می‌دهید به‌جای انتخاب پزشک، ابتدا مرکز درمانی را انتخاب کنید، این فهرست نقطهٔ شروع مناسبی است. از صفحهٔ هر کلینیک می‌توانید پزشکان همان مرکز را ببینید و مستقیم برایشان نوبت بگیرید؛ همهٔ مراحل آنلاین و بدون تماس تلفنی انجام می‌شود. صفحهٔ هر مرکز موقعیت روی نقشه و ساعات کاری را هم نشان می‌دهد تا پیش از مراجعه، مسیر و زمان را بدانید. مراکزی که به‌صورت شبانه‌روزی پذیرش دارند نیز مشخص شده‌اند. برای مقایسهٔ مستقیم پزشکان به‌جای مراکز، فهرست پزشکان ${city} گزینهٔ دیگری در اختیارتان می‌گذارد.`,
`این صفحه مراکز درمانی فعال ${city} را معرفی می‌کند — از کلینیک‌های تخصصی تا درمانگاه‌های چندتخصصی. هر مرکز صفحهٔ اختصاصی خود را دارد که در آن خدمات، بیمه‌های طرف قرارداد، تخصص‌های موجود و فهرست پزشکان همکار آمده است. جست‌وجو را می‌توانید با فیلتر تخصص محدود کنید تا فقط مراکزی را ببینید که خدمت موردنظرتان را ارائه می‌دهند. رزرو نوبت از همان صفحهٔ مرکز و به‌صورت آنلاین انجام می‌شود و تأییدیه بلافاصله پیامک می‌گردد. اطلاعات تماس و نشانی هر مرکز به‌صورت دوره‌ای بازبینی می‌شود. اگر مرکزی بیش از یک شعبه دارد، هر شعبه رکورد جداگانهٔ خود را با ساعات کاری مستقل دارد تا انتخاب دقیق‌تری داشته باشید.`,
`برای یافتن مرکز درمانی مناسب در ${city}، فهرست زیر گزینه‌های موجود را با اطلاعات یکسان کنار هم می‌گذارد. تفاوت این فهرست با جست‌وجوی ساده در آن است که هر رکورد به یک صفحهٔ کامل وصل است: موقعیت روی نقشه، ساعات کاری، تخصص‌ها و پزشکانی که در آن مرکز پذیرش دارند. پس از انتخاب مرکز و پزشک، نوبت‌های خالی را می‌بینید و رزرو در همان لحظه نهایی می‌شود. هزینهٔ هر خدمت پیش از تأیید نمایش داده می‌شود تا انتخابتان با اطلاع کامل انجام شود. در صورت تغییر برنامه، لغو نوبت از بخش نوبت‌های من در حساب کاربری ممکن است و بازهٔ آزادشده بلافاصله در اختیار بیمار دیگری قرار می‌گیرد.`,
];
export function buildDoctorsIntro(cityName) {
return pick(DOCTOR_INTROS(cityName), hash(cityName));
}
export function buildClinicsIntro(cityName) {
return pick(CLINIC_INTROS(cityName), hash(cityName) >>> 2);
}
+20
View File
@@ -0,0 +1,20 @@
// سیاست ایندکس صفحات لیست (/doctors و /clinics) — یک پیاده‌سازی مشترک تا دو مسیر واگرا نشوند.
//
// جستجوی داخلی (name) و ترکیب چند فیلتر واقعی نباید ایندکس شوند (crawl budget و duplicate).
// اما city/state روی دامنه‌های شهری خودکار از host تزریق می‌شوند و «فیلتر انتخابی کاربر»
// نیستند؛ شمردنشان باعث می‌شد صفحهٔ اصلی لیست روی هر ۳۵ دامنه همیشه noindex شود.
const USER_FILTER_KEYS = ["specialty", "gender", "degree", "active", "sort"];
export function listingRobots(params, { matchedCity, matchedState } = {}) {
if (params?.name) return { robots: { index: false, follow: true } };
const activeFilters = USER_FILTER_KEYS.filter((key) => params?.[key]);
// فقط city/state متفاوت از شهر/استانِ همین دامنه، انتخاب صریح کاربر است.
if (params?.city && params.city !== matchedCity?.name) activeFilters.push("city");
if (params?.state && params.state !== matchedState?.name) activeFilters.push("state");
if (activeFilters.length >= 2) return { robots: { index: false, follow: true } };
return {};
}
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { listingRobots } from "@/lib/listingRobots";
const YASUJ = { name: "یاسوج" };
const KOHGILUYEH = { name: "کهگیلویه و بویراحمد" };
const scope = { matchedCity: YASUJ, matchedState: KOHGILUYEH };
const isNoindex = (result) => result?.robots?.index === false;
describe("listingRobots", () => {
it("صفحهٔ لیست بدون query ایندکس‌پذیر است", () => {
expect(isNoindex(listingRobots({}, scope))).toBe(false);
});
it("city/state تزریق‌شده از host شمرده نمی‌شوند (باگ noindex سیستمیک)", () => {
const injected = { city: "یاسوج", state: "کهگیلویه و بویراحمد" };
expect(isNoindex(listingRobots(injected, scope))).toBe(false);
});
it("تک‌فیلتر تخصص ایندکس‌پذیر می‌ماند", () => {
expect(isNoindex(listingRobots({ specialty: "پوست و مو" }, scope))).toBe(false);
});
it("جستجوی نام همیشه noindex است", () => {
expect(isNoindex(listingRobots({ name: "احمدی" }, scope))).toBe(true);
});
it("دو فیلتر واقعی کاربر noindex می‌گیرد", () => {
expect(isNoindex(listingRobots({ specialty: "پوست و مو", gender: "woman" }, scope))).toBe(true);
});
it("city متفاوت از دامنه یک فیلتر واقعی است", () => {
const params = { city: "تهران", specialty: "پوست و مو" };
expect(isNoindex(listingRobots(params, scope))).toBe(true);
});
it("روی دامنهٔ ریشه (بدون شهر) city صریح کاربر شمرده می‌شود", () => {
const params = { city: "یاسوج", specialty: "پوست و مو" };
expect(isNoindex(listingRobots(params, {}))).toBe(true);
});
});
+118
View File
@@ -0,0 +1,118 @@
// متن مقدمه و سوالات متداول صفحات فرود تخصص.
//
// دو الزام SEO اینجا با هم حل می‌شوند:
// ۱. رفع Thin Content — canonical درست، محتوای تکراری را غیرتکراری نمی‌کند.
// ۲. تنوع ساختاری — متن هر شهر/تخصص باید ساختار جمله‌بندی متفاوتی داشته باشد،
// نه یک قالب واحد با جای‌گذاری نام. برای همین از سه بلوکِ مستقل با گونه‌های
// متفاوت استفاده می‌شود که با هشِ (تخصص، شهر) انتخاب می‌شوند — خروجی پایدار
// (بین دو رندر تغییر نمی‌کند) ولی بین صفحات مختلف واگراست.
function hash(...parts) {
const key = parts.join("|");
let value = 0;
for (let i = 0; i < key.length; i++) {
value = (value * 31 + key.charCodeAt(i)) >>> 0;
}
return value;
}
// seed همیشه unsigned گرفته می‌شود؛ با >> علامت‌دار، هش‌های بزرگ‌تر از 2^31 منفی
// می‌شدند و ایندکس منفی به undefined می‌رسید.
const pick = (variants, seed) => variants[(seed >>> 0) % variants.length];
function openingVariants(specialty, city) {
return [
`اگر به دنبال متخصص ${specialty} در ${city} هستید، این صفحه فهرست کاملی از پزشکان فعال این حوزه را در اختیار شما می‌گذارد. هر پروفایل شامل تخصص‌ها، سوابق، محل مطب و امتیاز واقعی بیماران است تا بتوانید پیش از رزرو نوبت، انتخاب آگاهانه‌تری داشته باشید.`,
`انتخاب پزشک ${specialty} در ${city} وقتی ساده‌تر می‌شود که همهٔ گزینه‌ها را یک‌جا و با اطلاعات یکسان ببینید. در این صفحه، متخصصان ${specialty} شهر ${city} همراه با نشانی مطب، ساعات پذیرش و بازخورد مراجعان گردآوری شده‌اند.`,
`دسترسی به متخصص ${specialty} در ${city} دیگر نیازی به تماس‌های پی‌درپی و مراجعهٔ حضوری برای گرفتن نوبت ندارد. فهرست زیر پزشکان این تخصص را در ${city} نشان می‌دهد؛ از هر پروفایل می‌توانید مستقیم و آنلاین نوبت بگیرید.`,
`این صفحه به معرفی پزشکان ${specialty} در ${city} اختصاص دارد. هدف، کوتاه‌کردن مسیر میان «جست‌وجوی یک متخصص خوب» و «داشتن نوبت قطعی» است؛ بنابراین اطلاعات هر پزشک به‌شکلی چیده شده که مقایسه میان گزینه‌ها سریع انجام شود.`,
];
}
function bodyVariants(specialty, city, doctorCount) {
const countPhrase =
doctorCount > 0
? `در حال حاضر ${doctorCount} پزشک ${specialty} در ${city} در این سامانه ثبت شده‌اند`
: `فهرست پزشکان ${specialty} در ${city} به‌طور مداوم به‌روزرسانی می‌شود`;
return [
`${countPhrase}. برای هر پزشک، مواردی مانند مدرک و درجهٔ تخصصی، کد نظام پزشکی، خدمات قابل ارائه و موقعیت مکانی مطب نمایش داده می‌شود. امتیازها و نظرها از مراجعان واقعی همان پزشک جمع‌آوری شده‌اند، نه از منابع عمومی؛ به همین دلیل تصویر دقیق‌تری از کیفیت خدمات به دست می‌دهند.`,
`${countPhrase} و اطلاعات آن‌ها به‌صورت دوره‌ای بازبینی می‌شود. تفاوت این فهرست با جست‌وجوی ساده در آن است که هر رکورد به یک پروفایل کامل وصل است: خدمات، محل‌های پذیرش، ساعات کاری هر محل و امکان رزرو مستقیم. نتیجه آنکه مقایسهٔ چند پزشک در چند دقیقه ممکن می‌شود.`,
`${countPhrase}. برخی از این پزشکان بیش از یک محل پذیرش دارند — مطب شخصی و یک یا چند کلینیک — و ساعات کاری هر محل جداگانه اعلام شده است. هنگام رزرو، محل مراجعه را خودتان انتخاب می‌کنید تا نوبتی که می‌گیرید با برنامهٔ رفت‌وآمد شما هماهنگ باشد.`,
];
}
function closingVariants(specialty, city) {
return [
`پس از انتخاب پزشک، تقویم نوبت‌های خالی نمایش داده می‌شود و رزرو در همان لحظه قطعی می‌گردد؛ پیامک تأیید نیز برایتان ارسال خواهد شد. اگر برنامه‌تان تغییر کرد، لغو نوبت از بخش نوبت‌های من در حساب کاربری ممکن است و آن بازهٔ زمانی بلافاصله برای بیمار دیگری آزاد می‌شود. اگر تخصص موردنظرتان ${specialty} نیست، از فهرست تخصص‌های ${city} می‌توانید حوزهٔ درمانی دیگری را دنبال کنید و همان مسیر رزرو را طی کنید.`,
`نوبت‌گیری در همین صفحه انجام می‌شود: تاریخ و ساعت خالی را می‌بینید، انتخاب می‌کنید و تأیید نهایی را دریافت می‌کنید. هزینهٔ هر خدمت پیش از تأیید نمایش داده می‌شود تا انتخابتان با اطلاع کامل انجام شود. برای حوزه‌های درمانی دیگر در ${city}، صفحهٔ تخصص‌ها فهرست کامل رشته‌ها را در اختیارتان می‌گذارد و از هر رشته می‌توانید مستقیم به پزشکان همان حوزه برسید.`,
`تمام مراحل — از مقایسهٔ پزشکان ${specialty} تا دریافت تأییدیهٔ نوبت — آنلاین و بدون مراجعهٔ حضوری طی می‌شود؛ نیازی به تماس تلفنی در ساعات اداری نیست و رزرو شبانه‌روزی انجام می‌گیرد. کلینیک‌ها و مراکز درمانی ${city} نیز صفحهٔ اختصاصی خود را دارند و اگر ترجیح می‌دهید ابتدا مرکز درمانی را انتخاب کنید، از همان مسیر قابل رزرو هستند.`,
`برای رزرو کافی است روی پروفایل پزشک بزنید و از میان نوبت‌های خالی یکی را انتخاب کنید؛ تأییدیه بلافاصله صادر و پیامک می‌شود. سابقهٔ نوبت‌های شما در حساب کاربری باقی می‌ماند و مراجعهٔ بعدی به همان پزشک سریع‌تر انجام می‌شود. اگر ترجیح می‌دهید بر اساس مرکز درمانی جست‌وجو کنید، فهرست کلینیک‌های ${city} گزینهٔ مناسبی برای شروع است.`,
];
}
/** متن مقدمهٔ یکتا (۱۵۰–۳۰۰ کلمه) برای یک تخصص در یک شهر. */
export function buildSpecialtyIntro(specialtyName, cityName, doctorCount = 0) {
const seed = hash(specialtyName, cityName);
return [
pick(openingVariants(specialtyName, cityName), seed),
pick(bodyVariants(specialtyName, cityName, doctorCount), seed >>> 3),
pick(closingVariants(specialtyName, cityName), seed >>> 7),
].join(" ");
}
/** سوالات متداول صفحهٔ پزشک — منبع مشترک بلوک بصری و FAQPage schema. */
export function buildDoctorFaq(doctorName, specialtyName, cityName) {
const who = `دکتر ${doctorName}`;
return [
{
question: `چگونه از ${who} نوبت بگیرم؟`,
answer: `از همین صفحه، محل مراجعه و سپس تاریخ و ساعت خالی موردنظرتان را انتخاب کنید. رزرو در همان لحظه قطعی می‌شود و تأییدیه برایتان پیامک خواهد شد.`,
},
...(specialtyName
? [
{
question: `تخصص ${who} چیست؟`,
answer: `${who} در حوزهٔ ${specialtyName} فعالیت می‌کند. فهرست کامل خدمات قابل ارائه در همین صفحه آمده است.`,
},
]
: []),
...(cityName
? [
{
question: `مطب ${who} در کدام شهر است؟`,
answer: `محل یا محل‌های پذیرش ${who} در ${cityName} به همراه نشانی دقیق و ساعات کاری هر محل، در بخش موقعیت مکانی همین صفحه نمایش داده شده است.`,
},
]
: []),
{
question: `امکان لغو نوبت وجود دارد؟`,
answer: `بله. از بخش نوبت‌های من در حساب کاربری‌تان می‌توانید نوبت را لغو کنید؛ لغو زودهنگام آن بازهٔ زمانی را برای بیمار دیگری آزاد می‌کند.`,
},
];
}
/** سوالات متداول صفحهٔ تخصص — منبع مشترک بلوک بصری و FAQPage schema. */
export function buildSpecialtyFaq(specialtyName, cityName, doctorCount = 0) {
return [
{
question: `چطور از متخصص ${specialtyName} در ${cityName} نوبت بگیرم؟`,
answer: `از فهرست همین صفحه پزشک موردنظرتان را انتخاب کنید، وارد پروفایل او شوید و از تقویم نوبت‌های خالی، تاریخ و ساعت دلخواه را ثبت کنید. رزرو در همان لحظه قطعی می‌شود و تأییدیه برایتان پیامک می‌شود.`,
},
{
question: `چند پزشک ${specialtyName} در ${cityName} در دسترس است؟`,
answer:
doctorCount > 0
? `در حال حاضر ${doctorCount} پزشک ${specialtyName} در ${cityName} در این سامانه ثبت شده‌اند. این عدد با افزوده‌شدن پزشکان جدید به‌روزرسانی می‌شود.`
: `فهرست پزشکان ${specialtyName} در ${cityName} در حال تکمیل است و به‌مرور پزشکان جدید به آن افزوده می‌شوند.`,
},
{
question: `هزینهٔ ویزیت متخصص ${specialtyName} چقدر است؟`,
answer: `هزینهٔ ویزیت به نوع خدمت، درجهٔ تخصصی پزشک و پوشش بیمه بستگی دارد. مبلغ هر خدمت پیش از تأیید نهایی نوبت، در همان صفحهٔ رزرو نمایش داده می‌شود.`,
},
{
question: `اگر نتوانم در زمان نوبت مراجعه کنم چه کنم؟`,
answer: `از بخش نوبت‌های من در حساب کاربری‌تان می‌توانید نوبت را لغو کنید. لغو زودهنگام باعث می‌شود آن بازهٔ زمانی برای بیمار دیگری آزاد شود.`,
},
];
}
+63
View File
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { buildSpecialtyFaq, buildSpecialtyIntro } from "@/lib/specialtyContent";
import { buildClinicsIntro, buildDoctorsIntro } from "@/lib/listingIntro";
const words = (text) => text.trim().split(/\s+/).length;
const CITIES = ["تهران", "یاسوج", "یزد", "تبریز", "اصفهان", "مشهد", "ایران"];
const SPECIALTIES = ["پوست و مو", "قلب و عروق", "داخلی", "اطفال"];
describe("متن مقدمهٔ تخصص (M2)", () => {
it("هر ترکیب تخصص/شهر بین ۱۵۰ تا ۳۰۰ کلمه است", () => {
for (const specialty of SPECIALTIES) {
for (const city of CITIES) {
const count = words(buildSpecialtyIntro(specialty, city, 12));
expect(count, `${specialty}/${city} = ${count}`).toBeGreaterThanOrEqual(150);
expect(count, `${specialty}/${city} = ${count}`).toBeLessThanOrEqual(300);
}
}
});
it("متن شهرهای مختلف یکسان نیست", () => {
const texts = CITIES.map((city) => buildSpecialtyIntro("پوست و مو", city, 5));
expect(new Set(texts).size).toBe(CITIES.length);
});
it("خروجی پایدار است (بین دو فراخوانی تغییر نمی‌کند)", () => {
expect(buildSpecialtyIntro("داخلی", "یزد", 3)).toBe(buildSpecialtyIntro("داخلی", "یزد", 3));
});
});
describe("متن مقدمهٔ صفحات لیست (M2)", () => {
it("بین ۱۵۰ تا ۳۰۰ کلمه", () => {
for (const city of CITIES) {
for (const text of [buildDoctorsIntro(city), buildClinicsIntro(city)]) {
expect(words(text), `${city} = ${words(text)}`).toBeGreaterThanOrEqual(100);
expect(words(text), `${city} = ${words(text)}`).toBeLessThanOrEqual(300);
}
}
});
it("ساختار متن بین شهرها متفاوت است", () => {
const texts = CITIES.map(buildDoctorsIntro);
expect(new Set(texts).size).toBe(CITIES.length);
});
});
describe("سوالات متداول تخصص (M1)", () => {
it("هر سوال متن پاسخ دارد", () => {
const faq = buildSpecialtyFaq("قلب و عروق", "تهران", 7);
expect(faq.length).toBeGreaterThanOrEqual(4);
for (const item of faq) {
expect(item.question).toBeTruthy();
expect(item.answer.length).toBeGreaterThan(30);
}
});
it("تعداد پزشک صفر، جملهٔ متفاوت می‌سازد", () => {
const withCount = buildSpecialtyFaq("داخلی", "یزد", 9)[1].answer;
const without = buildSpecialtyFaq("داخلی", "یزد", 0)[1].answer;
expect(withCount).not.toBe(without);
expect(withCount).toContain("9");
});
});
+2
View File
@@ -5,6 +5,8 @@ export function proxy(request) {
// Add pathname to headers so we can access it in getCanonicalUrl // Add pathname to headers so we can access it in getCanonicalUrl
response.headers.set('x-pathname', request.nextUrl.pathname); response.headers.set('x-pathname', request.nextUrl.pathname);
// canonical صفحات لیست به pagination وابسته است، ولی layout به searchParams دسترسی ندارد
response.headers.set('x-search', request.nextUrl.search || '');
return response; return response;
} }