273 lines
7.7 KiB
TypeScript
273 lines
7.7 KiB
TypeScript
import { notFound } from "next/navigation";
|
|
import type { Metadata } from "next";
|
|
import dynamic from "next/dynamic";
|
|
import type { BlogPost } from "../../../../lib/content";
|
|
import {
|
|
getBlogPostBySlug,
|
|
getAllBlogPosts as getAllPosts,
|
|
getRelatedBlogPosts,
|
|
} from "../../../../lib/content";
|
|
import { logger } from "../../../../lib/logger";
|
|
import ContentBanner from "../../../components/sections/ContentBanner";
|
|
import AskOrganizer from "../../../components/sections/AskOrganizer";
|
|
import { getAssetPath, ASSETS } from "../../../../lib/assetUtils";
|
|
import "../blog.css";
|
|
|
|
// Code split RelatedArticles - blog-specific, below the fold
|
|
const RelatedArticles = dynamic(
|
|
() => import("../../../components/sections/RelatedArticles"),
|
|
{
|
|
loading: () => (
|
|
<section className="py-[var(--spacing-scale-032)] min-h-[400px]" />
|
|
),
|
|
ssr: true,
|
|
},
|
|
);
|
|
|
|
// AskOrganizer data - same as index page
|
|
const askOrganizerData = {
|
|
title: "Still have questions?",
|
|
subtitle: "Get answers from an experienced organizer",
|
|
buttonText: "Ask an organizer",
|
|
};
|
|
|
|
interface PageProps {
|
|
params: Promise<{ slug: string }>;
|
|
}
|
|
|
|
/**
|
|
* Generate static params for all blog posts
|
|
* This enables static generation for all blog posts at build time
|
|
*/
|
|
export async function generateStaticParams() {
|
|
try {
|
|
const posts = getAllPosts();
|
|
return posts.map((post) => ({
|
|
slug: post.slug,
|
|
}));
|
|
} catch (error) {
|
|
logger.error("Error generating static params:", error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate metadata for each blog post
|
|
*/
|
|
export async function generateMetadata({
|
|
params,
|
|
}: PageProps): Promise<Metadata> {
|
|
try {
|
|
const { slug } = await params;
|
|
const post = getBlogPostBySlug(slug);
|
|
|
|
if (!post) {
|
|
return {
|
|
title: "Post Not Found",
|
|
description: "The requested blog post could not be found.",
|
|
};
|
|
}
|
|
|
|
return {
|
|
title: post.frontmatter.title,
|
|
description: post.frontmatter.description,
|
|
authors: [{ name: post.frontmatter.author }],
|
|
openGraph: {
|
|
title: post.frontmatter.title,
|
|
description: post.frontmatter.description,
|
|
type: "article",
|
|
publishedTime: post.frontmatter.date,
|
|
authors: [post.frontmatter.author],
|
|
url: `https://communityrule.com/blog/${slug}`,
|
|
siteName: "CommunityRule",
|
|
},
|
|
twitter: {
|
|
card: "summary_large_image",
|
|
title: post.frontmatter.title,
|
|
description: post.frontmatter.description,
|
|
creator: "@communityrule",
|
|
},
|
|
};
|
|
} catch (error) {
|
|
logger.error("Error generating metadata:", error);
|
|
return {
|
|
title: "Blog Post",
|
|
description: "A blog post from our community.",
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Dynamic blog post page
|
|
*/
|
|
export default async function BlogPostPage({ params }: PageProps) {
|
|
// Get the blog post data
|
|
const { slug } = await params;
|
|
const post = getBlogPostBySlug(slug);
|
|
|
|
// If post doesn't exist, show 404
|
|
if (!post) {
|
|
notFound();
|
|
}
|
|
|
|
// Get related articles with improved algorithm
|
|
const allPosts = getAllPosts();
|
|
const slugOrder = allPosts.map((post) => post.slug);
|
|
const relatedArticles = getRelatedBlogPosts(
|
|
post.slug,
|
|
post.frontmatter.related,
|
|
3,
|
|
);
|
|
|
|
// Generate structured data for search engines
|
|
const structuredData = {
|
|
"@context": "https://schema.org",
|
|
"@type": "Article",
|
|
headline: post.frontmatter.title,
|
|
description: post.frontmatter.description,
|
|
author: {
|
|
"@type": "Person",
|
|
name: post.frontmatter.author,
|
|
},
|
|
publisher: {
|
|
"@type": "Organization",
|
|
name: "CommunityRule",
|
|
url: "https://communityrule.com",
|
|
logo: {
|
|
"@type": "ImageObject",
|
|
url: "https://communityrule.com/assets/logo/Logo.svg",
|
|
},
|
|
},
|
|
datePublished: post.frontmatter.date,
|
|
dateModified: post.frontmatter.date,
|
|
mainEntityOfPage: {
|
|
"@type": "WebPage",
|
|
"@id": `https://communityrule.com/blog/${post.slug}`,
|
|
},
|
|
url: `https://communityrule.com/blog/${post.slug}`,
|
|
articleSection: "Community Building",
|
|
keywords: ["community", "governance", "decision making", "collaboration"],
|
|
};
|
|
|
|
// Breadcrumb structured data
|
|
const breadcrumbData = {
|
|
"@context": "https://schema.org",
|
|
"@type": "BreadcrumbList",
|
|
itemListElement: [
|
|
{
|
|
"@type": "ListItem",
|
|
position: 1,
|
|
name: "Home",
|
|
item: "https://communityrule.com",
|
|
},
|
|
{
|
|
"@type": "ListItem",
|
|
position: 2,
|
|
name: "Blog",
|
|
item: "https://communityrule.com/blog",
|
|
},
|
|
{
|
|
"@type": "ListItem",
|
|
position: 3,
|
|
name: post.frontmatter.title,
|
|
item: `https://communityrule.com/blog/${post.slug}`,
|
|
},
|
|
],
|
|
};
|
|
|
|
// Get article-specific background color from frontmatter
|
|
const getBackgroundColor = (post: BlogPost): string => {
|
|
if (post.frontmatter?.background?.color) {
|
|
return post.frontmatter.background.color;
|
|
}
|
|
return "#1F2937"; // Default fallback (dark gray)
|
|
};
|
|
|
|
const backgroundColor = getBackgroundColor(post);
|
|
|
|
return (
|
|
<>
|
|
{/* Structured Data */}
|
|
<script
|
|
type="application/ld+json"
|
|
dangerouslySetInnerHTML={{
|
|
__html: JSON.stringify(structuredData),
|
|
}}
|
|
/>
|
|
<script
|
|
type="application/ld+json"
|
|
dangerouslySetInnerHTML={{
|
|
__html: JSON.stringify(breadcrumbData),
|
|
}}
|
|
/>
|
|
|
|
<div
|
|
className="relative min-h-screen overflow-x-clip"
|
|
style={{ backgroundColor }}
|
|
>
|
|
{/* Content Banner */}
|
|
<ContentBanner post={post} />
|
|
|
|
{/* Decorative Shapes */}
|
|
{/* Right Side Shape (3/4 up the page) */}
|
|
<div
|
|
className="hidden md:block absolute top-1/4 right-0 pointer-events-none z-10"
|
|
style={{ transform: "translateX(40%)" }}
|
|
>
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={getAssetPath(ASSETS.CONTENT_SHAPE_1)}
|
|
alt=""
|
|
className="w-auto h-auto max-w-none"
|
|
style={{
|
|
width: "clamp(120px, 15vw, 200px)",
|
|
height: "auto",
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Left Side Shape (3/4 down the page) */}
|
|
<div
|
|
className="hidden md:block absolute top-1/2 left-0 pointer-events-none z-10"
|
|
style={{ transform: "translateX(-10%)" }}
|
|
>
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={getAssetPath(ASSETS.CONTENT_SHAPE_2)}
|
|
alt=""
|
|
className="w-auto h-auto max-w-none"
|
|
style={{
|
|
width: "clamp(100px, 12vw, 180px)",
|
|
height: "auto",
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Main Content — Figma Content page Template (19003:23305) article body instances */}
|
|
<article
|
|
data-node-id="19031:10426"
|
|
className="
|
|
relative z-[2] flex w-full justify-center
|
|
p-[var(--spacing-scale-024)]
|
|
sm:px-0 sm:py-[var(--spacing-scale-032)]
|
|
"
|
|
>
|
|
<div className="post-body w-full text-[var(--color-content-inverse-primary)] text-[16px] leading-[24px] sm:text-[18px] sm:leading-[130%] lg:text-[24px] lg:leading-[32px] xl:text-[32px] xl:leading-[40px] sm:max-w-[390px] md:max-w-[472px] lg:max-w-[700px] xl:max-w-[904px]">
|
|
<div dangerouslySetInnerHTML={{ __html: post.htmlContent }} />
|
|
</div>
|
|
</article>
|
|
|
|
{/* Related Articles Section */}
|
|
<RelatedArticles
|
|
relatedPosts={relatedArticles}
|
|
currentPostSlug={post.slug}
|
|
slugOrder={slugOrder}
|
|
/>
|
|
|
|
{/* Ask Organizer Section */}
|
|
<AskOrganizer {...askOrganizerData} variant="inverse" />
|
|
</div>
|
|
</>
|
|
);
|
|
}
|