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,27 +0,0 @@
|
||||
import CircularLoading from "@/app/component/loading/Circular";
|
||||
import CustomLoading from "@/app/component/loading/Custom";
|
||||
import TextLoading from "@/app/component/loading/Text";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="padding-responsive pt-[95px] sm:pt-[120px]">
|
||||
<div className="flex flex-col lg:flex-row items-center justify-start gap-[8px] lg:gap-[32px]">
|
||||
<CircularLoading
|
||||
loading
|
||||
width={100}
|
||||
height={100}
|
||||
className="w-[40px] sm:w-[81px] md:w-[123px] lg:w-[164px] h-[40px] sm:h-[81px] md:h-[123px] lg:h-[164px]"
|
||||
/>
|
||||
<div className="flex flex-col items-center lg:items-start gap-[8px] lg:gap-[16px]">
|
||||
<TextLoading loading width={90} height={20} />
|
||||
<TextLoading loading width={100} height={20} />
|
||||
<div className="flex items-center justify-start gap-11">
|
||||
<TextLoading loading width={60} height={20} />
|
||||
<TextLoading loading width={120} height={20} />
|
||||
</div>
|
||||
<CustomLoading loading width={160} height={25} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+48
-11
@@ -3,7 +3,11 @@ import DoctorPage from "@/components/doctor";
|
||||
import Layout from "@/components/layout/StLayout";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getStateInfo } from "@/lib/getStateInfo";
|
||||
import { getRequestOrigin } from "@/lib/getCanonicalUrl";
|
||||
import { getEntityOrigin } from "@/lib/getCanonicalUrl";
|
||||
import { extractEntityCityId, findCityById } from "@/lib/domainHelpers";
|
||||
import { buildDoctorFaq } from "@/lib/specialtyContent";
|
||||
import { isThinDoctor } from "@/lib/entityQuality";
|
||||
import specialtiesData from "@/data/specialties.json";
|
||||
import { safeJsonLd } from "@/lib/sanitize";
|
||||
import { imageUrl } from "@/helper";
|
||||
|
||||
@@ -77,12 +81,14 @@ export async function generateMetadata({ params }) {
|
||||
`رزرو نوبت آنلاین دکتر ${doctor.name}${specialtyNames ? " متخصص " + specialtyNames : ""}${doctor.address ? " | " + doctor.address : ""}`.trim()
|
||||
).slice(0, 160);
|
||||
const image = imageUrl(doctor.img?.[0]?.url) || FALLBACK_IMG;
|
||||
const isUnclaimed = doctor.owner_status !== "claimed";
|
||||
// C1-b — پزشک به دامنهٔ شهر خودش canonical میشود، روی هر دامنهای که سرو شود.
|
||||
const origin = await getEntityOrigin(extractEntityCityId(doctor));
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
...(isUnclaimed && { robots: { index: false, follow: true } }),
|
||||
alternates: { canonical: `${origin}/doctor/${doctor.uuid}` },
|
||||
...(isThinDoctor(doctor) && { robots: { index: false, follow: true } }),
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
@@ -96,20 +102,24 @@ export async function generateMetadata({ params }) {
|
||||
images: [image],
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
} catch (error) {
|
||||
// بازگشت خاموش، canonical و robots صفحه را بیصدا به fallback لایه میبرد — لاگ لازم است.
|
||||
console.error(`[doctor/${slug}] generateMetadata failed:`, error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function Doctor({ params }) {
|
||||
const { slug } = await params;
|
||||
const origin = await getRequestOrigin();
|
||||
let comments = null;
|
||||
let rateAggregate = { point: 0, satisfaction: 0, averages: [] };
|
||||
|
||||
const doctor = await getDoctor(slug);
|
||||
if (!doctor) notFound();
|
||||
|
||||
// همان مقصد canonical — تا url/@id و breadcrumb سیگنال متناقض ندهند.
|
||||
const origin = await getEntityOrigin(extractEntityCityId(doctor));
|
||||
|
||||
if (doctor) {
|
||||
try {
|
||||
const [resComments, resRate] = await Promise.all([
|
||||
@@ -229,23 +239,43 @@ async function Doctor({ params }) {
|
||||
: null;
|
||||
|
||||
const specialtyLabel = doctor?.specialties?.[0]?.name || "پزشکان";
|
||||
// مقصد تمیز صفحهٔ فرود تخصص؛ اگر تخصص slug نداشت، به لیست پزشکان برمیگردیم.
|
||||
const specialtySlug = specialtiesData.find(
|
||||
(item) => item.name === specialtyLabel && item.status === 1
|
||||
)?.slug;
|
||||
const specialtyItem = specialtySlug
|
||||
? `${origin}/specialties/${specialtySlug}`
|
||||
: `${origin}/doctors`;
|
||||
|
||||
const breadcrumbJsonLd = doctor
|
||||
? {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{ "@type": "ListItem", position: 1, name: "خانه", item: origin },
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 2,
|
||||
name: specialtyLabel,
|
||||
item: `${origin}/doctors?specialty=${encodeURIComponent(specialtyLabel)}`,
|
||||
},
|
||||
{ "@type": "ListItem", position: 2, name: specialtyLabel, item: specialtyItem },
|
||||
{ "@type": "ListItem", position: 3, name: `دکتر ${doctor.name}` },
|
||||
],
|
||||
}
|
||||
: null;
|
||||
|
||||
// FAQPage عمداً script مستقل است — اسکیماهای Physician/Review/Geo نباید تحت تأثیر باشند.
|
||||
const doctorCity = findCityById(extractEntityCityId(doctor));
|
||||
const faq = doctor
|
||||
? buildDoctorFaq(doctor.name, specialtyNames, doctorCity?.name)
|
||||
: [];
|
||||
const faqJsonLd = faq.length
|
||||
? {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
mainEntity: faq.map((item) => ({
|
||||
"@type": "Question",
|
||||
name: item.question,
|
||||
acceptedAnswer: { "@type": "Answer", text: item.answer },
|
||||
})),
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{jsonLd && (
|
||||
@@ -254,6 +284,12 @@ async function Doctor({ params }) {
|
||||
dangerouslySetInnerHTML={{ __html: safeJsonLd(jsonLd) }}
|
||||
/>
|
||||
)}
|
||||
{faqJsonLd && (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: safeJsonLd(faqJsonLd) }}
|
||||
/>
|
||||
)}
|
||||
{breadcrumbJsonLd && (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
@@ -269,6 +305,7 @@ async function Doctor({ params }) {
|
||||
bookableAddressUuids={[...bookableAddressUuids]}
|
||||
bookingLocations={bookingLocations}
|
||||
slug={slug}
|
||||
faq={faq}
|
||||
/>
|
||||
</Layout>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user