Ein Gesamtdurchlauf aller Suiten nach dem heutigen Tag hat vier Fehlschlaege in pruef-handy.mjs gezeigt -- eine Regression aus meiner eigenen Arbeit. DIE URSACHE Die Karte zum Zustand der Benachrichtigung stand als ausgewachsener Block ganz oben in der Uebersicht. Auf dem Handy schob sie die erste Kennzahl auf 513 Punkte nach unten: Man oeffnete die Verwaltung und sah zuerst einen Hinweis, die Arbeit erst nach dem Scrollen. ZWEI ANLAEUFE, WEIL DER ERSTE ZU KURZ SPRANG Zuerst wurde nur der GUTE Fall zu einer Zeile. Das half nichts -- im Pruefbrowser sind Benachrichtigungen blockiert, also griff weiterhin der Zweig mit der grossen Karte. Eine Aenderung, die nur den Fall behebt, den man gerade vor Augen hat, ist keine. Jetzt sind alle drei Zustaende eine Zeile mit Punkt und Kurztext, die Erklaerung erst beim Aufklappen. Und der gute Zustand steht am ENDE der Uebersicht statt oben: Eine Bestaetigung, dass nichts zu tun ist, gehoert nicht an den Anfang. Oben bleibt nur, was Handlung verlangt. Ergebnis: 513 -> 204 Punkte bis zum Ende des Kopfbereichs, Laenge 2635 -> 2028. DREI RUNDUNGSFAELLE .wd-btn--klein und zwei summary-Elemente kamen mit Rahmen und Zeilenhoehe auf 43,x Punkte. Der Test vergleicht mit "< 44", gibt aber gerundet "44" aus -- man sucht dann einen Fehler in einer Zahl, die richtig aussieht. Jetzt 44.5 bzw. 46; optisch aendert das nichts. ZWEI PRUEFUNGEN PRAEZISIERT, NICHT AUFGEWEICHT 1. "Erste Zahl im oberen Drittel" mass ab dem Seitenanfang und schlug damit auch bei einer BERECHTIGTEN Warnung an. Ein Test, der Warnungen als Mangel zaehlt, erzieht dazu, sie zu verstecken. Er misst jetzt den festen Kopfbereich (Titel, Suche, Reiter) -- also das, was man nicht wegbekommt. 2. Die Folgepruefung mass zunaechst Punkte-Abstaende. Das war fragil: Der Reiterstreifen klebt beim Scrollen oben fest, eine Messung lieferte -204. Sie zaehlt jetzt HINWEISBLOECKE statt Punkte -- unabhaengig von Scrollposition und Schriftgroesse. Gemeint war ohnehin "hoechstens eine Meldung", nicht "hoechstens 140px". 56/56 in pruef-handy, alle uebrigen Suiten unveraendert gruen. Co-Authored-By: Claude Opus 5 <[email protected]>
1133 lines
56 KiB
JavaScript
1133 lines
56 KiB
JavaScript
/* =====================================================================
|
||
DOGFATHER WEBDESIGN — wd-core.js
|
||
Gemeinsamer Unterbau für ALLE Seiten unter /webdesign/:
|
||
Sprachumschaltung (5 Sprachen), Kopf- und Fußzeile, Einblenden beim
|
||
Scrollen, kleine Helfer für Formulare und API-Aufrufe.
|
||
|
||
Warum ein eigener Kern statt assets/js/main.js:
|
||
1. main.js baut seine Kopfzeile mit RELATIVEN Adressen ("index.html",
|
||
"assets/img/..."). Aus einem Unterordner wie /webdesign/ zeigen die
|
||
auf /webdesign/index.html bzw. /webdesign/assets/... — alles kaputt.
|
||
Nachträglich auf absolute Pfade umzustellen würde die 40 bestehenden
|
||
Universe-Seiten anfassen; das Risiko steht in keinem Verhältnis.
|
||
2. main.js enthält eine nahtlose Seitennavigation (Austausch des
|
||
<main>-Inhalts per fetch), Musik-Widget, Vorschau-Modus und die
|
||
Universe-Navigation. Nichts davon gehört in einen geschäftlichen
|
||
Webdesign-Bereich, der bis zur Freigabe nicht öffentlich ist.
|
||
3. Getrennte Datei = der Bereich kann die laufende Live-Seite nicht
|
||
beschädigen.
|
||
|
||
Was BEWUSST geteilt wird, damit es sich wie eine Marke anfühlt:
|
||
- derselbe localStorage-Schlüssel "dogi-sprache" → wer im Universe auf
|
||
Französisch stellt, landet hier auf Französisch und umgekehrt;
|
||
- dieselbe Sprachliste inkl. Farben;
|
||
- dieselben Grundtokens aus main.css.
|
||
===================================================================== */
|
||
(function () {
|
||
"use strict";
|
||
|
||
/* ===================================================================
|
||
1. SPRACHEN
|
||
Identisch zur Liste in main.js (Code, Kürzel, Name, Leuchtfarbe) —
|
||
bewusst dupliziert statt importiert: main.js kapselt alles in einer
|
||
IIFE ohne Export, und ein <script src="main.js"> nur wegen dieser
|
||
Liste zu laden würde die komplette Universe-Navigation mitschleppen.
|
||
Ändert sich die Liste dort, muss sie hier mitgeändert werden — dieser
|
||
Hinweis steht deshalb bewusst so deutlich hier.
|
||
=================================================================== */
|
||
var SPRACHEN = [
|
||
{ code: "de", kurz: "DE", name: "Deutsch", glut: "#ffb300" },
|
||
{ code: "de-CH", kurz: "CH", name: "Schwiizerdütsch", glut: "#ff3b3b" },
|
||
{ code: "en", kurz: "EN", name: "English", glut: "#2f6bff" },
|
||
{ code: "fr", kurz: "FR", name: "Français", glut: "#00c2ff" },
|
||
{ code: "pt", kurz: "PT", name: "Português", glut: "#20d97a" },
|
||
];
|
||
var SPRACHE_SCHLUESSEL = "dogi-sprache";
|
||
|
||
function getSprache() {
|
||
var gespeichert = null;
|
||
/* localStorage kann werfen (Privatmodus in älteren Safari-Versionen,
|
||
gesperrte Cookies von Drittanbietern im iframe). Ein Absturz hier
|
||
würde die ganze Seite ohne Kopfzeile lassen — deshalb abgesichert. */
|
||
try { gespeichert = localStorage.getItem(SPRACHE_SCHLUESSEL); } catch (e) { /* egal */ }
|
||
for (var i = 0; i < SPRACHEN.length; i++) {
|
||
if (SPRACHEN[i].code === gespeichert) return gespeichert;
|
||
}
|
||
return "de"; // nie automatische Browsersprache — gleiche Regel wie im Universe
|
||
}
|
||
|
||
function setSprache(code) {
|
||
var gueltig = SPRACHEN.some(function (s) { return s.code === code; });
|
||
if (!gueltig) return;
|
||
try { localStorage.setItem(SPRACHE_SCHLUESSEL, code); } catch (e) { /* egal */ }
|
||
document.documentElement.setAttribute("lang", code);
|
||
uebersetzeSeite();
|
||
zeichneKopf();
|
||
zeichneFuss();
|
||
/* Seiteneigene Skripte (Formular-Zusammenfassung, Portal-Tabellen …)
|
||
setzen Text per JS und können hier nachziehen. */
|
||
document.dispatchEvent(new CustomEvent("wd-sprache-geaendert", { detail: { sprache: code } }));
|
||
}
|
||
|
||
/* ===================================================================
|
||
2. WÖRTERBUCH FÜR KOPF-/FUSSZEILE UND GETEILTE BAUSTEINE
|
||
Seiteneigene Texte kommen zusätzlich aus window.I18N_WD
|
||
(assets/js/i18n-wd-<seite>.js).
|
||
|
||
Schweizerdeutsch ist echter Dialekt, keine ß→ss-Umschrift:
|
||
"Leistungen" → "Was mir mache", "Preise & Zeit" → "Priis & Ziit",
|
||
"Häufige Fragen" → "Hüüfigi Frage".
|
||
=================================================================== */
|
||
var I18N_WD_CORE = {
|
||
/* --- Navigation --- */
|
||
nav_start: { de: "Start", "de-CH": "Start", en: "Home", fr: "Accueil", pt: "Início" },
|
||
nav_leistungen: { de: "Leistungen", "de-CH": "Was mir mache", en: "Services", fr: "Prestations", pt: "Serviços" },
|
||
nav_preise: { de: "Preise & Zeit", "de-CH": "Priis & Ziit", en: "Pricing & time", fr: "Tarifs & délais", pt: "Preços & prazos" },
|
||
nav_portfolio: { de: "Portfolio", "de-CH": "Portfolio", en: "Portfolio", fr: "Portfolio", pt: "Portfólio" },
|
||
nav_ablauf: { de: "Ablauf", "de-CH": "So laufts", en: "Process", fr: "Déroulement", pt: "Processo" },
|
||
nav_ueber: { de: "Über Dogfather", "de-CH": "Über Dogfather", en: "About", fr: "À propos", pt: "Sobre" },
|
||
nav_faq: { de: "Fragen", "de-CH": "Frage", en: "FAQ", fr: "FAQ", pt: "Perguntas" },
|
||
nav_anfrage: { de: "Projekt anfragen", "de-CH": "Projekt aafroge", en: "Start a project", fr: "Demander un projet", pt: "Pedir projeto" },
|
||
nav_menue: { de: "Menü", "de-CH": "Menü", en: "Menu", fr: "Menu", pt: "Menu" },
|
||
nav_menue_aria: { de: "Navigation öffnen und schließen", "de-CH": "Navigation uf- und zuemache", en: "Open and close navigation", fr: "Ouvrir et fermer la navigation", pt: "Abrir e fechar a navegação" },
|
||
nav_sprache_aria: { de: "Sprache wählen", "de-CH": "Sprooch uswähle", en: "Choose language", fr: "Choisir la langue", pt: "Escolher idioma" },
|
||
nav_zum_inhalt: { de: "Direkt zum Inhalt", "de-CH": "Direkt zum Inhalt", en: "Skip to content", fr: "Aller au contenu", pt: "Ir para o conteúdo" },
|
||
|
||
/* --- Marke --- */
|
||
marke_oben: { de: "DOGFATHER", "de-CH": "DOGFATHER", en: "DOGFATHER", fr: "DOGFATHER", pt: "DOGFATHER" },
|
||
marke_unten: { de: "Webdesign", "de-CH": "Webdesign", en: "Web design", fr: "Web design", pt: "Web design" },
|
||
marke_aria: { de: "Dogfather Webdesign — zur Startseite", "de-CH": "Dogfather Webdesign — zur Startsite", en: "Dogfather Web Design — to the home page", fr: "Dogfather Web Design — vers l'accueil", pt: "Dogfather Web Design — para a página inicial" },
|
||
|
||
/* --- Fußzeile --- */
|
||
fuss_claim: {
|
||
de: "Individuelle Webseiten, persönlich umgesetzt — vom ersten Gespräch bis zur fertigen Seite bei einer Ansprechperson.",
|
||
"de-CH": "Eigeti Websites, persönlich gmacht — vom erste Gspröch bis zur fertige Site bi einere Aaspröchperson.",
|
||
en: "Individual websites, built personally — from the first conversation to the finished site with one single contact.",
|
||
fr: "Des sites sur mesure, réalisés personnellement — du premier échange au site fini, avec un seul interlocuteur.",
|
||
pt: "Sites individuais, feitos pessoalmente — da primeira conversa ao site pronto, com uma única pessoa de contacto.",
|
||
},
|
||
fuss_bereiche: { de: "Bereiche", "de-CH": "Bereich", en: "Sections", fr: "Sections", pt: "Secções" },
|
||
fuss_zugang: { de: "Zugang", "de-CH": "Zuegang", en: "Access", fr: "Accès", pt: "Acesso" },
|
||
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)
|
||
plus E-Mail als niedrigschwellige Alternative. Bewusst KEINE
|
||
Telefonnummer und kein Messenger: beides erzeugt unstrukturierte
|
||
Nachrichten, die im Verwaltungsbereich nicht auftauchen. */
|
||
fuss_email: { de: "E-Mail schreiben", "de-CH": "E-Mail schriibe", en: "Send an e-mail", fr: "Écrire un e-mail", pt: "Enviar e-mail" },
|
||
fuss_rechte: { de: "Alle Rechte vorbehalten.", "de-CH": "Alli Rächt vorbhalte.", en: "All rights reserved.", fr: "Tous droits réservés.", pt: "Todos os direitos reservados." },
|
||
fuss_status: {
|
||
de: "Vorabversion — nicht öffentlich",
|
||
"de-CH": "Vorabversion — nöd öffentlich",
|
||
en: "Preview version — not public",
|
||
fr: "Version préliminaire — non publique",
|
||
pt: "Versão prévia — não pública",
|
||
},
|
||
|
||
/* --- Wiederkehrende Bausteine --- */
|
||
ab_preis: { de: "ab", "de-CH": "ab", en: "from", fr: "à partir de", pt: "a partir de" },
|
||
dauer: { de: "Dauer", "de-CH": "Duur", en: "Duration", fr: "Durée", pt: "Duração" },
|
||
|
||
/* ---------------------------------------------------------------
|
||
PREISE UND ZEITRAHMEN — EINZIGE QUELLE DER WAHRHEIT
|
||
|
||
Masterplan S.16, Definition of Done: "Preise, Dauer, 30 %
|
||
Anzahlung und Personalisierung sind überall widerspruchsfrei."
|
||
Genau deshalb stehen diese Angaben hier im Kern und NICHT in den
|
||
einzelnen Seiten-Wörterbüchern: sie erscheinen auf Startseite,
|
||
Leistungen, Preise und im Anfrageformular. Vier Kopien laufen
|
||
früher oder später auseinander — spätestens bei der ersten
|
||
Preisänderung, und dann steht auf einer Seite noch der alte Wert.
|
||
Wird ein Preis geändert, wird er HIER geändert. Einmal.
|
||
--------------------------------------------------------------- */
|
||
pk_onepager_dauer: { de: "ca. 5–10 Arbeitstage", "de-CH": "öppe 5–10 Arbeitstäg", en: "approx. 5–10 working days", fr: "env. 5 à 10 jours ouvrés", pt: "aprox. 5–10 dias úteis" },
|
||
pk_website_dauer: { de: "ca. 2–4 Wochen", "de-CH": "öppe 2–4 Wuche", en: "approx. 2–4 weeks", fr: "env. 2 à 4 semaines", pt: "aprox. 2–4 semanas" },
|
||
pk_shop_dauer: { de: "ca. 4–8 Wochen", "de-CH": "öppe 4–8 Wuche", en: "approx. 4–8 weeks", fr: "env. 4 à 8 semaines", pt: "aprox. 4–8 semanas" },
|
||
pk_verwaltung_dauer: { de: "ca. 6–12 Wochen", "de-CH": "öppe 6–12 Wuche", en: "approx. 6–12 weeks", fr: "env. 6 à 12 semaines", pt: "aprox. 6–12 semanas" },
|
||
betreuung_preis: { de: "ab 49 € / Monat", "de-CH": "ab 49 € / Monet", en: "from €49 / month", fr: "à partir de 49 € / mois", pt: "a partir de 49 € / mês" },
|
||
pk_personalisierung: {
|
||
de: "Personalisierung möglich — verändert Preis und Dauer.",
|
||
"de-CH": "Personalisierig möglich — verändert Priis und Duur.",
|
||
en: "Personalisation available — changes price and duration.",
|
||
fr: "Personnalisation possible — modifie le prix et la durée.",
|
||
pt: "Personalização possível — altera preço e duração.",
|
||
},
|
||
|
||
/* Paketnamen — erscheinen auf Startseite, Leistungen, Preise und im
|
||
Anfrageformular. Aus demselben Grund wie oben nur EINMAL definiert. */
|
||
pk_onepager_h3: { de: "Onepager", "de-CH": "Onepager", en: "One-pager", fr: "Page unique", pt: "Página única" },
|
||
pk_website_h3: { de: "Professionelle Website", "de-CH": "Profi-Website", en: "Professional website", fr: "Site professionnel", pt: "Website profissional" },
|
||
pk_shop_h3: { de: "Shop & umfangreiche Website", "de-CH": "Shop & grossi Website", en: "Shop & large website", fr: "Boutique & grand site", pt: "Loja & site extenso" },
|
||
pk_verwaltung_h3: { de: "Buchhaltungs- & Steuerverwaltungsseiten", "de-CH": "Buechhaltigs- & Stüürverwaltigssite", en: "Bookkeeping & tax admin portals", fr: "Portails de gestion comptable & fiscale", pt: "Portais de gestão contabilística & fiscal" },
|
||
betreuung_h3: { de: "Laufende Betreuung", "de-CH": "Laufendi Betreuig", en: "Ongoing support", fr: "Suivi continu", pt: "Acompanhamento contínuo" },
|
||
|
||
/* Die 30-%-Regel. Steht auf drei Seiten wortgleich und ist rechtlich
|
||
relevant — deshalb erst recht nur an einer Stelle. */
|
||
anzahlung_titel: { de: "Wichtig: 30 % Anzahlung", "de-CH": "Wichtig: 30 % Aazahlig", en: "Important: 30 % deposit", fr: "Important : acompte de 30 %", pt: "Importante: sinal de 30 %" },
|
||
anzahlung_text: {
|
||
de: "Der Projektstart wird nach Annahme des schriftlichen Angebots und Eingang einer Anzahlung von 30 % des vereinbarten Gesamtpreises reserviert.",
|
||
"de-CH": "Dr Projektstart wird nach Aanahm vom schriftliche Aagebot und Iigang vo ere Aazahlig vo 30 % vom vereinbarte Gsamtpriis reserviert.",
|
||
en: "The project start is reserved once the written quote has been accepted and a deposit of 30 % of the agreed total price has been received.",
|
||
fr: "Le démarrage du projet est réservé après acceptation du devis écrit et réception d'un acompte de 30 % du prix total convenu.",
|
||
pt: "O início do projeto é reservado após a aceitação do orçamento escrito e a receção de um sinal de 30 % do preço total acordado.",
|
||
},
|
||
mehr_dazu: { de: "Mehr dazu", "de-CH": "Meh dezue", en: "Learn more", fr: "En savoir plus", pt: "Saber mais" },
|
||
laedt: { de: "Wird geladen…", "de-CH": "Wird glade…", en: "Loading…", fr: "Chargement…", pt: "A carregar…" },
|
||
fehler_allg: {
|
||
de: "Etwas hat nicht geklappt. Bitte später noch einmal versuchen.",
|
||
"de-CH": "Öppis het nöd klappet. Bitte spöter nomol probiere.",
|
||
en: "Something went wrong. Please try again later.",
|
||
fr: "Une erreur est survenue. Merci de réessayer plus tard.",
|
||
pt: "Algo correu mal. Por favor tente novamente mais tarde.",
|
||
},
|
||
fehler_verbindung: {
|
||
de: "Keine Verbindung zum Server. Bitte Internetverbindung prüfen und erneut versuchen.",
|
||
"de-CH": "Kei Verbindig zum Server. Bitte d Internetverbindig prüefe und nomol probiere.",
|
||
en: "No connection to the server. Please check your internet connection and try again.",
|
||
fr: "Pas de connexion au serveur. Vérifiez votre connexion internet et réessayez.",
|
||
pt: "Sem ligação ao servidor. Verifique a ligação à internet e tente novamente.",
|
||
},
|
||
/* --- App-Installation --- */
|
||
install_titel: {
|
||
de: "Als App installieren",
|
||
"de-CH": "Als App installiere",
|
||
en: "Install as an app",
|
||
fr: "Installer en tant qu'application",
|
||
pt: "Instalar como aplicação",
|
||
},
|
||
install_text: {
|
||
de: "Schneller starten, eigenes Symbol, ohne Adressleiste — direkt vom Startbildschirm.",
|
||
"de-CH": "Schnäller starte, eiges Symbol, ohni Adressleischte — direkt vom Startbildschirm.",
|
||
en: "Faster to open, its own icon, no address bar — straight from your home screen.",
|
||
fr: "Ouverture plus rapide, icône dédiée, sans barre d'adresse — directement depuis l'écran d'accueil.",
|
||
pt: "Abre mais depressa, com ícone próprio e sem barra de endereço — direto do ecrã principal.",
|
||
},
|
||
install_knopf: {
|
||
de: "Installieren",
|
||
"de-CH": "Installiere",
|
||
en: "Install",
|
||
fr: "Installer",
|
||
pt: "Instalar",
|
||
},
|
||
install_ios: {
|
||
de: "Unten auf <strong>Teilen</strong> tippen, dann <strong>„Zum Home-Bildschirm“</strong> wählen.",
|
||
"de-CH": "Unde uf <strong>Teile</strong> tippe, denn <strong>„Zum Home-Bildschirm“</strong> uswähle.",
|
||
en: "Tap <strong>Share</strong> at the bottom, then choose <strong>“Add to Home Screen”</strong>.",
|
||
fr: "Touche <strong>Partager</strong> en bas, puis choisis <strong>« Sur l'écran d'accueil »</strong>.",
|
||
pt: "Toca em <strong>Partilhar</strong> em baixo e escolhe <strong>“Adicionar ao ecrã principal”</strong>.",
|
||
},
|
||
install_weg_aria: {
|
||
de: "Hinweis schließen",
|
||
"de-CH": "Hiiwiis zuemache",
|
||
en: "Dismiss notice",
|
||
fr: "Fermer la notification",
|
||
pt: "Fechar aviso",
|
||
},
|
||
|
||
fehler_zeit: {
|
||
de: "Zeitüberschreitung — die Verbindung war zu langsam. Bitte noch einmal versuchen.",
|
||
"de-CH": "Ziitüberschritig — d Verbindig isch z langsam gsi. Bitte nomol probiere.",
|
||
en: "Timed out — the connection was too slow. Please try again.",
|
||
fr: "Délai dépassé — la connexion était trop lente. Merci de réessayer.",
|
||
pt: "Tempo esgotado — a ligação estava demasiado lenta. Tente novamente.",
|
||
},
|
||
};
|
||
|
||
function t(schluessel) {
|
||
var eintrag = I18N_WD_CORE[schluessel];
|
||
if (!eintrag) return schluessel;
|
||
var sprache = getSprache();
|
||
return eintrag[sprache] || eintrag.de || schluessel;
|
||
}
|
||
|
||
/* Seiteneigenen Text nachschlagen — für Werte, die per JS gesetzt werden
|
||
(z.B. Fehlermeldungen im Anfrageformular) und deshalb kein data-i18n
|
||
tragen können. Fällt auf den Kern und dann auf den Schlüssel zurück. */
|
||
function feld(schluessel) {
|
||
var seite = window.I18N_WD || {};
|
||
var eintrag = seite[schluessel] || I18N_WD_CORE[schluessel];
|
||
if (!eintrag) return schluessel;
|
||
var sprache = getSprache();
|
||
return eintrag[sprache] || eintrag.de || schluessel;
|
||
}
|
||
|
||
/* Übersetzt ein beliebiges mehrsprachiges Objekt {de,de-CH,en,fr,pt} —
|
||
für Daten, die aus der Datenbank kommen (Leistungstexte aus der
|
||
Verwaltung), nicht aus einer i18n-Datei. */
|
||
function ausObjekt(obj) {
|
||
if (!obj) return "";
|
||
if (typeof obj === "string") return obj;
|
||
return obj[getSprache()] || obj.de || "";
|
||
}
|
||
|
||
/* ===================================================================
|
||
3. ÜBERSETZUNG ANWENDEN
|
||
Gleiche Attribut-Konventionen wie im Universe, damit man beim
|
||
Wechseln zwischen den Bereichen nicht umdenken muss:
|
||
data-i18n="schluessel" → innerHTML
|
||
data-i18n-attr="alt:key;title:anderer" → Attribute
|
||
=================================================================== */
|
||
function uebersetzeSeite() {
|
||
var sprache = getSprache();
|
||
document.documentElement.setAttribute("lang", sprache);
|
||
|
||
var seite = window.I18N_WD || {};
|
||
|
||
document.querySelectorAll("[data-i18n]").forEach(function (el) {
|
||
var schluessel = el.getAttribute("data-i18n");
|
||
var eintrag = seite[schluessel] || I18N_WD_CORE[schluessel];
|
||
if (!eintrag) return;
|
||
var text = eintrag[sprache] || eintrag.de;
|
||
if (text) el.innerHTML = text;
|
||
});
|
||
|
||
document.querySelectorAll("[data-i18n-attr]").forEach(function (el) {
|
||
el.getAttribute("data-i18n-attr").split(";").forEach(function (teil) {
|
||
var stueck = teil.trim();
|
||
if (!stueck) return;
|
||
var pos = stueck.indexOf(":");
|
||
if (pos < 1) return;
|
||
var attribut = stueck.slice(0, pos).trim();
|
||
var schluessel = stueck.slice(pos + 1).trim();
|
||
var eintrag = seite[schluessel] || I18N_WD_CORE[schluessel];
|
||
if (!eintrag) return;
|
||
var text = eintrag[sprache] || eintrag.de;
|
||
if (text) el.setAttribute(attribut, text);
|
||
});
|
||
});
|
||
|
||
/* Seitentitel und Beschreibung mit übersetzen (Masterplan S.15,
|
||
"Saubere Seitentitel"). Der Schlüssel steht am <html>-Element. */
|
||
var titelKey = document.documentElement.getAttribute("data-i18n-titel");
|
||
if (titelKey) {
|
||
var titelEintrag = seite[titelKey];
|
||
if (titelEintrag) {
|
||
var titel = titelEintrag[sprache] || titelEintrag.de;
|
||
/* Der Titel ist reiner Text — HTML-Entities aus dem Wörterbuch
|
||
(z.B. &) würden sonst wörtlich im Tab stehen. */
|
||
if (titel) document.title = entschaerfe(titel);
|
||
}
|
||
}
|
||
}
|
||
|
||
/* Wandelt die im Wörterbuch erlaubten HTML-Entities in echte Zeichen —
|
||
nur für Stellen, die reinen Text brauchen (title, aria-label). */
|
||
function entschaerfe(html) {
|
||
var hilfe = document.createElement("textarea");
|
||
hilfe.innerHTML = html;
|
||
return hilfe.value.replace(/<[^>]*>/g, "");
|
||
}
|
||
|
||
/* ===================================================================
|
||
4. NAVIGATION
|
||
Genau sieben Hauptpunkte (Masterplan S.5: "Maximal sieben
|
||
Hauptpunkte in der Desktopnavigation") plus EIN Haupt-CTA.
|
||
Kundenportal und Verwaltung stehen bewusst nur in der Fußzeile —
|
||
sie sind keine Verkaufswege, sondern Zugänge für bestehende Kunden.
|
||
=================================================================== */
|
||
var NAV = [
|
||
{ key: "nav_start", href: "/webdesign/" },
|
||
{ key: "nav_leistungen", href: "/webdesign/leistungen.html" },
|
||
{ key: "nav_portfolio", href: "/webdesign/portfolio.html" },
|
||
{ key: "nav_ablauf", href: "/webdesign/ablauf.html" },
|
||
{ key: "nav_ueber", href: "/webdesign/ueber.html" },
|
||
{ key: "nav_faq", href: "/webdesign/faq.html" },
|
||
];
|
||
|
||
/* Aktiver Punkt: Vergleich über den Dateinamen statt über den ganzen
|
||
Pfad — "/webdesign/" und "/webdesign/index.html" sind dieselbe Seite,
|
||
ein reiner Zeichenkettenvergleich würde das nicht erkennen. */
|
||
function aktuelleDatei() {
|
||
var teil = location.pathname.split("/").pop();
|
||
return teil ? teil : "index.html";
|
||
}
|
||
function istAktiv(href) {
|
||
var zielDatei = href.split("/").pop();
|
||
if (!zielDatei) zielDatei = "index.html";
|
||
return zielDatei === aktuelleDatei();
|
||
}
|
||
|
||
function zeichneKopf() {
|
||
var el = document.getElementById("wd-kopf");
|
||
if (!el) return;
|
||
|
||
var sprache = getSprache();
|
||
var aktiv = SPRACHEN.filter(function (s) { return s.code === sprache; })[0] || SPRACHEN[0];
|
||
|
||
/* Die Zugangswand setzt data-wd-kopf="minimal": dort gibt es noch keine
|
||
Sitzung, also darf die Kopfzeile auch keine Links auf geschützte
|
||
Seiten anbieten (die würden nur wieder auf die Zugangswand
|
||
zurückwerfen — eine Navigation, die sich im Kreis dreht). Der
|
||
Sprachschalter muss aber schon dort erreichbar sein, sonst kann
|
||
jemand, der kein Deutsch liest, die Anleitung gar nicht verstehen. */
|
||
var minimal = el.getAttribute("data-wd-kopf") === "minimal";
|
||
|
||
var navHtml = minimal ? "" : NAV.map(function (item) {
|
||
return '<a href="' + item.href + '"' + (istAktiv(item.href) ? ' class="wd-aktiv" aria-current="page"' : "") +
|
||
' data-i18n="' + item.key + '">' + t(item.key) + "</a>";
|
||
}).join("");
|
||
|
||
var sprachHtml = SPRACHEN.map(function (s) {
|
||
return '<button type="button" data-sprache="' + s.code + '"' +
|
||
(s.code === sprache ? ' aria-current="true"' : "") + ">" +
|
||
'<span class="wd-sprache-punkt" style="background:' + s.glut + '"></span>' + s.name + "</button>";
|
||
}).join("");
|
||
|
||
/* In der minimalen Fassung ist die Marke KEIN Link: es gibt noch keine
|
||
Sitzung, ein Klick landete nur wieder auf dieser Seite. */
|
||
var markeInnen =
|
||
'<img src="/assets/img/logo-dogfather-transparent.png" alt="" width="42" height="42" />' +
|
||
'<span class="wd-marke-text">' +
|
||
'<span class="wd-marke-oben" data-i18n="marke_oben">' + t("marke_oben") + "</span>" +
|
||
'<span class="wd-marke-unten" data-i18n="marke_unten">' + t("marke_unten") + "</span>" +
|
||
"</span>";
|
||
var markeHtml = minimal
|
||
? '<span class="wd-marke">' + markeInnen + "</span>"
|
||
: '<a class="wd-marke" href="/webdesign/" aria-label="' + entschaerfe(t("marke_aria")) +
|
||
'" data-i18n-attr="aria-label:marke_aria">' + markeInnen + "</a>";
|
||
|
||
el.innerHTML =
|
||
(minimal ? "" : '<a class="wd-skip" href="#wd-inhalt" data-i18n="nav_zum_inhalt">' + t("nav_zum_inhalt") + "</a>") +
|
||
'<header class="wd-kopf">' +
|
||
'<div class="wd-kopf-bar">' +
|
||
markeHtml +
|
||
(minimal ? "" :
|
||
'<button class="wd-menue-knopf" type="button" aria-expanded="false" aria-controls="wd-nav" ' +
|
||
'aria-label="' + entschaerfe(t("nav_menue_aria")) + '" data-i18n-attr="aria-label:nav_menue_aria">' +
|
||
'<span aria-hidden="true">☰</span><span data-i18n="nav_menue">' + t("nav_menue") + "</span>" +
|
||
"</button>") +
|
||
'<nav class="wd-nav' + (minimal ? " wd-nav--minimal" : "") + '" id="wd-nav">' +
|
||
navHtml +
|
||
'<div class="wd-sprache">' +
|
||
'<button class="wd-sprache-knopf" type="button" aria-expanded="false" ' +
|
||
'aria-label="' + entschaerfe(t("nav_sprache_aria")) + '" data-i18n-attr="aria-label:nav_sprache_aria">' +
|
||
'<span aria-hidden="true">🌐</span><span>' + aktiv.kurz + "</span><span aria-hidden=\"true\">▾</span>" +
|
||
"</button>" +
|
||
'<div class="wd-sprache-liste">' + sprachHtml + "</div>" +
|
||
"</div>" +
|
||
(minimal ? "" :
|
||
'<a class="wd-btn wd-btn--haupt wd-btn--klein" href="/webdesign/anfrage.html" data-i18n="nav_anfrage">' + t("nav_anfrage") + "</a>") +
|
||
"</nav>" +
|
||
"</div>" +
|
||
"</header>";
|
||
|
||
/* --- Handy-Menü (in der minimalen Kopfzeile gibt es keins) --- */
|
||
var menueKnopf = el.querySelector(".wd-menue-knopf");
|
||
var nav = el.querySelector(".wd-nav");
|
||
if (menueKnopf && nav) {
|
||
menueKnopf.addEventListener("click", function () {
|
||
var offen = nav.classList.toggle("wd-offen");
|
||
menueKnopf.setAttribute("aria-expanded", String(offen));
|
||
});
|
||
}
|
||
|
||
/* --- Sprachauswahl --- */
|
||
var sprachBox = el.querySelector(".wd-sprache");
|
||
var sprachKnopf = el.querySelector(".wd-sprache-knopf");
|
||
sprachKnopf.addEventListener("click", function (e) {
|
||
e.stopPropagation();
|
||
var offen = sprachBox.classList.toggle("wd-offen");
|
||
sprachKnopf.setAttribute("aria-expanded", String(offen));
|
||
});
|
||
el.querySelectorAll("[data-sprache]").forEach(function (btn) {
|
||
btn.addEventListener("click", function () { setSprache(btn.dataset.sprache); });
|
||
});
|
||
}
|
||
|
||
/* Klick außerhalb schließt die Sprachliste. Bewusst NUR EINMAL beim Start
|
||
angemeldet, nicht in zeichneKopf() — die Funktion läuft bei jedem
|
||
Sprachwechsel erneut, und ein Zuhörer pro Wechsel würde sich sonst
|
||
endlos aufsummieren (genau dieser Fehler ist in main.js dokumentiert). */
|
||
document.addEventListener("click", function (e) {
|
||
var kopf = document.getElementById("wd-kopf");
|
||
if (!kopf) return;
|
||
var box = kopf.querySelector(".wd-sprache");
|
||
if (box && !box.contains(e.target)) {
|
||
box.classList.remove("wd-offen");
|
||
var knopf = box.querySelector(".wd-sprache-knopf");
|
||
if (knopf) knopf.setAttribute("aria-expanded", "false");
|
||
}
|
||
});
|
||
document.addEventListener("keydown", function (e) {
|
||
if (e.key !== "Escape") return;
|
||
var kopf = document.getElementById("wd-kopf");
|
||
if (!kopf) return;
|
||
kopf.querySelectorAll(".wd-offen").forEach(function (n) { n.classList.remove("wd-offen"); });
|
||
kopf.querySelectorAll('[aria-expanded="true"]').forEach(function (n) { n.setAttribute("aria-expanded", "false"); });
|
||
});
|
||
|
||
function zeichneFuss() {
|
||
var el = document.getElementById("wd-fuss");
|
||
if (!el) return;
|
||
var jahr = new Date().getFullYear();
|
||
|
||
var bereicheHtml = NAV.slice(1).map(function (item) {
|
||
return '<li><a href="' + item.href + '" data-i18n="' + item.key + '">' + t(item.key) + "</a></li>";
|
||
}).join("");
|
||
|
||
el.innerHTML =
|
||
'<footer class="wd-fuss">' +
|
||
'<div class="wd-container">' +
|
||
'<div class="wd-fuss-grid">' +
|
||
"<div>" +
|
||
'<a class="wd-marke" href="/webdesign/" style="margin-bottom:.9rem">' +
|
||
'<img src="/assets/img/logo-dogfather-transparent.png" alt="" width="42" height="42" />' +
|
||
'<span class="wd-marke-text">' +
|
||
'<span class="wd-marke-oben" data-i18n="marke_oben">' + t("marke_oben") + "</span>" +
|
||
'<span class="wd-marke-unten" data-i18n="marke_unten">' + t("marke_unten") + "</span>" +
|
||
"</span>" +
|
||
"</a>" +
|
||
'<p class="wd-klein wd-leise" data-i18n="fuss_claim">' + t("fuss_claim") + "</p>" +
|
||
"</div>" +
|
||
"<div>" +
|
||
'<h2 class="wd-fuss-h" data-i18n="fuss_bereiche">' + t("fuss_bereiche") + "</h2>" +
|
||
"<ul>" + bereicheHtml + "</ul>" +
|
||
"</div>" +
|
||
"<div>" +
|
||
'<h2 class="wd-fuss-h" data-i18n="fuss_zugang">' + t("fuss_zugang") + "</h2>" +
|
||
"<ul>" +
|
||
'<li><a href="/webdesign/portal.html" data-i18n="fuss_portal">' + t("fuss_portal") + "</a></li>" +
|
||
'<li><a href="/webdesign/verwaltung.html" data-i18n="fuss_verwaltung">' + t("fuss_verwaltung") + "</a></li>" +
|
||
'<li><a href="/index.html" data-i18n="fuss_universe">' + t("fuss_universe") + "</a></li>" +
|
||
'<li><a href="mailto:[email protected]" data-i18n="fuss_email">' + t("fuss_email") + "</a></li>" +
|
||
'<li><a href="/webdesign/rechtliches.html" data-i18n="fuss_recht">' + t("fuss_recht") + "</a></li>" +
|
||
/* 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. */
|
||
'<li><a href="/webdesign/widerruf.html" class="wd-fuss-widerruf" data-i18n="fuss_widerruf">' + t("fuss_widerruf") + "</a></li>" +
|
||
'<li><a href="/kontakt.html" data-i18n="fuss_kontakt">' + t("fuss_kontakt") + "</a></li>" +
|
||
"</ul>" +
|
||
"</div>" +
|
||
"</div>" +
|
||
'<div class="wd-fuss-unten">' +
|
||
"<span>© " + jahr + ' Dogfather Webdesign — <span data-i18n="fuss_rechte">' + t("fuss_rechte") + "</span></span>" +
|
||
'<span class="wd-tag wd-tag--gold" data-i18n="fuss_status">' + t("fuss_status") + "</span>" +
|
||
"</div>" +
|
||
"</div>" +
|
||
"</footer>";
|
||
}
|
||
|
||
/* ===================================================================
|
||
5. EINBLENDEN BEIM SCROLLEN
|
||
Die Klasse .wd-bereit wird erst HIER gesetzt — dadurch sind alle
|
||
Elemente ohne JavaScript (oder bei einem Skriptfehler weiter oben)
|
||
ganz normal sichtbar, statt dauerhaft unsichtbar zu bleiben.
|
||
=================================================================== */
|
||
function einblendenAktivieren() {
|
||
var elemente = document.querySelectorAll(".wd-auf");
|
||
if (!elemente.length) return;
|
||
|
||
var reduziert = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||
if (reduziert || !("IntersectionObserver" in window)) return; // alles bleibt sofort sichtbar
|
||
|
||
document.documentElement.classList.add("wd-bereit");
|
||
|
||
var beobachter = new IntersectionObserver(function (eintraege) {
|
||
eintraege.forEach(function (eintrag) {
|
||
if (!eintrag.isIntersecting) return;
|
||
eintrag.target.classList.add("wd-sichtbar");
|
||
beobachter.unobserve(eintrag.target); // einmal eingeblendet reicht
|
||
});
|
||
}, { rootMargin: "0px 0px -8% 0px", threshold: .08 });
|
||
|
||
elemente.forEach(function (el, i) {
|
||
/* Leichte Staffelung innerhalb einer Reihe — aber gedeckelt, sonst
|
||
wartet man bei zehn Karten fast eine Sekunde auf die letzte. */
|
||
el.style.transitionDelay = Math.min(i % 4, 3) * 70 + "ms";
|
||
beobachter.observe(el);
|
||
});
|
||
}
|
||
|
||
/* ===================================================================
|
||
6. HELFER FÜR SEITENEIGENE SKRIPTE
|
||
=================================================================== */
|
||
|
||
/* Interne API (Postfach-Server, siehe DEPLOY.md). Gleiche Adresse wie
|
||
der Rest der Seite benutzt. */
|
||
var API_BASIS = "https://postfach.dogfather-universe.com";
|
||
var ZEITLIMIT_MS = 15000;
|
||
|
||
/* Ein Aufruf mit hartem Zeitlimit. Ohne das hängt fetch() im Mobilfunk
|
||
unbegrenzt — für die Person sieht es dann so aus, als sei der Tipp auf
|
||
den Knopf gar nicht angekommen. Exakt der Fehler, der in gate.html am
|
||
19.08.2026 gemeldet und behoben wurde; hier von Anfang an richtig. */
|
||
async function api(pfad, optionen) {
|
||
var opt = optionen || {};
|
||
var abbruch = new AbortController();
|
||
var timer = setTimeout(function () { abbruch.abort(); }, opt.zeitlimit || ZEITLIMIT_MS);
|
||
try {
|
||
var kopfzeilen = Object.assign({}, opt.headers || {});
|
||
var koerper = opt.body;
|
||
/* FormData darf KEINEN Content-Type gesetzt bekommen — der Browser
|
||
muss die multipart-Grenze selbst anhängen. */
|
||
if (koerper && !(koerper instanceof FormData) && typeof koerper !== "string") {
|
||
koerper = JSON.stringify(koerper);
|
||
kopfzeilen["Content-Type"] = "application/json";
|
||
}
|
||
var antwort = await fetch(API_BASIS + pfad, {
|
||
method: opt.method || (koerper ? "POST" : "GET"),
|
||
headers: kopfzeilen,
|
||
body: koerper,
|
||
signal: abbruch.signal,
|
||
cache: "no-store",
|
||
});
|
||
var daten = await antwort.json().catch(function () { return null; });
|
||
if (!antwort.ok || !daten || daten.ok === false) {
|
||
var meldung = (daten && (daten.error || daten.fehler)) || feld("fehler_allg");
|
||
var fehler = new Error(meldung);
|
||
fehler.status = antwort.status;
|
||
fehler.daten = daten;
|
||
throw fehler;
|
||
}
|
||
return daten;
|
||
} catch (e) {
|
||
if (e && e.name === "AbortError") throw new Error(feld("fehler_zeit"));
|
||
if (e instanceof TypeError) throw new Error(feld("fehler_verbindung")); // Netzwerk, nicht erreichbar
|
||
throw e;
|
||
} finally {
|
||
clearTimeout(timer);
|
||
}
|
||
}
|
||
|
||
/* Knopf während eines Vorgangs sperren + beschriften. Verhindert
|
||
Doppel-Absendungen (die bei Rate-Limits besonders teuer sind) und gibt
|
||
sofort sichtbare Rückmeldung. */
|
||
function knopfSperren(btn, an, textWaehrend) {
|
||
if (!btn) return;
|
||
if (an) {
|
||
if (!btn.dataset.originalText) btn.dataset.originalText = btn.textContent;
|
||
btn.disabled = true;
|
||
btn.setAttribute("aria-busy", "true");
|
||
if (textWaehrend) btn.textContent = textWaehrend;
|
||
} else {
|
||
btn.disabled = false;
|
||
btn.removeAttribute("aria-busy");
|
||
if (btn.dataset.originalText) btn.textContent = btn.dataset.originalText;
|
||
}
|
||
}
|
||
|
||
/* Meldungskasten setzen und den Fokus dorthin lenken — sonst bekommt
|
||
eine Person mit Screenreader gar nicht mit, dass etwas passiert ist. */
|
||
function meldung(el, text, art) {
|
||
if (!el) return;
|
||
el.textContent = text;
|
||
el.className = "wd-meldung wd-meldung--" + (art === "ok" ? "ok" : "fehler");
|
||
el.hidden = false;
|
||
el.setAttribute("role", art === "ok" ? "status" : "alert");
|
||
el.setAttribute("tabindex", "-1");
|
||
el.focus({ preventScroll: false });
|
||
}
|
||
|
||
function meldungAus(el) {
|
||
if (!el) return;
|
||
el.hidden = true;
|
||
el.textContent = "";
|
||
}
|
||
|
||
/* Preis-/Datumsformate richten sich nach der gewählten Sprache. */
|
||
function preis(betrag) {
|
||
var karte = { de: "de-DE", "de-CH": "de-CH", en: "en-GB", fr: "fr-FR", pt: "pt-PT" };
|
||
try {
|
||
return new Intl.NumberFormat(karte[getSprache()] || "de-DE", {
|
||
style: "currency", currency: "EUR", maximumFractionDigits: 0,
|
||
}).format(betrag);
|
||
} catch (e) {
|
||
return betrag + " €";
|
||
}
|
||
}
|
||
|
||
function datum(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",
|
||
}).format(d);
|
||
} catch (e) {
|
||
return d.toLocaleDateString();
|
||
}
|
||
}
|
||
|
||
/* 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. */
|
||
function sicher(text) {
|
||
return String(text == null ? "" : text)
|
||
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||
.replace(/"/g, """).replace(/'/g, "'");
|
||
}
|
||
|
||
/* ===================================================================
|
||
7. ALS APP INSTALLIERBAR (Handy und Computer)
|
||
|
||
Drei verschiedene Welten, die sich nicht vereinheitlichen lassen:
|
||
|
||
a) Android/Chrome und Chrome/Edge am Computer feuern
|
||
"beforeinstallprompt". Das Ereignis lässt sich abfangen und der
|
||
Einbau-Dialog später gezielt öffnen — genau einmal, und nur wenn
|
||
die Person den eigenen Knopf drückt.
|
||
b) iPhone/iPad (Safari) hat KEIN solches Ereignis und wird es
|
||
absehbar auch nicht bekommen. Dort geht es ausschließlich über
|
||
"Teilen → Zum Home-Bildschirm". Ohne Anleitung findet das kaum
|
||
jemand, deshalb dort ein kurzer, wegklickbarer Hinweis mit den
|
||
echten Schritten statt eines Knopfes, der nichts tut.
|
||
c) Bereits installiert (display-mode: standalone) → gar nichts
|
||
anzeigen. Ein "Installieren"-Knopf in einer schon installierten
|
||
App ist der sicherste Weg, unseriös zu wirken.
|
||
=================================================================== */
|
||
var INSTALL_ABGELEHNT = "wd-install-weg";
|
||
|
||
function schonInstalliert() {
|
||
return (window.matchMedia && window.matchMedia("(display-mode: standalone)").matches) ||
|
||
window.navigator.standalone === true; // iOS-eigene Kennung
|
||
}
|
||
|
||
function istApple() {
|
||
var ua = navigator.userAgent || "";
|
||
/* iPadOS meldet sich seit Version 13 als "Macintosh". Die zusätzliche
|
||
Touch-Prüfung unterscheidet ein iPad von einem echten Mac — ohne
|
||
sie bekämen Mac-Nutzer eine iPhone-Anleitung angezeigt. */
|
||
var iphone = /iPad|iPhone|iPod/.test(ua);
|
||
var ipadNeu = /Macintosh/.test(ua) && navigator.maxTouchPoints > 1;
|
||
return iphone || ipadNeu;
|
||
}
|
||
|
||
/* Das Symbol der App, die hier tatsaechlich installiert wird.
|
||
|
||
⚠️ Vorher stand hier fest "/assets/img/wd-app-192.png". Seit die
|
||
Verwaltung eine eigene App mit eigenem Symbol ist (26.08.2026),
|
||
zeigte der Hinweis dort das FALSCHE Bild: Man las "Als App
|
||
installieren" neben dem schwarzen Husky und bekam anschliessend das
|
||
Eiskristall-Wappen auf den Startbildschirm.
|
||
|
||
Ein Hinweis, der etwas anderes zeigt als das, was passiert, ist
|
||
schlimmer als gar keiner -- man haelt ihn beim naechsten Mal fuer
|
||
unzuverlaessig.
|
||
|
||
Jetzt wird das Symbol aus dem Manifest gelesen, das die Seite
|
||
tatsaechlich einbindet. Damit stimmt es automatisch auch fuer jede
|
||
kuenftige App, ohne dass jemand daran denken muss. */
|
||
function installSymbol() {
|
||
try {
|
||
var v = document.querySelector('link[rel="manifest"]');
|
||
if (v && /verwaltung\.webmanifest/.test(v.getAttribute("href") || "")) {
|
||
return "/assets/img/wd-admin-192.jpg";
|
||
}
|
||
} catch (e) { /* dann eben das allgemeine */ }
|
||
return "/assets/img/wd-app-192.png";
|
||
}
|
||
|
||
function installBannerBauen(inhalt, mitKnopf) {
|
||
var box = document.createElement("div");
|
||
box.className = "wd-install";
|
||
box.setAttribute("role", "region");
|
||
box.innerHTML =
|
||
'<div class="wd-install-text">' +
|
||
'<img src="' + installSymbol() + '" alt="" width="40" height="40" />' +
|
||
"<div><strong>" + t("install_titel") + "</strong><span>" + inhalt + "</span></div>" +
|
||
"</div>" +
|
||
'<div class="wd-install-knoepfe">' +
|
||
(mitKnopf ? '<button type="button" class="wd-btn wd-btn--haupt wd-btn--klein" data-wd-install>' + t("install_knopf") + "</button>" : "") +
|
||
'<button type="button" class="wd-btn wd-btn--still wd-btn--klein" data-wd-install-weg aria-label="' +
|
||
entschaerfe(t("install_weg_aria")) + '">✕</button>' +
|
||
"</div>";
|
||
document.body.appendChild(box);
|
||
|
||
/* Platz unter der Seite schaffen, solange der Hinweis liegt.
|
||
|
||
Er sitzt fest am unteren Rand und verdeckt sonst dauerhaft das,
|
||
was dort steht. Am 23.08.2026 blockierte er in der Verwaltung den
|
||
Annehmen-Knopf: Der Knopf war sichtbar, liess sich aber nicht
|
||
treffen -- man haette gedrueckt und gedrueckt und geglaubt, die
|
||
Seite sei kaputt. Aufgefallen, weil ein Test dreissig Sekunden
|
||
lang vergeblich klickte.
|
||
|
||
Gemessen statt geraten: Ein fester Wert waere bei zwei Zeilen
|
||
Text zu klein und bei einer zu gross. */
|
||
function platzSchaffen() {
|
||
var hoehe = box.getBoundingClientRect().height;
|
||
document.body.style.paddingBottom = (hoehe + 24) + "px";
|
||
}
|
||
platzSchaffen();
|
||
/* Bei Drehung oder Groessenaenderung neu messen -- im Querformat
|
||
braucht derselbe Text weniger Zeilen. */
|
||
window.addEventListener("resize", platzSchaffen);
|
||
|
||
function wegRaeumen() {
|
||
window.removeEventListener("resize", platzSchaffen);
|
||
document.body.style.paddingBottom = "";
|
||
box.remove();
|
||
}
|
||
box.wdWegRaeumen = wegRaeumen;
|
||
|
||
box.querySelector("[data-wd-install-weg]").addEventListener("click", function () {
|
||
wegRaeumen();
|
||
/* localStorage, nicht sessionStorage: ein "nein danke" soll halten.
|
||
Wer es sich anders überlegt, kann jederzeit über das Browsermenü
|
||
installieren. */
|
||
try { localStorage.setItem(INSTALL_ABGELEHNT, "ja"); } catch (e) { /* egal */ }
|
||
});
|
||
return box;
|
||
}
|
||
|
||
function installierbarkeitAktivieren() {
|
||
if (schonInstalliert()) return;
|
||
var abgelehnt = false;
|
||
try { abgelehnt = localStorage.getItem(INSTALL_ABGELEHNT) === "ja"; } catch (e) { /* egal */ }
|
||
if (abgelehnt) return;
|
||
|
||
// --- a) Android / Chrome / Edge ---
|
||
window.addEventListener("beforeinstallprompt", function (e) {
|
||
/* Den eigenen Dialog des Browsers unterdrücken und selbst
|
||
entscheiden, wann er kommt — sonst poppt er ungefragt über den
|
||
Inhalt, was viele sofort wegklicken. */
|
||
e.preventDefault();
|
||
var box = installBannerBauen(t("install_text"), true);
|
||
box.querySelector("[data-wd-install]").addEventListener("click", async function () {
|
||
/* Ueber wegRaeumen(), nicht ueber box.remove(): Sonst bliebe der
|
||
reservierte Platz unter der Seite stehen, und man haette nach
|
||
dem Installieren dauerhaft eine leere Flaeche am Ende. */
|
||
box.wdWegRaeumen();
|
||
e.prompt();
|
||
try { await e.userChoice; } catch (err) { /* egal, Entscheidung liegt beim Nutzer */ }
|
||
});
|
||
});
|
||
|
||
// --- b) iPhone / iPad ---
|
||
if (istApple()) {
|
||
/* Etwas verzögert, damit der Hinweis nicht beim ersten Blick auf
|
||
die Seite ins Gesicht springt. */
|
||
setTimeout(function () {
|
||
if (!schonInstalliert()) installBannerBauen(t("install_ios"), false);
|
||
}, 2500);
|
||
}
|
||
}
|
||
|
||
function serviceWorkerAnmelden() {
|
||
if (!("serviceWorker" in navigator)) return;
|
||
// Über file:// gibt es keine Service Worker — dort gar nicht erst versuchen.
|
||
if (!/^https?:$/.test(location.protocol)) return;
|
||
window.addEventListener("load", function () {
|
||
/* Die Versionsnummer in der Adresse ist kein Schmuck, sondern die
|
||
Umgehung eines Zwischenspeichers, den wir nicht steuern.
|
||
|
||
Gemessen am 26.08.2026: Der Server liefert sw.js mit
|
||
"Cache-Control: no-cache" aus. Cloudflare ersetzt das durch
|
||
"max-age=14400" -- vier Stunden, und zwar auch bei einem
|
||
Fehlschlag im eigenen Zwischenspeicher (cf-cache-status: MISS).
|
||
Ursache ist eine feste "Browser Cache TTL" in den
|
||
Cloudflare-Einstellungen; die Annahme im Kopf von
|
||
server/index.js, ein gesetztes Cache-Control werde respektiert,
|
||
trifft fuer diese Domain nicht zu.
|
||
|
||
Folge: Jede Aenderung am Service Worker erreicht die Geraete bis
|
||
zu vier Stunden zu spaet. Bei einem fehlerhaften Stand -- wie an
|
||
diesem Tag, als eine zu strenge Inhaltsrichtlinie ihn lahmlegte
|
||
und die App "Keine Verbindung" zeigte -- sind das vier Stunden,
|
||
in denen sich nichts reparieren laesst.
|
||
|
||
Mit der Versionsnummer ist es eine ANDERE Adresse, sobald sie
|
||
sich aendert. Ein Zwischenspeicher kann dafuer keinen alten
|
||
Stand haben. Die Nummer wird zusammen mit CACHE_NAME in sw.js
|
||
hochgezaehlt -- beide gehoeren zusammen. */
|
||
navigator.serviceWorker
|
||
.register("/webdesign/sw.js?v=62", { scope: "/webdesign/" })
|
||
.catch(function () { /* still schlucken: ohne SW funktioniert alles weiter */ });
|
||
});
|
||
}
|
||
|
||
/* ===================================================================
|
||
8. ZUGANG BEIM SCHLIESSEN DER SEITE BEENDEN
|
||
|
||
Wunsch vom 22.08.2026: "ich will das ich jedes mal den code gefragt
|
||
werde wenn man die seite zu macht."
|
||
|
||
Warum das Sitzungs-Cookie allein dafür NICHT reicht:
|
||
Ein Cookie ohne Ablaufdatum wirft der Browser beim Schließen zwar weg
|
||
— aber "Tabs wiederherstellen" (Chrome: "Dort fortfahren, wo du
|
||
aufgehört hast", Android beim erneuten Öffnen, iOS beim Zurückholen
|
||
einer App aus dem Speicher) stellt genau diese Cookies wieder her.
|
||
Man landet dann ohne Codeabfrage wieder mitten in der Seite.
|
||
|
||
Deshalb zusätzlich eine Kennung im sessionStorage. Der ist an die
|
||
Browser-Sitzung des Tabs gebunden und ist beim echten Schließen weg.
|
||
Fehlt die Kennung, obwohl ein Cookie da ist, war die Seite zu →
|
||
Sitzung serverseitig beenden und zurück zur Zugangswand.
|
||
|
||
Ehrliche Einschränkung: Wer die Adresse in einem NEUEN Tab von Hand
|
||
eingibt, hat dort ebenfalls einen leeren sessionStorage und wird
|
||
erneut gefragt. Das ist der Preis dafür, dass es beim Schließen
|
||
zuverlässig greift — und entspricht dem, was gewünscht war.
|
||
=================================================================== */
|
||
var SITZUNGS_MARKE = "wd-sitzung-aktiv";
|
||
|
||
function sitzungsMarkeSetzen() {
|
||
try { sessionStorage.setItem(SITZUNGS_MARKE, "1"); } catch (e) { /* egal */ }
|
||
}
|
||
|
||
function hatRollenCookie() {
|
||
return document.cookie.split(";").some(function (s) {
|
||
return s.trim().indexOf("dogiwd_rolle=") === 0;
|
||
});
|
||
}
|
||
|
||
function zugangPruefen() {
|
||
// Auf der Zugangswand selbst gibt es nichts zu beenden.
|
||
if (location.pathname.indexOf("/webdesign/zugang.html") === 0) return;
|
||
|
||
var marke = null;
|
||
try { marke = sessionStorage.getItem(SITZUNGS_MARKE); } catch (e) { return; }
|
||
if (marke) return; // laufende Sitzung, alles gut
|
||
|
||
/* Kein Rollen-Cookie -> es gab hier noch nie eine Anmeldung in diesem
|
||
Browser (oder der Bereich ist freigegeben). Dann NICHT abmelden:
|
||
sonst würde eine gerade erst begonnene Sitzung sofort beendet. */
|
||
if (!hatRollenCookie()) { sitzungsMarkeSetzen(); return; }
|
||
|
||
/* Cookie da, Marke weg -> die Seite war zwischendurch zu. */
|
||
fetch("/webdesign/zugang-beenden", { method: "POST" })
|
||
.catch(function () { /* auch ohne Antwort weiterleiten */ })
|
||
.then(function () {
|
||
location.replace(
|
||
"/webdesign/zugang.html?next=" + encodeURIComponent(location.pathname + location.search)
|
||
);
|
||
});
|
||
}
|
||
|
||
/* ===================================================================
|
||
9. START
|
||
=================================================================== */
|
||
function start() {
|
||
document.body.classList.add("wd");
|
||
zugangPruefen();
|
||
sitzungsMarkeSetzen();
|
||
uebersetzeSeite();
|
||
zeichneKopf();
|
||
zeichneFuss();
|
||
einblendenAktivieren();
|
||
serviceWorkerAnmelden();
|
||
installierbarkeitAktivieren();
|
||
document.dispatchEvent(new CustomEvent("wd-bereit"));
|
||
}
|
||
|
||
if (document.readyState === "loading") {
|
||
document.addEventListener("DOMContentLoaded", start);
|
||
} else {
|
||
start();
|
||
}
|
||
lichtStarten();
|
||
|
||
/* ===================================================================
|
||
LICHT UNTER DEM ZEIGER
|
||
|
||
Setzt zwei Variablen auf der überfahrenen Kachel; die eigentliche
|
||
Darstellung macht das CSS. So bleibt hier nur Rechnen, kein Malen.
|
||
|
||
DREI DINGE, DIE ES SPARSAM HALTEN
|
||
|
||
1. EIN Zuhörer am Dokument statt einem je Kachel. Bei zwanzig
|
||
Kacheln wären das zwanzig — und jede müsste beim Nachladen neuer
|
||
Inhalte einzeln nachgerüstet werden.
|
||
|
||
2. Geschrieben wird erst im nächsten Bildaufbau
|
||
(requestAnimationFrame). Der Zeiger meldet sich bis zu tausendmal
|
||
je Sekunde; der Bildschirm zeichnet sechzigmal. Ohne diese
|
||
Drosselung rechnet der Browser neunhundert Mal umsonst.
|
||
|
||
3. Gar nicht erst starten, wenn es keinen echten Zeiger gibt oder
|
||
Bewegung unerwünscht ist. Auf dem Handy bliebe der Lichtfleck an
|
||
einer zufälligen Stelle kleben.
|
||
=================================================================== */
|
||
function lichtStarten() {
|
||
if (!window.matchMedia) return;
|
||
if (!window.matchMedia("(hover: hover) and (pointer: fine)").matches) return;
|
||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||
|
||
var offen = false;
|
||
var letzte = null;
|
||
|
||
document.addEventListener("pointermove", function (e) {
|
||
var karte = e.target && e.target.closest ? e.target.closest(".wd-karte") : null;
|
||
if (!karte) return;
|
||
letzte = { karte: karte, x: e.clientX, y: e.clientY };
|
||
if (offen) return;
|
||
offen = true;
|
||
requestAnimationFrame(function () {
|
||
offen = false;
|
||
if (!letzte) return;
|
||
var k = letzte.karte;
|
||
var r = k.getBoundingClientRect();
|
||
k.style.setProperty("--wd-lichtx", (letzte.x - r.left) + "px");
|
||
k.style.setProperty("--wd-lichty", (letzte.y - r.top) + "px");
|
||
|
||
/* Und gleich die Neigung mitsetzen.
|
||
|
||
Das Licht braucht Bildpunkte ab der Kachelecke, die Neigung
|
||
dagegen einen Wert unabhaengig von der Kachelgroesse: -1 am
|
||
linken Rand, +1 am rechten. Sonst kippte eine breite Kachel
|
||
staerker als eine schmale, obwohl der Zeiger relativ an
|
||
derselben Stelle steht.
|
||
|
||
Beides in einem Durchgang, weil beides denselben Messwert
|
||
braucht -- ein zweiter Horcher wuerde dieselbe Rechnung
|
||
nochmal anstellen. */
|
||
if (r.width && r.height) {
|
||
var nx = ((letzte.x - r.left) / r.width - 0.5) * 2;
|
||
var ny = ((letzte.y - r.top) / r.height - 0.5) * 2;
|
||
k.style.setProperty("--wd-nx", nx.toFixed(3));
|
||
k.style.setProperty("--wd-ny", ny.toFixed(3));
|
||
|
||
/* WIE STARK gekippt wird, haengt an der Groesse der Kachel.
|
||
|
||
Bei gleichem Winkel legt eine grosse Kachel an ihren Ecken
|
||
viel mehr Weg zurueck als eine kleine -- fuenf Grad sehen
|
||
bei einer Preiskachel von 280 Punkten beilaeufig aus und
|
||
bei einer Portfolio-Kachel von 1470 Punkten wie ein
|
||
Kippen des halben Bildschirms.
|
||
|
||
Deshalb ein fester Bezugswert von 420 Punkten: Kacheln bis
|
||
dahin kippen voll, groessere anteilig weniger. Nach unten
|
||
bei 1,4 Grad begrenzt, damit auch die groesste Kachel noch
|
||
erkennbar reagiert und der Effekt nicht einfach ausfaellt. */
|
||
var lang = Math.max(r.width, r.height);
|
||
var grad = Math.max(1.4, Math.min(5, 5 * (420 / lang)));
|
||
k.style.setProperty("--wd-kipp", grad.toFixed(2));
|
||
}
|
||
});
|
||
}, { passive: true });
|
||
|
||
/* Beim Verlassen zuruecksetzen, sonst bleibt die Kachel schraeg
|
||
stehen, nachdem der Zeiger laengst weg ist.
|
||
|
||
pointerout statt mouseleave: Es meldet zuverlaessig auch dann,
|
||
wenn der Zeiger die Kachel ueber ein Kindelement verlaesst. */
|
||
document.addEventListener("pointerout", function (e) {
|
||
var karte = e.target && e.target.closest ? e.target.closest(".wd-karte") : null;
|
||
if (!karte) return;
|
||
/* Nur zuruecksetzen, wenn der Zeiger die Kachel WIRKLICH verlaesst
|
||
und nicht bloss von einem Kind zum naechsten wandert. */
|
||
if (e.relatedTarget && karte.contains(e.relatedTarget)) return;
|
||
karte.style.setProperty("--wd-nx", "0");
|
||
karte.style.setProperty("--wd-ny", "0");
|
||
if (letzte && letzte.karte === karte) letzte = null;
|
||
}, { passive: true });
|
||
|
||
bilderBewegen();
|
||
}
|
||
|
||
/* ===================================================================
|
||
BILDER, DIE SICH BEWEGEN
|
||
|
||
Faehrt der Zeiger ueber ein Bild, wandert es ein Stueck GEGEN die
|
||
Zeigerrichtung. Bewegte es sich mit, wirkte es wie ein Aufkleber,
|
||
der verrutscht; dagegen liest das Auge es als Tiefe hinter dem
|
||
Rahmen.
|
||
|
||
Gesetzt werden Werte von -1 bis +1, umgerechnet auf die Groesse des
|
||
Bildes. Ein fester Wert in Bildpunkten waere falsch: Ein
|
||
Vorschaubild von 1200 Punkten Breite und ein Kundenlogo von 80
|
||
wuerden sonst gleich weit wandern, was beim kleinen wie ein Ruck
|
||
aussieht.
|
||
=================================================================== */
|
||
function bilderBewegen() {
|
||
var WAHL = ".pfo-bild, .wd-projekt-bild, .wd-bildrahmen > img, .wd-karte > img";
|
||
var offen = false, letztes = null, zx = 0, zy = 0;
|
||
|
||
document.addEventListener("pointermove", function (e) {
|
||
var bild = null;
|
||
if (e.target && e.target.matches && e.target.matches(WAHL)) {
|
||
bild = e.target;
|
||
} else if (e.target && e.target.closest) {
|
||
/* Auch dann greifen, wenn der Zeiger ueber einem Element LIEGT,
|
||
das das Bild ueberdeckt -- etwa einem Textblock in derselben
|
||
Kachel. Sonst bliebe das Bild stehen, sobald man den Rahmen
|
||
verlaesst, aber die Kachel noch nicht. */
|
||
var rahmen = e.target.closest(".wd-bildrahmen, .pfo-projekt, .wd-karte");
|
||
if (rahmen) bild = rahmen.querySelector(WAHL);
|
||
}
|
||
if (!bild) return;
|
||
letztes = bild; zx = e.clientX; zy = e.clientY;
|
||
if (offen) return;
|
||
offen = true;
|
||
requestAnimationFrame(function () {
|
||
offen = false;
|
||
if (!letztes) return;
|
||
var r = letztes.getBoundingClientRect();
|
||
if (!r.width || !r.height) return;
|
||
var bx = ((zx - r.left) / r.width - 0.5) * 2;
|
||
var by = ((zy - r.top) / r.height - 0.5) * 2;
|
||
/* Auf -1..+1 begrenzen: Steht der Zeiger neben dem Bild, aber
|
||
noch in der Kachel, kaemen sonst Werte weit darueber heraus
|
||
und das Bild schoesse aus dem Rahmen. */
|
||
letztes.style.setProperty("--wd-bx", Math.max(-1, Math.min(1, bx)).toFixed(3));
|
||
letztes.style.setProperty("--wd-by", Math.max(-1, Math.min(1, by)).toFixed(3));
|
||
});
|
||
}, { passive: true });
|
||
|
||
/* Beim Verlassen zuruecksetzen, sonst bleibt das Bild verschoben
|
||
stehen, nachdem der Zeiger laengst weg ist. */
|
||
document.addEventListener("pointerout", function (e) {
|
||
if (!e.target || !e.target.closest) return;
|
||
var rahmen = e.target.closest(".wd-bildrahmen, .pfo-projekt, .wd-karte");
|
||
if (!rahmen) return;
|
||
if (e.relatedTarget && rahmen.contains(e.relatedTarget)) return;
|
||
var bild = rahmen.querySelector(WAHL);
|
||
if (!bild) return;
|
||
bild.style.setProperty("--wd-bx", "0");
|
||
bild.style.setProperty("--wd-by", "0");
|
||
if (letztes === bild) letztes = null;
|
||
}, { passive: true });
|
||
}
|
||
|
||
/* Nach außen sichtbare Schnittstelle für seiteneigene Skripte. */
|
||
window.WD = {
|
||
sprachen: SPRACHEN,
|
||
getSprache: getSprache,
|
||
setSprache: setSprache,
|
||
t: feld,
|
||
ausObjekt: ausObjekt,
|
||
uebersetzeSeite: uebersetzeSeite,
|
||
api: api,
|
||
apiBasis: API_BASIS,
|
||
knopfSperren: knopfSperren,
|
||
meldung: meldung,
|
||
meldungAus: meldungAus,
|
||
preis: preis,
|
||
datum: datum,
|
||
datumZeit: datumZeit,
|
||
sicher: sicher,
|
||
};
|
||
})();
|