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 && (