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
-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 { notFound } from "next/navigation";
import { getStateInfo } from "@/lib/getStateInfo";
import { getRequestOrigin } from "@/lib/getCanonicalUrl";
import { getEntityOrigin } from "@/lib/getCanonicalUrl";
import { extractEntityCityId, findCityById } from "@/lib/domainHelpers";
import { buildDoctorFaq } from "@/lib/specialtyContent";
import { isThinDoctor } from "@/lib/entityQuality";
import specialtiesData from "@/data/specialties.json";
import { safeJsonLd } from "@/lib/sanitize";
import { imageUrl } from "@/helper";
@@ -77,12 +81,14 @@ export async function generateMetadata({ params }) {
`رزرو نوبت آنلاین دکتر ${doctor.name}${specialtyNames ? " متخصص " + specialtyNames : ""}${doctor.address ? " | " + doctor.address : ""}`.trim()
).slice(0, 160);
const image = imageUrl(doctor.img?.[0]?.url) || FALLBACK_IMG;
const isUnclaimed = doctor.owner_status !== "claimed";
// C1-b — پزشک به دامنهٔ شهر خودش canonical می‌شود، روی هر دامنه‌ای که سرو شود.
const origin = await getEntityOrigin(extractEntityCityId(doctor));
return {
title,
description,
...(isUnclaimed && { robots: { index: false, follow: true } }),
alternates: { canonical: `${origin}/doctor/${doctor.uuid}` },
...(isThinDoctor(doctor) && { robots: { index: false, follow: true } }),
openGraph: {
title,
description,
@@ -96,20 +102,24 @@ export async function generateMetadata({ params }) {
images: [image],
},
};
} catch {
} catch (error) {
// بازگشت خاموش، canonical و robots صفحه را بی‌صدا به fallback لایه می‌برد — لاگ لازم است.
console.error(`[doctor/${slug}] generateMetadata failed:`, error);
return {};
}
}
async function Doctor({ params }) {
const { slug } = await params;
const origin = await getRequestOrigin();
let comments = null;
let rateAggregate = { point: 0, satisfaction: 0, averages: [] };
const doctor = await getDoctor(slug);
if (!doctor) notFound();
// همان مقصد canonical — تا url/@id و breadcrumb سیگنال متناقض ندهند.
const origin = await getEntityOrigin(extractEntityCityId(doctor));
if (doctor) {
try {
const [resComments, resRate] = await Promise.all([
@@ -229,23 +239,43 @@ async function Doctor({ params }) {
: null;
const specialtyLabel = doctor?.specialties?.[0]?.name || "پزشکان";
// مقصد تمیز صفحهٔ فرود تخصص؛ اگر تخصص slug نداشت، به لیست پزشکان برمی‌گردیم.
const specialtySlug = specialtiesData.find(
(item) => item.name === specialtyLabel && item.status === 1
)?.slug;
const specialtyItem = specialtySlug
? `${origin}/specialties/${specialtySlug}`
: `${origin}/doctors`;
const breadcrumbJsonLd = doctor
? {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{ "@type": "ListItem", position: 1, name: "خانه", item: origin },
{
"@type": "ListItem",
position: 2,
name: specialtyLabel,
item: `${origin}/doctors?specialty=${encodeURIComponent(specialtyLabel)}`,
},
{ "@type": "ListItem", position: 2, name: specialtyLabel, item: specialtyItem },
{ "@type": "ListItem", position: 3, name: `دکتر ${doctor.name}` },
],
}
: null;
// FAQPage عمداً script مستقل است — اسکیماهای Physician/Review/Geo نباید تحت تأثیر باشند.
const doctorCity = findCityById(extractEntityCityId(doctor));
const faq = doctor
? buildDoctorFaq(doctor.name, specialtyNames, doctorCity?.name)
: [];
const faqJsonLd = faq.length
? {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: faq.map((item) => ({
"@type": "Question",
name: item.question,
acceptedAnswer: { "@type": "Answer", text: item.answer },
})),
}
: null;
return (
<>
{jsonLd && (
@@ -254,6 +284,12 @@ async function Doctor({ params }) {
dangerouslySetInnerHTML={{ __html: safeJsonLd(jsonLd) }}
/>
)}
{faqJsonLd && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: safeJsonLd(faqJsonLd) }}
/>
)}
{breadcrumbJsonLd && (
<script
type="application/ld+json"
@@ -269,6 +305,7 @@ async function Doctor({ params }) {
bookableAddressUuids={[...bookableAddressUuids]}
bookingLocations={bookingLocations}
slug={slug}
faq={faq}
/>
</Layout>
</>