Custom post content blocks (images/gallery/video/quote) + backend-driven SEO settings

RichText.tsx switched to Payload's official React renderer + custom
JSXConverters (same call signature, LiveRichText/LivePostContent
untouched) — needed to render the new Lexical Blocks the Payload repo's
Posts.content just gained. Converters follow the existing CMS-image
convention (relative + aspect-[...] + fill + object-cover); the video
block resolves YouTube/Vimeo links to an iframe embed.

New getSeoSettings() fetcher (same pattern as getKleinunternehmer()),
app/layout.tsx now generateMetadata() reading it with the same fallback
values it used to hardcode. Per-post SEO overrides (seoTitle/
seoDescription/seoImage) wired into the blog detail page's metadata,
falling back to title/excerpt/thumbnail when empty.

Also fixed while auditing every page's metadata: missing descriptions on
3 konto pages, a static title on the dynamic order-detail route, and
missing OG images on /shop and /blog.
This commit is contained in:
Marco
2026-07-24 21:10:33 +00:00
parent b1b1aa2037
commit 797d9d42fe
13 changed files with 4249 additions and 184 deletions
+18 -5
View File
@@ -19,16 +19,29 @@ export async function generateMetadata({
const post = await getPostBySlug(slug);
if (!post) return { title: "Beitrag nicht gefunden" };
// Each falls back to the normal field when its SEO override (Posts.ts's
// "SEO" collapsible group) is empty — filling those in is optional, a
// post already has sensible metadata without them.
const title = post.seoTitle || post.title;
const description = post.seoDescription || post.excerpt;
const image = post.seoImage || post.thumbnail;
return {
title: post.title,
description: post.excerpt,
title,
description,
alternates: { canonical: `/blog/${post.slug}` },
openGraph: {
title: `${post.title} | einfach produktiv.`,
description: post.excerpt,
title: `${title} | einfach produktiv.`,
description,
url: `/blog/${post.slug}`,
type: "article",
images: post.thumbnail ? [{ url: post.thumbnail }] : undefined,
images: image ? [{ url: image }] : undefined,
},
twitter: {
card: "summary_large_image",
title,
description,
images: image ? [image] : undefined,
},
};
}
+7
View File
@@ -11,6 +11,13 @@ export const metadata: Metadata = {
title: "Blog",
description: "Gedanken, Methoden und Impulse für einen leichteren und klareren Alltag.",
alternates: { canonical: "/blog" },
openGraph: {
title: "Blog | einfach produktiv.",
description: "Gedanken, Methoden und Impulse für einen leichteren und klareren Alltag.",
url: "/blog",
type: "website",
images: ["/blog-featured.jpg"],
},
};
export default async function BlogOverviewPage() {
+179 -110
View File
@@ -1,31 +1,24 @@
import type { ReactNode } from "react";
import Image from "next/image";
import { RichText as LexicalRichText, type JSXConvertersFunction } from "@payloadcms/richtext-lexical/react";
import type { TOCSection } from "./SectionTOC";
import { QuoteLabel } from "./QuoteLabel";
// Minimal Lexical JSONJSX renderer for Payload's richText fields.
// Deliberately small and dependency-free (matches the project's existing
// style — see Posts.ts's own hand-rolled extractPlainText on the Payload
// side) rather than pulling in @payloadcms/richtext-lexical's full React
// renderer just to walk a legal page's headings/paragraphs/lists. Covers
// the node types real content actually uses; add more only when a page
// genuinely needs them.
// Switched 2026-07-24 from a small hand-rolled Lexical JSON->JSX walker to
// Payload's own official React renderer + custom JSXConverters — needed
// once Posts.content gained custom Lexical Blocks (Bild/Bildergalerie/
// Video/Zitat, see payload/src/collections/Posts.ts), which the old
// hand-rolled switch had no case for at all. extractHeadings()/headingId()
// below are kept as an independent, minimal walk over the raw JSON (same
// as before) — they only ever need to find h2 headings for SectionTOC and
// never touch Blocks, no reason to route that through the new renderer too.
type LexicalNode = {
type: string;
children?: LexicalNode[];
text?: string;
format?: number;
tag?: string;
listType?: "bullet" | "number";
fields?: { url?: string };
};
// Lexical's text format is a bitmask — see TextFormatType in the Lexical
// source (IS_BOLD = 1, IS_ITALIC = 2, IS_UNDERLINE = 8).
const BOLD = 1;
const ITALIC = 2;
const UNDERLINE = 8;
function plainText(node: LexicalNode): string {
if (node.type === "text") return node.text ?? "";
return (node.children ?? []).map(plainText).join("");
@@ -70,114 +63,185 @@ export function extractHeadings(content: unknown): TOCSection[] {
return headings;
}
function renderChildren(nodes: LexicalNode[] | undefined, keyPrefix: string, quoteLabel: string): ReactNode {
if (!nodes) return null;
return nodes.map((node, i) => renderNode(node, `${keyPrefix}-${i}`, quoteLabel));
// Payload upload relations resolve to the full media doc when fetched at
// sufficient depth (every richText-consuming fetch in app/lib/payload.ts
// already uses depth >= 2), or fall back to a bare id if not — only
// render when actually populated.
type MediaRef = { url?: string | null } | number | null | undefined;
function mediaUrl(ref: MediaRef): string | null {
if (ref && typeof ref === "object" && typeof ref.url === "string") return ref.url;
return null;
}
function renderNode(node: LexicalNode, key: string, quoteLabel: string): ReactNode {
switch (node.type) {
case "linebreak":
return <br key={key} />;
case "text": {
let el: ReactNode = node.text;
const format = node.format ?? 0;
if (format & BOLD) el = <strong key={key}>{el}</strong>;
if (format & ITALIC) el = <em key={key}>{el}</em>;
if (format & UNDERLINE) el = <u key={key}>{el}</u>;
return <span key={key}>{el}</span>;
}
case "link":
return (
<a
key={key}
href={node.fields?.url ?? "#"}
className="text-brand hover:underline"
>
{renderChildren(node.children, key, quoteLabel)}
</a>
);
case "heading": {
const Tag = (node.tag ?? "h2") as "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
const text = plainText(node);
// Naive YouTube/Vimeo URL -> embed URL. Not exhaustive (no playlist/short-
// link edge cases) — good enough for a "paste a link" editor field; a
// URL that doesn't match either pattern just doesn't render rather than
// guessing wrong.
function toEmbedUrl(url: string): string | null {
const youtube = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([\w-]{6,})/);
if (youtube) return `https://www.youtube.com/embed/${youtube[1]}`;
const vimeo = url.match(/vimeo\.com\/(\d+)/);
if (vimeo) return `https://player.vimeo.com/video/${vimeo[1]}`;
return null;
}
type ImageBlockFields = { image: MediaRef; caption?: string | null };
type ImageGalleryBlockFields = { images: { image: MediaRef; caption?: string | null }[] };
type VideoEmbedBlockFields = { url: string; caption?: string | null };
type QuoteBlockFields = { text: string; label?: string | null };
function BlockCaption({ caption }: { caption?: string | null }) {
if (!caption) return null;
return <p className="text-body-sm text-text-muted text-center">{caption}</p>;
}
// Same visual treatment as the QuoteBlock converter below (and the native
// blockquote case it replaces going forward) — see that converter's own
// comment for why both still exist.
function Quote({ label, children }: { label?: string; children: React.ReactNode }) {
return (
<div className="relative flex items-start gap-6 w-full my-6">
{/* Label/icon/underline are optional — if empty, only the divider +
quote text render. The quote itself is never optional, just this
framing around it. */}
{label && <QuoteLabel label={label} />}
<div className="w-px self-stretch bg-brand shrink-0" />
<p
className="text-text-primary text-[1.75rem] leading-[1.1] flex-1"
style={{ fontFamily: "var(--font-caveat)" }}
>
{children}
</p>
</div>
);
}
// A factory, not a module-level constant — needs to close over each
// call's own `quoteLabel` (the native "quote" converter reads it). Server
// Components can render multiple posts concurrently in the same process,
// so a shared module-level variable set right before rendering would be
// a real race condition, not just a style choice.
function buildConverters(quoteLabel: string): JSXConvertersFunction {
return ({ defaultConverters }) => ({
...defaultConverters,
paragraph: ({ node, nodesToJSX }) => (
<p className="text-body text-text-body">{nodesToJSX({ nodes: node.children })}</p>
),
heading: ({ node, nodesToJSX }) => {
const Tag = node.tag as "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
const text = plainText(node as unknown as LexicalNode);
return (
<Tag
key={key}
id={Tag === "h2" ? headingId(text) : undefined}
className="font-semibold text-h-small text-text-primary mt-2 scroll-mt-32 first:mt-0"
style={{ fontFamily: "var(--font-lora)" }}
>
{renderChildren(node.children, key, quoteLabel)}
{nodesToJSX({ nodes: node.children })}
<span className="block h-[0.125rem] w-8 bg-brand mt-2" aria-hidden />
</Tag>
);
}
case "list": {
},
list: ({ node, nodesToJSX }) => {
const ListTag = node.listType === "number" ? "ol" : "ul";
return (
<ListTag
key={key}
className={
"flex flex-col gap-2 text-body text-text-body " +
(node.listType === "number" ? "list-decimal pl-5" : "list-disc pl-5")
}
>
{renderChildren(node.children, key, quoteLabel)}
{nodesToJSX({ nodes: node.children })}
</ListTag>
);
}
case "listitem":
return (
<li key={key}>{renderChildren(node.children, key, quoteLabel)}</li>
);
case "paragraph":
return (
<p key={key} className="text-body text-text-body">
{renderChildren(node.children, key, quoteLabel)}
</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">
{/* Label/icon/underline are optional (Posts.quoteLabel) — if
empty, only the divider + quote text render. The blockquote
itself is never optional, just this framing around it. */}
{quoteLabel && <QuoteLabel label={quoteLabel} />}
<div className="w-px self-stretch bg-brand shrink-0" />
{/* Lexical's real QuoteNode holds flat text/linebreak children
directly, NOT nested paragraphs — pressing Enter inside a
blockquote in the editor exits it into a new paragraph
rather than adding a line within it (confirmed by reading
@lexical/rich-text's QuoteNode.insertNewAfter). An earlier
version of this case assumed nested-paragraph children,
which only happened to work for this session's own
hand-authored seed JSON — any blockquote actually typed in
the CMS (Shift+Enter for a soft line break) rendered blank,
since child.children was undefined on a plain text node. */}
<p
className="text-text-primary text-[1.75rem] leading-[1.1] flex-1"
style={{ fontFamily: "var(--font-caveat)" }}
>
{renderChildren(node.children, key, quoteLabel)}
</p>
</div>
);
default:
return renderChildren(node.children, key, quoteLabel);
}
},
listitem: ({ node, nodesToJSX }) => <li>{nodesToJSX({ nodes: node.children })}</li>,
link: ({ node, nodesToJSX }) => (
<a href={node.fields?.url ?? "#"} className="text-brand hover:underline">
{nodesToJSX({ nodes: node.children })}
</a>
),
// Lexical's native blockquote feature — used by every post written
// before Blocks existed. Kept working exactly as before (own comment on
// Posts.ts's `content` field editor config on why this stays enabled
// alongside the new QuoteBlock) rather than migrating old content.
quote: ({ node, nodesToJSX }) => (
<Quote label={quoteLabel}>{nodesToJSX({ nodes: node.children })}</Quote>
),
blocks: {
image: ({ node }: { node: { fields: unknown } }) => {
const fields = node.fields as ImageBlockFields;
const url = mediaUrl(fields.image);
if (!url) return null;
return (
<div className="flex flex-col gap-2 w-full">
<div className="relative w-full aspect-[3/2] rounded-md overflow-hidden bg-bg-muted">
<Image alt="" src={url} fill sizes="(min-width: 768px) 48rem, 100vw" className="object-cover" />
</div>
<BlockCaption caption={fields.caption} />
</div>
);
},
imageGallery: ({ node }: { node: { fields: unknown } }) => {
const fields = node.fields as ImageGalleryBlockFields;
const images = (fields.images ?? []).filter((row) => mediaUrl(row.image));
if (images.length === 0) return null;
return (
<div className="grid grid-cols-2 gap-4 w-full">
{images.map((row, i) => (
<div key={i} className="flex flex-col gap-2">
<div className="relative aspect-[4/3] rounded-md overflow-hidden bg-bg-muted">
<Image
alt=""
src={mediaUrl(row.image)!}
fill
sizes="(min-width: 768px) 24rem, 50vw"
className="object-cover"
/>
</div>
<BlockCaption caption={row.caption} />
</div>
))}
</div>
);
},
videoEmbed: ({ node }: { node: { fields: unknown } }) => {
const fields = node.fields as VideoEmbedBlockFields;
const embedUrl = toEmbedUrl(fields.url);
if (!embedUrl) return null;
return (
<div className="flex flex-col gap-2 w-full">
<div className="relative w-full aspect-video rounded-md overflow-hidden bg-bg-muted">
<iframe
src={embedUrl}
title={fields.caption ?? "Video"}
className="absolute inset-0 h-full w-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
<BlockCaption caption={fields.caption} />
</div>
);
},
// Per-quote label, unlike Posts.quoteLabel above (one label shared by
// every native blockquote in the post) — new quotes going forward use
// this instead of the native blockquote feature.
quote: ({ node }: { node: { fields: unknown } }) => {
const fields = node.fields as QuoteBlockFields;
const lines = fields.text.split("\n");
return (
<Quote label={fields.label ?? undefined}>
{lines.map((line, i) => (
<span key={i}>
{line}
{i < lines.length - 1 && <br />}
</span>
))}
</Quote>
);
},
},
});
}
export function RichText({
@@ -185,18 +249,23 @@ export function RichText({
quoteLabel = "Merke dir:",
}: {
content: unknown;
/** Label for any blockquote's callout (see the "quote" case above) —
* defaults to "Merke dir:" for callers that don't pass one (legal pages
* never use blockquotes, so this only actually matters for blog posts).
* Pass "" to hide the label/icon/underline for every blockquote here. */
/** Label for any native blockquote's callout — defaults to "Merke dir:"
* for callers that don't pass one (legal pages never use blockquotes,
* so this only actually matters for blog posts). Pass "" to hide the
* label/icon/underline for every native blockquote here. New content
* should use the Zitat block instead, which carries its own label. */
quoteLabel?: string;
}) {
const root = (content as { root?: LexicalNode })?.root;
const root = (content as { root?: { children?: unknown[] } })?.root;
if (!root?.children) return null;
return (
<div className="flex flex-col gap-4 w-full">
{renderChildren(root.children, "root", quoteLabel)}
<LexicalRichText
data={content as Parameters<typeof LexicalRichText>[0]["data"]}
converters={buildConverters(quoteLabel)}
disableContainer
/>
</div>
);
}
+14 -4
View File
@@ -13,10 +13,20 @@ import { buildTrackingUrl, CARRIER_LABELS } from "../../../lib/tracking";
import { OrderActionButton } from "./components/OrderActionButton";
import { OrderStatusBadge } from "../../components/OrderStatusBadge";
export const metadata: Metadata = {
title: "Bestelldetails",
robots: { index: false, follow: true },
};
// Dynamic (was a static "Bestelldetails" title despite this being a
// per-order route) — just formats the already-known order number into
// the title, no extra fetch needed for a noindex account page.
export async function generateMetadata({
params,
}: {
params: Promise<{ orderNumber: string }>;
}): Promise<Metadata> {
const { orderNumber } = await params;
return {
title: `Bestellung ${decodeURIComponent(orderNumber)}`,
robots: { index: false, follow: true },
};
}
export default async function KontoBestellungDetailPage({ params }: { params: Promise<{ orderNumber: string }> }) {
const { orderNumber } = await params;
+1
View File
@@ -4,6 +4,7 @@ import { Footer } from "../../components/Footer";
export const metadata: Metadata = {
title: "Passwort vergessen",
description: "Setze dein Passwort für dein einfach produktiv-Konto zurück.",
robots: { index: false, follow: true },
};
@@ -4,6 +4,7 @@ import { Footer } from "../../components/Footer";
export const metadata: Metadata = {
title: "Passwort zurücksetzen",
description: "Vergib ein neues Passwort für dein einfach produktiv-Konto.",
robots: { index: false, follow: true },
};
+1
View File
@@ -11,6 +11,7 @@ import { AccountDataSection } from "./components/AccountDataSection";
export const metadata: Metadata = {
title: "Mein Profil",
description: "Verwalte deine Kontodaten und dein Passwort bei einfach produktiv.",
robots: { index: false, follow: true },
};
+27 -17
View File
@@ -4,7 +4,7 @@ import "./globals.css";
import { Navbar } from "./components/Navbar";
import { CartFlyProvider } from "./components/CartFly";
import { CartSync } from "./components/CartSync";
import { getProducts } from "./lib/payload";
import { getProducts, getSeoSettings } from "./lib/payload";
const inter = Inter({
variable: "--font-inter",
@@ -30,22 +30,32 @@ const lora = Lora({
weight: ["400", "600"],
});
export const metadata: Metadata = {
metadataBase: new URL("https://einfach-produktiv.mk360.de"),
title: {
default: "einfach produktiv. Werkzeuge und Impulse für einen leichteren Alltag",
template: "%s | einfach produktiv.",
},
description: "Werkzeuge, Impulse und ein Blog für mehr Klarheit im Alltag.",
openGraph: {
siteName: "einfach produktiv.",
locale: "de_DE",
type: "website",
},
twitter: {
card: "summary_large_image",
},
};
// Backend-driven since 2026-07-24 (CompanySettings' "SEO" tab) — the
// literal strings below are only the fallback getSeoSettings() returns if
// that field is empty or unreachable, kept identical to what used to be
// hardcoded here so nothing changes until an admin actually fills in the
// new fields.
export async function generateMetadata(): Promise<Metadata> {
const seo = await getSeoSettings();
return {
metadataBase: new URL("https://einfach-produktiv.mk360.de"),
title: {
default: seo.defaultTitle ?? "einfach produktiv.",
template: seo.titleTemplate ?? "%s | einfach produktiv.",
},
description: seo.defaultDescription ?? undefined,
openGraph: {
siteName: "einfach produktiv.",
locale: "de_DE",
type: "website",
images: seo.defaultOgImage ? [{ url: seo.defaultOgImage }] : undefined,
},
twitter: {
card: "summary_large_image",
images: seo.defaultOgImage ? [seo.defaultOgImage] : undefined,
},
};
}
export default async function RootLayout({
children,
+64
View File
@@ -93,12 +93,23 @@ export type PostDetail = BlogPost & {
* the card entirely, per-post choice (unlike Products.spotlight, which
* is a single site-wide flag). */
relatedProduct: Product | null;
/** SEO overrides (Posts.ts's "SEO" collapsible group) — each null when
* empty, callers fall back to title/excerpt/thumbnail themselves rather
* than baking the fallback in here, so the distinction between "no
* override set" and "override happens to equal the normal value" stays
* visible to whoever reads this. */
seoTitle: string | null;
seoDescription: string | null;
seoImage: string | null;
};
export type PayloadPostDetail = PayloadPost & {
content: unknown;
quoteLabel: string | null;
relatedProduct: PayloadProduct | null;
seoTitle?: string | null;
seoDescription?: string | null;
seoImage?: { url: string } | number | null;
};
// Shared by getPostBySlug() and LivePostContent.tsx (which re-maps the raw
@@ -120,6 +131,9 @@ export function mapPayloadPost(doc: PayloadPostDetail): PostDetail {
featured: doc.featured,
quoteLabel: doc.quoteLabel ?? "",
relatedProduct: doc.relatedProduct ? mapPayloadProduct(doc.relatedProduct) : null,
seoTitle: doc.seoTitle || null,
seoDescription: doc.seoDescription || null,
seoImage: typeof doc.seoImage === "object" && doc.seoImage ? doc.seoImage.url : null,
};
}
@@ -870,3 +884,53 @@ export async function getKleinunternehmer(): Promise<boolean> {
const data: { docs?: { kleinunternehmer: boolean }[] } = await res.json();
return data.docs?.[0]?.kleinunternehmer ?? false;
}
export type SeoSettings = {
defaultTitle: string | null;
titleTemplate: string | null;
defaultDescription: string | null;
defaultOgImage: string | null;
};
// Fallback matches the values hardcoded in app/layout.tsx before this field
// existed — used whenever the backend field is empty or unreachable, so
// filling in the CompanySettings SEO tab is optional, not a hard
// dependency for the site to render sensible metadata.
const SEO_SETTINGS_FALLBACK: SeoSettings = {
defaultTitle: "einfach produktiv. Werkzeuge und Impulse für einen leichteren Alltag",
titleTemplate: "%s | einfach produktiv.",
defaultDescription: "Werkzeuge, Impulse und ein Blog für mehr Klarheit im Alltag.",
defaultOgImage: null,
};
// Same ISR-cached, public-catalog-freshness fetch as getKleinunternehmer()
// above — every page's metadata reads this, so it needs to be cheap/cached,
// not the always-fresh getCompanySettings() used for invoice generation.
export async function getSeoSettings(): Promise<SeoSettings> {
const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, limit: "1", depth: "1" });
const res = await fetch(`${PAYLOAD_URL}/api/company-settings?${params}`, {
headers: { "x-order-service-secret": process.env.ORDER_SERVICE_SECRET || "" },
next: { revalidate: 60 },
});
if (!res.ok) {
console.error(`getSeoSettings: Payload returned ${res.status} ${res.statusText}`);
return SEO_SETTINGS_FALLBACK;
}
const data: {
docs?: {
seoDefaultTitle?: string | null;
seoTitleTemplate?: string | null;
seoDefaultDescription?: string | null;
seoDefaultOgImage?: { url?: string } | number | null;
}[];
} = await res.json();
const doc = data.docs?.[0];
if (!doc) return SEO_SETTINGS_FALLBACK;
return {
defaultTitle: doc.seoDefaultTitle || SEO_SETTINGS_FALLBACK.defaultTitle,
titleTemplate: doc.seoTitleTemplate || SEO_SETTINGS_FALLBACK.titleTemplate,
defaultDescription: doc.seoDefaultDescription || SEO_SETTINGS_FALLBACK.defaultDescription,
defaultOgImage:
(typeof doc.seoDefaultOgImage === "object" && doc.seoDefaultOgImage?.url) || SEO_SETTINGS_FALLBACK.defaultOgImage,
};
}
+1
View File
@@ -15,6 +15,7 @@ export const metadata: Metadata = {
"Alles, was du für mehr Klarheit im Alltag brauchst — ToDo-Karten, Wochenplaner, Notizbücher und Zielkarten von einfach produktiv.",
url: "/shop",
type: "website",
images: ["/hero-todo-karten.png"],
},
};