Umsetzung des "Website Masterplan" (16 Seiten) unter /webdesign. Zugangsschutz mit EIGENER Schranke (server/webdesign-gate.js) statt gate.js: gate.js laesst seit dem oeffentlichen Start am 21.08.2026 jeden durch, weil die Pruefung auf SITE_PUBLIC_LAUNCH_AT als allererste Zeile steht. Haette man /webdesign dahintergehaengt, waere der ausdruecklich nicht-oeffentliche Bereich inklusive Preisen und spaeteren Kundendaten ab der ersten Sekunde fuer jeden lesbar gewesen. Eigenes Sitzungs-Cookie, bereich="webdesign" im Token, damit ein gueltiges Universe-Cookie hier NICHT gilt. 37/37 Tests. Sieben oeffentliche Seiten in fuenf Sprachen (de, de-CH mit echtem Dialekt, en, fr, pt). Preise, Zeitrahmen, Paketnamen und die 30-%-Regel stehen an genau EINER Stelle in wd-core.js -- der Masterplan verlangt "ueberall widerspruchsfrei", und vier Kopien laufen bei der ersten Preisaenderung auseinander. Als App installierbar auf Handy und PC. Der Service Worker speichert bewusst KEINE HTML-Seite zwischen: nach dem Abmelden wuerden sonst geschuetzte Seiten weiter ausgeliefert, ohne dass der Server je gefragt wird. 18/18 Tests. Handy-Abnahme ueber alle Seiten in fuenf Breiten (320-1440) und fuenf Sprachen: 40/40. Der Test fand 35 echte Fehler (Touch-Ziele unter 44px), behoben im Designsystem statt einzeln pro Seite. PayPal (Wunsch 22.08.2026 "sofort auf meinem paypal"): Orders API mit intent=CAPTURE, also sofortiger Einzug statt blosser Reservierung. Gebuehr und Nettobetrag getrennt gespeichert. Betraege durchgehend als Ganzzahl in Cent. Gefaelschte Webhooks werden abgewiesen. Fail closed solange Zugangsdaten fehlen. 28/28 Tests gegen einen nachgebauten PayPal-Server. Datenbank: 15 Tabellen mit Praefix wd_, fachlich vollstaendig vom Universe getrennt. Co-Authored-By: Claude Opus 5 <[email protected]>
801 lines
41 KiB
JavaScript
801 lines
41 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_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_preise", href: "/webdesign/preise.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>" +
|
||
'<h4 data-i18n="fuss_bereiche">' + t("fuss_bereiche") + "</h4>" +
|
||
"<ul>" + bereicheHtml + "</ul>" +
|
||
"</div>" +
|
||
"<div>" +
|
||
'<h4 data-i18n="fuss_zugang">' + t("fuss_zugang") + "</h4>" +
|
||
"<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="/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();
|
||
}
|
||
}
|
||
|
||
/* 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;
|
||
}
|
||
|
||
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="/assets/img/wd-app-192.png" 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);
|
||
|
||
box.querySelector("[data-wd-install-weg]").addEventListener("click", function () {
|
||
box.remove();
|
||
/* 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 () {
|
||
box.remove();
|
||
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 () {
|
||
navigator.serviceWorker
|
||
.register("/webdesign/sw.js", { scope: "/webdesign/" })
|
||
.catch(function () { /* still schlucken: ohne SW funktioniert alles weiter */ });
|
||
});
|
||
}
|
||
|
||
/* ===================================================================
|
||
8. START
|
||
=================================================================== */
|
||
function start() {
|
||
document.body.classList.add("wd");
|
||
uebersetzeSeite();
|
||
zeichneKopf();
|
||
zeichneFuss();
|
||
einblendenAktivieren();
|
||
serviceWorkerAnmelden();
|
||
installierbarkeitAktivieren();
|
||
document.dispatchEvent(new CustomEvent("wd-bereit"));
|
||
}
|
||
|
||
if (document.readyState === "loading") {
|
||
document.addEventListener("DOMContentLoaded", start);
|
||
} else {
|
||
start();
|
||
}
|
||
|
||
/* 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,
|
||
sicher: sicher,
|
||
};
|
||
})();
|