From dcfad4e011bf8a3cfd6f8b7e3a8968b1c6b2f6f0 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Sat, 26 Sep 2026 11:14:02 +0200 Subject: [PATCH] feat(legal): electronic withdrawal function and remaining compliance fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../migration.sql | 27 +++ .../migration.sql | 9 + prisma/schema.prisma | 32 +++ src/app/admin/actions/content.ts | 15 +- src/app/admin/layout.tsx | 6 +- src/app/admin/widerrufe/actions.ts | 34 +++ src/app/admin/widerrufe/page.tsx | 83 +++++++ src/app/api/newsletter/confirm/route.ts | 4 +- src/app/api/widerruf/route.ts | 95 ++++++++ src/app/konto/actions/newsletter.ts | 5 +- src/app/konto/bestellungen/page.tsx | 11 + src/app/vertrag-widerrufen/page.tsx | 35 +++ src/app/widerrufsbelehrung/page.tsx | 7 +- src/components/admin-shell.tsx | 1 + src/components/cookie-consent.tsx | 9 +- src/components/site-footer.tsx | 8 +- src/components/withdrawal-form.tsx | 223 ++++++++++++++++++ src/i18n/dictionaries/de.ts | 61 ++++- src/i18n/dictionaries/en.ts | 61 ++++- src/i18n/dictionary-type.ts | 42 ++++ src/lib/company-info.ts | 9 +- src/lib/consent.ts | 39 +++ src/lib/email-templates.ts | 15 ++ src/lib/validation.ts | 11 + src/lib/withdrawal.test.ts | 72 ++++++ src/lib/withdrawal.ts | 124 ++++++++++ src/store/recently-viewed-store.ts | 45 +++- 27 files changed, 1049 insertions(+), 34 deletions(-) create mode 100644 prisma/migrations/20260926010000_withdrawal_function/migration.sql create mode 100644 prisma/migrations/20260926020000_withdrawal_processed/migration.sql create mode 100644 src/app/admin/widerrufe/actions.ts create mode 100644 src/app/admin/widerrufe/page.tsx create mode 100644 src/app/api/widerruf/route.ts create mode 100644 src/app/vertrag-widerrufen/page.tsx create mode 100644 src/components/withdrawal-form.tsx create mode 100644 src/lib/consent.ts create mode 100644 src/lib/withdrawal.test.ts create mode 100644 src/lib/withdrawal.ts diff --git a/prisma/migrations/20260926010000_withdrawal_function/migration.sql b/prisma/migrations/20260926010000_withdrawal_function/migration.sql new file mode 100644 index 0000000..ffde0e2 --- /dev/null +++ b/prisma/migrations/20260926010000_withdrawal_function/migration.sql @@ -0,0 +1,27 @@ +-- Elektronische Widerrufsfunktion (§ 356a BGB, Pflicht seit 19.06.2026) +-- und Nachweis der Newsletter-Einwilligung (Art. 7 Abs. 1 DSGVO). + +-- CreateTable +CREATE TABLE "withdrawal_declarations" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "orderNumber" TEXT NOT NULL, + "email" TEXT NOT NULL, + "message" TEXT, + "locale" TEXT NOT NULL DEFAULT 'de', + "orderId" TEXT, + "receivedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "confirmationSentAt" TIMESTAMP(3), + + CONSTRAINT "withdrawal_declarations_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "withdrawal_declarations_receivedAt_idx" ON "withdrawal_declarations"("receivedAt"); + +-- AddForeignKey +ALTER TABLE "withdrawal_declarations" ADD CONSTRAINT "withdrawal_declarations_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "orders"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AlterTable +-- Nullable: fuer bestehende Abonnements ist der Zeitpunkt nicht bekannt. +ALTER TABLE "newsletter_subscribers" ADD COLUMN "confirmedAt" TIMESTAMP(3); diff --git a/prisma/migrations/20260926020000_withdrawal_processed/migration.sql b/prisma/migrations/20260926020000_withdrawal_processed/migration.sql new file mode 100644 index 0000000..ca971b5 --- /dev/null +++ b/prisma/migrations/20260926020000_withdrawal_processed/migration.sql @@ -0,0 +1,9 @@ +-- Bearbeitungsstand der Widerrufserklaerungen: null = offen. Die Zahl offener +-- Widerrufe steht im Admin-Menue, damit die 14-Tage-Erstattungsfrist +-- (§ 357 Abs. 1 BGB) nicht uebersehen wird. + +-- AlterTable +ALTER TABLE "withdrawal_declarations" ADD COLUMN "processedAt" TIMESTAMP(3); + +-- CreateIndex +CREATE INDEX "withdrawal_declarations_processedAt_idx" ON "withdrawal_declarations"("processedAt"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index afc2f23..00265fa 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -439,6 +439,7 @@ model Order { pickup Pickup? invoice Invoice? returnRequest ReturnRequest? + withdrawalDeclarations WithdrawalDeclaration[] /// Bedient sowohl "Meine Bestellungen" (Filter nach customerId, sortiert nach /// createdAt) als auch den reinen customerId-Filter beim Datenexport. @@ -649,6 +650,9 @@ model NewsletterSubscriber { /// Abmelde-Link fuer Abonnenten ohne Kundenkonto. confirmationToken String? @unique subscribedAt DateTime @default(now()) + /// Zeitpunkt des Klicks auf den Double-Opt-in-Link — Nachweis der + /// Einwilligung (Art. 7 Abs. 1 DSGVO). Null bei Altbestand vor Einfuehrung. + confirmedAt DateTime? unsubscribedAt DateTime? customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull) @@ -805,3 +809,31 @@ model BusinessInquiry { @@map("business_inquiries") } + +/// Widerrufserklaerung ueber die elektronische Widerrufsfunktion +/// (§ 356a BGB, Seite /vertrag-widerrufen). Wird IMMER gespeichert, auch wenn +/// Bestellnummer und E-Mail zu keiner Bestellung passen: der Widerruf ist mit +/// dem Zugang erklaert, die Zuordnung ist unsere Sache. orderId ist nur die +/// automatische Zuordnung, wenn beides passt. +model WithdrawalDeclaration { + id String @id @default(cuid()) + name String + /// Wortlaut wie eingegeben, nicht normalisiert — Beleg dessen, was erklaert wurde. + orderNumber String + email String + message String? + locale String @default("de") + orderId String? + /// Zugangszeitpunkt, steht so in der Eingangsbestaetigung (§ 356a Abs. 4 BGB). + receivedAt DateTime @default(now()) + /// Gesetzt, sobald der Mailserver die Eingangsbestaetigung angenommen hat. + confirmationSentAt DateTime? + /// Im Admin als erledigt markiert (Ware zurueck, Erstattung veranlasst). + processedAt DateTime? + + order Order? @relation(fields: [orderId], references: [id], onDelete: SetNull) + + @@index([receivedAt]) + @@index([processedAt]) + @@map("withdrawal_declarations") +} diff --git a/src/app/admin/actions/content.ts b/src/app/admin/actions/content.ts index a44ed6e..33f36a7 100644 --- a/src/app/admin/actions/content.ts +++ b/src/app/admin/actions/content.ts @@ -6,7 +6,10 @@ import { logAdminAction } from "@/lib/audit-log"; import { CONTENT_REGISTRY } from "@/lib/content-registry"; import { isValidUploadPath, isValidVideoUploadPath } from "@/lib/uploads"; import { requireAdmin } from "./_shared"; -import { succeed } from "@/lib/flash"; +import { fail, succeed } from "@/lib/flash"; +import { z } from "zod"; + +const COMPANY_EMAIL_KEY = "company.email"; /** * Generisch fuer alle drei Inhalts-Gruppen (Firmendaten/Homepage/Rechtstexte) @@ -48,7 +51,15 @@ export async function updateSiteContentAction(formData: FormData): Promise if (def.type === "video" && valueDe !== "" && !isValidVideoUploadPath(valueDe)) { continue; } - updates.push({ key: def.key, valueDe, valueEn: null }); + // Die Firmen-E-Mail steht im Impressum (§ 5 Abs. 1 Nr. 2 DDG verlangt + // eine direkt nutzbare Adresse), in der Widerrufsbelehrung und in + // mailto-Links. Eine verschleierte Schreibweise ("info [at] …") waere + // dort rechtlich angreifbar und technisch unbrauchbar — daher wird der + // ganze Speichervorgang abgelehnt statt still uebernommen. + if (def.key === COMPANY_EMAIL_KEY && !z.string().trim().email().safeParse(valueDe).success) { + return fail("/admin/inhalte", "Bitte gib als Firmen-E-Mail eine vollständige, gültige Adresse an (z. B. info@wabenhain.com). Sie steht im Impressum und muss direkt nutzbar sein."); + } + updates.push({ key: def.key, valueDe: def.key === COMPANY_EMAIL_KEY ? valueDe.trim() : valueDe, valueEn: null }); } } diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 633751e..e9b27d4 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -49,14 +49,15 @@ export default async function AdminLayout({ children }: LayoutProps<"/admin">) { // Was gerade auf Bearbeitung wartet — erscheint als Zaehler in der // Seitenleiste. Nur echte Arbeit: bezahlte, noch nicht versendete oder // abgeholte Bestellungen (keine offenen Zahlungen), neue Retouren, - // unfreigegebene Bewertungen, neue Firmenanfragen. Reine COUNT-Abfragen. + // offene Widerrufe, unfreigegebene Bewertungen, neue Firmenanfragen. Reine COUNT-Abfragen. const brand = await getBrandAssets(); const themeChoice = parseThemeChoice((await cookies()).get(THEME_COOKIE)?.value); - const [openOrders, openReturns, pendingReviews, newInquiries] = await Promise.all([ + const [openOrders, openReturns, openWithdrawals, pendingReviews, newInquiries] = await Promise.all([ prisma.order.count({ where: { status: { in: ["BEZAHLT", "IN_VORBEREITUNG", "VERSANDBEREIT", "ABHOLBEREIT"] }, archivedAt: null }, }), prisma.returnRequest.count({ where: { status: "ANGEFRAGT" } }), + prisma.withdrawalDeclaration.count({ where: { processedAt: null } }), prisma.review.count({ where: { isApproved: false } }), prisma.businessInquiry.count({ where: { status: "NEU" } }), ]); @@ -69,6 +70,7 @@ export default async function AdminLayout({ children }: LayoutProps<"/admin">) { badges={{ "/admin/bestellungen": openOrders, "/admin/retouren": openReturns, + "/admin/widerrufe": openWithdrawals, "/admin/bewertungen": pendingReviews, "/admin/anfragen": newInquiries, }} diff --git a/src/app/admin/widerrufe/actions.ts b/src/app/admin/widerrufe/actions.ts new file mode 100644 index 0000000..034e8c4 --- /dev/null +++ b/src/app/admin/widerrufe/actions.ts @@ -0,0 +1,34 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { fail, succeed } from "@/lib/flash"; +import { prisma } from "@/lib/prisma"; +import { requireAdmin } from "@/app/admin/actions"; +import { logAdminAction } from "@/lib/audit-log"; + +const PATH = "/admin/widerrufe"; + +/** Markiert eine Widerrufserklaerung als erledigt bzw. oeffnet sie wieder. Die Erklaerung selbst bleibt unveraendert — sie ist Beleg. */ +export async function setWithdrawalProcessedAction(formData: FormData): Promise { + const admin = await requireAdmin(); + const id = String(formData.get("id") ?? "").trim(); + const processed = formData.get("processed") === "1"; + + const declaration = await prisma.withdrawalDeclaration.findUnique({ where: { id }, select: { orderNumber: true } }); + if (!declaration) return fail(PATH, "Dieser Widerruf existiert nicht mehr."); + + await prisma.withdrawalDeclaration.update({ + where: { id }, + data: { processedAt: processed ? new Date() : null }, + }); + await logAdminAction(admin, { + action: processed ? "withdrawal.processed" : "withdrawal.reopened", + entityType: "WithdrawalDeclaration", + entityId: id, + detail: declaration.orderNumber, + }); + + revalidatePath(PATH); + revalidatePath("/admin", "layout"); + return succeed(PATH, `Widerruf zu ${declaration.orderNumber} ${processed ? "als erledigt markiert" : "wieder geöffnet"}.`); +} diff --git a/src/app/admin/widerrufe/page.tsx b/src/app/admin/widerrufe/page.tsx new file mode 100644 index 0000000..57eb118 --- /dev/null +++ b/src/app/admin/widerrufe/page.tsx @@ -0,0 +1,83 @@ +import Link from "next/link"; +import { prisma } from "@/lib/prisma"; +import { BackLink } from "@/components/back-link"; +import { Button } from "@/components/ui"; +import { AdminBadge, AdminEmptyState, AdminPageHeader } from "@/components/admin-list"; +import { formatDate } from "@/lib/format"; +import { formatReceivedAt } from "@/lib/withdrawal"; +import { setWithdrawalProcessedAction } from "./actions"; +import { getLocale } from "@/i18n/locale"; +import { getDictionary } from "@/i18n/get-dictionary"; + +const MAX_ROWS = 200; + +export default async function AdminWiderrufePage() { + const locale = await getLocale(); + const t = getDictionary(locale); + const s = t.adminWithdrawalsPage; + + // Offene zuerst (aelteste offene oben: deren Frist laeuft zuerst ab), dann erledigte. + const [open, done] = await Promise.all([ + prisma.withdrawalDeclaration.findMany({ where: { processedAt: null }, orderBy: { receivedAt: "asc" }, take: MAX_ROWS }), + prisma.withdrawalDeclaration.findMany({ where: { processedAt: { not: null } }, orderBy: { receivedAt: "desc" }, take: MAX_ROWS }), + ]); + const declarations = [...open, ...done]; + + return ( +
+ + +

{s.intro}

+ + {declarations.length === 0 ? : null} + +
+ {declarations.map((d) => ( +
+
+
+

{d.orderNumber}

+

+ {d.name} · {d.email} +

+

+ {s.receivedAt}: {formatReceivedAt(d.receivedAt, locale)} +

+
+ + {d.processedAt ? s.statusProcessed : s.statusOpen} + +
+ + {d.message ?

{d.message}

: null} + +
    +
  • + {d.orderId ? s.matched : s.unmatched} +
  • +
  • + {d.confirmationSentAt ? s.mailSent : s.mailNotSent} +
  • + {d.processedAt ?
  • {s.processedOn(formatDate(d.processedAt))}
  • : null} +
+ +
+
+ + + +
+ {d.orderId ? ( + + {s.viewOrder} + + ) : null} +
+
+ ))} +
+
+ ); +} diff --git a/src/app/api/newsletter/confirm/route.ts b/src/app/api/newsletter/confirm/route.ts index 80eea39..13c785e 100644 --- a/src/app/api/newsletter/confirm/route.ts +++ b/src/app/api/newsletter/confirm/route.ts @@ -16,7 +16,9 @@ export async function GET(request: Request) { await prisma.newsletterSubscriber.update({ where: { id: subscriber.id }, - data: { confirmed: true, unsubscribedAt: null }, + // confirmedAt belegt die Einwilligung (Art. 7 Abs. 1 DSGVO). Jede erneute + // Bestaetigung ueberschreibt ihn, weil erst sie die aktuelle Einwilligung ist. + data: { confirmed: true, confirmedAt: new Date(), unsubscribedAt: null }, }); await ensureNewsletterDiscountCode(); diff --git a/src/app/api/widerruf/route.ts b/src/app/api/widerruf/route.ts new file mode 100644 index 0000000..9014111 --- /dev/null +++ b/src/app/api/widerruf/route.ts @@ -0,0 +1,95 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { withdrawalSchema } from "@/lib/validation"; +import { rateLimit, getClientIp } from "@/lib/rate-limit"; +import { isTrustedOrigin } from "@/lib/csrf"; +import { sendMail } from "@/lib/mail"; +import { withdrawalReceiptEmail } from "@/lib/email-templates"; +import { escapeHtml, formatReceivedAt, withdrawalDeclarationLines } from "@/lib/withdrawal"; +import { getLocale } from "@/i18n/locale"; + +const WITHDRAWALS_PER_WINDOW = 10; +const WINDOW_MS = 60 * 60 * 1000; + +/** + * Elektronische Widerrufsfunktion (§ 356a BGB), zweite Stufe "Widerruf + * bestaetigen". Bewusst OHNE Turnstile: eine fehlschlagende Pruefung duerfte + * die Ausuebung eines gesetzlichen Rechts nicht verhindern. Missbrauch + * begrenzen Herkunftspruefung und Rate-Limit. + * + * Die Antwort ist fuer passende und unpassende Bestellnummern identisch — + * sonst liessen sich Bestellnummern samt E-Mail-Adresse durchprobieren. Die + * Erklaerung wird in jedem Fall gespeichert: sie ist mit dem Zugang wirksam + * abgegeben, die Zuordnung zur Bestellung ist unsere Aufgabe (Admin). + */ +export async function POST(request: Request) { + if (!isTrustedOrigin(request)) { + return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 403 }); + } + + const ip = await getClientIp(); + if (!rateLimit(`withdrawal:${ip}`, WITHDRAWALS_PER_WINDOW, WINDOW_MS)) { + return NextResponse.json( + { error: "Zu viele Anfragen. Bitte versuche es später erneut oder schreib uns eine E-Mail." }, + { status: 429 } + ); + } + + const body: unknown = await request.json().catch(() => null); + const parsed = withdrawalSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Bitte überprüfe deine Angaben.", issues: parsed.error.flatten() }, + { status: 400 } + ); + } + + const input = parsed.data; + const locale = await getLocale(); + const order = await prisma.order.findUnique({ + where: { orderNumber: input.orderNumber }, + select: { id: true, email: true }, + }); + const orderId = order && order.email.trim().toLowerCase() === input.email ? order.id : null; + + const declaration = await prisma.withdrawalDeclaration.create({ + data: { ...input, locale, orderId }, + }); + + const receipt = await withdrawalReceiptEmail(input, declaration.receivedAt, locale); + const confirmationSent = await sendMail({ to: input.email, ...receipt }); + if (confirmationSent) { + await prisma.withdrawalDeclaration.update({ + where: { id: declaration.id }, + data: { confirmationSentAt: new Date() }, + }); + } + + // Interne Benachrichtigung: ein Widerruf hat eine 14-Tage-Frist fuer die + // Erstattung und darf nicht bis zum naechsten Blick ins Admin liegen bleiben. + const adminEmail = process.env.ADMIN_EMAIL; + if (adminEmail) { + const noticeLines = [ + ...withdrawalDeclarationLines(input, "de"), + "", + `Eingegangen am: ${formatReceivedAt(declaration.receivedAt, "de")}`, + orderId ? "Automatisch der Bestellung zugeordnet." : "Keine passende Bestellung gefunden — bitte manuell prüfen.", + `Eingangsbestätigung an Kundschaft: ${confirmationSent ? "versendet" : "NICHT versendet"}`, + ]; + await sendMail({ + to: adminEmail, + subject: `Neuer Widerruf zu ${input.orderNumber.replace(/[\r\n]+/g, " ")}`, + text: noticeLines.join("\n"), + html: `

${noticeLines.map(escapeHtml).join("
")}

`, + }); + } + + return NextResponse.json( + { + receivedAt: formatReceivedAt(declaration.receivedAt, locale), + lines: withdrawalDeclarationLines(input, locale), + confirmationSent, + }, + { headers: { "Cache-Control": "no-store" } } + ); +} diff --git a/src/app/konto/actions/newsletter.ts b/src/app/konto/actions/newsletter.ts index 5f597d3..e4083c5 100644 --- a/src/app/konto/actions/newsletter.ts +++ b/src/app/konto/actions/newsletter.ts @@ -20,11 +20,12 @@ export async function toggleNewsletterAction(): Promise { } else if (existing) { await prisma.newsletterSubscriber.update({ where: { id: existing.id }, - data: { unsubscribedAt: null, confirmed: true }, + // Der Klick im eigenen Konto ist die Einwilligung — Zeitpunkt als Nachweis. + data: { unsubscribedAt: null, confirmed: true, confirmedAt: new Date() }, }); } else { await prisma.newsletterSubscriber.create({ - data: { email: customer.email, customerId: customer.id, confirmed: true }, + data: { email: customer.email, customerId: customer.id, confirmed: true, confirmedAt: new Date() }, }); } diff --git a/src/app/konto/bestellungen/page.tsx b/src/app/konto/bestellungen/page.tsx index 40d3935..9c0b3b2 100644 --- a/src/app/konto/bestellungen/page.tsx +++ b/src/app/konto/bestellungen/page.tsx @@ -154,6 +154,17 @@ export default async function KontoBestellungenPage() { {t.ordersPage.requestReturn} ) : null} + {RETURN_ELIGIBLE_STATUSES.includes(order.status) ? ( + // Elektronische Widerrufsfunktion (§ 356a BGB) mit + // vorausgefuellter Bestellnummer — die Rueckgabe oben ist + // ein Service, der Widerruf das gesetzliche Recht. + + {t.withdrawalPage.footerButton} + + ) : null} ({ productId: item.productId, diff --git a/src/app/vertrag-widerrufen/page.tsx b/src/app/vertrag-widerrufen/page.tsx new file mode 100644 index 0000000..08c8293 --- /dev/null +++ b/src/app/vertrag-widerrufen/page.tsx @@ -0,0 +1,35 @@ +import type { Metadata } from "next"; +import { Container, SectionHeading, FormPanel } from "@/components/ui"; +import { WithdrawalForm } from "@/components/withdrawal-form"; +import { getLocale } from "@/i18n/locale"; +import { getDictionary } from "@/i18n/get-dictionary"; + +export const metadata: Metadata = { title: "Vertrag widerrufen", alternates: { canonical: "/vertrag-widerrufen" } }; + +/** Nur Zeichen, die in Bestellnummern vorkommen — alles andere wird nicht vorausgefuellt. */ +const ORDER_NUMBER_PREFILL = /^[A-Za-z0-9-]{1,40}$/; + +/** + * Elektronische Widerrufsfunktion (§ 356a BGB). Ohne Anmeldung erreichbar — + * auch Gastbestellungen muessen hier widerrufen werden koennen. Aus dem + * Kundenkonto kommt man mit ?bestellung=… und vorausgefuellter Nummer. + */ +export default async function VertragWiderrufenPage(props: PageProps<"/vertrag-widerrufen">) { + const t = getDictionary(await getLocale()); + const { bestellung } = await props.searchParams; + const prefill = typeof bestellung === "string" && ORDER_NUMBER_PREFILL.test(bestellung) ? bestellung : ""; + + return ( +
+ + + +

{t.withdrawalPage.intro}

+
+ +
+
+
+
+ ); +} diff --git a/src/app/widerrufsbelehrung/page.tsx b/src/app/widerrufsbelehrung/page.tsx index 0d5be6c..6ee1bd5 100644 --- a/src/app/widerrufsbelehrung/page.tsx +++ b/src/app/widerrufsbelehrung/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { LegalPage } from "@/components/legal-page"; import { getContentValue, parseSections } from "@/lib/content"; import { getCompanyInfo } from "@/lib/company-info"; +import { getSiteUrl } from "@/lib/url"; import { getLocale } from "@/i18n/locale"; import { getDictionary } from "@/i18n/get-dictionary"; @@ -14,12 +15,16 @@ export const metadata: Metadata = { title: "Widerrufsbelehrung", alternates: { c * einem Umzug oder neuer Adresse automatisch richtig. */ const PROVIDER_PLACEHOLDER = "{{anbieter}}"; +/** Adresse der elektronischen Widerrufsfunktion (§ 356a BGB, app/vertrag-widerrufen). */ +const WITHDRAWAL_FUNCTION_PLACEHOLDER = "{{widerrufsfunktion}}"; export default async function WiderrufPage() { const locale = await getLocale(); const t = getDictionary(locale); const [company, raw] = await Promise.all([getCompanyInfo(), getContentValue("legal.widerruf", locale)]); const provider = `${company.name}, ${company.address.street}, ${company.address.zip} ${company.address.city}, E-Mail: ${company.email}`; - const sections = parseSections(raw.replaceAll(PROVIDER_PLACEHOLDER, provider)); + const sections = parseSections( + raw.replaceAll(PROVIDER_PLACEHOLDER, provider).replaceAll(WITHDRAWAL_FUNCTION_PLACEHOLDER, `${getSiteUrl()}/vertrag-widerrufen`) + ); return ; } diff --git a/src/components/admin-shell.tsx b/src/components/admin-shell.tsx index e30f529..1e1e6e1 100644 --- a/src/components/admin-shell.tsx +++ b/src/components/admin-shell.tsx @@ -33,6 +33,7 @@ function getAdminNavGroups(t: Dictionary): { label: string | null; items: NavIte items: [ { href: "/admin/bestellungen", label: t.adminShell.navOrders }, { href: "/admin/retouren", label: t.adminShell.navReturns }, + { href: "/admin/widerrufe", label: t.adminShell.navWithdrawals }, { href: "/admin/gutscheine", label: t.adminShell.navVouchers }, { href: "/admin/versand", label: t.adminShell.navShipping }, ], diff --git a/src/components/cookie-consent.tsx b/src/components/cookie-consent.tsx index 27ac389..e65c272 100644 --- a/src/components/cookie-consent.tsx +++ b/src/components/cookie-consent.tsx @@ -6,10 +6,9 @@ import { HoneycombPattern } from "@/components/honeycomb"; import { AnimatePresence, motion } from "motion/react"; import { Button } from "@/components/ui"; import { useI18n } from "@/i18n/i18n-provider"; +import { COOKIE_CONSENT_KEY, clearOptionalStorage, type Consent } from "@/lib/consent"; -export const COOKIE_CONSENT_KEY = "wabenhain-cookie-consent"; - -type Consent = "all" | "essential"; +export { COOKIE_CONSENT_KEY }; export function CookieConsentBanner() { const { t } = useI18n(); @@ -25,6 +24,7 @@ export function CookieConsentBanner() { function choose(consent: Consent) { window.localStorage.setItem(COOKIE_CONSENT_KEY, consent); + if (consent === "essential") clearOptionalStorage(); setVisible(false); } @@ -76,7 +76,10 @@ export function CookieSettingsButton() { + + + + ); + } + + return ( +
+ + + +