- Add metadataBase to root layout so OG/Twitter/canonical URLs resolve to absolute https URLs (fixes broken social previews) - New src/lib/seo.ts with SITE_URL + JSON-LD builders - New robots.ts (disallow api/admin/private app paths) and sitemap.ts (marketing pages + blog posts with real lastmod dates) - JSON-LD: Organization/WebSite/SoftwareApplication on home, Blog+Breadcrumb on blog list, BlogPosting+Breadcrumb on posts - Per-page canonical + Open Graph on all marketing pages; article OG + Twitter cards on blog posts; per-post dynamic OG image - noindex on (app) and admin layouts; richer PWA manifest - Fix CSP to allow plausible.io in script-src/connect-src (analytics was silently blocked) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
334 lines
13 KiB
TypeScript
334 lines
13 KiB
TypeScript
import type { Metadata } from "next";
|
|
import Link from "next/link";
|
|
import { notFound } from "next/navigation";
|
|
import { POSTS, getPost, formatDate } from "../posts";
|
|
import { Breadcrumb } from "@/components/marketing/Breadcrumb";
|
|
import {
|
|
blogPostingSchema,
|
|
breadcrumbSchema,
|
|
jsonLdScript,
|
|
} from "@/lib/seo";
|
|
|
|
// Turn a heading string into a URL-safe anchor ID
|
|
function slugifyHeading(heading: string): string {
|
|
return heading
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9\s-]/g, "")
|
|
.replace(/\s+/g, "-")
|
|
.replace(/-+/g, "-")
|
|
.trim();
|
|
}
|
|
|
|
export function generateStaticParams() {
|
|
return POSTS.map((p) => ({ slug: p.slug }));
|
|
}
|
|
|
|
export async function generateMetadata({
|
|
params,
|
|
}: {
|
|
params: Promise<{ slug: string }>;
|
|
}): Promise<Metadata> {
|
|
const { slug } = await params;
|
|
const post = getPost(slug);
|
|
if (!post) return {};
|
|
const url = `/blog/${post.slug}`;
|
|
return {
|
|
title: post.title,
|
|
description: post.excerpt,
|
|
alternates: { canonical: url },
|
|
openGraph: {
|
|
type: "article",
|
|
url,
|
|
siteName: "Tia",
|
|
locale: "en_IN",
|
|
title: post.title,
|
|
description: post.excerpt,
|
|
publishedTime: post.date,
|
|
modifiedTime: post.date,
|
|
authors: [post.author],
|
|
section: post.category,
|
|
// Uses the per-post opengraph-image.tsx in this segment.
|
|
},
|
|
twitter: {
|
|
card: "summary_large_image",
|
|
title: post.title,
|
|
description: post.excerpt,
|
|
},
|
|
};
|
|
}
|
|
|
|
export default async function BlogPostPage({
|
|
params,
|
|
}: {
|
|
params: Promise<{ slug: string }>;
|
|
}) {
|
|
const { slug } = await params;
|
|
const post = getPost(slug);
|
|
if (!post) notFound();
|
|
|
|
// Extract headings for TOC
|
|
const headings = post.sections.filter((s) => s.heading).map((s) => ({
|
|
text: s.heading!,
|
|
id: slugifyHeading(s.heading!),
|
|
}));
|
|
|
|
// Other posts (exclude current)
|
|
const otherPosts = POSTS.filter((p) => p.slug !== slug).slice(0, 3);
|
|
|
|
return (
|
|
<div className="min-h-screen bg-white">
|
|
{/* Structured data — BlogPosting + breadcrumb trail */}
|
|
<script
|
|
type="application/ld+json"
|
|
dangerouslySetInnerHTML={jsonLdScript([
|
|
blogPostingSchema(post),
|
|
breadcrumbSchema([
|
|
{ name: "Home", path: "/" },
|
|
{ name: "Blog", path: "/blog" },
|
|
{ name: post.title, path: `/blog/${post.slug}` },
|
|
]),
|
|
])}
|
|
/>
|
|
{/* Post hero / header */}
|
|
<div className="bg-gradient-to-br from-rose-50 to-pink-50 border-b border-rose-100">
|
|
<div className="max-w-6xl mx-auto px-5 pt-10 pb-10">
|
|
<div className="max-w-2xl">
|
|
<div className="flex flex-wrap items-center gap-2 mb-5">
|
|
<span className={`text-xs font-semibold px-2.5 py-0.5 rounded-full ${post.categoryColor}`}>
|
|
{post.category}
|
|
</span>
|
|
<span className="text-xs text-gray-400">{formatDate(post.date)}</span>
|
|
<span className="text-xs text-gray-400">·</span>
|
|
<span className="text-xs text-gray-400">{post.readTime}</span>
|
|
</div>
|
|
|
|
<h1 className="text-3xl sm:text-4xl font-bold text-gray-900 leading-tight mb-4">
|
|
{post.emoji} {post.title}
|
|
</h1>
|
|
<p className="text-lg text-gray-500 leading-relaxed">{post.excerpt}</p>
|
|
|
|
<div className="mt-5 text-sm text-gray-400">
|
|
By <span className="text-gray-600 font-medium">{post.author}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Breadcrumb — between hero and content */}
|
|
<div className="max-w-6xl mx-auto px-5 pt-5 pb-1">
|
|
<Breadcrumb
|
|
items={[
|
|
{ label: "Home", href: "/" },
|
|
{ label: "Blog", href: "/blog" },
|
|
{ label: post.title },
|
|
]}
|
|
/>
|
|
</div>
|
|
|
|
{/* 3-column body */}
|
|
<div className="max-w-6xl mx-auto px-5 py-6">
|
|
<div className="grid grid-cols-1 lg:grid-cols-[220px_minmax(0,1fr)_200px] gap-10">
|
|
|
|
{/* ── LEFT SIDEBAR: Table of Contents ── */}
|
|
<aside className="hidden lg:block">
|
|
<div className="sticky top-24">
|
|
<p className="text-xs font-bold text-gray-400 uppercase tracking-widest mb-4">In this article</p>
|
|
{headings.length > 0 ? (
|
|
<ol className="flex flex-col gap-2">
|
|
{headings.map((h, i) => (
|
|
<li key={i}>
|
|
<a
|
|
href={`#${h.id}`}
|
|
className="group flex items-start gap-2 text-sm text-gray-500 hover:text-rose-600 transition-colors duration-150"
|
|
>
|
|
<span className="shrink-0 mt-0.5 w-4 h-4 rounded-full bg-rose-100 text-rose-500 text-[10px] flex items-center justify-center font-bold group-hover:bg-rose-200 transition-colors duration-150">
|
|
{i + 1}
|
|
</span>
|
|
<span className="leading-snug">{h.text}</span>
|
|
</a>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
) : (
|
|
<p className="text-xs text-gray-400">No sections</p>
|
|
)}
|
|
|
|
<div className="mt-8 pt-6 border-t border-gray-100">
|
|
<Link
|
|
href="/blog"
|
|
className="flex items-center gap-1.5 text-sm text-rose-500 hover:text-rose-700 font-medium transition-colors"
|
|
>
|
|
← All articles
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* ── CENTER: Article body ── */}
|
|
<article className="min-w-0">
|
|
<div className="space-y-10">
|
|
{post.sections.map((section, i) => (
|
|
<div key={i}>
|
|
{section.heading && (
|
|
<h2
|
|
id={slugifyHeading(section.heading)}
|
|
className="text-xl font-bold text-gray-900 mb-4 mt-2 scroll-mt-28"
|
|
>
|
|
{section.heading}
|
|
</h2>
|
|
)}
|
|
|
|
{section.paragraphs?.map((p, j) => (
|
|
<p key={j} className="text-gray-600 leading-relaxed mb-4">
|
|
{p}
|
|
</p>
|
|
))}
|
|
|
|
{section.table && (
|
|
<div className="overflow-x-auto mb-4 rounded-xl border border-gray-100">
|
|
<table className="w-full text-sm border-collapse">
|
|
<thead>
|
|
<tr className="bg-rose-50">
|
|
{section.table.headers.map((h, j) => (
|
|
<th
|
|
key={j}
|
|
className="text-left px-4 py-3 font-semibold text-gray-700 border-b border-rose-100"
|
|
>
|
|
{h}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{section.table.rows.map((row, j) => (
|
|
<tr key={j} className="border-b border-gray-50 last:border-0 hover:bg-gray-50">
|
|
{row.map((cell, k) => (
|
|
<td key={k} className="px-4 py-3 text-gray-600">
|
|
{cell}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{section.list && (
|
|
<ul className="space-y-2.5 mb-4">
|
|
{section.list.map((item, j) => (
|
|
<li key={j} className="flex items-start gap-3 text-sm text-gray-600">
|
|
<span className="mt-1.5 w-1.5 h-1.5 rounded-full bg-rose-400 shrink-0" />
|
|
<span>
|
|
{item.label && (
|
|
<span className="font-semibold text-gray-800">{item.label}: </span>
|
|
)}
|
|
{item.text}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
{section.callout && (
|
|
<div className="bg-rose-50 border border-rose-100 rounded-xl p-5 flex gap-3 items-start mb-4">
|
|
<span className="text-xl shrink-0 mt-0.5">{section.callout.emoji}</span>
|
|
<p className="text-sm text-rose-800 leading-relaxed">{section.callout.text}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Article footer CTA */}
|
|
<div className="mt-14 pt-10 border-t border-gray-100">
|
|
<div className="bg-gradient-to-br from-rose-50 to-pink-50 rounded-2xl p-8 text-center border border-rose-100">
|
|
<div className="text-3xl mb-3">🌸</div>
|
|
<h3 className="text-xl font-bold text-gray-900 mb-2">Try Tia — free during early access</h3>
|
|
<p className="text-gray-500 text-sm mb-6 max-w-sm mx-auto">
|
|
Log feeds, track vaccinations, and build a digital heirloom for your child. Built for Indian families.
|
|
</p>
|
|
<Link
|
|
href="/login"
|
|
className="inline-flex items-center gap-2 bg-rose-500 hover:bg-rose-600 text-white text-sm font-semibold px-7 py-3 rounded-full transition-colors duration-200"
|
|
>
|
|
Get started →
|
|
</Link>
|
|
</div>
|
|
|
|
{/* Back link — visible on mobile too */}
|
|
<div className="mt-8 flex items-center justify-between">
|
|
<Link
|
|
href="/blog"
|
|
className="inline-flex items-center gap-1.5 text-sm font-medium text-rose-600 hover:text-rose-700 transition-colors"
|
|
>
|
|
← All articles
|
|
</Link>
|
|
<span className="text-xs text-gray-400">
|
|
{formatDate(post.date)} · {post.readTime}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
|
|
{/* ── RIGHT SIDEBAR: More articles + categories ── */}
|
|
<aside className="hidden lg:block">
|
|
<div className="sticky top-24 space-y-8">
|
|
|
|
{/* More articles */}
|
|
<div>
|
|
<p className="text-xs font-bold text-gray-400 uppercase tracking-widest mb-4">More articles</p>
|
|
<div className="flex flex-col gap-4">
|
|
{otherPosts.map((p) => (
|
|
<Link
|
|
key={p.slug}
|
|
href={`/blog/${p.slug}`}
|
|
className="group flex items-start gap-2.5"
|
|
>
|
|
<span className="text-lg shrink-0 mt-0.5">{p.emoji}</span>
|
|
<div className="min-w-0">
|
|
<p className="text-xs text-gray-700 group-hover:text-rose-600 leading-snug font-medium transition-colors duration-150 line-clamp-2">
|
|
{p.title}
|
|
</p>
|
|
<p className="text-xs text-gray-400 mt-0.5">{p.readTime}</p>
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t border-gray-100" />
|
|
|
|
{/* Category badge */}
|
|
<div>
|
|
<p className="text-xs font-bold text-gray-400 uppercase tracking-widest mb-3">Filed under</p>
|
|
<span className={`text-xs font-semibold px-2.5 py-1 rounded-full ${post.categoryColor}`}>
|
|
{post.category}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="border-t border-gray-100" />
|
|
|
|
{/* CTA */}
|
|
<div className="bg-rose-50 rounded-xl p-5 border border-rose-100 text-center">
|
|
<div className="text-2xl mb-2">🌸</div>
|
|
<p className="text-sm font-semibold text-gray-800 mb-1">Free early access</p>
|
|
<p className="text-xs text-gray-500 mb-4 leading-relaxed">
|
|
Built for Indian families. No ads, no data selling.
|
|
</p>
|
|
<Link
|
|
href="/login"
|
|
className="inline-block w-full text-center bg-rose-500 hover:bg-rose-600 text-white text-xs font-semibold px-4 py-2.5 rounded-full transition-colors duration-200"
|
|
>
|
|
Get started →
|
|
</Link>
|
|
</div>
|
|
|
|
</div>
|
|
</aside>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|