feat: add canonical URL handling and entity quality checks
- Implemented canonical URL strategies for city-specific domains and entities. - Added helper functions for domain and city resolution. - Created tests for canonical URL generation and domain resolution. - Introduced entity quality checks for doctors and clinics to ensure meaningful content. - Developed unique introductory texts for listing pages to avoid duplicate content. - Established robots.txt policies for listing pages to manage indexing based on user filters. - Enhanced specialty content with dynamic introductions and FAQs to improve SEO.
This commit is contained in:
@@ -1,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,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>
|
||||
|
||||
@@ -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="بیمه های طرف قرارداد">
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -29,6 +29,7 @@ function SpecialtiesPage({ cityId, cityName, stateName }) {
|
||||
return (
|
||||
<>
|
||||
<HeadPageList
|
||||
titleTag="p"
|
||||
title="تخصص مورد نظر شما چیست؟"
|
||||
detail="تخصص مورد نظرتان را جستجو کرده و یا از لیست زیر انتخاب نمایید:"
|
||||
>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user