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.

+
+
+
+ +
+ + + + + +