+ );
+}
+
+function Field({
+ name,
+ label,
+ hint,
+ type = "text",
+ ...inputProps
+}: {
+ name: string;
+ label: string;
+ hint?: string;
+ type?: string;
+ autoComplete?: string;
+ defaultValue?: string;
+ maxLength?: number;
+ required?: boolean;
+}) {
+ const hintId = hint ? `${name}-hint` : undefined;
+ return (
+
+ );
+}
diff --git a/src/i18n/dictionaries/de.ts b/src/i18n/dictionaries/de.ts
index 7ac0e51..692f27b 100644
--- a/src/i18n/dictionaries/de.ts
+++ b/src/i18n/dictionaries/de.ts
@@ -173,6 +173,30 @@ export const de: Dictionary = {
tocLabel: "Inhalt",
revisionLabel: "Stand",
},
+ withdrawalPage: {
+ title: "Vertrag widerrufen",
+ intro: "Hier kannst du deinen Kaufvertrag innerhalb der 14-tägigen Widerrufsfrist online widerrufen. Du brauchst dafür kein Kundenkonto und musst keinen Grund angeben.",
+ nameLabel: "Dein Name",
+ orderNumberLabel: "Bestellnummer",
+ orderNumberHint: "Steht in deiner Bestellbestätigung, z. B. WH-2026-0042.",
+ emailLabel: "E-Mail-Adresse",
+ emailHint: "An diese Adresse schicken wir die Eingangsbestätigung.",
+ messageLabel: "Nachricht (freiwillig)",
+ requiredNote: "Pflichtangaben sind nur Name, Bestellnummer und E-Mail-Adresse.",
+ startButton: "Vertrag widerrufen",
+ reviewHeading: "Bitte prüfe deinen Widerruf",
+ reviewIntro: "Mit dem Klick auf „Widerruf bestätigen“ senden wir diese Erklärung ab:",
+ confirmButton: "Widerruf bestätigen",
+ editButton: "Angaben ändern",
+ submitting: "Wird gesendet …",
+ successHeading: "Dein Widerruf ist eingegangen",
+ successMailSent: "Wir haben dir soeben eine Eingangsbestätigung mit dem Inhalt deiner Erklärung sowie Datum und Uhrzeit des Eingangs per E-Mail geschickt.",
+ successMailFailed: "Dein Widerruf ist wirksam bei uns eingegangen. Die Eingangsbestätigung per E-Mail konnte gerade nicht versendet werden; wir schicken sie dir schnellstmöglich nach. Bitte speichere diese Seite als Nachweis.",
+ receivedAtLabel: "Eingegangen am",
+ errorMessage: "Dein Widerruf konnte nicht gesendet werden. Bitte versuche es erneut oder schreib uns eine E-Mail — auch ein Widerruf per E-Mail oder Brief ist wirksam.",
+ policyLink: "Zur Widerrufsbelehrung",
+ footerButton: "Vertrag widerrufen",
+ },
notFoundPage: {
title: "Diese Seite gibt es nicht, zumindest nicht mehr.",
text: "Der Link ist vielleicht veraltet, oder die Seite wurde verschoben. Schau dich im Shop um oder geh zurück zur Startseite.",
@@ -188,7 +212,7 @@ export const de: Dictionary = {
cookieConsent: {
dialogLabel: "Cookie-Einstellungen",
bannerTextBeforeLink:
- "Wir verwenden nur technisch notwendige Cookies (z.B. für den Login im Kundenkonto). Mit deiner Zustimmung nutzen wir zusätzlich Cookies zur Reichweitenmessung. Details findest du in unserer ",
+ "Wir verwenden technisch notwendige Cookies (z. B. für den Login im Kundenkonto). Mit deiner Zustimmung merken wir uns außerdem in deinem Browser, welche Produkte du zuletzt angesehen hast. Tracking oder Werbung gibt es bei uns nicht. Details findest du in unserer ",
policyLinkLabel: "Cookie-Richtlinie",
bannerTextAfterLink: ".",
essentialOnly: "Nur notwendige",
@@ -269,7 +293,7 @@ export const de: Dictionary = {
{
heading: "3. Server-Logfiles beim Besuch der Website",
paragraphs: [
- "Beim Aufruf unserer Website erheben wir auf unserem Server automatisch technische Zugriffsdaten (z.B. IP-Adresse, Datum und Uhrzeit des Zugriffs, aufgerufene Seite, Browsertyp, Referrer-URL) in sogenannten Server-Logfiles. Diese Verarbeitung ist zur Bereitstellung der Website technisch erforderlich (Art. 6 Abs. 1 lit. f DSGVO) Die Logfiles werden nicht mit anderen Daten zusammengeführt und automatisch gelöscht, sobald sie für die Sicherheit und Fehleranalyse nicht mehr benötigt werden.",
+ "Beim Aufruf unserer Website erheben wir auf unserem Server automatisch technische Zugriffsdaten (z.B. IP-Adresse, Datum und Uhrzeit des Zugriffs, aufgerufene Seite, Browsertyp, Referrer-URL) in sogenannten Server-Logfiles. Diese Verarbeitung ist zur Bereitstellung der Website technisch erforderlich (Art. 6 Abs. 1 lit. f DSGVO) Die Logfiles werden nicht mit anderen Daten zusammengeführt und automatisch gelöscht, sobald sie für die Sicherheit und Fehleranalyse nicht mehr benötigt werden, spätestens nach 14 Tagen.",
],
},
{
@@ -280,6 +304,8 @@ export const de: Dictionary = {
"Zahlungen werden über unseren Zahlungsdienstleister Stripe Payments Europe, Ltd. abgewickelt. Dabei werden die für die Zahlungsabwicklung notwendigen Daten an Stripe übermittelt. Es gelten zusätzlich die Datenschutzhinweise von Stripe (stripe.com/de/privacy). Soweit dabei Daten an mit Stripe verbundene Unternehmen außerhalb der EU/des EWR (insbesondere Stripe, Inc. in den USA) übermittelt werden, erfolgt dies auf Grundlage von EU-Standardvertragsklauseln gemäß Art. 46 DSGVO.",
"Wählst du im Bezahlschritt PayPal oder Klarna, wirst du zum jeweiligen Anbieter weitergeleitet, und die für die Zahlung erforderlichen Bestelldaten werden an ihn übermittelt: PayPal (Europe) S.à r.l. et Cie, S.C.A., 22–24 Boulevard Royal, L-2449 Luxemburg, bzw. Klarna Bank AB (publ), Sveavägen 46, 111 34 Stockholm, Schweden. Diese Anbieter können eigene Prüfungen durchführen, etwa zur Identität oder Zahlungsfähigkeit; dafür sind sie selbst verantwortlich, und es gelten ihre Datenschutzhinweise. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO.",
"Bestell- und Rechnungsdaten werden aufgrund handels- und steuerrechtlicher Aufbewahrungspflichten (§ 257 HGB, § 147 AO) für die gesetzlich vorgeschriebene Dauer gespeichert.",
+ "Bestellbestätigungen und andere E-Mails zu deinem Vertrag oder Kundenkonto versenden wir über Microsoft 365 (Microsoft Ireland Operations Limited, One Microsoft Place, South County Business Park, Leopardstown, Dublin 18, Irland). Microsoft verarbeitet die Daten als unser Auftragsverarbeiter auf Grundlage eines Vertrags nach Art. 28 DSGVO. Eine Übermittlung an die Microsoft Corporation in die USA kann stattfinden; Microsoft ist unter dem EU-US Data Privacy Framework zertifiziert. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO.",
+ "Widerrufst du einen Vertrag über unsere Online-Widerrufsfunktion, speichern wir Name, Bestellnummer, E-Mail-Adresse, deine freiwillige Nachricht und den Zeitpunkt des Eingangs, senden dir eine Eingangsbestätigung und ordnen den Widerruf deiner Bestellung zu. Rechtsgrundlage ist Art. 6 Abs. 1 lit. c DSGVO in Verbindung mit § 356a BGB. Die Erklärung bewahren wir als Nachweis auf, bis die Verjährungsfristen für Ansprüche aus dem Vertrag abgelaufen sind.",
],
},
{
@@ -294,7 +320,7 @@ export const de: Dictionary = {
heading: "6. Newsletter (Erntepost)",
paragraphs: [
"Meldest du dich zu unserer Erntepost an, verwenden wir deine E-Mail-Adresse ausschließlich zum Versand von Informationen über neue Ernten. Rechtsgrundlage ist deine Einwilligung (Art. 6 Abs. 1 lit. a DSGVO). Eine Abmeldung ist jederzeit über den Link in jeder E-Mail oder im Kundenkonto möglich.",
- "Die Anmeldung erfolgt im Double-Opt-in-Verfahren: Eingetragen wirst du erst, nachdem du den Link in unserer Bestätigungs-E-Mail angeklickt hast. Mit der Abmeldung widerrufst du deine Einwilligung mit Wirkung für die Zukunft (Art. 7 Abs. 3 DSGVO). Deine Adresse bewahren wir danach mit dem Abmeldezeitpunkt auf, um die Einwilligung und ihren Widerruf nachweisen zu können und dir keine weitere Erntepost zu schicken (Art. 6 Abs. 1 lit. f DSGVO).",
+ "Die Anmeldung erfolgt im Double-Opt-in-Verfahren: Eingetragen wirst du erst, nachdem du den Link in unserer Bestätigungs-E-Mail angeklickt hast; den Zeitpunkt dieser Bestätigung speichern wir als Nachweis deiner Einwilligung. Meldest du dich in deinem Kundenkonto an, gilt der Klick dort als Einwilligung, und wir speichern ebenfalls dessen Zeitpunkt. Mit der Abmeldung widerrufst du deine Einwilligung mit Wirkung für die Zukunft (Art. 7 Abs. 3 DSGVO). Deine Adresse bewahren wir danach mit dem Abmeldezeitpunkt auf, um die Einwilligung und ihren Widerruf nachweisen zu können und dir keine weitere Erntepost zu schicken (Art. 6 Abs. 1 lit. f DSGVO).",
],
},
{
@@ -435,6 +461,7 @@ export const de: Dictionary = {
"Du hast das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.",
"Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag, an dem du oder ein von dir benannter Dritter, der nicht der Beförderer ist, die Waren in Besitz genommen hast bzw. hat.",
"Um dein Widerrufsrecht auszuüben, musst du uns ({{anbieter}}) mittels einer eindeutigen Erklärung (z. B. ein mit der Post versandter Brief oder eine E-Mail) über deinen Entschluss, diesen Vertrag zu widerrufen, informieren. Du kannst dafür das unten stehende Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist.",
+ "Du kannst dein Widerrufsrecht auch online unter {{widerrufsfunktion}} ausüben. Wenn du diese Online-Funktion nutzt, übermitteln wir dir auf einem dauerhaften Datenträger (z. B. per E-Mail) unverzüglich eine Eingangsbestätigung.",
"Zur Wahrung der Widerrufsfrist reicht es aus, dass du die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absendest.",
],
},
@@ -516,13 +543,13 @@ export const de: Dictionary = {
heading: "Rechtsgrundlagen",
paragraphs: [
"Speichern wir Informationen auf deinem Endgerät oder lesen sie aus, ist dafür grundsätzlich deine Einwilligung erforderlich (§ 25 Abs. 1 TDDDG). Keine Einwilligung braucht es, wenn die Speicherung unbedingt erforderlich ist, damit wir einen von dir ausdrücklich gewünschten Dienst bereitstellen können (§ 25 Abs. 2 Nr. 2 TDDDG), etwa die Anmeldung im Kundenkonto oder den Bestellabschluss.",
- "Soweit dabei personenbezogene Daten verarbeitet werden, stützen wir das auf Art. 6 Abs. 1 lit. b DSGVO (Vertrag und vorvertragliche Maßnahmen, z. B. Kundenkonto und Bestellung) oder Art. 6 Abs. 1 lit. f DSGVO (unser berechtigtes Interesse an einer sicheren, funktionsfähigen Website). Setzen wir künftig einwilligungspflichtige Technologien ein, ist Rechtsgrundlage deine Einwilligung nach § 25 Abs. 1 TDDDG in Verbindung mit Art. 6 Abs. 1 lit. a DSGVO.",
+ "Soweit dabei personenbezogene Daten verarbeitet werden, stützen wir das auf Art. 6 Abs. 1 lit. b DSGVO (Vertrag und vorvertragliche Maßnahmen, z. B. Kundenkonto und Bestellung) oder Art. 6 Abs. 1 lit. f DSGVO (unser berechtigtes Interesse an einer sicheren, funktionsfähigen Website). Für einwilligungspflichtige Speicherungen (siehe „Cookies mit Einwilligung“) ist Rechtsgrundlage deine Einwilligung nach § 25 Abs. 1 TDDDG in Verbindung mit Art. 6 Abs. 1 lit. a DSGVO.",
],
},
{
heading: "Überblick",
paragraphs: [
- "Wir verwenden ausschließlich technisch notwendige Cookies und Speichereinträge. Wir setzen keine Analyse-, Statistik-, Marketing- oder Tracking-Cookies ein, erstellen keine Nutzungsprofile und geben keine Daten zu Werbezwecken an Dritte weiter.",
+ "Wir verwenden technisch notwendige Cookies und Speichereinträge und — nur mit deiner Einwilligung — einen einzigen weiteren Speichereintrag für die Liste zuletzt angesehener Produkte. Wir setzen keine Analyse-, Statistik-, Marketing- oder Tracking-Cookies ein, erstellen keine Nutzungsprofile und geben keine Daten zu Werbezwecken an Dritte weiter.",
"Die meisten der unten genannten Cookies entstehen erst, wenn du eine bestimmte Funktion nutzt (z. B. Anmeldung, Bezahlung, Sprachwechsel). Beim bloßen Aufrufen der Website wird kein Cookie gesetzt.",
],
},
@@ -552,7 +579,6 @@ export const de: Dictionary = {
paragraphs: [
"Die folgenden Einträge liegen im lokalen Speicher deines Browsers. Anders als Cookies werden sie nicht bei jedem Seitenaufruf automatisch an uns gesendet und laufen nicht von selbst ab: Sie bleiben, bis du sie löschst (über die Browser-Einstellungen „Websitedaten löschen“) oder die Website sie entfernt. Rechtsgrundlage: § 25 Abs. 2 Nr. 2 TDDDG und Art. 6 Abs. 1 lit. b bzw. lit. f DSGVO.",
"wabenhain-cart – Dein Warenkorb (Artikel, Mengen, Preise). An uns übermittelt wird er erst, wenn du eine Bestellung abschickst. Nach erfolgreicher Bestellung wird er geleert. Speicherdauer: bis zur nächsten Bestellung oder bis du ihn selbst leerst.",
- "wabenhain-recently-viewed – Die Kennungen der bis zu 6 zuletzt angesehenen Produkte, um sie dir auf Produktseiten erneut anzuzeigen. Zum Anzeigen werden nur diese Produktkennungen abgefragt; wir speichern keinen Verlauf auf unseren Servern. Speicherdauer: bis du die Websitedaten in deinem Browser löschst.",
"wabenhain-cookie-consent – Deine Auswahl im Cookie-Banner („Nur notwendige“ oder „Alle akzeptieren“), damit der Banner nicht bei jedem Besuch erneut erscheint und wir deine Entscheidung nachweisen können. Speicherdauer: bis du deine Auswahl änderst oder die Websitedaten löschst. Rechtsgrundlage: § 25 Abs. 2 Nr. 2 TDDDG und Art. 6 Abs. 1 lit. c DSGVO in Verbindung mit Art. 7 Abs. 1 DSGVO.",
],
},
@@ -568,8 +594,9 @@ export const de: Dictionary = {
{
heading: "Cookies mit Einwilligung",
paragraphs: [
- "Derzeit setzen wir keine Cookies oder Technologien ein, die deine Einwilligung erfordern. Die Auswahl „Alle akzeptieren“ im Cookie-Banner schaltet deshalb aktuell nichts Zusätzliches frei; mit „Nur notwendige“ nutzt du die Website ohne jede Einschränkung.",
- "Sollten wir künftig z. B. eine Reichweitenmessung einsetzen, nennen wir sie vorher an dieser Stelle mit Anbieter, Zweck und Speicherdauer und aktivieren sie erst nach deiner ausdrücklichen, freiwilligen Einwilligung über den Cookie-Banner. Eine Ablehnung hat keine Nachteile für dich.",
+ "Nur wenn du im Cookie-Banner „Alle akzeptieren“ wählst, speichern wir den folgenden Eintrag. Ohne Einwilligung wird er nicht angelegt; die Liste gilt dann nur, solange der Tab geöffnet ist. Wählst du „Nur notwendige“ oder widerrufst du deine Einwilligung, löschen wir den Eintrag sofort. Rechtsgrundlage: § 25 Abs. 1 TDDDG in Verbindung mit Art. 6 Abs. 1 lit. a DSGVO.",
+ "wabenhain-recently-viewed – Die Kennungen der bis zu 6 zuletzt angesehenen Produkte (localStorage), um sie dir auf Produktseiten erneut anzuzeigen. Zum Anzeigen werden nur diese Produktkennungen abgefragt; wir speichern keinen Verlauf auf unseren Servern. Speicherdauer: bis du deine Einwilligung widerrufst oder die Websitedaten in deinem Browser löschst.",
+ "Weitere einwilligungspflichtige Technologien, etwa eine Reichweitenmessung, setzen wir nicht ein. Sollte sich das ändern, nennen wir sie vorher an dieser Stelle mit Anbieter, Zweck und Speicherdauer und aktivieren sie erst nach deiner ausdrücklichen, freiwilligen Einwilligung über den Cookie-Banner. Eine Ablehnung hat keine Nachteile für dich.",
],
},
{
@@ -1230,6 +1257,23 @@ export const de: Dictionary = {
sectionVisibility: "Sichtbarkeit",
create: "Anlegen",
},
+ adminWithdrawalsPage: {
+ title: "Widerrufe",
+ intro: "Erklärungen über die Online-Widerrufsfunktion (§ 356a BGB). Die Erstattung ist spätestens 14 Tage nach Eingang fällig; du darfst sie zurückhalten, bis die Ware zurück ist oder die Rücksendung nachgewiesen wurde.",
+ backToDashboard: "Zurück zum Dashboard",
+ empty: "Noch keine Widerrufe eingegangen.",
+ statusOpen: "Offen",
+ statusProcessed: "Erledigt",
+ matched: "Bestellung zugeordnet",
+ unmatched: "Keine passende Bestellung — bitte prüfen",
+ mailSent: "Eingangsbestätigung versendet",
+ mailNotSent: "Eingangsbestätigung NICHT versendet — bitte manuell nachholen",
+ receivedAt: "Eingegangen",
+ processedOn: (date) => `Erledigt am ${date}`,
+ markProcessed: "Als erledigt markieren",
+ reopen: "Wieder öffnen",
+ viewOrder: "Bestellung ansehen",
+ },
adminReturnsPage: {
refundConfirm: (orderNumber, amount) => `Bestellung ${orderNumber} jetzt mit ${amount} per Stripe erstatten? Das lässt sich nicht rückgängig machen.`,
rejectConfirm: (orderNumber) => `Retoure zu Bestellung ${orderNumber} wirklich ablehnen?`,
@@ -1442,6 +1486,7 @@ export const de: Dictionary = {
navDashboard: "Dashboard",
navOrders: "Bestellungen",
navReturns: "Retouren",
+ navWithdrawals: "Widerrufe",
navProducts: "Produkte",
navHarvests: "Ernten",
navBatches: "Chargen",
diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts
index c31cfc1..433192a 100644
--- a/src/i18n/dictionaries/en.ts
+++ b/src/i18n/dictionaries/en.ts
@@ -173,6 +173,30 @@ export const en: Dictionary = {
tocLabel: "Contents",
revisionLabel: "Last updated",
},
+ withdrawalPage: {
+ title: "Withdraw from contract",
+ intro: "Here you can withdraw from your purchase contract online within the 14-day withdrawal period. You do not need a customer account and do not have to give a reason.",
+ nameLabel: "Your name",
+ orderNumberLabel: "Order number",
+ orderNumberHint: "Shown in your order confirmation, e.g. WH-2026-0042.",
+ emailLabel: "Email address",
+ emailHint: "We will send the confirmation of receipt to this address.",
+ messageLabel: "Message (optional)",
+ requiredNote: "Only name, order number and email address are required.",
+ startButton: "Withdraw from contract",
+ reviewHeading: "Please check your withdrawal",
+ reviewIntro: "By clicking “Confirm withdrawal” we will submit this notice:",
+ confirmButton: "Confirm withdrawal",
+ editButton: "Edit details",
+ submitting: "Sending …",
+ successHeading: "Your withdrawal has been received",
+ successMailSent: "We have just emailed you a confirmation of receipt containing your notice and the date and time it was received.",
+ successMailFailed: "Your withdrawal has been validly received. The confirmation email could not be sent just now; we will send it to you as soon as possible. Please save this page as proof.",
+ receivedAtLabel: "Received on",
+ errorMessage: "Your withdrawal could not be sent. Please try again or email us — a withdrawal by email or letter is also valid.",
+ policyLink: "Right of withdrawal",
+ footerButton: "Withdraw from contract",
+ },
notFoundPage: {
title: "This page doesn't exist, at least not anymore.",
text: "The link might be outdated, or the page has moved. Take a look around the shop, or head back to the homepage.",
@@ -188,7 +212,7 @@ export const en: Dictionary = {
cookieConsent: {
dialogLabel: "Cookie settings",
bannerTextBeforeLink:
- "We only use technically necessary cookies (e.g. for logging into your customer account). With your consent, we also use cookies for audience measurement. Details can be found in our ",
+ "We use technically necessary cookies (e.g. for logging into your customer account). With your consent, we also remember in your browser which products you viewed recently. We do not use tracking or advertising. Details can be found in our ",
policyLinkLabel: "Cookie Policy",
bannerTextAfterLink: ".",
essentialOnly: "Necessary only",
@@ -266,7 +290,7 @@ export const en: Dictionary = {
{
heading: "3. Server Log Files When Visiting the Website",
paragraphs: [
- "When you access our website, we automatically collect on our server technical access data (e.g. IP address, date and time of access, page accessed, browser type, referrer URL) in what are known as server log files. This processing is technically necessary to provide the website (Art. 6(1)(f) GDPR) The log files are not combined with other data and are deleted automatically once they are no longer needed for security and error analysis.",
+ "When you access our website, we automatically collect on our server technical access data (e.g. IP address, date and time of access, page accessed, browser type, referrer URL) in what are known as server log files. This processing is technically necessary to provide the website (Art. 6(1)(f) GDPR) The log files are not combined with other data and are deleted automatically once they are no longer needed for security and error analysis, after 14 days at the latest.",
],
},
{
@@ -277,6 +301,8 @@ export const en: Dictionary = {
"Payments are processed by our payment provider, Stripe Payments Europe, Ltd. The data necessary for payment processing is transmitted to Stripe for this purpose. Stripe's privacy policy also applies (stripe.com/privacy). Where this involves transferring data to Stripe-affiliated companies outside the EU/EEA (in particular Stripe, Inc. in the USA), this is done on the basis of EU Standard Contractual Clauses pursuant to Art. 46 GDPR.",
"If you choose PayPal or Klarna in the payment step, you are redirected to the respective provider and the order data required for payment is transmitted to them: PayPal (Europe) S.à r.l. et Cie, S.C.A., 22–24 Boulevard Royal, L-2449 Luxembourg, or Klarna Bank AB (publ), Sveavägen 46, 111 34 Stockholm, Sweden. These providers may carry out their own checks, e.g. of identity or creditworthiness; they are responsible for these themselves and their privacy notices apply. Legal basis: Art. 6(1)(b) GDPR.",
"Order and invoice data is retained for the legally required period due to commercial and tax record-keeping obligations (§ 257 HGB, § 147 AO of German law).",
+ "We send order confirmations and other emails about your contract or customer account via Microsoft 365 (Microsoft Ireland Operations Limited, One Microsoft Place, South County Business Park, Leopardstown, Dublin 18, Ireland). Microsoft processes the data as our processor under a contract pursuant to Art. 28 GDPR. Data may be transferred to Microsoft Corporation in the USA; Microsoft is certified under the EU-US Data Privacy Framework. Legal basis: Art. 6(1)(b) GDPR.",
+ "If you withdraw from a contract via our online withdrawal function, we store your name, order number, email address, your optional message and the time of receipt, send you a confirmation of receipt and match the withdrawal to your order. Legal basis: Art. 6(1)(c) GDPR in conjunction with § 356a BGB. We keep the notice as proof until the limitation periods for claims under the contract have expired.",
],
},
{
@@ -291,7 +317,7 @@ export const en: Dictionary = {
heading: "6. Newsletter (Harvest Mail)",
paragraphs: [
"If you sign up for our harvest mail, we use your email address exclusively to send information about new harvests. The legal basis is your consent (Art. 6(1)(a) GDPR). You can unsubscribe at any time via the link in every email or through your customer account.",
- "Sign-up uses a double opt-in: you are only added once you click the link in our confirmation email. Unsubscribing withdraws your consent with effect for the future (Art. 7(3) GDPR). We then keep your address together with the time of unsubscribing so that we can demonstrate your consent and its withdrawal and send you no further harvest mail (Art. 6(1)(f) GDPR).",
+ "Sign-up uses a double opt-in: you are only added once you click the link in our confirmation email; we store the time of this confirmation as proof of your consent. If you subscribe in your customer account, the click there constitutes your consent and we store its time as well. Unsubscribing withdraws your consent with effect for the future (Art. 7(3) GDPR). We then keep your address together with the time of unsubscribing so that we can demonstrate your consent and its withdrawal and send you no further harvest mail (Art. 6(1)(f) GDPR).",
],
},
{
@@ -425,6 +451,7 @@ export const en: Dictionary = {
"You have the right to withdraw from this contract within fourteen days without giving any reason.",
"The withdrawal period is fourteen days from the day on which you, or a third party other than the carrier named by you, took possession of the goods.",
"To exercise your right of withdrawal, you must inform us ({{anbieter}}) of your decision to withdraw from this contract by means of a clear statement (e.g. a letter sent by post or an email). You may use the model withdrawal form below, but it is not obligatory.",
+ "You can also exercise your right of withdrawal online at {{widerrufsfunktion}}. If you use this online function, we will send you a confirmation of receipt on a durable medium (e.g. by email) without undue delay.",
"To meet the withdrawal deadline, it is sufficient for you to send your communication concerning your exercise of the right of withdrawal before the withdrawal period has expired.",
],
},
@@ -492,13 +519,13 @@ export const en: Dictionary = {
heading: "Legal basis",
paragraphs: [
"Storing information on your device or reading it from there generally requires your consent (§ 25(1) TDDDG). No consent is needed where the storage is strictly necessary for us to provide a service you have explicitly requested (§ 25(2) No. 2 TDDDG), such as signing in to your customer account or completing an order.",
- "Where personal data is processed in the process, we rely on Art. 6(1)(b) GDPR (contract and pre-contractual steps, e.g. customer account and order) or Art. 6(1)(f) GDPR (our legitimate interest in a secure, functioning website). Should we use technologies requiring consent in the future, the legal basis will be your consent under § 25(1) TDDDG in conjunction with Art. 6(1)(a) GDPR.",
+ "Where personal data is processed in the process, we rely on Art. 6(1)(b) GDPR (contract and pre-contractual steps, e.g. customer account and order) or Art. 6(1)(f) GDPR (our legitimate interest in a secure, functioning website). For storage requiring consent (see “Cookies requiring consent”), the legal basis is your consent under § 25(1) TDDDG in conjunction with Art. 6(1)(a) GDPR.",
],
},
{
heading: "Overview",
paragraphs: [
- "We only use technically necessary cookies and storage entries. We do not use analytics, statistics, marketing or tracking cookies, we do not build usage profiles, and we do not pass on data to third parties for advertising purposes.",
+ "We use technically necessary cookies and storage entries and — only with your consent — one further storage entry for the list of recently viewed products. We do not use analytics, statistics, marketing or tracking cookies, we do not build usage profiles, and we do not pass on data to third parties for advertising purposes.",
"Most of the cookies listed below are only created when you use a specific feature (e.g. signing in, paying, switching language). Merely visiting the website sets no cookie.",
],
},
@@ -528,7 +555,6 @@ export const en: Dictionary = {
paragraphs: [
"The following entries are kept in your browser's local storage. Unlike cookies, they are not automatically sent to us with every page request and do not expire on their own: they remain until you delete them (via your browser's “clear site data” setting) or the website removes them. Legal basis: § 25(2) No. 2 TDDDG and Art. 6(1)(b) or (f) GDPR.",
"wabenhain-cart – Your cart (items, quantities, prices). It is only transmitted to us when you submit an order, and it is emptied after a successful order. Storage period: until your next order or until you empty it yourself.",
- "wabenhain-recently-viewed – The identifiers of up to 6 recently viewed products, so they can be shown to you again on product pages. Only these product identifiers are requested for display; we store no browsing history on our servers. Storage period: until you clear the site data in your browser.",
"wabenhain-cookie-consent – Your choice in the cookie banner (“Essential only” or “Accept all”), so the banner does not reappear on every visit and we can demonstrate your decision. Storage period: until you change your choice or clear the site data. Legal basis: § 25(2) No. 2 TDDDG and Art. 6(1)(c) GDPR in conjunction with Art. 7(1) GDPR.",
],
},
@@ -544,8 +570,9 @@ export const en: Dictionary = {
{
heading: "Cookies requiring consent",
paragraphs: [
- "We currently use no cookies or technologies that require your consent. Choosing “Accept all” in the cookie banner therefore enables nothing additional at present; with “Essential only” you can use the website without any restriction.",
- "Should we introduce something like audience measurement in the future, we will name it here beforehand with provider, purpose and storage period, and only activate it after your explicit, voluntary consent via the cookie banner. Declining has no disadvantages for you.",
+ "We only store the following entry if you choose “Accept all” in the cookie banner. Without consent it is not created; the list then only lasts while the tab is open. If you choose “Essential only” or withdraw your consent, we delete the entry immediately. Legal basis: § 25(1) TDDDG in conjunction with Art. 6(1)(a) GDPR.",
+ "wabenhain-recently-viewed – The identifiers of up to 6 recently viewed products (localStorage), so they can be shown to you again on product pages. Only these product identifiers are requested for display; we store no browsing history on our servers. Storage period: until you withdraw your consent or clear the site data in your browser.",
+ "We do not use any other technologies requiring consent, such as audience measurement. Should that change, we will name them here beforehand with provider, purpose and storage period, and only activate it after your explicit, voluntary consent via the cookie banner. Declining has no disadvantages for you.",
],
},
{
@@ -1204,6 +1231,23 @@ export const en: Dictionary = {
sectionVisibility: "Visibility",
create: "Create",
},
+ adminWithdrawalsPage: {
+ title: "Withdrawals",
+ intro: "Notices submitted via the online withdrawal function (§ 356a BGB). The refund is due no later than 14 days after receipt; you may withhold it until the goods are back or proof of return has been provided.",
+ backToDashboard: "Back to dashboard",
+ empty: "No withdrawals received yet.",
+ statusOpen: "Open",
+ statusProcessed: "Done",
+ matched: "Order matched",
+ unmatched: "No matching order — please check",
+ mailSent: "Confirmation of receipt sent",
+ mailNotSent: "Confirmation of receipt NOT sent — please send manually",
+ receivedAt: "Received",
+ processedOn: (date) => `Done on ${date}`,
+ markProcessed: "Mark as done",
+ reopen: "Reopen",
+ viewOrder: "View order",
+ },
adminReturnsPage: {
refundConfirm: (orderNumber, amount) => `Refund ${amount} for order ${orderNumber} via Stripe now? This cannot be undone.`,
rejectConfirm: (orderNumber) => `Really reject the return for order ${orderNumber}?`,
@@ -1416,6 +1460,7 @@ export const en: Dictionary = {
navDashboard: "Dashboard",
navOrders: "Orders",
navReturns: "Returns",
+ navWithdrawals: "Withdrawals",
navProducts: "Products",
navHarvests: "Harvests",
navBatches: "Batches",
diff --git a/src/i18n/dictionary-type.ts b/src/i18n/dictionary-type.ts
index ac31656..5e5c120 100644
--- a/src/i18n/dictionary-type.ts
+++ b/src/i18n/dictionary-type.ts
@@ -195,6 +195,30 @@ export interface Dictionary {
tocLabel: string;
revisionLabel: string;
};
+ withdrawalPage: {
+ title: string;
+ intro: string;
+ nameLabel: string;
+ orderNumberLabel: string;
+ orderNumberHint: string;
+ emailLabel: string;
+ emailHint: string;
+ messageLabel: string;
+ requiredNote: string;
+ startButton: string;
+ reviewHeading: string;
+ reviewIntro: string;
+ confirmButton: string;
+ editButton: string;
+ submitting: string;
+ successHeading: string;
+ successMailSent: string;
+ successMailFailed: string;
+ receivedAtLabel: string;
+ errorMessage: string;
+ policyLink: string;
+ footerButton: string;
+ };
notFoundPage: {
title: string;
text: string;
@@ -794,6 +818,23 @@ export interface Dictionary {
sectionVisibility: string;
create: string;
};
+ adminWithdrawalsPage: {
+ title: string;
+ intro: string;
+ backToDashboard: string;
+ empty: string;
+ statusOpen: string;
+ statusProcessed: string;
+ matched: string;
+ unmatched: string;
+ mailSent: string;
+ mailNotSent: string;
+ receivedAt: string;
+ processedOn: (date: string) => string;
+ markProcessed: string;
+ reopen: string;
+ viewOrder: string;
+ };
adminReturnsPage: {
refundConfirm: (orderNumber: string, amount: string) => string;
rejectConfirm: (orderNumber: string) => string;
@@ -994,6 +1035,7 @@ export interface Dictionary {
navDashboard: string;
navOrders: string;
navReturns: string;
+ navWithdrawals: string;
navProducts: string;
navHarvests: string;
navBatches: string;
diff --git a/src/lib/company-info.ts b/src/lib/company-info.ts
index f859c64..57215d8 100644
--- a/src/lib/company-info.ts
+++ b/src/lib/company-info.ts
@@ -2,6 +2,9 @@ import { cache } from "react";
import { siteConfig } from "@/lib/site-config";
import { getContentValues } from "@/lib/content";
+/** Bewusst schlicht: nur "etwas@etwas.tld" 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,
diff --git a/src/lib/consent.ts b/src/lib/consent.ts
new file mode 100644
index 0000000..5f0baec
--- /dev/null
+++ b/src/lib/consent.ts
@@ -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.
+ }
+}
diff --git a/src/lib/email-templates.ts b/src/lib/email-templates.ts
index d85df79..fb7b5fd 100644
--- a/src/lib/email-templates.ts
+++ b/src/lib/email-templates.ts
@@ -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 {
+ 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 {
const s = getEmailStrings(order.locale).returnAccepted;
const html = await emailLayout(
diff --git a/src/lib/validation.ts b/src/lib/validation.ts
index ea8d388..0534ceb 100644
--- a/src/lib/validation.ts
+++ b/src/lib/validation.ts
@@ -29,6 +29,17 @@ export const returnRequestSchema = z.object({
});
export type ReturnRequestInput = z.infer;
+// 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;
+
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),
diff --git a/src/lib/withdrawal.test.ts b/src/lib/withdrawal.test.ts
new file mode 100644
index 0000000..eacb914
--- /dev/null
+++ b/src/lib/withdrawal.test.ts
@@ -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: "erika@example.com",
+ 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: erika@example.com",
+ ]);
+ });
+
+ 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: erika@example.com",
+ "Message: Falsche Sorte",
+ ]);
+ });
+});
+
+describe("escapeHtml", () => {
+ it("neutralises markup", () => {
+ expect(escapeHtml(`'&'`)).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: ``, orderNumber: "A1\r\nBcc: x@y.z" },
+ receivedAt,
+ "de"
+ );
+ expect(mail.bodyHtml).not.toContain(" `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, "'");
+}
+
+/** 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 = `
+