Generate Muster-Widerrufsformular PDF live from company-settings instead of a static upload

The "An:" address used to be baked into a hand-crafted PDF and silently
went stale whenever an admin updated the Impressum's Anbieterdaten
without also re-exporting/re-uploading the file by hand — exactly what
happened 2026-07-29. New /api/muster-widerrufsformular route renders it
on-the-fly via @react-pdf/renderer using the same getCompanySettings()
data as AnbieterAngaben.tsx's Impressum block, so the two can never drift
apart again. Disables react-pdf's default hyphenation, which otherwise
split the seller's email address mid-word to fit the line.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-29 22:30:51 +00:00
parent 22fef4fe49
commit 70abe20250
2 changed files with 169 additions and 10 deletions
+155
View File
@@ -0,0 +1,155 @@
import React from "react";
import { Document, Page, Text, View, StyleSheet, Font, renderToBuffer } from "@react-pdf/renderer";
import { getCompanySettings } from "../../lib/payload";
// react-pdf's default hyphenation would otherwise auto-split long words at
// syllable boundaries to fit the line — including inside the seller's own
// email address (confirmed: "hallo@einfach-produktiv.com" rendered as
// "einfach-produk-tiv.com"), which must never be broken mid-word.
Font.registerHyphenationCallback((word) => [word]);
// Generated on-the-fly from live company-settings instead of a static
// uploaded PDF (see /widerruf's own download link) — the "An:"
// address used to be baked into a hand-crafted PDF and silently went
// stale the moment an admin updated the Impressum's Anbieterdaten without
// remembering to also re-export/re-upload this file by hand (exactly what
// happened 2026-07-29: the address changed in company-settings, the PDF
// still had the old one). Same source data as AnbieterAngaben.tsx's
// Impressum block, so the two can never drift apart again.
const BRAND = "#f6a701";
const DARK = "#1b1b1a";
const GREY = "#737371";
const RULE = "#d1cec4";
const styles = StyleSheet.create({
page: {
fontSize: 10.5,
color: DARK,
paddingTop: 78,
paddingBottom: 64,
paddingHorizontal: 64,
},
topBar: {
position: "absolute",
top: 0,
left: 0,
right: 0,
height: 8,
backgroundColor: BRAND,
},
title: {
fontSize: 15,
fontFamily: "Times-Bold",
marginBottom: 8,
},
subtitle: {
fontSize: 20,
fontFamily: "Times-Bold",
marginBottom: 10,
},
accent: {
width: 32,
height: 2.2,
backgroundColor: BRAND,
marginBottom: 14,
},
note: {
fontSize: 9.5,
color: GREY,
lineHeight: 1.4,
marginBottom: 16,
},
label: {
fontFamily: "Helvetica-Bold",
marginBottom: 4,
},
paragraph: {
lineHeight: 1.45,
marginBottom: 4,
},
paragraphSpaced: {
lineHeight: 1.45,
marginTop: 16,
marginBottom: 4,
},
section: {
marginTop: 20,
},
sectionLabel: {
fontSize: 8.5,
fontFamily: "Helvetica-Bold",
color: GREY,
marginBottom: 8,
},
sectionRule: {
borderBottomWidth: 0.75,
borderBottomColor: RULE,
},
footerNote: {
fontSize: 8.5,
color: GREY,
marginTop: 18,
},
});
const SECTION_HEADERS = [
"BESTELLTE WARE(N) / DIENSTLEISTUNG",
"BESTELLT AM (*)",
"ERHALTEN AM (*)",
"NAME DES/DER VERBRAUCHER(S)",
"ANSCHRIFT DES/DER VERBRAUCHER(S)",
"UNTERSCHRIFT DES/DER VERBRAUCHER(S) (NUR BEI MITTEILUNG AUF PAPIER)",
"DATUM",
];
export async function GET() {
const seller = await getCompanySettings();
const addressLine = seller
? `${seller.sellerName} - ${seller.sellerStreet}, ${seller.sellerZip} ${seller.sellerCity}, E-Mail: ${seller.sellerEmail}`
: "einfach produktiv.";
const doc = React.createElement(
Document,
{ title: "Muster-Widerrufsformular - einfach produktiv." },
React.createElement(
Page,
{ size: "A4", style: styles.page },
React.createElement(View, { style: styles.topBar }),
React.createElement(Text, { style: styles.title }, "einfach produktiv."),
React.createElement(Text, { style: styles.subtitle }, "Muster-Widerrufsformular"),
React.createElement(View, { style: styles.accent }),
React.createElement(
Text,
{ style: styles.note },
"(Wenn du diesen Vertrag widerrufen möchtest, fülle bitte dieses Formular aus und sende es per Post oder als E-Mail-Anhang an uns zurück.)",
),
React.createElement(Text, { style: styles.label }, "An:"),
React.createElement(Text, { style: styles.paragraph }, addressLine),
React.createElement(
Text,
{ style: styles.paragraphSpaced },
"Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*):",
),
...SECTION_HEADERS.map((header) =>
React.createElement(
View,
{ key: header, style: styles.section },
React.createElement(Text, { style: styles.sectionLabel }, header),
React.createElement(View, { style: styles.sectionRule }),
),
),
React.createElement(Text, { style: styles.footerNote }, "(*) Unzutreffendes streichen."),
),
);
const buffer = await renderToBuffer(doc);
return new Response(buffer as unknown as BodyInit, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": 'inline; filename="Muster-Widerrufsformular.pdf"',
"Cache-Control": "no-store",
},
});
}
+14 -10
View File
@@ -75,14 +75,19 @@ export default async function WiderrufPage() {
<p className="text-body text-text-muted">Inhalte werden gerade aktualisiert.</p>
)}
{page?.attachment && (
<a
href={page.attachment.url}
target="_blank"
rel="noopener noreferrer"
download
className="group flex items-center gap-5 bg-bg-muted hover:bg-bg-white border border-border rounded-md p-6 transition-colors"
>
{/* Generated live from company-settings (/api/muster-widerrufsformular)
instead of the CMS attachment field — see that route's own
comment on why: the static uploaded PDF's "An:" address
silently went stale whenever an admin updated the
Impressum's Anbieterdaten without also re-exporting this
file by hand. */}
<a
href="/api/muster-widerrufsformular"
target="_blank"
rel="noopener noreferrer"
download="Muster-Widerrufsformular.pdf"
className="group flex items-center gap-5 bg-bg-muted hover:bg-bg-white border border-border rounded-md p-6 transition-colors"
>
<div className="flex size-12 shrink-0 items-center justify-center rounded-sm bg-bg-white border border-border text-brand">
<svg viewBox="0 0 24 24" className="size-6" fill="none" aria-hidden="true">
<path
@@ -103,7 +108,7 @@ export default async function WiderrufPage() {
</div>
<div className="flex flex-col gap-0.5 flex-1 min-w-0">
<p className="font-semibold text-body text-text-primary">
{page.attachment.title || "Muster-Widerrufsformular (PDF)"}
Muster-Widerrufsformular (PDF)
</p>
<p className="text-body-sm text-text-muted">Herunterladen</p>
</div>
@@ -116,7 +121,6 @@ export default async function WiderrufPage() {
<path d="M12 4v13m0 0-5-5m5 5 5-5M5 21h14" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</a>
)}
</div>
</div>