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
+13 -1
View File
@@ -1,7 +1,7 @@
import Link from "next/link";
import { convertTimestampToJalali } from "@/helper";
function Head({ data }) {
function Head({ data, cityName }) {
const firstTag = data?.tag?.[0];
const createdDate = data?.created ? convertTimestampToJalali(data.created) : "";
@@ -34,6 +34,18 @@ function Head({ data }) {
</h1>
</div>
<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 && (
<div className="flex items-center justify-start gap-1">
<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 RelatedContent from "./relatedContent";
function BlogPage({ blog, blogs }) {
function BlogPage({ blog, blogs, cityName }) {
return (
<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]">
<Detail data={blog} />
<RelatedContent data={blogs} />
@@ -1,11 +1,12 @@
import TextLoading from "@/app/component/loading/Text";
import React from "react";
function ContentDetail({ head, children }) {
// عنوان بخش‌ها h2 است؛ فقط نام خود کلینیک h1 می‌گیرد (as="h1"). کلاس‌ها یکسان — بدون تغییر بصری.
function ContentDetail({ head, children, as: Heading = "h2" }) {
return (
<div className="my-[24px]">
<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>
<div className="mt-[16px]">{children}</div>
</div>
+1 -1
View File
@@ -9,7 +9,7 @@ function About({ data }) {
return (
<div className="opacity-page transition-all duration-500">
<Images data={data} />
<ContentDetail head={data.title}>
<ContentDetail head={data.title} as="h1">
<TextDetail data={data} />
</ContentDetail>
<ContentDetail head="بیمه های طرف قرارداد">
+12 -1
View File
@@ -6,10 +6,11 @@ import About from "./components/about";
import Contact from "./components/contact";
import Doctors from "./components/doctors";
import Tabs from "@/app/component/Tabs";
import Pageguide from "@/app/component/Pageguide";
const listTab = ["درباره کلینیک", "اطلاعات تماس", "پزشک ها"];
function ClinicPage({ data, doctors,slug ,pages}) {
function ClinicPage({ data, doctors, slug, pages, origin }) {
const [value, setValue] = useState(0);
const handleChange = (_, newValue) => setValue(newValue);
@@ -22,6 +23,16 @@ function ClinicPage({ data, doctors,slug ,pages}) {
return (
<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
style={{
".MuiTabs-indicator": {
+8 -1
View File
@@ -12,6 +12,8 @@ import { useSearchParams } from "next/navigation";
import states from "@/data/state.json";
import cities from "@/data/city.json";
import { isRootCity } from "@/lib/rootCity";
import { resolveCityDisplayName } from "@/lib/domainHelpers";
import { buildClinicsIntro } from "@/lib/listingIntro";
function ClinicsPage({ clinics, matchedCity, matchedState }) {
const searchParams = useSearchParams();
@@ -35,7 +37,7 @@ function ClinicsPage({ clinics, matchedCity, matchedState }) {
return (
<div>
<HeadPageList
title="لیست کلینیک ها و درمانگاه های تخصصی"
title={`کلینیکها و مراکز درمانی ${resolveCityDisplayName(matchedCity)}`}
detail="در قسمت زیر می توانید کلینیک مورد نظر خود را جستجو نمایید"
>
<SearchBar
@@ -48,6 +50,11 @@ function ClinicsPage({ clinics, matchedCity, matchedState }) {
setFilteredClinics={setFilteredClinics}
/>
</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
limit={12}
page={page}
+5 -1
View File
@@ -15,7 +15,11 @@ function List({
selected,
setFilteredClinics,
}) {
const listPage = ["کلینیک ها", selected?.city?.name || ""];
const listPage = [
{ name: "خانه", href: "/" },
{ name: "کلینیک ها", href: "/clinics" },
{ name: selected?.city?.name || "" },
];
const [loading, setLoading] = useState(false);
const router = useRouter();
+11 -5
View File
@@ -4,8 +4,16 @@ import ClaimProfileSection from "./claim";
import DetailDoctor from "./detailDoctor";
import Share from "./detailDoctor/Share";
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 (
<div className="pt-[92px] sm:pt-[120px] mt:pt-[148px] lg:pt-[176px] mt-[px] padding-responsive">
<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">
{doctor?.specialties?.map((item, idx) => (
<span key={item.id ?? item.name} className="flex items-center">
<Link
href={`/doctors?specialty=${encodeURIComponent(item.name)}`}
className="hover:text-[#525252]"
>
<Link href={specialtyHref(item.name)} className="hover:text-[#525252]">
{item.name}
</Link>
<span className="mx-1">
@@ -44,6 +49,7 @@ function DoctorPage({ doctor, comments, rateAggregate, addresses, bookableAddres
<AppointmentList doctor={doctor} doctorSlug={slug} bookingLocations={bookingLocations} />
</div>
<ClaimProfileSection doctor={doctor} />
<Faq items={faq} />
</div>
);
}
+3 -2
View File
@@ -20,12 +20,13 @@ function FilterLocate({ filter, sendReq, setFilter, setDataInURL }) {
onClick={() => setOpen(true)}
>
<LocationD />
<h1 className="text-[#525252] text-[16px] font-semibold">
{/* متن دکمهٔ انتخاب موقعیت، عنوان صفحه نیست — تگ عوض شد، کلاس‌ها دست‌نخورده */}
<span className="text-[#525252] text-[16px] font-semibold">
{" "}
{filter?.state?.name || "استان"}
{" / "}
{filter?.city?.name || "شهر"}
</h1>
</span>
</Button>
</ModalSearchCity>
);
+9
View File
@@ -8,6 +8,8 @@ import specialties from "@/data/specialties.json";
import states from "@/data/state.json";
import cities from "@/data/city.json";
import { isRootCity } from "@/lib/rootCity";
import { resolveCityDisplayName } from "@/lib/domainHelpers";
import { buildDoctorsIntro } from "@/lib/listingIntro";
function DoctorsPage({ matchedCity, matchedState, list }) {
const searchParams = useSearchParams();
@@ -50,6 +52,13 @@ function DoctorsPage({ matchedCity, matchedState, list }) {
return (
<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
limit={12}
filter={filter}
+7 -4
View File
@@ -7,10 +7,13 @@ async function TextHeader() {
return (
<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}
</h1>
<h2
</p>
<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"
>
<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]">
<AText />
</div>
</h2>
</h1>
</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 (
<>
<HeadPageList
titleTag="p"
title="تخصص مورد نظر شما چیست؟"
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]">
<MenuS />
<h1 className="text-[#3B3B3B] text-[16px] sm:text-[19px] md:text-[22px] lg:text-[24px] font-bold">
لیست تخصص ها
لیست تخصصهای پزشکی در {cityName || "ایران"}
</h1>
</div>
{specialties.length === 0 ? (