From 70abe202507d796daffddb59ac0c6144d8c86601 Mon Sep 17 00:00:00 2001 From: Marco Date: Wed, 29 Jul 2026 22:30:51 +0000 Subject: [PATCH] Generate Muster-Widerrufsformular PDF live from company-settings instead of a static upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/api/muster-widerrufsformular/route.ts | 155 ++++++++++++++++++++++ app/widerruf/page.tsx | 24 ++-- 2 files changed, 169 insertions(+), 10 deletions(-) create mode 100644 app/api/muster-widerrufsformular/route.ts diff --git a/app/api/muster-widerrufsformular/route.ts b/app/api/muster-widerrufsformular/route.ts new file mode 100644 index 0000000..c70a659 --- /dev/null +++ b/app/api/muster-widerrufsformular/route.ts @@ -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", + }, + }); +} diff --git a/app/widerruf/page.tsx b/app/widerruf/page.tsx index d5deefe..eee9e96 100644 --- a/app/widerruf/page.tsx +++ b/app/widerruf/page.tsx @@ -75,14 +75,19 @@ export default async function WiderrufPage() {

Inhalte werden gerade aktualisiert.

)} - {page?.attachment && ( - + {/* 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. */} +

- {page.attachment.title || "Muster-Widerrufsformular (PDF)"} + Muster-Widerrufsformular (PDF)

Herunterladen

@@ -116,7 +121,6 @@ export default async function WiderrufPage() {
- )}