- Added `getBlogTagFacets` API call to fetch blog tags based on city scope. - Updated `BlogsPage` to read selected tag from URL and handle tag changes with URL updates. - Modified `Title` component to display tags from the new API and reflect active tag state. - Enhanced breadcrumb navigation to link categories to their respective pages. - Adjusted related content section to display articles from the same category and fixed layout issues. - Corrected heading hierarchy across various components for better SEO compliance. - Ensured consistent styling and spacing in related content items.
252 lines
10 KiB
JavaScript
252 lines
10 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 { getBlogCanonicalOrigin } from "@/lib/getCanonicalUrl";
|
|
import {
|
|
domainScopeCityId,
|
|
extractEntityCityId,
|
|
findCityById,
|
|
} from "@/lib/domainHelpers";
|
|
import { safeJsonLd } from "@/lib/sanitize";
|
|
import { normalizeBlog, blogCover } from "@/helper";
|
|
|
|
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
|
|
|
// scope دامنه در کلید cache مینشیند: یک پست روی دامنهٔ شهریِ دیگر ۴۰۴ است، پس
|
|
// پاسخِ دامنهٔ ریشه نباید به دامنهٔ شهری نشت کند.
|
|
const getBlog = cache(async (slug, cityId) => {
|
|
const query = cityId != null ? `?city_id=${cityId}` : "";
|
|
const res = await fetch(`${API_URL}/api/v1/blog/${slug}${query}`, {
|
|
// هر دو tag ثبت میشود چون بکاند با slug و uuid هر دو باطل میکند و URL
|
|
// سایت uuid است؛ `blog-list` هم برای باطلسازی گروهی همهٔ مقالهها.
|
|
next: { revalidate: 3600, tags: [`blog-${slug}`, "blog-list"] },
|
|
});
|
|
// ۴۰۴ یعنی «روی این دامنه وجود ندارد» — پستِ شهرِ دیگر (یا پستِ دامنهٔ اصلی)
|
|
// نباید روی دامنهٔ شهری باز شود؛ بکاند همان فیلتر لیست را اینجا هم اعمال میکند.
|
|
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;
|
|
});
|
|
|
|
/** پست را در scope دامنهٔ جاری میگیرد (دامنهٔ ریشه scope ندارد). */
|
|
async function getScopedBlog(slug) {
|
|
const { matchedCity, isRoot } = await getStateInfo();
|
|
return getBlog(slug, domainScopeCityId({ matchedCity, isRoot }));
|
|
}
|
|
|
|
export async function generateMetadata({ params }) {
|
|
const { slug } = await params;
|
|
const { matchedCity } = await getStateInfo();
|
|
const siteName = matchedCity?.site_name || "نوبت 724";
|
|
|
|
const blog = await getScopedBlog(slug);
|
|
if (!blog) notFound();
|
|
|
|
try {
|
|
// برند از شهرِ خودِ پست میآید، نه از دامنهای که اتفاقاً آن را سرو میکند —
|
|
// وگرنه پست یاسوجی که روی دامنهٔ یزد باز شود عنوانش «یزد نوبت» میشد در حالی
|
|
// که canonical آن به یاسوج اشاره میکند. پست سراسری برند دامنهٔ جاری را میگیرد.
|
|
const blogCity = findCityById(extractEntityCityId(blog));
|
|
const brand = blogCity?.site_name || siteName;
|
|
|
|
// meta_title از فیلد اختصاصی SEO (اگر پایپلاین/ادمین ست کرده باشد) وگرنه عنوان.
|
|
const title = `${blog.meta_title || blog.title} | ${brand}`;
|
|
const rawText = (blog.summary || blog.body || "")
|
|
.replace(/<[^>]*>/g, "")
|
|
.trim();
|
|
const baseDescription = rawText.slice(0, 160) || blog.title;
|
|
// فیلد meta_description اختصاصی مقدم است؛ وگرنه از خلاصه/متن استخراج میشود.
|
|
const description =
|
|
blog.meta_description ||
|
|
(blogCity
|
|
? `${baseDescription}`.slice(0, 150) + ` | ${blogCity.name}`
|
|
: baseDescription);
|
|
// کلیدواژهها از فیلدهای SEO (اصلی + فرعی) + تگها.
|
|
const keywords = [
|
|
blog.primary_keyword,
|
|
...(Array.isArray(blog.secondary_keywords) ? blog.secondary_keywords : []),
|
|
...(Array.isArray(blog.tags)
|
|
? blog.tags.map((t) => (typeof t === "string" ? t : t?.name))
|
|
: []),
|
|
].filter(Boolean);
|
|
// ترتیب تقدم og_image → image_url → کاور پیشفرض برند، همان چیزی که صفحه و
|
|
// کارتها نشان میدهند؛ یک منبع واحد تا OG با تصویر داخل صفحه فرق نکند.
|
|
const image = blogCover(blog);
|
|
|
|
// C1-b — پست شهریافته به دامنهٔ همان شهر canonical میشود؛ پست سراسری (یا پستِ
|
|
// رکورد ریشهٔ nobat724) به دامنهٔ اصلی، نه دامنهٔ جاری — وگرنه یک محتوا روی چند
|
|
// دامنه سرو میشد و هرکدام خودش را canonical اعلام میکرد.
|
|
// فیلد canonical_url اختصاصی (مثلاً نسخهٔ شهریِ پایپلاین) مقدم است.
|
|
const origin = getBlogCanonicalOrigin(extractEntityCityId(blog));
|
|
const canonical = blog.canonical_url || `${origin}/blog/${slug}`;
|
|
|
|
return {
|
|
title,
|
|
description,
|
|
...(keywords.length && { keywords }),
|
|
alternates: { canonical },
|
|
openGraph: {
|
|
title,
|
|
description,
|
|
type: "article",
|
|
...(blog.created_at && {
|
|
publishedTime: new Date(blog.created_at * 1000).toISOString(),
|
|
}),
|
|
...(blog.updated_at && {
|
|
modifiedTime: new Date(blog.updated_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 getScopedBlog(slug));
|
|
if (!blog) notFound();
|
|
|
|
const blogCityId = extractEntityCityId(blog);
|
|
const blogCity = findCityById(blogCityId);
|
|
const origin = getBlogCanonicalOrigin(blogCityId);
|
|
|
|
// «مقالات مرتبط» هم باید scope دامنه را رعایت کند، وگرنه روی دامنهٔ شهری لینکِ
|
|
// پستِ شهر دیگر نشان داده میشود که حالا ۴۰۴ میگیرد.
|
|
const { matchedCity, isRoot } = await getStateInfo();
|
|
const scopeCityId = domainScopeCityId({ matchedCity, isRoot });
|
|
const scopeQuery = scopeCityId != null ? `&city_id=${scopeCityId}` : "";
|
|
|
|
// دستهٔ مقاله = تگ اول؛ هم بردکرامب و هم مقالات مرتبط از همین میآیند.
|
|
const categoryName = Array.isArray(blog?.tag) ? blog.tag[0]?.name : null;
|
|
|
|
// اول همدستهها؛ دستههای کمپست (مثلاً «قلب و عروق» با یک مقاله) با آخرین
|
|
// مقالات پر میشوند تا ستون خالی یا کوتاه نماند.
|
|
const sameTagResponse = categoryName
|
|
? await fetchReq(
|
|
`${API_URL}/api/v1/blogs?page=1&limit=6&tag=${encodeURIComponent(categoryName)}${scopeQuery}`
|
|
)
|
|
: null;
|
|
const latestResponse = await fetchReq(
|
|
`${API_URL}/api/v1/blogs?page=1&limit=6${scopeQuery}`
|
|
);
|
|
|
|
const seen = new Set([blog?.uuid]);
|
|
const relatedBlogs = [
|
|
...(sameTagResponse?.data || []),
|
|
...(latestResponse?.data || []),
|
|
]
|
|
.filter((b) => !seen.has(b.uuid) && seen.add(b.uuid))
|
|
.slice(0, 4)
|
|
.map(normalizeBlog);
|
|
|
|
const brand = blogCity?.site_name || "نوبت 724";
|
|
const articleDescription =
|
|
blog?.meta_description ||
|
|
(blog?.body?.value || blog?.summary || "").replace(/<[^>]*>/g, "").trim().slice(0, 160);
|
|
|
|
const jsonLd = blog
|
|
? {
|
|
"@context": "https://schema.org",
|
|
"@type": "Article",
|
|
headline: blog.title,
|
|
...(articleDescription && { description: articleDescription }),
|
|
image: blogCover(blog),
|
|
mainEntityOfPage: { "@type": "WebPage", "@id": `${origin}/blog/${slug}` },
|
|
...(blog.created && {
|
|
datePublished: new Date(blog.created * 1000).toISOString(),
|
|
}),
|
|
...(blog.updated_at && {
|
|
dateModified: new Date(blog.updated_at * 1000).toISOString(),
|
|
}),
|
|
...(blog.author && { author: { "@type": "Person", name: blog.author } }),
|
|
publisher: { "@type": "Organization", name: brand },
|
|
// پست شهریافته حوزهٔ جغرافیاییاش را اعلام میکند؛ پست سراسری این فیلد را ندارد.
|
|
...(blogCity && {
|
|
spatialCoverage: { "@type": "Place", name: blogCity.name },
|
|
}),
|
|
}
|
|
: null;
|
|
|
|
// FAQPage از فیلد faq (سؤال/جواب) — فقط اگر مقاله FAQ داشته باشد.
|
|
const faqItems = Array.isArray(blog?.faq)
|
|
? blog.faq.filter((f) => f?.q && f?.a)
|
|
: [];
|
|
const faqJsonLd = faqItems.length
|
|
? {
|
|
"@context": "https://schema.org",
|
|
"@type": "FAQPage",
|
|
mainEntity: faqItems.map((f) => ({
|
|
"@type": "Question",
|
|
name: f.q,
|
|
acceptedAnswer: { "@type": "Answer", text: f.a },
|
|
})),
|
|
}
|
|
: 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` },
|
|
// دستهٔ مقاله = همان چیزی که بردکرامب HTML نشان میدهد؛ مقالهٔ بدون تگ
|
|
// سهسطحی میماند، چون سطح خالی کل BreadcrumbList را نامعتبر میکند.
|
|
...(categoryName
|
|
? [{
|
|
"@type": "ListItem",
|
|
position: 3,
|
|
name: categoryName,
|
|
item: `${origin}/blogs?tag=${encodeURIComponent(categoryName)}`,
|
|
}]
|
|
: []),
|
|
// آیتم آخر بدون `item` کل BreadcrumbList را نامعتبر میکند
|
|
{ "@type": "ListItem", position: categoryName ? 4 : 3, name: blog.title,
|
|
item: `${origin}/blog/${slug}` },
|
|
],
|
|
}
|
|
: null;
|
|
|
|
return (
|
|
<>
|
|
{jsonLd && (
|
|
<script
|
|
type="application/ld+json"
|
|
dangerouslySetInnerHTML={{ __html: safeJsonLd(jsonLd) }}
|
|
/>
|
|
)}
|
|
{breadcrumbJsonLd && (
|
|
<script
|
|
type="application/ld+json"
|
|
dangerouslySetInnerHTML={{ __html: safeJsonLd(breadcrumbJsonLd) }}
|
|
/>
|
|
)}
|
|
{faqJsonLd && (
|
|
<script
|
|
type="application/ld+json"
|
|
dangerouslySetInnerHTML={{ __html: safeJsonLd(faqJsonLd) }}
|
|
/>
|
|
)}
|
|
<Layout>
|
|
<BlogPage blog={blog} blogs={relatedBlogs} cityName={blogCity?.name} />
|
|
</Layout>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default Blog;
|