Files
nobat724_front/app/blog/[slug]/page.js
T
hamed fd48b48613 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.
2026-07-19 07:52:50 +03:30

142 lines
4.4 KiB
JavaScript

import { cache } from "react";
import BlogPage from "@/components/blog";
import Layout from "@/components/layout/StLayout";
import { notFound } from "next/navigation";
import { fetchReq } from "@/lib/req";
import { getStateInfo } from "@/lib/getStateInfo";
import { getEntityOrigin } from "@/lib/getCanonicalUrl";
import { extractEntityCityId, findCityById } from "@/lib/domainHelpers";
import { safeJsonLd } from "@/lib/sanitize";
import { normalizeBlog, imageUrl } from "@/helper";
const FALLBACK_IMG = "/assets/images/og-image.png";
const API_URL = process.env.NEXT_PUBLIC_API_URL;
const getBlog = cache(async (slug) => {
const res = await fetch(`${API_URL}/api/v1/blog/${slug}`, {
next: { revalidate: 3600, tags: [`blog-${slug}`] },
});
if (res.status === 404 || res.status === 400) return null;
if (!res.ok) throw new Error(`Failed to fetch blog: ${res.status}`);
const json = await res.json();
return json?.data?.data ?? null;
});
export async function generateMetadata({ params }) {
const { slug } = await params;
const { matchedCity } = await getStateInfo();
const siteName = matchedCity?.site_name || "نوبت 724";
const blog = await getBlog(slug);
if (!blog) notFound();
try {
const title = `${blog.title} | ${siteName}`;
const rawText = (blog.summary || blog.body || "")
.replace(/<[^>]*>/g, "")
.trim();
const description = rawText.slice(0, 160) || blog.title;
const image = blog.image_url ? imageUrl(blog.image_url) : FALLBACK_IMG;
// C1-b — پست شهریافته به دامنهٔ همان شهر canonical می‌شود؛
// پست سراسری (بدون شهر) روی دامنهٔ جاری self-canonical می‌ماند.
const origin = await getEntityOrigin(extractEntityCityId(blog));
return {
title,
description,
alternates: { canonical: `${origin}/blog/${slug}` },
openGraph: {
title,
description,
type: "article",
...(blog.created_at && {
publishedTime: new Date(blog.created_at * 1000).toISOString(),
}),
images: [image],
},
twitter: {
card: "summary_large_image",
title,
description,
images: [image],
},
};
} catch (error) {
console.error(`[blog/${slug}] generateMetadata failed:`, error);
return {};
}
}
async function Blog({ params }) {
const { slug } = await params;
const blog = normalizeBlog(await getBlog(slug));
if (!blog) notFound();
const blogCityId = extractEntityCityId(blog);
const blogCity = findCityById(blogCityId);
const origin = await getEntityOrigin(blogCityId);
let relatedBlogs = [];
const relatedResponse = await fetchReq(
`${API_URL}/api/v1/blogs?page=1&limit=6`
);
relatedBlogs = (relatedResponse?.data || [])
.filter((b) => b.uuid !== blog?.uuid)
.slice(0, 4)
.map(normalizeBlog);
const jsonLd = blog
? {
"@context": "https://schema.org",
"@type": "Article",
headline: blog.title,
...(blog.images?.[0]?.url && { image: imageUrl(blog.images[0].url) }),
...(blog.created && {
datePublished: new Date(blog.created * 1000).toISOString(),
}),
...(blog.author && { author: { "@type": "Person", name: blog.author } }),
// پست شهریافته حوزهٔ جغرافیایی‌اش را اعلام می‌کند؛ پست سراسری این فیلد را ندارد.
...(blogCity && {
spatialCoverage: { "@type": "Place", name: blogCity.name },
}),
}
: null;
const breadcrumbJsonLd = blog
? {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{ "@type": "ListItem", position: 1, name: "خانه", item: origin },
{ "@type": "ListItem", position: 2, name: "مقالات", item: `${origin}/blogs` },
{ "@type": "ListItem", position: 3, name: blog.title },
],
}
: null;
return (
<>
{jsonLd && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: safeJsonLd(jsonLd) }}
/>
)}
{breadcrumbJsonLd && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: safeJsonLd(breadcrumbJsonLd) }}
/>
)}
<Layout>
<BlogPage blog={blog} blogs={relatedBlogs} cityName={blogCity?.name} />
</Layout>
</>
);
}
export default Blog;