From eaeaec6b56681b25c0829a85176bce9c003f4017 Mon Sep 17 00:00:00 2001 From: Dogfather Date: Sat, 22 Aug 2026 18:47:45 +0200 Subject: [PATCH] Anfrageformular fertig - vier Schritte, Zwischenspeicherung, echte Absendung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Masterplan S.10 "Projektanfrage ohne Informationsverlust" umgesetzt. End-to-End gegen die echte Datenbank belegt: Anfrage A-2608-0002 liegt drin. 38/38 Browsertests, 45/45 Handy-Abnahme. Aufbau: 4 Schritte + Zusammenfassung + Dankeseite. - Zwischenspeicherung nach jedem Tastendruck. Geschlossener Tab, leerer Akku oder versehentliches Zurueck kosten keine Arbeit. Entwuerfe verfallen nach 30 Tagen -- ein halbes Jahr alter Entwurf verwirrt mehr als er hilft. - Folgefragen erscheinen nur passend zur Auswahl und werden beim Zurueckwechseln NICHT mitgeschickt, sonst landen alte Shop-Antworten in einer Onepager-Anfrage. - Die Zusammenfassung wird aus den Feldern gelesen, nicht aus einem nebenher gepflegten Objekt -- sie kann dadurch nie etwas anderes behaupten als das, was tatsaechlich abgeschickt wird. - Telefon wird zur Pflicht, sobald "Per Telefon" gewaehlt ist. Ein Wunsch, der nicht erfuellbar ist, ist schlimmer als eine Pflichtangabe. Drei Fehler, die erst der Test gefunden hat: 1. Der "Zurueck"-Knopf stand auch auf Schritt 1 da. Ursache: das hidden-Attribut wird nur ueber "display:none" umgesetzt und verliert gegen jedes eigene display -- und Knoepfe sind inline-flex. Zentral behoben ueber ".wd [hidden] { display:none !important }". 2. Nach erfolgreichem Absenden legte zeigeSeite() den geloeschten Entwurf sofort wieder an. Beim naechsten Besuch haette eine bereits gesendete Anfrage erneut im Formular gestanden. 3. Der Server meldet bei Spamverdacht bewusst Erfolg ohne Nummer. Ein echter Mensch, der zufaellig sehr schnell war, haette eine Dankeseite mit "—" gesehen und auf eine Antwort gewartet, die nie kommt. Jetzt ein ehrlicher Hinweis mit zweitem Weg, und der Entwurf bleibt erhalten. Pruefskripte geschaerft: - i18n-Pruefung liest jetzt auch assets/js/wd-.js, sonst meldet sie reihenweise "unbenutzt" fuer Schluessel, die aus dem Seitenskript kommen. - Handy-Abnahme ueberspringt absichtlich unsichtbare Eingabefelder (Auswahlkacheln, Honigtopf). Fehlalarme sind das Ende jeder Pruefung, weil man sie irgendwann pauschal ignoriert. Co-Authored-By: Claude Opus 5 --- assets/css/webdesign.css | 19 + assets/js/i18n-wd-anfrage.js | 10 + assets/js/wd-anfrage.js | 540 +++++++++++++++++++++++++++++ server/pruefe-webdesign-handy.mjs | 17 + server/pruefe-webdesign-i18n.mjs | 17 +- server/test-webdesign-formular.mjs | 216 ++++++++++++ webdesign/anfrage.html | 425 +++++++++++++++++++++++ 7 files changed, 1242 insertions(+), 2 deletions(-) create mode 100644 assets/js/wd-anfrage.js create mode 100644 server/test-webdesign-formular.mjs create mode 100644 webdesign/anfrage.html diff --git a/assets/css/webdesign.css b/assets/css/webdesign.css index f9de692e..2ed8ef65 100644 --- a/assets/css/webdesign.css +++ b/assets/css/webdesign.css @@ -197,6 +197,25 @@ body.wd::after { } .wd-skip:focus { left: 0; } +/* --------------------------------------------------------------------- + [hidden] MUSS gewinnen. + + Das hidden-Attribut wird vom Browser nur mit "display: none" aus dem + Standard-Stylesheet umgesetzt — jede eigene display-Angabe schlaegt es. + Und genau die haben hier viele Elemente: Knoepfe sind inline-flex, + Karten sind flex, Raster sind grid. + + Folge ohne diese Regel: el.hidden = true im JavaScript hat schlicht + keine Wirkung, und das Element bleibt sichtbar. Genau so aufgefallen im + Anfrageformular (22.08.2026) -- der "Zurueck"-Knopf stand auch auf dem + ersten Schritt da, obwohl es dorthin nichts zurueckzugehen gibt. + + Das !important ist hier ausnahmsweise richtig: "versteckt" ist eine + Aussage ueber den Zustand, nicht ueber die Gestaltung, und darf von + keiner Gestaltungsregel ueberstimmt werden. + --------------------------------------------------------------------- */ +.wd [hidden] { display: none !important; } + .wd-visuell-versteckt { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; diff --git a/assets/js/i18n-wd-anfrage.js b/assets/js/i18n-wd-anfrage.js index b61039ff..65b81f46 100644 --- a/assets/js/i18n-wd-anfrage.js +++ b/assets/js/i18n-wd-anfrage.js @@ -292,6 +292,16 @@ window.I18N_WD = { fr: "Note-le — avec ce numéro, ta demande est retrouvée immédiatement si tu nous relances.", pt: "Aponta-o — com este número o teu pedido é encontrado de imediato se voltares a contactar.", }, + /* Fallback, wenn der Server aus Spamschutzgruenden keine Nummer + vergeben hat. Bewusst ohne Schuldzuweisung und mit zweitem Weg. */ + af_danke_ohne_nummer: { + de: "Falls du innerhalb von zwei Tagen nichts hörst, schreib mir bitte kurz direkt eine E-Mail — dann ist unterwegs etwas verloren gegangen.", + "de-CH": "Falls du innert zwei Täg nüt ghörsch, schriib mer bitte churz e E-Mail — denn isch underwägs öppis verlore gange.", + en: "If you do not hear back within two days, please send me a short e-mail directly — then something got lost along the way.", + fr: "Si tu n'as pas de nouvelles sous deux jours, envoie-moi un court e-mail — c'est que quelque chose s'est perdu en route.", + pt: "Se não tiveres resposta em dois dias, envia-me um e-mail curto — é porque algo se perdeu pelo caminho.", + }, + af_danke_zurueck: { de: "Zurück zur Startseite", "de-CH": "Zrugg zur Startsite", en: "Back to the home page", fr: "Retour à l'accueil", pt: "Voltar ao início" }, /* --- Fehlermeldungen --- */ diff --git a/assets/js/wd-anfrage.js b/assets/js/wd-anfrage.js new file mode 100644 index 00000000..f27ccc9b --- /dev/null +++ b/assets/js/wd-anfrage.js @@ -0,0 +1,540 @@ +/* ===================================================================== + wd-anfrage.js — Steuerung des mehrstufigen Anfrageformulars + (Masterplan S.10 "Projektanfrage ohne Informationsverlust") + + Aufbau: 4 Eingabeschritte + Zusammenfassung + Dankeseite. + + Leitgedanken: + - Nichts geht verloren. Jede Eingabe landet sofort im Browserspeicher; + ein geschlossener Tab, ein leerer Akku oder ein versehentliches + Zurück kostet keine Arbeit. + - Geprüft wird beim Weitergehen, nicht beim Tippen. Wer noch mitten im + Wort ist, soll nicht angemeckert werden. + - Die Zusammenfassung zeigt, was WIRKLICH abgeschickt wird — aus den + Feldern gelesen, nicht aus dem, was das Skript zu wissen glaubt. + ===================================================================== */ +(function () { + "use strict"; + + var SPEICHER = "wd-anfrage-entwurf"; + var LETZTE_SEITE_EINGABE = 4; // danach kommt die Zusammenfassung + var SEITE_ZUSAMMENFASSUNG = 5; + var SEITE_DANKE = 6; + + var form, seiten, zaehler, marken, meldung, knopfLeiste, btnZurueck, btnWeiter; + var aktuelleSeite = 1; + var geoeffnetAm = Date.now(); + + /* ------------------------------------------------------------------ + Werte einsammeln. Bewusst direkt aus dem Formular statt aus einem + nebenher gepflegten Objekt: so kann die Anzeige nie etwas anderes + behaupten als das, was tatsächlich in den Feldern steht. + ------------------------------------------------------------------ */ + function werte() { + var d = new FormData(form); + var funktionen = d.getAll("funktionen"); + + /* Kategoriespezifische Felder tragen das Präfix "d_" und wandern + gesammelt in ein Unterobjekt. Neue Folgefragen brauchen dadurch + weder hier noch in der Datenbank eine Änderung. */ + var details = {}; + form.querySelectorAll("[name^='d_']").forEach(function (el) { + /* Nur Felder mitnehmen, die gerade sichtbar sind — sonst schickt + jemand, der von "Shop" auf "Onepager" zurückwechselt, immer noch + seine alten Shop-Antworten mit. */ + if (el.closest("[hidden]")) return; + var wert = (el.value || "").trim(); + if (wert) details[el.name.slice(2)] = wert; + }); + + var vorhanden = {}; + form.querySelectorAll("[name^='v_']").forEach(function (el) { + if (el.checked) vorhanden[el.name.slice(2)] = el.value; + }); + + return { + paket: d.get("paket") || "", + ziel: (d.get("ziel") || "").trim(), + zielgruppe: (d.get("zielgruppe") || "").trim(), + seiten: (d.get("seiten") || "").trim(), + funktionen: funktionen, + details: details, + hatTexte: vorhanden.texte || "", + hatBilder: vorhanden.bilder || "", + hatLogo: vorhanden.logo || "", + hatDomain: vorhanden.domain || "", + budget: d.get("budget") || "", + termin: (d.get("termin") || "").trim(), + name: (d.get("name") || "").trim(), + email: (d.get("email") || "").trim(), + telefon: (d.get("telefon") || "").trim(), + kontaktweg: d.get("kontaktweg") || "email", + bemerkung: (d.get("bemerkung") || "").trim(), + datenschutz: !!d.get("datenschutz"), + website: (d.get("website") || "").trim(), // Honigtopf + }; + } + + /* ------------------------------------------------------------------ + Zwischenspeicherung (Masterplan S.10: "Zwischenspeicherung verhindert + Datenverlust") + ------------------------------------------------------------------ */ + function speichern() { + try { + var w = werte(); + delete w.website; // Honigtopf nie speichern + sessionStorageSchreiben(JSON.stringify({ stand: w, seite: aktuelleSeite, zeit: Date.now() })); + } catch (e) { /* Speicher voll oder gesperrt — kein Grund abzubrechen */ } + } + + function sessionStorageSchreiben(text) { + /* localStorage, NICHT sessionStorage: der Entwurf soll auch morgen + noch da sein. Das ist der ganze Zweck. */ + try { localStorage.setItem(SPEICHER, text); } catch (e) { /* egal */ } + } + + function gespeichertesLesen() { + try { + var roh = localStorage.getItem(SPEICHER); + if (!roh) return null; + var daten = JSON.parse(roh); + /* Entwürfe verfallen nach 30 Tagen. Ein halbes Jahr alter Entwurf + verwirrt mehr, als er hilft. */ + if (!daten.zeit || Date.now() - daten.zeit > 30 * 24 * 3600 * 1000) { + localStorage.removeItem(SPEICHER); + return null; + } + return daten; + } catch (e) { return null; } + } + + function entwurfLoeschen() { + try { localStorage.removeItem(SPEICHER); } catch (e) { /* egal */ } + } + + function wiederherstellen(daten) { + var w = daten.stand || {}; + function setz(name, wert) { + var el = form.querySelector("[name='" + name + "']"); + if (el && typeof wert === "string") el.value = wert; + } + setz("ziel", w.ziel); setz("zielgruppe", w.zielgruppe); setz("seiten", w.seiten); + setz("termin", w.termin); setz("name", w.name); setz("email", w.email); + setz("telefon", w.telefon); setz("bemerkung", w.bemerkung); + var budget = form.querySelector("[name='budget']"); + if (budget && w.budget) budget.value = w.budget; + + if (w.paket) { + var p = form.querySelector("[name='paket'][value='" + w.paket + "']"); + if (p) p.checked = true; + } + if (w.kontaktweg) { + var k = form.querySelector("[name='kontaktweg'][value='" + w.kontaktweg + "']"); + if (k) k.checked = true; + } + (w.funktionen || []).forEach(function (f) { + var el = form.querySelector("[name='funktionen'][value='" + f + "']"); + if (el) el.checked = true; + }); + Object.keys(w.details || {}).forEach(function (k) { + var el = form.querySelector("[name='d_" + k + "']"); + if (el) el.value = w.details[k]; + }); + [["texte", w.hatTexte], ["bilder", w.hatBilder], ["logo", w.hatLogo], ["domain", w.hatDomain]] + .forEach(function (paar) { + if (!paar[1]) return; + var el = form.querySelector("[name='v_" + paar[0] + "'][value='" + paar[1] + "']"); + if (el) el.checked = true; + }); + if (w.datenschutz) { + var ds = form.querySelector("[name='datenschutz']"); + if (ds) ds.checked = true; + } + } + + /* ------------------------------------------------------------------ + Tabelle "Was hast du schon?" — per JS gebaut, damit die vier Zeilen + nicht viermal fast gleich im HTML stehen. + ------------------------------------------------------------------ */ + function baueVorhandenTabelle() { + var koerper = document.getElementById("af-vorhanden"); + if (!koerper) return; + var zeilen = [ + ["texte", "af3_texte"], + ["bilder", "af3_bilder"], + ["logo", "af3_logo"], + ["domain", "af3_domain"], + ]; + var antworten = [["ja", "af3_ja"], ["teilweise", "af3_teilweise"], ["nein", "af3_nein"]]; + + koerper.innerHTML = zeilen.map(function (z) { + var knoepfe = antworten.map(function (a) { + return '"; + }).join(""); + return '' + + window.WD.t(z[1]) + "" + knoepfe + ""; + }).join(""); + } + + /* ------------------------------------------------------------------ + Prüfung je Schritt + ------------------------------------------------------------------ */ + function fehlerSetzen(feldId, text) { + var feld = document.getElementById(feldId); + if (!feld) return; + feld.classList.toggle("wd-feld--fehler", !!text); + var p = feld.querySelector(".wd-feld-fehler"); + if (p) p.textContent = text || ""; + } + + function fehlerLeeren() { + form.querySelectorAll(".wd-feld--fehler").forEach(function (f) { + f.classList.remove("wd-feld--fehler"); + var p = f.querySelector(".wd-feld-fehler"); + if (p) p.textContent = ""; + }); + } + + /* Dieselbe absichtlich einfache Prüfung wie auf dem Server: Die einzige + zuverlässige Kontrolle ist ohnehin, ob die Antwortmail ankommt. Eine + strenge Regel lehnt vor allem gültige Adressen ab. */ + function istEmail(s) { + var t = String(s || "").trim().toLowerCase(); + var teile = t.split("@"); + return teile.length === 2 && !!teile[0] && teile[1].indexOf(".") > 0 && !/\s/.test(t); + } + + function pruefeSeite(nr) { + fehlerLeeren(); + var w = werte(); + var fehler = []; + + if (nr === 1) { + if (!w.paket) { fehlerSetzen("feld-paket", window.WD.t("af_fehler_paket")); fehler.push("feld-paket"); } + if (!w.ziel) { fehlerSetzen("feld-ziel", window.WD.t("af_fehler_ziel")); fehler.push("feld-ziel"); } + } + if (nr === 4) { + if (!w.name) { fehlerSetzen("feld-name", window.WD.t("af_fehler_name")); fehler.push("feld-name"); } + if (!istEmail(w.email)) { fehlerSetzen("feld-email", window.WD.t("af_fehler_email")); fehler.push("feld-email"); } + /* Wer telefonisch kontaktiert werden möchte, muss eine Nummer + angeben — sonst kann der Wunsch gar nicht erfüllt werden. */ + if (w.kontaktweg === "telefon" && !w.telefon) { + fehlerSetzen("feld-telefon", window.WD.t("af_fehler_telefon")); fehler.push("feld-telefon"); + } + if (!w.datenschutz) { + fehlerSetzen("feld-datenschutz", window.WD.t("af_fehler_datenschutz")); fehler.push("feld-datenschutz"); + } + } + + if (fehler.length) { + window.WD.meldung(meldung, window.WD.t("af_fehler_schritt"), "fehler"); + var erstes = document.getElementById(fehler[0]); + if (erstes) { + erstes.scrollIntoView({ behavior: "smooth", block: "center" }); + var eingabe = erstes.querySelector("input, textarea, select"); + if (eingabe) setTimeout(function () { eingabe.focus({ preventScroll: true }); }, 300); + } + return false; + } + window.WD.meldungAus(meldung); + return true; + } + + /* ------------------------------------------------------------------ + Navigation + ------------------------------------------------------------------ */ + function zeigeSeite(nr) { + aktuelleSeite = nr; + seiten.forEach(function (s) { + s.classList.toggle("af-sichtbar", Number(s.dataset.seite) === nr); + }); + + marken.forEach(function (m) { + var i = Number(m.dataset.marke); + m.classList.toggle("af-aktiv", i === nr); + m.classList.toggle("af-erledigt", i < nr); + }); + + var fortschritt = document.getElementById("af-fortschritt"); + var hinweis = document.getElementById("af-speicherhinweis"); + + if (nr === SEITE_DANKE) { + knopfLeiste.hidden = true; + if (fortschritt) fortschritt.hidden = true; + if (hinweis) hinweis.hidden = true; + } else { + knopfLeiste.hidden = false; + if (fortschritt) fortschritt.hidden = false; + if (hinweis) hinweis.hidden = false; + btnZurueck.hidden = nr === 1; + btnWeiter.textContent = + nr === SEITE_ZUSAMMENFASSUNG ? window.WD.t("af_senden") + : nr === LETZTE_SEITE_EINGABE ? window.WD.t("af_pruefen") + : window.WD.t("af_weiter"); + zaehler.textContent = + window.WD.t("af_schritt") + " " + Math.min(nr, 4) + " " + window.WD.t("af_von") + " 4"; + } + + /* Nach oben, aber nur bis zum Formular — ein Sprung ganz nach oben + zwingt sonst bei jedem Schritt zum Zurückscrollen. */ + var rahmen = document.getElementById("af-rahmen"); + if (rahmen) { + var y = rahmen.getBoundingClientRect().top + window.scrollY - 80; + window.scrollTo({ top: Math.max(0, y), behavior: "smooth" }); + } + + /* Auf der Dankeseite NICHT mehr speichern. + Sonst passiert Folgendes: absenden() loescht den Entwurf, ruft dann + zeigeSeite(DANKE) auf — und diese Zeile legt ihn sofort wieder an. + Beim naechsten Besuch stuenden die Daten einer bereits gesendeten + Anfrage wieder im Formular, samt "Wir haben deine letzte Eingabe + wiederhergestellt". Genau so im Selbsttest aufgefallen. */ + if (nr !== SEITE_DANKE) speichern(); + } + + function folgefragenAktualisieren() { + var w = werte(); + var shop = w.paket === "shop" || w.funktionen.indexOf("shop") >= 0; + var vw = w.paket === "verwaltung" || w.funktionen.indexOf("login") >= 0; + var eShop = document.getElementById("af-folge-shop"); + var eVw = document.getElementById("af-folge-verwaltung"); + if (eShop) eShop.hidden = !shop; + if (eVw) eVw.hidden = !vw; + } + + /* ------------------------------------------------------------------ + Zusammenfassung + ------------------------------------------------------------------ */ + function beschriftung(schluessel, roh) { + if (!roh) return null; + var t = window.WD.t(schluessel); + return t === schluessel ? roh : t; + } + + function baueZusammenfassung() { + var w = werte(); + var ziel = document.getElementById("af-zusammenfassung"); + var sicher = window.WD.sicher; + var leer = window.WD.t("af_zus_leer"); + + function zeile(was, wert) { + var istLeer = !wert; + return '
' + sicher(was) + "" + + '' + + sicher(istLeer ? leer : wert) + "
"; + } + + function block(titel, schrittNr, zeilen) { + return '

' + sicher(titel) + "

" + + '" + + "
" + zeilen.join("") + "
"; + } + + var paketName = { + onepager: "pk_onepager_h3", website: "pk_website_h3", shop: "pk_shop_h3", + verwaltung: "pk_verwaltung_h3", betreuung: "betreuung_h3", unklar: "af1_unklar", + }[w.paket]; + + var funktionsNamen = { + kontaktformular: "af2_f_kontakt", galerie: "af2_f_galerie", termin: "af2_f_termin", + shop: "af2_f_shop", login: "af2_f_login", blog: "af2_f_blog", + sprachen: "af2_f_sprachen", karte: "af2_f_karte", newsletter: "af2_f_newsletter", app: "af2_f_app", + }; + var funktionenText = w.funktionen + .map(function (f) { return beschriftung(funktionsNamen[f], f); }) + .filter(Boolean) + /* HTML-Entities aus dem Wörterbuch (&) in echte Zeichen — + hier ist reiner Text gefragt, er wird gleich escaped. */ + .map(function (s) { return s.replace(/&/g, "&"); }) + .join(", "); + + var antwortNamen = { ja: "af3_ja", teilweise: "af3_teilweise", nein: "af3_nein" }; + var budgetNamen = { + "bis-500": "af3_budget_1", "500-1000": "af3_budget_2", + "1000-2500": "af3_budget_3", "ueber-2500": "af3_budget_4", unklar: "af3_budget_5", + }; + + var html = ""; + html += block(window.WD.t("af_s1"), 1, [ + zeile(window.WD.t("af1_paket_label"), beschriftung(paketName, w.paket)), + zeile(window.WD.t("af1_ziel_label"), w.ziel), + zeile(window.WD.t("af1_zielgruppe_label"), w.zielgruppe), + ]); + var zeilen2 = [ + zeile(window.WD.t("af2_seiten_label"), w.seiten), + zeile(window.WD.t("af2_funktionen_label"), funktionenText), + ]; + Object.keys(w.details).forEach(function (k) { + zeilen2.push(zeile(k.replace(/_/g, " "), w.details[k])); + }); + html += block(window.WD.t("af_s2"), 2, zeilen2); + + html += block(window.WD.t("af_s3"), 3, [ + zeile(window.WD.t("af3_texte"), beschriftung(antwortNamen[w.hatTexte], w.hatTexte)), + zeile(window.WD.t("af3_bilder"), beschriftung(antwortNamen[w.hatBilder], w.hatBilder)), + zeile(window.WD.t("af3_logo"), beschriftung(antwortNamen[w.hatLogo], w.hatLogo)), + zeile(window.WD.t("af3_domain"), beschriftung(antwortNamen[w.hatDomain], w.hatDomain)), + zeile(window.WD.t("af3_budget_label"), beschriftung(budgetNamen[w.budget], w.budget)), + zeile(window.WD.t("af3_termin_label"), w.termin), + ]); + html += block(window.WD.t("af_s4"), 4, [ + zeile(window.WD.t("af4_name"), w.name), + zeile(window.WD.t("af4_email"), w.email), + zeile(window.WD.t("af4_telefon"), w.telefon), + zeile(window.WD.t("af4_weg"), window.WD.t(w.kontaktweg === "telefon" ? "af4_weg_telefon" : "af4_weg_email")), + zeile(window.WD.t("af4_bemerkung"), w.bemerkung), + ]); + + ziel.innerHTML = html; + ziel.querySelectorAll("[data-zu]").forEach(function (b) { + b.addEventListener("click", function () { zeigeSeite(Number(b.dataset.zu)); }); + }); + } + + /* ------------------------------------------------------------------ + Absenden + ------------------------------------------------------------------ */ + async function absenden() { + var w = werte(); + window.WD.knopfSperren(btnWeiter, true, window.WD.t("af_sendet")); + window.WD.meldungAus(meldung); + + try { + var antwort = await window.WD.api("/webdesign/anfrage", { + method: "POST", + body: Object.assign({}, w, { + sprache: window.WD.getSprache(), + geoeffnetAm: geoeffnetAm, + }), + }); + + /* Erst NACH bestätigtem Eingang löschen. Bricht das Absenden ab, + sind die Eingaben noch da und man kann es erneut versuchen. */ + /* Der Server meldet bei verdächtigen Absendungen bewusst Erfolg, + speichert aber nichts und liefert KEINE Nummer (Honigtopf oder + verdächtig kurze Ausfüllzeit — siehe routes/webdesign-anfragen.js). + Das ist gegenüber automatischen Skripten richtig so. + + Für einen echten Menschen, der zufällig sehr schnell war, wäre + eine Dankeseite mit "—" als Nummer aber eine stille Lüge: er + glaubt, die Anfrage sei unterwegs, und wartet auf eine Antwort, + die nie kommt. Deshalb hier ohne Nummer ein ehrlicher Hinweis mit + einem zweiten Weg. */ + var nummerFeld = document.getElementById("af-nummer"); + if (antwort.nummer) { + entwurfLoeschen(); + nummerFeld.textContent = antwort.nummer; + } else { + /* Entwurf ABSICHTLICH behalten — falls wirklich nichts ankam, + soll die Arbeit nicht weg sein. */ + nummerFeld.textContent = "—"; + nummerFeld.style.fontSize = "1rem"; + nummerFeld.style.letterSpacing = "normal"; + nummerFeld.textContent = window.WD.t("af_danke_ohne_nummer"); + } + zeigeSeite(SEITE_DANKE); + } catch (fehler) { + /* Feldbezogene Fehler vom Server anzeigen — der Server ist die + maßgebliche Prüfung, nicht der Browser. */ + var felder = fehler && fehler.daten && fehler.daten.felder; + if (felder) { + Object.keys(felder).forEach(function (f) { fehlerSetzen("feld-" + f, felder[f]); }); + } + window.WD.meldung(meldung, fehler.message || window.WD.t("fehler_allg"), "fehler"); + window.WD.knopfSperren(btnWeiter, false); + } + } + + /* ------------------------------------------------------------------ + Start + ------------------------------------------------------------------ */ + function start() { + form = document.getElementById("af-form"); + if (!form) return; + seiten = Array.prototype.slice.call(form.querySelectorAll(".af-seite")); + marken = Array.prototype.slice.call(document.querySelectorAll(".af-schritt-marke")); + zaehler = document.getElementById("af-zaehler"); + meldung = document.getElementById("af-meldung"); + knopfLeiste = document.getElementById("af-knopfleiste"); + btnZurueck = document.getElementById("af-zurueck"); + btnWeiter = document.getElementById("af-weiter"); + + baueVorhandenTabelle(); + + /* Paket aus der Adresszeile vorauswählen — die Leistungsseite + verlinkt mit ?paket=shop. Spart einen Klick und zeigt, dass der + Weg mitgedacht ist. */ + var params = new URLSearchParams(location.search); + var paket = params.get("paket"); + if (paket) { + var el = form.querySelector("[name='paket'][value='" + paket.replace(/[^a-z]/g, "") + "']"); + if (el) el.checked = true; + } + + var entwurf = gespeichertesLesen(); + if (entwurf) { + wiederherstellen(entwurf); + var box = document.getElementById("af-wieder"); + if (box) box.hidden = false; + } + + document.getElementById("af-verwerfen").addEventListener("click", function () { + entwurfLoeschen(); + location.reload(); + }); + + folgefragenAktualisieren(); + + btnWeiter.addEventListener("click", function () { + if (aktuelleSeite === SEITE_ZUSAMMENFASSUNG) return absenden(); + if (!pruefeSeite(aktuelleSeite)) return; + if (aktuelleSeite === LETZTE_SEITE_EINGABE) { + baueZusammenfassung(); + zeigeSeite(SEITE_ZUSAMMENFASSUNG); + return; + } + zeigeSeite(aktuelleSeite + 1); + }); + + btnZurueck.addEventListener("click", function () { + if (aktuelleSeite > 1) zeigeSeite(aktuelleSeite - 1); + }); + + /* Eingaben laufend sichern und Folgefragen nachziehen. "input" statt + "change", damit auch beim Tippen gespeichert wird. */ + form.addEventListener("input", function () { folgefragenAktualisieren(); speichern(); }); + form.addEventListener("change", function () { folgefragenAktualisieren(); speichern(); }); + + /* Enter im Formular soll weitergehen, nicht absenden — sonst ist die + Anfrage nach dem ersten Feld unterwegs. */ + form.addEventListener("submit", function (e) { e.preventDefault(); btnWeiter.click(); }); + + /* Telefon wird bei "Per Telefon" zur Pflicht — das Wörtchen + "optional" muss dann verschwinden, sonst ist die spätere + Fehlermeldung unfair. */ + form.querySelectorAll("[name='kontaktweg']").forEach(function (r) { + r.addEventListener("change", function () { + var opt = document.getElementById("af-telefon-opt"); + if (opt) opt.hidden = r.value === "telefon" && r.checked; + }); + }); + + /* Nach einem Sprachwechsel Knopfbeschriftung, Zähler und die per JS + gebauten Teile neu setzen. */ + document.addEventListener("wd-sprache-geaendert", function () { + baueVorhandenTabelle(); + wiederherstellen({ stand: werte() }); // Auswahl der Tabelle retten + if (aktuelleSeite === SEITE_ZUSAMMENFASSUNG) baueZusammenfassung(); + zeigeSeite(aktuelleSeite); + }); + + zeigeSeite(entwurf && entwurf.seite && entwurf.seite <= LETZTE_SEITE_EINGABE ? entwurf.seite : 1); + } + + if (window.WD) start(); + else document.addEventListener("wd-bereit", start); +})(); diff --git a/server/pruefe-webdesign-handy.mjs b/server/pruefe-webdesign-handy.mjs index 88e4e159..adf9fa4c 100644 --- a/server/pruefe-webdesign-handy.mjs +++ b/server/pruefe-webdesign-handy.mjs @@ -27,6 +27,7 @@ const SEITEN = [ "/webdesign/ablauf.html", "/webdesign/ueber.html", "/webdesign/faq.html", + "/webdesign/anfrage.html", ]; const BREITEN = [320, 390, 430, 768, 1440]; const SPRACHEN = ["de", "de-CH", "en", "fr", "pt"]; @@ -88,6 +89,22 @@ for (const pfad of SEITEN) { if (s.display === "none" || s.visibility === "hidden") continue; const r = el.getBoundingClientRect(); if (r.height === 0 || r.width === 0) continue; + + /* Absichtlich unsichtbare Eingabefelder ueberspringen. + Bei Auswahlkacheln (.wd-wahl-kachel) liegt das echte Ankreuzfeld + unsichtbar hinter der Kachel -- angetippt wird die KACHEL, und + die ist gross genug. Ebenso der Honigtopf im Anfrageformular, + der fuer Menschen gar nicht existieren soll. + Ohne diese Ausnahme meldet die Pruefung fuenf Fehler, die keine + sind -- und Fehlalarme sind das Ende jeder Pruefung, weil man + sie irgendwann pauschal ignoriert. */ + if (el.tagName === "INPUT") { + if (parseFloat(s.opacity) === 0) continue; // unsichtbar hinter der Kachel + if (r.right < 0 || r.left > vp) continue; // aus dem Bild geschoben + const label = el.closest("label"); + if (label && label.getBoundingClientRect().height >= 44) continue; // Label ist das Ziel + } + if (r.height < 44) { klein.push((el.tagName.toLowerCase() + " „" + (el.textContent || "").trim().slice(0, 24) + "“ ") + Math.round(r.height) + "px"); diff --git a/server/pruefe-webdesign-i18n.mjs b/server/pruefe-webdesign-i18n.mjs index 6ab61781..d8b2c238 100644 --- a/server/pruefe-webdesign-i18n.mjs +++ b/server/pruefe-webdesign-i18n.mjs @@ -58,8 +58,21 @@ for (const datei of seiten) { } const titelKey = (html.match(/data-i18n-titel="([^"]+)"/) || [])[1]; if (titelKey) benutzt.add(titelKey); - // Schlüssel, die nur im Seitenskript per WD.t(...) geholt werden - for (const m of html.matchAll(/WD\.t\("([a-z0-9_]+)"\)/g)) benutzt.add(m[1]); + // Schlüssel, die nur per WD.t(...) geholt werden — im Seitenskript + // unten in der HTML-Datei UND in einer zugehörigen assets/js/wd-.js. + // Ohne den zweiten Teil meldet die Prüfung reihenweise "unbenutzt" für + // Schlüssel, die sehr wohl benutzt werden — nur eben aus einer anderen + // Datei (aufgefallen beim Anfrageformular, 22.08.2026). + const quellen = [html]; + const seitenSkript = `assets/js/wd-${name}.js`; + if (existsSync(seitenSkript)) quellen.push(readFileSync(seitenSkript, "utf8")); + for (const quelle of quellen) { + for (const m of quelle.matchAll(/WD\.t\("([a-z0-9_]+)"\)/g)) benutzt.add(m[1]); + // Schlüssel aus Nachschlagetabellen, z.B. { ja: "af3_ja", ... } + for (const m of quelle.matchAll(/["']((?:af|pk|zg|pf|ue|fq|ab|ls|pr|s\d)[a-z0-9_]*)["']\s*[,}\]]/g)) { + benutzt.add(m[1]); + } + } const fehlend = []; const unvollstaendig = []; diff --git a/server/test-webdesign-formular.mjs b/server/test-webdesign-formular.mjs new file mode 100644 index 00000000..b2e58511 --- /dev/null +++ b/server/test-webdesign-formular.mjs @@ -0,0 +1,216 @@ +/* ===================================================================== + test-webdesign-formular.mjs — Anfrageformular im echten Browser + + Spielt das Formular so durch, wie ein Mensch es tut, und prüft die + Dinge, die man beim Klicken NICHT sieht: + - Pflichtfelder blockieren das Weitergehen + - Folgefragen erscheinen nur passend zur Auswahl + - Eingaben überleben das Schließen des Tabs + - die Zusammenfassung zeigt wirklich das Eingegebene + - Absenden erzeugt eine echte Anfragenummer + + Standardmäßig wird NICHT abgeschickt (sonst entsteht bei jedem Lauf ein + Datensatz in der echten Datenbank). Mit --senden wird einmal wirklich + abgeschickt: + node test-webdesign-formular.mjs --senden + ===================================================================== */ +import { chromium } from "playwright"; + +const BASIS = "http://127.0.0.1:4199"; +const CODE = "DOGI-TEST-1"; +const WIRKLICH_SENDEN = process.argv.includes("--senden"); + +let ok = 0, schlecht = 0; +const pruefe = (name, b, zusatz = "") => { + if (b) { ok++; console.log(" ok " + name); } + else { schlecht++; console.log(" FEHLT " + name + " " + zusatz); } +}; + +const browser = await chromium.launch(); +const kontext = await browser.newContext(); +const seite = await kontext.newPage(); +const konsole = []; +seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); }); +seite.on("pageerror", (e) => konsole.push("pageerror: " + e.message)); + +async function anmelden(p) { + await p.goto(BASIS + "/webdesign/zugang.html", { waitUntil: "networkidle" }); + await p.fill("#zg-code-dogfather", CODE); + await Promise.all([ + p.waitForURL((u) => !u.pathname.endsWith("zugang.html"), { timeout: 15000 }), + p.click("#zg-form-dogfather button[type=submit]"), + ]); +} + +console.log("\n=== Anfrageformular im Browser ===\n"); + +await anmelden(seite); + +/* ---------- 1. Aufbau ---------- */ +console.log("1. Aufbau"); +await seite.goto(BASIS + "/webdesign/anfrage.html", { waitUntil: "networkidle" }); +await seite.waitForTimeout(400); +pruefe("Schritt 1 ist sichtbar", await seite.locator('[data-seite="1"]').isVisible()); +pruefe("Schritt 2 ist verborgen", !(await seite.locator('[data-seite="2"]').isVisible())); +pruefe("Zaehler zeigt Schritt 1 von 4", (await seite.locator("#af-zaehler").textContent()).includes("1")); +pruefe("Zurueck ist auf Schritt 1 ausgeblendet", !(await seite.locator("#af-zurueck").isVisible())); +pruefe("Tabelle 'Was hast du schon' wurde gebaut", + (await seite.locator("#af-vorhanden tr").count()) === 4); + +/* ---------- 2. Pflichtfelder ---------- */ +console.log("\n2. Pflichtfelder blockieren"); +await seite.click("#af-weiter"); +await seite.waitForTimeout(400); +pruefe("bleibt auf Schritt 1", await seite.locator('[data-seite="1"]').isVisible()); +pruefe("Paket wird bemaengelt", await seite.locator("#feld-paket.wd-feld--fehler").count() > 0); +pruefe("Ziel wird bemaengelt", await seite.locator("#feld-ziel.wd-feld--fehler").count() > 0); +pruefe("Sammelmeldung erscheint", await seite.locator("#af-meldung").isVisible()); + +/* ---------- 3. Vorauswahl per Adresszeile ---------- */ +console.log("\n3. Vorauswahl ueber ?paket="); +await seite.goto(BASIS + "/webdesign/anfrage.html?paket=shop", { waitUntil: "networkidle" }); +await seite.waitForTimeout(400); +pruefe("Paket 'shop' ist vorausgewaehlt", + await seite.locator('[name="paket"][value="shop"]').isChecked()); + +/* ---------- 4. Durchlauf ---------- */ +console.log("\n4. Durchlauf"); +await seite.fill("#af-ziel", "Wir wollen unsere Haekelwerke endlich online verkaufen."); +await seite.fill("#af-zielgruppe", "Bastelfans, 25-55 Jahre"); +await seite.click("#af-weiter"); +await seite.waitForTimeout(500); +pruefe("Schritt 2 erreicht", await seite.locator('[data-seite="2"]').isVisible()); +pruefe("Shop-Folgefragen erscheinen bei Paket 'shop'", + await seite.locator("#af-folge-shop").isVisible(), + "-> Masterplan S.10 'Intelligente Felder'"); +pruefe("Verwaltungs-Folgefragen bleiben verborgen", + !(await seite.locator("#af-folge-verwaltung").isVisible())); + +await seite.fill("#af-shop-anzahl", "40"); +await seite.check('[name="funktionen"][value="galerie"]'); +await seite.check('[name="funktionen"][value="login"]'); +await seite.waitForTimeout(300); +pruefe("Verwaltungs-Folgefragen erscheinen nach 'Kundenbereich'", + await seite.locator("#af-folge-verwaltung").isVisible()); + +await seite.click("#af-weiter"); +await seite.waitForTimeout(500); +pruefe("Schritt 3 erreicht", await seite.locator('[data-seite="3"]').isVisible()); +await seite.check('[name="v_texte"][value="teilweise"]'); +await seite.check('[name="v_bilder"][value="ja"]'); +await seite.check('[name="v_logo"][value="nein"]'); +await seite.check('[name="v_domain"][value="ja"]'); +await seite.selectOption("#af-budget", "1000-2500"); +await seite.fill("#af-termin", "moeglichst im Oktober"); + +await seite.click("#af-weiter"); +await seite.waitForTimeout(500); +pruefe("Schritt 4 erreicht", await seite.locator('[data-seite="4"]').isVisible()); +pruefe("Knopf heisst jetzt 'Angaben pruefen'", + /pr[üu]fen/i.test(await seite.locator("#af-weiter").textContent())); + +/* ---------- 5. Telefonpflicht ---------- */ +console.log("\n5. Telefon wird Pflicht bei 'Per Telefon'"); +await seite.fill("#af-name", "Testfirma Meier"); +await seite.fill("#af-email", "meier@example.com"); +await seite.check('[name="kontaktweg"][value="telefon"]'); +await seite.check("#af-datenschutz"); +await seite.click("#af-weiter"); +await seite.waitForTimeout(400); +pruefe("ohne Nummer wird blockiert", await seite.locator("#feld-telefon.wd-feld--fehler").count() > 0, + "-> sonst ein Wunsch, der gar nicht erfuellbar ist"); +await seite.check('[name="kontaktweg"][value="email"]'); + +/* ---------- 6. Datenschutz ---------- */ +await seite.uncheck("#af-datenschutz"); +await seite.click("#af-weiter"); +await seite.waitForTimeout(400); +pruefe("ohne Datenschutz-Zustimmung wird blockiert", + await seite.locator("#feld-datenschutz.wd-feld--fehler").count() > 0); +await seite.check("#af-datenschutz"); +await seite.fill("#af-bemerkung", "Bitte moeglichst bald melden."); + +/* ---------- 7. Zusammenfassung ---------- */ +console.log("\n6. Zusammenfassung"); +await seite.click("#af-weiter"); +await seite.waitForTimeout(600); +pruefe("Zusammenfassung erreicht", await seite.locator('[data-seite="5"]').isVisible()); +const zusText = await seite.locator("#af-zusammenfassung").textContent(); +pruefe("zeigt das eingegebene Ziel", zusText.includes("Haekelwerke")); +pruefe("zeigt Name und E-Mail", zusText.includes("Testfirma Meier") && zusText.includes("meier@example.com")); +pruefe("zeigt angekreuzte Funktionen", /Galerie|galerie/i.test(zusText)); +pruefe("zeigt die Shop-Folgefrage", zusText.includes("40")); +pruefe("leere Felder als 'keine Angabe'", /keine Angabe/i.test(zusText)); +pruefe("Knopf heisst jetzt 'Anfrage absenden'", + /absenden/i.test(await seite.locator("#af-weiter").textContent())); +pruefe("jeder Block hat einen Aendern-Knopf", + (await seite.locator("#af-zusammenfassung [data-zu]").count()) === 4); + +await seite.locator('[data-zu="1"]').click(); +await seite.waitForTimeout(400); +pruefe("'Aendern' springt zum richtigen Schritt", await seite.locator('[data-seite="1"]').isVisible()); +pruefe("Eingaben sind dabei erhalten", + (await seite.inputValue("#af-ziel")).includes("Haekelwerke")); + +/* ---------- 8. Zwischenspeicherung ---------- */ +console.log("\n7. Zwischenspeicherung"); +const neuerTab = await kontext.newPage(); +await anmelden(neuerTab); +await neuerTab.goto(BASIS + "/webdesign/anfrage.html", { waitUntil: "networkidle" }); +await neuerTab.waitForTimeout(600); +pruefe("Hinweis auf wiederhergestellte Eingaben erscheint", + await neuerTab.locator("#af-wieder").isVisible()); +pruefe("Ziel wurde wiederhergestellt", + (await neuerTab.inputValue("#af-ziel")).includes("Haekelwerke")); +pruefe("Name wurde wiederhergestellt", + (await neuerTab.inputValue("#af-name")) === "Testfirma Meier"); +pruefe("Paketwahl wurde wiederhergestellt", + await neuerTab.locator('[name="paket"][value="shop"]').isChecked()); +await neuerTab.click("#af-verwerfen"); +await neuerTab.waitForTimeout(600); +pruefe("'Neu anfangen' leert das Formular", + (await neuerTab.inputValue("#af-ziel")) === ""); +await neuerTab.close(); + +/* ---------- 9. Absenden ---------- */ +console.log("\n8. Absenden"); +if (!WIRKLICH_SENDEN) { + console.log(" (uebersprungen -- mit --senden wird wirklich abgeschickt)"); +} else { + await seite.goto(BASIS + "/webdesign/anfrage.html", { waitUntil: "networkidle" }); + /* Der Server weist Absendungen unter 3 Sekunden als vermutlich + automatisch ab (Spamschutz, siehe routes/webdesign-anfragen.js). + Ein Testlauf ist naturgemaess schneller als ein Mensch -- ohne diese + Wartezeit prueft der Test versehentlich den Spamschutz statt des + Formulars. Genau so beim ersten Lauf passiert. */ + await seite.waitForTimeout(3500); + await seite.check('[name="paket"][value="onepager"]'); + await seite.fill("#af-ziel", "TESTANFRAGE aus dem Selbsttest - bitte archivieren."); + await seite.click("#af-weiter"); await seite.waitForTimeout(400); + await seite.click("#af-weiter"); await seite.waitForTimeout(400); + await seite.click("#af-weiter"); await seite.waitForTimeout(400); + await seite.fill("#af-name", "Selbsttest"); + await seite.fill("#af-email", "selbsttest@example.com"); + await seite.check("#af-datenschutz"); + await seite.click("#af-weiter"); await seite.waitForTimeout(600); + await seite.click("#af-weiter"); + await seite.waitForTimeout(6000); + + const aufDanke = await seite.locator('[data-seite="6"]').isVisible(); + pruefe("Dankeseite erscheint", aufDanke, + aufDanke ? "" : "Meldung: " + (await seite.locator("#af-meldung").textContent().catch(() => ""))); + if (aufDanke) { + const nummer = (await seite.locator("#af-nummer").textContent()).trim(); + pruefe("echte Anfragenummer im Format A-JJMM-0001", /^A-\d{4}-\d{4}$/.test(nummer), nummer); + console.log(" -> Nummer: " + nummer); + pruefe("Entwurf wurde nach Erfolg geloescht", + (await seite.evaluate(() => localStorage.getItem("wd-anfrage-entwurf"))) === null); + pruefe("Schrittanzeige ist auf der Dankeseite weg", + !(await seite.locator("#af-fortschritt").isVisible())); + } +} + +await browser.close(); +console.log(konsole.length ? "\nKONSOLENFEHLER:\n" + [...new Set(konsole)].join("\n") : "\nKeine Konsolenfehler."); +console.log(`\n=== ${ok} bestanden, ${schlecht} gescheitert ===\n`); +process.exit(schlecht === 0 && konsole.length === 0 ? 0 : 1); diff --git a/webdesign/anfrage.html b/webdesign/anfrage.html new file mode 100644 index 00000000..7c1c9ca6 --- /dev/null +++ b/webdesign/anfrage.html @@ -0,0 +1,425 @@ + + + + + +Projekt anfragen — Dogfather Webdesign + + + + + + + + + + + + + + + +
+ +
+
+
+ Projektanfrage +

Erzähl mir von deinem Vorhaben

+

Vier kurze Schritte, etwa fünf Minuten. Unverbindlich, kostenlos — und du siehst am Ende alles noch einmal, bevor du absendest.

+
+
+ +
+
+ + + + +
+ + +
+
+
+
+
Vorhaben
+
+
+
+
Umfang
+
+
+
+
Inhalte & Rahmen
+
+
+
+
Kontakt
+
+
+

Schritt 1 von 4

+
+ + + +
+ + + + + +
+

Worum geht es ungefähr?

+ +
+ Art des Projekts + Nur eine grobe Richtung — wenn du unsicher bist, wähle das Letzte. +
+ + + + + + +
+

+
+ +
+ + Ein, zwei Sätze reichen. + +

+
+ +
+ + +
+
+ + +
+

Wie groß soll es werden?

+ +
+ + +
+ +
+ Was soll die Seite können? + Alles, was du hier ankreuzt, wird im Angebot berücksichtigt. Nichts davon ist Pflicht. +
+ + + + + + + + + + +
+
+ + + + + +
+ + +
+

Was hast du schon, und was ist der Rahmen?

+

Bitte ehrlich antworten — fehlende Texte und Bilder sind der häufigste Grund, warum Projekte länger dauern.

+ +
+ + +
+
+ +
+ + Keine Bindung — die Angabe hilft nur, dir kein Angebot zu schreiben, das an deinem Rahmen vorbeigeht. + +
+ +
+ + Die Zeitrahmen auf der Preisseite gelten ab Eingang der Anzahlung, nicht ab heute. + +
+
+ + +
+

Wie erreiche ich dich?

+ +
+ + +

+
+ +
+ + +

+
+ +
+ Wie darf ich mich melden? +
+ + +
+
+ +
+ + +

+
+ +
+ + Auch Fragen an mich gehören hier hinein. + +
+ +
+ +

+

+ Datenschutz nachlesen +

+
+
+ + +
+

Passt alles so?

+

Das schickst du ab. Du kannst jeden Punkt noch ändern.

+
+
+

Das ist noch kein Auftrag

+

Mit dem Absenden entsteht kein Vertrag und keine Zahlungspflicht. Du bekommst zuerst ein schriftliches Angebot und entscheidest dann in Ruhe.

+
+
+ + +
+
+ +

Angekommen!

+

Deine Anfrage liegt bei mir. Ich melde mich innerhalb weniger Tage.

+

Deine Anfragenummer

+

—

+

Notier sie dir — mit dieser Nummer findest du deine Anfrage sofort wieder.

+ +
+
+ +
+ + +
+
+
+ +

Deine Eingaben werden automatisch in diesem Browser gespeichert.

+
+
+
+ +
+ + + + + +