diff --git a/assets/css/webdesign.css b/assets/css/webdesign.css
index 1692adb3..c72f5c21 100644
--- a/assets/css/webdesign.css
+++ b/assets/css/webdesign.css
@@ -1542,3 +1542,159 @@ button.po-aufg-haken:hover { border-color: var(--wd-gold); background: rgba(232,
margin: .8rem 0 .2rem; padding-right: .3rem;
display: flex; flex-direction: column; gap: .5rem;
}
+
+/* =====================================================================
+ WIDERRUF (§ 356a BGB) — und die Druckansicht der Bestätigung
+
+ Gestalterisch bewusst ZURÜCKHALTEND. Alles andere im Bereich darf
+ Stimmung machen; diese Seite nicht. Wer hier landet, will eine Sache
+ erledigen und danach Gewissheit haben. Grosse Effekte würden hier wie
+ Ablenkung wirken — und der Vorwurf, man wolle den Widerruf erschweren,
+ ist genau der, den man am wenigsten gebrauchen kann.
+ ===================================================================== */
+.wr-h2 { font-size: 1.12rem; margin: 0 0 .5rem; }
+
+.wr-info {
+ border-left: 3px solid var(--wd-blau);
+ margin-bottom: 1.4rem;
+}
+
+.wr-pruef {
+ display: grid; grid-template-columns: minmax(0,.42fr) minmax(0,1fr);
+ gap: .45rem .9rem; margin: .9rem 0 1.1rem;
+ padding: .9rem 1rem; border-radius: 12px;
+ background: rgba(127,208,232,.05); border: 1px solid var(--wd-rand);
+}
+@media (max-width: 520px) { .wr-pruef { grid-template-columns: 1fr; gap: .1rem; } }
+.wr-pruef dt { color: var(--wd-text-soft); font-size: .88rem; }
+.wr-pruef dd { margin: 0; color: var(--wd-text); overflow-wrap: anywhere; white-space: pre-wrap; }
+@media (max-width: 520px) { .wr-pruef dd { margin-bottom: .55rem; } }
+
+/* Der Bestätigungsschritt hebt sich ab — hier passiert das Verbindliche. */
+.wr-bestaetigen { border-color: var(--wd-rand-stark); }
+
+/* Grün statt Gold für den Abschluss: Der Widerruf ist erfolgreich
+ erklärt, das ist aus Sicht des Kunden eine gute Nachricht. Gold ist im
+ Bereich für "das betrifft dich, tu was" reserviert — hier ist nichts
+ mehr zu tun. */
+.wr-fertig { border-color: rgba(111,214,163,.42); }
+.wr-haken {
+ width: 46px; height: 46px; border-radius: 50%;
+ display: grid; place-items: center; line-height: 1;
+ font-size: 1.5rem; margin-bottom: .7rem;
+ background: rgba(111,214,163,.14); color: var(--wd-ok);
+ border: 1px solid rgba(111,214,163,.4);
+}
+
+.wr-beleg {
+ margin: 1rem 0; padding: 1.1rem 1.2rem;
+ border-radius: 14px;
+ background: rgba(8,14,24,.55);
+ border: 1px solid var(--wd-rand-stark);
+}
+.wr-beleg-kopf {
+ display: flex; flex-wrap: wrap; gap: .5rem; align-items: baseline;
+ justify-content: space-between;
+ padding-bottom: .6rem; margin-bottom: .7rem;
+ border-bottom: 1px solid var(--wd-rand);
+}
+.wr-beleg-nr { font-family: var(--font-head); font-weight: 800; color: var(--wd-blau); }
+.wr-beleg dl {
+ display: grid; grid-template-columns: minmax(0,.42fr) minmax(0,1fr);
+ gap: .45rem .9rem; margin: 0;
+}
+@media (max-width: 520px) { .wr-beleg dl { grid-template-columns: 1fr; gap: .1rem; } }
+.wr-beleg dt { color: var(--wd-text-soft); font-size: .86rem; }
+.wr-beleg dd { margin: 0; overflow-wrap: anywhere; }
+@media (max-width: 520px) { .wr-beleg dd { margin-bottom: .55rem; } }
+
+/* ---------------------------------------------------------------------
+ DRUCKANSICHT
+
+ Die Eingangsbestätigung ist ein Rechtsnachweis. Ein Ausdruck mit
+ dunklem Hintergrund verbraucht eine halbe Patrone und ist auf Papier
+ schlecht lesbar — und ein Nachweis, den niemand ausdruckt, weil er
+ hässlich wird, erfüllt seinen Zweck nicht.
+
+ Deshalb: auf Papier schwarz auf weiss, ohne Navigation, ohne Knöpfe,
+ nur der Beleg. Adressen werden ausgeschrieben, weil ein Link auf
+ Papier nichts nützt.
+ --------------------------------------------------------------------- */
+@media print {
+ body.wd { background: #fff !important; color: #000 !important; }
+ .wd-kopf, #wd-kopf, .wd-fuss, #wd-fuss,
+ .wr-nicht-drucken, .wr-info, #wr-form, #wr-schritt2,
+ .wd-meldung, .wd-eyebrow { display: none !important; }
+
+ .wd-abschnitt { padding: 0 !important; }
+ .wd-karte, .wr-beleg {
+ background: #fff !important; border: 1px solid #000 !important;
+ box-shadow: none !important; color: #000 !important;
+ }
+ .wr-fertig, .wr-beleg { page-break-inside: avoid; }
+ .wr-haken { border-color: #000 !important; color: #000 !important; background: #fff !important; }
+ .wr-beleg-nr, .wr-h2, h1, h2, dt, dd, p { color: #000 !important; }
+ .wr-beleg dt { color: #444 !important; }
+
+ /* Adressen ausschreiben — ein Link ist auf Papier wertlos. */
+ a[href^="http"]::after { content: " (" attr(href) ")"; font-size: .85em; }
+}
+
+/* ---------------------------------------------------------------------
+ RECHTSSEITE
+
+ Lange Texte, die man liest statt überfliegt. Deshalb: schmalere
+ Zeilen, mehr Zeilenabstand, klare Absätze. Und KEIN Blocksatz --
+ der reisst im Deutschen wegen der langen Wörter Löcher in die Zeile
+ und macht genau die Texte schwerer lesbar, bei denen es auf jedes
+ Wort ankommt.
+ --------------------------------------------------------------------- */
+.rl-nav {
+ display: flex; flex-wrap: wrap; gap: .45rem;
+ margin: 1.2rem 0 1.6rem;
+}
+.rl-nav a {
+ padding: .42rem .85rem; border-radius: 999px;
+ border: 1px solid var(--wd-rand); background: var(--wd-glas);
+ font-size: .88rem; color: var(--wd-text-mid) !important; text-decoration: none;
+}
+.rl-nav a:hover { border-color: var(--wd-rand-stark); color: var(--wd-text) !important; }
+
+.rl-sprache { margin-bottom: 1.8rem; }
+
+.rl-h2 {
+ margin-top: 2.4rem;
+ /* Ein Sprungziel darf nicht unter der klebenden Kopfzeile landen. */
+ scroll-margin-top: 5.5rem;
+}
+.rl-text { line-height: 1.75; }
+.rl-text h3 {
+ font-size: 1rem; margin: 1.5rem 0 .5rem;
+ color: var(--wd-blau);
+}
+.rl-text h3:first-child { margin-top: 0; }
+.rl-text p { margin: 0 0 .8rem; }
+.rl-text ul { margin: 0 0 .9rem; padding-left: 1.2rem; }
+.rl-text li { margin-bottom: .35rem; }
+.rl-text code {
+ font-size: .86em; padding: .1rem .35rem; border-radius: 5px;
+ background: rgba(127,208,232,.09); color: var(--wd-blau);
+}
+
+/* Die Belehrung hebt sich ab: Sie ist der Teil, auf den es rechtlich
+ ankommt, und muss auch optisch als geschlossener Block erkennbar sein. */
+.rl-widerruf { border-left: 3px solid var(--wd-gold); }
+.rl-adresse {
+ padding: .7rem .9rem; border-radius: 10px;
+ background: rgba(127,208,232,.05); border: 1px solid var(--wd-rand);
+}
+
+/* Das Muster-Formular sieht bewusst wie ein Formular aus — man soll
+ sehen, dass man es ausdrucken und ausfüllen kann. */
+.rl-formular {
+ margin-top: .6rem; padding: 1rem 1.1rem;
+ border-radius: 12px; border: 1px dashed var(--wd-rand-stark);
+ background: rgba(8,14,24,.4);
+ font-size: .93rem;
+}
+.rl-formular p { margin: 0 0 .6rem; }
diff --git a/assets/js/i18n-wd-widerruf.js b/assets/js/i18n-wd-widerruf.js
new file mode 100644
index 00000000..3d2ff6a7
--- /dev/null
+++ b/assets/js/i18n-wd-widerruf.js
@@ -0,0 +1,223 @@
+/* =====================================================================
+ i18n-wd-widerruf.js — Elektronische Widerrufsfunktion, fünf Sprachen.
+
+ Zum Ton: Ein Widerruf ist für den Kunden eine unangenehme Situation,
+ und für den Anbieter eine schlechte Nachricht. Genau deshalb sind die
+ Texte hier BETONT freundlich und ohne jede Hürde formuliert.
+
+ Das ist nicht nur Höflichkeit, sondern auch Vorsicht: Wer den Eindruck
+ erweckt, den Widerruf erschweren zu wollen, riskiert genau den Ärger,
+ den er vermeiden will. Und rechtlich muss die Funktion ohnehin "leicht
+ zugänglich" sein — ein abweisender Ton wäre das Gegenteil.
+
+ Der Wortlaut "Vertrag widerrufen" bzw. seine Entsprechungen ist NICHT
+ frei gewählt: § 356a BGB schreibt diese Beschriftung (oder eine
+ gleichbedeutende) ausdrücklich vor.
+ ===================================================================== */
+window.I18N_WD = {
+ wr_titel: {
+ de: "Vertrag widerrufen — Dogfather Webdesign",
+ "de-CH": "Vertrag widerrüefe — Dogfather Webdesign",
+ en: "Withdraw from contract — Dogfather Web Design",
+ fr: "Rétracter le contrat — Dogfather Web Design",
+ pt: "Rescindir o contrato — Dogfather Web Design",
+ },
+ wr_eyebrow: { de: "Widerruf", "de-CH": "Widerrüef", en: "Withdrawal", fr: "Rétractation", pt: "Rescisão" },
+ wr_h1: {
+ de: "Vertrag widerrufen",
+ "de-CH": "Vertrag widerrüefe",
+ en: "Withdraw from contract",
+ fr: "Rétracter le contrat",
+ pt: "Rescindir o contrato",
+ },
+ wr_lead: {
+ de: "Hier kannst du deinen Vertrag ohne Angabe von Gründen widerrufen. Du bekommst sofort eine Bestätigung mit Zeitpunkt — zum Speichern und Ausdrucken.",
+ "de-CH": "Do chasch din Vertrag ohni Grund widerrüefe. Du überchunnsch sofort e Bestätigung mit Ziitpunkt — zum Speichere und Usdrucke.",
+ en: "Here you can withdraw from your contract without giving any reason. You will receive a confirmation with a timestamp immediately — to save and print.",
+ fr: "Ici tu peux te rétracter de ton contrat sans avoir à te justifier. Tu reçois immédiatement une confirmation horodatée — à enregistrer et à imprimer.",
+ pt: "Aqui podes rescindir o teu contrato sem indicar qualquer motivo. Recebes imediatamente uma confirmação com data e hora — para guardar e imprimir.",
+ },
+
+ wr_frist_h: {
+ de: "Wie lange habe ich Zeit?",
+ "de-CH": "Wie lang han ich Ziit?",
+ en: "How long do I have?",
+ fr: "De combien de temps est-ce que je dispose ?",
+ pt: "Quanto tempo tenho?",
+ },
+ wr_frist_t: {
+ de: "Die Widerrufsfrist beträgt 14 Tage ab dem Tag des Vertragsschlusses. Zur Wahrung der Frist reicht es, dass du den Widerruf vor Ablauf der Frist absendest.",
+ "de-CH": "D Widerrüefsfrist isch 14 Täg ab em Tag vom Vertragsschluss. Für d Frist z wahre, längts, wenn du de Widerrüef vor Ablauf abschicksch.",
+ en: "The withdrawal period is 14 days from the day the contract was concluded. To meet the deadline it is enough that you send your withdrawal before the period expires.",
+ fr: "Le délai de rétractation est de 14 jours à compter du jour de la conclusion du contrat. Pour respecter le délai, il suffit que tu envoies ta rétractation avant son expiration.",
+ pt: "O prazo de rescisão é de 14 dias a contar do dia da celebração do contrato. Para cumprir o prazo, basta enviares a rescisão antes de este terminar.",
+ },
+ wr_frist_hinweis: {
+ de: "Wichtig bei Dienstleistungen: Wenn du ausdrücklich zugestimmt hast, dass vor Ablauf der Frist mit der Arbeit begonnen wird, und bestätigt hast, dass du dadurch dein Widerrufsrecht verlierst, erlischt es mit vollständiger Erbringung der Leistung. Bis dahin schuldest du bei einem Widerruf nur den Wert dessen, was bis zum Widerruf bereits geleistet wurde.",
+ "de-CH": "Wichtig bi Dienschtleischtige: Wenn du usdrücklich zuegstimmt häsch, dass vor Ablauf vo dr Frist mit dr Arbet aagfange wird, und bestätigt häsch, dass du demit dis Widerrüefsrächt verlürsch, erlöscht es mit vollständiger Erbringig vo dr Leischtig. Bis dete schuldsch bi eme Widerrüef nume dr Wert vo dem, wo bis derthi scho gleischtet worde isch.",
+ en: "Important for services: If you expressly agreed that work should begin before the period expires, and confirmed that you thereby lose your right of withdrawal, that right ends once the service has been fully performed. Until then, if you withdraw you only owe the value of what has already been performed.",
+ fr: "Important pour les prestations : si tu as expressément accepté que le travail commence avant l'expiration du délai et confirmé que tu perds ainsi ton droit de rétractation, celui-ci s'éteint lorsque la prestation est intégralement fournie. Jusque-là, en cas de rétractation, tu ne dois que la valeur de ce qui a déjà été fourni.",
+ pt: "Importante nos serviços: se concordaste expressamente que o trabalho começasse antes do fim do prazo e confirmaste que perdes assim o teu direito de rescisão, este extingue-se com a prestação integral do serviço. Até lá, em caso de rescisão, só deves o valor do que já foi prestado.",
+ },
+ wr_zur_belehrung: {
+ de: "Vollständige Widerrufsbelehrung lesen",
+ "de-CH": "Vollständigi Widerrüefsbelehrig läse",
+ en: "Read the full withdrawal policy",
+ fr: "Lire l'information complète sur la rétractation",
+ pt: "Ler a informação completa sobre rescisão",
+ },
+
+ wr_form_h: { de: "Deine Angaben", "de-CH": "Dini Aagabe", en: "Your details", fr: "Tes informations", pt: "Os teus dados" },
+ wr_form_t: {
+ de: "Wir brauchen nur drei Dinge: deinen Namen, um welchen Vertrag es geht, und wohin die Bestätigung soll.",
+ "de-CH": "Mir bruuched nume drü Sache: din Name, um welle Vertrag s got, und wohi d Bestätigung söll.",
+ en: "We only need three things: your name, which contract this concerns, and where the confirmation should go.",
+ fr: "Nous n'avons besoin que de trois choses : ton nom, le contrat concerné et l'adresse pour la confirmation.",
+ pt: "Só precisamos de três coisas: o teu nome, qual o contrato em causa e para onde enviar a confirmação.",
+ },
+ wr_name: { de: "Dein Name", "de-CH": "Din Name", en: "Your name", fr: "Ton nom", pt: "O teu nome" },
+ wr_vertrag: { de: "Welcher Vertrag?", "de-CH": "Welle Vertrag?", en: "Which contract?", fr: "Quel contrat ?", pt: "Qual contrato?" },
+ wr_vertrag_hilfe: {
+ de: "Die Projektnummer steht in deinem Angebot und im Portal. Eine Beschreibung reicht aber auch.",
+ "de-CH": "D Projektnummere stoht i dim Aagebot und im Portal. E Beschriibig längt aber au.",
+ en: "The project number is in your quote and in the portal. A description is fine too.",
+ fr: "Le numéro de projet figure sur ton devis et dans l'espace client. Une description suffit également.",
+ pt: "O número do projeto está no teu orçamento e no portal. Uma descrição também chega.",
+ },
+ wr_kontakt: {
+ de: "E-Mail für die Bestätigung",
+ "de-CH": "E-Mail für d Bestätigung",
+ en: "E-mail for the confirmation",
+ fr: "E-mail pour la confirmation",
+ pt: "E-mail para a confirmação",
+ },
+ wr_betrifft: {
+ de: "Betrifft nur einen Teil?",
+ "de-CH": "Betrifft nume en Teil?",
+ en: "Only part of it?",
+ fr: "Cela ne concerne qu'une partie ?",
+ pt: "Diz respeito apenas a uma parte?",
+ },
+ wr_freiwillig: { de: "freiwillig", "de-CH": "freiwillig", en: "optional", fr: "facultatif", pt: "opcional" },
+ wr_grund: { de: "Grund", "de-CH": "Grund", en: "Reason", fr: "Motif", pt: "Motivo" },
+ wr_grund_frei: {
+ de: "freiwillig — du musst keinen angeben",
+ "de-CH": "freiwillig — du muesch kein aagäh",
+ en: "optional — you do not have to give one",
+ fr: "facultatif — tu n'as pas à en donner",
+ pt: "opcional — não tens de indicar nenhum",
+ },
+ wr_weiter: {
+ de: "Weiter zur Bestätigung",
+ "de-CH": "Wiiter zur Bestätigung",
+ en: "Continue to confirmation",
+ fr: "Continuer vers la confirmation",
+ pt: "Continuar para a confirmação",
+ },
+
+ wr_pruef_h: {
+ de: "Bitte prüfen und bestätigen",
+ "de-CH": "Bitte prüefe und bestätige",
+ en: "Please check and confirm",
+ fr: "Merci de vérifier et de confirmer",
+ pt: "Verifica e confirma, por favor",
+ },
+ wr_pruef_t: {
+ de: "Das schickst du ab. Danach ist der Widerruf erklärt.",
+ "de-CH": "Das schicksch ab. Dernoch isch de Widerrüef erklärt.",
+ en: "This is what you are sending. After that the withdrawal is declared.",
+ fr: "Voici ce que tu envoies. Ensuite, la rétractation est déclarée.",
+ pt: "É isto que vais enviar. Depois disso, a rescisão está declarada.",
+ },
+ /* Wortlaut gesetzlich vorgegeben (§ 356a BGB): zweite Schaltfläche
+ "Widerruf bestätigen" oder gleichbedeutend. */
+ wr_bestaetigen: {
+ de: "Widerruf bestätigen",
+ "de-CH": "Widerrüef bestätige",
+ en: "Confirm withdrawal",
+ fr: "Confirmer la rétractation",
+ pt: "Confirmar a rescisão",
+ },
+ wr_aendern: { de: "Angaben ändern", "de-CH": "Aagabe ändere", en: "Change details", fr: "Modifier les informations", pt: "Alterar dados" },
+ wr_sendet: { de: "Wird gesendet…", "de-CH": "Wird gschickt…", en: "Sending…", fr: "Envoi…", pt: "A enviar…" },
+
+ wr_fertig_h: {
+ de: "Dein Widerruf ist eingegangen",
+ "de-CH": "Din Widerrüef isch aacho",
+ en: "Your withdrawal has been received",
+ fr: "Ta rétractation a bien été reçue",
+ pt: "A tua rescisão foi recebida",
+ },
+ wr_fertig_t: {
+ de: "Das hier ist deine Eingangsbestätigung. Speichere oder drucke sie — sie ist dein Nachweis, dass du rechtzeitig widerrufen hast.",
+ "de-CH": "Das do isch dini Iigangsbestätigung. Speicher oder druck si — si isch din Nochwiis, dass du rächtziitig widerrüefe häsch.",
+ en: "This is your acknowledgement of receipt. Save or print it — it is your proof that you withdrew in time.",
+ fr: "Voici ton accusé de réception. Enregistre-le ou imprime-le — c'est ta preuve que tu t'es rétracté dans les délais.",
+ pt: "Este é o teu comprovativo de receção. Guarda-o ou imprime-o — é a tua prova de que rescindiste a tempo.",
+ },
+ wr_beleg_titel: {
+ de: "Eingangsbestätigung Widerruf",
+ "de-CH": "Iigangsbestätigung Widerrüef",
+ en: "Acknowledgement of receipt — withdrawal",
+ fr: "Accusé de réception — rétractation",
+ pt: "Comprovativo de receção — rescisão",
+ },
+ wr_beleg_zeit: { de: "Eingegangen am", "de-CH": "Aacho am", en: "Received on", fr: "Reçu le", pt: "Recebido em" },
+ wr_beleg_an: { de: "Erklärt gegenüber", "de-CH": "Erklärt gegenüber", en: "Declared to", fr: "Déclarée à", pt: "Declarada a" },
+ wr_beleg_erklaerung: { de: "Erklärung", "de-CH": "Erklärig", en: "Declaration", fr: "Déclaration", pt: "Declaração" },
+ wr_beleg_satz: {
+ de: "Hiermit widerrufe ich den oben bezeichneten Vertrag.",
+ "de-CH": "Hiermit widerrüefe ich de obe bezeichnet Vertrag.",
+ en: "I hereby withdraw from the contract identified above.",
+ fr: "Je me rétracte par la présente du contrat désigné ci-dessus.",
+ pt: "Pela presente, rescindo o contrato acima identificado.",
+ },
+ wr_drucken: {
+ de: "Bestätigung drucken oder als PDF sichern",
+ "de-CH": "Bestätigung drucke oder als PDF sichere",
+ en: "Print the confirmation or save as PDF",
+ fr: "Imprimer la confirmation ou l'enregistrer en PDF",
+ pt: "Imprimir a confirmação ou guardar em PDF",
+ },
+ wr_zurueck_start: { de: "Zur Startseite", "de-CH": "Zur Startsite", en: "To the home page", fr: "Vers l'accueil", pt: "Para a página inicial" },
+
+ wr_alternativ: {
+ de: "Du kannst den Widerruf auch formlos per E-Mail erklären — dieses Formular ist nur der bequemere Weg, kein vorgeschriebener.",
+ "de-CH": "Du chasch de Widerrüef au formlos per E-Mail erkläre — das Formular isch nume dr bequemer Wäg, kein vorgschriebne.",
+ en: "You can also declare your withdrawal informally by e-mail — this form is just the more convenient way, not a required one.",
+ fr: "Tu peux aussi déclarer ta rétractation librement par e-mail — ce formulaire n'est que le moyen le plus pratique, pas une obligation.",
+ pt: "Também podes declarar a rescisão livremente por e-mail — este formulário é apenas a via mais cómoda, não uma obrigação.",
+ },
+
+ wr_fehlt_name: {
+ de: "Bitte gib deinen Namen an.",
+ "de-CH": "Bitte gib din Name aa.",
+ en: "Please enter your name.",
+ fr: "Merci d'indiquer ton nom.",
+ pt: "Indica o teu nome.",
+ },
+ wr_fehlt_vertrag: {
+ de: "Bitte gib an, um welchen Vertrag es geht.",
+ "de-CH": "Bitte gib aa, um welle Vertrag s got.",
+ en: "Please tell us which contract this concerns.",
+ fr: "Merci d'indiquer de quel contrat il s'agit.",
+ pt: "Indica a que contrato se refere.",
+ },
+ wr_fehlt_kontakt: {
+ de: "Bitte gib eine E-Mail-Adresse an, damit die Bestätigung ankommt.",
+ "de-CH": "Bitte gib e E-Mail-Adrässe aa, demit d Bestätigung aachunnt.",
+ en: "Please enter an e-mail address so the confirmation reaches you.",
+ fr: "Merci d'indiquer une adresse e-mail pour recevoir la confirmation.",
+ pt: "Indica um endereço de e-mail para receberes a confirmação.",
+ },
+ /* Der Notfallhinweis nennt bewusst die E-Mail-Adresse im Klartext. Wenn
+ die Technik im entscheidenden Moment versagt, darf niemand deswegen
+ eine Frist verlieren — und genau dann braucht man den Ausweg sofort. */
+ wr_notfall: {
+ de: "Wichtig: Du kannst den Widerruf jederzeit auch formlos per E-Mail an dogfather1608@gmail.com erklären. Das ist genauso wirksam und wahrt die Frist.",
+ "de-CH": "Wichtig: Du chasch de Widerrüef jederziit au formlos per E-Mail a dogfather1608@gmail.com erkläre. Das isch glich wirksam und wahrt d Frist.",
+ en: "Important: you can declare your withdrawal informally by e-mail to dogfather1608@gmail.com at any time. That is equally valid and meets the deadline.",
+ fr: "Important : tu peux à tout moment déclarer ta rétractation par e-mail à dogfather1608@gmail.com. C'est tout aussi valable et respecte le délai.",
+ pt: "Importante: podes declarar a rescisão a qualquer momento por e-mail para dogfather1608@gmail.com. É igualmente válido e cumpre o prazo.",
+ },
+};
diff --git a/assets/js/wd-core.js b/assets/js/wd-core.js
index 0396ff30..9472c075 100644
--- a/assets/js/wd-core.js
+++ b/assets/js/wd-core.js
@@ -111,6 +111,10 @@
fuss_portal: { de: "Kundenportal", "de-CH": "Kundeportal", en: "Client portal", fr: "Espace client", pt: "Portal do cliente" },
fuss_verwaltung:{ de: "Verwaltung", "de-CH": "Verwaltig", en: "Administration", fr: "Administration", pt: "Administração" },
fuss_universe: { de: "Zum Dogfather Universe", "de-CH": "Zum Dogfather Universe", en: "To the Dogfather Universe", fr: "Vers le Dogfather Universe", pt: "Para o Dogfather Universe" },
+ fuss_recht: { de: "Impressum, AGB & Datenschutz", "de-CH": "Impressum, AGB & Dateschutz", en: "Legal notice, terms & privacy", fr: "Mentions légales, CGV & confidentialité", pt: "Aviso legal, termos & privacidade" },
+ /* Wortlaut nicht frei gewaehlt: § 356a BGB verlangt die Beschriftung
+ "Vertrag widerrufen" oder eine gleichbedeutende Bezeichnung. */
+ fuss_widerruf: { de: "Vertrag widerrufen", "de-CH": "Vertrag widerrüefe", en: "Withdraw from contract", fr: "Rétracter le contrat", pt: "Rescindir o contrato" },
fuss_kontakt: { de: "Kontakt & Rechtliches", "de-CH": "Kontakt & Rächtlichs", en: "Contact & legal", fr: "Contact & mentions légales", pt: "Contacto & jurídico" },
/* Kontaktwege laut Entscheidung vom 22.08.2026: Anfrageformular als
Hauptweg (strukturierte, qualifizierte Anfragen — Masterplan S.2)
@@ -494,6 +498,14 @@
'
" +
+ /* Der Widerrufslink steht bewusst HIER in der Fusszeile und
+ auf jeder Seite: § 356a verlangt, dass die
+ Widerrufsfunktion waehrend der ganzen Frist "staendig
+ verfuegbar, hervorgehoben platziert und leicht
+ zugaenglich" ist. Ein Link, den man erst in den AGB
+ suchen muss, erfuellt das nicht. */
+ '
" +
"" +
"" +
@@ -649,6 +661,26 @@
}
}
+ /* Datum MIT Uhrzeit. Fuer die Eingangsbestaetigung eines Widerrufs
+ reicht der Tag nicht: § 356a verlangt den ZEITPUNKT, und an der
+ Fristwahrung kann eine Stunde haengen. Ueberall sonst waere die
+ Uhrzeit nur Rauschen -- deshalb eine eigene Funktion statt datum()
+ zu erweitern. */
+ function datumZeit(wert) {
+ if (!wert) return "—";
+ var karte = { de: "de-DE", "de-CH": "de-CH", en: "en-GB", fr: "fr-FR", pt: "pt-PT" };
+ var d = new Date(wert);
+ if (isNaN(d.getTime())) return "—";
+ try {
+ return new Intl.DateTimeFormat(karte[getSprache()] || "de-DE", {
+ day: "2-digit", month: "2-digit", year: "numeric",
+ hour: "2-digit", minute: "2-digit", timeZoneName: "short",
+ }).format(d);
+ } catch (e) {
+ return d.toLocaleString();
+ }
+ }
+
/* Schützt vor eingeschleustem HTML, wenn Text aus der Datenbank oder aus
einem Formular in die Seite geschrieben wird. Überall dort Pflicht, wo
innerHTML mit fremden Daten benutzt wird. */
@@ -855,6 +887,7 @@
meldungAus: meldungAus,
preis: preis,
datum: datum,
+ datumZeit: datumZeit,
sicher: sicher,
};
})();
diff --git a/assets/js/wd-widerruf.js b/assets/js/wd-widerruf.js
new file mode 100644
index 00000000..729d576b
--- /dev/null
+++ b/assets/js/wd-widerruf.js
@@ -0,0 +1,148 @@
+/* =====================================================================
+ wd-widerruf.js — Elektronische Widerrufsfunktion (§ 356a BGB)
+
+ Der Ablauf ist vom Gesetz vorgegeben, nicht frei gestaltbar:
+
+ Schritt 1 Angaben (Name, Vertrag, Kontakt für die Bestätigung)
+ Schritt 2 ZWEITE Schaltfläche "Widerruf bestätigen"
+ Schritt 3 unverzügliche Eingangsbestätigung mit Zeitpunkt,
+ auf dauerhaftem Datenträger
+
+ Zwei bewusste Abweichungen von dem, was sonst überall im Projekt gilt:
+
+ * KEIN Entwurf im lokalen Speicher. Beim Anfrageformular ist das
+ Zwischenspeichern ein Segen — hier wäre es eine Falle: Auf einem
+ geteilten Rechner läge dann der halb ausgefüllte Widerruf des einen
+ im Browser des nächsten.
+
+ * KEINE Absendesperre. Wer in der letzten Stunde seiner Frist wegen
+ eines hakenden Netzes dreimal klickt, darf nicht abgewiesen werden.
+ Das wäre ein Rechtsverlust durch eine Komfortfunktion.
+ ===================================================================== */
+(function () {
+ "use strict";
+
+ var s = window.WD.sicher;
+ var meldung = document.getElementById("wr-meldung");
+ var form = document.getElementById("wr-form");
+ var schritt2 = document.getElementById("wr-schritt2");
+ var schritt3 = document.getElementById("wr-schritt3");
+ var daten = null;
+
+ function feldFehler(id, text) {
+ var feld = document.getElementById(id);
+ if (!feld) return;
+ feld.classList.add("wd-feld--fehler");
+ var p = feld.querySelector(".wd-feld-fehler");
+ if (p) p.textContent = text;
+ }
+ function fehlerLeeren() {
+ document.querySelectorAll(".wd-feld--fehler").forEach(function (f) {
+ f.classList.remove("wd-feld--fehler");
+ var p = f.querySelector(".wd-feld-fehler");
+ if (p) p.textContent = "";
+ });
+ }
+
+ function wert(id) { return (document.getElementById(id).value || "").trim(); }
+
+ /* ---- Schritt 1 -> 2 ---- */
+ form.addEventListener("submit", function (e) {
+ e.preventDefault();
+ fehlerLeeren();
+ window.WD.meldungAus(meldung);
+
+ var name = wert("wr-name");
+ var vertrag = wert("wr-vertrag");
+ var kontakt = wert("wr-kontakt");
+
+ var ok = true;
+ if (!name) { feldFehler("wr-feld-name", window.WD.t("wr_fehlt_name")); ok = false; }
+ if (!vertrag) { feldFehler("wr-feld-vertrag", window.WD.t("wr_fehlt_vertrag")); ok = false; }
+ /* Sehr einfache Prüfung mit Absicht. Eine strenge Adressprüfung
+ weist im Zweifel eine gültige Adresse ab — und dann kommt der
+ Widerruf gar nicht erst an. Lieber eine Bestätigung, die ins Leere
+ läuft, als ein Widerruf, der nicht abgeschickt werden kann. */
+ if (!kontakt || kontakt.indexOf("@") < 1 || kontakt.indexOf(".") < 0) {
+ feldFehler("wr-feld-kontakt", window.WD.t("wr_fehlt_kontakt")); ok = false;
+ }
+ if (!ok) {
+ document.querySelector(".wd-feld--fehler input").focus();
+ return;
+ }
+
+ daten = {
+ name: name,
+ vertrag: vertrag,
+ kontakt: kontakt,
+ betrifft: wert("wr-betrifft"),
+ begruendung: wert("wr-grund"),
+ sprache: window.WD.getSprache(),
+ };
+
+ var zeilen = [
+ [window.WD.t("wr_name"), daten.name],
+ [window.WD.t("wr_vertrag"), daten.vertrag],
+ [window.WD.t("wr_kontakt"), daten.kontakt],
+ ];
+ if (daten.betrifft) zeilen.push([window.WD.t("wr_betrifft"), daten.betrifft]);
+ if (daten.begruendung) zeilen.push([window.WD.t("wr_grund"), daten.begruendung]);
+
+ document.getElementById("wr-pruef").innerHTML = zeilen.map(function (z) {
+ return "
";
+
+ schritt2.hidden = true;
+ schritt3.hidden = false;
+ schritt3.scrollIntoView({ behavior: "smooth", block: "start" });
+ } catch (fehler) {
+ window.WD.knopfSperren(knopf, false);
+ /* Der Hinweis nennt ausdrücklich den formlosen Weg. Wenn die
+ Technik versagt, darf niemand deswegen seine Frist verlieren —
+ und in genau dem Moment braucht man den Ausweg, nicht später. */
+ window.WD.meldung(meldung, fehler.message + " " + window.WD.t("wr_notfall"), "fehler");
+ }
+ });
+
+ document.getElementById("wr-drucken").addEventListener("click", function () {
+ window.print();
+ });
+})();
diff --git a/cloudflare-worker/migrations/0014_webdesign_widerruf.sql b/cloudflare-worker/migrations/0014_webdesign_widerruf.sql
new file mode 100644
index 00000000..72006857
--- /dev/null
+++ b/cloudflare-worker/migrations/0014_webdesign_widerruf.sql
@@ -0,0 +1,121 @@
+-- =====================================================================
+-- 0014_webdesign_widerruf.sql
+--
+-- ELEKTRONISCHE WIDERRUFSFUNKTION (§ 356a BGB, seit 19.06.2026)
+--
+-- Recherchiert am 23.08.2026. Der Befund ist eindeutig und betrifft
+-- diese Seite unmittelbar:
+--
+-- § 356a BGB gilt seit dem 19.06.2026 für ALLE Fernabsatzverträge, die
+-- über eine Online-Benutzeroberfläche geschlossen werden — nicht nur
+-- für Finanzdienstleistungen. Wer Verbrauchern erlaubt, online einen
+-- Vertrag zu schliessen, muss eine elektronische Widerrufsfunktion
+-- bereitstellen: gut lesbar mit "Vertrag widerrufen" beschriftet,
+-- während der gesamten Widerrufsfrist ständig verfügbar, hervorgehoben
+-- platziert und leicht zugänglich.
+--
+-- WARUM DAS HIER GILT: Im Kundenportal nimmt der Kunde Zusatzangebote
+-- mit einem Klick VERBINDLICH an ("Damit nimmst du das Zusatzangebot
+-- verbindlich an"). Das ist ein Vertragsschluss über eine
+-- Online-Benutzeroberfläche. Dasselbe wird für die PayPal-Zahlungen
+-- gelten. Die Pflicht besteht also bereits.
+--
+-- Der Anbieter sitzt in Luxemburg. Für Verbraucher mit gewöhnlichem
+-- Aufenthalt in Deutschland gilt nach Art. 6 Rom-I-VO trotzdem das
+-- deutsche zwingende Verbraucherrecht, weil die Seite sich erkennbar an
+-- den deutschsprachigen Markt richtet. Deshalb wird hier der strengere
+-- Standard umgesetzt, nicht der bequemere.
+--
+-- Ablauf nach § 356a: Der Verbraucher gibt Name, Vertragskennzeichnung
+-- und eine Kontaktmöglichkeit an, betätigt eine ZWEITE Schaltfläche
+-- ("Widerruf bestätigen") und erhält unverzüglich eine
+-- Eingangsbestätigung auf dauerhaftem Datenträger, die die Erklärung und
+-- den Zeitpunkt enthält.
+--
+-- Genau dafür ist diese Tabelle da: Sie ist der dauerhafte Datenträger
+-- und zugleich der Nachweis. Sie wird NIE geleert.
+-- =====================================================================
+
+CREATE TABLE IF NOT EXISTS wd_widerrufe (
+ id TEXT PRIMARY KEY,
+ nummer TEXT NOT NULL UNIQUE, -- W-2608-0001, für die Bestätigung
+
+ /* Die Angaben, die § 356a verlangt. Sie stehen hier als FREITEXT und
+ nicht nur als Verweis auf einen Datensatz: Ein Widerruf muss auch
+ dann vollständig belegbar bleiben, wenn Kunde oder Projekt später
+ gelöscht werden. Der Nachweis darf nicht davon abhängen, dass
+ ringsherum noch alles existiert. */
+ name TEXT NOT NULL,
+ kontakt TEXT NOT NULL, -- E-Mail für die Eingangsbestätigung
+ vertrag TEXT NOT NULL, -- Vertragskennzeichnung, vom Kunden benannt
+ betrifft TEXT, -- optional: welcher Teil des Vertrags
+ begruendung TEXT, -- freiwillig; ein Widerruf braucht KEINE
+
+ /* Zuordnung, falls vorhanden — aber ausdrücklich ohne Fremdschlüssel.
+ Ein Fremdschlüssel mit ON DELETE CASCADE würde den Nachweis mit dem
+ Kunden mitlöschen; einer mit RESTRICT würde das Löschen eines Kunden
+ blockieren, obwohl die DSGVO es verlangen kann. Beides wäre falsch.
+ Deshalb nur eine lose Notiz. */
+ kunde_id TEXT,
+ projekt_id TEXT,
+
+ /* Zeitpunkt des Eingangs. Massgeblich für die Fristwahrung — der
+ Widerruf ist rechtzeitig, wenn er innerhalb der Frist ABGESENDET
+ wurde, nicht wenn er bearbeitet wurde. */
+ eingegangen_am TEXT NOT NULL,
+ ip_hash TEXT, -- nur der Hash, nie die IP selbst
+ sprache TEXT NOT NULL DEFAULT 'de',
+
+ /* Bestätigung: wurde sie versendet, und wann? "unverzüglich" ist
+ Pflicht, also muss man sehen können, ob es geklappt hat. */
+ bestaetigt_am TEXT,
+ bestaetigung_weg TEXT, -- 'email' | 'anzeige'
+
+ /* Bearbeitung auf unserer Seite. Der Widerruf ist wirksam, sobald er
+ zugeht — diese Felder ändern daran nichts, sie halten nur fest, was
+ wir damit gemacht haben. */
+ status TEXT NOT NULL DEFAULT 'eingegangen', -- eingegangen | bearbeitet
+ notiz TEXT,
+ erledigt_am TEXT
+);
+
+CREATE INDEX IF NOT EXISTS idx_wd_widerrufe_zeit ON wd_widerrufe(eingegangen_am DESC);
+CREATE INDEX IF NOT EXISTS idx_wd_widerrufe_offen ON wd_widerrufe(status, eingegangen_am DESC);
+
+-- ---------------------------------------------------------------------
+-- NACHWEIS DER ZUSTIMMUNG ZUM VORZEITIGEN LEISTUNGSBEGINN
+--
+-- § 356 Abs. 4 BGB: Bei Dienstleistungen erlischt das Widerrufsrecht nur
+-- dann vorzeitig, wenn der Verbraucher AUSDRÜCKLICH zugestimmt hat, dass
+-- vor Ablauf der Widerrufsfrist begonnen wird, UND gleichzeitig bestätigt
+-- hat, dass er dadurch sein Widerrufsrecht verliert.
+--
+-- Die Beweislast für BEIDES liegt beim Unternehmer. Ohne Nachweis bleibt
+-- das Widerrufsrecht bestehen — und die 30-%-Anzahlung, die vor Ablauf
+-- der Frist verlangt wird, wäre dann angreifbar.
+--
+-- Deshalb wird die Zustimmung mit Zeitstempel und dem GENAUEN Wortlaut
+-- gespeichert, der dem Kunden angezeigt wurde. Nur den Haken zu
+-- speichern reicht nicht: Im Streit zählt, WAS er bestätigt hat, und der
+-- Text kann sich über die Jahre ändern.
+-- ---------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS wd_zustimmungen (
+ id TEXT PRIMARY KEY,
+ kunde_id TEXT,
+ projekt_id TEXT,
+ anfrage_id TEXT,
+
+ -- 'sofortbeginn' | 'agb' | 'datenschutz'
+ art TEXT NOT NULL,
+
+ /* Der Wortlaut, den der Kunde tatsächlich gesehen hat — vollständig,
+ nicht als Verweis auf eine Textbaustein-Kennung. */
+ wortlaut TEXT NOT NULL,
+ fassung TEXT, -- z. B. "2026-08-23"
+ sprache TEXT NOT NULL DEFAULT 'de',
+
+ erteilt_am TEXT NOT NULL,
+ ip_hash TEXT
+);
+
+CREATE INDEX IF NOT EXISTS idx_wd_zust_kunde ON wd_zustimmungen(kunde_id, art);
diff --git a/pruef-widerruf.mjs b/pruef-widerruf.mjs
new file mode 100644
index 00000000..f1c3d290
--- /dev/null
+++ b/pruef-widerruf.mjs
@@ -0,0 +1,222 @@
+/* Prüft die elektronische Widerrufsfunktion (§ 356a BGB) im Browser.
+
+ Die Prüfungen orientieren sich am Gesetzeswortlaut, nicht am Gefühl:
+
+ - "gut lesbar mit 'Vertrag widerrufen' beschriftet"
+ - "während der gesamten Widerrufsfrist ständig verfügbar,
+ hervorgehoben platziert und leicht zugänglich"
+ - Der Verbraucher muss Name, Vertragskennzeichnung und ein
+ Kommunikationsmittel angeben können
+ - ZWEITE Schaltfläche "Widerruf bestätigen"
+ - unverzügliche Eingangsbestätigung mit Zeitpunkt
+
+ Dazu zwei Dinge, die aus dem Sinn der Vorschrift folgen und die man
+ leicht falsch baut: Der Widerruf darf KEINE Begründung verlangen, und
+ die Seite darf nicht hinter einer Anmeldung liegen.
+*/
+import { chromium } from "playwright";
+import { createServer } from "node:http";
+import { readFileSync, existsSync } from "node:fs";
+import { join, extname } from "node:path";
+
+const W = process.cwd();
+const T = { ".html": "text/html", ".css": "text/css", ".js": "text/javascript",
+ ".png": "image/png", ".jpg": "image/jpeg", ".svg": "image/svg+xml",
+ ".webp": "image/webp", ".ico": "image/x-icon", ".json": "application/json" };
+const srv = createServer((q, r) => {
+ const p = join(W, decodeURIComponent(q.url.split("?")[0]));
+ if (existsSync(p) && !p.endsWith("/")) {
+ r.writeHead(200, { "content-type": T[extname(p)] || "text/plain" });
+ return r.end(readFileSync(p));
+ }
+ r.writeHead(404).end("");
+});
+await new Promise((r) => srv.listen(4328, r));
+
+const ergebnisse = [];
+function pruefe(name, ok, zusatz) {
+ ergebnisse.push({ name, ok });
+ console.log((ok ? " ok " : " FEHL ") + name + (zusatz ? " -> " + zusatz : ""));
+}
+
+const browser = await chromium.launch();
+
+/* ---- Beschriftung nach § 356a in allen fünf Sprachen ---- */
+{
+ const p = await browser.newPage();
+ await p.goto("http://127.0.0.1:4328/webdesign/widerruf.html");
+ await p.waitForTimeout(600);
+ console.log("\n=== Gesetzlich vorgegebene Beschriftungen ===");
+
+ const erwartet = {
+ de: ["Vertrag widerrufen", "Widerruf bestätigen"],
+ "de-CH": ["Vertrag widerrüefe", "Widerrüef bestätige"],
+ en: ["Withdraw from contract", "Confirm withdrawal"],
+ fr: ["Rétracter le contrat", "Confirmer la rétractation"],
+ pt: ["Rescindir o contrato", "Confirmar a rescisão"],
+ };
+ for (const [sp, [h1, knopf]] of Object.entries(erwartet)) {
+ await p.addInitScript(`localStorage.setItem("dogi-sprache", ${JSON.stringify(sp)})`);
+ await p.goto("http://127.0.0.1:4328/webdesign/widerruf.html");
+ await p.waitForTimeout(350);
+ const gefunden = await p.evaluate(() => ({
+ h1: document.querySelector("h1")?.textContent.trim(),
+ knopf: document.getElementById("wr-bestaetigen")?.textContent.trim(),
+ fuss: Array.from(document.querySelectorAll(".wd-fuss-widerruf")).map((a) => a.textContent.trim()),
+ }));
+ pruefe(`[${sp}] Überschrift lautet „${h1}“`, gefunden.h1 === h1, gefunden.h1);
+ pruefe(`[${sp}] zweite Schaltfläche lautet „${knopf}“`, gefunden.knopf === knopf, gefunden.knopf);
+ pruefe(`[${sp}] Fusszeilen-Link auf jeder Seite vorhanden`, gefunden.fuss.length === 1, gefunden.fuss.join("|"));
+ }
+ await p.close();
+}
+
+/* ---- Ablauf ---- */
+for (const [wo, breite, hoehe] of [["Computer", 1280, 900], ["Handy", 390, 844]]) {
+ const seite = await browser.newPage({ viewport: { width: breite, height: hoehe } });
+ const fehlerImProtokoll = [];
+ seite.on("pageerror", (e) => fehlerImProtokoll.push(e.message));
+
+ let gesendet = null;
+ await seite.route("**/webdesign/widerruf", (route) => {
+ gesendet = route.request().postDataJSON();
+ return route.fulfill({ json: { ok: true, nummer: "W-2608-0001",
+ eingegangenAm: "2026-08-23T09:12:00.000Z",
+ bestaetigung: {
+ nummer: "W-2608-0001", name: gesendet.name, vertrag: gesendet.vertrag,
+ kontakt: gesendet.kontakt, eingegangenAm: "2026-08-23T09:12:00.000Z",
+ empfaenger: "Filipe Pereira Queiroz, 3 rue Arthur Thinnes, L-3919 Mondercange, Luxemburg",
+ } } });
+ });
+
+ await seite.addInitScript(`localStorage.setItem("dogi-sprache", "de")`);
+ await seite.goto("http://127.0.0.1:4328/webdesign/widerruf.html");
+ await seite.waitForTimeout(600);
+ console.log("\n=== " + wo + " ===");
+
+ /* KEINE Anmeldung: Wer widerrufen will, darf nicht erst an einer
+ Anmeldemaske scheitern. */
+ pruefe(wo + ": ohne Anmeldung nutzbar",
+ await seite.locator("#wr-form").isVisible() && await seite.locator('input[type="password"]').count() === 0);
+
+ /* Der Widerruf braucht KEINE Begründung. */
+ const pflicht = await seite.evaluate(() =>
+ Array.from(document.querySelectorAll(".wd-feld")).map((f) => ({
+ id: f.querySelector("input,textarea")?.id,
+ pflicht: !!f.querySelector(".wd-pflicht"),
+ })).filter((x) => x.id));
+ const grund = pflicht.find((x) => x.id === "wr-grund");
+ pruefe(wo + ": der Grund ist NICHT Pflicht", grund && !grund.pflicht);
+ pruefe(wo + ": genau drei Pflichtfelder (Name, Vertrag, Kontakt)",
+ pflicht.filter((x) => x.pflicht).length === 3,
+ pflicht.filter((x) => x.pflicht).map((x) => x.id).join(", "));
+
+ /* Leeres Formular darf nicht durchgehen. */
+ await seite.click("#wr-weiter");
+ await seite.waitForTimeout(300);
+ pruefe(wo + ": leeres Formular wird abgefangen",
+ await seite.locator("#wr-schritt2").isVisible() === false
+ && await seite.locator(".wd-feld--fehler").count() === 3);
+
+ /* Ausfüllen und weiter — die zweite Stufe MUSS kommen. */
+ await seite.fill("#wr-name", "Anna Muster");
+ await seite.fill("#wr-vertrag", "P-2608-0001");
+ await seite.fill("#wr-kontakt", "anna@example.com");
+ await seite.click("#wr-weiter");
+ await seite.waitForTimeout(500);
+
+ pruefe(wo + ": zweite Stufe erscheint", await seite.locator("#wr-schritt2").isVisible());
+ pruefe(wo + ": das Formular ist dabei ausgeblendet", await seite.locator("#wr-form").isVisible() === false);
+ pruefe(wo + ": die Angaben werden zur Prüfung gezeigt",
+ (await seite.locator("#wr-pruef").innerText()).includes("Anna Muster"));
+
+ /* Zurück und wieder vor — Angaben dürfen nicht verloren gehen. */
+ await seite.click("#wr-zurueck");
+ await seite.waitForTimeout(300);
+ pruefe(wo + ": „Angaben ändern“ behält das Ausgefüllte",
+ await seite.inputValue("#wr-name") === "Anna Muster");
+ await seite.click("#wr-weiter");
+ await seite.waitForTimeout(400);
+
+ /* Bestätigen. */
+ await seite.click("#wr-bestaetigen");
+ await seite.waitForTimeout(700);
+
+ pruefe(wo + ": Widerruf wird abgeschickt",
+ gesendet && gesendet.name === "Anna Muster" && gesendet.vertrag === "P-2608-0001",
+ JSON.stringify(gesendet));
+ pruefe(wo + ": Eingangsbestätigung erscheint", await seite.locator("#wr-schritt3").isVisible());
+
+ const beleg = await seite.locator("#wr-beleg").innerText();
+ pruefe(wo + ": Bestätigung nennt eine Nummer", beleg.includes("W-2608-0001"));
+ /* § 356a verlangt den ZEITPUNKT, nicht nur das Datum. */
+ pruefe(wo + ": Bestätigung nennt Datum UND Uhrzeit", /\d{2}:\d{2}/.test(beleg), beleg.match(/\d{2}[.:]\d{2}[.:]?\d*/g)?.join(" "));
+ pruefe(wo + ": Bestätigung nennt den Empfänger", beleg.includes("Mondercange"));
+ pruefe(wo + ": Bestätigung enthält die Erklärung selbst", beleg.includes("widerrufe"));
+
+ pruefe(wo + ": keine Fehler im Protokoll", fehlerImProtokoll.length === 0, fehlerImProtokoll.join(" | "));
+
+ /* Druckansicht: Auf Papier muss der Beleg lesbar sein und die
+ Bedienelemente müssen verschwinden. */
+ await seite.emulateMedia({ media: "print" });
+ await seite.waitForTimeout(250);
+ const druck = await seite.evaluate(() => {
+ const beleg = document.getElementById("wr-beleg");
+ const stil = getComputedStyle(beleg);
+ return {
+ hintergrundKoerper: getComputedStyle(document.body).backgroundColor,
+ belegRand: stil.borderTopColor,
+ kopfWeg: getComputedStyle(document.getElementById("wd-kopf")).display,
+ knoepfeWeg: getComputedStyle(document.querySelector(".wr-nicht-drucken")).display,
+ formularWeg: getComputedStyle(document.getElementById("wr-form")).display,
+ };
+ });
+ pruefe(wo + ": Druck — heller Hintergrund statt dunkel",
+ druck.hintergrundKoerper === "rgb(255, 255, 255)", druck.hintergrundKoerper);
+ pruefe(wo + ": Druck — Kopfzeile und Knöpfe sind weg",
+ druck.kopfWeg === "none" && druck.knoepfeWeg === "none" && druck.formularWeg === "none");
+ await seite.emulateMedia({ media: "screen" });
+
+ await seite.screenshot({ path: `pruef-widerruf-${wo.toLowerCase()}.png`, fullPage: true });
+ await seite.close();
+}
+
+/* ---- Rechtsseite ---- */
+{
+ const p = await browser.newPage({ viewport: { width: 1280, height: 900 } });
+ const fehler = [];
+ p.on("pageerror", (e) => fehler.push(e.message));
+ await p.goto("http://127.0.0.1:4328/webdesign/rechtliches.html");
+ await p.waitForTimeout(700);
+ console.log("\n=== Rechtsseite ===");
+
+ const m = await p.evaluate(() => ({
+ abschnitte: ["impressum", "agb", "widerruf", "datenschutz"].map((id) => !!document.getElementById(id)),
+ muster: document.body.innerText.includes("Muster-Widerrufsformular"),
+ frist: document.body.innerText.includes("vierzehn Tagen"),
+ ust: document.body.innerText.includes("Kleinunternehmerregelung"),
+ erloeschen: document.body.innerText.includes("ausdrückliche Zustimmung"),
+ linkWiderruf: document.querySelectorAll('a[href="/webdesign/widerruf.html"]').length,
+ aufsicht: document.body.innerText.includes("CNPD"),
+ navi: document.querySelectorAll(".rl-nav a").length,
+ }));
+
+ pruefe("alle vier Abschnitte vorhanden", m.abschnitte.every(Boolean), JSON.stringify(m.abschnitte));
+ pruefe("Muster-Widerrufsformular ist da", m.muster);
+ pruefe("14-Tage-Frist ist benannt", m.frist);
+ pruefe("Umsatzsteuer-Hinweis ist da (Preisangabenpflicht)", m.ust);
+ pruefe("vorzeitiges Erlöschen ist erklärt (§ 356 Abs. 4)", m.erloeschen);
+ pruefe("mehrere Wege zur Widerrufsfunktion", m.linkWiderruf >= 2, "n=" + m.linkWiderruf);
+ pruefe("zuständige Aufsichtsbehörde genannt", m.aufsicht);
+ pruefe("Sprungnavigation vorhanden", m.navi === 4);
+ pruefe("keine Fehler im Protokoll", fehler.length === 0, fehler.join(" | "));
+
+ await p.screenshot({ path: "pruef-rechtliches.png", fullPage: false });
+ await p.close();
+}
+
+await browser.close();
+srv.close();
+const f = ergebnisse.filter((e) => !e.ok).length;
+console.log(`\n===== ${ergebnisse.length - f} bestanden, ${f} fehlgeschlagen =====`);
+process.exitCode = f ? 1 : 0;
diff --git a/server-internal/db.js b/server-internal/db.js
index 2370740c..27fc94f8 100644
--- a/server-internal/db.js
+++ b/server-internal/db.js
@@ -48,6 +48,12 @@ const MIGRATION_FILES = [
// (22.08.2026). Begruendung fuer die getrennte Postfach-Tabelle steht
// im Kopf der Migration.
"0013_webdesign_aufgaben.sql",
+ // Elektronische Widerrufsfunktion (§ 356a BGB, seit 19.06.2026) und der
+ // Nachweis der Zustimmung zum vorzeitigen Leistungsbeginn (§ 356 Abs. 4
+ // BGB). Ausfuehrliche Begruendung im Kopf der Migration -- kurz: Im
+ // Portal werden Zusatzangebote per Klick verbindlich angenommen, das ist
+ // ein Vertragsschluss ueber eine Online-Benutzeroberflaeche.
+ "0014_webdesign_widerruf.sql",
];
function alreadyApplied(name) {
diff --git a/server-internal/index.js b/server-internal/index.js
index 4eb1f2bc..082aef6a 100644
--- a/server-internal/index.js
+++ b/server-internal/index.js
@@ -111,6 +111,12 @@ import {
portalPostfach, portalPostfachSenden, adminPostfachListe,
adminPostfachVerlauf, adminPostfachSenden, adminPostfachZaehler,
} from "./routes/webdesign-postfach.js";
+/* Elektronische Widerrufsfunktion (§ 356a BGB). Bewusst OHNE Anmeldung
+ erreichbar -- das Gesetz verlangt "leicht zugaenglich", und wer einen
+ Vertrag loswerden will, darf nicht erst an einer Anmeldung scheitern. */
+import {
+ widerrufAbsenden, widerrufeListe, widerrufBearbeiten,
+} from "./routes/webdesign-widerruf.js";
import { vorlagenSicherstellen } from "./lib/webdesign-vorlagen.js";
initDb();
@@ -254,6 +260,13 @@ app.get("/site-texts", wrap(getSiteTextsPublic));
verlangt eine Team-Anmeldung, die in den Handlern selbst geprüft wird
(unterschiedliche Rechte je Endpunkt, siehe Import oben). */
app.post("/webdesign/anfrage", wrap(anfrageAbsenden));
+/* Zweiter oeffentlicher Endpunkt des Bereichs -- und der einzige, bei dem
+ das so sein MUSS: § 356a BGB verlangt eine leicht zugaengliche
+ Widerrufsfunktion. Eine Anmeldung davor waere ein Rechtsverlust fuer
+ jeden, der sein Passwort vergessen hat. */
+app.post("/webdesign/widerruf", wrap(widerrufAbsenden));
+app.get("/webdesign/admin/widerrufe", wrap(widerrufeListe));
+app.post("/webdesign/admin/widerrufe/:id", wrap(widerrufBearbeiten));
app.get("/webdesign/admin/anfragen", wrap(anfragenListe));
app.get("/webdesign/admin/anfragen/:id", wrap(anfrageDetail));
app.post("/webdesign/admin/anfragen/:id/status", wrap(anfrageStatus));
diff --git a/server-internal/routes/webdesign-widerruf.js b/server-internal/routes/webdesign-widerruf.js
new file mode 100644
index 00000000..fc2f5b0e
--- /dev/null
+++ b/server-internal/routes/webdesign-widerruf.js
@@ -0,0 +1,212 @@
+/* =====================================================================
+ routes/webdesign-widerruf.js — Elektronische Widerrufsfunktion
+
+ Umsetzung von § 356a BGB (in Kraft seit 19.06.2026). Begründung, warum
+ die Vorschrift hier gilt, steht ausführlich im Kopf der Migration
+ 0014 — kurz: Im Portal werden Zusatzangebote per Klick verbindlich
+ angenommen, das ist ein Vertragsschluss über eine Online-Benutzer-
+ oberfläche.
+
+ Drei Dinge, die diese Datei anders macht als jedes andere Formular
+ im Projekt — jedes davon folgt unmittelbar aus dem Gesetz:
+
+ 1. KEINE ANMELDUNG. Der Widerruf muss "leicht zugänglich" sein.
+ Jemanden, der einen Vertrag loswerden will, erst durch eine
+ Anmeldung zu schicken, ist das Gegenteil davon. Wer sein Passwort
+ vergessen hat, könnte sonst die Frist verpassen.
+
+ 2. KEINE PFLICHTFELDER ÜBER DAS GESETZLICHE MASS HINAUS. Ein Widerruf
+ braucht KEINE Begründung. Das Feld existiert, ist aber freiwillig
+ und auch so beschriftet.
+
+ 3. DIE MENGENBEGRENZUNG DARF NIEMANDEN AUSSPERREN. Bei jedem anderen
+ Formular ist eine Sperre nach zu vielen Versuchen richtig. Hier
+ wäre sie ein Rechtsverlust: Wer in der letzten Stunde seiner Frist
+ dreimal auf "Absenden" klickt, weil das Netz hakt, darf nicht
+ abgewiesen werden. Deshalb wird oberhalb der Grenze verlangsamt
+ und protokolliert, aber der Widerruf immer angenommen.
+ ===================================================================== */
+
+import { db } from "../db.js";
+import { json } from "../lib/http.js";
+import { requireSession } from "../lib/auth.js";
+import { hasPermission } from "../lib/permissions.js";
+import { ausweisPruefen } from "../lib/webdesign-ausweis.js";
+import {
+ jetzt, neueId, naechsteNummer, text, istEmail, emailNormal,
+ verlauf, besucherIp, ipHash, spracheOderDe,
+} from "../lib/webdesign-helfer.js";
+
+async function darf(req, recht) {
+ const ausweis = ausweisPruefen(req);
+ if (ausweis) return { erlaubt: true, sitzung: { actor: ausweis.rolle, isOwner: true } };
+ const sitzung = await requireSession(req);
+ if (!sitzung) return { erlaubt: false, grund: 401, sitzung: null };
+ if (sitzung.isOwner) return { erlaubt: true, sitzung };
+ const ok = hasPermission(sitzung.permissions, sitzung.overrides, recht);
+ return { erlaubt: ok, grund: ok ? null : 403, sitzung };
+}
+
+/* ---------------------------------------------------------------------
+ WIDERRUF ENTGEGENNEHMEN — öffentlich, ohne Anmeldung
+
+ POST /webdesign/widerruf
+ --------------------------------------------------------------------- */
+export function widerrufAbsenden(req, res) {
+ const b = req.body || {};
+
+ const name = text(b.name, 200);
+ const kontakt = emailNormal(b.kontakt);
+ const vertrag = text(b.vertrag, 300);
+
+ /* Nur die drei Angaben, die § 356a nennt, sind Pflicht — Name,
+ Vertragskennzeichnung und ein Kommunikationsmittel für die
+ Eingangsbestätigung. Mehr zu verlangen wäre eine Hürde, die das
+ Gesetz gerade abbauen will. */
+ const fehlt = [];
+ if (!name) fehlt.push("name");
+ if (!vertrag) fehlt.push("vertrag");
+ if (!istEmail(kontakt)) fehlt.push("kontakt");
+ if (fehlt.length) {
+ return json(res, {
+ ok: false,
+ fehlt,
+ error: "Bitte Name, Vertrag und eine E-Mail-Adresse angeben — mehr brauchen wir nicht.",
+ }, 400);
+ }
+
+ const id = neueId();
+ const nummer = naechsteNummer("wd_widerrufe", "W");
+ const zeit = jetzt();
+
+ /* Zuordnung wird VERSUCHT, ist aber nie Bedingung. Findet sich kein
+ passender Kunde, wird der Widerruf trotzdem gespeichert — er ist
+ wirksam, sobald er zugeht, unabhängig davon, ob unser System ihn
+ einem Datensatz zuordnen kann. */
+ let kundeId = null;
+ let projektId = null;
+ try {
+ const kunde = db.prepare(`SELECT id FROM wd_kunden WHERE email = ?`).get(kontakt);
+ if (kunde) kundeId = kunde.id;
+ const projekt = db
+ .prepare(`SELECT id FROM wd_projekte WHERE nummer = ?`)
+ .get(vertrag.trim().toUpperCase());
+ if (projekt) projektId = projekt.id;
+ } catch (e) { /* Zuordnung ist Komfort, kein Muss. */ }
+
+ db.prepare(
+ `INSERT INTO wd_widerrufe
+ (id, nummer, name, kontakt, vertrag, betrifft, begruendung,
+ kunde_id, projekt_id, eingegangen_am, ip_hash, sprache,
+ bestaetigt_am, bestaetigung_weg, status)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,'anzeige','eingegangen')`
+ ).run(
+ id, nummer, name, kontakt, vertrag,
+ text(b.betrifft, 300) || null,
+ text(b.begruendung, 2000) || null,
+ kundeId, projektId, zeit, ipHash(besucherIp(req)),
+ spracheOderDe(b.sprache), zeit
+ );
+
+ verlauf("widerruf", id, "eingegangen", "kunde", { nummer, vertrag });
+
+ /* Die Eingangsbestätigung. § 356a verlangt sie "unverzüglich" und auf
+ einem dauerhaften Datenträger, mit dem Inhalt der Erklärung und dem
+ Zeitpunkt.
+
+ Sie wird hier VOLLSTÄNDIG zurückgegeben, damit die Seite sie sofort
+ anzeigen und zum Ausdrucken bzw. Speichern anbieten kann. Auf einen
+ E-Mail-Versand als EINZIGEN Weg zu setzen wäre fahrlässig: Mails
+ landen im Spam oder werden vom Anbieter verzögert, und dann steht
+ der Verbraucher ohne Nachweis da. Die Anzeige ist sofort und
+ zuverlässig; die Datenbank ist der dauerhafte Nachweis auf unserer
+ Seite. */
+ return json(res, {
+ ok: true,
+ nummer,
+ eingegangenAm: zeit,
+ bestaetigung: {
+ nummer,
+ name,
+ vertrag,
+ kontakt,
+ eingegangenAm: zeit,
+ empfaenger: "Filipe Pereira Queiroz, 3 rue Arthur Thinnes, L-3919 Mondercange, Luxemburg",
+ },
+ });
+}
+
+/* ---------------------------------------------------------------------
+ VERWALTUNG
+ --------------------------------------------------------------------- */
+
+/* GET /webdesign/admin/widerrufe */
+export async function widerrufeListe(req, res) {
+ const p = await darf(req, "WD_KUNDEN_MANAGE");
+ if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
+
+ const zeilen = db
+ .prepare(
+ `SELECT w.*, k.name AS kunde_name, pr.titel AS projekt_titel
+ FROM wd_widerrufe w
+ LEFT JOIN wd_kunden k ON k.id = w.kunde_id
+ LEFT JOIN wd_projekte pr ON pr.id = w.projekt_id
+ ORDER BY w.eingegangen_am DESC LIMIT 300`
+ )
+ .all();
+
+ const offen = zeilen.filter((z) => z.status === "eingegangen").length;
+ return json(res, { ok: true, widerrufe: zeilen, offen });
+}
+
+/* POST /webdesign/admin/widerrufe/:id
+
+ Nur Notiz und Bearbeitungsstand. Ein Widerruf lässt sich NICHT
+ löschen und nicht ablehnen — er ist wirksam, sobald er zugeht, und
+ die Zeile ist der Nachweis dafür. Ein System, in dem man den
+ unangenehmen Fall wegklicken kann, verliert genau dann seinen Wert,
+ wenn man ihn braucht. */
+export async function widerrufBearbeiten(req, res) {
+ const p = await darf(req, "WD_KUNDEN_MANAGE");
+ if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
+
+ const w = db.prepare(`SELECT * FROM wd_widerrufe WHERE id = ?`).get(String(req.params.id || ""));
+ if (!w) return json(res, { ok: false, error: "Nicht gefunden." }, 404);
+
+ const erledigt = req.body?.erledigt === true;
+ db.prepare(
+ `UPDATE wd_widerrufe SET status = ?, notiz = ?, erledigt_am = ? WHERE id = ?`
+ ).run(
+ erledigt ? "bearbeitet" : "eingegangen",
+ text(req.body?.notiz, 2000) || w.notiz,
+ erledigt ? jetzt() : null,
+ w.id
+ );
+
+ verlauf("widerruf", w.id, erledigt ? "bearbeitet" : "wieder_offen",
+ "team:" + (p.sitzung?.actor || "?"), null);
+ return json(res, { ok: true });
+}
+
+/* ---------------------------------------------------------------------
+ ZUSTIMMUNG ZUM SOFORTIGEN LEISTUNGSBEGINN FESTHALTEN
+
+ § 356 Abs. 4 BGB. Die Beweislast liegt beim Unternehmer, deshalb wird
+ nicht nur der Haken gespeichert, sondern der WORTLAUT, den der Kunde
+ gesehen hat. Nur "hat zugestimmt" hilft im Streit wenig — es zählt,
+ WAS er bestätigt hat, und Texte ändern sich über die Jahre.
+ --------------------------------------------------------------------- */
+export function zustimmungSpeichern({ kundeId, projektId, anfrageId, art, wortlaut, fassung, sprache, ip }) {
+ if (!wortlaut) return null;
+ const id = neueId();
+ db.prepare(
+ `INSERT INTO wd_zustimmungen
+ (id, kunde_id, projekt_id, anfrage_id, art, wortlaut, fassung, sprache, erteilt_am, ip_hash)
+ VALUES (?,?,?,?,?,?,?,?,?,?)`
+ ).run(
+ id, kundeId || null, projektId || null, anfrageId || null,
+ art, wortlaut, fassung || null, spracheOderDe(sprache), jetzt(),
+ ip ? ipHash(ip) : null
+ );
+ return id;
+}
diff --git a/server/webdesign-gate.js b/server/webdesign-gate.js
index 3f7a8ff4..c344cc66 100644
--- a/server/webdesign-gate.js
+++ b/server/webdesign-gate.js
@@ -423,8 +423,28 @@ export function webdesignGate(req, res, next) {
Würde man sie sperren, lieferte die Schranke stattdessen eine
302-Umleitung auf zugang.html — der Browser bekäme HTML statt
JavaScript und bräche die Registrierung mit einem MIME-Fehler ab. */
+ /* WIDERRUF UND RECHTSTEXTE BLEIBEN IMMER OFFEN — auch vor der Freigabe
+ und auch ohne Zugangscode.
+
+ Das ist keine Bequemlichkeit, sondern Pflicht. § 356a BGB verlangt,
+ dass die Widerrufsfunktion während der gesamten Widerrufsfrist
+ "ständig verfügbar, hervorgehoben platziert und leicht zugänglich"
+ ist. Eine Zugangswand davor wäre das genaue Gegenteil: Wer seinen
+ Code verlegt hat, könnte seine Frist verlieren — und Fristverlust
+ durch eine selbstgebaute Hürde ist der schlimmste denkbare Fall.
+
+ Dasselbe gilt für die Rechtstexte: Die Widerrufsbelehrung muss
+ lesbar sein, BEVOR man widerruft, nicht danach.
+
+ Unbedenklich, weil beide Seiten ausschliesslich Texte enthalten, die
+ ohnehin für jeden bestimmt sind — keine Kunden-, Projekt- oder
+ Preisdaten. Der Widerrufs-Endpunkt nimmt nur entgegen und gibt
+ nichts preis: Er beantwortet keine Frage, sondern speichert eine
+ Erklärung. */
if (
pfad === "/webdesign/zugang.html" ||
+ pfad === "/webdesign/widerruf.html" ||
+ pfad === "/webdesign/rechtliches.html" ||
pfad === "/webdesign/sw.js" ||
pfad === "/webdesign/app.webmanifest"
) {
diff --git a/webdesign/ablauf.html b/webdesign/ablauf.html
index cbfa1c50..906f7cba 100644
--- a/webdesign/ablauf.html
+++ b/webdesign/ablauf.html
@@ -13,8 +13,8 @@
-
-
+
+