Add blog detail page (/blog/[slug])

- app/blog/[slug]/page.tsx: article header (category/readTime, Playfair
  display title, excerpt, byline), full-bleed hero image, RichText body,
  "Passend dazu: ToDo-Karten" cross-promo, author bio card, "Weiterlesen"
  related-post card. Widths, fonts, and the Passend-dazu card's
  border/padding/spacing were corrected against the actual built Figma
  frame (page-blog-detail, node 4667:344, jCCZyh1DGwdjpv1wGge9To) via
  get_design_context after a few visually-wrong guesses.
- RichText.tsx: new "quote" case renders Lexical's blockquote feature as
  the "Merke dir:" pull-quote — label+underline+divider+Caveat lines,
  using the real exported sparkle/underline assets, matching Figma's
  actual (background-less) structure instead of a guessed bordered card.
- lib/payload.ts: getPostBySlug() for single-post fetches.
- lib/format.ts: formatDate() — "03. Juli 2025"-style German dates.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-19 20:51:41 +00:00
parent 3cf45cfb16
commit 2b270cf299
7 changed files with 314 additions and 0 deletions
+212
View File
@@ -0,0 +1,212 @@
import type { Metadata } from "next";
import Link from "next/link";
import Image from "next/image";
import { notFound } from "next/navigation";
import { Reveal } from "../../components/Reveal";
import { Footer } from "../../components/Footer";
import { RichText } from "../../components/RichText";
import { getBlogPosts, getPostBySlug } from "../../lib/payload";
import { formatDate } from "../../lib/format";
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post) return { title: "Beitrag nicht gefunden" };
return {
title: post.title,
description: post.excerpt,
alternates: { canonical: `/blog/${post.slug}` },
openGraph: {
title: `${post.title} | einfach produktiv.`,
description: post.excerpt,
url: `/blog/${post.slug}`,
type: "article",
images: post.thumbnail ? [{ url: post.thumbnail }] : undefined,
},
};
}
export default async function BlogDetailPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post) notFound();
// "Weiterlesen" — any other post, most recent first. Not the current
// one; falls back to nothing (section just doesn't render) rather than
// showing a fake/duplicate card when this is the only post.
const otherPosts = await getBlogPosts(4);
const nextPost = otherPosts.find((p) => p.slug !== post.slug) ?? null;
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<Reveal className="flex flex-col gap-4 items-start pt-10 pb-8 px-[var(--layout-padding-x)] w-full max-w-[48rem] mx-auto">
<div className="flex items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
<span>{post.category}</span>
<span></span>
<span>{post.readTime} Min</span>
</div>
{/* text-display + Playfair, not text-h-feature + Lora — matches
the actual Figma title exactly (Playfair Display SemiBold,
44px), same "hero headline" treatment as Hero.tsx/
TodoKartenHero.tsx/WeeklyImpulsesHero.tsx use, not the Lora
section-heading style the legal pages use. text-display's
clamp floors at exactly 2.75rem (44px) at the 768px Tablet
breakpoint, matching the Figma value precisely. */}
<p
className="font-semibold text-display text-text-primary leading-[1.12]"
style={{ fontFamily: "var(--font-playfair)" }}
>
{post.title}
</p>
<p className="text-body text-text-muted">{post.excerpt}</p>
<div className="flex items-center gap-3 pt-1">
<div className="relative size-9 shrink-0 rounded-full overflow-hidden">
<Image alt="Björn" src="/about-author.jpg" fill sizes="36px" className="object-cover" />
</div>
<p className="text-body-sm text-text-primary">
Björn <span className="text-text-muted"> {formatDate(post.publishedAt)}</span>
</p>
</div>
</Reveal>
{post.thumbnail && (
// max-w-[70rem] (1120px) — exact Figma value (node 4667:379):
// 1120px hero vs. 700px body = 1.6x, not full-bleed to the page
// edge (an earlier version guessed that, which came out far too
// wide) and not capped to the body column either.
<Reveal delay={0.1} className="w-full max-w-[70rem] mx-auto px-[var(--layout-padding-x)] pb-10">
<div className="relative w-full aspect-[1120/460] rounded-md overflow-hidden bg-bg-muted">
<Image alt="" src={post.thumbnail} fill sizes="(min-width: 1120px) 70rem, 100vw" className="object-cover" />
</div>
</Reveal>
)}
<Reveal delay={0.15} className="flex flex-col gap-6 w-full max-w-[48rem] mx-auto px-[var(--layout-padding-x)] pb-10">
<RichText content={post.content} />
{/* "Passend dazu" — static cross-promo, not CMS content (same
reasoning as the legal pages' brand callouts): every post
currently points at the same flagship product rather than
needing a per-post "related product" field that nothing else
uses yet. Matches the actual built Figma frame exactly (node
4674:349, fetched via get_design_context) — 1px border-border,
rounded-md, px-9/py-7 padding; an earlier version guessed a
plain borderless row instead, which get_metadata's structural
dump didn't reveal (frame-level stroke/padding/radius aren't
visible there, only get_design_context shows those). */}
<Link
href="/todo-cards"
className="group flex items-center gap-6 border border-border rounded-md px-9 py-7 hover:border-brand transition-colors"
>
<img alt="" src="/icon-todo-passend-dazu.png" className="w-16 h-[4.6875rem] shrink-0 object-contain" />
<div className="flex-1 min-w-0 flex flex-col gap-2.5">
<p className="font-bold text-[0.8125rem] text-brand">Passend dazu:</p>
<div className="flex items-end justify-between gap-4 w-full">
{/* w-[19rem] (305px) — matches Figma's title-col exactly,
so the description wraps at the same point instead of
stretching out to fill the space before "Entdecken". */}
<div className="flex flex-col gap-2 items-start w-[19rem] shrink-0">
<p
className="font-semibold text-[1.375rem] text-text-primary whitespace-nowrap"
style={{ fontFamily: "var(--font-lora)" }}
>
ToDo-Karten
</p>
<p className="text-[0.9375rem] text-text-muted leading-[1.45]">Bringe Struktur in deine Aufgaben und gewinne Zeit zurück.</p>
</div>
<span className="flex items-center gap-1.5 font-bold text-[0.875rem] text-text-primary whitespace-nowrap">
Entdecken
<svg
viewBox="0 0 20 20"
className="size-3.5 transition-transform duration-200 group-hover:translate-x-1"
fill="none"
aria-hidden="true"
>
<path d="M4 10h12m0 0-5-5m5 5-5 5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</div>
</div>
</Link>
</Reveal>
{/* Author bio — same hardcoded-brand-chrome reasoning as above;
this is a single-author blog, there's no author field on the
Posts collection to read from. */}
<Reveal delay={0.2} className="w-full max-w-[48rem] mx-auto px-[var(--layout-padding-x)] pb-14">
<div className="flex items-center gap-4 bg-bg-muted rounded-md p-6">
<div className="relative size-14 shrink-0 rounded-full overflow-hidden">
<Image alt="Björn" src="/about-author.jpg" fill sizes="56px" className="object-cover" />
</div>
<div className="flex flex-col gap-0.5">
<p className="font-semibold text-body text-text-primary">Geschrieben von Björn.</p>
<p className="text-body-sm text-text-muted">Führungskraft. Familienmensch.</p>
<p className="text-body-sm text-text-muted">Gründer von einfach produktiv.</p>
</div>
</div>
</Reveal>
{nextPost && (
// max-w-[58.75rem] (940px) — exact Figma value (node 4667:382),
// deliberately wider than the 48rem body column (widened past
// the 43.75rem/700px Figma value per user preference), matching
// the original build's "Weiterlesen" card being wider than the
// text column rather than symmetric to it.
<Reveal delay={0.25} className="w-full max-w-[58.75rem] mx-auto px-[var(--layout-padding-x)] pb-16">
<p
className="font-semibold text-h-small text-text-primary mb-4"
style={{ fontFamily: "var(--font-lora)" }}
>
Weiterlesen
</p>
<Link
href={`/blog/${nextPost.slug}`}
className="group flex flex-col sm:flex-row items-stretch border border-border rounded-md overflow-hidden transition-transform duration-300 hover:-translate-y-1"
>
<div className="relative w-full sm:w-64 aspect-video sm:aspect-auto shrink-0 bg-bg-muted">
{nextPost.thumbnail && (
<Image alt="" src={nextPost.thumbnail} fill sizes="(min-width: 640px) 16rem, 100vw" className="object-cover" />
)}
</div>
<div className="flex flex-col justify-center gap-2 p-6 min-w-0">
<div className="flex items-center gap-2 font-semibold text-text-muted text-body-sm uppercase tracking-wide">
<span>{nextPost.category}</span>
<span></span>
<span>{nextPost.readTime} Min</span>
</div>
<p
className="font-semibold text-h-small text-text-primary"
style={{ fontFamily: "var(--font-lora)" }}
>
{nextPost.title}
</p>
<p className="flex items-center gap-1.5 text-body-sm text-text-muted line-clamp-1">
{nextPost.excerpt}
<svg
viewBox="0 0 20 20"
className="size-3.5 shrink-0 text-text-primary transition-transform duration-200 group-hover:translate-x-1"
fill="none"
aria-hidden="true"
>
<path d="M4 10h12m0 0-5-5m5 5-5 5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</p>
</div>
</Link>
</Reveal>
)}
</main>
<Footer />
</>
);
}
+48
View File
@@ -129,6 +129,54 @@ function renderNode(node: LexicalNode, key: string): ReactNode {
{renderChildren(node.children, key)}
</p>
);
// Lexical's default blockquote feature — used sitewide as a "Merke
// dir:" pull-quote callout, per page-blog-detail's actual built Figma
// frame (node 4676:341, file jCCZyh1DGwdjpv1wGge9To) — NOT a bordered/
// background card (an earlier version of this guessed one; the real
// design has no background or padding at all, just a plain 3-column
// row: label+underline, a full-height divider rule, then the quote
// lines). Icon is the actual exported sparkle asset from that node
// (icon-sparkle-merke-dir.png), not a hand-drawn approximation. The
// "Merke dir:" label itself is generic/hardcoded here rather than
// content-authored, since a blog post's own body text drives which
// lines get quoted, not the label framing them — legal pages never
// use blockquotes, so this styling is effectively blog-only in
// practice despite living in the shared renderer.
case "quote":
return (
<div key={key} className="relative flex items-start gap-6 w-full my-6">
<div className="flex items-center gap-2 shrink-0">
<span className="flex h-7 w-6 items-center justify-center shrink-0">
<img alt="" src="/icon-sparkle-merke-dir.png" className="w-full h-full object-contain" />
</span>
<span
className="font-bold text-text-primary text-[1.625rem] whitespace-nowrap"
style={{ fontFamily: "var(--font-caveat)" }}
>
Merke dir:
</span>
</div>
{/* Hand-drawn underline image, not a plain bar — exported
straight from the Figma node (label-underline). */}
<img
alt=""
src="/icon-merke-dir-underline.png"
className="absolute left-8 top-[2.1875rem] w-[8.5rem] h-[1.4375rem] object-cover pointer-events-none"
/>
<div className="w-px self-stretch bg-brand shrink-0" />
<div className="flex flex-col gap-3.5 flex-1">
{(node.children ?? []).map((child, i) => (
<p
key={`${key}-q-${i}`}
className="text-text-primary text-[1.75rem] leading-[1.1]"
style={{ fontFamily: "var(--font-caveat)" }}
>
{renderChildren(child.children, `${key}-q-${i}`)}
</p>
))}
</div>
</div>
);
default:
return renderChildren(node.children, key);
}
+13
View File
@@ -9,3 +9,16 @@
export function formatPrice(value: number): string {
return `${value.toFixed(2).replace(".", ",")}`;
}
const MONTHS_DE = [
"Januar", "Februar", "März", "April", "Mai", "Juni",
"Juli", "August", "September", "Oktober", "November", "Dezember",
];
// "03. Juli 2025" — spelled-out German month, not Intl.DateTimeFormat,
// so the exact mockup format doesn't depend on the server's ICU locale data.
export function formatDate(iso: string): string {
const d = new Date(iso);
const day = String(d.getDate()).padStart(2, "0");
return `${day}. ${MONTHS_DE[d.getMonth()]} ${d.getFullYear()}`;
}
+41
View File
@@ -56,6 +56,47 @@ export async function getBlogPosts(limit = 3): Promise<BlogPost[]> {
}));
}
export type PostDetail = BlogPost & {
content: unknown;
publishedAt: string;
};
type PayloadPostDetail = PayloadPost & { content: unknown; publishedAt: string };
export async function getPostBySlug(slug: string): Promise<PostDetail | null> {
const params = new URLSearchParams({
"where[tenant.slug][equals]": TENANT_SLUG,
"where[slug][equals]": slug,
depth: "2",
limit: "1",
});
const res = await fetch(`${PAYLOAD_URL}/api/posts?${params}`, {
next: { revalidate: 60 },
});
if (!res.ok) {
console.error(`getPostBySlug: Payload returned ${res.status} ${res.statusText}`);
return null;
}
const data: { docs?: PayloadPostDetail[] } = await res.json();
const doc = data.docs?.[0];
if (!doc) return null;
return {
id: doc.id,
title: doc.title,
slug: doc.slug,
category:
typeof doc.category === "object" && doc.category ? doc.category.name : "",
readTime: doc.readTime,
excerpt: doc.excerpt,
thumbnail:
typeof doc.thumbnail === "object" && doc.thumbnail ? doc.thumbnail.url : null,
content: doc.content,
publishedAt: doc.publishedAt,
};
}
// Frontend-facing shape — `id` is Payload's `slug` field, not its numeric
// row id. Cart items are stored in localStorage keyed by this string (see
// lib/cart.ts), so slugs were chosen in the Products collection to match
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB