Files
nobat724_front/app/blog/[slug]/page.js
T

283 lines
12 KiB
JavaScript

import { cache } from "react";
import BlogPage from "@/components/blog";
import Layout from "@/components/layout/StLayout";
import { notFound, permanentRedirect } 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 { decodeRouteParam } from "@/lib/routeParams";
import { normalizeBlog, blogCover } from "@/helper";
import legacySlugs from "@/data/blogLegacySlugs.json";
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 }));
}
/**
* پست را با هر شناسه‌ای که روزی به آن اشاره داشته پیدا می‌کند.
*
* بک‌اند خودش slug و uuid و topic_slug و پسوند ۸ کاراکتریِ uuid را resolve می‌کند.
* چیزی که آنجا قابل resolve نیست، اسلاگ‌های لاتینِ نسل قبل‌اند: هیچ ردی در دیتابیس
* ندارند چون هنگام تغییر عنوان بازنویسی شدند. Search Console همان‌ها را روی
* `behbahan-nobat.ir` ۴۰۴ گزارش کرد، پس نگاشتشان در data/blogLegacySlugs.json
* دستی نگه داشته می‌شود — مقدارِ نگاشت پیشوند uuid است، نه اسلاگ مقصد، تا فیلتر
* شهریِ همان درخواست دوباره اعمال شود و پستِ شهر دیگر ریدایرکتِ ۴۰۴ نسازد.
*/
async function resolveScopedBlog(slug) {
const blog = await getScopedBlog(slug);
if (blog) return blog;
const legacyUuidPrefix = legacySlugs[slug];
return legacyUuidPrefix ? getScopedBlog(legacyUuidPrefix) : null;
}
export async function generateMetadata({ params }) {
const { slug: rawSlug } = await params;
const slug = decodeRouteParam(rawSlug);
const { matchedCity } = await getStateInfo();
const siteName = matchedCity?.site_name || "نوبت 724";
const blog = await resolveScopedBlog(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));
// slug اینجا decode شده است؛ canonical باید شکل درصدی را بدهد وگرنه روی
// اسلاگ فارسی یک URL با کاراکتر غیر ASCII منتشر می‌شود.
const canonical = blog.canonical_url || `${origin}/blog/${encodeURIComponent(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: rawSlug } = await params;
const slug = decodeRouteParam(rawSlug);
const slugPath = encodeURIComponent(slug);
const blog = normalizeBlog(await resolveScopedBlog(slug));
if (!blog) notFound();
// اسلاگ کهنه (عنوان عوض شده یا نسل لاتین) به آدرس قطعی منتقل می‌شود؛ دو URL برای
// یک محتوا باقی نمی‌ماند و لینک‌های قدیمیِ ایندکس‌شده اعتبارشان را می‌برند.
if (blog.slug && blog.slug !== slug) {
permanentRedirect(`/blog/${encodeURIComponent(blog.slug)}`);
}
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/${slugPath}` },
...(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/${slugPath}` },
],
}
: 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} />
</Layout>
</>
);
}
export default Blog;