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:
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()}`;
|
||||
}
|
||||
|
||||
@@ -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 |
Reference in New Issue
Block a user