Next.js 15 site for گروه بازرگانی بین المللی آریو مجد آسیا, rebuilt from majdtrading.com. Persian, English and Arabic; RTL and LTR. - Pages: home, about, contact, six product categories, blog index and detail - i18n: prose in per-locale JSON, structure in TypeScript, drift checked by `npm run i18n:check`. Blog stays single-language by design - Meshkee backend: blog content, tenant resolution, session via the customer portal, and /meshkee/sitemap-config.json - Live market data from livedata.ir, cached server-side once a minute - Self-hosted fonts: IRANYekan (fa), IBM Plex Sans Arabic (ar), Space Grotesk and BBH Bartle (Latin) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
122 lines
3.9 KiB
TypeScript
122 lines
3.9 KiB
TypeScript
import type { Metadata } from "next";
|
|
import Link from "next/link";
|
|
import { notFound, redirect } from "next/navigation";
|
|
import PageHero from "@/components/PageHero";
|
|
import Reveal from "@/components/Reveal";
|
|
import { Section } from "@/components/Section";
|
|
import { isLocale } from "@/i18n/config";
|
|
import { getDictionary } from "@/i18n/dictionaries";
|
|
import { pageImages } from "@/content";
|
|
import { getPostById } from "@/lib/blog";
|
|
|
|
type Params = { locale: string; id: string; slug: string };
|
|
|
|
/** A malformed escape sequence must not take the page down. */
|
|
function safeDecode(value: string) {
|
|
try {
|
|
return decodeURIComponent(value);
|
|
} catch {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
export async function generateMetadata({
|
|
params,
|
|
}: {
|
|
params: Promise<Params>;
|
|
}): Promise<Metadata> {
|
|
const { locale, id } = await params;
|
|
const post = await getPostById(Number(id));
|
|
if (!post) return {};
|
|
|
|
return {
|
|
title: post.title,
|
|
description: post.abstract ?? undefined,
|
|
alternates: { canonical: `/${locale}/blog/${post.id}/${encodeURIComponent(post.slug)}` },
|
|
openGraph: post.imageUrl ? { images: [post.imageUrl], type: "article" } : undefined,
|
|
};
|
|
}
|
|
|
|
export default async function BlogPostPage({ params }: { params: Promise<Params> }) {
|
|
const { locale, id, slug } = await params;
|
|
if (!isLocale(locale)) notFound();
|
|
|
|
const numericId = Number(id);
|
|
if (!Number.isInteger(numericId)) notFound();
|
|
|
|
const post = await getPostById(numericId);
|
|
if (!post) notFound();
|
|
|
|
// The slug segment is SEO-only. If it drifts from the canonical form (an old
|
|
// link, or the title was edited), send the reader to the canonical URL.
|
|
//
|
|
// Two encoding traps here, both of which produce a 500 rather than a redirect:
|
|
// the incoming param arrives percent-ENCODED, so it must be decoded before
|
|
// comparing against a Persian slug; and a Location header may not contain raw
|
|
// non-ASCII, so the target must be encoded on the way back out.
|
|
const decodedSlug = safeDecode(slug);
|
|
if (decodedSlug !== post.slug) {
|
|
redirect(`/${locale}/blog/${post.id}/${encodeURIComponent(post.slug)}`);
|
|
}
|
|
|
|
const t = await getDictionary(locale);
|
|
|
|
const publishedAt = post.publishedAt
|
|
? new Intl.DateTimeFormat(locale, { day: "numeric", month: "long", year: "numeric" }).format(
|
|
new Date(post.publishedAt)
|
|
)
|
|
: null;
|
|
|
|
return (
|
|
<>
|
|
<PageHero
|
|
title={post.title}
|
|
latin={t.blog.headingLatin}
|
|
image={post.imageUrl ?? pageImages.about}
|
|
/>
|
|
|
|
<Section>
|
|
<article className="mx-auto max-w-3xl">
|
|
<Reveal>
|
|
{publishedAt && (
|
|
<p className="text-xs text-muted">
|
|
{t.blog.publishedOn}{" "}
|
|
<time dateTime={post.publishedAt ?? undefined} className="text-brand/70">
|
|
{publishedAt}
|
|
</time>
|
|
{post.author && <span className="text-brand/70"> · {post.author}</span>}
|
|
</p>
|
|
)}
|
|
<span className="mt-5 block h-1 w-16 bg-brand" />
|
|
|
|
{post.abstract && (
|
|
<p className="mt-8 text-justify text-base font-medium leading-9 text-brand">
|
|
{post.abstract}
|
|
</p>
|
|
)}
|
|
|
|
{post.bodyHtml && (
|
|
/* Body arrives as HTML from the Meshkee editor — authored in the
|
|
client's own dashboard, trusted the way a CMS body is.
|
|
`.prose-brand` in globals.css styles it without a plugin. */
|
|
<div
|
|
className="prose-brand mt-8"
|
|
dangerouslySetInnerHTML={{ __html: post.bodyHtml }}
|
|
/>
|
|
)}
|
|
</Reveal>
|
|
</article>
|
|
|
|
<div className="mx-auto mt-12 max-w-3xl border-t border-brand-100 pt-8">
|
|
<Link
|
|
href={`/${locale}/blog`}
|
|
className="text-sm font-bold text-brand transition-colors hover:text-brand-500"
|
|
>
|
|
{t.blog.backToList}
|
|
</Link>
|
|
</div>
|
|
</Section>
|
|
</>
|
|
);
|
|
}
|