feat(legal): electronic withdrawal function and remaining compliance fixes
Withdrawal function (§ 356a BGB, mandatory since 19 June 2026): - /vertrag-widerrufen: two-step flow "Vertrag widerrufen" -> "Widerruf bestätigen", no login, only name, order number and e-mail required. Prominent footer button on every page, prefilled link per order in the customer account. - /api/widerruf stores every declaration (matched to the order only if order number and e-mail fit), mails a receipt with the declaration and the exact time of receipt, notifies ADMIN_EMAIL. Same response for matching and non-matching orders (no enumeration), origin check and rate limit instead of a CAPTCHA that could block a statutory right. User input is HTML-escaped and stripped of line breaks in headers. - /admin/widerrufe lists declarations, open ones first, with an open-count badge and "mark as done". - Withdrawal notice gains the statutory sentence on the online function. Other fixes: - Newsletter stores confirmedAt as proof of consent (Art. 7(1) GDPR). - "Recently viewed" is only persisted with consent (§ 25(1) TDDDG); choosing "essential only" or withdrawing consent deletes it. The banner no longer promises audience-measurement cookies that do not exist. - Admin rejects an invalid company e-mail; an obfuscated stored value falls back to the configured address so the legal notice stays valid. - Privacy policy: Microsoft 365 as mail processor, log retention of at most 14 days, withdrawal-function processing, consent timestamps. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
4ab1a7edae
commit
dcfad4e011
27 files changed
+1049
-34
No files matched your search
@@ -2,6 +2,9 @@ import { cache } from "react";
|
||||
import { siteConfig } from "@/lib/site-config";
|
||||
import { getContentValues } from "@/lib/content";
|
||||
|
||||
/** Bewusst schlicht: nur "[email protected]" ohne Leerzeichen — faengt Verschleierungen wie "info [at] x.de" ab. */
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
const COMPANY_KEYS = [
|
||||
"company.name",
|
||||
"company.claim",
|
||||
@@ -46,7 +49,11 @@ export const getCompanyInfo = cache(async () => {
|
||||
claimAlt: v["company.claimAlt"],
|
||||
description: v["company.description"],
|
||||
location: v["company.location"],
|
||||
email: v["company.email"],
|
||||
// Altbestand kann eine verschleierte Adresse enthalten ("info [at] …"),
|
||||
// die das Admin inzwischen ablehnt. Im Impressum und in der
|
||||
// Widerrufsbelehrung muss sie direkt nutzbar sein — dann lieber die
|
||||
// konfigurierte Standardadresse als eine unbrauchbare.
|
||||
email: EMAIL_PATTERN.test(v["company.email"].trim()) ? v["company.email"].trim() : siteConfig.email,
|
||||
phone: v["company.phone"],
|
||||
address: {
|
||||
...siteConfig.address,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Cookie-/Speicher-Einwilligung (§ 25 Abs. 1 TDDDG). Die Auswahl aus dem
|
||||
* Banner liegt im localStorage; nur "all" erlaubt einwilligungspflichtige
|
||||
* Speicherungen. Nur im Browser aufrufen.
|
||||
*/
|
||||
|
||||
export const COOKIE_CONSENT_KEY = "wabenhain-cookie-consent";
|
||||
|
||||
/**
|
||||
* localStorage-Schluessel des "Zuletzt angesehen"-Verlaufs
|
||||
* (store/recently-viewed-store.ts) — einwilligungspflichtig, weil die
|
||||
* Funktion fuer den Einkauf nicht unbedingt erforderlich ist.
|
||||
*/
|
||||
export const RECENTLY_VIEWED_KEY = "wabenhain-recently-viewed";
|
||||
|
||||
export type Consent = "all" | "essential";
|
||||
|
||||
export function readConsent(): Consent | null {
|
||||
try {
|
||||
const value = window.localStorage.getItem(COOKIE_CONSENT_KEY);
|
||||
return value === "all" || value === "essential" ? value : null;
|
||||
} catch {
|
||||
// Gesperrter Speicher (privater Modus, Richtlinie): wie "keine Einwilligung".
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function hasOptionalConsent(): boolean {
|
||||
return readConsent() === "all";
|
||||
}
|
||||
|
||||
/** Entfernt alle einwilligungspflichtigen Eintraege — bei "Nur notwendige" und beim Widerruf. */
|
||||
export function clearOptionalStorage(): void {
|
||||
try {
|
||||
window.localStorage.removeItem(RECENTLY_VIEWED_KEY);
|
||||
} catch {
|
||||
// Nichts zu entfernen, wenn der Speicher nicht zugaenglich ist.
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { formatPrice } from "@/lib/format";
|
||||
import { getSiteUrl } from "@/lib/url";
|
||||
import { getCompanyInfo } from "@/lib/company-info";
|
||||
import { getEmailStrings } from "@/lib/email-strings";
|
||||
import { withdrawalReceiptEmailContent, type WithdrawalDeclarationInput } from "@/lib/withdrawal";
|
||||
import type { Locale } from "@/i18n/config";
|
||||
|
||||
interface EmailContent {
|
||||
@@ -301,6 +302,20 @@ export async function backInStockEmail(product: BackInStockInput): Promise<Email
|
||||
};
|
||||
}
|
||||
|
||||
/** Eingangsbestaetigung der elektronischen Widerrufsfunktion (§ 356a Abs. 4 BGB). */
|
||||
export async function withdrawalReceiptEmail(
|
||||
input: WithdrawalDeclarationInput,
|
||||
receivedAt: Date,
|
||||
locale: Locale
|
||||
): Promise<EmailContent> {
|
||||
const content = withdrawalReceiptEmailContent(input, receivedAt, locale);
|
||||
return {
|
||||
subject: content.subject,
|
||||
html: await emailLayout(locale, content.preheader, content.bodyHtml),
|
||||
text: content.text,
|
||||
};
|
||||
}
|
||||
|
||||
export async function returnAcceptedEmail(order: OrderStatusUpdateInput): Promise<EmailContent> {
|
||||
const s = getEmailStrings(order.locale).returnAccepted;
|
||||
const html = await emailLayout(
|
||||
|
||||
@@ -29,6 +29,17 @@ export const returnRequestSchema = z.object({
|
||||
});
|
||||
export type ReturnRequestInput = z.infer<typeof returnRequestSchema>;
|
||||
|
||||
// Elektronische Widerrufsfunktion (§ 356a Abs. 2 BGB): Pflicht sind NUR Name,
|
||||
// Vertragsidentifikation und E-Mail — jedes weitere Pflichtfeld waere eine
|
||||
// unzulaessige Huerde. Die Nachricht bleibt daher optional.
|
||||
export const withdrawalSchema = z.object({
|
||||
name: z.string().trim().min(2, "Bitte gib deinen Namen an.").max(120),
|
||||
orderNumber: z.string().trim().min(1, "Bitte gib deine Bestellnummer an.").max(40),
|
||||
email: emailSchema,
|
||||
message: z.string().trim().max(2000).optional().transform((value) => value || null),
|
||||
});
|
||||
export type WithdrawalInput = z.infer<typeof withdrawalSchema>;
|
||||
|
||||
export const businessInquirySchema = z.object({
|
||||
companyName: z.string().trim().min(2, "Bitte gib den Firmennamen an.").max(200),
|
||||
contactPerson: z.string().trim().min(2, "Bitte gib einen Ansprechpartner an.").max(100),
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { escapeHtml, formatReceivedAt, withdrawalDeclarationLines, withdrawalReceiptEmailContent } from "./withdrawal";
|
||||
|
||||
const input = {
|
||||
name: "Erika Mustermann",
|
||||
orderNumber: "WH-2026-0042",
|
||||
email: "[email protected]",
|
||||
message: null,
|
||||
};
|
||||
|
||||
// 26.09.2026 12:03:07 UTC = 14:03:07 in Berlin (Sommerzeit)
|
||||
const receivedAt = new Date(Date.UTC(2026, 8, 26, 12, 3, 7));
|
||||
|
||||
describe("formatReceivedAt", () => {
|
||||
it("formats in German local time with seconds", () => {
|
||||
expect(formatReceivedAt(receivedAt, "de")).toBe("26.09.2026, 14:03:07 Uhr (MESZ)");
|
||||
});
|
||||
|
||||
it("uses winter time in winter", () => {
|
||||
expect(formatReceivedAt(new Date(Date.UTC(2026, 0, 5, 9, 0, 0)), "de")).toBe("05.01.2026, 10:00:00 Uhr (MEZ)");
|
||||
});
|
||||
|
||||
it("formats in English with the German time zone", () => {
|
||||
expect(formatReceivedAt(receivedAt, "en")).toBe("26 September 2026, 14:03:07 (CEST, German time)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("withdrawalDeclarationLines", () => {
|
||||
it("states the withdrawal and the identifying data", () => {
|
||||
expect(withdrawalDeclarationLines(input, "de")).toEqual([
|
||||
"Hiermit widerrufe ich den Vertrag über die Bestellung WH-2026-0042.",
|
||||
"Name: Erika Mustermann",
|
||||
"E-Mail: [email protected]",
|
||||
]);
|
||||
});
|
||||
|
||||
it("adds an optional message", () => {
|
||||
expect(withdrawalDeclarationLines({ ...input, message: "Falsche Sorte" }, "en")).toEqual([
|
||||
"I hereby withdraw from the contract for order WH-2026-0042.",
|
||||
"Name: Erika Mustermann",
|
||||
"Email: [email protected]",
|
||||
"Message: Falsche Sorte",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("escapeHtml", () => {
|
||||
it("neutralises markup", () => {
|
||||
expect(escapeHtml(`<a href="x">'&'</a>`)).toBe("<a href="x">'&'</a>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("withdrawalReceiptEmailContent", () => {
|
||||
it("contains the declaration and the time of receipt in text and HTML", () => {
|
||||
const mail = withdrawalReceiptEmailContent(input, receivedAt, "de");
|
||||
expect(mail.subject).toBe("Eingangsbestätigung deines Widerrufs zu WH-2026-0042");
|
||||
expect(mail.text).toContain("Hiermit widerrufe ich den Vertrag über die Bestellung WH-2026-0042.");
|
||||
expect(mail.text).toContain("26.09.2026, 14:03:07 Uhr (MESZ)");
|
||||
expect(mail.bodyHtml).toContain("26.09.2026, 14:03:07 Uhr (MESZ)");
|
||||
});
|
||||
|
||||
it("escapes user input in the HTML body and strips line breaks from the subject", () => {
|
||||
const mail = withdrawalReceiptEmailContent(
|
||||
{ ...input, name: `<img src=x onerror=alert(1)>`, orderNumber: "A1\r\nBcc: [email protected]" },
|
||||
receivedAt,
|
||||
"de"
|
||||
);
|
||||
expect(mail.bodyHtml).not.toContain("<img");
|
||||
expect(mail.bodyHtml).toContain("<img src=x onerror=alert(1)>");
|
||||
expect(mail.subject).not.toMatch(/[\r\n]/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { Locale } from "@/i18n/config";
|
||||
|
||||
/**
|
||||
* Elektronische Widerrufsfunktion (§ 356a BGB): Wortlaut der Erklaerung und
|
||||
* der Eingangsbestaetigung. Bewusst an EINER Stelle statt in email-strings.ts:
|
||||
* derselbe Wortlaut erscheint in der Mail an die Kundschaft, auf der
|
||||
* Bestaetigungsseite und im Admin — die Eingangsbestaetigung muss genau das
|
||||
* wiedergeben, was erklaert wurde (§ 356a Abs. 4 BGB).
|
||||
*/
|
||||
|
||||
export interface WithdrawalDeclarationInput {
|
||||
name: string;
|
||||
orderNumber: string;
|
||||
email: string;
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
export interface WithdrawalReceiptEmail {
|
||||
subject: string;
|
||||
preheader: string;
|
||||
/** Inhalt fuer den gemeinsamen Mail-Rahmen (emailLayout), bereits maskiert. */
|
||||
bodyHtml: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const TIME_ZONE = "Europe/Berlin";
|
||||
|
||||
const STRINGS = {
|
||||
de: {
|
||||
declaration: (orderNumber: string) => `Hiermit widerrufe ich den Vertrag über die Bestellung ${orderNumber}.`,
|
||||
name: "Name",
|
||||
email: "E-Mail",
|
||||
message: "Nachricht",
|
||||
subject: (orderNumber: string) => `Eingangsbestätigung deines Widerrufs zu ${orderNumber}`,
|
||||
heading: "Dein Widerruf ist bei uns eingegangen",
|
||||
intro: "Hiermit bestätigen wir den Eingang deiner Widerrufserklärung, die du über unsere Widerrufsfunktion abgegeben hast. Sie lautet:",
|
||||
receivedAt: "Eingegangen am",
|
||||
next: "Bitte sende uns die Ware innerhalb von 14 Tagen zurück, sofern du sie bereits erhalten hast. Sobald die Ware bei uns ist oder du uns die Rücksendung nachgewiesen hast, erstatten wir dir den Kaufpreis. Diese Bestätigung dokumentiert den Eingang deiner Erklärung.",
|
||||
},
|
||||
en: {
|
||||
declaration: (orderNumber: string) => `I hereby withdraw from the contract for order ${orderNumber}.`,
|
||||
name: "Name",
|
||||
email: "Email",
|
||||
message: "Message",
|
||||
subject: (orderNumber: string) => `Confirmation of receipt of your withdrawal for ${orderNumber}`,
|
||||
heading: "We have received your withdrawal",
|
||||
intro: "We hereby confirm receipt of the notice of withdrawal you submitted via our withdrawal function. It reads:",
|
||||
receivedAt: "Received on",
|
||||
next: "If you have already received the goods, please send them back to us within 14 days. We will refund the purchase price once the goods have arrived or you have shown proof of return. This confirmation documents the receipt of your notice.",
|
||||
},
|
||||
} as const;
|
||||
|
||||
function part(parts: Intl.DateTimeFormatPart[], type: Intl.DateTimeFormatPartTypes): string {
|
||||
return parts.find((p) => p.type === type)?.value ?? "";
|
||||
}
|
||||
|
||||
/** Zugangszeitpunkt in deutscher Ortszeit, sekundengenau und mit Zeitzone — er ist Beweismittel. */
|
||||
export function formatReceivedAt(date: Date, locale: Locale): string {
|
||||
const time = new Intl.DateTimeFormat("de-DE", {
|
||||
timeZone: TIME_ZONE,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hourCycle: "h23",
|
||||
}).format(date);
|
||||
if (locale === "de") {
|
||||
const day = new Intl.DateTimeFormat("de-DE", { timeZone: TIME_ZONE, day: "2-digit", month: "2-digit", year: "numeric" }).format(date);
|
||||
const zone = part(new Intl.DateTimeFormat("de-DE", { timeZone: TIME_ZONE, timeZoneName: "short" }).formatToParts(date), "timeZoneName");
|
||||
return `${day}, ${time} Uhr (${zone})`;
|
||||
}
|
||||
const day = new Intl.DateTimeFormat("en-GB", { timeZone: TIME_ZONE, day: "numeric", month: "long", year: "numeric" }).format(date);
|
||||
const zone = part(new Intl.DateTimeFormat("en-GB", { timeZone: TIME_ZONE, timeZoneName: "short" }).formatToParts(date), "timeZoneName");
|
||||
return `${day}, ${time} (${zone}, German time)`;
|
||||
}
|
||||
|
||||
export function withdrawalDeclarationLines(input: WithdrawalDeclarationInput, locale: Locale): string[] {
|
||||
const s = STRINGS[locale];
|
||||
return [
|
||||
s.declaration(input.orderNumber),
|
||||
`${s.name}: ${input.name}`,
|
||||
`${s.email}: ${input.email}`,
|
||||
...(input.message ? [`${s.message}: ${input.message}`] : []),
|
||||
];
|
||||
}
|
||||
|
||||
export function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/** Zeilenumbrueche im Betreff wuerden sonst als weitere Mail-Header gelesen (Header-Injection). */
|
||||
function singleLine(value: string): string {
|
||||
return value.replace(/[\r\n]+/g, " ");
|
||||
}
|
||||
|
||||
export function withdrawalReceiptEmailContent(
|
||||
input: WithdrawalDeclarationInput,
|
||||
receivedAt: Date,
|
||||
locale: Locale
|
||||
): WithdrawalReceiptEmail {
|
||||
const s = STRINGS[locale];
|
||||
const lines = withdrawalDeclarationLines(input, locale);
|
||||
const when = formatReceivedAt(receivedAt, locale);
|
||||
const bodyHtml = `
|
||||
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading}</h1>
|
||||
<p style="margin:0 0 16px;line-height:1.5;">${s.intro}</p>
|
||||
<div style="margin:0 0 16px;padding:16px;background-color:#F4EFE5;line-height:1.6;">
|
||||
${lines.map((line) => escapeHtml(line)).join("<br>")}
|
||||
</div>
|
||||
<p style="margin:0 0 16px;line-height:1.5;"><strong>${s.receivedAt}:</strong> ${escapeHtml(when)}</p>
|
||||
<p style="margin:0;line-height:1.5;">${s.next}</p>
|
||||
`;
|
||||
const text = [s.heading, "", s.intro, "", ...lines, "", `${s.receivedAt}: ${when}`, "", s.next].join("\n");
|
||||
return {
|
||||
subject: singleLine(s.subject(input.orderNumber)),
|
||||
preheader: escapeHtml(singleLine(`${s.receivedAt}: ${when}`)),
|
||||
bodyHtml,
|
||||
text,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user