Initial import: Majd Trading website
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>
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user