Link Impressum's seller identity to company-settings, not hand-typed prose

"Angaben zum Anbieter"/"Umsatzsteuer"/"Handelsregister"/"Geschäftsführung"/
"Verantwortlich für den Inhalt" used to be hand-typed into the Impressum's
richText content (seed-legal-pages.ts) with no connection to the same
seller data the invoice PDFs and every email footer already pull from
company-settings — an admin updating one had no reason to remember the
other existed, and the old text was already stale in one concrete way:
it never showed Handelsregister/Geschäftsführung at all even though
company-settings has modeled both since the legal-form work shipped.

Now rendered by a new AnbieterAngaben component, straight from
getCompanySettings(), positioned above the CMS richText (which keeps
only genuinely editorial content: Kontakt, Haftung für Inhalte, Haftung
für Links, Urheberrecht). Same "structural/brand elements in code, only
pull the actual numbers/copy that need single-sourcing from data"
pattern this page's own Nachhaltigkeit sidebar card already used.

RichText.tsx's headingId() is now exported so the new block's headings
get the exact same id-assignment logic as CMS-driven ones, keeping the
SectionTOC sidebar's ids in sync with both sources.

/impressum moves from static to dynamic rendering (it now fetches live
company-settings data, cache: "no-store") — an acceptable tradeoff for
a legally-required page to never show stale seller info.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-23 11:10:02 +00:00
parent 039b8ba28d
commit 21c4e9f007
3 changed files with 113 additions and 5 deletions
+7 -1
View File
@@ -35,7 +35,13 @@ function plainText(node: LexicalNode): string {
// "section-1" id from the leading number — immune to copy edits changing
// the heading text later, unlike a text-derived slug. Anything else
// (headings with no leading number) falls back to a plain slugify.
function headingId(text: string): string {
// Exported — the Impressum page renders some of its own headings outside
// this CMS-driven richText (the "Angaben zum Anbieter"/"Umsatzsteuer"/
// "Verantwortlich für den Inhalt" sections come straight from
// company-settings, not the richText field, see app/impressum/page.tsx)
// and needs the exact same id-assignment logic so its SectionTOC entries
// actually match the ids those headings render with.
export function headingId(text: string): string {
const numbered = text.match(/^(\d+)\./);
if (numbered) return `section-${numbered[1]}`;
return text
@@ -0,0 +1,92 @@
import type { ReactNode } from "react";
import { headingId } from "../../components/RichText";
import type { CompanySettings } from "../../lib/payload";
import type { TOCSection } from "../../components/SectionTOC";
// Renders "Angaben zum Anbieter"/"Umsatzsteuer"/(conditionally)
// "Handelsregister"/"Geschäftsführung"/"Verantwortlich für den Inhalt"
// straight from company-settings, matching RichText.tsx's own heading/
// paragraph classes so it reads as one continuous page with the CMS
// content below it, not a bolted-on block. This used to be hand-typed
// prose baked into the Impressum's richText (seed-legal-pages.ts on the
// Payload side) — duplicated, and silently out of date the moment an
// admin changed company-settings without also remembering to re-edit the
// Impressum text by hand. Single-sourced here instead, same "structural/
// brand elements in code, only pull the actual numbers/copy that need
// single-sourcing from data" pattern this page's own Nachhaltigkeit card
// already uses (see page.tsx's comment on that).
//
// Also closes a real compliance gap the old hand-typed text had: it never
// showed Handelsregister/Geschäftsführung at all, even though
// company-settings already models both (§37a HGB/§35a GmbHG) — those
// fields just weren't wired into the Impressum. A sole proprietorship
// (this shop's current legalForm) has neither, so neither section shows
// today, but the moment that changes in company-settings, the Impressum
// picks it up automatically instead of needing a second manual edit.
export function anbieterAngabenHeadings(seller: CompanySettings | null): TOCSection[] {
if (!seller) return [];
const sections = ["Angaben zum Anbieter", "Umsatzsteuer"];
if (seller.registerCourt && seller.registerNumber) sections.push("Handelsregister");
if (seller.managingDirector) sections.push("Geschäftsführung");
sections.push("Verantwortlich für den Inhalt");
return sections.map((title) => ({ id: headingId(title), title }));
}
function Heading({ children }: { children: string }) {
return (
<h2
id={headingId(children)}
className="font-semibold text-h-small text-text-primary mt-2 scroll-mt-32 first:mt-0"
style={{ fontFamily: "var(--font-lora)" }}
>
{children}
<span className="block h-[0.125rem] w-8 bg-brand mt-2" aria-hidden />
</h2>
);
}
function P({ children }: { children: ReactNode }) {
return <p className="text-body text-text-body">{children}</p>;
}
export function AnbieterAngaben({ seller }: { seller: CompanySettings }) {
return (
<div className="flex flex-col gap-4 w-full">
<Heading>Angaben zum Anbieter</Heading>
<P>{seller.sellerName}</P>
<P>{seller.sellerStreet}</P>
<P>
{seller.sellerZip} {seller.sellerCity}
</P>
<P>{seller.sellerCountry}</P>
<P>E-Mail: {seller.sellerEmail}</P>
<Heading>Umsatzsteuer</Heading>
<P>Umsatzsteuer-Identifikationsnummer gemäß § 27 a Umsatzsteuergesetz:</P>
<P>{seller.vatId}</P>
{seller.registerCourt && seller.registerNumber && (
<>
<Heading>Handelsregister</Heading>
<P>{seller.registerCourt}</P>
<P>{seller.registerNumber}</P>
</>
)}
{seller.managingDirector && (
<>
<Heading>Geschäftsführung</Heading>
<P>{seller.managingDirector}</P>
</>
)}
<Heading>Verantwortlich für den Inhalt</Heading>
<P>{seller.managingDirector || seller.sellerName}</P>
<P>{seller.sellerStreet}</P>
<P>
{seller.sellerZip} {seller.sellerCity}
</P>
<P>{seller.sellerCountry}</P>
</div>
);
}
+14 -4
View File
@@ -7,7 +7,8 @@ import { Footer } from "../components/Footer";
import { RichText, extractHeadings } from "../components/RichText";
import { LiveRichText } from "../components/LiveRichText";
import { SectionTOC } from "../components/SectionTOC";
import { getLegalPage } from "../lib/payload";
import { getLegalPage, getCompanySettings } from "../lib/payload";
import { AnbieterAngaben, anbieterAngabenHeadings } from "./components/AnbieterAngaben";
export const metadata: Metadata = {
title: "Impressum",
@@ -17,8 +18,11 @@ export const metadata: Metadata = {
export default async function ImpressumPage() {
const { isEnabled: isPreview } = await draftMode();
const page = await getLegalPage("impressum", { draft: isPreview });
const headings = page ? extractHeadings(page.content) : [];
const [page, seller] = await Promise.all([getLegalPage("impressum", { draft: isPreview }), getCompanySettings()]);
// Anbieter-Angaben headings first — that block renders above the CMS
// content below, so its TOC entries need to lead too, or the sidebar
// would list sections in a different order than they actually appear.
const headings = [...anbieterAngabenHeadings(seller), ...(page ? extractHeadings(page.content) : [])];
return (
<>
@@ -66,7 +70,13 @@ export default async function ImpressumPage() {
</div>
</div>
<div className="w-full lg:flex-1 min-w-0">
<div className="w-full lg:flex-1 min-w-0 flex flex-col gap-8">
{/* Seller identity (name/address/USt-ID/Handelsregister/
Geschäftsführung) comes straight from company-settings, not
the CMS richText below — single-sourced so it can never
drift out of sync with the same data the invoice PDFs and
every email footer already use. See AnbieterAngaben.tsx. */}
{seller && <AnbieterAngaben seller={seller} />}
{page ? (
isPreview ? <LiveRichText initialContent={page.content} /> : <RichText content={page.content} />
) : (