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:
@@ -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
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user