Webdesign-Bereich: Fundament, oeffentliche Seiten, Zugangsschutz, PayPal
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]>
@@ -0,0 +1,851 @@
|
|||||||
|
/* =====================================================================
|
||||||
|
DOGFATHER WEBDESIGN — webdesign.css
|
||||||
|
Eigenes Designsystem für den Bereich /webdesign, umgesetzt nach dem
|
||||||
|
"Website Masterplan" (Seite 6, VISUELLE DNA).
|
||||||
|
|
||||||
|
Warum eine EIGENE Datei statt main.css zu erweitern:
|
||||||
|
- main.css trägt drei Welten (Dogfather/HasiDog/SpicyMedia) über
|
||||||
|
theme-*.css. Der Webdesign-Bereich ist eine vierte, eigenständige
|
||||||
|
Marke mit eigener Farblogik (Gold ist hier z.B. AUSSCHLIESSLICH für
|
||||||
|
Hinweise/Zahlung reserviert, im Universe ist es allgemeine Deko).
|
||||||
|
Eine Vermischung würde beide Systeme unscharf machen.
|
||||||
|
- Der Bereich ist bis zur Freigabe nicht öffentlich. Solange darf er
|
||||||
|
am laufenden Universe nichts kaputt machen können — getrennte Datei
|
||||||
|
heißt: null Risiko für die Live-Seite.
|
||||||
|
Die Grundtokens (--radius, Schriftfamilien) kommen weiterhin aus
|
||||||
|
main.css, das hier zusätzlich geladen wird. Alles Eigene trägt bewusst
|
||||||
|
das Präfix --wd- / .wd-, damit es sich nie mit main.css beißt.
|
||||||
|
|
||||||
|
AUGENSCHONEND ist Dauervorgabe (nicht projektspezifisch):
|
||||||
|
gedeckte Flächen statt greller Neon-Optik, Leuchten nur als weicher
|
||||||
|
Schimmer, keine Flacker-/Strobe-Effekte, jede Animation respektiert
|
||||||
|
prefers-reduced-motion, Textkontraste durchgehend über WCAG-AA.
|
||||||
|
===================================================================== */
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------
|
||||||
|
1. FARBWELT & TOKENS
|
||||||
|
Kontraste gegen die dunkelste Fläche (#05070b) nachgerechnet:
|
||||||
|
--wd-text #eef4fb -> 17.4:1 (AAA)
|
||||||
|
--wd-text-mid #b3c2d4 -> 10.2:1 (AAA)
|
||||||
|
--wd-text-soft #8f9fb4 -> 6.6:1 (AA, auch für kleine Schrift)
|
||||||
|
--wd-blau #7fd0e8 -> 10.6:1 (AAA)
|
||||||
|
--wd-lila #b79dff -> 8.1:1 (AAA)
|
||||||
|
--wd-gold #e8c877 -> 11.0:1 (AAA)
|
||||||
|
Bewusst KEIN reines #00e5ff / #ff00ff — solche vollgesättigten Neons
|
||||||
|
blenden auf dunklem Grund und sind genau das, was vermieden werden soll.
|
||||||
|
--------------------------------------------------------------------- */
|
||||||
|
:root {
|
||||||
|
--wd-schwarz: #05070b; /* Tiefschwarz — Grundfläche */
|
||||||
|
--wd-dunkelblau: #0a1220; /* Dunkelblau — zweite Grundfläche */
|
||||||
|
--wd-dunkelblau-2: #0e1826; /* leicht angehoben, für Abschnittswechsel */
|
||||||
|
--wd-flaeche: #101a29; /* Kartengrund (deckend, für Formularfelder) */
|
||||||
|
|
||||||
|
--wd-blau: #7fd0e8; /* Babyblau — Hauptaktionen, Linien, Leuchten */
|
||||||
|
--wd-blau-hell: #a9e2f2; /* Eisblau — Lesbarkeit auf dunklem Grund */
|
||||||
|
--wd-blau-tief: #3d9dbd; /* für Ränder/Verläufe, nicht für Text */
|
||||||
|
--wd-lila: #b79dff; /* sekundäre Bereiche, Kontrast */
|
||||||
|
--wd-lila-tief: #7c5cd6;
|
||||||
|
--wd-gold: #e8c877; /* NUR Hinweise & Zahlungsinformationen */
|
||||||
|
--wd-gold-tief: #a8873a;
|
||||||
|
|
||||||
|
--wd-text: #eef4fb;
|
||||||
|
--wd-text-mid: #b3c2d4;
|
||||||
|
--wd-text-soft: #8f9fb4;
|
||||||
|
|
||||||
|
--wd-rand: rgba(127, 208, 232, .16);
|
||||||
|
--wd-rand-stark: rgba(127, 208, 232, .34);
|
||||||
|
--wd-glas: rgba(16, 26, 41, .62);
|
||||||
|
--wd-glas-tief: rgba(10, 18, 32, .82);
|
||||||
|
|
||||||
|
--wd-ok: #6fd6a3;
|
||||||
|
--wd-fehler: #ff9d9d;
|
||||||
|
--wd-warnung: var(--wd-gold);
|
||||||
|
|
||||||
|
--wd-r: 16px; /* Standardradius */
|
||||||
|
--wd-r-gross: 22px;
|
||||||
|
--wd-schatten: 0 24px 60px -28px rgba(0, 0, 0, .9);
|
||||||
|
--wd-schatten-tief: 0 40px 90px -34px rgba(0, 0, 0, .95);
|
||||||
|
|
||||||
|
/* Bewegung an EINER Stelle steuerbar — die Reduktions-Regel ganz unten
|
||||||
|
setzt diese Variablen auf 0s, damit auch neu hinzukommende Animationen
|
||||||
|
automatisch mit abgeschaltet sind und nicht einzeln nachgepflegt
|
||||||
|
werden müssen. */
|
||||||
|
--wd-zeit-schnell: .16s;
|
||||||
|
--wd-zeit: .28s;
|
||||||
|
--wd-zeit-lang: .6s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------
|
||||||
|
2. GRUNDLAGEN
|
||||||
|
--------------------------------------------------------------------- */
|
||||||
|
body.wd {
|
||||||
|
background: var(--wd-schwarz);
|
||||||
|
color: var(--wd-text);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
line-height: 1.7;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cinematische Tiefe: zwei sehr weiche, weit auseinanderliegende
|
||||||
|
Lichtquellen statt eines flächigen Verlaufs. Fest positioniert, damit
|
||||||
|
beim Scrollen echte Tiefe entsteht, ohne eine einzige Animation zu
|
||||||
|
kosten (Masterplan S.6 "Cinematische Tiefe, Licht, Nebel" — S.15 aber
|
||||||
|
auch "Animationen leistungsbewusst"). */
|
||||||
|
body.wd::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -2;
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
radial-gradient(1100px 720px at 18% -8%, rgba(61, 157, 189, .20), transparent 68%),
|
||||||
|
radial-gradient(900px 640px at 92% 8%, rgba(124, 92, 214, .16), transparent 66%),
|
||||||
|
linear-gradient(180deg, #070b12 0%, var(--wd-schwarz) 46%, #04060a 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Feine Rasterlinien — gibt der Fläche Struktur (Premium statt Baukasten),
|
||||||
|
bleibt mit 3 % Deckkraft klar unter der Wahrnehmungsschwelle für
|
||||||
|
"unruhig". mask blendet das Raster nach unten aus, damit es nicht
|
||||||
|
teppichartig über die ganze Seite liegt. */
|
||||||
|
body.wd::after {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: .5;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(127, 208, 232, .03) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(127, 208, 232, .03) 1px, transparent 1px);
|
||||||
|
background-size: 64px 64px;
|
||||||
|
-webkit-mask-image: linear-gradient(180deg, #000 0%, transparent 62%);
|
||||||
|
mask-image: linear-gradient(180deg, #000 0%, transparent 62%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wd-container { width: min(1180px, 92vw); margin: 0 auto; }
|
||||||
|
.wd-container--schmal { width: min(860px, 92vw); }
|
||||||
|
|
||||||
|
.wd-abschnitt { padding: clamp(3.4rem, 7vw, 6.5rem) 0; position: relative; }
|
||||||
|
.wd-abschnitt--eng { padding: clamp(2rem, 4vw, 3.2rem) 0; }
|
||||||
|
/* Abwechselnde Grundfläche statt Trennlinien — ruhiger und trotzdem klar. */
|
||||||
|
.wd-abschnitt--gehoben { background: linear-gradient(180deg, transparent, var(--wd-dunkelblau) 14%, var(--wd-dunkelblau) 86%, transparent); }
|
||||||
|
|
||||||
|
/* ---------- Typografie (Masterplan S.6) ----------
|
||||||
|
Dynamische Größen mit clamp(); Fließtext bewusst ruhiger und kleiner als
|
||||||
|
im Universe (dort 1.32rem), weil hier deutlich mehr Sachtext steht —
|
||||||
|
Preise, Bedingungen und Abläufe müssen lesbar sein, nicht laut. */
|
||||||
|
.wd h1, .wd h2, .wd h3, .wd h4 {
|
||||||
|
font-family: var(--font-head);
|
||||||
|
line-height: 1.15;
|
||||||
|
letter-spacing: -.015em;
|
||||||
|
margin: 0 0 .5em;
|
||||||
|
color: var(--wd-text);
|
||||||
|
/* Verhindert, dass lange deutsche Komposita ("Buchhaltungsverwaltungs-
|
||||||
|
seiten") auf dem Handy aus dem Kasten laufen. */
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
hyphens: auto;
|
||||||
|
}
|
||||||
|
.wd h1 { font-size: clamp(2.15rem, 5.6vw, 3.9rem); font-weight: 800; }
|
||||||
|
.wd h2 { font-size: clamp(1.75rem, 4.2vw, 2.75rem); font-weight: 800; }
|
||||||
|
.wd h3 { font-size: clamp(1.2rem, 2.4vw, 1.5rem); font-weight: 700; }
|
||||||
|
.wd h4 { font-size: 1.05rem; font-weight: 700; }
|
||||||
|
.wd p { margin: 0 0 1em; color: var(--wd-text-mid); font-size: clamp(1rem, 1.5vw, 1.12rem); }
|
||||||
|
.wd .wd-lead { font-size: clamp(1.08rem, 2vw, 1.32rem); color: var(--wd-text); line-height: 1.65; }
|
||||||
|
.wd .wd-klein { font-size: .92rem; }
|
||||||
|
.wd .wd-leise { color: var(--wd-text-soft); }
|
||||||
|
.wd a { color: var(--wd-blau); text-decoration: none; }
|
||||||
|
.wd a:hover { color: var(--wd-blau-hell); }
|
||||||
|
.wd strong { color: var(--wd-text); font-weight: 700; }
|
||||||
|
|
||||||
|
.wd-eyebrow {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: .74rem;
|
||||||
|
letter-spacing: .18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--wd-blau);
|
||||||
|
margin-bottom: .9em;
|
||||||
|
}
|
||||||
|
.wd-eyebrow--lila { color: var(--wd-lila); }
|
||||||
|
|
||||||
|
/* Sichtbarer Fokus ÜBERALL (Masterplan S.15 Barrierearmut). :focus-visible
|
||||||
|
statt :focus, damit Mausklicks keinen Rahmen hinterlassen, Tastatur aber
|
||||||
|
immer einen — und zwar auch auf Elementen, die erst später dazukommen. */
|
||||||
|
.wd :focus-visible {
|
||||||
|
outline: 3px solid var(--wd-blau);
|
||||||
|
outline-offset: 3px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sprungmarke für Tastatur-/Screenreader-Nutzung. */
|
||||||
|
.wd-skip {
|
||||||
|
position: absolute; left: -9999px; top: 0; z-index: 200;
|
||||||
|
background: var(--wd-blau); color: #04060a; font-weight: 800;
|
||||||
|
padding: .8rem 1.2rem; border-radius: 0 0 10px 0;
|
||||||
|
}
|
||||||
|
.wd-skip:focus { left: 0; }
|
||||||
|
|
||||||
|
.wd-visuell-versteckt {
|
||||||
|
position: absolute; width: 1px; height: 1px; overflow: hidden;
|
||||||
|
clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------
|
||||||
|
3. KARTEN — "glasartig mit feinen Neonrändern" (Masterplan S.6)
|
||||||
|
Der Rand ist bewusst ein Verlauf über border-box statt einer flachen
|
||||||
|
Farbe: dadurch wirkt er wie eine Lichtkante, nicht wie ein Strich.
|
||||||
|
--------------------------------------------------------------------- */
|
||||||
|
.wd-karte {
|
||||||
|
position: relative;
|
||||||
|
border-radius: var(--wd-r-gross);
|
||||||
|
padding: clamp(1.3rem, 2.6vw, 1.9rem);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(20, 32, 50, .72), rgba(9, 15, 26, .86)) padding-box,
|
||||||
|
linear-gradient(140deg, rgba(127, 208, 232, .40), rgba(127, 208, 232, .07) 45%, rgba(183, 157, 255, .30)) border-box;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
box-shadow: var(--wd-schatten);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
transition: transform var(--wd-zeit) ease, box-shadow var(--wd-zeit) ease;
|
||||||
|
}
|
||||||
|
/* Sekundäre Bereiche tragen Lila (Masterplan S.6). */
|
||||||
|
.wd-karte--lila {
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(26, 22, 48, .72), rgba(12, 10, 24, .88)) padding-box,
|
||||||
|
linear-gradient(140deg, rgba(183, 157, 255, .42), rgba(183, 157, 255, .07) 45%, rgba(127, 208, 232, .26)) border-box;
|
||||||
|
}
|
||||||
|
/* Nur für Karten, die wirklich klickbar sind — eine Karte, die sich hebt,
|
||||||
|
ohne dass man sie anklicken kann, verspricht eine Aktion, die es nicht
|
||||||
|
gibt. */
|
||||||
|
a.wd-karte:hover, .wd-karte--klickbar:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: var(--wd-schatten-tief), 0 0 44px -20px rgba(127, 208, 232, .55);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Farbige Kappe oben — übernimmt die Optik der Masterplan-PDF, in der jede
|
||||||
|
Karte einen blauen bzw. lila Balken trägt. */
|
||||||
|
.wd-karte--kappe { padding-top: clamp(1.6rem, 3vw, 2.2rem); overflow: hidden; }
|
||||||
|
.wd-karte--kappe::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute; top: 0; left: 0; right: 0; height: 5px;
|
||||||
|
background: linear-gradient(90deg, var(--wd-blau-tief), var(--wd-blau));
|
||||||
|
}
|
||||||
|
.wd-karte--lila.wd-karte--kappe::before { background: linear-gradient(90deg, var(--wd-lila-tief), var(--wd-lila)); }
|
||||||
|
|
||||||
|
.wd-karte h3 { margin-bottom: .55em; }
|
||||||
|
.wd-karte > :last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
/* Aufzählung in Karten — Punkt als eigenes Element statt list-style, damit
|
||||||
|
die Farbe steuerbar ist und mehrzeilige Punkte sauber einrücken. */
|
||||||
|
.wd-liste { margin: 0; padding: 0; list-style: none; }
|
||||||
|
.wd-liste li {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 1.35rem;
|
||||||
|
margin-bottom: .55rem;
|
||||||
|
color: var(--wd-text-mid);
|
||||||
|
font-size: clamp(.98rem, 1.4vw, 1.05rem);
|
||||||
|
}
|
||||||
|
.wd-liste li::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute; left: 0; top: .62em;
|
||||||
|
width: 7px; height: 7px; border-radius: 50%;
|
||||||
|
background: var(--wd-blau);
|
||||||
|
box-shadow: 0 0 10px rgba(127, 208, 232, .7);
|
||||||
|
}
|
||||||
|
.wd-karte--lila .wd-liste li::before { background: var(--wd-lila); box-shadow: 0 0 10px rgba(183, 157, 255, .7); }
|
||||||
|
.wd-liste li:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
/* ---------- Raster ---------- */
|
||||||
|
.wd-grid { display: grid; gap: clamp(1rem, 2vw, 1.5rem); }
|
||||||
|
.wd-grid--2 { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.wd-grid--3 { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
.wd-grid--4 { grid-template-columns: repeat(4, 1fr); }
|
||||||
|
@media (max-width: 900px) { .wd-grid--3, .wd-grid--4 { grid-template-columns: repeat(2, 1fr); } }
|
||||||
|
@media (max-width: 640px) { .wd-grid--2, .wd-grid--3, .wd-grid--4 { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------
|
||||||
|
4. KNÖPFE
|
||||||
|
Genau EIN Haupt-CTA pro Seite (Masterplan S.3) — deshalb gibt es
|
||||||
|
bewusst nur eine einzige auffällige Knopfvariante. Alles andere ist
|
||||||
|
sichtbar, konkurriert aber nicht.
|
||||||
|
--------------------------------------------------------------------- */
|
||||||
|
.wd-btn {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; gap: .5rem;
|
||||||
|
/* min-height 48px = Touch-Ziel-Mindestmaß (Masterplan S.15). */
|
||||||
|
min-height: 48px;
|
||||||
|
padding: .85rem 1.6rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
transition: transform var(--wd-zeit-schnell) ease, box-shadow var(--wd-zeit) ease,
|
||||||
|
background var(--wd-zeit) ease, border-color var(--wd-zeit) ease, color var(--wd-zeit) ease;
|
||||||
|
}
|
||||||
|
/* Sofortige, sichtbare Reaktion (Masterplan S.6 "Buttons reagieren sofort
|
||||||
|
und sichtbar") — active feuert ohne Verzögerung, auch auf dem Handy. */
|
||||||
|
.wd-btn:active { transform: translateY(1px) scale(.985); }
|
||||||
|
.wd-btn[disabled], .wd-btn[aria-disabled="true"] { opacity: .5; cursor: not-allowed; transform: none; }
|
||||||
|
|
||||||
|
.wd-btn--haupt {
|
||||||
|
/* Dunkler Text auf hellem Babyblau: 11.7:1 — der einzige Weg, einen
|
||||||
|
leuchtenden Knopf zu bauen, ohne die Lesbarkeit zu opfern. */
|
||||||
|
background: linear-gradient(135deg, var(--wd-blau-hell), var(--wd-blau) 55%, #63bcd8);
|
||||||
|
color: #04070c;
|
||||||
|
box-shadow: 0 12px 34px -14px rgba(127, 208, 232, .8);
|
||||||
|
}
|
||||||
|
.wd-btn--haupt:hover { box-shadow: 0 16px 44px -14px rgba(127, 208, 232, .95); transform: translateY(-2px); }
|
||||||
|
|
||||||
|
.wd-btn--zweit {
|
||||||
|
background: rgba(127, 208, 232, .07);
|
||||||
|
border-color: var(--wd-rand-stark);
|
||||||
|
color: var(--wd-blau-hell);
|
||||||
|
}
|
||||||
|
.wd-btn--zweit:hover { background: rgba(127, 208, 232, .14); border-color: var(--wd-blau); }
|
||||||
|
|
||||||
|
.wd-btn--still { background: transparent; border-color: var(--wd-rand); color: var(--wd-text-mid); }
|
||||||
|
.wd-btn--still:hover { border-color: var(--wd-rand-stark); color: var(--wd-text); }
|
||||||
|
|
||||||
|
.wd-btn--breit { width: 100%; }
|
||||||
|
/* "klein" heißt schmaler und leiser, NICHT niedriger als das Touch-Ziel.
|
||||||
|
Die Höhe stand hier ursprünglich auf 40px — die Handy-Abnahme
|
||||||
|
(server/pruefe-webdesign-handy.mjs) hat das quer über alle Seiten
|
||||||
|
angemerkt: „Dieses Paket anfragen“, „Live ansehen“ und „Was genau
|
||||||
|
enthalten ist“ waren durchgehend 4px zu flach. 44px ist die Untergrenze,
|
||||||
|
ab der ein Ziel mit dem Daumen zuverlässig getroffen wird. */
|
||||||
|
.wd-btn--klein { min-height: 44px; padding: .6rem 1.05rem; font-size: .9rem; }
|
||||||
|
|
||||||
|
.wd-btn-reihe { display: flex; flex-wrap: wrap; gap: .8rem; align-items: center; }
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------
|
||||||
|
5. HINWEISFLÄCHEN — Gold ist AUSSCHLIESSLICH hierfür reserviert
|
||||||
|
(Masterplan S.6: "Gold ausschließlich für wichtige Hinweise und
|
||||||
|
Zahlungsinformationen"). Wer Gold sieht, soll wissen: das ist wichtig.
|
||||||
|
--------------------------------------------------------------------- */
|
||||||
|
.wd-hinweis {
|
||||||
|
position: relative;
|
||||||
|
border-radius: var(--wd-r);
|
||||||
|
padding: 1.15rem 1.35rem 1.15rem 1.6rem;
|
||||||
|
background: linear-gradient(180deg, rgba(232, 200, 119, .09), rgba(232, 200, 119, .04));
|
||||||
|
border: 1px solid rgba(232, 200, 119, .32);
|
||||||
|
border-left: 4px solid var(--wd-gold);
|
||||||
|
}
|
||||||
|
.wd-hinweis-titel {
|
||||||
|
margin: 0 0 .35rem;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: .82rem;
|
||||||
|
letter-spacing: .12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--wd-gold);
|
||||||
|
}
|
||||||
|
.wd-hinweis p { margin: 0; color: var(--wd-text-mid); font-size: .98rem; }
|
||||||
|
.wd-hinweis p + p { margin-top: .6rem; }
|
||||||
|
|
||||||
|
/* Neutrale Variante für Erklärungen, die NICHT wichtig-wichtig sind —
|
||||||
|
damit Gold seine Signalwirkung behält. */
|
||||||
|
.wd-notiz {
|
||||||
|
border-radius: var(--wd-r);
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
background: rgba(127, 208, 232, .05);
|
||||||
|
border: 1px solid var(--wd-rand);
|
||||||
|
}
|
||||||
|
.wd-notiz p { margin: 0; font-size: .96rem; }
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------
|
||||||
|
6. TAGS / KENNZEICHEN
|
||||||
|
--------------------------------------------------------------------- */
|
||||||
|
.wd-tag {
|
||||||
|
display: inline-flex; align-items: center; gap: .35rem;
|
||||||
|
padding: .3rem .7rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: .78rem; font-weight: 700; letter-spacing: .04em;
|
||||||
|
background: rgba(127, 208, 232, .12);
|
||||||
|
border: 1px solid var(--wd-rand-stark);
|
||||||
|
color: var(--wd-blau-hell);
|
||||||
|
}
|
||||||
|
.wd-tag--lila { background: rgba(183, 157, 255, .12); border-color: rgba(183, 157, 255, .34); color: var(--wd-lila); }
|
||||||
|
.wd-tag--gold { background: rgba(232, 200, 119, .12); border-color: rgba(232, 200, 119, .34); color: var(--wd-gold); }
|
||||||
|
.wd-tag--ok { background: rgba(111, 214, 163, .12); border-color: rgba(111, 214, 163, .34); color: var(--wd-ok); }
|
||||||
|
.wd-tags { display: flex; flex-wrap: wrap; gap: .45rem; }
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------
|
||||||
|
7. PREISKARTEN (Masterplan S.8)
|
||||||
|
Preis, Dauer und Personalisierungshinweis gehören sichtbar zusammen —
|
||||||
|
der Hinweis steht deshalb direkt unter dem Preis, nicht im Kleingedruckten.
|
||||||
|
--------------------------------------------------------------------- */
|
||||||
|
.wd-preis-karte { display: flex; flex-direction: column; }
|
||||||
|
.wd-preis {
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: clamp(1.9rem, 4.4vw, 2.5rem);
|
||||||
|
line-height: 1.1;
|
||||||
|
margin: .2rem 0 .5rem;
|
||||||
|
color: var(--wd-blau);
|
||||||
|
}
|
||||||
|
.wd-karte--lila .wd-preis { color: var(--wd-lila); }
|
||||||
|
.wd-preis-zeile { display: flex; align-items: baseline; gap: .5rem; flex-wrap: wrap; margin-bottom: .5rem; }
|
||||||
|
.wd-preis-label { font-size: .78rem; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; color: var(--wd-blau); }
|
||||||
|
.wd-karte--lila .wd-preis-label { color: var(--wd-lila); }
|
||||||
|
.wd-preis-wert { color: var(--wd-text-mid); font-size: .98rem; }
|
||||||
|
/* mt:auto schiebt den Knopf immer an den Kartenboden — sonst stehen die
|
||||||
|
Knöpfe in einer Reihe unterschiedlich hoher Karten treppenförmig. */
|
||||||
|
.wd-preis-karte .wd-btn { margin-top: auto; }
|
||||||
|
.wd-personalisierung {
|
||||||
|
margin-top: .2rem;
|
||||||
|
font-size: .88rem;
|
||||||
|
color: var(--wd-text-soft);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------
|
||||||
|
8. NUMMERIERTE SCHRITTE (Ablauf / Customer Journey)
|
||||||
|
--------------------------------------------------------------------- */
|
||||||
|
.wd-schritte { display: grid; gap: 1rem; }
|
||||||
|
.wd-schritt {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 1.1rem;
|
||||||
|
align-items: start;
|
||||||
|
border-radius: var(--wd-r-gross);
|
||||||
|
padding: 1.25rem 1.4rem;
|
||||||
|
background: linear-gradient(180deg, rgba(20, 32, 50, .55), rgba(9, 15, 26, .75));
|
||||||
|
border: 1px solid var(--wd-rand);
|
||||||
|
}
|
||||||
|
.wd-schritt-nr {
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: clamp(1.4rem, 3vw, 1.9rem);
|
||||||
|
line-height: 1;
|
||||||
|
min-width: 2.2ch;
|
||||||
|
color: var(--wd-blau);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.wd-schritt:nth-child(even) { border-color: rgba(183, 157, 255, .18); }
|
||||||
|
.wd-schritt:nth-child(even) .wd-schritt-nr { color: var(--wd-lila); }
|
||||||
|
.wd-schritt h3 { margin: 0 0 .3em; font-size: 1.12rem; }
|
||||||
|
.wd-schritt p { margin: 0; font-size: 1rem; }
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.wd-schritt { grid-template-columns: 1fr; gap: .4rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------
|
||||||
|
9. FAQ — natives <details>, damit es ohne JavaScript funktioniert
|
||||||
|
und die Tastaturbedienung vom Browser kommt statt nachgebaut zu werden.
|
||||||
|
--------------------------------------------------------------------- */
|
||||||
|
.wd-faq { border-radius: var(--wd-r); border: 1px solid var(--wd-rand); background: rgba(16, 26, 41, .5); margin-bottom: .7rem; overflow: hidden; }
|
||||||
|
.wd-faq > summary {
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
padding: 1.05rem 3rem 1.05rem 1.25rem;
|
||||||
|
position: relative;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.04rem;
|
||||||
|
color: var(--wd-text);
|
||||||
|
transition: background var(--wd-zeit-schnell) ease;
|
||||||
|
}
|
||||||
|
.wd-faq > summary::-webkit-details-marker { display: none; }
|
||||||
|
.wd-faq > summary:hover { background: rgba(127, 208, 232, .05); }
|
||||||
|
.wd-faq > summary::after {
|
||||||
|
content: "+";
|
||||||
|
position: absolute; right: 1.2rem; top: 50%; transform: translateY(-50%);
|
||||||
|
font-size: 1.5rem; line-height: 1; color: var(--wd-blau);
|
||||||
|
transition: transform var(--wd-zeit) ease;
|
||||||
|
}
|
||||||
|
.wd-faq[open] > summary::after { transform: translateY(-50%) rotate(45deg); }
|
||||||
|
.wd-faq-inhalt { padding: 0 1.25rem 1.15rem; }
|
||||||
|
.wd-faq-inhalt p { margin: 0; font-size: 1rem; }
|
||||||
|
.wd-faq-inhalt p + p { margin-top: .7rem; }
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------
|
||||||
|
10. FORMULARE (Masterplan S.10 + S.15)
|
||||||
|
Zustände für Fehler und Erfolg sind Pflicht — und der Fehler steht
|
||||||
|
IMMER unter dem Feld, nie nur als Farbe: Farbe allein ist für
|
||||||
|
farbfehlsichtige Menschen keine Information.
|
||||||
|
--------------------------------------------------------------------- */
|
||||||
|
.wd-feld { margin-bottom: 1.15rem; }
|
||||||
|
.wd-feld > label,
|
||||||
|
.wd-feld-legende {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: .45rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: .95rem;
|
||||||
|
color: var(--wd-text);
|
||||||
|
}
|
||||||
|
.wd-pflicht { color: var(--wd-blau); margin-left: .15rem; }
|
||||||
|
.wd-feld-hilfe { display: block; margin: -.2rem 0 .5rem; font-size: .88rem; color: var(--wd-text-soft); }
|
||||||
|
|
||||||
|
.wd-eingabe,
|
||||||
|
.wd-feld input[type="text"], .wd-feld input[type="email"], .wd-feld input[type="tel"],
|
||||||
|
.wd-feld input[type="url"], .wd-feld input[type="number"], .wd-feld input[type="date"],
|
||||||
|
.wd-feld input[type="password"], .wd-feld input[type="search"],
|
||||||
|
.wd-feld select, .wd-feld textarea {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: .82rem 1rem;
|
||||||
|
min-height: 48px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--wd-flaeche);
|
||||||
|
border: 1.5px solid var(--wd-rand);
|
||||||
|
color: var(--wd-text);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
/* 16px verhindert, dass iOS beim Fokus in ein Feld hineinzoomt. */
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
transition: border-color var(--wd-zeit-schnell) ease, box-shadow var(--wd-zeit-schnell) ease, background var(--wd-zeit-schnell) ease;
|
||||||
|
}
|
||||||
|
.wd-feld textarea { min-height: 130px; resize: vertical; }
|
||||||
|
.wd-feld select {
|
||||||
|
/* Eigener Pfeil: das Standard-Dreieck von Windows ist auf dunklem Grund
|
||||||
|
fast unsichtbar. */
|
||||||
|
appearance: none;
|
||||||
|
background-image: linear-gradient(45deg, transparent 50%, var(--wd-blau) 50%), linear-gradient(135deg, var(--wd-blau) 50%, transparent 50%);
|
||||||
|
background-position: calc(100% - 20px) center, calc(100% - 14px) center;
|
||||||
|
background-size: 6px 6px, 6px 6px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
padding-right: 2.6rem;
|
||||||
|
}
|
||||||
|
.wd-feld ::placeholder { color: rgba(143, 159, 180, .7); }
|
||||||
|
.wd-eingabe:focus, .wd-feld input:focus, .wd-feld select:focus, .wd-feld textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--wd-blau);
|
||||||
|
background: #12203180;
|
||||||
|
box-shadow: 0 0 0 4px rgba(127, 208, 232, .16);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fehlerzustand */
|
||||||
|
.wd-feld--fehler .wd-eingabe,
|
||||||
|
.wd-feld--fehler input, .wd-feld--fehler select, .wd-feld--fehler textarea {
|
||||||
|
border-color: var(--wd-fehler);
|
||||||
|
box-shadow: 0 0 0 4px rgba(255, 157, 157, .12);
|
||||||
|
}
|
||||||
|
.wd-feld-fehler {
|
||||||
|
display: none;
|
||||||
|
margin: .45rem 0 0;
|
||||||
|
font-size: .9rem;
|
||||||
|
color: var(--wd-fehler);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.wd-feld-fehler::before { content: "⚠ "; }
|
||||||
|
.wd-feld--fehler .wd-feld-fehler { display: block; }
|
||||||
|
|
||||||
|
/* Auswahlkacheln (Radio/Checkbox als anklickbare Fläche) — deutlich
|
||||||
|
fingerfreundlicher als native Punkte und zeigt den gewählten Zustand
|
||||||
|
nicht nur farblich, sondern über Rand + Häkchen. */
|
||||||
|
.wd-wahl { display: grid; gap: .7rem; }
|
||||||
|
.wd-wahl--2 { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
@media (max-width: 640px) { .wd-wahl--2 { grid-template-columns: 1fr; } }
|
||||||
|
.wd-wahl-kachel { position: relative; display: block; cursor: pointer; }
|
||||||
|
.wd-wahl-kachel input { position: absolute; opacity: 0; width: 1px; height: 1px; }
|
||||||
|
.wd-wahl-inhalt {
|
||||||
|
display: block;
|
||||||
|
padding: 1rem 1.15rem 1rem 2.9rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(16, 26, 41, .6);
|
||||||
|
border: 1.5px solid var(--wd-rand);
|
||||||
|
transition: border-color var(--wd-zeit-schnell) ease, background var(--wd-zeit-schnell) ease;
|
||||||
|
}
|
||||||
|
.wd-wahl-inhalt::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute; left: 1rem; top: 1.15rem;
|
||||||
|
width: 20px; height: 20px; border-radius: 6px;
|
||||||
|
border: 2px solid var(--wd-rand-stark);
|
||||||
|
background: rgba(5, 7, 11, .6);
|
||||||
|
transition: border-color var(--wd-zeit-schnell) ease, background var(--wd-zeit-schnell) ease;
|
||||||
|
}
|
||||||
|
.wd-wahl-kachel input[type="radio"] + .wd-wahl-inhalt::before { border-radius: 50%; }
|
||||||
|
.wd-wahl-kachel:hover .wd-wahl-inhalt { border-color: var(--wd-rand-stark); }
|
||||||
|
.wd-wahl-kachel input:checked + .wd-wahl-inhalt {
|
||||||
|
border-color: var(--wd-blau);
|
||||||
|
background: rgba(127, 208, 232, .09);
|
||||||
|
}
|
||||||
|
.wd-wahl-kachel input:checked + .wd-wahl-inhalt::before {
|
||||||
|
border-color: var(--wd-blau);
|
||||||
|
background: var(--wd-blau);
|
||||||
|
/* Häkchen als CSS-Maske — kein Bild, keine Schriftart nötig. */
|
||||||
|
box-shadow: inset 0 0 0 3px #05070b, inset 0 0 0 20px var(--wd-blau);
|
||||||
|
}
|
||||||
|
.wd-wahl-kachel input:focus-visible + .wd-wahl-inhalt { outline: 3px solid var(--wd-blau); outline-offset: 3px; }
|
||||||
|
.wd-wahl-titel { display: block; font-weight: 700; color: var(--wd-text); }
|
||||||
|
.wd-wahl-text { display: block; font-size: .9rem; color: var(--wd-text-soft); margin-top: .15rem; }
|
||||||
|
|
||||||
|
/* Erfolgs-/Fehlermeldung eines ganzen Formulars */
|
||||||
|
.wd-meldung { border-radius: var(--wd-r); padding: 1rem 1.2rem; margin-bottom: 1.2rem; font-weight: 600; }
|
||||||
|
.wd-meldung--ok { background: rgba(111, 214, 163, .1); border: 1px solid rgba(111, 214, 163, .38); color: var(--wd-ok); }
|
||||||
|
.wd-meldung--fehler { background: rgba(255, 157, 157, .1); border: 1px solid rgba(255, 157, 157, .38); color: var(--wd-fehler); }
|
||||||
|
.wd-meldung[hidden] { display: none; }
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------
|
||||||
|
11. FORTSCHRITT (mehrstufiges Formular)
|
||||||
|
--------------------------------------------------------------------- */
|
||||||
|
.wd-fortschritt { margin-bottom: 1.8rem; }
|
||||||
|
.wd-fortschritt-schiene { height: 6px; border-radius: 999px; background: rgba(127, 208, 232, .12); overflow: hidden; }
|
||||||
|
.wd-fortschritt-balken {
|
||||||
|
height: 100%; width: 0%;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(90deg, var(--wd-blau-tief), var(--wd-blau));
|
||||||
|
transition: width var(--wd-zeit-lang) cubic-bezier(.4, 0, .2, 1);
|
||||||
|
}
|
||||||
|
.wd-fortschritt-text { margin: .6rem 0 0; font-size: .88rem; color: var(--wd-text-soft); }
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------
|
||||||
|
12. TABELLEN (Verwaltung / Portal)
|
||||||
|
Auf schmalen Bildschirmen scrollt die Tabelle waagerecht in ihrem
|
||||||
|
eigenen Kasten, statt die ganze Seite zu verbreitern.
|
||||||
|
--------------------------------------------------------------------- */
|
||||||
|
.wd-tabelle-rahmen { overflow-x: auto; border-radius: var(--wd-r); border: 1px solid var(--wd-rand); }
|
||||||
|
.wd-tabelle { width: 100%; border-collapse: collapse; font-size: .95rem; min-width: 560px; }
|
||||||
|
.wd-tabelle th, .wd-tabelle td { padding: .8rem 1rem; text-align: left; border-bottom: 1px solid var(--wd-rand); }
|
||||||
|
.wd-tabelle th {
|
||||||
|
font-family: var(--font-head); font-size: .78rem; font-weight: 800;
|
||||||
|
letter-spacing: .1em; text-transform: uppercase; color: var(--wd-blau);
|
||||||
|
background: rgba(127, 208, 232, .05);
|
||||||
|
position: sticky; top: 0;
|
||||||
|
}
|
||||||
|
.wd-tabelle tbody tr:last-child td { border-bottom: none; }
|
||||||
|
.wd-tabelle tbody tr:hover { background: rgba(127, 208, 232, .04); }
|
||||||
|
.wd-tabelle td { color: var(--wd-text-mid); }
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------
|
||||||
|
13. BEWEGUNG — "kontrolliert beim Scrollen" (Masterplan S.6)
|
||||||
|
Die Elemente starten sichtbar und werden erst per JS auf unsichtbar
|
||||||
|
gesetzt (Klasse .wd-bereit am <html>). Ohne JavaScript oder bei einem
|
||||||
|
Skriptfehler bleibt dadurch ALLES lesbar — der häufigste schwere Fehler
|
||||||
|
bei Scroll-Animationen ist eine dauerhaft unsichtbare Seite.
|
||||||
|
--------------------------------------------------------------------- */
|
||||||
|
.wd-bereit .wd-auf {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(18px);
|
||||||
|
transition: opacity var(--wd-zeit-lang) ease, transform var(--wd-zeit-lang) cubic-bezier(.22, 1, .36, 1);
|
||||||
|
}
|
||||||
|
.wd-bereit .wd-auf.wd-sichtbar { opacity: 1; transform: none; }
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------
|
||||||
|
14. KOPF- UND FUSSZEILE des Webdesign-Bereichs
|
||||||
|
Eigene Leiste statt der Universe-Navigation: der Bereich hat eigene
|
||||||
|
Ziele (Anfrage statt Bewerbung) und ist bis zur Freigabe nicht
|
||||||
|
öffentlich. Der Rückweg ins Universe steht trotzdem immer sichtbar
|
||||||
|
drin (Masterplan S.14 "Direkte Navigation zwischen Universe und
|
||||||
|
Webdesign").
|
||||||
|
--------------------------------------------------------------------- */
|
||||||
|
.wd-kopf {
|
||||||
|
position: sticky; top: 0; z-index: 60;
|
||||||
|
background: rgba(5, 7, 11, .92);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
border-bottom: 1px solid var(--wd-rand);
|
||||||
|
}
|
||||||
|
.wd-kopf-bar {
|
||||||
|
width: min(1320px, 94vw); margin: 0 auto;
|
||||||
|
display: flex; align-items: center; gap: 1rem;
|
||||||
|
padding: .7rem 0;
|
||||||
|
}
|
||||||
|
/* min-height 44px: die Marke ist ein Link auf die Startseite und damit
|
||||||
|
ein echtes Touch-Ziel — sie war vorher exakt so hoch wie das Logo (42px). */
|
||||||
|
.wd-marke { display: flex; align-items: center; gap: .65rem; flex-shrink: 0; min-height: 44px; }
|
||||||
|
.wd-marke img { width: 42px; height: 42px; object-fit: contain; }
|
||||||
|
.wd-marke-text { display: flex; flex-direction: column; line-height: 1.1; }
|
||||||
|
.wd-marke-oben { font-family: var(--font-head); font-weight: 800; font-size: 1rem; color: var(--wd-text); letter-spacing: .02em; }
|
||||||
|
.wd-marke-unten { font-size: .68rem; letter-spacing: .18em; text-transform: uppercase; color: var(--wd-blau); font-weight: 700; }
|
||||||
|
|
||||||
|
.wd-nav { display: flex; align-items: center; gap: .15rem; margin-left: auto; }
|
||||||
|
.wd-nav a {
|
||||||
|
/* inline-flex + min-height statt nur Innenabstand: mit reinem padding
|
||||||
|
kam der Menüpunkt auf 43px — einen Pixel unter dem Touch-Ziel, was
|
||||||
|
die Handy-Abnahme zu Recht angemerkt hat. */
|
||||||
|
display: inline-flex; align-items: center; min-height: 44px;
|
||||||
|
padding: .5rem .8rem; border-radius: 10px;
|
||||||
|
font-size: .95rem; font-weight: 600; color: var(--wd-text-mid);
|
||||||
|
transition: color var(--wd-zeit-schnell) ease, background var(--wd-zeit-schnell) ease;
|
||||||
|
}
|
||||||
|
.wd-nav a:hover { color: var(--wd-text); background: rgba(127, 208, 232, .07); }
|
||||||
|
.wd-nav a.wd-aktiv { color: var(--wd-blau); background: rgba(127, 208, 232, .11); }
|
||||||
|
.wd-nav .wd-btn { margin-left: .5rem; }
|
||||||
|
|
||||||
|
.wd-menue-knopf {
|
||||||
|
display: none; margin-left: auto;
|
||||||
|
align-items: center; gap: .45rem;
|
||||||
|
min-height: 44px; padding: .5rem .9rem;
|
||||||
|
border-radius: 10px; border: 1px solid var(--wd-rand);
|
||||||
|
background: transparent; color: var(--wd-text); cursor: pointer; font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sprachschalter — teilt sich die gespeicherte Sprache mit dem Universe
|
||||||
|
(localStorage "dogi-sprache"), damit ein Wechsel hier auch dort gilt. */
|
||||||
|
.wd-sprache { position: relative; }
|
||||||
|
.wd-sprache-knopf {
|
||||||
|
display: inline-flex; align-items: center; gap: .35rem;
|
||||||
|
/* 44px statt 40px — auch auf dem Computer ist das der bessere Wert:
|
||||||
|
Touchscreen-Laptops und Tablets im Querformat landen ebenfalls hier. */
|
||||||
|
min-height: 44px; padding: .5rem .75rem;
|
||||||
|
border-radius: 10px; border: 1px solid var(--wd-rand);
|
||||||
|
background: transparent; color: var(--wd-text-mid); cursor: pointer;
|
||||||
|
font-weight: 700; font-size: .88rem;
|
||||||
|
}
|
||||||
|
.wd-sprache-knopf:hover { color: var(--wd-text); border-color: var(--wd-rand-stark); }
|
||||||
|
.wd-sprache-liste {
|
||||||
|
position: absolute; right: 0; top: calc(100% + .4rem);
|
||||||
|
min-width: 190px; padding: .35rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #0b1320;
|
||||||
|
border: 1px solid var(--wd-rand-stark);
|
||||||
|
box-shadow: var(--wd-schatten);
|
||||||
|
display: none;
|
||||||
|
z-index: 70;
|
||||||
|
}
|
||||||
|
.wd-sprache.wd-offen .wd-sprache-liste { display: block; }
|
||||||
|
.wd-sprache-liste button {
|
||||||
|
display: flex; align-items: center; gap: .6rem;
|
||||||
|
width: 100%; min-height: 42px; padding: .5rem .7rem;
|
||||||
|
border: none; border-radius: 8px; background: transparent;
|
||||||
|
color: var(--wd-text-mid); cursor: pointer; text-align: left; font-size: .95rem;
|
||||||
|
}
|
||||||
|
.wd-sprache-liste button:hover { background: rgba(127, 208, 232, .09); color: var(--wd-text); }
|
||||||
|
.wd-sprache-liste button[aria-current="true"] { color: var(--wd-blau); font-weight: 700; }
|
||||||
|
.wd-sprache-punkt { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
|
||||||
|
@media (max-width: 1080px) {
|
||||||
|
.wd-menue-knopf { display: inline-flex; }
|
||||||
|
.wd-nav {
|
||||||
|
display: none;
|
||||||
|
position: absolute; left: 0; right: 0; top: 100%;
|
||||||
|
flex-direction: column; align-items: stretch; gap: .2rem;
|
||||||
|
padding: .8rem 4vw 1.2rem;
|
||||||
|
background: #070b12;
|
||||||
|
border-bottom: 1px solid var(--wd-rand);
|
||||||
|
box-shadow: var(--wd-schatten);
|
||||||
|
/* Vollbildnavigation auf dem Handy (Masterplan S.5), aber scrollbar —
|
||||||
|
bei 8 Punkten + Sprachschalter passt sonst nicht alles auf ein
|
||||||
|
kleines Display. */
|
||||||
|
max-height: calc(100vh - 70px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.wd-nav.wd-offen { display: flex; }
|
||||||
|
/* Die minimale Kopfzeile (Zugangswand) enthält nur den Sprachschalter und
|
||||||
|
hat deshalb bewusst keinen Menü-Knopf. Ohne diese Ausnahme wäre sie auf
|
||||||
|
dem Handy durch die Regel oben komplett unsichtbar — man könnte die
|
||||||
|
Sprache dort dann gar nicht mehr umstellen. */
|
||||||
|
.wd-nav--minimal {
|
||||||
|
display: flex; position: static; flex-direction: row;
|
||||||
|
padding: 0; background: none; border: none; box-shadow: none;
|
||||||
|
max-height: none; overflow: visible; width: auto;
|
||||||
|
}
|
||||||
|
.wd-nav--minimal .wd-sprache { margin-top: 0; }
|
||||||
|
.wd-nav--minimal .wd-sprache-knopf { width: auto; }
|
||||||
|
.wd-nav--minimal .wd-sprache-liste { position: absolute; width: auto; }
|
||||||
|
.wd-nav a { padding: .8rem .9rem; font-size: 1.02rem; }
|
||||||
|
.wd-nav .wd-btn { margin: .6rem 0 0; }
|
||||||
|
.wd-sprache { margin-top: .6rem; }
|
||||||
|
.wd-sprache-knopf { width: 100%; justify-content: center; }
|
||||||
|
.wd-sprache-liste { position: static; width: 100%; margin-top: .4rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fußzeile */
|
||||||
|
.wd-fuss { border-top: 1px solid var(--wd-rand); background: rgba(5, 7, 11, .8); padding: 2.6rem 0 2rem; margin-top: 3rem; }
|
||||||
|
.wd-fuss-grid { display: grid; grid-template-columns: 1.4fr 1fr 1fr; gap: 2rem; }
|
||||||
|
@media (max-width: 780px) { .wd-fuss-grid { grid-template-columns: 1fr; gap: 1.5rem; } }
|
||||||
|
.wd-fuss h4 { font-size: .78rem; letter-spacing: .14em; text-transform: uppercase; color: var(--wd-blau); margin-bottom: .8rem; }
|
||||||
|
.wd-fuss ul li { margin-bottom: .1rem; }
|
||||||
|
/* Fußzeilenlinks waren nur so hoch wie ihre Zeile (20px) und damit auf dem
|
||||||
|
Handy praktisch nicht sicher zu treffen — sie liegen zudem dicht
|
||||||
|
untereinander, was Fehlgriffe zusätzlich begünstigt. Als inline-flex mit
|
||||||
|
Mindesthöhe sind sie ein ordentliches Touch-Ziel; die Fußzeile wird
|
||||||
|
dadurch etwas höher, was hier die richtige Abwägung ist. */
|
||||||
|
.wd-fuss a {
|
||||||
|
display: inline-flex; align-items: center; min-height: 44px;
|
||||||
|
color: var(--wd-text-mid); font-size: .95rem;
|
||||||
|
}
|
||||||
|
/* Die Marke in der Fußzeile bringt ihre eigene Höhe mit. */
|
||||||
|
.wd-fuss a.wd-marke { min-height: 48px; }
|
||||||
|
.wd-fuss a:hover { color: var(--wd-blau); }
|
||||||
|
.wd-fuss-unten {
|
||||||
|
margin-top: 2rem; padding-top: 1.2rem;
|
||||||
|
border-top: 1px solid var(--wd-rand);
|
||||||
|
display: flex; flex-wrap: wrap; gap: .8rem; justify-content: space-between;
|
||||||
|
font-size: .86rem; color: var(--wd-text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------
|
||||||
|
15. HERO
|
||||||
|
"Der erste Bildschirm enthält niemals zu viel Text" (Masterplan S.7).
|
||||||
|
min-height bewusst mit svh: 100vh ist auf dem Handy die Höhe OHNE
|
||||||
|
Adressleiste — der Knopf rutscht damit beim ersten Laden unter die
|
||||||
|
sichtbare Kante, genau das, was die Hero-Regel verbietet.
|
||||||
|
--------------------------------------------------------------------- */
|
||||||
|
.wd-hero { position: relative; padding: clamp(3.5rem, 9vw, 7rem) 0 clamp(3rem, 6vw, 5rem); min-height: 78svh; display: flex; align-items: center; }
|
||||||
|
.wd-hero-inhalt { max-width: 780px; }
|
||||||
|
.wd-hero h1 { margin-bottom: .45em; }
|
||||||
|
.wd-hero .wd-lead { margin-bottom: 1.8rem; max-width: 62ch; }
|
||||||
|
.wd-hero-glanz {
|
||||||
|
/* Kein Farbverlauf über den ganzen Titel — nur die Kernaussage trägt ihn,
|
||||||
|
sonst leidet die Lesbarkeit langer Überschriften. */
|
||||||
|
background: linear-gradient(120deg, var(--wd-blau-hell), var(--wd-blau) 45%, var(--wd-lila));
|
||||||
|
-webkit-background-clip: text; background-clip: text; color: transparent;
|
||||||
|
}
|
||||||
|
.wd-hero-belege { display: flex; flex-wrap: wrap; gap: .5rem 1.4rem; margin-top: 2rem; }
|
||||||
|
.wd-hero-beleg { display: flex; align-items: center; gap: .45rem; font-size: .92rem; color: var(--wd-text-soft); }
|
||||||
|
.wd-hero-beleg::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--wd-blau); box-shadow: 0 0 8px var(--wd-blau); }
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------
|
||||||
|
16. INSTALLATIONS-HINWEIS ("Als App installieren")
|
||||||
|
Sitzt unten statt oben: oben würde er die Kopfzeile verdecken, und auf
|
||||||
|
dem Handy ist der Daumen unten. env(safe-area-inset-bottom) hält ihn
|
||||||
|
über der Gestenleiste neuerer iPhones — ohne das liegt der Schließen-
|
||||||
|
Knopf genau unter dem Strich und ist praktisch nicht treffbar.
|
||||||
|
--------------------------------------------------------------------- */
|
||||||
|
.wd-install {
|
||||||
|
position: fixed; z-index: 80;
|
||||||
|
left: 50%; transform: translateX(-50%);
|
||||||
|
bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
|
||||||
|
width: min(560px, calc(100vw - 1.6rem));
|
||||||
|
display: flex; align-items: center; gap: 1rem;
|
||||||
|
padding: .85rem 1rem;
|
||||||
|
border-radius: var(--wd-r);
|
||||||
|
background: rgba(11, 19, 32, .97);
|
||||||
|
border: 1px solid var(--wd-rand-stark);
|
||||||
|
box-shadow: var(--wd-schatten-tief);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
animation: wd-install-auf .45s cubic-bezier(.22, 1, .36, 1) both;
|
||||||
|
}
|
||||||
|
@keyframes wd-install-auf { from { opacity: 0; transform: translate(-50%, 16px); } to { opacity: 1; transform: translate(-50%, 0); } }
|
||||||
|
.wd-install-text { display: flex; align-items: center; gap: .8rem; flex: 1; min-width: 0; }
|
||||||
|
.wd-install-text img { border-radius: 9px; flex-shrink: 0; }
|
||||||
|
.wd-install-text strong { display: block; font-size: .95rem; color: var(--wd-text); }
|
||||||
|
.wd-install-text span { display: block; font-size: .84rem; color: var(--wd-text-soft); line-height: 1.45; }
|
||||||
|
.wd-install-knoepfe { display: flex; align-items: center; gap: .4rem; flex-shrink: 0; }
|
||||||
|
.wd-install-knoepfe .wd-btn--still { min-width: 44px; padding: .5rem .7rem; }
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
/* Auf schmalen Bildschirmen untereinander — nebeneinander wird der Text
|
||||||
|
auf zwei Wörter pro Zeile gequetscht. */
|
||||||
|
.wd-install { flex-direction: column; align-items: stretch; gap: .7rem; }
|
||||||
|
.wd-install-knoepfe { justify-content: flex-end; }
|
||||||
|
.wd-install-knoepfe .wd-btn--haupt { flex: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------
|
||||||
|
17. BEWEGUNGSREDUKTION — eine Regel für alles
|
||||||
|
--------------------------------------------------------------------- */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
:root { --wd-zeit-schnell: 0s; --wd-zeit: 0s; --wd-zeit-lang: 0s; }
|
||||||
|
.wd *, .wd *::before, .wd *::after {
|
||||||
|
animation-duration: .001ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: .001ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
/* Wichtig: die Einblend-Elemente müssen dann SOFORT sichtbar sein,
|
||||||
|
nicht "unsichtbar mit 0s Übergang". */
|
||||||
|
.wd-bereit .wd-auf { opacity: 1; transform: none; }
|
||||||
|
.wd-btn:hover, a.wd-karte:hover, .wd-karte--klickbar:hover { transform: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Druckansicht: Angebote/Zusammenfassungen werden erfahrungsgemäß
|
||||||
|
ausgedruckt oder als PDF gesichert. */
|
||||||
|
@media print {
|
||||||
|
body.wd { background: #fff; color: #000; }
|
||||||
|
body.wd::before, body.wd::after, .wd-kopf, .wd-fuss, .wd-btn { display: none !important; }
|
||||||
|
.wd-karte { border: 1px solid #bbb; background: #fff; box-shadow: none; }
|
||||||
|
.wd h1, .wd h2, .wd h3, .wd p, .wd li { color: #000 !important; }
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 106 KiB |
@@ -0,0 +1,107 @@
|
|||||||
|
/* =====================================================================
|
||||||
|
i18n-wd-ablauf.js — Ablauf (Masterplan S.5, Punkt 05)
|
||||||
|
Die sechs Schritte selbst stehen in wd-core.js NICHT nochmal — sie sind
|
||||||
|
hier ausführlicher als auf der Startseite und deshalb eigene Texte.
|
||||||
|
===================================================================== */
|
||||||
|
window.I18N_WD = {
|
||||||
|
seiten_titel: {
|
||||||
|
de: "Ablauf — Dogfather Webdesign",
|
||||||
|
"de-CH": "So laufts — Dogfather Webdesign",
|
||||||
|
en: "Process — Dogfather Web Design",
|
||||||
|
fr: "Déroulement — Dogfather Web Design",
|
||||||
|
pt: "Processo — Dogfather Web Design",
|
||||||
|
},
|
||||||
|
|
||||||
|
ab_eyebrow: { de: "Ablauf", "de-CH": "So laufts", en: "Process", fr: "Déroulement", pt: "Processo" },
|
||||||
|
ab_h1: {
|
||||||
|
de: "Sieben Schritte, keine Überraschungen",
|
||||||
|
"de-CH": "Sibe Schritt, kei Überraschige",
|
||||||
|
en: "Seven steps, no surprises",
|
||||||
|
fr: "Sept étapes, aucune surprise",
|
||||||
|
pt: "Sete passos, sem surpresas",
|
||||||
|
},
|
||||||
|
ab_lead: {
|
||||||
|
de: "Jeder Schritt endet mit etwas, das du sehen und prüfen kannst. Du weißt jederzeit, wo dein Projekt steht und was als Nächstes von wem gebraucht wird.",
|
||||||
|
"de-CH": "Jede Schritt ändet mit öppisem, wo du gseh und prüefe chasch. Du weisch jederziit, wo dis Projekt stoht und was als nöchsts vo wäm bruucht wird.",
|
||||||
|
en: "Every step ends with something you can see and check. You always know where your project stands and what is needed next, from whom.",
|
||||||
|
fr: "Chaque étape se termine par quelque chose que tu peux voir et vérifier. Tu sais à tout moment où en est ton projet et ce qui est attendu, de qui.",
|
||||||
|
pt: "Cada passo termina com algo que podes ver e verificar. Sabes sempre em que ponto está o teu projeto e o que é preciso a seguir, e de quem.",
|
||||||
|
},
|
||||||
|
|
||||||
|
du_machst: { de: "Du machst", "de-CH": "Du machsch", en: "You do", fr: "Tu fais", pt: "Tu fazes" },
|
||||||
|
ich_mache: { de: "Ich mache", "de-CH": "Ich mache", en: "I do", fr: "Je fais", pt: "Eu faço" },
|
||||||
|
ergebnis: { de: "Am Ende steht", "de-CH": "Am Schluss stoht", en: "The outcome", fr: "Résultat de l'étape", pt: "No fim fica" },
|
||||||
|
|
||||||
|
/* --- 1 Anfrage --- */
|
||||||
|
s1_h2: { de: "Anfrage", "de-CH": "Aafrog", en: "Enquiry", fr: "Demande", pt: "Pedido" },
|
||||||
|
s1_du: { de: "Formular ausfüllen — Ziel, Umfang, Budgetrahmen, Wunschtermin.", "de-CH": "S Formular usfülle — Ziel, Umfang, Budgetrahme, Wunschtermin.", en: "Fill in the form — goal, scope, budget range, desired date.", fr: "Remplir le formulaire — objectif, étendue, budget, date souhaitée.", pt: "Preencher o formulário — objetivo, âmbito, orçamento, data pretendida." },
|
||||||
|
s1_ich: { de: "Anfrage prüfen und innerhalb weniger Tage melden.", "de-CH": "D Aafrog prüefe und innert weniger Täg mälde.", en: "Review the enquiry and get back to you within a few days.", fr: "Examiner la demande et revenir vers toi sous quelques jours.", pt: "Analisar o pedido e responder dentro de poucos dias." },
|
||||||
|
s1_erg: { de: "Eine Bestätigung mit Zusammenfassung dessen, was du angegeben hast.", "de-CH": "E Bestätigung mit ere Zämefassig vo dem, was du aagäh hesch.", en: "A confirmation with a summary of what you submitted.", fr: "Une confirmation avec le récapitulatif de ce que tu as indiqué.", pt: "Uma confirmação com o resumo do que indicaste." },
|
||||||
|
|
||||||
|
/* --- 2 Briefing --- */
|
||||||
|
s2_h2: { de: "Briefing", "de-CH": "Briefing", en: "Briefing", fr: "Briefing", pt: "Briefing" },
|
||||||
|
s2_du: { de: "Fragen beantworten und sagen, was du schon an Texten, Bildern und Logo hast.", "de-CH": "Frage beantworte und säge, was du scho a Täxt, Bilder und Logo hesch.", en: "Answer questions and say what texts, images and logo you already have.", fr: "Répondre aux questions et indiquer les textes, images et logo déjà disponibles.", pt: "Responder a perguntas e dizer que textos, imagens e logótipo já tens." },
|
||||||
|
s2_ich: { de: "Nachfragen, wo etwas unklar ist — lieber jetzt als mitten im Bau.", "de-CH": "Nachefroge, wo öppis unklar isch — lieber jetzt als mitte im Bau.", en: "Ask where something is unclear — better now than mid-build.", fr: "Poser des questions là où c'est flou — mieux vaut maintenant qu'en pleine construction.", pt: "Perguntar onde algo não está claro — melhor agora do que a meio da construção." },
|
||||||
|
s2_erg: { de: "Ein gemeinsames Bild davon, was die Seite können muss.", "de-CH": "Es gmeinsams Bild devo, was d Site chöne muess.", en: "A shared picture of what the site has to do.", fr: "Une vision commune de ce que le site doit faire.", pt: "Uma imagem partilhada do que o site tem de fazer." },
|
||||||
|
|
||||||
|
/* --- 3 Angebot --- */
|
||||||
|
s3_h2: { de: "Angebot & Anzahlung", "de-CH": "Aagebot & Aazahlig", en: "Quote & deposit", fr: "Devis & acompte", pt: "Orçamento & sinal" },
|
||||||
|
s3_du: { de: "Angebot lesen, Fragen stellen, annehmen oder ablehnen — und bei Annahme die 30 % überweisen.", "de-CH": "S Aagebot läse, Frage stelle, aanäh oder ablähne — und bi dr Aanahm d 30 % überwiise.", en: "Read the quote, ask questions, accept or decline — and on acceptance transfer the 30 %.", fr: "Lire le devis, poser des questions, accepter ou refuser — et en cas d'acceptation verser les 30 %.", pt: "Ler o orçamento, colocar perguntas, aceitar ou recusar — e, ao aceitar, transferir os 30 %." },
|
||||||
|
s3_ich: { de: "Preis, Umfang, Zeitrahmen und alles, was extra kostet, schriftlich festhalten.", "de-CH": "Priis, Umfang, Ziitrahme und alles, was extra choschtet, schriftlich festhalte.", en: "Put price, scope, timeframe and everything that costs extra in writing.", fr: "Consigner par écrit le prix, l'étendue, le délai et tout ce qui coûte en supplément.", pt: "Registar por escrito preço, âmbito, prazo e tudo o que custa à parte." },
|
||||||
|
s3_erg: { de: "Ein reservierter Starttermin. Vorher wird kein Zeitfenster blockiert.", "de-CH": "En reservierte Starttermin. Vorher wird kes Ziitfenschter blockiert.", en: "A reserved start date. No slot is blocked before that.", fr: "Une date de démarrage réservée. Aucun créneau n'est bloqué avant.", pt: "Uma data de início reservada. Antes disso não há data bloqueada." },
|
||||||
|
|
||||||
|
/* --- 4 Design --- */
|
||||||
|
s4_h2: { de: "Design", "de-CH": "Design", en: "Design", fr: "Design", pt: "Design" },
|
||||||
|
s4_du: { de: "Entwurf ansehen und Rückmeldung geben — am besten gesammelt statt in vielen einzelnen Nachrichten.", "de-CH": "Dr Entwurf aaluege und Rückmäldig gäh — am beschte gsammlet statt i viilne einzelne Nachrichte.", en: "Look at the draft and give feedback — ideally bundled rather than in many separate messages.", fr: "Regarder la maquette et donner ton retour — de préférence groupé plutôt qu'en messages épars.", pt: "Ver o esboço e dar feedback — de preferência agrupado em vez de muitas mensagens soltas." },
|
||||||
|
s4_ich: { de: "Aufbau, Farben und Schriften entwerfen — und dir Handy- und Computeransicht zeigen, nicht nur eine davon.", "de-CH": "Ufbau, Farbe und Schrifte entwerfe — und dir Handy- und Computeraasicht zeige, nöd nur eini devo.", en: "Design layout, colours and typography — and show you both the mobile and desktop view, not just one.", fr: "Concevoir structure, couleurs et typographie — et te montrer la vue mobile ET ordinateur, pas seulement l'une.", pt: "Desenhar estrutura, cores e tipografia — e mostrar-te a vista de telemóvel e de computador, não só uma." },
|
||||||
|
s4_erg: { de: "Ein freigegebener Entwurf. Erst danach wird gebaut.", "de-CH": "En freigäh Entwurf. Erscht denn wird baut.", en: "An approved design. Only then does building start.", fr: "Une maquette validée. La construction ne commence qu'après.", pt: "Um design aprovado. Só depois se constrói." },
|
||||||
|
|
||||||
|
/* --- 5 Entwicklung --- */
|
||||||
|
s5_h2: { de: "Entwicklung", "de-CH": "Entwicklig", en: "Development", fr: "Développement", pt: "Desenvolvimento" },
|
||||||
|
s5_du: { de: "Fehlende Inhalte nachliefern, falls noch welche offen sind.", "de-CH": "Fählendi Inhalt nachliefere, falls no welli offe sind.", en: "Supply any content that is still outstanding.", fr: "Fournir les contenus encore manquants.", pt: "Fornecer os conteúdos que ainda faltem." },
|
||||||
|
s5_ich: { de: "Die Seite bauen — mit den echten Inhalten, nicht mit Blindtext.", "de-CH": "D Site baue — mit de ächte Inhalt, nöd mit Blindtäxt.", en: "Build the site — with the real content, not with dummy text.", fr: "Construire le site — avec les vrais contenus, pas du faux texte.", pt: "Construir o site — com os conteúdos reais, não com texto fictício." },
|
||||||
|
s5_erg: { de: "Eine benutzbare Seite auf einer Vorschauadresse, die nur du und ich kennen.", "de-CH": "E bruuchbari Site uf ere Vorschauadress, wo nur du und ich kenned.", en: "A usable site on a preview address only you and I know.", fr: "Un site utilisable sur une adresse de prévisualisation connue de toi et moi seuls.", pt: "Um site utilizável num endereço de pré-visualização que só tu e eu conhecemos." },
|
||||||
|
|
||||||
|
/* --- 6 Tests --- */
|
||||||
|
s6_h2: { de: "Tests", "de-CH": "Teschts", en: "Testing", fr: "Tests", pt: "Testes" },
|
||||||
|
s6_du: { de: "Selbst durchklicken — besonders auf deinem eigenen Handy.", "de-CH": "Sälber dureklicke — bsunders uf dim eigene Handy.", en: "Click through it yourself — especially on your own phone.", fr: "Tout parcourir toi-même — surtout sur ton propre téléphone.", pt: "Percorrer tudo tu mesmo — sobretudo no teu próprio telemóvel." },
|
||||||
|
s6_ich: { de: "Jedes Formular, jeden Link und jeden Fehlerfall durchspielen, auf Handy, Tablet und Computer.", "de-CH": "Jedes Formular, jede Link und jede Fählerfall durespiele, uf Handy, Tablet und Computer.", en: "Play through every form, every link and every error case, on mobile, tablet and desktop.", fr: "Tester chaque formulaire, chaque lien et chaque cas d'erreur, sur mobile, tablette et ordinateur.", pt: "Testar cada formulário, cada ligação e cada caso de erro, em telemóvel, tablet e computador." },
|
||||||
|
s6_erg: { de: "Eine Liste dessen, was geprüft wurde — und was bewusst nicht enthalten ist.", "de-CH": "E Lischte vo dem, was prüeft worde isch — und was bewusst nöd dinne isch.", en: "A list of what was checked — and what is deliberately not included.", fr: "Une liste de ce qui a été vérifié — et de ce qui n'est délibérément pas inclus.", pt: "Uma lista do que foi verificado — e do que fica deliberadamente de fora." },
|
||||||
|
|
||||||
|
/* --- 7 Übergabe --- */
|
||||||
|
s7_h2: { de: "Freigabe & Übergabe", "de-CH": "Freigab & Übergab", en: "Approval & handover", fr: "Validation & remise", pt: "Aprovação & entrega" },
|
||||||
|
s7_du: { de: "Ausdrücklich freigeben und den Restbetrag begleichen.", "de-CH": "Uusdrücklich freigäh und dr Rescht zahle.", en: "Give explicit approval and settle the remaining amount.", fr: "Donner ta validation explicite et régler le solde.", pt: "Dar aprovação explícita e liquidar o valor restante." },
|
||||||
|
s7_ich: { de: "Live schalten, dich einweisen und die Zugänge übergeben.", "de-CH": "Live schalte, dich iiwiise und d Zuegäng übergäh.", en: "Take it live, walk you through it and hand over the access details.", fr: "Mettre en ligne, te former et te remettre les accès.", pt: "Colocar no ar, dar-te formação e entregar os acessos." },
|
||||||
|
s7_erg: { de: "Deine Seite, deine Zugänge, deine Nutzungsrechte. Betreuung ist optional, kein Zwang.", "de-CH": "Dini Site, dini Zuegäng, dini Nutzigsrächt. D Betreuig isch optional, kein Zwang.", en: "Your site, your access, your usage rights. Support is optional, not compulsory.", fr: "Ton site, tes accès, tes droits d'utilisation. Le suivi est optionnel, pas obligatoire.", pt: "O teu site, os teus acessos, os teus direitos de utilização. O acompanhamento é opcional, não obrigatório." },
|
||||||
|
|
||||||
|
ab_wichtig_titel: {
|
||||||
|
de: "Keine Veröffentlichung ohne deine Freigabe",
|
||||||
|
"de-CH": "Kei Veröffentlichig ohni dini Freigab",
|
||||||
|
en: "No publication without your approval",
|
||||||
|
fr: "Aucune mise en ligne sans ta validation",
|
||||||
|
pt: "Sem publicação sem a tua aprovação",
|
||||||
|
},
|
||||||
|
ab_wichtig_text: {
|
||||||
|
de: "Die Seite geht ausschließlich dann online, wenn du das ausdrücklich sagst. Das gilt auch für die Aufnahme ins Portfolio — beides ist getrennt und beides brauchst du nicht zu erlauben.",
|
||||||
|
"de-CH": "D Site goht uusschliesslich denn online, wenn du das uusdrücklich seisch. Das gilt au für d Ufnahm is Portfolio — beidi sind trennt und beidi muesch nöd erlaube.",
|
||||||
|
en: "The site goes online only when you explicitly say so. The same applies to being included in the portfolio — the two are separate and you need not permit either.",
|
||||||
|
fr: "Le site n'est mis en ligne que si tu le demandes explicitement. Il en va de même pour l'ajout au portfolio — les deux sont indépendants et tu n'es obligé d'accepter ni l'un ni l'autre.",
|
||||||
|
pt: "O site só vai para o ar quando o disseres explicitamente. O mesmo se aplica à inclusão no portfólio — são coisas separadas e não tens de autorizar nenhuma.",
|
||||||
|
},
|
||||||
|
|
||||||
|
ab_dauer_h2: {
|
||||||
|
de: "Wie lange dauert das insgesamt?",
|
||||||
|
"de-CH": "Wie lang duuret das im Ganze?",
|
||||||
|
en: "How long does all of this take?",
|
||||||
|
fr: "Combien de temps cela prend-il en tout ?",
|
||||||
|
pt: "Quanto tempo demora tudo isto?",
|
||||||
|
},
|
||||||
|
ab_dauer_p: {
|
||||||
|
de: "Die Zeitrahmen auf der Preisseite gelten ab dem Eingang der Anzahlung — nicht ab der ersten Nachricht. Der häufigste Grund für Verzögerungen ist nicht die Technik, sondern dass Texte oder Bilder noch fehlen. Deshalb wird im Formular gleich danach gefragt.",
|
||||||
|
"de-CH": "D Ziitrahme uf dr Priissite gälted ab em Iigang vo dr Aazahlig — nöd ab dr erschte Nachricht. Dr hüüfigscht Grund für Verzögerige isch nöd d Technik, sondern dass Täxt oder Bilder no fähled. Drum wird im Formular grad dernoch gfrogt.",
|
||||||
|
en: "The timeframes on the pricing page start when the deposit arrives — not from the first message. The most common reason for delays is not the technology but missing texts or images. That is why the form asks about it right away.",
|
||||||
|
fr: "Les délais indiqués sur la page tarifs courent à partir de la réception de l'acompte — pas du premier message. La principale cause de retard n'est pas la technique mais l'absence de textes ou d'images. C'est pourquoi le formulaire pose la question d'emblée.",
|
||||||
|
pt: "Os prazos na página de preços contam a partir da entrada do sinal — não da primeira mensagem. A causa mais comum de atrasos não é a tecnologia, mas a falta de textos ou imagens. Por isso o formulário pergunta isso logo.",
|
||||||
|
},
|
||||||
|
ab_zu_preise: { de: "Zu den Zeitrahmen", "de-CH": "Zu de Ziitrahme", en: "See the timeframes", fr: "Voir les délais", pt: "Ver os prazos" },
|
||||||
|
};
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
/* =====================================================================
|
||||||
|
i18n-wd-faq.js — Häufige Fragen (Masterplan S.5, Punkt 07)
|
||||||
|
Abgedeckte Themen laut Masterplan: Preise, Dauer, Inhalte, Änderungen,
|
||||||
|
Hosting, Rechte, Zahlung und Veröffentlichung.
|
||||||
|
|
||||||
|
Die fünf Fragen, die schon auf der Startseite stehen, sind hier bewusst
|
||||||
|
NICHT wiederholt — dort ist der Auszug, hier die Vollversion mit den
|
||||||
|
Themen, die dort keinen Platz hatten.
|
||||||
|
===================================================================== */
|
||||||
|
window.I18N_WD = {
|
||||||
|
seiten_titel: {
|
||||||
|
de: "Häufige Fragen — Dogfather Webdesign",
|
||||||
|
"de-CH": "Hüüfigi Frage — Dogfather Webdesign",
|
||||||
|
en: "FAQ — Dogfather Web Design",
|
||||||
|
fr: "Questions fréquentes — Dogfather Web Design",
|
||||||
|
pt: "Perguntas frequentes — Dogfather Web Design",
|
||||||
|
},
|
||||||
|
|
||||||
|
fq_eyebrow: { de: "Häufige Fragen", "de-CH": "Hüüfigi Frage", en: "Frequently asked questions", fr: "Questions fréquentes", pt: "Perguntas frequentes" },
|
||||||
|
fq_h1: {
|
||||||
|
de: "Alles, was vorher geklärt sein sollte",
|
||||||
|
"de-CH": "Alles, wo vorher klärt si sött",
|
||||||
|
en: "Everything that should be clear beforehand",
|
||||||
|
fr: "Tout ce qui devrait être clair au préalable",
|
||||||
|
pt: "Tudo o que deve ficar claro antes",
|
||||||
|
},
|
||||||
|
fq_lead: {
|
||||||
|
de: "Sortiert nach Thema. Wenn deine Frage nicht dabei ist, schreib sie einfach ins Anfrageformular — sie landet dann hier, wenn sie öfter kommt.",
|
||||||
|
"de-CH": "Sortiert nach Thema. Wenn dini Frog nöd debii isch, schriib si eifach is Aafrogformular — si landet denn do, wenn si öfter chunnt.",
|
||||||
|
en: "Sorted by topic. If your question is not here, just put it in the enquiry form — it ends up on this page if it comes up more often.",
|
||||||
|
fr: "Classées par thème. Si ta question n'y figure pas, écris-la simplement dans le formulaire — elle atterrira ici si elle revient souvent.",
|
||||||
|
pt: "Ordenadas por tema. Se a tua pergunta não estiver aqui, escreve-a no formulário — acaba nesta página se surgir com frequência.",
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Themen-Überschriften */
|
||||||
|
fq_t_preis: { de: "Preise & Zahlung", "de-CH": "Priis & Zahlig", en: "Pricing & payment", fr: "Tarifs & paiement", pt: "Preços & pagamento" },
|
||||||
|
fq_t_dauer: { de: "Dauer & Änderungen", "de-CH": "Duur & Änderige", en: "Duration & changes", fr: "Durée & modifications", pt: "Duração & alterações" },
|
||||||
|
fq_t_inhalt: { de: "Inhalte & Zusammenarbeit", "de-CH": "Inhalt & Zämeschaffe", en: "Content & collaboration", fr: "Contenus & collaboration", pt: "Conteúdos & colaboração" },
|
||||||
|
fq_t_technik: { de: "Hosting, Domain & Rechte", "de-CH": "Hosting, Domain & Rächt", en: "Hosting, domain & rights", fr: "Hébergement, domaine & droits", pt: "Alojamento, domínio & direitos" },
|
||||||
|
fq_t_veroeff: { de: "Veröffentlichung & danach", "de-CH": "Veröffentlichig & dernoch", en: "Publication & afterwards", fr: "Mise en ligne & après", pt: "Publicação & depois" },
|
||||||
|
|
||||||
|
/* --- Preise & Zahlung --- */
|
||||||
|
f1_f: { de: "Warum steht kein Festpreis auf der Seite?", "de-CH": "Wieso stoht kein Fixpriis uf dr Site?", en: "Why is there no fixed price on the site?", fr: "Pourquoi aucun prix fixe n'est indiqué ?", pt: "Porque não há um preço fixo no site?" },
|
||||||
|
f1_a: {
|
||||||
|
de: "Weil ein ehrlicher Festpreis erst möglich ist, wenn der Umfang bekannt ist. Eine Seite mit drei Abschnitten und eine mit dreißig Produkten kosten nun einmal nicht dasselbe. Die Beträge hier sind Einstiegspunkte, die dir zeigen, in welcher Größenordnung du planen musst — der verbindliche Preis kommt schriftlich nach dem Briefing.",
|
||||||
|
"de-CH": "Wil en ehrliche Fixpriis erscht möglich isch, wenn dr Umfang bekannt isch. E Site mit drü Abschnitt und eini mit driissg Produkt choschted halt nöd s Glich. D Beträg do sind Iistiegspünkt, wo dir zeiged, i welere Grössenordnig du plane muesch — dr verbindlich Priis chunnt schriftlich nach em Briefing.",
|
||||||
|
en: "Because an honest fixed price is only possible once the scope is known. A site with three sections and one with thirty products simply do not cost the same. The figures here are starting points that show you the ballpark you need to plan for — the binding price comes in writing after the briefing.",
|
||||||
|
fr: "Parce qu'un prix fixe honnête n'est possible qu'une fois l'étendue connue. Un site à trois sections et un site à trente produits ne coûtent tout simplement pas la même chose. Les montants ici sont des points de départ qui te montrent l'ordre de grandeur — le prix ferme arrive par écrit après le briefing.",
|
||||||
|
pt: "Porque um preço fixo honesto só é possível quando o âmbito é conhecido. Um site com três secções e outro com trinta produtos não custam o mesmo. Os valores aqui são pontos de partida que mostram a ordem de grandeza — o preço vinculativo vem por escrito após o briefing.",
|
||||||
|
},
|
||||||
|
f2_f: { de: "Was passiert, wenn ich nach der Anzahlung abspringe?", "de-CH": "Was passiert, wenn i nach dr Aazahlig abspringe?", en: "What happens if I pull out after paying the deposit?", fr: "Que se passe-t-il si je me désiste après l'acompte ?", pt: "O que acontece se desistir depois do sinal?" },
|
||||||
|
f2_a: {
|
||||||
|
de: "Die Anzahlung hat den Zweck, dein Zeitfenster zu reservieren — dieses Fenster war dann für andere blockiert. Bereits geleistete Arbeit wird verrechnet; wenn noch praktisch nichts passiert ist, finden wir eine faire Lösung. Das steht so auch im Angebot, damit es im Ernstfall nicht Auslegungssache ist.",
|
||||||
|
"de-CH": "D Aazahlig het dr Zwäck, dis Ziitfenschter z reserviere — das Fenschter isch denn für anderi blockiert gsi. Scho gleischtete Arbet wird verrächnet; wenn no praktisch nüt passiert isch, finded mer e fairi Lösig. Das stoht so au im Aagebot, damit s im Ärnschtfall nöd Uuslegigssach isch.",
|
||||||
|
en: "The deposit exists to reserve your time slot — that slot was then blocked for others. Work already done is offset; if practically nothing has happened yet, we find a fair solution. This is written into the quote so it is not a matter of interpretation if it ever comes up.",
|
||||||
|
fr: "L'acompte sert à réserver ton créneau — ce créneau était alors bloqué pour d'autres. Le travail déjà réalisé est décompté ; si presque rien n'a encore été fait, nous trouvons une solution équitable. C'est écrit dans le devis pour que cela ne soit pas sujet à interprétation le cas échéant.",
|
||||||
|
pt: "O sinal serve para reservar a tua janela de tempo — essa janela ficou bloqueada para outros. O trabalho já feito é descontado; se praticamente nada aconteceu ainda, encontramos uma solução justa. Isto fica escrito no orçamento para não ser questão de interpretação se acontecer.",
|
||||||
|
},
|
||||||
|
f3_f: { de: "Kommen später versteckte Kosten dazu?", "de-CH": "Chömed spöter versteckti Choschte dezue?", en: "Are there hidden costs later on?", fr: "Y a-t-il des coûts cachés par la suite ?", pt: "Há custos escondidos mais tarde?" },
|
||||||
|
f3_a: {
|
||||||
|
de: "Von mir nicht — was extra kostet, steht vorher im Angebot. Was aber sehr wohl dauerhaft Geld kostet und nicht von mir kommt: die Domain (meist ein kleiner Jahresbetrag), der Server beziehungsweise das Hosting, und bei einem Shop die Gebühren des Zahlungsanbieters pro Bestellung. Diese Posten benenne ich im Angebot ausdrücklich, damit sie dich nicht überraschen.",
|
||||||
|
"de-CH": "Vo mir nöd — was extra choschtet, stoht vorher im Aagebot. Was aber sehr wohl duurhaft Gäld choschtet und nöd vo mir chunnt: d Domain (meischtens en chline Jahresbetrag), dr Server bzw. s Hosting, und bi eme Shop d Gebühre vom Zahligsaabieter pro Bstellig. Die Poschte benenn i im Aagebot uusdrücklich, damit si dich nöd überrasched.",
|
||||||
|
en: "Not from me — anything that costs extra is in the quote beforehand. What does cost money on an ongoing basis and does not come from me: the domain (usually a small annual amount), the server or hosting, and for a shop the payment provider's fee per order. I name these items explicitly in the quote so they do not surprise you.",
|
||||||
|
fr: "Pas de mon côté — tout supplément figure au devis à l'avance. Ce qui coûte en revanche de façon récurrente et ne vient pas de moi : le domaine (généralement un petit montant annuel), le serveur ou l'hébergement, et pour une boutique les frais du prestataire de paiement par commande. Je nomme ces postes explicitement dans le devis pour éviter les surprises.",
|
||||||
|
pt: "Da minha parte não — tudo o que custa à parte está no orçamento antecipadamente. O que custa mesmo de forma recorrente e não vem de mim: o domínio (normalmente um valor anual pequeno), o servidor ou alojamento e, numa loja, as taxas do fornecedor de pagamento por encomenda. Nomeio estes itens explicitamente no orçamento para não te surpreenderem.",
|
||||||
|
},
|
||||||
|
|
||||||
|
/* --- Dauer & Änderungen --- */
|
||||||
|
f4_f: { de: "Woran liegt es, wenn ein Projekt länger dauert?", "de-CH": "A was ligt s, wenn es Projekt länger duuret?", en: "What makes a project take longer?", fr: "Pourquoi un projet prend-il parfois plus de temps ?", pt: "Porque é que um projeto às vezes demora mais?" },
|
||||||
|
f4_a: {
|
||||||
|
de: "In den allermeisten Fällen an fehlenden Inhalten, nicht an der Technik. Solange Texte, Logo oder Produktfotos nicht da sind, kann die Seite nicht fertig werden — und Blindtext hilft niemandem. Der zweitwichtigste Grund sind Rückmeldungen, die tröpfchenweise kommen: fünf einzelne Nachrichten über zwei Wochen kosten mehr Zeit als eine gesammelte Liste.",
|
||||||
|
"de-CH": "I de allermeischte Fäll a fählende Inhalt, nöd a dr Technik. So lang Täxt, Logo oder Produktfotene nöd do sind, cha d Site nöd fertig wärde — und Blindtäxt hilft niemertem. Dr zweitwichtigscht Grund sind Rückmäldige, wo tröpfliwiis chömed: füf einzelni Nachrichte über zwei Wuche choschted meh Ziit als ei gsammleti Lischte.",
|
||||||
|
en: "In the vast majority of cases it is missing content, not the technology. As long as texts, logo or product photos are not there, the site cannot be finished — and dummy text helps nobody. The second biggest reason is feedback trickling in: five separate messages over two weeks cost more time than one collected list.",
|
||||||
|
fr: "Dans la grande majorité des cas, ce sont les contenus manquants, pas la technique. Tant que textes, logo ou photos produits ne sont pas là, le site ne peut pas être terminé — et le faux texte n'aide personne. La deuxième cause : des retours au compte-gouttes ; cinq messages séparés sur deux semaines coûtent plus de temps qu'une liste unique.",
|
||||||
|
pt: "Na grande maioria dos casos são conteúdos em falta, não a tecnologia. Enquanto textos, logótipo ou fotos de produtos não existirem, o site não pode ficar pronto — e texto fictício não ajuda ninguém. A segunda maior razão é feedback aos poucos: cinco mensagens separadas ao longo de duas semanas custam mais tempo do que uma lista reunida.",
|
||||||
|
},
|
||||||
|
f5_f: { de: "Wie viele Änderungen sind drin?", "de-CH": "Wie viili Änderige sind dinne?", en: "How many changes are included?", fr: "Combien de modifications sont incluses ?", pt: "Quantas alterações estão incluídas?" },
|
||||||
|
f5_a: {
|
||||||
|
de: "Typischerweise zwei Feedbackrunden — gemeint sind gesammelte Rückmeldungen, nicht einzelne Korrekturen. Kleinigkeiten wie ein Tippfehler oder eine vertauschte Telefonnummer zähle ich nicht mit. Was darüber hinausgeht (neue Abschnitte, ein anderes Grundlayout, zusätzliche Funktionen) wird vorher beziffert, und du entscheidest, ob es dir das wert ist.",
|
||||||
|
"de-CH": "Typischerwiis zwei Feedbackrunde — gmeint sind gsammleti Rückmäldige, nöd einzelni Korrekture. Chliigkeite wie en Tippfähler oder e vertuschti Telefonnummere zell i nöd mit. Was drüber use goht (neui Abschnitt, es anders Grundlayout, zuesätzlichi Funktione) wird vorher bezifferet, und du entscheidsch, ob s dir das wärt isch.",
|
||||||
|
en: "Typically two feedback rounds — meaning collected feedback, not individual corrections. Small things like a typo or a swapped phone number I do not count. Anything beyond that (new sections, a different base layout, extra features) is quoted beforehand and you decide whether it is worth it to you.",
|
||||||
|
fr: "Généralement deux tours de retours — il s'agit de retours groupés, pas de corrections isolées. Les broutilles comme une faute de frappe ou un numéro inversé ne comptent pas. Au-delà (nouvelles sections, autre mise en page de base, fonctions supplémentaires), c'est chiffré à l'avance et tu décides si cela en vaut la peine.",
|
||||||
|
pt: "Tipicamente duas rondas de feedback — entenda-se feedback reunido, não correções isoladas. Pequenas coisas como uma gralha ou um número de telefone trocado não conto. O que vai além disso (novas secções, outro layout base, funcionalidades extra) é orçamentado antes e tu decides se vale a pena.",
|
||||||
|
},
|
||||||
|
f6_f: { de: "Kann ich Texte später selbst ändern?", "de-CH": "Cha ich Täxt spöter sälber ändere?", en: "Can I change texts myself later?", fr: "Puis-je modifier les textes moi-même plus tard ?", pt: "Posso alterar textos eu próprio mais tarde?" },
|
||||||
|
f6_a: {
|
||||||
|
de: "Ja, das ist ausdrücklich das Ziel. Bei größeren Projekten gehört ein Verwaltungsbereich dazu, in dem du Texte, Bilder und Produkte ohne Programmierkenntnisse pflegst — plus eine Einweisung dafür. Bei einem einfachen Onepager ist so ein Bereich meist überdimensioniert; dort sind kleine Textänderungen Teil der Betreuung oder werden nach Aufwand abgerechnet.",
|
||||||
|
"de-CH": "Jo, das isch uusdrücklich s Ziel. Bi grössere Projekt ghört en Verwaltigsbereich dezue, wo du Täxt, Bilder und Produkt ohni Programmierkenntnis pflegsch — plus e Iiwiisig defür. Bi eme eifache Onepager isch so en Bereich meischtens überdimensioniert; dete sind chlini Täxtänderige Teil vo dr Betreuig oder wärded nach Ufwand abgrächnet.",
|
||||||
|
en: "Yes, that is explicitly the goal. Larger projects include an admin area where you maintain texts, images and products without programming knowledge — plus a walkthrough for it. For a simple one-pager such an area is usually overkill; there, small text changes are part of the support package or billed by effort.",
|
||||||
|
fr: "Oui, c'est explicitement l'objectif. Les projets plus importants incluent un espace d'administration où tu gères textes, images et produits sans compétences en programmation — avec une prise en main. Pour une page unique, un tel espace est généralement surdimensionné ; les petites modifications de texte relèvent alors du suivi ou sont facturées au temps passé.",
|
||||||
|
pt: "Sim, esse é explicitamente o objetivo. Projetos maiores incluem uma área de administração onde geres textos, imagens e produtos sem conhecimentos de programação — mais uma introdução. Numa página única simples, essa área costuma ser exagerada; aí, pequenas alterações de texto fazem parte do acompanhamento ou são faturadas por esforço.",
|
||||||
|
},
|
||||||
|
|
||||||
|
/* --- Inhalte & Zusammenarbeit --- */
|
||||||
|
f7_f: { de: "Ich habe kein Logo. Ist das ein Problem?", "de-CH": "Ich han kes Logo. Isch das es Problem?", en: "I don't have a logo. Is that a problem?", fr: "Je n'ai pas de logo. Est-ce un problème ?", pt: "Não tenho logótipo. Isso é problema?" },
|
||||||
|
f7_a: {
|
||||||
|
de: "Kein Hindernis, aber auch nicht kostenlos. Eine Seite lässt sich sauber ohne Logo bauen — mit einem gut gesetzten Schriftzug sieht das oft besser aus als ein schnell zusammengeklicktes Symbol. Ein richtiges Logo zu entwerfen ist eine eigene Leistung und wird separat berechnet.",
|
||||||
|
"de-CH": "Kes Hindernis, aber au nöd gratis. E Site loht sich suuber ohni Logo baue — mit eme guet gsetzte Schriftzug gseht das oft besser us als es schnäll zämeklicktes Symbol. Es richtigs Logo z entwerfe isch e eigeni Leischtig und wird separat verrächnet.",
|
||||||
|
en: "Not an obstacle, but not free either. A site can be built cleanly without a logo — a well-set wordmark often looks better than a quickly clicked-together symbol. Designing a proper logo is a separate service and is billed separately.",
|
||||||
|
fr: "Pas un obstacle, mais pas gratuit non plus. Un site peut très bien se construire sans logo — un logotype typographique bien composé fait souvent meilleur effet qu'un symbole bricolé à la hâte. Concevoir un vrai logo est une prestation distincte, facturée séparément.",
|
||||||
|
pt: "Não é obstáculo, mas também não é gratuito. Um site pode ser feito bem sem logótipo — um lettering bem composto costuma ficar melhor do que um símbolo montado à pressa. Desenhar um logótipo a sério é um serviço à parte e é faturado separadamente.",
|
||||||
|
},
|
||||||
|
f8_f: { de: "Kann ich Bilder aus dem Internet verwenden?", "de-CH": "Cha ich Bilder us em Internet bruuche?", en: "Can I use images from the internet?", fr: "Puis-je utiliser des images trouvées sur internet ?", pt: "Posso usar imagens da internet?" },
|
||||||
|
f8_a: {
|
||||||
|
de: "Nur wenn die Lizenz das erlaubt. Ein Bild, das man irgendwo gefunden hat, ist rechtlich der häufigste teure Fehler auf kleinen Webseiten — Abmahnungen kosten schnell ein Vielfaches der ganzen Seite. Ich setze deshalb nur Bilder ein, deren Herkunft geklärt ist, und sage dir bei deinen eigenen Bildern, worauf zu achten ist.",
|
||||||
|
"de-CH": "Nur wenn d Lizänz das erlaubt. Es Bild, wo mer irgendwo gfunde het, isch rächtlich dr hüüfigscht tüüre Fähler uf chline Websites — Abmahnige choschted schnäll es Viilfachs vo dr ganze Site. Ich setze drum nur Bilder ii, wo d Herkunft klärt isch, und säge dir bi dine eigene Bilder, uf was z achte isch.",
|
||||||
|
en: "Only if the licence allows it. An image found somewhere is legally the most common expensive mistake on small websites — a warning letter quickly costs a multiple of the whole site. I therefore only use images whose origin is clear, and I tell you what to watch out for with your own images.",
|
||||||
|
fr: "Uniquement si la licence le permet. Une image trouvée quelque part est juridiquement l'erreur coûteuse la plus fréquente sur les petits sites — une mise en demeure coûte vite plusieurs fois le prix du site entier. Je n'utilise donc que des images dont l'origine est établie, et je t'indique à quoi faire attention pour tes propres images.",
|
||||||
|
pt: "Só se a licença o permitir. Uma imagem encontrada algures é, juridicamente, o erro caro mais comum em sites pequenos — uma notificação custa depressa várias vezes o preço do site inteiro. Por isso uso apenas imagens com origem esclarecida e digo-te a que estar atento nas tuas próprias imagens.",
|
||||||
|
},
|
||||||
|
f9_f: { de: "Wie läuft die Kommunikation während des Projekts?", "de-CH": "Wie lauft d Kommunikation während em Projekt?", en: "How does communication work during the project?", fr: "Comment se passe la communication pendant le projet ?", pt: "Como funciona a comunicação durante o projeto?" },
|
||||||
|
f9_a: {
|
||||||
|
de: "Über deinen eigenen Kundenbereich auf dieser Seite. Dort siehst du den Projektstatus, die vereinbarten Leistungen, offene Rückfragen und alle Dateien — und dort landen auch Änderungswünsche samt Aufwand und Preis. Der Vorteil gegenüber Nachrichten in einem Chat: nichts geht verloren und beide Seiten können später nachlesen, was vereinbart war.",
|
||||||
|
"de-CH": "Über din eigene Kundebereich uf dere Site. Dete gsehsch dr Projektstatus, d vereinbarte Leischtige, offni Rückfroge und alli Datei — und dete lande au Änderigswünsch samt Ufwand und Priis. Dr Vorteil gägenüber Nachrichte in eme Chat: nüt goht verlore und beidi Site chönd spöter nacheläse, was vereinbart gsi isch.",
|
||||||
|
en: "Through your own client area on this site. There you see the project status, the agreed services, open questions and all files — and that is also where change requests land, together with effort and price. The advantage over messages in a chat: nothing gets lost and both sides can look up later what was agreed.",
|
||||||
|
fr: "Via ton espace client sur ce site. Tu y vois l'état du projet, les prestations convenues, les questions en suspens et tous les fichiers — et c'est aussi là qu'arrivent les demandes de modification, avec l'effort et le prix. L'avantage par rapport à des messages dans un chat : rien ne se perd et les deux parties peuvent relire ce qui a été convenu.",
|
||||||
|
pt: "Através da tua área de cliente neste site. Aí vês o estado do projeto, os serviços acordados, questões em aberto e todos os ficheiros — e é também aí que chegam os pedidos de alteração, com esforço e preço. A vantagem sobre mensagens num chat: nada se perde e ambos os lados podem consultar depois o que foi acordado.",
|
||||||
|
},
|
||||||
|
|
||||||
|
/* --- Hosting, Domain & Rechte --- */
|
||||||
|
f10_f: { de: "Brauche ich eine eigene Domain und ein eigenes Hosting?", "de-CH": "Bruuch i e eigeni Domain und es eigets Hosting?", en: "Do I need my own domain and hosting?", fr: "Ai-je besoin de mon propre domaine et hébergement ?", pt: "Preciso de domínio e alojamento próprios?" },
|
||||||
|
f10_a: {
|
||||||
|
de: "Ja, beides gehört dir und läuft auf deinen Namen — das ist wichtig, damit du unabhängig bleibst. Wenn du noch nichts hast, helfe ich bei der Auswahl und der Einrichtung. Wenn du schon eine Domain hast, ziehen wir um; die Adresse bleibt dann dieselbe.",
|
||||||
|
"de-CH": "Jo, beidi ghöred dir und laufed uf din Name — das isch wichtig, damit du unabhängig blibsch. Wenn du no nüt hesch, hilf i bi dr Uuswahl und dr Iirichtig. Wenn du scho e Domain hesch, züged mer um; d Adress blibt denn diesälb.",
|
||||||
|
en: "Yes, both belong to you and run in your name — that matters so you stay independent. If you have nothing yet, I help with choosing and setting it up. If you already have a domain, we migrate; the address then stays the same.",
|
||||||
|
fr: "Oui, les deux t'appartiennent et sont à ton nom — c'est important pour rester indépendant. Si tu n'as encore rien, je t'aide à choisir et à configurer. Si tu as déjà un domaine, nous migrons ; l'adresse reste alors identique.",
|
||||||
|
pt: "Sim, ambos te pertencem e ficam em teu nome — isso é importante para manteres a independência. Se ainda não tens nada, ajudo na escolha e na configuração. Se já tens domínio, migramos; o endereço mantém-se.",
|
||||||
|
},
|
||||||
|
f11_f: { de: "Bin ich danach von dir abhängig?", "de-CH": "Bin i dernoch vo dir abhängig?", en: "Will I depend on you afterwards?", fr: "Serai-je dépendant de toi ensuite ?", pt: "Fico dependente de ti depois?" },
|
||||||
|
f11_a: {
|
||||||
|
de: "Nein, und das ist Absicht. Domain und Hosting laufen auf dich, du bekommst alle Zugänge, und nach vollständiger Bezahlung liegen die Nutzungsrechte an der Seite bei dir. Betreuung ist ein Angebot, keine Voraussetzung — wenn du sie kündigst, läuft deine Seite trotzdem weiter.",
|
||||||
|
"de-CH": "Nei, und das isch Absicht. Domain und Hosting laufed uf dich, du überchunnsch alli Zuegäng, und nach vollständiger Zahlig liged d Nutzigsrächt a dr Site bi dir. Betreuig isch es Aagebot, kei Voruussetzig — wenn du si kündsch, lauft dini Site trotzdem wiiter.",
|
||||||
|
en: "No, and that is deliberate. Domain and hosting are in your name, you receive all access details, and after full payment the usage rights to the site are yours. Support is an offer, not a requirement — if you cancel it, your site keeps running.",
|
||||||
|
fr: "Non, et c'est voulu. Domaine et hébergement sont à ton nom, tu reçois tous les accès, et après paiement intégral les droits d'utilisation du site sont à toi. Le suivi est une offre, pas une obligation — si tu le résilies, ton site continue de fonctionner.",
|
||||||
|
pt: "Não, e isso é intencional. Domínio e alojamento ficam em teu nome, recebes todos os acessos e, após o pagamento integral, os direitos de utilização do site são teus. O acompanhamento é uma oferta, não um requisito — se o cancelares, o teu site continua a funcionar.",
|
||||||
|
},
|
||||||
|
f12_f: { de: "Wie steht es um Datenschutz und Impressum?", "de-CH": "Wie stoht s um Datesch un Impressum?", en: "What about data protection and legal notice?", fr: "Qu'en est-il de la protection des données et des mentions légales ?", pt: "E quanto à proteção de dados e informações legais?" },
|
||||||
|
f12_a: {
|
||||||
|
de: "Die technischen Voraussetzungen baue ich ein: verschlüsselte Verbindung, sichere Cookies, keine unnötigen Dienste von Dritten, ein Datenschutztext, der zu den tatsächlich eingesetzten Bausteinen passt. Was ich ausdrücklich nicht mache, ist Rechtsberatung — die endgültige rechtliche Prüfung deiner Texte gehört zu einer Fachperson.",
|
||||||
|
"de-CH": "D technische Voruussetzige bau i ii: verschlüsselti Verbindig, sicheri Cookies, kei unnötigi Dienscht vo Dritte, en Dateschutztäxt, wo zu de tatsächlich iigsetzte Bauteil passt. Was i uusdrücklich nöd mache, isch Rächtsberatig — d ändgültigi rächtlichi Prüefig vo dine Täxt ghört zu ere Fachperson.",
|
||||||
|
en: "I build in the technical prerequisites: encrypted connection, secure cookies, no unnecessary third-party services, a privacy text that matches the components actually used. What I explicitly do not do is legal advice — the final legal review of your texts belongs with a qualified professional.",
|
||||||
|
fr: "Je mets en place les prérequis techniques : connexion chiffrée, cookies sécurisés, pas de services tiers superflus, un texte de confidentialité correspondant aux composants réellement utilisés. Ce que je ne fais explicitement pas, c'est du conseil juridique — la validation juridique finale de tes textes revient à un professionnel.",
|
||||||
|
pt: "Implemento os requisitos técnicos: ligação encriptada, cookies seguros, sem serviços de terceiros desnecessários, um texto de privacidade que corresponde aos componentes realmente usados. O que explicitamente não faço é aconselhamento jurídico — a validação legal final dos teus textos cabe a um profissional.",
|
||||||
|
},
|
||||||
|
|
||||||
|
/* --- Veröffentlichung & danach --- */
|
||||||
|
f13_f: { de: "Wird meine Seite ins Portfolio aufgenommen?", "de-CH": "Wird mini Site is Portfolio ufgnoh?", en: "Will my site be added to the portfolio?", fr: "Mon site sera-t-il ajouté au portfolio ?", pt: "O meu site vai para o portfólio?" },
|
||||||
|
f13_a: {
|
||||||
|
de: "Nur wenn du es erlaubst, und das ist eine eigene Entscheidung — unabhängig davon, ob deine Seite live geht. Du kannst jederzeit widerrufen, dann verschwindet der Eintrag. Erfundene Kundenstimmen gibt es hier grundsätzlich nicht; ein Zitat erscheint nur, wenn du es selbst geschrieben und freigegeben hast.",
|
||||||
|
"de-CH": "Nur wenn du s erlaubsch, und das isch e eigeni Entscheidig — unabhängig devo, ob dini Site live goht. Du chasch jederziit widerrüefe, denn verschwindet dr Iitrag. Erfundeni Kundestimme git s do grundsätzlich nöd; es Zitat erschiint nur, wenn du s sälber gschriebe und freigäh hesch.",
|
||||||
|
en: "Only if you allow it, and that is a separate decision — independent of whether your site goes live. You can withdraw at any time and the entry disappears. Invented customer quotes do not exist here on principle; a quote only appears if you wrote and approved it yourself.",
|
||||||
|
fr: "Uniquement si tu l'autorises, et c'est une décision distincte — indépendante de la mise en ligne de ton site. Tu peux te rétracter à tout moment et l'entrée disparaît. Les témoignages inventés n'existent pas ici par principe ; une citation n'apparaît que si tu l'as écrite et validée toi-même.",
|
||||||
|
pt: "Só se autorizares, e essa é uma decisão separada — independente de o teu site ir para o ar. Podes retirar a autorização a qualquer momento e a entrada desaparece. Depoimentos inventados não existem aqui por princípio; uma citação só aparece se a tiveres escrito e aprovado.",
|
||||||
|
},
|
||||||
|
f14_f: { de: "Was passiert, wenn die Seite ausfällt?", "de-CH": "Was passiert, wenn d Site uusfallt?", en: "What happens if the site goes down?", fr: "Que se passe-t-il si le site tombe en panne ?", pt: "O que acontece se o site ficar em baixo?" },
|
||||||
|
f14_a: {
|
||||||
|
de: "Mit Betreuung: Ich merke es in der Regel selbst, weil die Erreichbarkeit überwacht wird, und es gibt Sicherungen, die nachweislich zurückgespielt werden können — eine Sicherung, die noch nie getestet wurde, ist keine. Ohne Betreuung: Du meldest dich, und ich helfe nach Aufwand, so schnell es geht.",
|
||||||
|
"de-CH": "Mit Betreuig: Ich merk s in dr Regel sälber, wil d Erreichbarkeit überwacht wird, und s git Sicherige, wo nachwiisbar zrugg gspielt wärde chönd — e Sicherig, wo no nie tescht worde isch, isch kei. Ohni Betreuig: Du mäldsch dich, und ich hilf nach Ufwand, so schnäll s goht.",
|
||||||
|
en: "With support: I usually notice myself because uptime is monitored, and there are backups that can demonstrably be restored — a backup that has never been tested is not one. Without support: you get in touch and I help, billed by effort, as fast as possible.",
|
||||||
|
fr: "Avec suivi : je le remarque généralement moi-même car la disponibilité est surveillée, et il existe des sauvegardes dont la restauration a été testée — une sauvegarde jamais testée n'en est pas une. Sans suivi : tu me contactes et j'interviens au temps passé, aussi vite que possible.",
|
||||||
|
pt: "Com acompanhamento: normalmente dou por isso, porque a disponibilidade é monitorizada, e existem cópias de segurança que comprovadamente podem ser restauradas — uma cópia nunca testada não é uma cópia. Sem acompanhamento: contactas-me e ajudo por esforço, o mais rápido possível.",
|
||||||
|
},
|
||||||
|
|
||||||
|
fq_rest_titel: {
|
||||||
|
de: "Deine Frage war nicht dabei?",
|
||||||
|
"de-CH": "Dini Frog isch nöd debii gsi?",
|
||||||
|
en: "Your question wasn't there?",
|
||||||
|
fr: "Ta question n'y était pas ?",
|
||||||
|
pt: "A tua pergunta não estava aqui?",
|
||||||
|
},
|
||||||
|
fq_rest_text: {
|
||||||
|
de: "Schreib sie einfach ins Anfrageformular — es gibt dort ein freies Feld dafür. Du kannst mir auch direkt eine E-Mail schreiben, wenn dir das lieber ist.",
|
||||||
|
"de-CH": "Schriib si eifach is Aafrogformular — es git dete es freis Fäld defür. Du chasch mer au grad e E-Mail schriibe, wenn dir das lieber isch.",
|
||||||
|
en: "Just write it in the enquiry form — there is a free-text field for exactly that. You can also simply e-mail me if you prefer.",
|
||||||
|
fr: "Écris-la simplement dans le formulaire — un champ libre est prévu pour cela. Tu peux aussi m'envoyer un e-mail si tu préfères.",
|
||||||
|
pt: "Escreve-a simplesmente no formulário — existe um campo livre para isso. Também me podes enviar um e-mail, se preferires.",
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,487 @@
|
|||||||
|
/* =====================================================================
|
||||||
|
i18n-wd-index.js — Startseite des Webdesign-Bereichs, 5 Sprachen.
|
||||||
|
|
||||||
|
Grundsätze (gelten für alle i18n-wd-*.js):
|
||||||
|
- Markennamen bleiben unübersetzt: Dogfather, VanVan, Dogfather Universe.
|
||||||
|
- Schweizerdeutsch ist echter Dialekt, keine ß→ss-Umschrift.
|
||||||
|
- Preise und Zeitrahmen sind an EINER Stelle formuliert und werden
|
||||||
|
überall gleich zitiert — Masterplan S.16 verlangt ausdrücklich
|
||||||
|
"Preise, Dauer, 30 % Anzahlung und Personalisierung sind überall
|
||||||
|
widerspruchsfrei".
|
||||||
|
===================================================================== */
|
||||||
|
window.I18N_WD = {
|
||||||
|
seiten_titel: {
|
||||||
|
de: "Dogfather Webdesign — individuelle Webseiten, persönlich umgesetzt",
|
||||||
|
"de-CH": "Dogfather Webdesign — eigeti Websites, persönlich gmacht",
|
||||||
|
en: "Dogfather Web Design — individual websites, built personally",
|
||||||
|
fr: "Dogfather Web Design — des sites sur mesure, réalisés personnellement",
|
||||||
|
pt: "Dogfather Web Design — sites individuais, feitos pessoalmente",
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ---------- 01 HERO ---------- */
|
||||||
|
hero_eyebrow: {
|
||||||
|
de: "Dogfather Webdesign",
|
||||||
|
"de-CH": "Dogfather Webdesign",
|
||||||
|
en: "Dogfather Web Design",
|
||||||
|
fr: "Dogfather Web Design",
|
||||||
|
pt: "Dogfather Web Design",
|
||||||
|
},
|
||||||
|
hero_h1: {
|
||||||
|
de: "Deine Webseite soll nicht mithalten.<br /><span class=\"wd-hero-glanz\">Sie soll auffallen.</span>",
|
||||||
|
"de-CH": "Dini Website söll nöd mithalte.<br /><span class=\"wd-hero-glanz\">Si söll uffalle.</span>",
|
||||||
|
en: "Your website shouldn't keep up.<br /><span class=\"wd-hero-glanz\">It should stand out.</span>",
|
||||||
|
fr: "Ton site ne doit pas suivre le rythme.<br /><span class=\"wd-hero-glanz\">Il doit se démarquer.</span>",
|
||||||
|
pt: "O teu site não deve acompanhar.<br /><span class=\"wd-hero-glanz\">Deve destacar-se.</span>",
|
||||||
|
},
|
||||||
|
hero_text: {
|
||||||
|
de: "Individuell gebaut statt aus dem Baukasten. Du sprichst von der ersten Idee bis zur fertigen Seite mit einer Person — und weißt vorher, was es kostet und wie lange es dauert.",
|
||||||
|
"de-CH": "Eigets baut statt us em Baukaste. Du redsch vo dr erste Idee bis zur fertige Site mit einere Person — und weisch vorher, was es choscht und wie lang s duuret.",
|
||||||
|
en: "Built individually, not from a template kit. From the first idea to the finished site you talk to one single person — and you know the cost and the timeframe up front.",
|
||||||
|
fr: "Conçu sur mesure, pas à partir d'un kit. De la première idée au site fini, tu parles à une seule personne — et tu connais le prix et les délais à l'avance.",
|
||||||
|
pt: "Construído individualmente, não a partir de um kit. Da primeira ideia ao site pronto falas com uma única pessoa — e sabes o preço e o prazo à partida.",
|
||||||
|
},
|
||||||
|
hero_cta: {
|
||||||
|
de: "Projekt anfragen",
|
||||||
|
"de-CH": "Projekt aafroge",
|
||||||
|
en: "Start a project",
|
||||||
|
fr: "Demander un projet",
|
||||||
|
pt: "Pedir projeto",
|
||||||
|
},
|
||||||
|
hero_cta2: {
|
||||||
|
de: "Beispiele ansehen",
|
||||||
|
"de-CH": "Bispiel aaluege",
|
||||||
|
en: "See examples",
|
||||||
|
fr: "Voir des exemples",
|
||||||
|
pt: "Ver exemplos",
|
||||||
|
},
|
||||||
|
hero_beleg1: { de: "Ab 300 €", "de-CH": "Ab 300 €", en: "From €300", fr: "À partir de 300 €", pt: "A partir de 300 €" },
|
||||||
|
hero_beleg2: { de: "Erste Seiten in 5–10 Arbeitstagen", "de-CH": "Erschti Site i 5–10 Arbeitstäg", en: "First sites in 5–10 working days", fr: "Premiers sites en 5 à 10 jours ouvrés", pt: "Primeiros sites em 5–10 dias úteis" },
|
||||||
|
hero_beleg3: { de: "Eine feste Ansprechperson", "de-CH": "Ei feschti Aaspröchperson", en: "One fixed contact person", fr: "Un interlocuteur unique", pt: "Uma pessoa de contacto fixa" },
|
||||||
|
|
||||||
|
/* ---------- 02 VERTRAUEN ---------- */
|
||||||
|
vt_h2: {
|
||||||
|
de: "Warum das hier anders läuft",
|
||||||
|
"de-CH": "Wieso s da andersch lauft",
|
||||||
|
en: "Why this works differently",
|
||||||
|
fr: "Pourquoi ça se passe autrement ici",
|
||||||
|
pt: "Porque é que aqui é diferente",
|
||||||
|
},
|
||||||
|
vt_1_h3: { de: "Individuell", "de-CH": "Eiget", en: "Individual", fr: "Sur mesure", pt: "Individual" },
|
||||||
|
vt_1_p: {
|
||||||
|
de: "Kein fertiges Muster mit ausgetauschtem Logo. Aufbau, Farben und Texte entstehen für dein Vorhaben.",
|
||||||
|
"de-CH": "Kes fertigs Muschter mit uustuschtem Logo. Ufbau, Farbe und Täxt entstöhnd für dis Vorhabe.",
|
||||||
|
en: "No ready-made template with a swapped logo. Structure, colours and copy are created for your project.",
|
||||||
|
fr: "Pas de modèle tout fait avec un logo remplacé. Structure, couleurs et textes sont créés pour ton projet.",
|
||||||
|
pt: "Nenhum modelo pronto com o logótipo trocado. Estrutura, cores e textos são criados para o teu projeto.",
|
||||||
|
},
|
||||||
|
vt_2_h3: { de: "Persönlich", "de-CH": "Persönlich", en: "Personal", fr: "Personnel", pt: "Pessoal" },
|
||||||
|
vt_2_p: {
|
||||||
|
de: "Du hast eine Ansprechperson — keine Ticketnummer und keine wechselnden Zuständigkeiten.",
|
||||||
|
"de-CH": "Du hesch ei Aaspröchperson — kei Ticketnummere und kei wächslendi Zueständigkeite.",
|
||||||
|
en: "You have one contact person — no ticket number and no changing responsibilities.",
|
||||||
|
fr: "Tu as un interlocuteur — pas de numéro de ticket ni de responsables qui changent.",
|
||||||
|
pt: "Tens uma pessoa de contacto — sem número de bilhete e sem responsáveis a mudar.",
|
||||||
|
},
|
||||||
|
vt_3_h3: { de: "Auf jedem Gerät", "de-CH": "Uf jedem Grät", en: "On every device", fr: "Sur chaque appareil", pt: "Em todos os dispositivos" },
|
||||||
|
vt_3_p: {
|
||||||
|
de: "Handy zuerst geprüft, dann Tablet und Computer. Nichts wird abgeschnitten, nichts ist zu klein zum Antippen.",
|
||||||
|
"de-CH": "S Handy zerscht prüeft, denn s Tablet und dr Computer. Nüt wird abgschnitte, nüt isch z chli zum Aatippe.",
|
||||||
|
en: "Mobile checked first, then tablet and desktop. Nothing gets cut off, nothing is too small to tap.",
|
||||||
|
fr: "Mobile vérifié en premier, puis tablette et ordinateur. Rien n'est coupé, rien n'est trop petit à toucher.",
|
||||||
|
pt: "Telemóvel verificado primeiro, depois tablet e computador. Nada fica cortado, nada é pequeno demais para tocar.",
|
||||||
|
},
|
||||||
|
vt_4_h3: { de: "Klare Preise", "de-CH": "Klari Priis", en: "Clear pricing", fr: "Tarifs clairs", pt: "Preços claros" },
|
||||||
|
vt_4_p: {
|
||||||
|
de: "Einstiegspreise und Zeitrahmen stehen offen auf der Seite. Der Endpreis kommt schriftlich, bevor gearbeitet wird.",
|
||||||
|
"de-CH": "Iistiegspriis und Ziitrahme stöhnd offe uf dr Site. Dr Ändpriis chunnt schriftlich, bevor gschaffet wird.",
|
||||||
|
en: "Starting prices and timeframes are stated openly on the site. The final price comes in writing before any work starts.",
|
||||||
|
fr: "Les prix de départ et les délais sont indiqués ouvertement. Le prix final est confirmé par écrit avant le début du travail.",
|
||||||
|
pt: "Preços iniciais e prazos estão indicados abertamente. O preço final vem por escrito antes de começar o trabalho.",
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ---------- 03 LEISTUNGSKARTEN ---------- */
|
||||||
|
ls_eyebrow: { de: "Leistungen", "de-CH": "Was mir mache", en: "Services", fr: "Prestations", pt: "Serviços" },
|
||||||
|
ls_h2: {
|
||||||
|
de: "Vier Wege zu deiner Seite",
|
||||||
|
"de-CH": "Vier Wäg zu dinere Site",
|
||||||
|
en: "Four routes to your website",
|
||||||
|
fr: "Quatre voies vers ton site",
|
||||||
|
pt: "Quatro caminhos para o teu site",
|
||||||
|
},
|
||||||
|
ls_text: {
|
||||||
|
de: "Alle Preise sind Einstiegspunkte. Umfang, Funktionen und Personalisierung können Preis und Dauer verändern — was bei dir gilt, steht im schriftlichen Angebot.",
|
||||||
|
"de-CH": "Alli Priis sind Iistiegspünkt. Umfang, Funktione und Personalisierig chönd Priis und Duur verändere — was bi dir gilt, stoht im schriftliche Aagebot.",
|
||||||
|
en: "All prices are starting points. Scope, features and personalisation can change price and duration — what applies to you is set out in the written quote.",
|
||||||
|
fr: "Tous les prix sont des points de départ. L'étendue, les fonctions et la personnalisation peuvent modifier le prix et la durée — ce qui s'applique à toi figure dans le devis écrit.",
|
||||||
|
pt: "Todos os preços são pontos de partida. Âmbito, funcionalidades e personalização podem alterar preço e duração — o que se aplica a ti consta do orçamento escrito.",
|
||||||
|
},
|
||||||
|
|
||||||
|
pk_onepager_p: {
|
||||||
|
de: "Eine starke einzelne Seite für Person, Angebot, Aktion oder Kampagne.",
|
||||||
|
"de-CH": "Ei starchi einzelni Site für Person, Aagebot, Aktion oder Kampagne.",
|
||||||
|
en: "One strong single page for a person, an offer, a campaign or a promotion.",
|
||||||
|
fr: "Une seule page forte pour une personne, une offre, une action ou une campagne.",
|
||||||
|
pt: "Uma página única e forte para pessoa, oferta, ação ou campanha.",
|
||||||
|
},
|
||||||
|
|
||||||
|
pk_website_p: {
|
||||||
|
de: "Mehrere Unterseiten für Unternehmen, Creator, Teams oder Projekte.",
|
||||||
|
"de-CH": "Mehreri Undersite für Firme, Creator, Teams oder Projekt.",
|
||||||
|
en: "Several sub-pages for companies, creators, teams or projects.",
|
||||||
|
fr: "Plusieurs sous-pages pour entreprises, créateurs, équipes ou projets.",
|
||||||
|
pt: "Várias subpáginas para empresas, criadores, equipas ou projetos.",
|
||||||
|
},
|
||||||
|
|
||||||
|
pk_shop_p: {
|
||||||
|
de: "Produktverkauf, Nutzerbereiche und umfangreiche Funktionen.",
|
||||||
|
"de-CH": "Produktverchauf, Nutzerbereich und viili Funktione.",
|
||||||
|
en: "Product sales, user areas and extensive functionality.",
|
||||||
|
fr: "Vente de produits, espaces utilisateurs et fonctions étendues.",
|
||||||
|
pt: "Venda de produtos, áreas de utilizador e funcionalidades extensas.",
|
||||||
|
},
|
||||||
|
|
||||||
|
pk_verwaltung_p: {
|
||||||
|
de: "Technisches Portal für Belege, Einnahmen, Ausgaben und Dokumente.",
|
||||||
|
"de-CH": "Technischs Portal für Bäläg, Iinahme, Uusgabe und Dokumänt.",
|
||||||
|
en: "A technical portal for receipts, income, expenses and documents.",
|
||||||
|
fr: "Un portail technique pour justificatifs, recettes, dépenses et documents.",
|
||||||
|
pt: "Um portal técnico para comprovativos, receitas, despesas e documentos.",
|
||||||
|
},
|
||||||
|
|
||||||
|
pk_details: { de: "Was genau enthalten ist", "de-CH": "Was gnau dinne isch", en: "What exactly is included", fr: "Ce qui est inclus exactement", pt: "O que está exatamente incluído" },
|
||||||
|
|
||||||
|
betreuung_p: {
|
||||||
|
de: "Kleine Änderungen werden typischerweise innerhalb von ca. 2–5 Arbeitstagen bearbeitet. Umfang und Priorität richten sich nach dem gewählten Paket.",
|
||||||
|
"de-CH": "Chlini Änderige wärded typischerwiis innert öppe 2–5 Arbeitstäg gmacht. Umfang und Priorität richted sich nach em gwählte Paket.",
|
||||||
|
en: "Small changes are typically handled within approx. 2–5 working days. Scope and priority depend on the package you choose.",
|
||||||
|
fr: "Les petites modifications sont généralement traitées en 2 à 5 jours ouvrés. L'étendue et la priorité dépendent de la formule choisie.",
|
||||||
|
pt: "Pequenas alterações são normalmente tratadas em aprox. 2–5 dias úteis. O âmbito e a prioridade dependem do pacote escolhido.",
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
/* ---------- 04 AUSGEWÄHLTE PROJEKTE ---------- */
|
||||||
|
pf_eyebrow: { de: "Ausgewählte Projekte", "de-CH": "Uusgwählti Projekt", en: "Selected projects", fr: "Projets sélectionnés", pt: "Projetos selecionados" },
|
||||||
|
pf_h2: {
|
||||||
|
de: "Zwei Seiten, die wirklich laufen",
|
||||||
|
"de-CH": "Zwei Site, wo würklich laufed",
|
||||||
|
en: "Two sites that are genuinely live",
|
||||||
|
fr: "Deux sites réellement en ligne",
|
||||||
|
pt: "Dois sites realmente no ar",
|
||||||
|
},
|
||||||
|
pf_text: {
|
||||||
|
de: "Keine erfundenen Beispiele und keine Bewertungen, die es nie gab. Beides sind echte, öffentlich erreichbare Seiten mit allem, was dahintersteckt.",
|
||||||
|
"de-CH": "Kei erfundeni Bispiel und kei Bewärtige, wo s nie ggäh het. Beidi sind ächti, öffentlich erreichbari Site mit allem, wo dehinter steckt.",
|
||||||
|
en: "No invented examples and no reviews that never existed. Both are real, publicly reachable sites — including everything behind them.",
|
||||||
|
fr: "Aucun exemple inventé, aucun avis qui n'a jamais existé. Les deux sont de vrais sites accessibles publiquement, avec tout ce qu'il y a derrière.",
|
||||||
|
pt: "Sem exemplos inventados e sem avaliações que nunca existiram. Ambos são sites reais, publicamente acessíveis — incluindo tudo o que está por trás.",
|
||||||
|
},
|
||||||
|
pf_alle: { de: "Alle Projekte ansehen", "de-CH": "Alli Projekt aaluege", en: "View all projects", fr: "Voir tous les projets", pt: "Ver todos os projetos" },
|
||||||
|
pf_aufgabe: { de: "Aufgabe", "de-CH": "Ufgab", en: "Task", fr: "Mission", pt: "Tarefa" },
|
||||||
|
pf_ergebnis: { de: "Ergebnis", "de-CH": "Ergebnis", en: "Result", fr: "Résultat", pt: "Resultado" },
|
||||||
|
pf_ansehen: { de: "Live ansehen", "de-CH": "Live aaluege", en: "View live", fr: "Voir en ligne", pt: "Ver ao vivo" },
|
||||||
|
|
||||||
|
pf_universe_h3: { de: "Dogfather Universe", "de-CH": "Dogfather Universe", en: "Dogfather Universe", fr: "Dogfather Universe", pt: "Dogfather Universe" },
|
||||||
|
pf_universe_alt: {
|
||||||
|
de: "Startseite von dogfather-universe.com",
|
||||||
|
"de-CH": "Startsite vo dogfather-universe.com",
|
||||||
|
en: "Home page of dogfather-universe.com",
|
||||||
|
fr: "Page d'accueil de dogfather-universe.com",
|
||||||
|
pt: "Página inicial de dogfather-universe.com",
|
||||||
|
},
|
||||||
|
pf_universe_p: {
|
||||||
|
de: "Content- und Community-Plattform mit fünf Sprachen, Teamseiten, Streamplan, Bewerbungssystem, Supporter-Abo und eigenem Verwaltungsbereich.",
|
||||||
|
"de-CH": "Content- und Community-Plattform mit füf Sprooche, Teamsite, Streamplan, Bewerbigssyschtem, Supporter-Abo und eigenem Verwaltigsbereich.",
|
||||||
|
en: "Content and community platform with five languages, team pages, stream schedule, application system, supporter subscription and its own admin area.",
|
||||||
|
fr: "Plateforme de contenu et de communauté en cinq langues, avec pages d'équipe, planning de stream, système de candidature, abonnement supporter et espace d'administration.",
|
||||||
|
pt: "Plataforma de conteúdo e comunidade em cinco idiomas, com páginas de equipa, plano de streams, sistema de candidaturas, subscrição de apoiante e área de administração própria.",
|
||||||
|
},
|
||||||
|
pf_universe_aufgabe: {
|
||||||
|
de: "Drei getrennte Welten unter ein Dach bringen, ohne dass es unübersichtlich wird.",
|
||||||
|
"de-CH": "Drü trennti Wälte under es Dach bringe, ohni dass es unübersichtlich wird.",
|
||||||
|
en: "Bring three separate worlds under one roof without it becoming confusing.",
|
||||||
|
fr: "Réunir trois univers distincts sous un même toit sans que cela devienne confus.",
|
||||||
|
pt: "Reunir três mundos separados sob o mesmo teto sem que se torne confuso.",
|
||||||
|
},
|
||||||
|
pf_universe_ergebnis: {
|
||||||
|
de: "Öffentlich seit August 2026, läuft auf einem eigenen Server, inhaltlich komplett selbst pflegbar.",
|
||||||
|
"de-CH": "Öffentlich sit Auguscht 2026, lauft uf eme eigene Server, inhaltlich komplett sälber pflegbar.",
|
||||||
|
en: "Public since August 2026, running on its own server, fully maintainable in-house.",
|
||||||
|
fr: "En ligne depuis août 2026, hébergé sur un serveur dédié, entièrement gérable en interne.",
|
||||||
|
pt: "Público desde agosto de 2026, a correr em servidor próprio, totalmente gerível internamente.",
|
||||||
|
},
|
||||||
|
pf_tag_mehrsprachig: { de: "5 Sprachen", "de-CH": "5 Sprooche", en: "5 languages", fr: "5 langues", pt: "5 idiomas" },
|
||||||
|
pf_tag_verwaltung: { de: "Eigene Verwaltung", "de-CH": "Eigeni Verwaltig", en: "Own admin area", fr: "Espace d'administration", pt: "Área de administração" },
|
||||||
|
pf_tag_abo: { de: "Abo & Zahlung", "de-CH": "Abo & Zahlig", en: "Subscription & payment", fr: "Abonnement & paiement", pt: "Subscrição & pagamento" },
|
||||||
|
|
||||||
|
pf_shop_alt: {
|
||||||
|
de: "Startseite von vans-diy-bastelbedarf.com",
|
||||||
|
"de-CH": "Startsite vo vans-diy-bastelbedarf.com",
|
||||||
|
en: "Home page of vans-diy-bastelbedarf.com",
|
||||||
|
fr: "Page d'accueil de vans-diy-bastelbedarf.com",
|
||||||
|
pt: "Página inicial de vans-diy-bastelbedarf.com",
|
||||||
|
},
|
||||||
|
pf_shop_h3: { de: "Van's DIY & Bastelbedarf", "de-CH": "Van's DIY & Bastelbedarf", en: "Van's DIY & Bastelbedarf", fr: "Van's DIY & Bastelbedarf", pt: "Van's DIY & Bastelbedarf" },
|
||||||
|
pf_shop_p: {
|
||||||
|
de: "Vollständiger Onlineshop mit Produktverwaltung, Warenkorb, Gutscheincodes, Versandabläufen und einem Adminbereich, in dem Artikel ohne Technikkenntnisse gepflegt werden.",
|
||||||
|
"de-CH": "Vollständige Onlineshop mit Produktverwaltig, Warechorb, Gutscheincodes, Versandabläuf und eme Adminbereich, wo Artikel ohni Technikkenntnis pflegt wärded.",
|
||||||
|
en: "A complete online shop with product management, basket, voucher codes, shipping flows and an admin area where items are maintained without any technical knowledge.",
|
||||||
|
fr: "Une boutique en ligne complète avec gestion des produits, panier, codes de réduction, flux d'expédition et un espace d'administration où les articles se gèrent sans connaissances techniques.",
|
||||||
|
pt: "Uma loja online completa com gestão de produtos, carrinho, códigos de desconto, fluxos de envio e uma área de administração onde os artigos se gerem sem conhecimentos técnicos.",
|
||||||
|
},
|
||||||
|
pf_shop_aufgabe: {
|
||||||
|
de: "Ein Shop, den die Inhaberin komplett selbst befüllen kann — ohne Schulung und ohne Angst, etwas kaputtzumachen.",
|
||||||
|
"de-CH": "En Shop, wo d Inhaberin komplett sälber befülle cha — ohni Schuelig und ohni Angscht, öppis kaputt z mache.",
|
||||||
|
en: "A shop the owner can fill entirely herself — without training and without fear of breaking something.",
|
||||||
|
fr: "Une boutique que la propriétaire peut alimenter entièrement elle-même — sans formation et sans crainte de casser quelque chose.",
|
||||||
|
pt: "Uma loja que a proprietária consegue preencher sozinha — sem formação e sem medo de estragar algo.",
|
||||||
|
},
|
||||||
|
pf_shop_ergebnis: {
|
||||||
|
de: "Läuft auf eigener Domain und eigenem Server; Artikelpflege passiert selbstständig im Adminbereich.",
|
||||||
|
"de-CH": "Lauft uf eigener Domain und eigenem Server; d Artikelpflege passiert sälbstständig im Adminbereich.",
|
||||||
|
en: "Running on its own domain and server; item maintenance happens independently in the admin area.",
|
||||||
|
fr: "Hébergée sur son propre domaine et serveur ; la gestion des articles se fait en autonomie dans l'espace d'administration.",
|
||||||
|
pt: "A funcionar em domínio e servidor próprios; a gestão de artigos é feita de forma autónoma na área de administração.",
|
||||||
|
},
|
||||||
|
pf_shop_hinweis: {
|
||||||
|
de: "Bilder und Detailansicht folgen, sobald die Inhaberin sie freigegeben hat. Ohne Freigabe zeigen wir hier nichts.",
|
||||||
|
"de-CH": "Bilder und Detailaasicht chömed, sobald d Inhaberin sie freigäh het. Ohni Freigab zeiged mir do nüt.",
|
||||||
|
en: "Images and the detailed view will follow as soon as the owner has approved them. Without approval we show nothing here.",
|
||||||
|
fr: "Les images et la vue détaillée suivront dès que la propriétaire les aura validées. Sans validation, nous ne montrons rien ici.",
|
||||||
|
pt: "Imagens e a vista detalhada seguem assim que a proprietária as aprovar. Sem aprovação não mostramos nada aqui.",
|
||||||
|
},
|
||||||
|
pf_tag_shop: { de: "Shop & Kasse", "de-CH": "Shop & Kasse", en: "Shop & checkout", fr: "Boutique & paiement", pt: "Loja & pagamento" },
|
||||||
|
pf_tag_admin: { de: "Selbstpflege", "de-CH": "Sälberpflege", en: "Self-service editing", fr: "Gestion autonome", pt: "Gestão autónoma" },
|
||||||
|
pf_tag_gutschein: { de: "Gutscheincodes", "de-CH": "Gutscheincodes", en: "Voucher codes", fr: "Codes promo", pt: "Códigos de desconto" },
|
||||||
|
|
||||||
|
/* ---------- 05 SO LÄUFT ES AB ---------- */
|
||||||
|
ab_eyebrow: { de: "So läuft es ab", "de-CH": "So laufts ab", en: "How it works", fr: "Comment ça se passe", pt: "Como funciona" },
|
||||||
|
ab_h2: {
|
||||||
|
de: "Von der Anfrage bis zur Übergabe",
|
||||||
|
"de-CH": "Vo dr Aafrog bis zur Übergab",
|
||||||
|
en: "From enquiry to handover",
|
||||||
|
fr: "De la demande à la remise",
|
||||||
|
pt: "Do pedido à entrega",
|
||||||
|
},
|
||||||
|
ab_1_h3: { de: "Anfrage", "de-CH": "Aafrog", en: "Enquiry", fr: "Demande", pt: "Pedido" },
|
||||||
|
ab_1_p: {
|
||||||
|
de: "Du füllst das Formular aus. Es fragt genau das ab, was für ein ehrliches Angebot nötig ist — nicht mehr.",
|
||||||
|
"de-CH": "Du füllsch s Formular us. Es frogt gnau das ab, wo für es ehrlichs Aagebot nötig isch — nöd meh.",
|
||||||
|
en: "You fill in the form. It asks exactly what is needed for an honest quote — nothing more.",
|
||||||
|
fr: "Tu remplis le formulaire. Il demande exactement ce qui est nécessaire pour un devis honnête — rien de plus.",
|
||||||
|
pt: "Preenches o formulário. Pergunta exatamente o necessário para um orçamento honesto — nada mais.",
|
||||||
|
},
|
||||||
|
ab_2_h3: { de: "Briefing", "de-CH": "Briefing", en: "Briefing", fr: "Briefing", pt: "Briefing" },
|
||||||
|
ab_2_p: {
|
||||||
|
de: "Wir klären offene Punkte im Gespräch: Ziel, Zielgruppe, Umfang und was du schon an Inhalten hast.",
|
||||||
|
"de-CH": "Mir kläred offni Pünkt im Gspröch: Ziel, Zielgruppe, Umfang und was du scho a Inhalt hesch.",
|
||||||
|
en: "We clarify the open points in a conversation: goal, audience, scope and the content you already have.",
|
||||||
|
fr: "Nous clarifions les points ouverts lors d'un échange : objectif, public, étendue et contenus déjà disponibles.",
|
||||||
|
pt: "Esclarecemos os pontos em aberto numa conversa: objetivo, público, âmbito e os conteúdos que já tens.",
|
||||||
|
},
|
||||||
|
ab_3_h3: { de: "Angebot", "de-CH": "Aagebot", en: "Quote", fr: "Devis", pt: "Orçamento" },
|
||||||
|
ab_3_p: {
|
||||||
|
de: "Du bekommst Preis, Zeitrahmen und Umfang schriftlich. Erst wenn du zustimmst und die 30 % Anzahlung da sind, wird dein Termin reserviert.",
|
||||||
|
"de-CH": "Du überchunnsch Priis, Ziitrahme und Umfang schriftlich. Erscht wenn du zuestimmsch und d 30 % Aazahlig do sind, wird din Termin reserviert.",
|
||||||
|
en: "You receive price, timeframe and scope in writing. Your slot is reserved only once you agree and the 30 % deposit has arrived.",
|
||||||
|
fr: "Tu reçois le prix, le délai et l'étendue par écrit. Ton créneau n'est réservé qu'après ton accord et la réception de l'acompte de 30 %.",
|
||||||
|
pt: "Recebes preço, prazo e âmbito por escrito. A tua data só é reservada depois de concordares e de o sinal de 30 % ter chegado.",
|
||||||
|
},
|
||||||
|
ab_4_h3: { de: "Design", "de-CH": "Design", en: "Design", fr: "Design", pt: "Design" },
|
||||||
|
ab_4_p: {
|
||||||
|
de: "Du siehst den Entwurf, bevor gebaut wird — auf Handy und Computer. Zwei Feedbackrunden sind üblich eingeplant.",
|
||||||
|
"de-CH": "Du gsehsch dr Entwurf, bevor baut wird — uf Handy und Computer. Zwei Feedbackrunde sind üblich iiplant.",
|
||||||
|
en: "You see the draft before anything gets built — on mobile and desktop. Two feedback rounds are usually included.",
|
||||||
|
fr: "Tu vois la maquette avant toute construction — sur mobile et ordinateur. Deux tours de retours sont généralement prévus.",
|
||||||
|
pt: "Vês o esboço antes de se construir seja o que for — em telemóvel e computador. Normalmente estão previstas duas rondas de feedback.",
|
||||||
|
},
|
||||||
|
ab_5_h3: { de: "Entwicklung & Tests", "de-CH": "Entwicklig & Teschts", en: "Development & testing", fr: "Développement & tests", pt: "Desenvolvimento & testes" },
|
||||||
|
ab_5_p: {
|
||||||
|
de: "Die Seite wird gebaut und danach durchgespielt: jedes Formular, jeder Link, jeder Fehlerfall — auf echten Bildschirmgrößen.",
|
||||||
|
"de-CH": "D Site wird baut und denn durespielt: jedes Formular, jede Link, jede Fählerfall — uf ächte Bildschirmgrössi.",
|
||||||
|
en: "The site gets built and then played through: every form, every link, every error case — at real screen sizes.",
|
||||||
|
fr: "Le site est développé puis testé de bout en bout : chaque formulaire, chaque lien, chaque cas d'erreur — à des tailles d'écran réelles.",
|
||||||
|
pt: "O site é construído e depois testado por completo: cada formulário, cada ligação, cada caso de erro — em tamanhos de ecrã reais.",
|
||||||
|
},
|
||||||
|
ab_6_h3: { de: "Übergabe", "de-CH": "Übergab", en: "Handover", fr: "Remise", pt: "Entrega" },
|
||||||
|
ab_6_p: {
|
||||||
|
de: "Veröffentlichung erst nach deiner ausdrücklichen Freigabe. Danach eine Einweisung, damit du deine Inhalte selbst pflegen kannst.",
|
||||||
|
"de-CH": "Veröffentlichig erscht nach dinere uusdrückliche Freigab. Denn e Iiwiisig, damit du dini Inhalt sälber pflege chasch.",
|
||||||
|
en: "Publication happens only after your explicit approval. Then a walkthrough so you can maintain your content yourself.",
|
||||||
|
fr: "La mise en ligne n'a lieu qu'après ta validation explicite. Ensuite une prise en main pour que tu puisses gérer tes contenus toi-même.",
|
||||||
|
pt: "A publicação só acontece após a tua aprovação explícita. Depois uma introdução para poderes gerir os teus conteúdos.",
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ---------- 06 WARUM DOGFATHER ---------- */
|
||||||
|
wd_eyebrow: { de: "Warum Dogfather", "de-CH": "Wieso Dogfather", en: "Why Dogfather", fr: "Pourquoi Dogfather", pt: "Porquê Dogfather" },
|
||||||
|
wd_h2: {
|
||||||
|
de: "Eine Person, die dranbleibt",
|
||||||
|
"de-CH": "Ei Person, wo dra bliibt",
|
||||||
|
en: "One person who stays on it",
|
||||||
|
fr: "Une personne qui reste impliquée",
|
||||||
|
pt: "Uma pessoa que não larga o projeto",
|
||||||
|
},
|
||||||
|
wd_p1: {
|
||||||
|
de: "Hinter Dogfather Webdesign steht keine Agentur mit wechselnden Zuständigkeiten, sondern eine Person, die dein Projekt von Anfang bis Ende begleitet — und die dieselbe Sorgfalt hineinsteckt, die im Dogfather Universe steckt.",
|
||||||
|
"de-CH": "Hinder Dogfather Webdesign stoht kei Agentur mit wächslende Zueständigkeite, sondern ei Person, wo dis Projekt vo Aafang bis Änd begleitet — und wo diesälb Sorgfalt inestecht, wo im Dogfather Universe stecht.",
|
||||||
|
en: "Behind Dogfather Web Design there is no agency with changing responsibilities, but one person who accompanies your project from start to finish — with the same care that went into the Dogfather Universe.",
|
||||||
|
fr: "Derrière Dogfather Web Design, il n'y a pas d'agence aux responsables changeants, mais une personne qui accompagne ton projet du début à la fin — avec le même soin que celui investi dans le Dogfather Universe.",
|
||||||
|
pt: "Por trás do Dogfather Web Design não está uma agência com responsáveis a mudar, mas uma pessoa que acompanha o teu projeto do início ao fim — com o mesmo cuidado investido no Dogfather Universe.",
|
||||||
|
},
|
||||||
|
wd_p2: {
|
||||||
|
de: "Das heißt auch: keine Versprechen, die später nicht gehalten werden. Was nicht geht, sage ich vorher — nicht, wenn die Anzahlung schon da ist.",
|
||||||
|
"de-CH": "Das heisst au: kei Verspräche, wo spöter nöd ghalte wärded. Was nöd goht, säg i vorher — nöd, wenn d Aazahlig scho do isch.",
|
||||||
|
en: "That also means: no promises that later go unkept. If something isn't feasible I say so up front — not once the deposit has landed.",
|
||||||
|
fr: "Cela signifie aussi : pas de promesses non tenues par la suite. Si quelque chose n'est pas faisable, je le dis à l'avance — pas une fois l'acompte encaissé.",
|
||||||
|
pt: "Isso também significa: sem promessas que depois não se cumprem. Se algo não é viável, digo-o à partida — não depois de o sinal ter entrado.",
|
||||||
|
},
|
||||||
|
wd_mehr: { de: "Mehr über Dogfather", "de-CH": "Meh über dä Dogfather", en: "More about Dogfather", fr: "En savoir plus sur Dogfather", pt: "Mais sobre o Dogfather" },
|
||||||
|
wd_foto_alt: {
|
||||||
|
de: "Dogfather",
|
||||||
|
"de-CH": "Dogfather",
|
||||||
|
en: "Dogfather",
|
||||||
|
fr: "Dogfather",
|
||||||
|
pt: "Dogfather",
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ---------- 07 FAQ ---------- */
|
||||||
|
faq_eyebrow: { de: "Häufige Fragen", "de-CH": "Hüüfigi Frage", en: "Frequently asked", fr: "Questions fréquentes", pt: "Perguntas frequentes" },
|
||||||
|
faq_h2: {
|
||||||
|
de: "Das, was fast alle fragen",
|
||||||
|
"de-CH": "Das, wo fascht alli fröged",
|
||||||
|
en: "What almost everyone asks",
|
||||||
|
fr: "Ce que presque tout le monde demande",
|
||||||
|
pt: "O que quase todos perguntam",
|
||||||
|
},
|
||||||
|
faq_1_f: {
|
||||||
|
de: "Ist der genannte Preis der Endpreis?",
|
||||||
|
"de-CH": "Isch dä gnennt Priis dr Ändpriis?",
|
||||||
|
en: "Is the stated price the final price?",
|
||||||
|
fr: "Le prix indiqué est-il le prix final ?",
|
||||||
|
pt: "O preço indicado é o preço final?",
|
||||||
|
},
|
||||||
|
faq_1_a: {
|
||||||
|
de: "Nein. Die Beträge auf dieser Seite sind Einstiegspreise. Der verbindliche Endpreis entsteht erst durch ein schriftliches Angebot, nachdem der tatsächliche Umfang geklärt ist. Das Anfrageformular erzeugt keinen Vertrag.",
|
||||||
|
"de-CH": "Nei. D Beträg uf dere Site sind Iistiegspriis. Dr verbindlich Ändpriis entstoht erscht dur es schriftlichs Aagebot, nachdem dr tatsächlich Umfang klärt isch. S Aafrogformular macht kein Vertrag.",
|
||||||
|
en: "No. The amounts on this page are starting prices. The binding final price only comes from a written quote once the actual scope has been clarified. The enquiry form does not create a contract.",
|
||||||
|
fr: "Non. Les montants sur cette page sont des prix de départ. Le prix final contractuel découle d'un devis écrit, une fois l'étendue réelle clarifiée. Le formulaire de demande ne crée aucun contrat.",
|
||||||
|
pt: "Não. Os valores nesta página são preços iniciais. O preço final vinculativo resulta de um orçamento escrito, depois de esclarecido o âmbito real. O formulário de pedido não cria qualquer contrato.",
|
||||||
|
},
|
||||||
|
faq_2_f: {
|
||||||
|
de: "Warum 30 % Anzahlung?",
|
||||||
|
"de-CH": "Wieso 30 % Aazahlig?",
|
||||||
|
en: "Why a 30 % deposit?",
|
||||||
|
fr: "Pourquoi un acompte de 30 % ?",
|
||||||
|
pt: "Porquê um sinal de 30 %?",
|
||||||
|
},
|
||||||
|
faq_2_a: {
|
||||||
|
de: "Weil damit dein Zeitfenster fest reserviert wird. Solange keine Anzahlung da ist, ist kein Termin blockiert — und ein Projekt, das kurzfristig abspringt, geht sonst auf Kosten aller anderen, die warten.",
|
||||||
|
"de-CH": "Wil demit dis Ziitfenschter fescht reserviert wird. So lang kei Aazahlig do isch, isch kein Termin blockiert — und es Projekt, wo churzfrischtig abspringt, goht süsch uf Choschte vo allne andere, wo warted.",
|
||||||
|
en: "Because it firmly reserves your time slot. Without a deposit no slot is blocked — and a project that drops out at short notice otherwise comes at the expense of everyone else who is waiting.",
|
||||||
|
fr: "Parce qu'il réserve fermement ton créneau. Sans acompte, aucun créneau n'est bloqué — et un projet qui se désiste au dernier moment se fait sinon au détriment de tous ceux qui attendent.",
|
||||||
|
pt: "Porque reserva firmemente a tua janela de tempo. Sem sinal não há data bloqueada — e um projeto que desiste em cima da hora fica de outro modo à custa de todos os que esperam.",
|
||||||
|
},
|
||||||
|
faq_3_f: {
|
||||||
|
de: "Was ist, wenn ich noch keine Texte und Bilder habe?",
|
||||||
|
"de-CH": "Was isch, wenn i no kei Täxt und Bilder han?",
|
||||||
|
en: "What if I don't have any text or images yet?",
|
||||||
|
fr: "Et si je n'ai encore ni textes ni images ?",
|
||||||
|
pt: "E se ainda não tiver textos nem imagens?",
|
||||||
|
},
|
||||||
|
faq_3_a: {
|
||||||
|
de: "Das ist normal und kein Hindernis. Sag es im Anfrageformular ehrlich — dann planen wir es von Anfang an ein, statt später überrascht zu werden. Fehlende Inhalte sind der häufigste Grund, warum Projekte länger dauern als geplant.",
|
||||||
|
"de-CH": "Das isch normal und kes Hindernis. Sägs im Aafrogformular ehrlich — denn plane mers vo Aafang a ii, statt spöter überrascht z wärde. Fählendi Inhalt sind dr hüüfigscht Grund, wieso Projekt länger duured als plant.",
|
||||||
|
en: "That is normal and no obstacle. Say so honestly in the enquiry form — then we plan for it from the start instead of being surprised later. Missing content is the most common reason why projects take longer than planned.",
|
||||||
|
fr: "C'est normal et ce n'est pas un obstacle. Indique-le honnêtement dans le formulaire — nous l'intégrons dès le départ au lieu d'être surpris plus tard. Les contenus manquants sont la première cause de retard des projets.",
|
||||||
|
pt: "É normal e não é obstáculo. Diz isso honestamente no formulário — assim planeamos desde o início em vez de sermos surpreendidos depois. Falta de conteúdos é a causa mais comum de projetos demorarem mais do que o previsto.",
|
||||||
|
},
|
||||||
|
faq_4_f: {
|
||||||
|
de: "Gehört die Seite danach mir?",
|
||||||
|
"de-CH": "Ghört d Site denn mir?",
|
||||||
|
en: "Do I own the site afterwards?",
|
||||||
|
fr: "Le site m'appartient-il ensuite ?",
|
||||||
|
pt: "O site fica meu depois?",
|
||||||
|
},
|
||||||
|
faq_4_a: {
|
||||||
|
de: "Ja. Nach vollständiger Bezahlung erhältst du die Nutzungsrechte an der für dich erstellten Seite samt Inhalten. Eingesetzte fremde Bausteine (z. B. Schriften oder Bilder aus Bibliotheken) behalten ihre eigene Lizenz — welche das sind, steht im Angebot.",
|
||||||
|
"de-CH": "Jo. Nach vollständiger Zahlig überchunnsch d Nutzigsrächt a dr für dich erstellte Site samt Inhalt. Iigsetzti frömdi Bauteil (z. B. Schrifte oder Bilder us Bibliotheke) behalted ihri eigeni Lizänz — weli das sind, stoht im Aagebot.",
|
||||||
|
en: "Yes. After full payment you receive the usage rights to the site created for you, including its content. Third-party components used (e.g. fonts or stock images) keep their own licence — which ones these are is listed in the quote.",
|
||||||
|
fr: "Oui. Après paiement intégral, tu obtiens les droits d'utilisation du site créé pour toi, contenus compris. Les composants tiers utilisés (polices, images de banque) conservent leur propre licence — lesquels, c'est précisé dans le devis.",
|
||||||
|
pt: "Sim. Após o pagamento integral recebes os direitos de utilização do site criado para ti, incluindo os conteúdos. Componentes de terceiros utilizados (p. ex. tipos de letra ou imagens de banco) mantêm a sua própria licença — quais são consta do orçamento.",
|
||||||
|
},
|
||||||
|
faq_5_f: {
|
||||||
|
de: "Wird ohne meine Zustimmung etwas veröffentlicht?",
|
||||||
|
"de-CH": "Wird ohni mini Zuestimmig öppis veröffentlicht?",
|
||||||
|
en: "Will anything be published without my consent?",
|
||||||
|
fr: "Quelque chose sera-t-il publié sans mon accord ?",
|
||||||
|
pt: "Será publicado algo sem o meu consentimento?",
|
||||||
|
},
|
||||||
|
faq_5_a: {
|
||||||
|
de: "Nein. Weder die Seite selbst noch ein Beispiel davon im Portfolio. Beides braucht deine ausdrückliche Freigabe, und beides ist getrennt — du kannst die Seite live schalten und trotzdem nicht im Portfolio auftauchen wollen.",
|
||||||
|
"de-CH": "Nei. Weder d Site sälber no es Bispiel devo im Portfolio. Beidi bruuched dini uusdrücklichi Freigab, und beidi sind trennt — du chasch d Site live schalte und trotzdem nöd im Portfolio uftauche welle.",
|
||||||
|
en: "No. Neither the site itself nor an example of it in the portfolio. Both require your explicit approval, and both are separate — you can take the site live and still not want to appear in the portfolio.",
|
||||||
|
fr: "Non. Ni le site lui-même, ni un exemple dans le portfolio. Les deux nécessitent ton accord explicite et sont indépendants — tu peux mettre le site en ligne sans vouloir figurer au portfolio.",
|
||||||
|
pt: "Não. Nem o site em si nem um exemplo dele no portfólio. Ambos exigem a tua aprovação explícita e são independentes — podes pôr o site no ar e mesmo assim não querer aparecer no portfólio.",
|
||||||
|
},
|
||||||
|
faq_alle: { de: "Alle Fragen und Antworten", "de-CH": "Alli Frage und Antworte", en: "All questions and answers", fr: "Toutes les questions et réponses", pt: "Todas as perguntas e respostas" },
|
||||||
|
|
||||||
|
/* ---------- 08 ABSCHLUSS-CTA ---------- */
|
||||||
|
cta_h2: {
|
||||||
|
de: "Erzähl mir von deinem Vorhaben",
|
||||||
|
"de-CH": "Verzell mer vo dim Vorhabe",
|
||||||
|
en: "Tell me about your project",
|
||||||
|
fr: "Parle-moi de ton projet",
|
||||||
|
pt: "Conta-me o teu projeto",
|
||||||
|
},
|
||||||
|
cta_p: {
|
||||||
|
de: "Ein paar Minuten für das Formular reichen. Du bekommst danach eine Zusammenfassung, ich melde mich mit Rückfragen oder direkt mit einem Angebot — unverbindlich und ohne Kosten.",
|
||||||
|
"de-CH": "E paar Minute fürs Formular länged. Du überchunnsch denn e Zämefassig, ich mälde mich mit Rückfroge oder grad mit eme Aagebot — unverbindlich und ohni Choschte.",
|
||||||
|
en: "A few minutes with the form is enough. You then get a summary, and I come back with follow-up questions or directly with a quote — non-binding and free of charge.",
|
||||||
|
fr: "Quelques minutes suffisent pour le formulaire. Tu reçois ensuite un récapitulatif, et je reviens vers toi avec des questions ou directement avec un devis — sans engagement ni frais.",
|
||||||
|
pt: "Bastam alguns minutos no formulário. Recebes depois um resumo e eu volto com perguntas ou diretamente com um orçamento — sem compromisso e sem custos.",
|
||||||
|
},
|
||||||
|
cta_knopf: { de: "Projekt anfragen", "de-CH": "Projekt aafroge", en: "Start a project", fr: "Demander un projet", pt: "Pedir projeto" },
|
||||||
|
cta_klein: {
|
||||||
|
de: "Unverbindlich · Keine Kosten · Keine automatische Preiszusage",
|
||||||
|
"de-CH": "Unverbindlich · Kei Choschte · Kei automatischi Priiszuesag",
|
||||||
|
en: "Non-binding · Free of charge · No automatic price commitment",
|
||||||
|
fr: "Sans engagement · Gratuit · Aucun engagement de prix automatique",
|
||||||
|
pt: "Sem compromisso · Sem custos · Sem compromisso automático de preço",
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ---------- Marken-Intro nach dem Zugang (Masterplan S.3, Schritt 02) ---------- */
|
||||||
|
intro_gruss_dogfather: {
|
||||||
|
de: "Willkommen, Dogfather",
|
||||||
|
"de-CH": "Willkomme, Dogfather",
|
||||||
|
en: "Welcome, Dogfather",
|
||||||
|
fr: "Bienvenue, Dogfather",
|
||||||
|
pt: "Bem-vindo, Dogfather",
|
||||||
|
},
|
||||||
|
intro_gruss_vanvan: {
|
||||||
|
de: "Willkommen, VanVan",
|
||||||
|
"de-CH": "Willkomme, VanVan",
|
||||||
|
en: "Welcome, VanVan",
|
||||||
|
fr: "Bienvenue, VanVan",
|
||||||
|
pt: "Bem-vinda, VanVan",
|
||||||
|
},
|
||||||
|
intro_gruss_allgemein: {
|
||||||
|
de: "Willkommen",
|
||||||
|
"de-CH": "Willkomme",
|
||||||
|
en: "Welcome",
|
||||||
|
fr: "Bienvenue",
|
||||||
|
pt: "Bem-vindo",
|
||||||
|
},
|
||||||
|
intro_unter: {
|
||||||
|
de: "Dogfather Webdesign — Vorabversion",
|
||||||
|
"de-CH": "Dogfather Webdesign — Vorabversion",
|
||||||
|
en: "Dogfather Web Design — preview version",
|
||||||
|
fr: "Dogfather Web Design — version préliminaire",
|
||||||
|
pt: "Dogfather Web Design — versão prévia",
|
||||||
|
},
|
||||||
|
intro_ueberspringen: {
|
||||||
|
de: "Überspringen",
|
||||||
|
"de-CH": "Überspringe",
|
||||||
|
en: "Skip",
|
||||||
|
fr: "Passer",
|
||||||
|
pt: "Saltar",
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
/* =====================================================================
|
||||||
|
i18n-wd-leistungen.js — Leistungsdetails (Masterplan S.9)
|
||||||
|
Fünf Sprachen. Preise/Dauer sind wortgleich zur Startseite und zur
|
||||||
|
Preisseite formuliert (Masterplan S.16: überall widerspruchsfrei).
|
||||||
|
===================================================================== */
|
||||||
|
window.I18N_WD = {
|
||||||
|
seiten_titel: {
|
||||||
|
de: "Leistungen — Dogfather Webdesign",
|
||||||
|
"de-CH": "Was mir mache — Dogfather Webdesign",
|
||||||
|
en: "Services — Dogfather Web Design",
|
||||||
|
fr: "Prestations — Dogfather Web Design",
|
||||||
|
pt: "Serviços — Dogfather Web Design",
|
||||||
|
},
|
||||||
|
|
||||||
|
ls_eyebrow: { de: "Klare Pakete", "de-CH": "Klari Pakete", en: "Clear packages", fr: "Des formules claires", pt: "Pacotes claros" },
|
||||||
|
ls_h1: {
|
||||||
|
de: "Was du ungefähr bekommst",
|
||||||
|
"de-CH": "Was du öppe überchunnsch",
|
||||||
|
en: "What you get, roughly",
|
||||||
|
fr: "Ce que tu obtiens, en gros",
|
||||||
|
pt: "O que recebes, aproximadamente",
|
||||||
|
},
|
||||||
|
ls_lead: {
|
||||||
|
de: "Keine leeren Werbeversprechen. Zu jeder Kategorie steht hier, für wen sie gedacht ist, was typischerweise drin ist, was extra kostet — und wo die Grenze verläuft.",
|
||||||
|
"de-CH": "Kei leeri Werbeverspräche. Zu jedere Kategorie stoht do, für wän si dänkt isch, was typischerwiis dinne isch, was extra choschtet — und wo d Gränze verlauft.",
|
||||||
|
en: "No empty marketing promises. For every category this page states who it is for, what is typically included, what costs extra — and where the limit lies.",
|
||||||
|
fr: "Pas de promesses publicitaires creuses. Pour chaque catégorie, cette page indique à qui elle s'adresse, ce qui est généralement inclus, ce qui coûte en plus — et où se situe la limite.",
|
||||||
|
pt: "Sem promessas publicitárias vazias. Para cada categoria esta página indica a quem se destina, o que está tipicamente incluído, o que custa extra — e onde está o limite.",
|
||||||
|
},
|
||||||
|
|
||||||
|
/* --- Gemeinsame Beschriftungen --- */
|
||||||
|
fuer_wen: { de: "Für wen", "de-CH": "Für wän", en: "Who it's for", fr: "Pour qui", pt: "Para quem" },
|
||||||
|
enthalten: { de: "Typisch enthalten", "de-CH": "Typisch dinne", en: "Typically included", fr: "Généralement inclus", pt: "Tipicamente incluído" },
|
||||||
|
beispiel: { de: "Konkretes Beispiel", "de-CH": "Konkrets Bispiel", en: "A concrete example", fr: "Exemple concret", pt: "Exemplo concreto" },
|
||||||
|
extras: { de: "Kostet extra", "de-CH": "Choschtet extra", en: "Costs extra", fr: "En supplément", pt: "Custa à parte" },
|
||||||
|
grenze: { de: "Nicht enthalten", "de-CH": "Nöd dinne", en: "Not included", fr: "Non inclus", pt: "Não incluído" },
|
||||||
|
jetzt_anfragen: { de: "Dieses Paket anfragen", "de-CH": "Das Paket aafroge", en: "Enquire about this package", fr: "Demander cette formule", pt: "Pedir este pacote" },
|
||||||
|
|
||||||
|
extras_allgemein: {
|
||||||
|
de: "Zusätzliche Sprachen · weitere Unterseiten · Logo- und Bildbearbeitung · Texterstellung · Anbindung an fremde Systeme · zusätzliche Feedbackrunden über die vereinbarten hinaus",
|
||||||
|
"de-CH": "Zuesätzlichi Sprooche · wiiteri Undersite · Logo- und Bildbearbeitig · Täxt schriibe · Aabindig a frömdi Syschtem · zuesätzlichi Feedbackrunde über die vereinbarte use",
|
||||||
|
en: "Additional languages · further sub-pages · logo and image editing · copywriting · connections to third-party systems · extra feedback rounds beyond those agreed",
|
||||||
|
fr: "Langues supplémentaires · pages additionnelles · retouche de logo et d'images · rédaction · connexion à des systèmes tiers · tours de retours supplémentaires au-delà de ce qui est convenu",
|
||||||
|
pt: "Idiomas adicionais · mais subpáginas · edição de logótipo e imagens · redação de textos · ligação a sistemas externos · rondas de feedback extra além das acordadas",
|
||||||
|
},
|
||||||
|
|
||||||
|
/* --- Onepager --- */
|
||||||
|
op_h2: { de: "Onepager", "de-CH": "Onepager", en: "One-pager", fr: "Page unique", pt: "Página única" },
|
||||||
|
op_fuer: {
|
||||||
|
de: "Einzelpersonen, Creator, eine einzelne Aktion oder ein klar umrissenes Angebot, das keine Unterseiten braucht.",
|
||||||
|
"de-CH": "Einzelpersone, Creator, ei einzelni Aktion oder es klar umrissnigs Aagebot, wo kei Undersite bruucht.",
|
||||||
|
en: "Individuals, creators, a single campaign or a clearly defined offer that does not need sub-pages.",
|
||||||
|
fr: "Particuliers, créateurs, une action ponctuelle ou une offre clairement définie qui n'a pas besoin de sous-pages.",
|
||||||
|
pt: "Particulares, criadores, uma ação pontual ou uma oferta bem definida que não precisa de subpáginas.",
|
||||||
|
},
|
||||||
|
op_1: { de: "Bis zu ca. fünf Inhaltsbereiche auf einer Seite", "de-CH": "Bis zu öppe füf Inhaltsbereich uf einere Site", en: "Up to approx. five content sections on one page", fr: "Jusqu'à environ cinq sections de contenu sur une page", pt: "Até cerca de cinco secções de conteúdo numa página" },
|
||||||
|
op_2: { de: "Hero, Vorstellung, Angebot, Links und Kontakt", "de-CH": "Hero, Vorstellig, Aagebot, Links und Kontakt", en: "Hero, introduction, offer, links and contact", fr: "Hero, présentation, offre, liens et contact", pt: "Hero, apresentação, oferta, ligações e contacto" },
|
||||||
|
op_3: { de: "Responsiv auf Handy, Tablet und Computer", "de-CH": "Responsiv uf Handy, Tablet und Computer", en: "Responsive on mobile, tablet and desktop", fr: "Responsive sur mobile, tablette et ordinateur", pt: "Responsivo em telemóvel, tablet e computador" },
|
||||||
|
op_4: { de: "Grundstruktur für Suchmaschinen (Titel, Beschreibung, Überschriften)", "de-CH": "Grundstruktur für Suechmaschine (Titel, Beschriibig, Überschrifte)", en: "Basic search engine structure (title, description, headings)", fr: "Structure de base pour les moteurs de recherche (titre, description, titres)", pt: "Estrutura básica para motores de busca (título, descrição, cabeçalhos)" },
|
||||||
|
op_5: { de: "Typischerweise zwei Feedbackrunden", "de-CH": "Typischerwiis zwei Feedbackrunde", en: "Typically two feedback rounds", fr: "Généralement deux tours de retours", pt: "Tipicamente duas rondas de feedback" },
|
||||||
|
op_beispiel: {
|
||||||
|
de: "Eine Seite für einen Creator: oben wer du bist und was du machst, darunter die aktuelle Aktion, deine Kanäle, ein kurzes Kontaktformular. Alles auf einem Bildschirm erreichbar, ohne Menü.",
|
||||||
|
"de-CH": "Ei Site für en Creator: obe wär du bisch und was du machsch, drunter d aktuelli Aktion, dini Kanäl, es churzes Kontaktformular. Alles uf eme Bildschirm erreichbar, ohni Menü.",
|
||||||
|
en: "One page for a creator: who you are and what you do at the top, below it the current campaign, your channels, a short contact form. Everything reachable on one screen, without a menu.",
|
||||||
|
fr: "Une page pour un créateur : en haut qui tu es et ce que tu fais, en dessous l'action en cours, tes canaux, un court formulaire de contact. Tout accessible sur un écran, sans menu.",
|
||||||
|
pt: "Uma página para um criador: em cima quem és e o que fazes, abaixo a ação atual, os teus canais, um formulário de contacto curto. Tudo acessível num ecrã, sem menu.",
|
||||||
|
},
|
||||||
|
op_grenze: {
|
||||||
|
de: "Kein Shop, keine Nutzerkonten, keine Unterseiten. Wenn absehbar mehr als eine Seite nötig wird, ist die professionelle Website der ehrlichere Weg — auch wenn sie mehr kostet.",
|
||||||
|
"de-CH": "Kei Shop, kei Nutzerkonte, kei Undersite. Wenn abgseh isch, dass meh als ei Site nötig wird, isch d Profi-Website dr ehrlicher Wäg — au wenn si meh choschtet.",
|
||||||
|
en: "No shop, no user accounts, no sub-pages. If more than one page is foreseeably needed, the professional website is the more honest route — even though it costs more.",
|
||||||
|
fr: "Pas de boutique, pas de comptes utilisateurs, pas de sous-pages. Si plus d'une page s'avère nécessaire, le site professionnel est la voie la plus honnête — même s'il coûte plus cher.",
|
||||||
|
pt: "Sem loja, sem contas de utilizador, sem subpáginas. Se for previsível ser necessária mais do que uma página, o website profissional é o caminho mais honesto — mesmo custando mais.",
|
||||||
|
},
|
||||||
|
|
||||||
|
/* --- Professionelle Website --- */
|
||||||
|
ws_h2: { de: "Professionelle Website", "de-CH": "Profi-Website", en: "Professional website", fr: "Site professionnel", pt: "Website profissional" },
|
||||||
|
ws_fuer: {
|
||||||
|
de: "Unternehmen, Teams, Vereine, Creator mit mehreren Themen — überall dort, wo eine einzelne Seite zu voll würde.",
|
||||||
|
"de-CH": "Firme, Teams, Verein, Creator mit mehrere Thema — überall dete, wo ei einzelni Site z voll wär.",
|
||||||
|
en: "Companies, teams, clubs, creators with several topics — wherever a single page would become too crowded.",
|
||||||
|
fr: "Entreprises, équipes, associations, créateurs avec plusieurs thèmes — partout où une seule page serait trop chargée.",
|
||||||
|
pt: "Empresas, equipas, associações, criadores com vários temas — sempre que uma única página ficaria demasiado cheia.",
|
||||||
|
},
|
||||||
|
ws_1: { de: "Typisch bis zu fünf Kernseiten", "de-CH": "Typisch bis zu füf Kernsite", en: "Typically up to five core pages", fr: "Généralement jusqu'à cinq pages principales", pt: "Tipicamente até cinco páginas principais" },
|
||||||
|
ws_2: { de: "Navigation, Formulare, Galerie oder Videos", "de-CH": "Navigation, Formular, Galerie oder Video", en: "Navigation, forms, gallery or videos", fr: "Navigation, formulaires, galerie ou vidéos", pt: "Navegação, formulários, galeria ou vídeos" },
|
||||||
|
ws_3: { de: "Responsive Umsetzung und Funktionscheck auf echten Geräten", "de-CH": "Responsivi Umsetzig und Funktionscheck uf ächte Grät", en: "Responsive build and functional check on real devices", fr: "Réalisation responsive et vérification fonctionnelle sur appareils réels", pt: "Implementação responsiva e verificação funcional em dispositivos reais" },
|
||||||
|
ws_4: { de: "Unterstützung bei der Veröffentlichung (Domain, Zertifikat, Umzug)", "de-CH": "Understützig bi dr Veröffentlichig (Domain, Zertifikat, Umzug)", en: "Support with going live (domain, certificate, migration)", fr: "Accompagnement à la mise en ligne (domaine, certificat, migration)", pt: "Apoio na publicação (domínio, certificado, migração)" },
|
||||||
|
ws_5: { de: "Einweisung, damit du Texte selbst ändern kannst", "de-CH": "Iiwiisig, damit du Täxt sälber ändere chasch", en: "A walkthrough so you can change texts yourself", fr: "Une prise en main pour modifier les textes toi-même", pt: "Uma introdução para poderes alterar textos sozinho" },
|
||||||
|
ws_beispiel: {
|
||||||
|
de: "Ein Handwerksbetrieb: Startseite, Leistungen, Referenzen mit Bildern, Über uns, Kontakt mit Anfahrt und Formular. Anfragen landen strukturiert im Postfach statt als formlose Mail.",
|
||||||
|
"de-CH": "En Handwärchsbetrieb: Startsite, Leischtige, Referänze mit Bilder, Über üs, Kontakt mit Aafahrt und Formular. Aafroge lande strukturiert im Poschtfach statt als formlosi Mail.",
|
||||||
|
en: "A trade business: home, services, references with images, about us, contact with directions and a form. Enquiries arrive structured in the inbox instead of as a formless e-mail.",
|
||||||
|
fr: "Une entreprise artisanale : accueil, prestations, références avec photos, à propos, contact avec plan d'accès et formulaire. Les demandes arrivent structurées dans la boîte de réception au lieu d'un e-mail informe.",
|
||||||
|
pt: "Uma empresa de ofício: início, serviços, referências com imagens, sobre nós, contacto com indicações e formulário. Os pedidos chegam estruturados à caixa de entrada em vez de um e-mail informe.",
|
||||||
|
},
|
||||||
|
ws_grenze: {
|
||||||
|
de: "Kein Produktverkauf und keine geschützten Nutzerbereiche. Sobald etwas verkauft oder ein Login gebraucht wird, gilt die nächste Kategorie.",
|
||||||
|
"de-CH": "Kein Produktverchauf und kei gschützti Nutzerbereich. Sobald öppis verchauft wird oder es Login bruucht wird, gilt d nöchscht Kategorie.",
|
||||||
|
en: "No product sales and no protected user areas. As soon as something is sold or a login is needed, the next category applies.",
|
||||||
|
fr: "Pas de vente de produits ni d'espaces utilisateurs protégés. Dès qu'il y a vente ou besoin d'un identifiant, c'est la catégorie suivante.",
|
||||||
|
pt: "Sem venda de produtos e sem áreas de utilizador protegidas. Assim que houver venda ou necessidade de login, aplica-se a categoria seguinte.",
|
||||||
|
},
|
||||||
|
|
||||||
|
/* --- Shop --- */
|
||||||
|
sh_h2: { de: "Shop & umfangreiche Website", "de-CH": "Shop & grossi Website", en: "Shop & large website", fr: "Boutique & grand site", pt: "Loja & site extenso" },
|
||||||
|
sh_fuer: {
|
||||||
|
de: "Alle, die tatsächlich online verkaufen wollen — Handgemachtes, digitale Produkte, Merch — oder einen geschützten Bereich für Kundinnen und Kunden brauchen.",
|
||||||
|
"de-CH": "Alli, wo würklich online verchaufe wänd — Handgmachts, digitali Produkt, Merch — oder en gschützte Bereich für Chunde bruuched.",
|
||||||
|
en: "Anyone who genuinely wants to sell online — handmade goods, digital products, merch — or needs a protected area for customers.",
|
||||||
|
fr: "Tous ceux qui veulent réellement vendre en ligne — fait main, produits numériques, merch — ou qui ont besoin d'un espace protégé pour leurs clients.",
|
||||||
|
pt: "Quem quer mesmo vender online — artigos feitos à mão, produtos digitais, merch — ou precisa de uma área protegida para clientes.",
|
||||||
|
},
|
||||||
|
sh_1: { de: "Produkte, Detailseiten, Warenkorb und Kasse", "de-CH": "Produkt, Detailsite, Warechorb und Kasse", en: "Products, detail pages, basket and checkout", fr: "Produits, fiches détaillées, panier et paiement", pt: "Produtos, páginas de detalhe, carrinho e finalização" },
|
||||||
|
sh_2: { de: "Zahlungs- und Versandabläufe eingerichtet und durchgespielt", "de-CH": "Zahligs- und Versandabläuf iigrichtet und durespielt", en: "Payment and shipping flows set up and tested end to end", fr: "Flux de paiement et d'expédition configurés et testés de bout en bout", pt: "Fluxos de pagamento e envio configurados e testados de ponta a ponta" },
|
||||||
|
sh_3: { de: "Mobile Kundenführung — Kasse funktioniert auf dem Handy wirklich", "de-CH": "Mobili Chundeführig — d Kasse funktioniert uf em Handy würklich", en: "Mobile customer journey — checkout genuinely works on a phone", fr: "Parcours client mobile — le paiement fonctionne vraiment sur téléphone", pt: "Percurso de cliente móvel — a finalização funciona mesmo no telemóvel" },
|
||||||
|
sh_4: { de: "Adminbereich zur eigenständigen Produktpflege", "de-CH": "Adminbereich zur sälbstständige Produktpflege", en: "Admin area for maintaining products yourself", fr: "Espace d'administration pour gérer les produits toi-même", pt: "Área de administração para gerires os produtos" },
|
||||||
|
sh_5: { de: "Einweisung in die Produktpflege", "de-CH": "Iiwiisig i d Produktpflege", en: "A walkthrough of product maintenance", fr: "Prise en main de la gestion des produits", pt: "Introdução à gestão de produtos" },
|
||||||
|
sh_beispiel: {
|
||||||
|
de: "Van's DIY & Bastelbedarf: Kategorien, Artikel mit Bildern und Varianten, Warenkorb, Gutscheincodes, Versandregeln — und ein Adminbereich, in dem neue Artikel ohne Technikkenntnisse angelegt werden.",
|
||||||
|
"de-CH": "Van's DIY & Bastelbedarf: Kategorie, Artikel mit Bilder und Variante, Warechorb, Gutscheincodes, Versandregle — und en Adminbereich, wo neui Artikel ohni Technikkenntnis aagleit wärded.",
|
||||||
|
en: "Van's DIY & Bastelbedarf: categories, items with images and variants, basket, voucher codes, shipping rules — and an admin area where new items are created without any technical knowledge.",
|
||||||
|
fr: "Van's DIY & Bastelbedarf : catégories, articles avec photos et variantes, panier, codes promo, règles d'expédition — et un espace d'administration où les nouveaux articles se créent sans connaissances techniques.",
|
||||||
|
pt: "Van's DIY & Bastelbedarf: categorias, artigos com imagens e variantes, carrinho, códigos de desconto, regras de envio — e uma área de administração onde se criam novos artigos sem conhecimentos técnicos.",
|
||||||
|
},
|
||||||
|
sh_grenze: {
|
||||||
|
de: "Gebühren von Zahlungsanbietern, Kosten für Domain und Server sowie steuerliche Pflichten liegen bei dir. Ich richte die Technik ein, übernehme aber weder Buchhaltung noch Rechtsberatung.",
|
||||||
|
"de-CH": "Gebühre vo Zahligsaabieter, Choschte für Domain und Server und stüürlichi Pflichte liged bi dir. Ich richte d Technik ii, übernimm aber weder Buechhaltig no Rächtsberatig.",
|
||||||
|
en: "Payment provider fees, domain and server costs and tax obligations remain yours. I set up the technology but take on neither bookkeeping nor legal advice.",
|
||||||
|
fr: "Les frais des prestataires de paiement, les coûts de domaine et de serveur ainsi que les obligations fiscales restent à ta charge. Je mets en place la technique, mais je n'assure ni la comptabilité ni le conseil juridique.",
|
||||||
|
pt: "Taxas de fornecedores de pagamento, custos de domínio e servidor e obrigações fiscais são da tua responsabilidade. Eu monto a tecnologia, mas não assumo contabilidade nem aconselhamento jurídico.",
|
||||||
|
},
|
||||||
|
|
||||||
|
/* --- Verwaltungsseiten --- */
|
||||||
|
vw_h2: { de: "Buchhaltungs- & Steuerverwaltungsseiten", "de-CH": "Buechhaltigs- & Stüürverwaltigssite", en: "Bookkeeping & tax administration portals", fr: "Portails de gestion comptable & fiscale", pt: "Portais de gestão contabilística & fiscal" },
|
||||||
|
vw_fuer: {
|
||||||
|
de: "Selbstständige und kleine Betriebe, die Belege, Einnahmen und Ausgaben nicht mehr in Ordnern und Tabellen suchen wollen.",
|
||||||
|
"de-CH": "Sälbstständigi und chlini Betrieb, wo Bäläg, Iinahme und Uusgabe nüm i Ordner und Tabelle sueche wänd.",
|
||||||
|
en: "Self-employed people and small businesses who no longer want to search for receipts, income and expenses in folders and spreadsheets.",
|
||||||
|
fr: "Indépendants et petites structures qui ne veulent plus chercher justificatifs, recettes et dépenses dans des classeurs et des tableurs.",
|
||||||
|
pt: "Trabalhadores independentes e pequenas empresas que já não querem procurar comprovativos, receitas e despesas em pastas e folhas de cálculo.",
|
||||||
|
},
|
||||||
|
vw_1: { de: "Geschützter Login und Benutzerrollen", "de-CH": "Gschützts Login und Benutzerrolle", en: "Protected login and user roles", fr: "Connexion protégée et rôles utilisateurs", pt: "Login protegido e perfis de utilizador" },
|
||||||
|
vw_2: { de: "Belege, Einnahmen, Ausgaben und Dokumente an einem Ort", "de-CH": "Bäläg, Iinahme, Uusgabe und Dokumänt an eim Ort", en: "Receipts, income, expenses and documents in one place", fr: "Justificatifs, recettes, dépenses et documents au même endroit", pt: "Comprovativos, receitas, despesas e documentos num só lugar" },
|
||||||
|
vw_3: { de: "Filter, Suche, Auswertungen und Export", "de-CH": "Filter, Suechi, Uuswärtige und Export", en: "Filters, search, reports and export", fr: "Filtres, recherche, analyses et export", pt: "Filtros, pesquisa, análises e exportação" },
|
||||||
|
vw_4: { de: "Nachvollziehbarer Änderungsverlauf mit Zeitstempel", "de-CH": "Nachvollziehbare Änderigsverlauf mit Ziitstämpel", en: "Traceable change history with timestamps", fr: "Historique des modifications horodaté et traçable", pt: "Histórico de alterações rastreável com data e hora" },
|
||||||
|
vw_5: { de: "Regelmäßige Sicherungen mit geprüfter Wiederherstellung", "de-CH": "Regelmässigi Sicherige mit prüefter Wiederherstellig", en: "Regular backups with a tested restore", fr: "Sauvegardes régulières avec restauration testée", pt: "Cópias de segurança regulares com restauro testado" },
|
||||||
|
vw_beispiel: {
|
||||||
|
de: "Ein Portal, in dem du ein Foto eines Belegs hochlädst, Betrag und Kategorie einträgst und am Jahresende alles gefiltert exportierst — statt einen Schuhkarton zur Steuerberatung zu tragen.",
|
||||||
|
"de-CH": "Es Portal, wo du es Foto vom Bäläg ufelodsch, Betrag und Kategorie iiträitsch und am Jahresänd alles gfilteret exportiersch — statt en Schuehchartone zur Stüürberatig z trage.",
|
||||||
|
en: "A portal where you upload a photo of a receipt, enter amount and category, and export everything filtered at the end of the year — instead of carrying a shoebox to the tax advisor.",
|
||||||
|
fr: "Un portail où tu télécharges la photo d'un justificatif, saisis le montant et la catégorie, et exportes tout filtré en fin d'année — au lieu d'apporter une boîte à chaussures au comptable.",
|
||||||
|
pt: "Um portal onde carregas a foto de um comprovativo, indicas valor e categoria e no fim do ano exportas tudo filtrado — em vez de levares uma caixa de sapatos ao contabilista.",
|
||||||
|
},
|
||||||
|
vw_grenze: {
|
||||||
|
de: "Das ist ausdrücklich eine technische Organisationslösung. Keine Buchhaltung, keine Steuerberatung, keine Erstellung von Steuererklärungen — dafür braucht es weiterhin eine Fachperson.",
|
||||||
|
"de-CH": "Das isch uusdrücklich e technischi Organisationslösig. Kei Buechhaltig, kei Stüürberatig, kei Erstellig vo Stüürerklärige — defür bruuchts wiiterhin e Fachperson.",
|
||||||
|
en: "This is explicitly a technical organisational tool. No bookkeeping, no tax advice, no preparation of tax returns — a qualified professional is still required for that.",
|
||||||
|
fr: "Il s'agit explicitement d'un outil d'organisation technique. Pas de comptabilité, pas de conseil fiscal, pas d'établissement de déclarations — un professionnel qualifié reste nécessaire.",
|
||||||
|
pt: "Trata-se explicitamente de uma solução técnica de organização. Sem contabilidade, sem aconselhamento fiscal, sem elaboração de declarações — para isso continua a ser necessário um profissional.",
|
||||||
|
},
|
||||||
|
|
||||||
|
/* --- Betreuung --- */
|
||||||
|
bt_h2: { de: "Laufende Betreuung", "de-CH": "Laufendi Betreuig", en: "Ongoing support", fr: "Suivi continu", pt: "Acompanhamento contínuo" },
|
||||||
|
bt_fuer: {
|
||||||
|
de: "Alle, die nach der Übergabe nicht selbst an der Technik schrauben wollen — oder einfach wissen möchten, dass jemand hinschaut.",
|
||||||
|
"de-CH": "Alli, wo nach dr Übergab nöd sälber a dr Technik schruube wänd — oder eifach wüsse wänd, dass öpper hiluegt.",
|
||||||
|
en: "Anyone who does not want to tinker with the technology after handover — or simply wants to know that someone is keeping an eye on it.",
|
||||||
|
fr: "Tous ceux qui ne veulent pas bricoler la technique après la remise — ou qui veulent simplement savoir que quelqu'un veille.",
|
||||||
|
pt: "Quem não quer mexer na tecnologia depois da entrega — ou simplesmente quer saber que alguém está atento.",
|
||||||
|
},
|
||||||
|
bt_1: { de: "Kleine Änderungen typischerweise in ca. 2–5 Arbeitstagen", "de-CH": "Chlini Änderige typischerwiis i öppe 2–5 Arbeitstäg", en: "Small changes typically within approx. 2–5 working days", fr: "Petites modifications généralement sous 2 à 5 jours ouvrés", pt: "Pequenas alterações tipicamente em aprox. 2–5 dias úteis" },
|
||||||
|
bt_2: { de: "Sicherheitsaktualisierungen und Überwachung der Erreichbarkeit", "de-CH": "Sicherheitsaktualisierige und Überwachig vo dr Erreichbarkeit", en: "Security updates and uptime monitoring", fr: "Mises à jour de sécurité et surveillance de la disponibilité", pt: "Atualizações de segurança e monitorização da disponibilidade" },
|
||||||
|
bt_3: { de: "Regelmäßige Sicherungen, die auch wirklich zurückgespielt werden können", "de-CH": "Regelmässigi Sicherige, wo au würklich zrugg gspielt wärde chönd", en: "Regular backups that can genuinely be restored", fr: "Sauvegardes régulières réellement restaurables", pt: "Cópias de segurança regulares que podem mesmo ser restauradas" },
|
||||||
|
bt_4: { de: "Umfang und Priorität nach gewähltem Paket", "de-CH": "Umfang und Priorität nach gwählitem Paket", en: "Scope and priority according to the chosen package", fr: "Étendue et priorité selon la formule choisie", pt: "Âmbito e prioridade conforme o pacote escolhido" },
|
||||||
|
bt_grenze: {
|
||||||
|
de: "Betreuung ist keine unbegrenzte Weiterentwicklung. Neue Funktionen oder ganze Unterseiten sind eigene Aufträge — der Aufwand wird vorher genannt, du entscheidest.",
|
||||||
|
"de-CH": "Betreuig isch kei unbegränzti Wiiterentwicklig. Neui Funktione oder ganzi Undersite sind eigeni Ufträg — dr Ufwand wird vorher gnennt, du entscheidsch.",
|
||||||
|
en: "Support is not unlimited further development. New features or entire sub-pages are separate jobs — the effort is quoted beforehand and you decide.",
|
||||||
|
fr: "Le suivi n'est pas un développement illimité. De nouvelles fonctions ou des pages entières sont des missions distinctes — l'effort est chiffré à l'avance et tu décides.",
|
||||||
|
pt: "O acompanhamento não é desenvolvimento ilimitado. Novas funcionalidades ou páginas inteiras são trabalhos à parte — o esforço é indicado previamente e tu decides.",
|
||||||
|
},
|
||||||
|
|
||||||
|
abgrenzung_titel: {
|
||||||
|
de: "Technische Abgrenzung",
|
||||||
|
"de-CH": "Technischi Abgränzig",
|
||||||
|
en: "Technical boundary",
|
||||||
|
fr: "Périmètre technique",
|
||||||
|
pt: "Delimitação técnica",
|
||||||
|
},
|
||||||
|
abgrenzung_text: {
|
||||||
|
de: "Buchhaltungs- und Steuerverwaltungsseiten sind ausschließlich technische Organisationslösungen. Diese Seite verspricht keine Buchhaltung, keine Steuerberatung und keine Erstellung von Steuererklärungen.",
|
||||||
|
"de-CH": "Buechhaltigs- und Stüürverwaltigssite sind uusschliesslich technischi Organisationslösige. Die Site verspricht kei Buechhaltig, kei Stüürberatig und kei Erstellig vo Stüürerklärige.",
|
||||||
|
en: "Bookkeeping and tax administration portals are exclusively technical organisational solutions. This site promises no bookkeeping, no tax advice and no preparation of tax returns.",
|
||||||
|
fr: "Les portails de gestion comptable et fiscale sont exclusivement des outils d'organisation technique. Ce site ne promet ni comptabilité, ni conseil fiscal, ni établissement de déclarations.",
|
||||||
|
pt: "Os portais de gestão contabilística e fiscal são exclusivamente soluções técnicas de organização. Este site não promete contabilidade, aconselhamento fiscal nem elaboração de declarações.",
|
||||||
|
},
|
||||||
|
|
||||||
|
cta_h2: {
|
||||||
|
de: "Nicht sicher, welches Paket passt?",
|
||||||
|
"de-CH": "Nöd sicher, weles Paket passt?",
|
||||||
|
en: "Not sure which package fits?",
|
||||||
|
fr: "Pas sûr de la formule adaptée ?",
|
||||||
|
pt: "Não sabes que pacote encaixa?",
|
||||||
|
},
|
||||||
|
cta_p: {
|
||||||
|
de: "Beschreib einfach dein Vorhaben. Wenn ein kleineres Paket reicht, sage ich das — auch wenn das weniger Umsatz bedeutet.",
|
||||||
|
"de-CH": "Beschriib eifach dis Vorhabe. Wenn es chliners Paket längt, säg i das — au wenn das weniger Umsatz heisst.",
|
||||||
|
en: "Just describe your project. If a smaller package is enough, I will say so — even if that means less revenue.",
|
||||||
|
fr: "Décris simplement ton projet. Si une formule plus petite suffit, je le dirai — même si cela signifie moins de chiffre d'affaires.",
|
||||||
|
pt: "Descreve simplesmente o teu projeto. Se um pacote menor chegar, eu digo-o — mesmo que isso signifique menos faturação.",
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
/* =====================================================================
|
||||||
|
i18n-wd-portfolio.js — Portfolio (Masterplan S.11)
|
||||||
|
|
||||||
|
Inhaltsregeln, die hier bewusst eingehalten sind:
|
||||||
|
- Keine erfundenen Kundenbewertungen. Auf dieser Seite steht KEIN
|
||||||
|
einziges Zitat, weil es noch keines gibt, das jemand freigegeben hat.
|
||||||
|
- Keine fremden Projekte ohne Erlaubnis. Beide gezeigten Seiten sind
|
||||||
|
eigene Projekte; die Vorschaubilder sind echte Screenshots der
|
||||||
|
laufenden Seiten vom 22.08.2026, keine Mockups.
|
||||||
|
- Jede Referenz im gleichen Format: Ausgangslage, Lösung, Ergebnis.
|
||||||
|
===================================================================== */
|
||||||
|
window.I18N_WD = {
|
||||||
|
seiten_titel: {
|
||||||
|
de: "Portfolio — Dogfather Webdesign",
|
||||||
|
"de-CH": "Portfolio — Dogfather Webdesign",
|
||||||
|
en: "Portfolio — Dogfather Web Design",
|
||||||
|
fr: "Portfolio — Dogfather Web Design",
|
||||||
|
pt: "Portfólio — Dogfather Web Design",
|
||||||
|
},
|
||||||
|
|
||||||
|
pf_eyebrow: { de: "Portfolio", "de-CH": "Portfolio", en: "Portfolio", fr: "Portfolio", pt: "Portfólio" },
|
||||||
|
pf_h1: {
|
||||||
|
de: "Zwei Projekte, die man anfassen kann",
|
||||||
|
"de-CH": "Zwei Projekt, wo mer aalange cha",
|
||||||
|
en: "Two projects you can actually visit",
|
||||||
|
fr: "Deux projets que l'on peut réellement visiter",
|
||||||
|
pt: "Dois projetos que se podem mesmo visitar",
|
||||||
|
},
|
||||||
|
pf_lead: {
|
||||||
|
de: "Beide Seiten laufen wirklich, unter ihrer eigenen Adresse, auf einem eigenen Server. Die Vorschaubilder sind echte Aufnahmen der laufenden Seiten — keine gezeichneten Entwürfe.",
|
||||||
|
"de-CH": "Beidi Site laufed würklich, under ihrer eigene Adress, uf eme eigene Server. D Vorschaubilder sind ächti Ufnahme vo de laufende Site — kei zeichneti Entwürf.",
|
||||||
|
en: "Both sites are genuinely running, at their own address, on their own server. The preview images are real captures of the live sites — not drawn mockups.",
|
||||||
|
fr: "Les deux sites fonctionnent réellement, à leur propre adresse, sur un serveur dédié. Les aperçus sont de vraies captures des sites en ligne — pas des maquettes dessinées.",
|
||||||
|
pt: "Ambos os sites estão mesmo no ar, no seu próprio endereço, em servidor próprio. As pré-visualizações são capturas reais dos sites ativos — não são maquetes desenhadas.",
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Gemeinsame Beschriftungen — gleiches Format für jede Referenz */
|
||||||
|
pf_ausgang: { de: "Ausgangslage", "de-CH": "Uusgangslag", en: "Starting point", fr: "Situation de départ", pt: "Ponto de partida" },
|
||||||
|
pf_loesung: { de: "Umgesetzte Lösung", "de-CH": "Umgsetzti Lösig", en: "Solution built", fr: "Solution réalisée", pt: "Solução implementada" },
|
||||||
|
pf_ergebnis: { de: "Ergebnis", "de-CH": "Ergebnis", en: "Result", fr: "Résultat", pt: "Resultado" },
|
||||||
|
pf_funktionen: { de: "Wichtigste Funktionen", "de-CH": "Wichtigschti Funktione", en: "Key features", fr: "Fonctions clés", pt: "Funcionalidades principais" },
|
||||||
|
pf_stil: { de: "Stilrichtung", "de-CH": "Stilrichtig", en: "Visual style", fr: "Style visuel", pt: "Estilo visual" },
|
||||||
|
pf_details: { de: "Details ansehen", "de-CH": "Details aaluege", en: "See details", fr: "Voir les détails", pt: "Ver detalhes" },
|
||||||
|
pf_ansehen: { de: "Live ansehen", "de-CH": "Live aaluege", en: "View live", fr: "Voir en ligne", pt: "Ver ao vivo" },
|
||||||
|
|
||||||
|
/* --- Projekt 1: Dogfather Universe --- */
|
||||||
|
p1_h2: { de: "Dogfather Universe", "de-CH": "Dogfather Universe", en: "Dogfather Universe", fr: "Dogfather Universe", pt: "Dogfather Universe" },
|
||||||
|
p1_alt: {
|
||||||
|
de: "Startseite von dogfather-universe.com",
|
||||||
|
"de-CH": "Startsite vo dogfather-universe.com",
|
||||||
|
en: "Home page of dogfather-universe.com",
|
||||||
|
fr: "Page d'accueil de dogfather-universe.com",
|
||||||
|
pt: "Página inicial de dogfather-universe.com",
|
||||||
|
},
|
||||||
|
p1_art: { de: "Umfangreiche Website", "de-CH": "Grossi Website", en: "Large website", fr: "Grand site", pt: "Site extenso" },
|
||||||
|
p1_ausgang: {
|
||||||
|
de: "Drei getrennte Welten — Streaming, eine Community rund um HasiDog und der Manager-Bereich — sollten unter ein Dach, ohne dass Besucher sich verlaufen. Vorher lagen die Inhalte über mehrere Plattformen verstreut und niemand hatte den Überblick.",
|
||||||
|
"de-CH": "Drü trennti Wälte — Streaming, e Community rund um HasiDog und dr Manager-Bereich — hend under es Dach sölle, ohni dass Bsuecher sich verlaufed. Vorher sind d Inhalt über mehreri Plattforme verstreut gläge und niemert het dr Überblick gha.",
|
||||||
|
en: "Three separate worlds — streaming, a community around HasiDog, and the manager area — had to live under one roof without visitors getting lost. Previously the content was scattered across several platforms and nobody had an overview.",
|
||||||
|
fr: "Trois univers distincts — le streaming, une communauté autour de HasiDog et l'espace manager — devaient cohabiter sans que les visiteurs se perdent. Auparavant, les contenus étaient éparpillés sur plusieurs plateformes et personne n'avait de vue d'ensemble.",
|
||||||
|
pt: "Três mundos separados — streaming, uma comunidade em torno do HasiDog e a área de manager — tinham de caber sob o mesmo teto sem que os visitantes se perdessem. Antes, os conteúdos estavam espalhados por várias plataformas e ninguém tinha visão de conjunto.",
|
||||||
|
},
|
||||||
|
p1_loesung: {
|
||||||
|
de: "Eine Seite mit drei farblich klar getrennten Welten, gemeinsamer Navigation und durchgängig fünf Sprachen. Dazu ein Verwaltungsbereich, in dem Teammitglieder, Termine, Texte und Bewerbungen ohne Programmierkenntnisse gepflegt werden — und ein Supporter-Abo mit angebundener Zahlung.",
|
||||||
|
"de-CH": "Ei Site mit drü farblich klar trennte Wälte, gmeinsamer Navigation und durchgängig füf Sprooche. Dezue en Verwaltigsbereich, wo Teammitglieder, Termin, Täxt und Bewerbige ohni Programmierkenntnis pflegt wärded — und es Supporter-Abo mit aabundener Zahlig.",
|
||||||
|
en: "One site with three clearly colour-separated worlds, shared navigation and five languages throughout. Plus an admin area where team members, dates, texts and applications are maintained without programming knowledge — and a supporter subscription with integrated payment.",
|
||||||
|
fr: "Un site avec trois univers nettement distingués par la couleur, une navigation commune et cinq langues partout. S'y ajoutent un espace d'administration où membres de l'équipe, dates, textes et candidatures se gèrent sans compétences en programmation — et un abonnement supporter avec paiement intégré.",
|
||||||
|
pt: "Um site com três mundos claramente separados por cor, navegação partilhada e cinco idiomas em toda a parte. Além disso, uma área de administração onde membros da equipa, datas, textos e candidaturas são geridos sem conhecimentos de programação — e uma subscrição de apoiante com pagamento integrado.",
|
||||||
|
},
|
||||||
|
p1_ergebnis: {
|
||||||
|
de: "Seit August 2026 öffentlich und im täglichen Einsatz. Inhalte werden vom Team selbst gepflegt, ohne dass jemand Code anfassen muss. Die Seite läuft auf einem eigenen Server statt bei einem Anbieter, der jederzeit die Bedingungen ändern kann.",
|
||||||
|
"de-CH": "Sit Auguscht 2026 öffentlich und im tägliche Iisatz. Inhalt wärded vom Team sälber pflegt, ohni dass öpper Code aalange muess. D Site lauft uf eme eigene Server statt bi eme Aabieter, wo jederziit d Bedingige ändere cha.",
|
||||||
|
en: "Public since August 2026 and in daily use. Content is maintained by the team itself without anyone having to touch code. The site runs on its own server rather than with a provider that can change the terms at any time.",
|
||||||
|
fr: "En ligne depuis août 2026 et utilisé quotidiennement. Les contenus sont gérés par l'équipe elle-même sans que personne ait à toucher au code. Le site tourne sur un serveur dédié plutôt que chez un prestataire pouvant modifier ses conditions à tout moment.",
|
||||||
|
pt: "Público desde agosto de 2026 e em uso diário. Os conteúdos são geridos pela própria equipa sem ninguém ter de tocar em código. O site corre em servidor próprio em vez de num fornecedor que pode alterar as condições a qualquer momento.",
|
||||||
|
},
|
||||||
|
p1_f1: { de: "Fünf Sprachen, inklusive echtem Schweizerdeutsch", "de-CH": "Füf Sprooche, inklusiv ächtem Schwiizerdütsch", en: "Five languages, including genuine Swiss German", fr: "Cinq langues, dont un véritable suisse allemand", pt: "Cinco idiomas, incluindo suíço-alemão autêntico" },
|
||||||
|
p1_f2: { de: "Verwaltungsbereich mit Rollen und Rechten", "de-CH": "Verwaltigsbereich mit Rolle und Rächt", en: "Admin area with roles and permissions", fr: "Espace d'administration avec rôles et droits", pt: "Área de administração com perfis e permissões" },
|
||||||
|
p1_f3: { de: "Bewerbungssystem mit Statusverfolgung", "de-CH": "Bewerbigssyschtem mit Statusverfolgig", en: "Application system with status tracking", fr: "Système de candidature avec suivi de statut", pt: "Sistema de candidaturas com acompanhamento de estado" },
|
||||||
|
p1_f4: { de: "Supporter-Abo mit Zahlungsanbindung", "de-CH": "Supporter-Abo mit Zahligsaabindig", en: "Supporter subscription with payment integration", fr: "Abonnement supporter avec paiement intégré", pt: "Subscrição de apoiante com pagamento integrado" },
|
||||||
|
p1_f5: { de: "Automatische Live-Erkennung des Streams", "de-CH": "Automatischi Live-Erkennig vom Stream", en: "Automatic live detection of the stream", fr: "Détection automatique du direct", pt: "Deteção automática de transmissão em direto" },
|
||||||
|
p1_f6: { de: "Als App installierbar", "de-CH": "Als App installierbar", en: "Installable as an app", fr: "Installable comme application", pt: "Instalável como aplicação" },
|
||||||
|
p1_stil: {
|
||||||
|
de: "Dunkel und cinematisch, mit einer eigenen Leitfarbe je Welt.",
|
||||||
|
"de-CH": "Dunkel und cinematisch, mit ere eigene Leitfarb pro Wält.",
|
||||||
|
en: "Dark and cinematic, with its own signature colour per world.",
|
||||||
|
fr: "Sombre et cinématographique, avec une couleur dominante par univers.",
|
||||||
|
pt: "Escuro e cinematográfico, com uma cor própria para cada mundo.",
|
||||||
|
},
|
||||||
|
|
||||||
|
/* --- Projekt 2: Van's DIY --- */
|
||||||
|
p2_h2: { de: "Van's DIY & Bastelbedarf", "de-CH": "Van's DIY & Bastelbedarf", en: "Van's DIY & Bastelbedarf", fr: "Van's DIY & Bastelbedarf", pt: "Van's DIY & Bastelbedarf" },
|
||||||
|
p2_alt: {
|
||||||
|
de: "Startseite von vans-diy-bastelbedarf.com",
|
||||||
|
"de-CH": "Startsite vo vans-diy-bastelbedarf.com",
|
||||||
|
en: "Home page of vans-diy-bastelbedarf.com",
|
||||||
|
fr: "Page d'accueil de vans-diy-bastelbedarf.com",
|
||||||
|
pt: "Página inicial de vans-diy-bastelbedarf.com",
|
||||||
|
},
|
||||||
|
p2_art: { de: "Onlineshop", "de-CH": "Onlineshop", en: "Online shop", fr: "Boutique en ligne", pt: "Loja online" },
|
||||||
|
p2_ausgang: {
|
||||||
|
de: "Handgemachte Häkelwerke, Plushies und Bastelzubehör wurden bis dahin über Nachrichten verkauft: Preis nachfragen, Verfügbarkeit klären, Versand einzeln absprechen. Das kostete pro Bestellung mehr Zeit als das Herstellen mancher Artikel.",
|
||||||
|
"de-CH": "Handgmachti Häkelwärch, Plushies und Bastelzubehör sind bis dete über Nachrichte verchauft worde: Priis nachefroge, Verfüegbarkeit kläre, Versand einzeln abspräche. Das het pro Bstellig meh Ziit choschtet als s Herstelle vo mängem Artikel.",
|
||||||
|
en: "Handmade crochet work, plushies and craft supplies were sold via messages until then: ask for the price, clarify availability, arrange shipping individually. That cost more time per order than making some of the items.",
|
||||||
|
fr: "Les créations au crochet, peluches et fournitures créatives se vendaient jusque-là par messages : demander le prix, vérifier la disponibilité, convenir de l'expédition au cas par cas. Cela coûtait plus de temps par commande que la fabrication de certains articles.",
|
||||||
|
pt: "Peças de crochet feitas à mão, plushies e material de artesanato eram vendidos por mensagens: perguntar o preço, confirmar a disponibilidade, combinar o envio caso a caso. Isso custava mais tempo por encomenda do que produzir alguns dos artigos.",
|
||||||
|
},
|
||||||
|
p2_loesung: {
|
||||||
|
de: "Ein vollständiger Shop mit Kategorien, Artikeln samt Varianten, Warenkorb, Gutscheincodes und Versandregeln — und ein Adminbereich, der bewusst so gebaut wurde, dass die Inhaberin ihn ohne Technikkenntnisse bedient. Die Kategorien sind dort standardmäßig zugeklappt, damit die Artikelliste nicht erschlägt.",
|
||||||
|
"de-CH": "En vollständige Shop mit Kategorie, Artikel samt Variante, Warechorb, Gutscheincodes und Versandregle — und en Adminbereich, wo bewusst so baut worde isch, dass d Inhaberin ihn ohni Technikkenntnis bediene cha. D Kategorie sind dete standardmässig zueklappt, damit d Artikellischte nöd erschloht.",
|
||||||
|
en: "A complete shop with categories, items including variants, basket, voucher codes and shipping rules — plus an admin area deliberately built so the owner operates it without technical knowledge. Categories there are collapsed by default so the item list is not overwhelming.",
|
||||||
|
fr: "Une boutique complète avec catégories, articles et variantes, panier, codes promo et règles d'expédition — et un espace d'administration conçu pour que la propriétaire l'utilise sans compétences techniques. Les catégories y sont repliées par défaut afin que la liste d'articles ne submerge pas.",
|
||||||
|
pt: "Uma loja completa com categorias, artigos e variantes, carrinho, códigos de desconto e regras de envio — e uma área de administração propositadamente feita para que a proprietária a use sem conhecimentos técnicos. As categorias estão recolhidas por defeito para a lista de artigos não ser esmagadora.",
|
||||||
|
},
|
||||||
|
p2_ergebnis: {
|
||||||
|
de: "Artikel werden selbstständig angelegt und gepflegt, ohne Rückfrage und ohne Wartezeit. Bestellungen laufen strukturiert ein statt als lose Nachrichten. Die Seite läuft auf eigener Domain und eigenem Server.",
|
||||||
|
"de-CH": "Artikel wärded sälbstständig aagleit und pflegt, ohni Rückfrog und ohni Wartziit. Bstellige laufed strukturiert ii statt als losi Nachrichte. D Site lauft uf eigener Domain und eigenem Server.",
|
||||||
|
en: "Items are created and maintained independently, without asking and without waiting. Orders come in structured instead of as loose messages. The site runs on its own domain and its own server.",
|
||||||
|
fr: "Les articles sont créés et gérés en autonomie, sans demande préalable ni attente. Les commandes arrivent structurées au lieu de messages épars. Le site tourne sur son propre domaine et son propre serveur.",
|
||||||
|
pt: "Os artigos são criados e geridos de forma autónoma, sem perguntar e sem esperar. As encomendas chegam estruturadas em vez de mensagens soltas. O site corre em domínio e servidor próprios.",
|
||||||
|
},
|
||||||
|
p2_f1: { de: "Kategorien, Artikel und Varianten", "de-CH": "Kategorie, Artikel und Variante", en: "Categories, items and variants", fr: "Catégories, articles et variantes", pt: "Categorias, artigos e variantes" },
|
||||||
|
p2_f2: { de: "Warenkorb und Kasse, auf dem Handy geprüft", "de-CH": "Warechorb und Kasse, uf em Handy prüeft", en: "Basket and checkout, tested on mobile", fr: "Panier et paiement, testés sur mobile", pt: "Carrinho e finalização, testados em telemóvel" },
|
||||||
|
p2_f3: { de: "Gutscheincodes und Versandregeln", "de-CH": "Gutscheincodes und Versandregle", en: "Voucher codes and shipping rules", fr: "Codes promo et règles d'expédition", pt: "Códigos de desconto e regras de envio" },
|
||||||
|
p2_f4: { de: "Adminbereich für die eigenständige Artikelpflege", "de-CH": "Adminbereich für d sälbstständigi Artikelpflege", en: "Admin area for maintaining items independently", fr: "Espace d'administration pour gérer les articles en autonomie", pt: "Área de administração para gerir artigos de forma autónoma" },
|
||||||
|
p2_f5: { de: "Mehrsprachig", "de-CH": "Mehrsprachig", en: "Multilingual", fr: "Multilingue", pt: "Multilíngue" },
|
||||||
|
p2_stil: {
|
||||||
|
de: "Dunkel mit warmen Violett- und Rosétönen, passend zum handgemachten Sortiment.",
|
||||||
|
"de-CH": "Dunkel mit warme Violett- und Rosétöne, passend zum handgmachte Sortimänt.",
|
||||||
|
en: "Dark with warm violet and rosé tones, matching the handmade range.",
|
||||||
|
fr: "Sombre avec des tons violets et rosés chauds, en accord avec l'assortiment fait main.",
|
||||||
|
pt: "Escuro com tons quentes de violeta e rosé, a condizer com a gama feita à mão.",
|
||||||
|
},
|
||||||
|
|
||||||
|
pf_regeln_titel: {
|
||||||
|
de: "Was hier bewusst fehlt",
|
||||||
|
"de-CH": "Was do bewusst fählt",
|
||||||
|
en: "What is deliberately missing here",
|
||||||
|
fr: "Ce qui manque ici volontairement",
|
||||||
|
pt: "O que falta aqui de propósito",
|
||||||
|
},
|
||||||
|
pf_regeln_text: {
|
||||||
|
de: "Es steht kein einziges Kundenzitat auf dieser Seite. Nicht, weil es keine zufriedenen Kunden gäbe, sondern weil noch niemand ein Zitat geschrieben und freigegeben hat — und erfundene Bewertungen kommen hier grundsätzlich nicht vor. Sobald echte da sind, stehen sie hier. Vorher lieber eine leere Stelle als eine erfundene.",
|
||||||
|
"de-CH": "Es stoht kes einzigs Kundezitat uf dere Site. Nöd, wil s kei zfriedene Chunde gäb, sondern wil no niemert es Zitat gschriebe und freigäh het — und erfundeni Bewärtige chömed do grundsätzlich nöd vor. Sobald ächti do sind, stöhnd si do. Vorher lieber e läeri Stell als e erfundeni.",
|
||||||
|
en: "There is not a single customer quote on this page. Not because there are no satisfied customers, but because nobody has written and approved a quote yet — and invented reviews do not appear here on principle. As soon as real ones exist, they will be here. Until then, an empty spot is preferable to a fabricated one.",
|
||||||
|
fr: "Aucune citation client ne figure sur cette page. Non pas faute de clients satisfaits, mais parce que personne n'a encore rédigé et validé de témoignage — et les avis inventés n'ont pas leur place ici, par principe. Dès qu'il y en aura de véritables, ils seront ici. En attendant, mieux vaut un espace vide qu'un faux.",
|
||||||
|
pt: "Não há uma única citação de cliente nesta página. Não por falta de clientes satisfeitos, mas porque ainda ninguém escreveu e aprovou um depoimento — e avaliações inventadas não aparecem aqui por princípio. Assim que existirem reais, estarão aqui. Até lá, prefiro um espaço vazio a um inventado.",
|
||||||
|
},
|
||||||
|
|
||||||
|
pf_cta_h2: {
|
||||||
|
de: "Soll deins das dritte sein?",
|
||||||
|
"de-CH": "Söll dis s dritte si?",
|
||||||
|
en: "Shall yours be the third?",
|
||||||
|
fr: "Le tien sera-t-il le troisième ?",
|
||||||
|
pt: "O teu vai ser o terceiro?",
|
||||||
|
},
|
||||||
|
pf_cta_p: {
|
||||||
|
de: "Ob dein Projekt hier später auftaucht, entscheidest ausschließlich du — die Aufnahme ins Portfolio ist von der Veröffentlichung deiner Seite getrennt und braucht deine ausdrückliche Erlaubnis.",
|
||||||
|
"de-CH": "Ob dis Projekt do spöter uftaucht, entscheidsch uusschliesslich du — d Ufnahm is Portfolio isch vo dr Veröffentlichig vo dinere Site trennt und bruucht dini uusdrücklichi Erlaubnis.",
|
||||||
|
en: "Whether your project appears here later is entirely your decision — inclusion in the portfolio is separate from publishing your site and requires your explicit permission.",
|
||||||
|
fr: "Que ton projet apparaisse ici plus tard ne dépend que de toi — l'ajout au portfolio est indépendant de la mise en ligne de ton site et requiert ton autorisation explicite.",
|
||||||
|
pt: "Se o teu projeto aparece aqui mais tarde é decisão exclusivamente tua — a inclusão no portfólio é separada da publicação do teu site e exige a tua autorização explícita.",
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/* =====================================================================
|
||||||
|
i18n-wd-preise.js — Preise & Zeit (Masterplan S.8)
|
||||||
|
Die konkreten Beträge und Zeitrahmen stehen NICHT hier, sondern in
|
||||||
|
wd-core.js — siehe dort den Abschnitt "EINZIGE QUELLE DER WAHRHEIT".
|
||||||
|
Hier nur die Texte drumherum.
|
||||||
|
===================================================================== */
|
||||||
|
window.I18N_WD = {
|
||||||
|
seiten_titel: {
|
||||||
|
de: "Preise & Zeit — Dogfather Webdesign",
|
||||||
|
"de-CH": "Priis & Ziit — Dogfather Webdesign",
|
||||||
|
en: "Pricing & time — Dogfather Web Design",
|
||||||
|
fr: "Tarifs & délais — Dogfather Web Design",
|
||||||
|
pt: "Preços & prazos — Dogfather Web Design",
|
||||||
|
},
|
||||||
|
|
||||||
|
pr_eyebrow: { de: "Preise & Zeit", "de-CH": "Priis & Ziit", en: "Pricing & time", fr: "Tarifs & délais", pt: "Preços & prazos" },
|
||||||
|
pr_h1: {
|
||||||
|
de: "Was es kostet und wie lange es dauert",
|
||||||
|
"de-CH": "Was es choschtet und wie lang s duuret",
|
||||||
|
en: "What it costs and how long it takes",
|
||||||
|
fr: "Combien ça coûte et combien de temps ça prend",
|
||||||
|
pt: "Quanto custa e quanto tempo demora",
|
||||||
|
},
|
||||||
|
pr_lead: {
|
||||||
|
de: "Alle Werte sind transparente Einstiegspunkte. Personalisierung, Funktionsumfang, Materialien und zusätzliche Änderungswünsche können Preis und Dauer verändern.",
|
||||||
|
"de-CH": "Alli Wärt sind transparänti Iistiegspünkt. Personalisierig, Funktionsumfang, Material und zuesätzlichi Änderigswünsch chönd Priis und Duur verändere.",
|
||||||
|
en: "All figures are transparent starting points. Personalisation, feature scope, materials and additional change requests can alter price and duration.",
|
||||||
|
fr: "Tous les montants sont des points de départ transparents. La personnalisation, l'étendue des fonctions, les matériaux et les demandes de modification supplémentaires peuvent modifier le prix et la durée.",
|
||||||
|
pt: "Todos os valores são pontos de partida transparentes. Personalização, âmbito de funcionalidades, materiais e pedidos de alteração adicionais podem alterar preço e duração.",
|
||||||
|
},
|
||||||
|
|
||||||
|
pr_tabelle_titel: {
|
||||||
|
de: "Alles auf einen Blick",
|
||||||
|
"de-CH": "Alles uf ei Blick",
|
||||||
|
en: "Everything at a glance",
|
||||||
|
fr: "Tout en un coup d'œil",
|
||||||
|
pt: "Tudo num relance",
|
||||||
|
},
|
||||||
|
pr_sp_paket: { de: "Paket", "de-CH": "Paket", en: "Package", fr: "Formule", pt: "Pacote" },
|
||||||
|
pr_sp_preis: { de: "Einstiegspreis", "de-CH": "Iistiegspriis", en: "Starting price", fr: "Prix de départ", pt: "Preço inicial" },
|
||||||
|
pr_sp_dauer: { de: "Zeitrahmen", "de-CH": "Ziitrahme", en: "Timeframe", fr: "Délai", pt: "Prazo" },
|
||||||
|
pr_sp_fuer: { de: "Gedacht für", "de-CH": "Dänkt für", en: "Intended for", fr: "Destiné à", pt: "Destinado a" },
|
||||||
|
|
||||||
|
pr_kurz_onepager: { de: "Eine Person, ein Angebot, eine Aktion", "de-CH": "Ei Person, es Aagebot, ei Aktion", en: "One person, one offer, one campaign", fr: "Une personne, une offre, une action", pt: "Uma pessoa, uma oferta, uma ação" },
|
||||||
|
pr_kurz_website: { de: "Firmen, Teams, mehrere Themen", "de-CH": "Firme, Teams, mehreri Thema", en: "Companies, teams, several topics", fr: "Entreprises, équipes, plusieurs thèmes", pt: "Empresas, equipas, vários temas" },
|
||||||
|
pr_kurz_shop: { de: "Wer wirklich online verkauft", "de-CH": "Wär würklich online verchauft", en: "Anyone genuinely selling online", fr: "Ceux qui vendent vraiment en ligne", pt: "Quem vende mesmo online" },
|
||||||
|
pr_kurz_verwaltung: { de: "Belege und Zahlen organisieren", "de-CH": "Bäläg und Zahle organisiere", en: "Organising receipts and figures", fr: "Organiser justificatifs et chiffres", pt: "Organizar comprovativos e números" },
|
||||||
|
pr_kurz_betreuung: { de: "Nach der Übergabe weiterlaufen lassen", "de-CH": "Nach dr Übergab wiiterlaufe lo", en: "Keeping things running after handover", fr: "Faire vivre le site après la remise", pt: "Manter tudo a funcionar após a entrega" },
|
||||||
|
|
||||||
|
pr_was_h2: {
|
||||||
|
de: "Was den Preis bewegt",
|
||||||
|
"de-CH": "Was dä Priis bewegt",
|
||||||
|
en: "What moves the price",
|
||||||
|
fr: "Ce qui fait bouger le prix",
|
||||||
|
pt: "O que faz mexer o preço",
|
||||||
|
},
|
||||||
|
pr_hoch_h3: { de: "Macht es teurer und länger", "de-CH": "Macht s tüürer und länger", en: "Makes it more expensive and longer", fr: "Rend le projet plus cher et plus long", pt: "Torna mais caro e mais demorado" },
|
||||||
|
pr_hoch_1: { de: "Viele Unterseiten oder viele Produkte", "de-CH": "Viili Undersite oder viili Produkt", en: "Many sub-pages or many products", fr: "Beaucoup de sous-pages ou de produits", pt: "Muitas subpáginas ou muitos produtos" },
|
||||||
|
pr_hoch_2: { de: "Zusätzliche Sprachen", "de-CH": "Zuesätzlichi Sprooche", en: "Additional languages", fr: "Langues supplémentaires", pt: "Idiomas adicionais" },
|
||||||
|
pr_hoch_3: { de: "Texte und Bilder müssen erst erstellt werden", "de-CH": "Täxt und Bilder mönd zerscht gmacht wärde", en: "Texts and images still have to be created", fr: "Les textes et images doivent encore être créés", pt: "Textos e imagens ainda têm de ser criados" },
|
||||||
|
pr_hoch_4: { de: "Anbindung an fremde Systeme (Kasse, Kalender, Warenwirtschaft)", "de-CH": "Aabindig a frömdi Syschtem (Kasse, Kaländer, Warewirtschaft)", en: "Connections to third-party systems (checkout, calendar, inventory)", fr: "Connexions à des systèmes tiers (caisse, agenda, gestion des stocks)", pt: "Ligações a sistemas externos (caixa, calendário, gestão de stock)" },
|
||||||
|
pr_hoch_5: { de: "Mehr Feedbackrunden als vereinbart", "de-CH": "Meh Feedbackrunde als vereinbart", en: "More feedback rounds than agreed", fr: "Plus de tours de retours que convenu", pt: "Mais rondas de feedback do que o acordado" },
|
||||||
|
|
||||||
|
pr_runter_h3: { de: "Macht es günstiger und schneller", "de-CH": "Macht s günschtiger und schnäller", en: "Makes it cheaper and faster", fr: "Rend le projet moins cher et plus rapide", pt: "Torna mais barato e mais rápido" },
|
||||||
|
pr_runter_1: { de: "Texte, Logo und Bilder liegen fertig vor", "de-CH": "Täxt, Logo und Bilder sind fertig do", en: "Texts, logo and images are ready", fr: "Textes, logo et images sont prêts", pt: "Textos, logótipo e imagens já prontos" },
|
||||||
|
pr_runter_2: { de: "Klare Vorstellung vom Ziel der Seite", "de-CH": "Klari Vorstellig vom Ziel vo dr Site", en: "A clear idea of what the site should achieve", fr: "Une idée claire de l'objectif du site", pt: "Uma ideia clara do objetivo do site" },
|
||||||
|
pr_runter_3: { de: "Eine Person entscheidet, nicht ein Gremium", "de-CH": "Ei Person entscheidet, nöd es Gremium", en: "One person decides, not a committee", fr: "Une personne décide, pas un comité", pt: "Uma pessoa decide, não um comité" },
|
||||||
|
pr_runter_4: { de: "Rückmeldungen gebündelt statt tröpfchenweise", "de-CH": "Rückmäldige bündlet statt tröpfliwiis", en: "Feedback bundled instead of trickling in", fr: "Des retours groupés plutôt qu'au compte-gouttes", pt: "Feedback agrupado em vez de aos poucos" },
|
||||||
|
pr_runter_5: { de: "Bestehende Domain und bestehendes Hosting", "de-CH": "Bestehendi Domain und bestehends Hosting", en: "An existing domain and existing hosting", fr: "Un domaine et un hébergement existants", pt: "Domínio e alojamento já existentes" },
|
||||||
|
|
||||||
|
pr_zahlung_h2: {
|
||||||
|
de: "Wie bezahlt wird",
|
||||||
|
"de-CH": "Wie zahlt wird",
|
||||||
|
en: "How payment works",
|
||||||
|
fr: "Comment se passe le paiement",
|
||||||
|
pt: "Como se paga",
|
||||||
|
},
|
||||||
|
pr_z1_h3: { de: "1. Angebot", "de-CH": "1. Aagebot", en: "1. Quote", fr: "1. Devis", pt: "1. Orçamento" },
|
||||||
|
pr_z1_p: {
|
||||||
|
de: "Nach dem Briefing bekommst du Preis, Umfang und Zeitrahmen schriftlich. Bis dahin entstehen dir keine Kosten.",
|
||||||
|
"de-CH": "Nach em Briefing überchunnsch Priis, Umfang und Ziitrahme schriftlich. Bis dete entstöhnd dir kei Choschte.",
|
||||||
|
en: "After the briefing you receive price, scope and timeframe in writing. Up to that point you incur no costs.",
|
||||||
|
fr: "Après le briefing, tu reçois le prix, l'étendue et le délai par écrit. Jusque-là, aucun frais pour toi.",
|
||||||
|
pt: "Após o briefing recebes preço, âmbito e prazo por escrito. Até aí não tens custos.",
|
||||||
|
},
|
||||||
|
pr_z2_h3: { de: "2. Anzahlung 30 %", "de-CH": "2. Aazahlig 30 %", en: "2. Deposit 30 %", fr: "2. Acompte 30 %", pt: "2. Sinal 30 %" },
|
||||||
|
pr_z2_p: {
|
||||||
|
de: "Mit der Annahme des Angebots und dem Eingang der Anzahlung wird dein Zeitfenster fest reserviert. Erst dann beginnt die Arbeit.",
|
||||||
|
"de-CH": "Mit dr Aanahm vom Aagebot und em Iigang vo dr Aazahlig wird dis Ziitfenschter fescht reserviert. Erscht denn foht d Arbet a.",
|
||||||
|
en: "Accepting the quote and the arrival of the deposit firmly reserve your time slot. Only then does work begin.",
|
||||||
|
fr: "L'acceptation du devis et la réception de l'acompte réservent fermement ton créneau. Le travail commence seulement ensuite.",
|
||||||
|
pt: "A aceitação do orçamento e a entrada do sinal reservam firmemente a tua janela. Só então começa o trabalho.",
|
||||||
|
},
|
||||||
|
pr_z3_h3: { de: "3. Restbetrag", "de-CH": "3. Rescht", en: "3. Remaining amount", fr: "3. Solde", pt: "3. Valor restante" },
|
||||||
|
pr_z3_p: {
|
||||||
|
de: "Der Rest wird nach deiner Abnahme fällig, vor der Veröffentlichung. Die Nutzungsrechte gehen mit der vollständigen Bezahlung an dich über.",
|
||||||
|
"de-CH": "Dr Rescht wird nach dinere Abnahm fällig, vor dr Veröffentlichig. D Nutzigsrächt göhnd mit dr vollständige Zahlig a dich über.",
|
||||||
|
en: "The remainder is due after your acceptance, before publication. Usage rights transfer to you upon full payment.",
|
||||||
|
fr: "Le solde est dû après ta validation, avant la mise en ligne. Les droits d'utilisation te sont transférés au paiement intégral.",
|
||||||
|
pt: "O restante é devido após a tua aceitação, antes da publicação. Os direitos de utilização passam para ti com o pagamento integral.",
|
||||||
|
},
|
||||||
|
pr_z4_h3: { de: "Betreuung", "de-CH": "Betreuig", en: "Support", fr: "Suivi", pt: "Acompanhamento" },
|
||||||
|
pr_z4_p: {
|
||||||
|
de: "Läuft monatlich und ist jederzeit zum Monatsende kündbar. Keine Mindestlaufzeit, keine automatische Verlängerung um ein ganzes Jahr.",
|
||||||
|
"de-CH": "Lauft monetlich und isch jederziit uf s Monetsänd kündbar. Kei Mindeschtlaufziit, kei automatischi Verlängerig um es ganzes Jahr.",
|
||||||
|
en: "Runs monthly and can be cancelled at any time to the end of the month. No minimum term, no automatic renewal for a whole year.",
|
||||||
|
fr: "Mensuel et résiliable à tout moment pour la fin du mois. Pas de durée minimale, pas de reconduction automatique d'un an.",
|
||||||
|
pt: "É mensal e cancelável a qualquer momento para o fim do mês. Sem prazo mínimo, sem renovação automática por um ano inteiro.",
|
||||||
|
},
|
||||||
|
|
||||||
|
pr_keine_zusage_titel: {
|
||||||
|
de: "Keine automatische Preiszusage",
|
||||||
|
"de-CH": "Kei automatischi Priiszuesag",
|
||||||
|
en: "No automatic price commitment",
|
||||||
|
fr: "Aucun engagement de prix automatique",
|
||||||
|
pt: "Sem compromisso automático de preço",
|
||||||
|
},
|
||||||
|
pr_keine_zusage_text: {
|
||||||
|
de: "Die Angaben auf dieser Seite sind Richtwerte, kein Vertrag. Auch das Anfrageformular erzeugt keine verbindliche Zusage. Der Endpreis entsteht ausschließlich durch ein schriftliches Angebot nach Prüfung des tatsächlichen Umfangs.",
|
||||||
|
"de-CH": "D Aagabe uf dere Site sind Richtwärt, kein Vertrag. Au s Aafrogformular macht kei verbindlichi Zuesag. Dr Ändpriis entstoht uusschliesslich dur es schriftlichs Aagebot nach Prüefig vom tatsächliche Umfang.",
|
||||||
|
en: "The figures on this page are guide values, not a contract. The enquiry form does not create a binding commitment either. The final price arises exclusively from a written quote after the actual scope has been reviewed.",
|
||||||
|
fr: "Les indications de cette page sont des valeurs indicatives, pas un contrat. Le formulaire de demande ne crée pas non plus d'engagement ferme. Le prix final résulte exclusivement d'un devis écrit après examen de l'étendue réelle.",
|
||||||
|
pt: "Os valores nesta página são indicativos, não um contrato. O formulário de pedido também não cria compromisso vinculativo. O preço final resulta exclusivamente de um orçamento escrito após análise do âmbito real.",
|
||||||
|
},
|
||||||
|
|
||||||
|
pr_cta_h2: {
|
||||||
|
de: "Passt das zu deinem Budget?",
|
||||||
|
"de-CH": "Passt das zu dim Budget?",
|
||||||
|
en: "Does that fit your budget?",
|
||||||
|
fr: "Est-ce compatible avec ton budget ?",
|
||||||
|
pt: "Isto encaixa no teu orçamento?",
|
||||||
|
},
|
||||||
|
pr_cta_p: {
|
||||||
|
de: "Sag im Formular einfach, welcher Rahmen realistisch ist. Wenn es nicht passt, sage ich das offen — statt ein Angebot zu schreiben, das nachher niemand bezahlen will.",
|
||||||
|
"de-CH": "Säg im Formular eifach, wele Rahme realistisch isch. Wenn s nöd passt, säg i das offe — statt es Aagebot z schriibe, wo nachher niemert zahle will.",
|
||||||
|
en: "Just state in the form what range is realistic. If it doesn't fit, I'll say so openly — instead of writing a quote nobody wants to pay afterwards.",
|
||||||
|
fr: "Indique simplement dans le formulaire la fourchette réaliste. Si cela ne convient pas, je le dirai franchement — plutôt que d'écrire un devis que personne ne voudra payer.",
|
||||||
|
pt: "Diz simplesmente no formulário que intervalo é realista. Se não encaixar, digo-o abertamente — em vez de escrever um orçamento que depois ninguém quer pagar.",
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
/* =====================================================================
|
||||||
|
i18n-wd-ueber.js — Über Dogfather (Masterplan S.5, Punkt 06)
|
||||||
|
Persönlichkeit, Erfahrung, Arbeitsweise, Anspruch, direkte Betreuung.
|
||||||
|
|
||||||
|
Bewusst KEINE bürgerlichen Daten im Fließtext: öffentlich tritt nur der
|
||||||
|
Künstlername auf (Regel aus DogiHompage/CLAUDE.md). Die vollständigen
|
||||||
|
Angaben stehen dort, wo sie hingehören — im Impressum.
|
||||||
|
===================================================================== */
|
||||||
|
window.I18N_WD = {
|
||||||
|
seiten_titel: {
|
||||||
|
de: "Über Dogfather — Dogfather Webdesign",
|
||||||
|
"de-CH": "Über dä Dogfather — Dogfather Webdesign",
|
||||||
|
en: "About Dogfather — Dogfather Web Design",
|
||||||
|
fr: "À propos de Dogfather — Dogfather Web Design",
|
||||||
|
pt: "Sobre o Dogfather — Dogfather Web Design",
|
||||||
|
},
|
||||||
|
|
||||||
|
ue_eyebrow: { de: "Über Dogfather", "de-CH": "Über dä Dogfather", en: "About Dogfather", fr: "À propos de Dogfather", pt: "Sobre o Dogfather" },
|
||||||
|
ue_h1: {
|
||||||
|
de: "Wer hier eigentlich baut",
|
||||||
|
"de-CH": "Wär do eigentlich baut",
|
||||||
|
en: "Who actually builds this",
|
||||||
|
fr: "Qui construit réellement",
|
||||||
|
pt: "Quem constrói isto de facto",
|
||||||
|
},
|
||||||
|
ue_lead: {
|
||||||
|
de: "Keine Agentur, kein Callcenter, keine Ticketnummer. Eine Person, die dein Projekt von der ersten Nachricht bis zur Übergabe begleitet — und die danach noch erreichbar ist.",
|
||||||
|
"de-CH": "Kei Agentur, kes Callcenter, kei Ticketnummere. Ei Person, wo dis Projekt vo dr erschte Nachricht bis zur Übergab begleitet — und wo dernoch no erreichbar isch.",
|
||||||
|
en: "No agency, no call centre, no ticket number. One person who accompanies your project from the first message to handover — and who is still reachable afterwards.",
|
||||||
|
fr: "Pas d'agence, pas de centre d'appels, pas de numéro de ticket. Une personne qui accompagne ton projet du premier message à la remise — et qui reste joignable ensuite.",
|
||||||
|
pt: "Sem agência, sem call center, sem número de bilhete. Uma pessoa que acompanha o teu projeto desde a primeira mensagem até à entrega — e que continua contactável depois.",
|
||||||
|
},
|
||||||
|
ue_foto_alt: { de: "Dogfather", "de-CH": "Dogfather", en: "Dogfather", fr: "Dogfather", pt: "Dogfather" },
|
||||||
|
|
||||||
|
ue_text1: {
|
||||||
|
de: "Dogfather ist der Name, unter dem ich seit Jahren im Netz unterwegs bin — als Streamer, als Teil einer Community und mittlerweile auch als jemand, der Webseiten baut. Das eine ist aus dem anderen entstanden: Irgendwann brauchte das eigene Projekt eine richtige Seite, dann brauchte jemand aus dem Umfeld eine, dann noch jemand.",
|
||||||
|
"de-CH": "Dogfather isch dr Name, under dem i sit Jahre im Netz underwägs bi — als Streamer, als Teil vo ere Community und mittlerwiil au als öpper, wo Websites baut. S eint isch us em andere entstande: Irgendwenn het s eigene Projekt e richtigi Site bruucht, denn het öpper us em Umfäld eini bruucht, denn no öpper.",
|
||||||
|
en: "Dogfather is the name I have been going by online for years — as a streamer, as part of a community, and by now also as someone who builds websites. One grew out of the other: at some point my own project needed a proper site, then someone around me needed one, then someone else.",
|
||||||
|
fr: "Dogfather est le nom sous lequel je suis présent en ligne depuis des années — comme streamer, comme membre d'une communauté, et désormais aussi comme personne qui construit des sites. L'un est né de l'autre : à un moment, mon propre projet a eu besoin d'un vrai site, puis quelqu'un de mon entourage, puis un autre.",
|
||||||
|
pt: "Dogfather é o nome com que ando na internet há anos — como streamer, como parte de uma comunidade e, entretanto, também como alguém que constrói sites. Uma coisa nasceu da outra: a certa altura o meu próprio projeto precisou de um site a sério, depois alguém à minha volta precisou de um, depois outra pessoa.",
|
||||||
|
},
|
||||||
|
ue_text2: {
|
||||||
|
de: "Dadurch sind die Seiten hier nicht am Reißbrett entstanden, sondern an echten Problemen: Eine Shop-Inhaberin, die ihre Artikel selbst pflegen können muss, ohne Angst etwas kaputtzumachen. Ein Team, das seine eigenen Inhalte ändern will, ohne jedes Mal jemanden zu fragen. Genau daran messe ich, ob eine Seite gut ist — nicht daran, wie sie auf einem Werbebild aussieht.",
|
||||||
|
"de-CH": "Dedurch sind d Site do nöd am Riissbrätt entstande, sondern a ächte Probläm: E Shop-Inhaberin, wo ihri Artikel sälber pflege chöne muess, ohni Angscht öppis kaputt z mache. Es Team, wo sini eigene Inhalt ändere will, ohni jedes Mal öpper z froge. Gnau dra mäss i, ob e Site guet isch — nöd dra, wie si uf eme Werbebild uusgseht.",
|
||||||
|
en: "As a result the sites here were not conceived at a drawing board but on real problems: a shop owner who has to be able to maintain her own items without fear of breaking something. A team that wants to change its own content without asking someone every time. That is exactly how I judge whether a site is good — not by how it looks in a promo image.",
|
||||||
|
fr: "De ce fait, les sites ici ne sont pas nés sur une planche à dessin mais de problèmes réels : une commerçante qui doit pouvoir gérer ses articles elle-même sans craindre de tout casser. Une équipe qui veut modifier ses contenus sans devoir demander à chaque fois. C'est précisément à cela que je juge la qualité d'un site — pas à son allure sur une image promotionnelle.",
|
||||||
|
pt: "Por isso, os sites aqui não nasceram numa prancheta, mas de problemas reais: uma dona de loja que tem de conseguir gerir os seus artigos sem medo de estragar algo. Uma equipa que quer alterar os seus conteúdos sem ter de perguntar sempre a alguém. É exatamente por aí que avalio se um site é bom — não pelo aspeto numa imagem publicitária.",
|
||||||
|
},
|
||||||
|
|
||||||
|
ue_werte_h2: {
|
||||||
|
de: "Wie ich arbeite",
|
||||||
|
"de-CH": "Wie ich schaffe",
|
||||||
|
en: "How I work",
|
||||||
|
fr: "Comment je travaille",
|
||||||
|
pt: "Como trabalho",
|
||||||
|
},
|
||||||
|
ue_w1_h3: { de: "Ehrlich vor gefällig", "de-CH": "Ehrlich vor gfällig", en: "Honest before agreeable", fr: "Honnête plutôt que complaisant", pt: "Honesto antes de agradável" },
|
||||||
|
ue_w1_p: {
|
||||||
|
de: "Wenn ein kleineres Paket reicht, sage ich das — auch wenn ich damit weniger verdiene. Wenn etwas technisch nicht sinnvoll ist, sage ich das vorher, nicht wenn die Anzahlung schon da ist.",
|
||||||
|
"de-CH": "Wenn es chliners Paket längt, säg i das — au wenn i demit weniger verdiene. Wenn öppis technisch nöd sinnvoll isch, säg i das vorher, nöd wenn d Aazahlig scho do isch.",
|
||||||
|
en: "If a smaller package is enough, I say so — even if I earn less. If something does not make technical sense, I say so up front, not once the deposit has arrived.",
|
||||||
|
fr: "Si une formule plus petite suffit, je le dis — même si je gagne moins. Si quelque chose n'a pas de sens techniquement, je le dis avant, pas une fois l'acompte encaissé.",
|
||||||
|
pt: "Se um pacote menor chegar, digo-o — mesmo ganhando menos. Se algo não faz sentido tecnicamente, digo-o antes, não depois de o sinal ter chegado.",
|
||||||
|
},
|
||||||
|
ue_w2_h3: { de: "Handy zuerst", "de-CH": "S Handy zerscht", en: "Mobile first", fr: "Le mobile d'abord", pt: "Primeiro o telemóvel" },
|
||||||
|
ue_w2_p: {
|
||||||
|
de: "Die meisten Besucher kommen mit dem Handy. Deshalb wird dort zuerst geprüft, nicht zuletzt — und zwar auf echten Bildschirmgrößen, nicht nur in einem verkleinerten Fenster am Computer.",
|
||||||
|
"de-CH": "Die meischte Bsuecher chömed mit em Handy. Drum wird dete zerscht prüeft, nöd zletscht — und zwar uf ächte Bildschirmgrössi, nöd nur i eme verchlinerte Fänschter am Computer.",
|
||||||
|
en: "Most visitors come on a phone. So that is where I check first, not last — and at real screen sizes, not just in a shrunken desktop window.",
|
||||||
|
fr: "La plupart des visiteurs viennent sur mobile. C'est donc là que je vérifie en premier, pas en dernier — et à des tailles d'écran réelles, pas seulement dans une fenêtre réduite sur ordinateur.",
|
||||||
|
pt: "A maioria dos visitantes chega pelo telemóvel. Por isso é aí que verifico primeiro, não por último — e em tamanhos de ecrã reais, não apenas numa janela reduzida no computador.",
|
||||||
|
},
|
||||||
|
ue_w3_h3: { de: "Fertig heißt fertig", "de-CH": "Fertig heisst fertig", en: "Done means done", fr: "Terminé veut dire terminé", pt: "Pronto significa pronto" },
|
||||||
|
ue_w3_p: {
|
||||||
|
de: "Keine halb fertigen Unterseiten, keine Platzhalter und keine Links, die ins Leere führen. Eine Seite gilt erst als fertig, wenn jedes Formular, jeder Zugang und jeder Fehlerfall wirklich durchgespielt wurde.",
|
||||||
|
"de-CH": "Kei halb fertigi Undersite, kei Platzhalter und kei Links, wo is Läre füehred. E Site gilt erscht als fertig, wenn jedes Formular, jede Zuegang und jede Fählerfall würklich durespielt worde isch.",
|
||||||
|
en: "No half-finished sub-pages, no placeholders and no links that lead nowhere. A site only counts as finished once every form, every login and every error case has genuinely been played through.",
|
||||||
|
fr: "Pas de sous-pages à moitié faites, pas de contenus provisoires, pas de liens qui ne mènent nulle part. Un site n'est considéré comme terminé qu'une fois chaque formulaire, chaque accès et chaque cas d'erreur réellement testés.",
|
||||||
|
pt: "Sem subpáginas meio feitas, sem marcadores de posição e sem ligações que não levam a lado nenhum. Um site só conta como terminado depois de cada formulário, cada acesso e cada caso de erro terem sido realmente testados.",
|
||||||
|
},
|
||||||
|
ue_w4_h3: { de: "Augenschonend", "de-CH": "Augeschonend", en: "Easy on the eyes", fr: "Reposant pour les yeux", pt: "Suave para os olhos" },
|
||||||
|
ue_w4_p: {
|
||||||
|
de: "Dunkel, kontraststark genug zum Lesen, aber ohne grelle Neonfarben und ohne flackernde Effekte. Wer in den Systemeinstellungen weniger Bewegung wünscht, bekommt sie auch nicht.",
|
||||||
|
"de-CH": "Dunkel, kontrastriich gnueg zum Läse, aber ohni grelli Neonfarbe und ohni flackerndi Effäkt. Wär i de Syschtemiistellige weniger Bewegig wünscht, überchunnt si au nöd.",
|
||||||
|
en: "Dark, with enough contrast to read comfortably, but without glaring neon colours and without flickering effects. Anyone who asks for less motion in their system settings gets less motion.",
|
||||||
|
fr: "Sombre, avec assez de contraste pour lire confortablement, mais sans néons criards ni effets clignotants. Qui demande moins d'animations dans ses réglages système en obtient moins.",
|
||||||
|
pt: "Escuro, com contraste suficiente para ler bem, mas sem cores néon berrantes e sem efeitos a piscar. Quem pede menos movimento nas definições do sistema recebe menos movimento.",
|
||||||
|
},
|
||||||
|
|
||||||
|
ue_erfahrung_h2: {
|
||||||
|
de: "Woran ich das gelernt habe",
|
||||||
|
"de-CH": "Wo dra ich das glernt han",
|
||||||
|
en: "Where I learned this",
|
||||||
|
fr: "Où j'ai appris tout cela",
|
||||||
|
pt: "Onde aprendi isto",
|
||||||
|
},
|
||||||
|
ue_e1_h3: { de: "Dogfather Universe", "de-CH": "Dogfather Universe", en: "Dogfather Universe", fr: "Dogfather Universe", pt: "Dogfather Universe" },
|
||||||
|
ue_e1_p: {
|
||||||
|
de: "Eine Plattform mit fünf Sprachen, Teamseiten, Bewerbungssystem, Supporter-Abo mit Zahlungsanbindung und einem Verwaltungsbereich, in dem Inhalte ohne Programmierkenntnisse gepflegt werden. Öffentlich seit August 2026, auf einem eigenen Server.",
|
||||||
|
"de-CH": "E Plattform mit füf Sprooche, Teamsite, Bewerbigssyschtem, Supporter-Abo mit Zahligsaabindig und eme Verwaltigsbereich, wo Inhalt ohni Programmierkenntnis pflegt wärded. Öffentlich sit Auguscht 2026, uf eme eigene Server.",
|
||||||
|
en: "A platform with five languages, team pages, an application system, a supporter subscription with payment integration and an admin area where content is maintained without programming knowledge. Public since August 2026, on its own server.",
|
||||||
|
fr: "Une plateforme en cinq langues, avec pages d'équipe, système de candidature, abonnement supporter relié au paiement et espace d'administration où les contenus se gèrent sans compétences en programmation. En ligne depuis août 2026, sur un serveur dédié.",
|
||||||
|
pt: "Uma plataforma em cinco idiomas, com páginas de equipa, sistema de candidaturas, subscrição de apoiante com pagamento integrado e uma área de administração onde os conteúdos são geridos sem conhecimentos de programação. Público desde agosto de 2026, em servidor próprio.",
|
||||||
|
},
|
||||||
|
ue_e2_h3: { de: "Van's DIY & Bastelbedarf", "de-CH": "Van's DIY & Bastelbedarf", en: "Van's DIY & Bastelbedarf", fr: "Van's DIY & Bastelbedarf", pt: "Van's DIY & Bastelbedarf" },
|
||||||
|
ue_e2_p: {
|
||||||
|
de: "Ein vollständiger Onlineshop mit Warenkorb, Gutscheincodes und Versandabläufen — und einem Adminbereich, der so gebaut ist, dass die Inhaberin ihn ohne Technikkenntnisse bedient. Genau daran habe ich gelernt, wie viel Aufwand in „einfach zu bedienen“ steckt.",
|
||||||
|
"de-CH": "En vollständige Onlineshop mit Warechorb, Gutscheincodes und Versandabläuf — und eme Adminbereich, wo so baut isch, dass d Inhaberin ihn ohni Technikkenntnis bediene cha. Gnau dra han i glernt, wie viil Ufwand i „eifach z bediene“ steckt.",
|
||||||
|
en: "A complete online shop with basket, voucher codes and shipping flows — and an admin area built so the owner can operate it without technical knowledge. That is exactly where I learned how much work goes into “easy to use”.",
|
||||||
|
fr: "Une boutique en ligne complète avec panier, codes promo et flux d'expédition — et un espace d'administration conçu pour que la propriétaire l'utilise sans compétences techniques. C'est précisément là que j'ai appris tout le travail que représente « simple à utiliser ».",
|
||||||
|
pt: "Uma loja online completa com carrinho, códigos de desconto e fluxos de envio — e uma área de administração feita para que a proprietária a use sem conhecimentos técnicos. Foi exatamente aí que aprendi quanto trabalho está por trás de “fácil de usar”.",
|
||||||
|
},
|
||||||
|
ue_projekte: { de: "Projekte ansehen", "de-CH": "Projekt aaluege", en: "View projects", fr: "Voir les projets", pt: "Ver projetos" },
|
||||||
|
|
||||||
|
ue_team_titel: {
|
||||||
|
de: "Nicht allein, aber verantwortlich",
|
||||||
|
"de-CH": "Nöd elei, aber verantwortlich",
|
||||||
|
en: "Not alone, but accountable",
|
||||||
|
fr: "Pas seul, mais responsable",
|
||||||
|
pt: "Não sozinho, mas responsável",
|
||||||
|
},
|
||||||
|
ue_team_text: {
|
||||||
|
de: "Bei Rückfragen, Gegenlesen und der Abnahme hilft VanVan mit — sie hat auf diese Vorabversion dieselben Rechte wie ich. Deine Ansprechperson für dein Projekt bleibe trotzdem ich, damit du nie erklären musst, was du letzte Woche schon erklärt hast.",
|
||||||
|
"de-CH": "Bi Rückfroge, Gägeläse und dr Abnahm hilft d VanVan mit — si het uf die Vorabversion diesälbe Rächt wie ich. Dini Aaspröchperson für dis Projekt blib trotzdem ich, damit du nie erkläre muesch, was du letschti Wuche scho erklärt hesch.",
|
||||||
|
en: "VanVan helps with follow-up questions, proofreading and sign-off — she has exactly the same rights to this preview as I do. Your contact person for your project remains me, so you never have to explain again what you already explained last week.",
|
||||||
|
fr: "VanVan aide pour les questions, la relecture et la validation — elle a exactement les mêmes droits sur cette version préliminaire que moi. Ton interlocuteur pour ton projet reste moi, pour que tu n'aies jamais à réexpliquer ce que tu as déjà expliqué la semaine dernière.",
|
||||||
|
pt: "A VanVan ajuda com perguntas de seguimento, revisão e aprovação — tem exatamente os mesmos direitos nesta versão prévia que eu. A tua pessoa de contacto para o teu projeto continuo a ser eu, para que nunca tenhas de explicar de novo o que já explicaste na semana passada.",
|
||||||
|
},
|
||||||
|
|
||||||
|
ue_cta_h2: {
|
||||||
|
de: "Klingt das nach jemandem, mit dem du arbeiten willst?",
|
||||||
|
"de-CH": "Tönt das nach öpperem, mit dem du schaffe wottsch?",
|
||||||
|
en: "Does that sound like someone you want to work with?",
|
||||||
|
fr: "Cela ressemble-t-il à quelqu'un avec qui tu veux travailler ?",
|
||||||
|
pt: "Isto soa a alguém com quem queres trabalhar?",
|
||||||
|
},
|
||||||
|
ue_cta_p: {
|
||||||
|
de: "Dann erzähl mir von deinem Vorhaben. Das Formular dauert ein paar Minuten und verpflichtet dich zu nichts.",
|
||||||
|
"de-CH": "Denn verzell mer vo dim Vorhabe. S Formular duuret e paar Minute und verpflichtet dich zu nüt.",
|
||||||
|
en: "Then tell me about your project. The form takes a few minutes and commits you to nothing.",
|
||||||
|
fr: "Alors parle-moi de ton projet. Le formulaire prend quelques minutes et ne t'engage à rien.",
|
||||||
|
pt: "Então conta-me o teu projeto. O formulário demora alguns minutos e não te compromete a nada.",
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/* =====================================================================
|
||||||
|
i18n-wd-zugang.js — Texte der Zugangswand (/webdesign/zugang.html)
|
||||||
|
Fünf Sprachen wie im gesamten Dogfather Universe.
|
||||||
|
Schweizerdeutsch ist echter Dialekt, keine ß→ss-Umschrift.
|
||||||
|
===================================================================== */
|
||||||
|
window.I18N_WD = {
|
||||||
|
seiten_titel: {
|
||||||
|
de: "Zugang — Dogfather Webdesign",
|
||||||
|
"de-CH": "Zuegang — Dogfather Webdesign",
|
||||||
|
en: "Access — Dogfather Web Design",
|
||||||
|
fr: "Accès — Dogfather Web Design",
|
||||||
|
pt: "Acesso — Dogfather Web Design",
|
||||||
|
},
|
||||||
|
|
||||||
|
zg_eyebrow: {
|
||||||
|
de: "Nicht öffentlich bis zur Freigabe",
|
||||||
|
"de-CH": "Nöd öffentlich bis zur Freigab",
|
||||||
|
en: "Not public until release",
|
||||||
|
fr: "Non public jusqu'à la validation",
|
||||||
|
pt: "Não público até à aprovação",
|
||||||
|
},
|
||||||
|
zg_h1: {
|
||||||
|
de: "Willkommen zurück",
|
||||||
|
"de-CH": "Willkomme zrugg",
|
||||||
|
en: "Welcome back",
|
||||||
|
fr: "Bon retour",
|
||||||
|
pt: "Bem-vindo de volta",
|
||||||
|
},
|
||||||
|
zg_text: {
|
||||||
|
de: "Diese Vorabversion ist nur für Dogfather und VanVan gedacht. Wähle deine Karte und gib deinen persönlichen Zugangscode ein.",
|
||||||
|
"de-CH": "Die Vorabversion isch nur für dä Dogfather und d VanVan dänkt. Wähl dini Charte und tipp din persönlich Zuegangscode ii.",
|
||||||
|
en: "This preview is intended for Dogfather and VanVan only. Choose your card and enter your personal access code.",
|
||||||
|
fr: "Cette version préliminaire est réservée à Dogfather et VanVan. Choisis ta carte et saisis ton code d'accès personnel.",
|
||||||
|
pt: "Esta versão prévia destina-se apenas ao Dogfather e à VanVan. Escolhe o teu cartão e introduz o teu código de acesso pessoal.",
|
||||||
|
},
|
||||||
|
|
||||||
|
zg_dogfather: { de: "Dogfather", "de-CH": "Dogfather", en: "Dogfather", fr: "Dogfather", pt: "Dogfather" },
|
||||||
|
zg_vanvan: { de: "VanVan", "de-CH": "VanVan", en: "VanVan", fr: "VanVan", pt: "VanVan" },
|
||||||
|
|
||||||
|
/* Beide Karten tragen bewusst dieselbe Rollenbeschreibung — Masterplan
|
||||||
|
S.4: "Exakt dieselbe vollständige Ansicht", "Exakt dieselben
|
||||||
|
Verwaltungs-, Änderungs- und Freigaberechte". */
|
||||||
|
zg_rolle: {
|
||||||
|
de: "Volladministrator",
|
||||||
|
"de-CH": "Volladministrator",
|
||||||
|
en: "Full administrator",
|
||||||
|
fr: "Administrateur complet",
|
||||||
|
pt: "Administrador total",
|
||||||
|
},
|
||||||
|
zg_code_label: {
|
||||||
|
de: "Zugangscode",
|
||||||
|
"de-CH": "Zuegangscode",
|
||||||
|
en: "Access code",
|
||||||
|
fr: "Code d'accès",
|
||||||
|
pt: "Código de acesso",
|
||||||
|
},
|
||||||
|
zg_oeffnen: { de: "Öffnen", "de-CH": "Ufmache", en: "Open", fr: "Ouvrir", pt: "Abrir" },
|
||||||
|
zg_pruefe: {
|
||||||
|
de: "Wird geprüft…",
|
||||||
|
"de-CH": "Wird prüeft…",
|
||||||
|
en: "Checking…",
|
||||||
|
fr: "Vérification…",
|
||||||
|
pt: "A verificar…",
|
||||||
|
},
|
||||||
|
zg_willkommen: {
|
||||||
|
de: "Willkommen!",
|
||||||
|
"de-CH": "Willkomme!",
|
||||||
|
en: "Welcome!",
|
||||||
|
fr: "Bienvenue !",
|
||||||
|
pt: "Bem-vindo!",
|
||||||
|
},
|
||||||
|
zg_leer: {
|
||||||
|
de: "Bitte zuerst den Zugangscode eingeben.",
|
||||||
|
"de-CH": "Bitte zerscht dä Zuegangscode iigäh.",
|
||||||
|
en: "Please enter your access code first.",
|
||||||
|
fr: "Merci de saisir d'abord ton code d'accès.",
|
||||||
|
pt: "Introduz primeiro o teu código de acesso.",
|
||||||
|
},
|
||||||
|
zg_falsch: {
|
||||||
|
de: "Falscher Zugangscode.",
|
||||||
|
"de-CH": "Falsche Zuegangscode.",
|
||||||
|
en: "Wrong access code.",
|
||||||
|
fr: "Code d'accès incorrect.",
|
||||||
|
pt: "Código de acesso incorreto.",
|
||||||
|
},
|
||||||
|
|
||||||
|
zg_sicher_titel: {
|
||||||
|
de: "Warum ein Code?",
|
||||||
|
"de-CH": "Wieso en Code?",
|
||||||
|
en: "Why a code?",
|
||||||
|
fr: "Pourquoi un code ?",
|
||||||
|
pt: "Porquê um código?",
|
||||||
|
},
|
||||||
|
zg_sicher_text: {
|
||||||
|
de: "Solange die Seite nicht freigegeben ist, soll niemand Preise, Beispiele oder Kundendaten sehen können. Der Code wird ausschließlich auf dem Server geprüft und nie im Browser gespeichert. Nach fünf Fehlversuchen greift eine kurze Wartezeit.",
|
||||||
|
"de-CH": "So lang d Site nöd freigäh isch, söll niemert Priis, Bispiel oder Kundedate gseh chönne. Dä Code wird nur uf em Server prüeft und nie im Browser gspeicheret. Nach füf Fählversüech chunnt e churzi Wartziit.",
|
||||||
|
en: "Until the site is released, nobody should be able to see prices, examples or client data. The code is checked exclusively on the server and never stored in the browser. After five failed attempts a short waiting period applies.",
|
||||||
|
fr: "Tant que le site n'est pas validé, personne ne doit voir les tarifs, les exemples ou les données clients. Le code est vérifié uniquement sur le serveur et n'est jamais stocké dans le navigateur. Après cinq tentatives échouées, un court délai d'attente s'applique.",
|
||||||
|
pt: "Enquanto o site não for aprovado, ninguém deve conseguir ver preços, exemplos ou dados de clientes. O código é verificado exclusivamente no servidor e nunca é guardado no navegador. Após cinco tentativas falhadas aplica-se um curto período de espera.",
|
||||||
|
},
|
||||||
|
zg_zurueck: {
|
||||||
|
de: "Zum Dogfather Universe",
|
||||||
|
"de-CH": "Zum Dogfather Universe",
|
||||||
|
en: "To the Dogfather Universe",
|
||||||
|
fr: "Vers le Dogfather Universe",
|
||||||
|
pt: "Para o Dogfather Universe",
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,800 @@
|
|||||||
|
/* =====================================================================
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- 0011_webdesign.sql — Datenbank für den Bereich /webdesign
|
||||||
|
-- (Masterplan S.10 Lead-System, S.12 Kundenportal, S.13 Verwaltung)
|
||||||
|
--
|
||||||
|
-- Warum alles mit dem Präfix "wd_":
|
||||||
|
-- Diese Datenbank trägt bereits das Universe (Team, Bewerbungen,
|
||||||
|
-- Supporter, Abstimmungen, Testimonials). Der Webdesign-Bereich ist ein
|
||||||
|
-- eigenes Geschäft mit eigenen Kunden — ohne Präfix wäre in einem Jahr
|
||||||
|
-- nicht mehr erkennbar, welche "users"-Tabelle wozu gehört, und ein
|
||||||
|
-- versehentlicher JOIN über zwei fachlich getrennte Welten ist der
|
||||||
|
-- Anfang jedes Datenlecks.
|
||||||
|
--
|
||||||
|
-- Grundsätze:
|
||||||
|
-- - Löschen wird vermieden. Kritische Datensätze werden archiviert
|
||||||
|
-- (Masterplan S.13: "Kritische Löschungen werden zunächst archiviert").
|
||||||
|
-- - Jede Tabelle mit Kundenbezug hat eine kunde_id, über die serverseitig
|
||||||
|
-- gefiltert wird. Ohne diese Spalte gibt es keine saubere Trennung.
|
||||||
|
-- - Zeitstempel immer als ISO-8601-Text in UTC (wie im restlichen
|
||||||
|
-- Projekt), damit Sortierung und Vergleich rein textuell funktionieren.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- ANFRAGEN (Masterplan S.10)
|
||||||
|
-- Entsteht durch das öffentliche Formular. Wird NICHT gelöscht, sondern
|
||||||
|
-- archiviert — eine Anfrage ist auch dann noch belegrelevant, wenn daraus
|
||||||
|
-- nichts wurde.
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS wd_anfragen (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
-- Kurze, sprechende Nummer für die Kommunikation ("A-2608-0001").
|
||||||
|
nummer TEXT NOT NULL UNIQUE,
|
||||||
|
|
||||||
|
-- Kontakt
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
telefon TEXT,
|
||||||
|
kontaktweg TEXT NOT NULL DEFAULT 'email', -- email | telefon
|
||||||
|
|
||||||
|
-- Projekt
|
||||||
|
paket TEXT NOT NULL, -- onepager | website | shop | verwaltung | betreuung | unklar
|
||||||
|
ziel TEXT, -- Was soll die Seite erreichen
|
||||||
|
zielgruppe TEXT,
|
||||||
|
wirkung TEXT,
|
||||||
|
seiten TEXT, -- gewünschte Seiten/Bereiche
|
||||||
|
funktionen TEXT, -- JSON-Array angekreuzter Funktionen
|
||||||
|
-- Kategoriespezifische Folgefragen als JSON, damit neue Fragen keine
|
||||||
|
-- Schemaänderung brauchen (Masterplan S.10 "Intelligente Felder").
|
||||||
|
details TEXT,
|
||||||
|
|
||||||
|
-- Vorhandene Inhalte
|
||||||
|
hat_texte TEXT, -- ja | teilweise | nein
|
||||||
|
hat_bilder TEXT,
|
||||||
|
hat_logo TEXT,
|
||||||
|
hat_domain TEXT,
|
||||||
|
|
||||||
|
-- Rahmen
|
||||||
|
budget TEXT,
|
||||||
|
termin TEXT,
|
||||||
|
bemerkung TEXT,
|
||||||
|
|
||||||
|
-- Verwaltung
|
||||||
|
status TEXT NOT NULL DEFAULT 'neu', -- neu | pruefung | rueckfrage | angebot | angenommen | abgeschlossen | abgelehnt
|
||||||
|
archiviert INTEGER NOT NULL DEFAULT 0,
|
||||||
|
kunde_id TEXT, -- gesetzt, sobald daraus ein Kunde wurde
|
||||||
|
sprache TEXT NOT NULL DEFAULT 'de', -- in welcher Sprache angefragt wurde -> so wird geantwortet
|
||||||
|
|
||||||
|
-- Nachvollziehbarkeit
|
||||||
|
erstellt_am TEXT NOT NULL,
|
||||||
|
aktualisiert_am TEXT,
|
||||||
|
ip_hash TEXT, -- nur Hash, nie die IP selbst (Datensparsamkeit)
|
||||||
|
|
||||||
|
FOREIGN KEY (kunde_id) REFERENCES wd_kunden(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wd_anfragen_status ON wd_anfragen(status, archiviert);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wd_anfragen_erstellt ON wd_anfragen(erstellt_am DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wd_anfragen_email ON wd_anfragen(email);
|
||||||
|
|
||||||
|
-- Interne Notizen und Antworten an der Anfrage (Masterplan S.10).
|
||||||
|
CREATE TABLE IF NOT EXISTS wd_anfrage_notizen (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
anfrage_id TEXT NOT NULL,
|
||||||
|
autor TEXT NOT NULL, -- dogfather | vanvan
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
intern INTEGER NOT NULL DEFAULT 1, -- 1 = nur intern sichtbar
|
||||||
|
erstellt_am TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (anfrage_id) REFERENCES wd_anfragen(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wd_anfrage_notizen ON wd_anfrage_notizen(anfrage_id, erstellt_am);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- KUNDEN (Masterplan S.12)
|
||||||
|
-- Ein Kunde entsteht erst, wenn ein Projekt gekauft oder eine Betreuung
|
||||||
|
-- vereinbart wurde. Registrierung allein reicht ausdrücklich nicht:
|
||||||
|
-- "Projektkauf oder laufende Betreuung wird vor Freischaltung geprüft".
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS wd_kunden (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
firma TEXT,
|
||||||
|
telefon TEXT,
|
||||||
|
sprache TEXT NOT NULL DEFAULT 'de',
|
||||||
|
|
||||||
|
-- Anmeldung
|
||||||
|
passwort_hash TEXT, -- NULL = noch kein Passwort gesetzt
|
||||||
|
email_bestaetigt INTEGER NOT NULL DEFAULT 0,
|
||||||
|
-- Freischaltung ist bewusst getrennt von der E-Mail-Bestätigung:
|
||||||
|
-- bestätigte E-Mail heißt nur "die Adresse gehört dir", nicht
|
||||||
|
-- "du darfst hier hinein".
|
||||||
|
freigeschaltet INTEGER NOT NULL DEFAULT 0,
|
||||||
|
gesperrt INTEGER NOT NULL DEFAULT 0,
|
||||||
|
gesperrt_grund TEXT,
|
||||||
|
|
||||||
|
-- Betreuung
|
||||||
|
betreuung_aktiv INTEGER NOT NULL DEFAULT 0,
|
||||||
|
betreuung_paket TEXT,
|
||||||
|
betreuung_start TEXT,
|
||||||
|
betreuung_ende TEXT,
|
||||||
|
|
||||||
|
benachrichtigungen INTEGER NOT NULL DEFAULT 1,
|
||||||
|
erstellt_am TEXT NOT NULL,
|
||||||
|
aktualisiert_am TEXT,
|
||||||
|
letzter_login TEXT,
|
||||||
|
archiviert INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wd_kunden_email ON wd_kunden(email);
|
||||||
|
|
||||||
|
-- Einmal-Kennungen für E-Mail-Bestätigung, Einladung und Passwort-Reset.
|
||||||
|
-- Getrennt von der Kundentabelle, damit ein Kunde mehrere offene Vorgänge
|
||||||
|
-- haben kann und abgelaufene Kennungen einfach wegzuräumen sind.
|
||||||
|
CREATE TABLE IF NOT EXISTS wd_kunden_token (
|
||||||
|
token_hash TEXT PRIMARY KEY, -- nur der Hash, nie das Token selbst
|
||||||
|
kunde_id TEXT NOT NULL,
|
||||||
|
zweck TEXT NOT NULL, -- bestaetigung | einladung | passwort
|
||||||
|
laeuft_ab TEXT NOT NULL,
|
||||||
|
benutzt_am TEXT,
|
||||||
|
erstellt_am TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (kunde_id) REFERENCES wd_kunden(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wd_kunden_token_kunde ON wd_kunden_token(kunde_id, zweck);
|
||||||
|
|
||||||
|
-- Aktive Anmeldungen. Serverseitig gespeichert, damit sich Sitzungen im
|
||||||
|
-- Verwaltungsbereich gezielt beenden lassen (Masterplan S.13:
|
||||||
|
-- "Aktive Sitzungen abmelden").
|
||||||
|
CREATE TABLE IF NOT EXISTS wd_kunden_sitzungen (
|
||||||
|
token_hash TEXT PRIMARY KEY,
|
||||||
|
kunde_id TEXT NOT NULL,
|
||||||
|
erstellt_am TEXT NOT NULL,
|
||||||
|
laeuft_ab TEXT NOT NULL,
|
||||||
|
letzte_aktion TEXT,
|
||||||
|
FOREIGN KEY (kunde_id) REFERENCES wd_kunden(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wd_sitzungen_kunde ON wd_kunden_sitzungen(kunde_id);
|
||||||
|
|
||||||
|
-- Fehlversuche bei der Kundenanmeldung (Sperre nach zu vielen Versuchen).
|
||||||
|
CREATE TABLE IF NOT EXISTS wd_login_versuche (
|
||||||
|
schluessel TEXT PRIMARY KEY, -- E-Mail in Kleinschreibung
|
||||||
|
anzahl INTEGER NOT NULL DEFAULT 0,
|
||||||
|
gesperrt_bis TEXT,
|
||||||
|
zuletzt TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- PROJEKTE (Masterplan S.12/S.13)
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS wd_projekte (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
nummer TEXT NOT NULL UNIQUE, -- "P-2608-0001"
|
||||||
|
kunde_id TEXT NOT NULL,
|
||||||
|
anfrage_id TEXT,
|
||||||
|
|
||||||
|
titel TEXT NOT NULL,
|
||||||
|
paket TEXT NOT NULL,
|
||||||
|
beschreibung TEXT,
|
||||||
|
|
||||||
|
status TEXT NOT NULL DEFAULT 'briefing', -- briefing | angebot | design | entwicklung | tests | abnahme | uebergeben | pausiert | abgebrochen
|
||||||
|
naechster_schritt TEXT,
|
||||||
|
wartet_auf TEXT, -- kunde | dogfather -> wer gerade am Zug ist
|
||||||
|
|
||||||
|
-- Geld. In CENT als Ganzzahl, nie als Kommazahl: 0.1 + 0.2 ergibt in
|
||||||
|
-- Gleitkomma nicht 0.3, und bei Rechnungsbeträgen ist das inakzeptabel.
|
||||||
|
preis_cent INTEGER,
|
||||||
|
anzahlung_cent INTEGER,
|
||||||
|
anzahlung_bezahlt INTEGER NOT NULL DEFAULT 0,
|
||||||
|
rest_bezahlt INTEGER NOT NULL DEFAULT 0,
|
||||||
|
waehrung TEXT NOT NULL DEFAULT 'EUR',
|
||||||
|
|
||||||
|
start_am TEXT,
|
||||||
|
richttermin TEXT,
|
||||||
|
uebergabe_am TEXT,
|
||||||
|
|
||||||
|
-- Portfoliofreigabe ist bewusst ein EIGENES Feld, getrennt von der
|
||||||
|
-- Veröffentlichung der Kundenseite (Masterplan S.13: "Portfoliofreigabe
|
||||||
|
-- separat dokumentieren"). Beides darf nie aneinander hängen.
|
||||||
|
portfolio_freigabe INTEGER NOT NULL DEFAULT 0,
|
||||||
|
portfolio_freigabe_am TEXT,
|
||||||
|
|
||||||
|
archiviert INTEGER NOT NULL DEFAULT 0,
|
||||||
|
erstellt_am TEXT NOT NULL,
|
||||||
|
aktualisiert_am TEXT,
|
||||||
|
|
||||||
|
FOREIGN KEY (kunde_id) REFERENCES wd_kunden(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (anfrage_id) REFERENCES wd_anfragen(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wd_projekte_kunde ON wd_projekte(kunde_id, archiviert);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wd_projekte_status ON wd_projekte(status);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- DATEIEN (Masterplan S.12: Versionen und Zeitstempel)
|
||||||
|
-- Die Datei selbst liegt im Dateisystem neben der Datenbank, hier steht
|
||||||
|
-- nur der Verweis. Der gespeicherte Name ist eine zufällige Kennung —
|
||||||
|
-- ein hochgeladener Originalname darf nie in einen Pfad wandern.
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS wd_dateien (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
projekt_id TEXT,
|
||||||
|
kunde_id TEXT NOT NULL,
|
||||||
|
anfrage_id TEXT,
|
||||||
|
|
||||||
|
anzeigename TEXT NOT NULL, -- was der Mensch sieht
|
||||||
|
speichername TEXT NOT NULL, -- zufällig, im Dateisystem
|
||||||
|
typ TEXT NOT NULL, -- MIME-Typ
|
||||||
|
groesse INTEGER NOT NULL,
|
||||||
|
|
||||||
|
kategorie TEXT NOT NULL DEFAULT 'sonstiges', -- logo | bild | text | angebot | freigabe | sonstiges
|
||||||
|
version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
ersetzt_id TEXT, -- Vorgängerversion
|
||||||
|
hochgeladen_von TEXT NOT NULL, -- kunde | dogfather | vanvan
|
||||||
|
-- Kunden dürfen nur sehen, was für sie bestimmt ist. Interne Arbeitsdateien
|
||||||
|
-- bleiben damit unsichtbar, ohne sie in einer zweiten Tabelle zu führen.
|
||||||
|
fuer_kunde INTEGER NOT NULL DEFAULT 1,
|
||||||
|
|
||||||
|
erstellt_am TEXT NOT NULL,
|
||||||
|
geloescht INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
FOREIGN KEY (kunde_id) REFERENCES wd_kunden(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (projekt_id) REFERENCES wd_projekte(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wd_dateien_projekt ON wd_dateien(projekt_id, geloescht);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wd_dateien_kunde ON wd_dateien(kunde_id, geloescht);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- NACHRICHTEN am Projekt (Masterplan S.12: "projektbezogene Kommunikation
|
||||||
|
-- bleibt vollständig nachvollziehbar")
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS wd_nachrichten (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
projekt_id TEXT NOT NULL,
|
||||||
|
kunde_id TEXT NOT NULL,
|
||||||
|
autor TEXT NOT NULL, -- kunde | dogfather | vanvan
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
intern INTEGER NOT NULL DEFAULT 0, -- 1 = Kunde sieht es NICHT
|
||||||
|
gelesen_kunde INTEGER NOT NULL DEFAULT 0,
|
||||||
|
gelesen_admin INTEGER NOT NULL DEFAULT 0,
|
||||||
|
erstellt_am TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (projekt_id) REFERENCES wd_projekte(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (kunde_id) REFERENCES wd_kunden(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wd_nachrichten_projekt ON wd_nachrichten(projekt_id, erstellt_am);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- ÄNDERUNGSWÜNSCHE / ZUSATZANGEBOTE (Masterplan S.12)
|
||||||
|
-- Der Kunde meldet eine Idee an, Dogfather beziffert Aufwand und Preis,
|
||||||
|
-- der Kunde nimmt an oder lehnt ab. Ein eigener Datensatz statt einer
|
||||||
|
-- Nachricht, damit der Zustand eindeutig ist und nichts im Verlauf untergeht.
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS wd_aenderungen (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
projekt_id TEXT NOT NULL,
|
||||||
|
kunde_id TEXT NOT NULL,
|
||||||
|
|
||||||
|
titel TEXT NOT NULL,
|
||||||
|
beschreibung TEXT NOT NULL,
|
||||||
|
|
||||||
|
status TEXT NOT NULL DEFAULT 'eingereicht', -- eingereicht | geprueft | angeboten | angenommen | abgelehnt | umgesetzt
|
||||||
|
aufwand_text TEXT,
|
||||||
|
zusatzpreis_cent INTEGER,
|
||||||
|
zusatzdauer_text TEXT,
|
||||||
|
antwort TEXT,
|
||||||
|
|
||||||
|
entschieden_am TEXT,
|
||||||
|
erstellt_am TEXT NOT NULL,
|
||||||
|
aktualisiert_am TEXT,
|
||||||
|
|
||||||
|
FOREIGN KEY (projekt_id) REFERENCES wd_projekte(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (kunde_id) REFERENCES wd_kunden(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wd_aenderungen_projekt ON wd_aenderungen(projekt_id, status);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- INHALTE, DIE IN DER VERWALTUNG PFLEGBAR SIND (Masterplan S.13)
|
||||||
|
-- Ein einfacher Schlüssel-Wert-Speicher mit mehrsprachigem JSON-Wert.
|
||||||
|
-- Bewusst kein eigenes Feld je Text: sonst braucht jeder neue Satz auf der
|
||||||
|
-- Seite eine Datenbankänderung.
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS wd_inhalte (
|
||||||
|
schluessel TEXT PRIMARY KEY,
|
||||||
|
wert_json TEXT NOT NULL, -- {"de":"...","de-CH":"...","en":"...","fr":"...","pt":"..."}
|
||||||
|
bereich TEXT, -- zur Gruppierung in der Oberfläche
|
||||||
|
aktualisiert_am TEXT NOT NULL,
|
||||||
|
aktualisiert_von TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- ÄNDERUNGSVERLAUF (Masterplan S.13: "Änderungsverlauf und verantwortliche
|
||||||
|
-- Person protokollieren")
|
||||||
|
-- Absichtlich eine eigene Tabelle statt Spalten in jeder Tabelle: so ist
|
||||||
|
-- der Verlauf an einer Stelle lesbar und überlebt auch das Archivieren
|
||||||
|
-- des Datensatzes, auf den er sich bezieht.
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS wd_verlauf (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
bereich TEXT NOT NULL, -- anfrage | kunde | projekt | inhalt | zugang | datei
|
||||||
|
objekt_id TEXT,
|
||||||
|
aktion TEXT NOT NULL, -- angelegt | geaendert | archiviert | freigegeben | ...
|
||||||
|
wer TEXT NOT NULL, -- dogfather | vanvan | kunde:<id> | system
|
||||||
|
details TEXT, -- JSON: was genau sich geändert hat
|
||||||
|
erstellt_am TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wd_verlauf_objekt ON wd_verlauf(bereich, objekt_id, erstellt_am DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wd_verlauf_zeit ON wd_verlauf(erstellt_am DESC);
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- 0012_webdesign_zahlungen.sql — Zahlungen im Webdesign-Bereich
|
||||||
|
--
|
||||||
|
-- Vier Zahlungswege (Entscheidung 22.08.2026):
|
||||||
|
-- anzahlung 30 % bei Angebotsannahme -> PayPal Orders (einmalig)
|
||||||
|
-- restbetrag nach der Abnahme -> PayPal Orders (einmalig)
|
||||||
|
-- zusatz angenommenes Zusatzangebot -> PayPal Orders (einmalig)
|
||||||
|
-- betreuung monatlich ab 49 € -> PayPal Subscriptions
|
||||||
|
--
|
||||||
|
-- Grundsätze:
|
||||||
|
-- * Beträge IMMER in Cent als Ganzzahl. Gleitkomma ist bei Geld falsch.
|
||||||
|
-- * Eine Zahlung wird NIE gelöscht und NIE überschrieben — auch eine
|
||||||
|
-- fehlgeschlagene ist ein Beleg dafür, was passiert ist.
|
||||||
|
-- * Der Zustand kommt am Ende immer von PayPal, nie vom Browser des
|
||||||
|
-- Kunden. Deshalb gibt es zusätzlich zur Rückleitung einen Webhook.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS wd_zahlungen (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
nummer TEXT NOT NULL UNIQUE, -- "Z-2608-0001"
|
||||||
|
|
||||||
|
kunde_id TEXT NOT NULL,
|
||||||
|
projekt_id TEXT,
|
||||||
|
aenderung_id TEXT, -- bei Zusatzangeboten
|
||||||
|
|
||||||
|
art TEXT NOT NULL, -- anzahlung | restbetrag | zusatz | betreuung
|
||||||
|
zweck_text TEXT NOT NULL, -- was der Kunde bei PayPal liest
|
||||||
|
|
||||||
|
betrag_cent INTEGER NOT NULL,
|
||||||
|
waehrung TEXT NOT NULL DEFAULT 'EUR',
|
||||||
|
|
||||||
|
-- offen = angelegt, Kunde war noch nicht bei PayPal
|
||||||
|
-- freigegeben= Kunde hat bei PayPal bestätigt, Einzug läuft
|
||||||
|
-- bezahlt = Geld ist eingezogen und auf dem Konto
|
||||||
|
-- fehlge = Einzug abgelehnt (Deckung, Abbruch, Zeitablauf)
|
||||||
|
-- erstattet = ganz oder teilweise zurückgezahlt
|
||||||
|
status TEXT NOT NULL DEFAULT 'offen',
|
||||||
|
|
||||||
|
-- PayPal-Kennungen. order_id bei Einmalzahlung, subscription_id beim Abo.
|
||||||
|
paypal_order_id TEXT,
|
||||||
|
paypal_capture_id TEXT,
|
||||||
|
paypal_subscription_id TEXT,
|
||||||
|
-- Was PayPal tatsächlich gutgeschrieben hat, nach Abzug der Gebühr.
|
||||||
|
-- Bewusst getrennt vom Rechnungsbetrag: die Gebühr ist Aufwand, kein
|
||||||
|
-- Rabatt, und für die Buchhaltung müssen beide Zahlen erhalten bleiben.
|
||||||
|
netto_cent INTEGER,
|
||||||
|
gebuehr_cent INTEGER,
|
||||||
|
|
||||||
|
-- Verbraucherschutz (gemischte Privat-/Firmenkunden, Entscheidung
|
||||||
|
-- 22.08.2026). Bei einer Dienstleistung, die vor Ablauf der 14 Tage
|
||||||
|
-- beginnt, erlischt das Widerrufsrecht NUR, wenn der Kunde vorher
|
||||||
|
-- ausdrücklich zugestimmt hat. Ohne Zeitstempel ist das im Streitfall
|
||||||
|
-- nicht beweisbar — genau derselbe Nachweis wie beim Supporter-Abo.
|
||||||
|
widerruf_zugestimmt INTEGER NOT NULL DEFAULT 0,
|
||||||
|
widerruf_zugestimmt_am TEXT,
|
||||||
|
agb_zugestimmt_am TEXT,
|
||||||
|
kundenart TEXT, -- privat | firma
|
||||||
|
|
||||||
|
erstellt_am TEXT NOT NULL,
|
||||||
|
freigegeben_am TEXT,
|
||||||
|
bezahlt_am TEXT,
|
||||||
|
aktualisiert_am TEXT,
|
||||||
|
fehler_text TEXT,
|
||||||
|
|
||||||
|
FOREIGN KEY (kunde_id) REFERENCES wd_kunden(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (projekt_id) REFERENCES wd_projekte(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (aenderung_id) REFERENCES wd_aenderungen(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wd_zahlungen_kunde ON wd_zahlungen(kunde_id, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wd_zahlungen_projekt ON wd_zahlungen(projekt_id, art);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wd_zahlungen_order ON wd_zahlungen(paypal_order_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wd_zahlungen_abo ON wd_zahlungen(paypal_subscription_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wd_zahlungen_zeit ON wd_zahlungen(erstellt_am DESC);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- Eingegangene PayPal-Webhooks.
|
||||||
|
--
|
||||||
|
-- Zwei Gründe für diese Tabelle:
|
||||||
|
-- 1. Doppelte Zustellung. PayPal sendet dasselbe Ereignis mehrfach, wenn
|
||||||
|
-- unsere Antwort nicht ankommt. Ohne Merkliste würde eine Zahlung
|
||||||
|
-- mehrfach verbucht. Die event_id als Primärschlüssel macht das
|
||||||
|
-- unmöglich.
|
||||||
|
-- 2. Nachvollziehbarkeit. Wenn Geld fehlt, muss man sehen können, was
|
||||||
|
-- PayPal wann gemeldet hat — nicht nur, was wir daraus gemacht haben.
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS wd_paypal_ereignisse (
|
||||||
|
event_id TEXT PRIMARY KEY,
|
||||||
|
typ TEXT NOT NULL,
|
||||||
|
zahlung_id TEXT,
|
||||||
|
signatur_ok INTEGER NOT NULL DEFAULT 0,
|
||||||
|
verarbeitet INTEGER NOT NULL DEFAULT 0,
|
||||||
|
rohdaten TEXT,
|
||||||
|
empfangen_am TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wd_paypal_zeit ON wd_paypal_ereignisse(empfangen_am DESC);
|
||||||
|
|
||||||
|
-- Betreuungs-Abos. Eigene Tabelle statt einer Spalte am Kunden, weil ein
|
||||||
|
-- Kunde mehrere Websites mit je eigener Betreuung haben kann.
|
||||||
|
CREATE TABLE IF NOT EXISTS wd_betreuung_abos (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
kunde_id TEXT NOT NULL,
|
||||||
|
projekt_id TEXT,
|
||||||
|
paket TEXT NOT NULL,
|
||||||
|
preis_cent INTEGER NOT NULL,
|
||||||
|
paypal_subscription_id TEXT UNIQUE,
|
||||||
|
-- angelegt | aktiv | pausiert | gekuendigt | abgelaufen
|
||||||
|
status TEXT NOT NULL DEFAULT 'angelegt',
|
||||||
|
start_am TEXT,
|
||||||
|
naechste_zahlung TEXT,
|
||||||
|
gekuendigt_am TEXT,
|
||||||
|
laeuft_bis TEXT, -- Zugang bleibt bis dahin
|
||||||
|
erstellt_am TEXT NOT NULL,
|
||||||
|
aktualisiert_am TEXT,
|
||||||
|
FOREIGN KEY (kunde_id) REFERENCES wd_kunden(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (projekt_id) REFERENCES wd_projekte(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wd_abos_kunde ON wd_betreuung_abos(kunde_id, status);
|
||||||
@@ -45,3 +45,43 @@ PAYPAL_CLIENT_ID=
|
|||||||
PAYPAL_CLIENT_SECRET=
|
PAYPAL_CLIENT_SECRET=
|
||||||
PAYPAL_PLAN_ID=
|
PAYPAL_PLAN_ID=
|
||||||
PAYPAL_WEBHOOK_ID=
|
PAYPAL_WEBHOOK_ID=
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PayPal fuer den Webdesign-Bereich (/webdesign), 22.08.2026
|
||||||
|
# Wunsch: "die gelder sollen auch sofort auf meinem paypal landen" -- deshalb
|
||||||
|
# wird mit intent=CAPTURE sofort eingezogen, nicht nur reserviert.
|
||||||
|
#
|
||||||
|
# Diese Werte kann NUR Filipe selbst erzeugen (PayPal-Business-Konto +
|
||||||
|
# Developer-App haengen an seiner Identitaet und Bankverbindung).
|
||||||
|
# Solange sie fehlen, ist die Zahlung sauber GESPERRT statt halb zu
|
||||||
|
# funktionieren -- eine Zahlung, die "irgendwie durchgeht" ohne dass Geld
|
||||||
|
# fliesst, waere der schlimmste denkbare Fehler.
|
||||||
|
# NIEMALS echte Werte hier eintragen, nur in der echten .env!
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# sandbox = Testmodus ohne echtes Geld (Voreinstellung), live = Echtbetrieb
|
||||||
|
PAYPAL_ENV=sandbox
|
||||||
|
|
||||||
|
# Aus dem PayPal-Entwicklerbereich, App vom Typ "Merchant".
|
||||||
|
# Achtung: Sandbox und Live haben UNTERSCHIEDLICHE Zugangsdaten.
|
||||||
|
PAYPAL_CLIENT_ID=
|
||||||
|
PAYPAL_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Webhook fuer den Webdesign-Bereich. Bewusst eine EIGENE Webhook-Kennung,
|
||||||
|
# getrennt vom Supporter-Abo (PAYPAL_WEBHOOK_ID): sonst laesst sich der eine
|
||||||
|
# Bereich nicht abschalten, ohne den anderen mit zu treffen.
|
||||||
|
# Ziel-Adresse beim Anlegen in PayPal:
|
||||||
|
# https://postfach.dogfather-universe.com/webdesign/paypal-webhook
|
||||||
|
PAYPAL_WD_WEBHOOK_ID=
|
||||||
|
|
||||||
|
# Abo-Plaene fuer die laufende Betreuung (ab 49 EUR/Monat). Ein Plan je Paket,
|
||||||
|
# weil PayPal den Betrag am Plan festmacht, nicht am einzelnen Abo.
|
||||||
|
# Nur noetig, wenn Betreuungs-Abos ueber die Seite abgeschlossen werden sollen;
|
||||||
|
# Anzahlung und Restbetrag funktionieren auch ohne diese Werte.
|
||||||
|
PAYPAL_WD_PLAN_BASIS=
|
||||||
|
PAYPAL_WD_PLAN_PLUS=
|
||||||
|
PAYPAL_WD_PLAN_PREMIUM=
|
||||||
|
|
||||||
|
# Zusatz fuer die IP-Hashes im Webdesign-Bereich. Beliebige lange
|
||||||
|
# Zufallszeichenkette. Wird sie geaendert, passen alte Hashes nicht mehr --
|
||||||
|
# das ist unkritisch, sie dienen nur der Missbrauchserkennung.
|
||||||
|
WD_IP_SALT=
|
||||||
|
|||||||
@@ -34,6 +34,15 @@ const MIGRATION_FILES = [
|
|||||||
"0008_team_members_rich.sql",
|
"0008_team_members_rich.sql",
|
||||||
"0009_supporter_access_code.sql",
|
"0009_supporter_access_code.sql",
|
||||||
"0010_testimonials_avatar.sql",
|
"0010_testimonials_avatar.sql",
|
||||||
|
// Webdesign-Bereich (22.08.2026): Anfragen, Kunden, Projekte, Dateien,
|
||||||
|
// Nachrichten, Änderungswünsche, pflegbare Inhalte, Änderungsverlauf.
|
||||||
|
// Alle Tabellen tragen das Präfix "wd_" und sind fachlich vollständig
|
||||||
|
// vom Universe getrennt — Begründung im Kopf der Migration.
|
||||||
|
"0011_webdesign.sql",
|
||||||
|
// Zahlungen im Webdesign-Bereich (22.08.2026): PayPal-Einmalzahlungen
|
||||||
|
// fuer Anzahlung/Restbetrag/Zusatzangebote, PayPal-Abo fuer die
|
||||||
|
// monatliche Betreuung, Webhook-Merkliste gegen Doppelverbuchung.
|
||||||
|
"0012_webdesign_zahlungen.sql",
|
||||||
];
|
];
|
||||||
|
|
||||||
function alreadyApplied(name) {
|
function alreadyApplied(name) {
|
||||||
|
|||||||
@@ -78,6 +78,14 @@ import {
|
|||||||
getTeamPublic, getTeamAdmin, saveTeamMember, importLegacyMember, deleteTeamMember, uploadTeamPhoto,
|
getTeamPublic, getTeamAdmin, saveTeamMember, importLegacyMember, deleteTeamMember, uploadTeamPhoto,
|
||||||
} from "./routes/team.js";
|
} from "./routes/team.js";
|
||||||
import { getSiteTextsPublic, saveSiteText } from "./routes/site-texts.js";
|
import { getSiteTextsPublic, saveSiteText } from "./routes/site-texts.js";
|
||||||
|
/* Webdesign-Bereich (22.08.2026). Die Rechteprüfung sitzt bewusst IN den
|
||||||
|
Handlern statt in einer Middleware davor: die Endpunkte brauchen
|
||||||
|
unterschiedliche Rechte (ansehen vs. bearbeiten), und ein einzelnes
|
||||||
|
requireTeamSession davor würde diesen Unterschied verwischen. */
|
||||||
|
import {
|
||||||
|
anfrageAbsenden, anfragenListe, anfrageDetail,
|
||||||
|
anfrageStatus, anfrageArchivieren, anfrageNotiz,
|
||||||
|
} from "./routes/webdesign-anfragen.js";
|
||||||
|
|
||||||
initDb();
|
initDb();
|
||||||
|
|
||||||
@@ -208,6 +216,17 @@ app.get("/testimonials", wrap(getApprovedTestimonialsPublic));
|
|||||||
app.get("/team", wrap(getTeamPublic));
|
app.get("/team", wrap(getTeamPublic));
|
||||||
app.get("/site-texts", wrap(getSiteTextsPublic));
|
app.get("/site-texts", wrap(getSiteTextsPublic));
|
||||||
|
|
||||||
|
/* ---------- Webdesign-Bereich (22.08.2026) ----------
|
||||||
|
Genau EIN öffentlicher Endpunkt: das Anfrageformular. Alles Weitere
|
||||||
|
verlangt eine Team-Anmeldung, die in den Handlern selbst geprüft wird
|
||||||
|
(unterschiedliche Rechte je Endpunkt, siehe Import oben). */
|
||||||
|
app.post("/webdesign/anfrage", wrap(anfrageAbsenden));
|
||||||
|
app.get("/webdesign/admin/anfragen", wrap(anfragenListe));
|
||||||
|
app.get("/webdesign/admin/anfragen/:id", wrap(anfrageDetail));
|
||||||
|
app.post("/webdesign/admin/anfragen/:id/status", wrap(anfrageStatus));
|
||||||
|
app.post("/webdesign/admin/anfragen/:id/archivieren", wrap(anfrageArchivieren));
|
||||||
|
app.post("/webdesign/admin/anfragen/:id/notiz", wrap(anfrageNotiz));
|
||||||
|
|
||||||
app.post("/supporter/register", wrap(registerSupporter));
|
app.post("/supporter/register", wrap(registerSupporter));
|
||||||
app.post("/supporter/verify-email", wrap(verifySupporterEmail));
|
app.post("/supporter/verify-email", wrap(verifySupporterEmail));
|
||||||
// Normaler Login seit 21.08.2026: E-Mail + selbst gewählter Zugangscode.
|
// Normaler Login seit 21.08.2026: E-Mail + selbst gewählter Zugangscode.
|
||||||
|
|||||||
@@ -52,6 +52,17 @@ export const PERMISSIONS = {
|
|||||||
TEAM_MANAGE: "Team-Mitglieder verwalten (Modis/Scouts/Creator/Manager)",
|
TEAM_MANAGE: "Team-Mitglieder verwalten (Modis/Scouts/Creator/Manager)",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* Webdesign-Rechte (22.08.2026) — an den Katalog oben angehängt, damit die
|
||||||
|
bestehende Rechteverwaltung sie ohne Änderung mit anzeigt. */
|
||||||
|
Object.assign(PERMISSIONS, {
|
||||||
|
WD_ANFRAGEN_VIEW: "Webdesign: Projektanfragen ansehen",
|
||||||
|
WD_ANFRAGEN_MANAGE: "Webdesign: Anfragen bearbeiten, Status ändern, archivieren",
|
||||||
|
WD_KUNDEN_MANAGE: "Webdesign: Kunden anlegen, freischalten, sperren",
|
||||||
|
WD_PROJEKTE_MANAGE: "Webdesign: Projekte, Termine und Freigaben verwalten",
|
||||||
|
WD_ZAHLUNGEN_VIEW: "Webdesign: Zahlungen und Beträge einsehen",
|
||||||
|
WD_INHALTE_EDIT: "Webdesign: Texte und Preise der Seite bearbeiten",
|
||||||
|
});
|
||||||
|
|
||||||
export const PERMISSION_GROUPS = [
|
export const PERMISSION_GROUPS = [
|
||||||
{ label: "Bewerbungsbereiche", keys: ["APPLICATIONS_VIEW_CREATOR", "APPLICATIONS_VIEW_MANAGER_SCOUT", "APPLICATIONS_VIEW_ARCHIVED", "APPLICATIONS_OPEN_FULL"] },
|
{ label: "Bewerbungsbereiche", keys: ["APPLICATIONS_VIEW_CREATOR", "APPLICATIONS_VIEW_MANAGER_SCOUT", "APPLICATIONS_VIEW_ARCHIVED", "APPLICATIONS_OPEN_FULL"] },
|
||||||
{ label: "Bearbeitung", keys: ["APPLICATIONS_TAKE_OVER", "APPLICATIONS_ASSIGN", "APPLICATIONS_CHANGE_STATUS", "APPLICATIONS_ARCHIVE", "APPLICATIONS_RESTORE", "APPLICATIONS_DELETE"] },
|
{ label: "Bearbeitung", keys: ["APPLICATIONS_TAKE_OVER", "APPLICATIONS_ASSIGN", "APPLICATIONS_CHANGE_STATUS", "APPLICATIONS_ARCHIVE", "APPLICATIONS_RESTORE", "APPLICATIONS_DELETE"] },
|
||||||
@@ -63,6 +74,11 @@ export const PERMISSION_GROUPS = [
|
|||||||
{ label: "Startseite", keys: ["EVENTS_MANAGE"] },
|
{ label: "Startseite", keys: ["EVENTS_MANAGE"] },
|
||||||
{ label: "Stimmen", keys: ["TESTIMONIALS_MANAGE"] },
|
{ label: "Stimmen", keys: ["TESTIMONIALS_MANAGE"] },
|
||||||
{ label: "Team", keys: ["TEAM_MANAGE"] },
|
{ label: "Team", keys: ["TEAM_MANAGE"] },
|
||||||
|
/* Webdesign-Bereich (22.08.2026). Bewusst EIGENE Rechte statt der
|
||||||
|
bestehenden Bewerbungs-Rechte: eine Person, die Creator-Bewerbungen
|
||||||
|
lesen darf, soll damit nicht automatisch Kundendaten, Preise und
|
||||||
|
Zahlungen einsehen können. Das sind zwei getrennte Geschäfte. */
|
||||||
|
{ label: "Webdesign", keys: ["WD_ANFRAGEN_VIEW", "WD_ANFRAGEN_MANAGE", "WD_KUNDEN_MANAGE", "WD_PROJEKTE_MANAGE", "WD_ZAHLUNGEN_VIEW", "WD_INHALTE_EDIT"] },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const OWNER_ONLY_ACTIONS = [
|
export const OWNER_ONLY_ACTIONS = [
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
/* =====================================================================
|
||||||
|
webdesign-helfer.js — geteilte Bausteine für den Webdesign-Bereich.
|
||||||
|
Bewusst klein gehalten und ohne Abhängigkeit zu den Universe-Modulen,
|
||||||
|
damit eine Änderung dort diesen Bereich nicht mitreißt.
|
||||||
|
===================================================================== */
|
||||||
|
import { randomUUID, createHash, timingSafeEqual } from "node:crypto";
|
||||||
|
import { db } from "../db.js";
|
||||||
|
|
||||||
|
export const SPRACHEN = ["de", "de-CH", "en", "fr", "pt"];
|
||||||
|
|
||||||
|
export const PAKETE = ["onepager", "website", "shop", "verwaltung", "betreuung", "unklar"];
|
||||||
|
|
||||||
|
export const ANFRAGE_STATUS = [
|
||||||
|
"neu", "pruefung", "rueckfrage", "angebot", "angenommen", "abgeschlossen", "abgelehnt",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const PROJEKT_STATUS = [
|
||||||
|
"briefing", "angebot", "design", "entwicklung", "tests", "abnahme", "uebergeben", "pausiert", "abgebrochen",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function jetzt() {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function neueId() {
|
||||||
|
return randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fortlaufende, gut aussprechbare Nummer: "A-2608-0001".
|
||||||
|
Der Zähler läuft pro Präfix, damit die Nummer im Gespräch kurz bleibt
|
||||||
|
("die A-2608-7") statt eine 36-stellige Kennung vorlesen zu müssen. */
|
||||||
|
export function naechsteNummer(tabelle, praefix) {
|
||||||
|
const d = new Date();
|
||||||
|
const monat = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||||
|
const jahr = String(d.getUTCFullYear()).slice(2);
|
||||||
|
const stamm = `${praefix}-${jahr}${monat}-`;
|
||||||
|
const zeile = db
|
||||||
|
.prepare(`SELECT nummer FROM ${tabelle} WHERE nummer LIKE ? ORDER BY nummer DESC LIMIT 1`)
|
||||||
|
.get(stamm + "%");
|
||||||
|
const letzte = zeile ? parseInt(String(zeile.nummer).slice(stamm.length), 10) : 0;
|
||||||
|
return stamm + String((Number.isFinite(letzte) ? letzte : 0) + 1).padStart(4, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zeichenketten aus Formularen einheitlich säubern:
|
||||||
|
- Rand-Leerzeichen weg
|
||||||
|
- Länge begrenzt (sonst kann jemand mit einem 10-MB-Feld die Datenbank
|
||||||
|
aufblähen; die Begrenzung im Body-Parser allein reicht nicht, weil
|
||||||
|
ein einzelnes Feld auch innerhalb eines gültigen Bodys riesig sein kann)
|
||||||
|
- Steuerzeichen entfernen, die in Logs und E-Mails Unfug anrichten */
|
||||||
|
export function text(wert, maxLaenge = 2000) {
|
||||||
|
if (wert == null) return "";
|
||||||
|
return String(wert)
|
||||||
|
/* Steuerzeichen raus, aber Zeilenumbruch und Tabulator behalten --
|
||||||
|
die braucht ein mehrzeiliges Textfeld.
|
||||||
|
Geschrieben ueber Unicode-Eigenschaften statt ueber einen Bereich
|
||||||
|
aus echten Steuerzeichen: solche Zeichen im Quelltext ueberleben
|
||||||
|
weder Copy-and-paste noch jeden Editor zuverlaessig, und ein
|
||||||
|
stillschweigend kaputter Filter faellt erst auf, wenn er gebraucht
|
||||||
|
wird. \P{Cc} bedeutet "kein Steuerzeichen", die Verneinung der
|
||||||
|
Klasse trifft also genau die Steuerzeichen ausser \n und \t. */
|
||||||
|
.replace(/[^\P{Cc}\n\t]/gu, "")
|
||||||
|
.trim()
|
||||||
|
.slice(0, maxLaenge);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sehr bewusst KEINE komplizierte E-Mail-Regex. Die einzige zuverlässige
|
||||||
|
Prüfung ist der Bestätigungslink; alles andere lehnt entweder gültige
|
||||||
|
Adressen ab (z.B. neue Endungen, Umlaut-Domains) oder lässt Unsinn durch.
|
||||||
|
Geprüft wird deshalb nur das strukturell Notwendige. */
|
||||||
|
export function istEmail(wert) {
|
||||||
|
const s = text(wert, 254).toLowerCase();
|
||||||
|
if (!s || s.length < 6) return false;
|
||||||
|
const teile = s.split("@");
|
||||||
|
if (teile.length !== 2) return false;
|
||||||
|
const [lokal, domain] = teile;
|
||||||
|
if (!lokal || !domain) return false;
|
||||||
|
if (!domain.includes(".")) return false;
|
||||||
|
if (/\s/.test(s)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emailNormal(wert) {
|
||||||
|
return text(wert, 254).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* IP nur als Hash speichern (Datensparsamkeit). Der Hash reicht, um
|
||||||
|
wiederholte Absendungen derselben Quelle zu erkennen, lässt sich aber
|
||||||
|
nicht ohne Weiteres zurückrechnen. Der feste Zusatz verhindert, dass
|
||||||
|
jemand mit einer fertigen Regenbogentabelle alle IPv4-Adressen durchprobiert. */
|
||||||
|
export function ipHash(ip) {
|
||||||
|
if (!ip) return null;
|
||||||
|
const zusatz = process.env.WD_IP_SALT || "dogfather-webdesign";
|
||||||
|
return createHash("sha256").update(zusatz + "|" + String(ip)).digest("hex").slice(0, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tokenHash(token) {
|
||||||
|
return createHash("sha256").update(String(token)).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gleichZeitsicher(a, b) {
|
||||||
|
const pa = Buffer.from(String(a ?? ""), "utf8");
|
||||||
|
const pb = Buffer.from(String(b ?? ""), "utf8");
|
||||||
|
if (pa.length !== pb.length) return false; // timingSafeEqual wirft sonst
|
||||||
|
return timingSafeEqual(pa, pb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Änderungsverlauf schreiben (Masterplan S.13). Absichtlich als eigene
|
||||||
|
Funktion und nicht inline: so ist garantiert, dass jeder Eintrag
|
||||||
|
dieselben Felder hat und keiner vergessen wird. */
|
||||||
|
export function verlauf(bereich, objektId, aktion, wer, details) {
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO wd_verlauf (id, bereich, objekt_id, aktion, wer, details, erstellt_am)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
).run(
|
||||||
|
neueId(),
|
||||||
|
bereich,
|
||||||
|
objektId || null,
|
||||||
|
aktion,
|
||||||
|
wer || "system",
|
||||||
|
details ? JSON.stringify(details) : null,
|
||||||
|
jetzt()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Einfache Mengenbegrenzung, in der Datenbank statt im Arbeitsspeicher —
|
||||||
|
dieser Dienst kann neu starten, ohne dass ein Absender dadurch seine
|
||||||
|
Sperre verliert. */
|
||||||
|
export function zuVieleVersuche(schluessel, maxAnzahl, sperreMinuten) {
|
||||||
|
const zeile = db.prepare(`SELECT * FROM wd_login_versuche WHERE schluessel = ?`).get(schluessel);
|
||||||
|
if (!zeile) return false;
|
||||||
|
if (zeile.gesperrt_bis && zeile.gesperrt_bis > jetzt()) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function versuchZaehlen(schluessel, maxAnzahl = 5, sperreMinuten = 15) {
|
||||||
|
const zeile = db.prepare(`SELECT * FROM wd_login_versuche WHERE schluessel = ?`).get(schluessel);
|
||||||
|
const anzahl = (zeile?.anzahl || 0) + 1;
|
||||||
|
const gesperrtBis = anzahl >= maxAnzahl
|
||||||
|
? new Date(Date.now() + sperreMinuten * 60000).toISOString()
|
||||||
|
: null;
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO wd_login_versuche (schluessel, anzahl, gesperrt_bis, zuletzt)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(schluessel) DO UPDATE SET anzahl = excluded.anzahl,
|
||||||
|
gesperrt_bis = excluded.gesperrt_bis, zuletzt = excluded.zuletzt`
|
||||||
|
).run(schluessel, anzahl, gesperrtBis, jetzt());
|
||||||
|
return { anzahl, gesperrtBis };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function versucheZuruecksetzen(schluessel) {
|
||||||
|
db.prepare(`DELETE FROM wd_login_versuche WHERE schluessel = ?`).run(schluessel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Die echte Besucher-IP. Gleiche Begründung und gleiche Abhängigkeit wie
|
||||||
|
in server/gate.js: Cloudflare setzt diesen Header und die Firewall lässt
|
||||||
|
nur Cloudflare an die Ports 80/443. Ändert sich das, muss diese Stelle
|
||||||
|
zurückgebaut werden. */
|
||||||
|
export function besucherIp(req) {
|
||||||
|
const cf = req.headers["cf-connecting-ip"];
|
||||||
|
if (typeof cf === "string" && cf.trim() && cf.length <= 45) return cf.trim();
|
||||||
|
return req.ip || req.socket?.remoteAddress || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sprache aus der Anfrage übernehmen, damit die Antwort-E-Mail in
|
||||||
|
derselben Sprache kommt, in der gefragt wurde. */
|
||||||
|
export function spracheOderDe(wert) {
|
||||||
|
const s = text(wert, 10);
|
||||||
|
return SPRACHEN.includes(s) ? s : "de";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Betrag in Cent -> lesbarer Text. Nie mit Gleitkomma rechnen. */
|
||||||
|
export function centZuText(cent, waehrung = "EUR") {
|
||||||
|
if (cent == null) return null;
|
||||||
|
const ganz = Math.trunc(cent / 100);
|
||||||
|
const rest = String(Math.abs(cent % 100)).padStart(2, "0");
|
||||||
|
return `${ganz},${rest} ${waehrung === "EUR" ? "€" : waehrung}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
/* =====================================================================
|
||||||
|
webdesign-paypal.js — PayPal für den Webdesign-Bereich.
|
||||||
|
|
||||||
|
ZWEI VERSCHIEDENE PAYPAL-WELTEN, BEWUSST GETRENNT
|
||||||
|
|
||||||
|
1) Orders API (v2) — Einmalzahlungen: 30 % Anzahlung, Restbetrag,
|
||||||
|
angenommene Zusatzangebote. Beträge unterscheiden sich bei jedem
|
||||||
|
Projekt, deshalb geht das NICHT über Abo-Pläne.
|
||||||
|
2) Subscriptions API (v1) — die monatliche Betreuung. Fester Betrag,
|
||||||
|
wiederkehrend, braucht einen Plan im PayPal-Konto.
|
||||||
|
|
||||||
|
lib/paypal.js (Supporter-Abo) kann nur Nummer 2 und ist an den dortigen
|
||||||
|
Plan gebunden. Diese Datei ist deshalb eine eigene, damit eine Änderung
|
||||||
|
am Supporter-Abo keine Kundenzahlungen kaputt macht — und umgekehrt.
|
||||||
|
|
||||||
|
⚠️ WANN LANDET DAS GELD AUF DEM KONTO?
|
||||||
|
Bei PayPal gibt es zwei Wege: "authorize" (Betrag nur reservieren, bis
|
||||||
|
zu 29 Tage später einziehen) und "capture" (sofort einziehen). Hier wird
|
||||||
|
ausdrücklich CAPTURE benutzt — Wunsch vom 22.08.2026: "die gelder sollen
|
||||||
|
auch sofort auf meinem paypal landen". Mit capture ist das Geld
|
||||||
|
unmittelbar nach der Bestätigung des Kunden auf dem Business-Konto,
|
||||||
|
ohne Treuhand und ohne Wartezeit.
|
||||||
|
|
||||||
|
⚠️ FAIL CLOSED
|
||||||
|
Solange die Zugangsdaten fehlen, wirft jede Funktion einen klaren
|
||||||
|
Fehler, statt so zu tun, als sei etwas bezahlt worden. Eine Zahlung,
|
||||||
|
die "irgendwie durchgeht", obwohl kein Geld geflossen ist, wäre der
|
||||||
|
schlimmste denkbare Fehler in diesem Modul.
|
||||||
|
===================================================================== */
|
||||||
|
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
|
export class PayPalNichtEingerichtet extends Error {
|
||||||
|
constructor(fehlend) {
|
||||||
|
super(
|
||||||
|
"PayPal ist für den Webdesign-Bereich noch nicht eingerichtet. Fehlende Einstellungen: " +
|
||||||
|
(fehlend || []).join(", ")
|
||||||
|
);
|
||||||
|
this.name = "PayPalNichtEingerichtet";
|
||||||
|
this.code = "PAYPAL_NICHT_EINGERICHTET";
|
||||||
|
this.fehlend = fehlend || [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Testmodus zuerst. Wer vergisst, PAYPAL_ENV auf "live" zu stellen, zahlt
|
||||||
|
in der Spielwiese — ärgerlich, aber harmlos. Andersherum (versehentlich
|
||||||
|
live) würde echtes Geld bewegt. Die ungefährliche Richtung gehört in die
|
||||||
|
Voreinstellung. */
|
||||||
|
function basis() {
|
||||||
|
return process.env.PAYPAL_ENV === "live"
|
||||||
|
? "https://api-m.paypal.com"
|
||||||
|
: "https://api-m.sandbox.paypal.com";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function istLive() {
|
||||||
|
return process.env.PAYPAL_ENV === "live";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prüft, ob alles Nötige da ist. `fuer` unterscheidet die beiden Welten:
|
||||||
|
Einmalzahlungen brauchen KEINEN Plan, das Abo schon. Ohne diese
|
||||||
|
Unterscheidung wäre die Anzahlung blockiert, nur weil noch kein
|
||||||
|
Betreuungsplan angelegt wurde. */
|
||||||
|
export function fehlendeEinstellungen(fuer = "einmal") {
|
||||||
|
const fehlt = [];
|
||||||
|
if (!process.env.PAYPAL_CLIENT_ID) fehlt.push("PAYPAL_CLIENT_ID");
|
||||||
|
if (!process.env.PAYPAL_CLIENT_SECRET) fehlt.push("PAYPAL_CLIENT_SECRET");
|
||||||
|
if (fuer === "abo" && !process.env.PAYPAL_WD_PLAN_BASIS) fehlt.push("PAYPAL_WD_PLAN_BASIS");
|
||||||
|
return fehlt;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function istEingerichtet(fuer = "einmal") {
|
||||||
|
return fehlendeEinstellungen(fuer).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruefeEingerichtet(fuer = "einmal") {
|
||||||
|
const fehlt = fehlendeEinstellungen(fuer);
|
||||||
|
if (fehlt.length) throw new PayPalNichtEingerichtet(fehlt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zugangstoken zwischenspeichern. PayPal begrenzt die Anzahl der
|
||||||
|
Token-Anfragen; ohne Zwischenspeicher holt jede Zahlung ein neues. */
|
||||||
|
let token = null; // { wert, laeuftAb }
|
||||||
|
|
||||||
|
async function zugangstoken() {
|
||||||
|
pruefeEingerichtet("einmal");
|
||||||
|
if (token && token.laeuftAb > Date.now() + 60_000) return token.wert;
|
||||||
|
|
||||||
|
const anmeldung = Buffer.from(
|
||||||
|
`${process.env.PAYPAL_CLIENT_ID}:${process.env.PAYPAL_CLIENT_SECRET}`
|
||||||
|
).toString("base64");
|
||||||
|
|
||||||
|
const antwort = await fetch(`${basis()}/v1/oauth2/token`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Basic ${anmeldung}`,
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
body: "grant_type=client_credentials",
|
||||||
|
});
|
||||||
|
if (!antwort.ok) {
|
||||||
|
const txt = await antwort.text().catch(() => "");
|
||||||
|
throw new Error(`PayPal-Anmeldung fehlgeschlagen (${antwort.status}): ${txt.slice(0, 300)}`);
|
||||||
|
}
|
||||||
|
const daten = await antwort.json();
|
||||||
|
token = {
|
||||||
|
wert: daten.access_token,
|
||||||
|
laeuftAb: Date.now() + (daten.expires_in || 3600) * 1000,
|
||||||
|
};
|
||||||
|
return token.wert;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Für Tests: Zwischenspeicher leeren, damit ein Wechsel der Zugangsdaten
|
||||||
|
sofort wirkt statt erst nach einer Stunde. */
|
||||||
|
export function tokenVergessen() {
|
||||||
|
token = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ruf(pfad, optionen = {}) {
|
||||||
|
const t = await zugangstoken();
|
||||||
|
const antwort = await fetch(`${basis()}${pfad}`, {
|
||||||
|
...optionen,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${t}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(optionen.headers || {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const txt = await antwort.text();
|
||||||
|
const daten = txt ? JSON.parse(txt) : null;
|
||||||
|
if (!antwort.ok) {
|
||||||
|
const fehler = new Error(`PayPal-Anfrage fehlgeschlagen (${antwort.status}): ${txt.slice(0, 400)}`);
|
||||||
|
fehler.paypalStatus = antwort.status;
|
||||||
|
fehler.paypalBody = daten;
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
return daten;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cent -> PayPal-Format. PayPal will einen String mit genau zwei
|
||||||
|
Nachkommastellen ("300.00"). Bewusst aus der Ganzzahl gebaut statt über
|
||||||
|
toFixed() auf einer Kommazahl: (betrag/100).toFixed(2) rundet bei
|
||||||
|
krummen Beträgen unter Umständen falsch. */
|
||||||
|
export function centZuPaypal(cent) {
|
||||||
|
if (!Number.isInteger(cent) || cent < 0) {
|
||||||
|
throw new Error("Betrag muss eine nicht-negative Ganzzahl in Cent sein, war: " + cent);
|
||||||
|
}
|
||||||
|
const ganz = Math.floor(cent / 100);
|
||||||
|
const rest = String(cent % 100).padStart(2, "0");
|
||||||
|
return `${ganz}.${rest}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rückweg: PayPal liefert "300.00" -> 30000 Cent. Über Zeichenketten
|
||||||
|
gerechnet, damit nirgends eine Gleitkommazahl entsteht. */
|
||||||
|
export function paypalZuCent(wert) {
|
||||||
|
const s = String(wert ?? "0").trim();
|
||||||
|
if (!/^\d+(\.\d{1,2})?$/.test(s)) return null;
|
||||||
|
const [ganz, rest = ""] = s.split(".");
|
||||||
|
return parseInt(ganz, 10) * 100 + parseInt((rest + "00").slice(0, 2), 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------
|
||||||
|
1) EINMALZAHLUNG — Bestellung anlegen
|
||||||
|
Der Kunde wird danach zum "approve"-Link geschickt und bestätigt dort
|
||||||
|
bei PayPal. Erst der anschließende Einzug (siehe unten) bewegt Geld.
|
||||||
|
--------------------------------------------------------------------- */
|
||||||
|
export async function bestellungAnlegen({
|
||||||
|
zahlungId,
|
||||||
|
nummer,
|
||||||
|
betragCent,
|
||||||
|
waehrung = "EUR",
|
||||||
|
zweck,
|
||||||
|
rueckkehrUrl,
|
||||||
|
abbruchUrl,
|
||||||
|
sprache = "de",
|
||||||
|
}) {
|
||||||
|
pruefeEingerichtet("einmal");
|
||||||
|
|
||||||
|
const sprachKarte = { de: "de-DE", "de-CH": "de-DE", en: "en-GB", fr: "fr-FR", pt: "pt-PT" };
|
||||||
|
|
||||||
|
const daten = await ruf("/v2/checkout/orders", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
/* Schützt gegen doppelte Bestellungen, wenn dieselbe Anfrage wegen
|
||||||
|
eines Verbindungsabbruchs zweimal ankommt. PayPal liefert dann
|
||||||
|
dieselbe Bestellung zurück, statt eine zweite anzulegen. */
|
||||||
|
"PayPal-Request-Id": `wd-order-${zahlungId}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
intent: "CAPTURE", // sofort einziehen, siehe Kopf der Datei
|
||||||
|
purchase_units: [
|
||||||
|
{
|
||||||
|
/* Unsere eigene Kennung. Kommt im Webhook zurück und ist der
|
||||||
|
einzige verlässliche Weg, eine PayPal-Meldung der richtigen
|
||||||
|
Zahlung zuzuordnen — die Bestellnummer allein reicht nicht,
|
||||||
|
weil sie bei manchen Ereignissen fehlt. */
|
||||||
|
custom_id: zahlungId,
|
||||||
|
invoice_id: nummer,
|
||||||
|
description: String(zweck || "Dogfather Webdesign").slice(0, 127),
|
||||||
|
amount: {
|
||||||
|
currency_code: waehrung,
|
||||||
|
value: centZuPaypal(betragCent),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
payment_source: {
|
||||||
|
paypal: {
|
||||||
|
experience_context: {
|
||||||
|
brand_name: "Dogfather Webdesign",
|
||||||
|
locale: sprachKarte[sprache] || "de-DE",
|
||||||
|
// Keine Lieferadresse abfragen — es gibt nichts zu versenden.
|
||||||
|
shipping_preference: "NO_SHIPPING",
|
||||||
|
// Knopf heißt "Jetzt bezahlen" statt "Weiter": der Kunde soll
|
||||||
|
// wissen, dass mit dem Klick Geld fließt.
|
||||||
|
user_action: "PAY_NOW",
|
||||||
|
return_url: rueckkehrUrl,
|
||||||
|
cancel_url: abbruchUrl,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const freigabe = (daten.links || []).find((l) => l.rel === "payer-action" || l.rel === "approve");
|
||||||
|
return {
|
||||||
|
orderId: daten.id,
|
||||||
|
freigabeUrl: freigabe ? freigabe.href : null,
|
||||||
|
status: daten.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------
|
||||||
|
Einzug — HIER fließt das Geld auf Dogfathers Konto.
|
||||||
|
--------------------------------------------------------------------- */
|
||||||
|
export async function bestellungEinziehen(orderId) {
|
||||||
|
pruefeEingerichtet("einmal");
|
||||||
|
|
||||||
|
const daten = await ruf(`/v2/checkout/orders/${encodeURIComponent(orderId)}/capture`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "PayPal-Request-Id": `wd-capture-${orderId}` },
|
||||||
|
body: "{}",
|
||||||
|
});
|
||||||
|
|
||||||
|
const einheit = (daten.purchase_units || [])[0] || {};
|
||||||
|
const einzug = ((einheit.payments || {}).captures || [])[0] || {};
|
||||||
|
const aufschluesselung = (einzug.seller_receivable_breakdown || {});
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: daten.status, // COMPLETED wenn alles gut ging
|
||||||
|
captureId: einzug.id || null,
|
||||||
|
captureStatus: einzug.status || null,
|
||||||
|
bruttoCent: paypalZuCent(einzug.amount?.value),
|
||||||
|
/* net_amount ist das, was tatsächlich auf dem Konto ankommt.
|
||||||
|
paypal_fee ist die Gebühr. Beides getrennt festhalten — die Gebühr
|
||||||
|
ist Aufwand und gehört in die Buchhaltung, nicht stillschweigend
|
||||||
|
vom Rechnungsbetrag abgezogen. */
|
||||||
|
nettoCent: paypalZuCent(aufschluesselung.net_amount?.value),
|
||||||
|
gebuehrCent: paypalZuCent(aufschluesselung.paypal_fee?.value),
|
||||||
|
zahlerEmail: daten.payer?.email_address || null,
|
||||||
|
rohdaten: daten,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bestellungAbfragen(orderId) {
|
||||||
|
pruefeEingerichtet("einmal");
|
||||||
|
return ruf(`/v2/checkout/orders/${encodeURIComponent(orderId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------
|
||||||
|
2) BETREUUNGS-ABO
|
||||||
|
--------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/* Welcher Plan zu welchem Paket gehört. Ein eigener Plan je Paket, weil
|
||||||
|
PayPal den Betrag am Plan festmacht — nicht am einzelnen Abo. */
|
||||||
|
export function planFuerPaket(paket) {
|
||||||
|
const karte = {
|
||||||
|
basis: process.env.PAYPAL_WD_PLAN_BASIS,
|
||||||
|
plus: process.env.PAYPAL_WD_PLAN_PLUS,
|
||||||
|
premium: process.env.PAYPAL_WD_PLAN_PREMIUM,
|
||||||
|
};
|
||||||
|
return karte[paket] || karte.basis || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function aboAnlegen({ aboId, paket, rueckkehrUrl, abbruchUrl, sprache = "de" }) {
|
||||||
|
pruefeEingerichtet("abo");
|
||||||
|
const planId = planFuerPaket(paket);
|
||||||
|
if (!planId) throw new PayPalNichtEingerichtet([`PAYPAL_WD_PLAN_${String(paket).toUpperCase()}`]);
|
||||||
|
|
||||||
|
const sprachKarte = { de: "de-DE", "de-CH": "de-DE", en: "en-GB", fr: "fr-FR", pt: "pt-PT" };
|
||||||
|
|
||||||
|
const daten = await ruf("/v1/billing/subscriptions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "PayPal-Request-Id": `wd-abo-${aboId}` },
|
||||||
|
body: JSON.stringify({
|
||||||
|
plan_id: planId,
|
||||||
|
custom_id: aboId,
|
||||||
|
application_context: {
|
||||||
|
brand_name: "Dogfather Webdesign",
|
||||||
|
locale: sprachKarte[sprache] || "de-DE",
|
||||||
|
shipping_preference: "NO_SHIPPING",
|
||||||
|
user_action: "SUBSCRIBE_NOW",
|
||||||
|
return_url: rueckkehrUrl,
|
||||||
|
cancel_url: abbruchUrl,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const freigabe = (daten.links || []).find((l) => l.rel === "approve");
|
||||||
|
return {
|
||||||
|
subscriptionId: daten.id,
|
||||||
|
freigabeUrl: freigabe ? freigabe.href : null,
|
||||||
|
status: daten.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function aboAbfragen(subscriptionId) {
|
||||||
|
pruefeEingerichtet("abo");
|
||||||
|
return ruf(`/v1/billing/subscriptions/${encodeURIComponent(subscriptionId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Kündigung. Der Zugang bleibt bis zum Ende der bezahlten Periode —
|
||||||
|
der Kunde hat den Monat schließlich bezahlt. */
|
||||||
|
export async function aboKuendigen(subscriptionId, grund) {
|
||||||
|
pruefeEingerichtet("abo");
|
||||||
|
await ruf(`/v1/billing/subscriptions/${encodeURIComponent(subscriptionId)}/cancel`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ reason: String(grund || "Kündigung durch Kunde").slice(0, 127) }),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------
|
||||||
|
3) WEBHOOK-PRÜFUNG
|
||||||
|
|
||||||
|
Warum das nicht optional ist: Der Webhook-Endpunkt ist öffentlich
|
||||||
|
erreichbar. Ohne Signaturprüfung könnte jeder eine erfundene Meldung
|
||||||
|
"Zahlung eingegangen" schicken und damit ein Projekt als bezahlt
|
||||||
|
markieren lassen. Die Prüfung ist die einzige Stelle, die eine echte
|
||||||
|
PayPal-Meldung von einer erfundenen unterscheidet.
|
||||||
|
--------------------------------------------------------------------- */
|
||||||
|
export async function webhookEchtheitPruefen(kopfzeilen, rohkoerper) {
|
||||||
|
pruefeEingerichtet("einmal");
|
||||||
|
if (!process.env.PAYPAL_WD_WEBHOOK_ID) {
|
||||||
|
throw new PayPalNichtEingerichtet(["PAYPAL_WD_WEBHOOK_ID"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pruefung = await ruf("/v1/notifications/verify-webhook-signature", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
auth_algo: kopfzeilen["paypal-auth-algo"],
|
||||||
|
cert_url: kopfzeilen["paypal-cert-url"],
|
||||||
|
transmission_id: kopfzeilen["paypal-transmission-id"],
|
||||||
|
transmission_sig: kopfzeilen["paypal-transmission-sig"],
|
||||||
|
transmission_time: kopfzeilen["paypal-transmission-time"],
|
||||||
|
webhook_id: process.env.PAYPAL_WD_WEBHOOK_ID,
|
||||||
|
webhook_event: JSON.parse(rohkoerper),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return pruefung.verification_status === "SUCCESS";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ereignisse, auf die wir reagieren. Alles andere wird protokolliert und
|
||||||
|
ignoriert — PayPal schickt Dutzende Arten, und auf unbekannte zu
|
||||||
|
reagieren ist gefährlicher als sie liegen zu lassen. */
|
||||||
|
export const WEBHOOK_EREIGNISSE = {
|
||||||
|
"CHECKOUT.ORDER.APPROVED": "bestellung_freigegeben",
|
||||||
|
"PAYMENT.CAPTURE.COMPLETED": "zahlung_eingegangen",
|
||||||
|
"PAYMENT.CAPTURE.DENIED": "zahlung_abgelehnt",
|
||||||
|
"PAYMENT.CAPTURE.REFUNDED": "zahlung_erstattet",
|
||||||
|
"BILLING.SUBSCRIPTION.ACTIVATED": "abo_aktiv",
|
||||||
|
"BILLING.SUBSCRIPTION.CANCELLED": "abo_gekuendigt",
|
||||||
|
"BILLING.SUBSCRIPTION.SUSPENDED": "abo_pausiert",
|
||||||
|
"BILLING.SUBSCRIPTION.PAYMENT.FAILED": "abo_zahlung_fehlgeschlagen",
|
||||||
|
"PAYMENT.SALE.COMPLETED": "abo_zahlung_eingegangen",
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Kennung für ein Ereignis. Fällt PayPals eigene id aus, wird eine eigene
|
||||||
|
erzeugt — dann greift der Doppel-Schutz zwar nicht, aber die Meldung
|
||||||
|
geht wenigstens nicht verloren. */
|
||||||
|
export function ereignisId(ereignis) {
|
||||||
|
return ereignis?.id || `ohne-id-${randomUUID()}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
/* =====================================================================
|
||||||
|
routes/webdesign-anfragen.js — Projektanfragen (Masterplan S.10)
|
||||||
|
|
||||||
|
Zwei Welten in einer Datei:
|
||||||
|
- EIN öffentlicher Endpunkt (Formular absenden). Der ist von außen
|
||||||
|
erreichbar und deshalb der einzige, der wirklich gehärtet sein muss.
|
||||||
|
- Mehrere Verwaltungs-Endpunkte, die eine Team-Anmeldung verlangen.
|
||||||
|
|
||||||
|
Absichtliche Entscheidung: Das Formular erzeugt NIE einen Kunden und NIE
|
||||||
|
eine Preiszusage (Masterplan S.10: "Das Formular zeigt Richtwerte,
|
||||||
|
erstellt aber keinen automatisch verbindlichen Vertrag"). Es legt genau
|
||||||
|
eine Anfrage an, mehr nicht.
|
||||||
|
===================================================================== */
|
||||||
|
|
||||||
|
import { db } from "../db.js";
|
||||||
|
import { json } from "../lib/http.js";
|
||||||
|
import { requireSession } from "../lib/auth.js";
|
||||||
|
import { hasPermission } from "../lib/permissions.js";
|
||||||
|
import {
|
||||||
|
jetzt, neueId, naechsteNummer, text, istEmail, emailNormal,
|
||||||
|
ipHash, besucherIp, spracheOderDe, verlauf,
|
||||||
|
PAKETE, ANFRAGE_STATUS,
|
||||||
|
} from "../lib/webdesign-helfer.js";
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------
|
||||||
|
Rechteprüfung. Der Eigentümer darf immer alles — genau wie im
|
||||||
|
restlichen internen Bereich.
|
||||||
|
--------------------------------------------------------------------- */
|
||||||
|
async function darf(req, recht) {
|
||||||
|
const sitzung = await requireSession(req);
|
||||||
|
if (!sitzung) return { erlaubt: false, grund: 401, sitzung: null };
|
||||||
|
if (sitzung.isOwner) return { erlaubt: true, sitzung };
|
||||||
|
const ok = hasPermission(sitzung.permissions, sitzung.overrides, recht);
|
||||||
|
return { erlaubt: ok, grund: ok ? null : 403, sitzung };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------
|
||||||
|
Missbrauchsschutz für den öffentlichen Endpunkt.
|
||||||
|
|
||||||
|
Masterplan S.10 verlangt "Spam- und Missbrauchsschutz ohne unnötige
|
||||||
|
Hürden" — also ausdrücklich KEIN Captcha. Stattdessen drei Maßnahmen,
|
||||||
|
die für echte Menschen unsichtbar sind:
|
||||||
|
|
||||||
|
1. Honigtopf: ein Feld, das im Browser versteckt ist. Menschen füllen es
|
||||||
|
nie aus, viele automatische Skripte schon.
|
||||||
|
2. Mindest-Ausfüllzeit: das Formular schickt mit, wann es geöffnet
|
||||||
|
wurde. Wer in unter drei Sekunden durch ein mehrstufiges Formular
|
||||||
|
kommt, hat es nicht gelesen.
|
||||||
|
3. Mengenbegrenzung pro Absender-IP.
|
||||||
|
|
||||||
|
Wichtig: Die ersten beiden sind höflich — sie melden Erfolg, speichern
|
||||||
|
aber nichts. Ein Skript, dem man sagt "erkannt", probiert es anders.
|
||||||
|
Eines, das "gespeichert" liest, hört auf.
|
||||||
|
--------------------------------------------------------------------- */
|
||||||
|
const ANFRAGE_MAX_PRO_STUNDE = 5;
|
||||||
|
const MINDEST_SEKUNDEN = 3;
|
||||||
|
|
||||||
|
function zuVieleAnfragen(hash) {
|
||||||
|
if (!hash) return false;
|
||||||
|
const grenze = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||||||
|
const zeile = db
|
||||||
|
.prepare(`SELECT COUNT(*) AS anzahl FROM wd_anfragen WHERE ip_hash = ? AND erstellt_am > ?`)
|
||||||
|
.get(hash, grenze);
|
||||||
|
return (zeile?.anzahl || 0) >= ANFRAGE_MAX_PRO_STUNDE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------
|
||||||
|
ÖFFENTLICH: Anfrage absenden
|
||||||
|
--------------------------------------------------------------------- */
|
||||||
|
export async function anfrageAbsenden(req, res) {
|
||||||
|
const koerper = req.body || {};
|
||||||
|
|
||||||
|
// --- 1. Honigtopf ---
|
||||||
|
if (text(koerper.website, 200)) {
|
||||||
|
// Absichtlich "ok" melden, siehe Begründung oben.
|
||||||
|
return json(res, { ok: true, nummer: null, hinweis: "angenommen" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2. Ausfüllzeit ---
|
||||||
|
const geoeffnet = Number(koerper.geoeffnetAm);
|
||||||
|
if (Number.isFinite(geoeffnet) && geoeffnet > 0) {
|
||||||
|
const sekunden = (Date.now() - geoeffnet) / 1000;
|
||||||
|
if (sekunden < MINDEST_SEKUNDEN) {
|
||||||
|
return json(res, { ok: true, nummer: null, hinweis: "angenommen" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3. Mengenbegrenzung ---
|
||||||
|
const hash = ipHash(besucherIp(req));
|
||||||
|
if (zuVieleAnfragen(hash)) {
|
||||||
|
return json(res, {
|
||||||
|
ok: false,
|
||||||
|
error: "Es sind schon mehrere Anfragen von dir eingegangen. Bitte melde dich direkt per E-Mail, dann klären wir das persönlich.",
|
||||||
|
}, 429);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Pflichtangaben (Masterplan S.10) ---
|
||||||
|
const name = text(koerper.name, 120);
|
||||||
|
const email = emailNormal(koerper.email);
|
||||||
|
const paket = text(koerper.paket, 30);
|
||||||
|
const datenschutz = koerper.datenschutz === true || koerper.datenschutz === "ja";
|
||||||
|
|
||||||
|
const fehler = {};
|
||||||
|
if (!name) fehler.name = "Bitte gib deinen Namen an.";
|
||||||
|
if (!email) fehler.email = "Bitte gib deine E-Mail-Adresse an.";
|
||||||
|
else if (!istEmail(email)) fehler.email = "Diese E-Mail-Adresse sieht nicht gültig aus.";
|
||||||
|
if (!paket || !PAKETE.includes(paket)) fehler.paket = "Bitte wähle aus, worum es ungefähr geht.";
|
||||||
|
if (!text(koerper.ziel, 4000)) fehler.ziel = "Bitte beschreib kurz, was die Seite erreichen soll.";
|
||||||
|
if (!datenschutz) fehler.datenschutz = "Ohne diese Zustimmung dürfen wir deine Angaben nicht speichern.";
|
||||||
|
|
||||||
|
if (Object.keys(fehler).length) {
|
||||||
|
/* Alle Fehler auf einmal zurückgeben, nicht einen nach dem anderen —
|
||||||
|
sonst schickt jemand das Formular fünfmal ab und findet jedes Mal
|
||||||
|
einen neuen Fehler. */
|
||||||
|
return json(res, { ok: false, error: "Bitte prüfe die markierten Felder.", felder: fehler }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = neueId();
|
||||||
|
const nummer = naechsteNummer("wd_anfragen", "A");
|
||||||
|
const zeit = jetzt();
|
||||||
|
|
||||||
|
/* Funktionen und kategoriespezifische Antworten als JSON. Begrenzt
|
||||||
|
gespeichert, damit niemand über ein Array die Datenbank vollschreibt. */
|
||||||
|
const funktionen = Array.isArray(koerper.funktionen)
|
||||||
|
? koerper.funktionen.slice(0, 40).map((f) => text(f, 80)).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
let details = {};
|
||||||
|
if (koerper.details && typeof koerper.details === "object" && !Array.isArray(koerper.details)) {
|
||||||
|
for (const [k, v] of Object.entries(koerper.details).slice(0, 40)) {
|
||||||
|
details[text(k, 60)] = text(typeof v === "boolean" ? (v ? "ja" : "nein") : v, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO wd_anfragen (
|
||||||
|
id, nummer, name, email, telefon, kontaktweg,
|
||||||
|
paket, ziel, zielgruppe, wirkung, seiten, funktionen, details,
|
||||||
|
hat_texte, hat_bilder, hat_logo, hat_domain,
|
||||||
|
budget, termin, bemerkung,
|
||||||
|
status, sprache, erstellt_am, ip_hash
|
||||||
|
) VALUES (?,?,?,?,?,?, ?,?,?,?,?,?,?, ?,?,?,?, ?,?,?, ?,?,?,?)`
|
||||||
|
).run(
|
||||||
|
id, nummer, name, email,
|
||||||
|
text(koerper.telefon, 60) || null,
|
||||||
|
["email", "telefon"].includes(text(koerper.kontaktweg, 20)) ? text(koerper.kontaktweg, 20) : "email",
|
||||||
|
paket,
|
||||||
|
text(koerper.ziel, 4000),
|
||||||
|
text(koerper.zielgruppe, 1000) || null,
|
||||||
|
text(koerper.wirkung, 1000) || null,
|
||||||
|
text(koerper.seiten, 2000) || null,
|
||||||
|
JSON.stringify(funktionen),
|
||||||
|
JSON.stringify(details),
|
||||||
|
text(koerper.hatTexte, 20) || null,
|
||||||
|
text(koerper.hatBilder, 20) || null,
|
||||||
|
text(koerper.hatLogo, 20) || null,
|
||||||
|
text(koerper.hatDomain, 20) || null,
|
||||||
|
text(koerper.budget, 60) || null,
|
||||||
|
text(koerper.termin, 60) || null,
|
||||||
|
text(koerper.bemerkung, 4000) || null,
|
||||||
|
"neu",
|
||||||
|
spracheOderDe(koerper.sprache),
|
||||||
|
zeit,
|
||||||
|
hash
|
||||||
|
);
|
||||||
|
|
||||||
|
verlauf("anfrage", id, "angelegt", "system", { nummer, paket });
|
||||||
|
|
||||||
|
/* Benachrichtigung an Dogfather. Läuft bewusst NACH dem Speichern und
|
||||||
|
ohne await auf den Erfolg: die Anfrage ist bereits sicher in der
|
||||||
|
Datenbank, und ein Ausfall des Mail-Versands darf den Kunden nicht
|
||||||
|
eine Fehlermeldung sehen lassen für etwas, das geklappt hat. */
|
||||||
|
benachrichtige(nummer, name, email, paket).catch((e) =>
|
||||||
|
console.error("[webdesign] Benachrichtigung fehlgeschlagen:", e?.message || e)
|
||||||
|
);
|
||||||
|
|
||||||
|
return json(res, {
|
||||||
|
ok: true,
|
||||||
|
nummer,
|
||||||
|
/* Die Zusammenfassung kommt vom Server zurück, damit der Kunde
|
||||||
|
schwarz auf weiß sieht, was tatsächlich gespeichert wurde — nicht
|
||||||
|
das, was sein Browser glaubt abgeschickt zu haben. */
|
||||||
|
zusammenfassung: { nummer, name, email, paket, eingegangen: zeit },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Discord-Benachrichtigung über den bereits vorhandenen Webhook-Weg.
|
||||||
|
Bewusst OHNE Kundendaten im Text: die Meldung sagt nur, dass etwas da
|
||||||
|
ist. Wer die Anfrage lesen will, meldet sich in der Verwaltung an —
|
||||||
|
ein Discord-Kanal ist kein sicherer Ort für Kontaktdaten. */
|
||||||
|
async function benachrichtige(nummer, name, email, paket) {
|
||||||
|
const url = process.env.DISCORD_WEBHOOK_WEBDESIGN;
|
||||||
|
if (!url) return;
|
||||||
|
await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
content: `🖥️ Neue Webdesign-Anfrage **${nummer}** (${paket}). Details in der Verwaltung.`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------
|
||||||
|
VERWALTUNG
|
||||||
|
--------------------------------------------------------------------- */
|
||||||
|
export async function anfragenListe(req, res) {
|
||||||
|
const p = await darf(req, "WD_ANFRAGEN_VIEW");
|
||||||
|
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
|
||||||
|
|
||||||
|
const status = text(req.query.status, 30);
|
||||||
|
const paket = text(req.query.paket, 30);
|
||||||
|
const archiviert = req.query.archiviert === "1" ? 1 : 0;
|
||||||
|
const suche = text(req.query.suche, 100);
|
||||||
|
|
||||||
|
const bedingungen = ["archiviert = ?"];
|
||||||
|
const werte = [archiviert];
|
||||||
|
if (status && ANFRAGE_STATUS.includes(status)) { bedingungen.push("status = ?"); werte.push(status); }
|
||||||
|
if (paket && PAKETE.includes(paket)) { bedingungen.push("paket = ?"); werte.push(paket); }
|
||||||
|
if (suche) {
|
||||||
|
bedingungen.push("(name LIKE ? OR email LIKE ? OR nummer LIKE ?)");
|
||||||
|
werte.push(`%${suche}%`, `%${suche}%`, `%${suche}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const zeilen = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, nummer, name, email, paket, status, budget, termin, sprache, erstellt_am, kunde_id
|
||||||
|
FROM wd_anfragen WHERE ${bedingungen.join(" AND ")}
|
||||||
|
ORDER BY erstellt_am DESC LIMIT 300`
|
||||||
|
)
|
||||||
|
.all(...werte);
|
||||||
|
|
||||||
|
/* Zähler je Status für die Filterleiste — in einer Abfrage statt sieben. */
|
||||||
|
const zaehler = {};
|
||||||
|
for (const z of db.prepare(`SELECT status, COUNT(*) AS n FROM wd_anfragen WHERE archiviert = 0 GROUP BY status`).all()) {
|
||||||
|
zaehler[z.status] = z.n;
|
||||||
|
}
|
||||||
|
|
||||||
|
return json(res, { ok: true, anfragen: zeilen, zaehler });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function anfrageDetail(req, res) {
|
||||||
|
const p = await darf(req, "WD_ANFRAGEN_VIEW");
|
||||||
|
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
|
||||||
|
|
||||||
|
const zeile = db.prepare(`SELECT * FROM wd_anfragen WHERE id = ?`).get(text(req.params.id, 64));
|
||||||
|
if (!zeile) return json(res, { ok: false, error: "Anfrage nicht gefunden." }, 404);
|
||||||
|
|
||||||
|
/* JSON-Felder aufgelöst zurückgeben, damit die Oberfläche nichts
|
||||||
|
nachparsen muss (und ein kaputter Eintrag dort keinen Absturz auslöst). */
|
||||||
|
const sicherParsen = (s, standard) => { try { return JSON.parse(s); } catch { return standard; } };
|
||||||
|
zeile.funktionen = sicherParsen(zeile.funktionen, []);
|
||||||
|
zeile.details = sicherParsen(zeile.details, {});
|
||||||
|
|
||||||
|
const notizen = db
|
||||||
|
.prepare(`SELECT * FROM wd_anfrage_notizen WHERE anfrage_id = ? ORDER BY erstellt_am ASC`)
|
||||||
|
.all(zeile.id);
|
||||||
|
|
||||||
|
return json(res, { ok: true, anfrage: zeile, notizen });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function anfrageStatus(req, res) {
|
||||||
|
const p = await darf(req, "WD_ANFRAGEN_MANAGE");
|
||||||
|
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
|
||||||
|
|
||||||
|
const id = text(req.params.id, 64);
|
||||||
|
const neu = text(req.body?.status, 30);
|
||||||
|
if (!ANFRAGE_STATUS.includes(neu)) {
|
||||||
|
return json(res, { ok: false, error: "Unbekannter Status." }, 400);
|
||||||
|
}
|
||||||
|
const alt = db.prepare(`SELECT status FROM wd_anfragen WHERE id = ?`).get(id);
|
||||||
|
if (!alt) return json(res, { ok: false, error: "Anfrage nicht gefunden." }, 404);
|
||||||
|
|
||||||
|
db.prepare(`UPDATE wd_anfragen SET status = ?, aktualisiert_am = ? WHERE id = ?`).run(neu, jetzt(), id);
|
||||||
|
verlauf("anfrage", id, "status_geaendert", p.sitzung.actor, { von: alt.status, nach: neu });
|
||||||
|
|
||||||
|
return json(res, { ok: true, status: neu });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function anfrageArchivieren(req, res) {
|
||||||
|
const p = await darf(req, "WD_ANFRAGEN_MANAGE");
|
||||||
|
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
|
||||||
|
|
||||||
|
const id = text(req.params.id, 64);
|
||||||
|
/* Archivieren statt löschen (Masterplan S.13). Der Datensatz bleibt
|
||||||
|
vollständig erhalten und lässt sich jederzeit zurückholen. */
|
||||||
|
const zurueck = req.body?.zurueck === true;
|
||||||
|
const treffer = db
|
||||||
|
.prepare(`UPDATE wd_anfragen SET archiviert = ?, aktualisiert_am = ? WHERE id = ?`)
|
||||||
|
.run(zurueck ? 0 : 1, jetzt(), id);
|
||||||
|
if (!treffer.changes) return json(res, { ok: false, error: "Anfrage nicht gefunden." }, 404);
|
||||||
|
|
||||||
|
verlauf("anfrage", id, zurueck ? "wiederhergestellt" : "archiviert", p.sitzung.actor, null);
|
||||||
|
return json(res, { ok: true, archiviert: !zurueck });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function anfrageNotiz(req, res) {
|
||||||
|
const p = await darf(req, "WD_ANFRAGEN_MANAGE");
|
||||||
|
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
|
||||||
|
|
||||||
|
const anfrageId = text(req.params.id, 64);
|
||||||
|
const inhalt = text(req.body?.text, 4000);
|
||||||
|
if (!inhalt) return json(res, { ok: false, error: "Die Notiz ist leer." }, 400);
|
||||||
|
const da = db.prepare(`SELECT 1 FROM wd_anfragen WHERE id = ?`).get(anfrageId);
|
||||||
|
if (!da) return json(res, { ok: false, error: "Anfrage nicht gefunden." }, 404);
|
||||||
|
|
||||||
|
const id = neueId();
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO wd_anfrage_notizen (id, anfrage_id, autor, text, intern, erstellt_am)
|
||||||
|
VALUES (?,?,?,?,?,?)`
|
||||||
|
).run(id, anfrageId, p.sitzung.actor, inhalt, 1, jetzt());
|
||||||
|
|
||||||
|
verlauf("anfrage", anfrageId, "notiz_ergaenzt", p.sitzung.actor, null);
|
||||||
|
return json(res, { ok: true, id });
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
/* =====================================================================
|
||||||
|
test-webdesign-anfragen.mjs — Selbsttest des Anfrage-Endpunkts.
|
||||||
|
|
||||||
|
Läuft gegen eine Wegwerf-Datenbank im Arbeitsspeicher. Prüft vor allem
|
||||||
|
die Fälle, die man beim Klicken NICHT sieht: Spamschutz, Pflichtfelder,
|
||||||
|
Längenbegrenzung, und dass die Verwaltungs-Endpunkte ohne Anmeldung
|
||||||
|
wirklich dichtmachen.
|
||||||
|
|
||||||
|
Aufruf (aus server-internal/): node test-webdesign-anfragen.mjs
|
||||||
|
===================================================================== */
|
||||||
|
|
||||||
|
import { mkdtempSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
/* Eigene Datenbankdatei in einem Temporärordner: db.js liest DB_PATH beim
|
||||||
|
Import. Eine echte Datei statt ":memory:", weil better-sqlite3 mit WAL
|
||||||
|
im Arbeitsspeicher nicht arbeitet. */
|
||||||
|
process.env.DB_PATH = join(mkdtempSync(join(tmpdir(), "wd-test-")), "test.db");
|
||||||
|
|
||||||
|
const { initDb, db } = await import("./db.js");
|
||||||
|
initDb();
|
||||||
|
|
||||||
|
const { anfrageAbsenden, anfragenListe, anfrageDetail, anfrageStatus, anfrageArchivieren } =
|
||||||
|
await import("./routes/webdesign-anfragen.js");
|
||||||
|
|
||||||
|
let ok = 0, schlecht = 0;
|
||||||
|
function pruefe(name, bedingung, zusatz = "") {
|
||||||
|
if (bedingung) { ok++; console.log(" ok " + name); }
|
||||||
|
else { schlecht++; console.log(" FEHLT " + name + " " + zusatz); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Minimaler Ersatz für Express-Request/Response. */
|
||||||
|
function antwortAttrappe() {
|
||||||
|
const a = { code: 200, daten: null };
|
||||||
|
a.status = (c) => { a.code = c; return a; };
|
||||||
|
a.json = (d) => { a.daten = d; return a; };
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
function anfrageAttrappe(koerper, { ip = "203.0.113.5", auth = null, params = {}, query = {} } = {}) {
|
||||||
|
return {
|
||||||
|
body: koerper,
|
||||||
|
params,
|
||||||
|
query,
|
||||||
|
headers: {
|
||||||
|
"cf-connecting-ip": ip,
|
||||||
|
...(auth ? { authorization: "Bearer " + auth } : {}),
|
||||||
|
},
|
||||||
|
socket: { remoteAddress: ip },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const gueltig = () => ({
|
||||||
|
name: "Testfirma Meier",
|
||||||
|
email: "[email protected]",
|
||||||
|
paket: "website",
|
||||||
|
ziel: "Wir wollen endlich online gefunden werden und Anfragen bekommen.",
|
||||||
|
datenschutz: true,
|
||||||
|
// Formular vor über einer Minute geöffnet -> Zeitprüfung ist zufrieden
|
||||||
|
geoeffnetAm: Date.now() - 60000,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("\n=== Anfrageformular ===\n");
|
||||||
|
|
||||||
|
/* ---------- 1. Gültige Anfrage ---------- */
|
||||||
|
console.log("1. Gueltige Anfrage");
|
||||||
|
{
|
||||||
|
const res = antwortAttrappe();
|
||||||
|
await anfrageAbsenden(anfrageAttrappe(gueltig()), res);
|
||||||
|
pruefe("wird angenommen", res.daten?.ok === true, JSON.stringify(res.daten));
|
||||||
|
pruefe("Nummer im Format A-JJMM-0001", /^A-\d{4}-\d{4}$/.test(res.daten?.nummer || ""), res.daten?.nummer);
|
||||||
|
pruefe("Zusammenfassung kommt zurueck", !!res.daten?.zusammenfassung?.eingegangen);
|
||||||
|
|
||||||
|
const zeile = db.prepare(`SELECT * FROM wd_anfragen WHERE nummer = ?`).get(res.daten.nummer);
|
||||||
|
pruefe("liegt in der Datenbank", !!zeile);
|
||||||
|
pruefe("Status startet auf 'neu'", zeile?.status === "neu");
|
||||||
|
pruefe("E-Mail wird kleingeschrieben gespeichert", zeile?.email === "[email protected]");
|
||||||
|
pruefe("IP wird nur als Hash gespeichert",
|
||||||
|
!!zeile?.ip_hash && !String(zeile.ip_hash).includes("203.0.113"),
|
||||||
|
"-> die echte IP darf nirgends stehen");
|
||||||
|
pruefe("Aenderungsverlauf wurde geschrieben",
|
||||||
|
!!db.prepare(`SELECT 1 FROM wd_verlauf WHERE bereich='anfrage' AND objekt_id=?`).get(zeile.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 2. Pflichtfelder ---------- */
|
||||||
|
console.log("\n2. Pflichtfelder");
|
||||||
|
{
|
||||||
|
const faelle = [
|
||||||
|
["ohne Name", { name: "" }, "name"],
|
||||||
|
["ohne E-Mail", { email: "" }, "email"],
|
||||||
|
["unsinnige E-Mail", { email: "keine-email" }, "email"],
|
||||||
|
["ohne Paket", { paket: "" }, "paket"],
|
||||||
|
["unbekanntes Paket", { paket: "raumschiff" }, "paket"],
|
||||||
|
["ohne Ziel", { ziel: "" }, "ziel"],
|
||||||
|
["ohne Datenschutz-Zustimmung", { datenschutz: false }, "datenschutz"],
|
||||||
|
];
|
||||||
|
for (const [name, ueberschreibung, feld] of faelle) {
|
||||||
|
const res = antwortAttrappe();
|
||||||
|
await anfrageAbsenden(anfrageAttrappe({ ...gueltig(), ...ueberschreibung }, { ip: "203.0.113.6" }), res);
|
||||||
|
pruefe(name + " -> abgelehnt", res.code === 400 && !!res.daten?.felder?.[feld],
|
||||||
|
JSON.stringify(res.daten?.felder));
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = antwortAttrappe();
|
||||||
|
await anfrageAbsenden(anfrageAttrappe({ name: "", email: "", paket: "", ziel: "", datenschutz: false }, { ip: "203.0.113.7" }), res);
|
||||||
|
pruefe("alle Fehler kommen auf einmal", Object.keys(res.daten?.felder || {}).length >= 5,
|
||||||
|
"-> sonst schickt man das Formular fuenfmal ab");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 3. Spamschutz ---------- */
|
||||||
|
console.log("\n3. Spamschutz");
|
||||||
|
{
|
||||||
|
const vorher = db.prepare(`SELECT COUNT(*) AS n FROM wd_anfragen`).get().n;
|
||||||
|
|
||||||
|
const res1 = antwortAttrappe();
|
||||||
|
await anfrageAbsenden(anfrageAttrappe({ ...gueltig(), website: "http://spam.example" }, { ip: "203.0.113.8" }), res1);
|
||||||
|
pruefe("Honigtopf: meldet Erfolg", res1.daten?.ok === true);
|
||||||
|
pruefe("Honigtopf: speichert aber NICHTS",
|
||||||
|
db.prepare(`SELECT COUNT(*) AS n FROM wd_anfragen`).get().n === vorher,
|
||||||
|
"-> absichtlich hoeflich, damit das Skript nicht umbaut");
|
||||||
|
|
||||||
|
const res2 = antwortAttrappe();
|
||||||
|
await anfrageAbsenden(anfrageAttrappe({ ...gueltig(), geoeffnetAm: Date.now() - 500 }, { ip: "203.0.113.9" }), res2);
|
||||||
|
pruefe("zu schnell ausgefuellt: speichert nichts",
|
||||||
|
db.prepare(`SELECT COUNT(*) AS n FROM wd_anfragen`).get().n === vorher);
|
||||||
|
|
||||||
|
// Mengenbegrenzung: dieselbe IP mehrfach
|
||||||
|
let letzte = null;
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
letzte = antwortAttrappe();
|
||||||
|
await anfrageAbsenden(anfrageAttrappe(gueltig(), { ip: "203.0.113.99" }), letzte);
|
||||||
|
}
|
||||||
|
pruefe("nach fuenf Anfragen pro Stunde wird gebremst", letzte.code === 429, "war " + letzte.code);
|
||||||
|
|
||||||
|
const andere = antwortAttrappe();
|
||||||
|
await anfrageAbsenden(anfrageAttrappe(gueltig(), { ip: "198.51.100.1" }), andere);
|
||||||
|
pruefe("andere Absender sind NICHT mitgesperrt", andere.daten?.ok === true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 4. Laengen und Sonderzeichen ---------- */
|
||||||
|
console.log("\n4. Laengen und Sonderzeichen");
|
||||||
|
{
|
||||||
|
const res = antwortAttrappe();
|
||||||
|
await anfrageAbsenden(anfrageAttrappe({
|
||||||
|
...gueltig(),
|
||||||
|
name: "N".repeat(5000),
|
||||||
|
ziel: "Z".repeat(50000),
|
||||||
|
funktionen: Array.from({ length: 500 }, (_, i) => "funktion-" + i),
|
||||||
|
details: Object.fromEntries(Array.from({ length: 500 }, (_, i) => ["k" + i, "v".repeat(9000)])),
|
||||||
|
}, { ip: "198.51.100.2" }), res);
|
||||||
|
pruefe("ueberlange Eingaben werden angenommen und gekuerzt", res.daten?.ok === true);
|
||||||
|
|
||||||
|
const zeile = db.prepare(`SELECT * FROM wd_anfragen WHERE nummer = ?`).get(res.daten.nummer);
|
||||||
|
pruefe("Name auf 120 Zeichen begrenzt", zeile.name.length === 120, String(zeile.name.length));
|
||||||
|
pruefe("Ziel auf 4000 Zeichen begrenzt", zeile.ziel.length === 4000, String(zeile.ziel.length));
|
||||||
|
pruefe("Funktionsliste auf 40 Eintraege begrenzt", JSON.parse(zeile.funktionen).length === 40);
|
||||||
|
pruefe("Detailfelder auf 40 begrenzt", Object.keys(JSON.parse(zeile.details)).length === 40);
|
||||||
|
|
||||||
|
const res2 = antwortAttrappe();
|
||||||
|
await anfrageAbsenden(anfrageAttrappe({
|
||||||
|
...gueltig(),
|
||||||
|
name: "Bösewicht <script>alert(1)</script>",
|
||||||
|
ziel: "Zeile1\nZeile2\tmit Tabulator",
|
||||||
|
}, { ip: "198.51.100.3" }), res2);
|
||||||
|
const z2 = db.prepare(`SELECT * FROM wd_anfragen WHERE nummer = ?`).get(res2.daten.nummer);
|
||||||
|
pruefe("HTML wird als Text gespeichert, nicht ausgefuehrt", z2.name.includes("<script>"),
|
||||||
|
"-> Entschaerfung passiert bei der Anzeige, nicht beim Speichern");
|
||||||
|
pruefe("Zeilenumbruch und Tabulator bleiben erhalten",
|
||||||
|
z2.ziel.includes("\n") && z2.ziel.includes("\t"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 5. Verwaltung ist ohne Anmeldung dicht ---------- */
|
||||||
|
console.log("\n5. Verwaltung ohne Anmeldung");
|
||||||
|
{
|
||||||
|
for (const [name, fn, req] of [
|
||||||
|
["Liste", anfragenListe, anfrageAttrappe(null, { query: {} })],
|
||||||
|
["Detail", anfrageDetail, anfrageAttrappe(null, { params: { id: "x" } })],
|
||||||
|
["Status aendern", anfrageStatus, anfrageAttrappe({ status: "angebot" }, { params: { id: "x" } })],
|
||||||
|
["Archivieren", anfrageArchivieren, anfrageAttrappe({}, { params: { id: "x" } })],
|
||||||
|
]) {
|
||||||
|
const res = antwortAttrappe();
|
||||||
|
await fn(req, res);
|
||||||
|
pruefe(name + " -> 401 ohne Anmeldung", res.code === 401, "war " + res.code);
|
||||||
|
pruefe(name + " -> keine Daten im Fehlerfall", !res.daten?.anfragen && !res.daten?.anfrage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = antwortAttrappe();
|
||||||
|
await anfragenListe(anfrageAttrappe(null, { auth: "voellig-erfundenes-token" }), res);
|
||||||
|
pruefe("erfundenes Token wird abgelehnt", res.code === 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n=== ${ok} bestanden, ${schlecht} gescheitert ===\n`);
|
||||||
|
process.exit(schlecht === 0 ? 0 : 1);
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
/* =====================================================================
|
||||||
|
test-webdesign-paypal.mjs — Selbsttest der PayPal-Anbindung.
|
||||||
|
|
||||||
|
Läuft OHNE echte PayPal-Zugangsdaten: ein nachgebauter PayPal-Server
|
||||||
|
antwortet auf 127.0.0.1 statt api-m.paypal.com. Dadurch lässt sich
|
||||||
|
alles prüfen, was schiefgehen kann, ohne dass Geld bewegt wird und ohne
|
||||||
|
dass Filipe vorher ein Konto einrichten muss.
|
||||||
|
|
||||||
|
Aufruf: node test-webdesign-paypal.mjs
|
||||||
|
===================================================================== */
|
||||||
|
|
||||||
|
/* Bewusst OHNE Fremdpakete (kein express): server-internal braucht
|
||||||
|
better-sqlite3, das auf Windows einen C++-Compiler zum Uebersetzen
|
||||||
|
verlangt. Ein Test, der sich nur mit vollstaendig eingerichteter
|
||||||
|
Entwicklungsumgebung ausfuehren laesst, wird nicht ausgefuehrt --
|
||||||
|
und damit ist er wertlos. node:http reicht hier voellig. */
|
||||||
|
import { createServer } from "node:http";
|
||||||
|
|
||||||
|
let bestanden = 0, gescheitert = 0;
|
||||||
|
function pruefe(name, bedingung, zusatz = "") {
|
||||||
|
if (bedingung) { bestanden++; console.log(" ok " + name); }
|
||||||
|
else { gescheitert++; console.log(" FEHLT " + name + " " + zusatz); }
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n=== PayPal-Anbindung Webdesign ===\n");
|
||||||
|
|
||||||
|
/* ---------- 1. Rechnen mit Geld ---------- */
|
||||||
|
console.log("1. Betraege");
|
||||||
|
const { centZuPaypal, paypalZuCent } = await import("./lib/webdesign-paypal.js");
|
||||||
|
{
|
||||||
|
const faelle = [
|
||||||
|
[30000, "300.00"], [50000, "500.00"], [100000, "1000.00"],
|
||||||
|
[4900, "49.00"], [1, "0.01"], [99, "0.99"], [100, "1.00"],
|
||||||
|
[123456, "1234.56"], [0, "0.00"],
|
||||||
|
];
|
||||||
|
let alle = true;
|
||||||
|
for (const [cent, erwartet] of faelle) {
|
||||||
|
if (centZuPaypal(cent) !== erwartet) { alle = false; console.log(` ${cent} -> ${centZuPaypal(cent)}, erwartet ${erwartet}`); }
|
||||||
|
}
|
||||||
|
pruefe("Cent -> PayPal-Format", alle);
|
||||||
|
|
||||||
|
let rueck = true;
|
||||||
|
for (const [cent, txt] of faelle) if (paypalZuCent(txt) !== cent) { rueck = false; console.log(` ${txt} -> ${paypalZuCent(txt)}, erwartet ${cent}`); }
|
||||||
|
pruefe("PayPal-Format -> Cent", rueck);
|
||||||
|
|
||||||
|
/* Der eigentliche Grund fuer die Ganzzahl-Rechnung: mit Gleitkomma
|
||||||
|
entstehen genau hier falsche Betraege. */
|
||||||
|
let rundung = true;
|
||||||
|
for (let c = 0; c <= 200000; c += 7) {
|
||||||
|
if (paypalZuCent(centZuPaypal(c)) !== c) { rundung = false; console.log(" Rundungsfehler bei " + c); break; }
|
||||||
|
}
|
||||||
|
pruefe("Hin- und Rueckrechnung ueber 28.000 Betraege verlustfrei", rundung);
|
||||||
|
|
||||||
|
let wirft = false;
|
||||||
|
try { centZuPaypal(30.5); } catch { wirft = true; }
|
||||||
|
pruefe("Kommazahl wird abgelehnt", wirft);
|
||||||
|
wirft = false;
|
||||||
|
try { centZuPaypal(-100); } catch { wirft = true; }
|
||||||
|
pruefe("negativer Betrag wird abgelehnt", wirft);
|
||||||
|
pruefe("unsinniges PayPal-Format ergibt null", paypalZuCent("abc") === null && paypalZuCent("1.234") === null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 2. Fail closed ---------- */
|
||||||
|
console.log("\n2. Ohne Zugangsdaten wird gesperrt");
|
||||||
|
{
|
||||||
|
const gemerkt = { ...process.env };
|
||||||
|
delete process.env.PAYPAL_CLIENT_ID;
|
||||||
|
delete process.env.PAYPAL_CLIENT_SECRET;
|
||||||
|
|
||||||
|
const m = await import("./lib/webdesign-paypal.js");
|
||||||
|
pruefe("istEingerichtet meldet false", m.istEingerichtet() === false);
|
||||||
|
pruefe("fehlende Werte werden benannt",
|
||||||
|
m.fehlendeEinstellungen().includes("PAYPAL_CLIENT_ID"));
|
||||||
|
|
||||||
|
let fehler = null;
|
||||||
|
try { await m.bestellungAnlegen({ zahlungId: "x", nummer: "Z-1", betragCent: 100 }); }
|
||||||
|
catch (e) { fehler = e; }
|
||||||
|
pruefe("Bestellung anlegen wirft statt still zu scheitern", fehler?.code === "PAYPAL_NICHT_EINGERICHTET");
|
||||||
|
pruefe("Fehlermeldung nennt die fehlende Einstellung", /PAYPAL_CLIENT_ID/.test(fehler?.message || ""));
|
||||||
|
|
||||||
|
let fehler2 = null;
|
||||||
|
try { await m.bestellungEinziehen("ORDER-1"); } catch (e) { fehler2 = e; }
|
||||||
|
pruefe("Einzug wirft ebenfalls", fehler2?.code === "PAYPAL_NICHT_EINGERICHTET");
|
||||||
|
|
||||||
|
Object.assign(process.env, gemerkt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 3. Gegen einen nachgebauten PayPal-Server ---------- */
|
||||||
|
console.log("\n3. Vollstaendiger Ablauf gegen einen nachgebauten PayPal-Server");
|
||||||
|
{
|
||||||
|
const gesehen = { requestIds: [], intents: [], betraege: [] };
|
||||||
|
|
||||||
|
/* Nachgebauter PayPal-Server. Antwortet nur auf die Pfade, die wir
|
||||||
|
tatsaechlich aufrufen -- alles andere gibt 404, damit ein Tippfehler
|
||||||
|
im Pfad sofort auffaellt statt still ins Leere zu laufen. */
|
||||||
|
const server = createServer((req, res) => {
|
||||||
|
let koerper = "";
|
||||||
|
req.on("data", (stueck) => { koerper += stueck; });
|
||||||
|
req.on("end", () => {
|
||||||
|
const antworte = (code, daten) => {
|
||||||
|
res.writeHead(code, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify(daten));
|
||||||
|
};
|
||||||
|
/* Nur JSON auspacken. Der Token-Aufruf schickt bewusst
|
||||||
|
form-urlencoded ("grant_type=client_credentials") -- ein
|
||||||
|
bedingungsloses JSON.parse ist hier genau der Fehler, den der
|
||||||
|
echte PayPal-Server auch machen wuerde. */
|
||||||
|
const istJson = (req.headers["content-type"] || "").includes("json");
|
||||||
|
let body = {};
|
||||||
|
if (istJson && koerper) {
|
||||||
|
try { body = JSON.parse(koerper); }
|
||||||
|
catch { return antworte(400, { error: "kein gueltiges JSON" }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.url === "/v1/oauth2/token") {
|
||||||
|
const auth = req.headers.authorization || "";
|
||||||
|
if (!auth.startsWith("Basic ")) return antworte(401, { error: "kein Basic" });
|
||||||
|
return antworte(200, { access_token: "test-token", expires_in: 3600 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.url === "/v2/checkout/orders" && req.method === "POST") {
|
||||||
|
gesehen.requestIds.push(req.headers["paypal-request-id"]);
|
||||||
|
gesehen.intents.push(body.intent);
|
||||||
|
gesehen.betraege.push(body.purchase_units[0].amount.value);
|
||||||
|
return antworte(201, {
|
||||||
|
id: "ORDER-TEST-1",
|
||||||
|
status: "PAYER_ACTION_REQUIRED",
|
||||||
|
links: [
|
||||||
|
{ rel: "self", href: "x" },
|
||||||
|
{ rel: "payer-action", href: "https://sandbox.paypal.com/checkoutnow?token=ORDER-TEST-1" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\/v2\/checkout\/orders\/[^/]+\/capture$/.test(req.url)) {
|
||||||
|
return antworte(200, {
|
||||||
|
id: "ORDER-TEST-1",
|
||||||
|
status: "COMPLETED",
|
||||||
|
payer: { email_address: "[email protected]" },
|
||||||
|
purchase_units: [{
|
||||||
|
payments: {
|
||||||
|
captures: [{
|
||||||
|
id: "CAPTURE-1",
|
||||||
|
status: "COMPLETED",
|
||||||
|
amount: { currency_code: "EUR", value: "300.00" },
|
||||||
|
seller_receivable_breakdown: {
|
||||||
|
gross_amount: { value: "300.00" },
|
||||||
|
paypal_fee: { value: "10.79" },
|
||||||
|
net_amount: { value: "289.21" },
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.url === "/v1/notifications/verify-webhook-signature") {
|
||||||
|
const echt = body.transmission_sig === "echte-signatur";
|
||||||
|
return antworte(200, { verification_status: echt ? "SUCCESS" : "FAILURE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
antworte(404, { error: "unbekannter Pfad: " + req.url });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(0, "127.0.0.1");
|
||||||
|
await new Promise((r) => server.once("listening", r));
|
||||||
|
const port = server.address().port;
|
||||||
|
|
||||||
|
/* Den nachgebauten Server unterschieben: fetch so umbiegen, dass
|
||||||
|
Aufrufe an api-m.sandbox.paypal.com lokal landen. Sauberer als eine
|
||||||
|
Sonderbehandlung im Produktivcode -- der bleibt dadurch unveraendert. */
|
||||||
|
const echtesFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = (url, opt) => {
|
||||||
|
const u = String(url).replace(/^https:\/\/api-m\.(sandbox\.)?paypal\.com/, `http://127.0.0.1:${port}`);
|
||||||
|
return echtesFetch(u, opt);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.env.PAYPAL_CLIENT_ID = "test-id";
|
||||||
|
process.env.PAYPAL_CLIENT_SECRET = "test-secret";
|
||||||
|
process.env.PAYPAL_WD_WEBHOOK_ID = "WH-TEST";
|
||||||
|
delete process.env.PAYPAL_ENV; // Testmodus
|
||||||
|
|
||||||
|
const m = await import("./lib/webdesign-paypal.js");
|
||||||
|
m.tokenVergessen();
|
||||||
|
|
||||||
|
pruefe("Testmodus ist die Voreinstellung (nicht live)", m.istLive() === false);
|
||||||
|
pruefe("mit Zugangsdaten gilt es als eingerichtet", m.istEingerichtet() === true);
|
||||||
|
|
||||||
|
const bestellung = await m.bestellungAnlegen({
|
||||||
|
zahlungId: "zahlung-abc",
|
||||||
|
nummer: "Z-2608-0001",
|
||||||
|
betragCent: 30000,
|
||||||
|
zweck: "30 % Anzahlung Projekt P-2608-0001",
|
||||||
|
rueckkehrUrl: "https://dogfather-universe.com/webdesign/portal.html?zahlung=ok",
|
||||||
|
abbruchUrl: "https://dogfather-universe.com/webdesign/portal.html?zahlung=ab",
|
||||||
|
});
|
||||||
|
pruefe("Bestellnummer kommt zurueck", bestellung.orderId === "ORDER-TEST-1");
|
||||||
|
pruefe("Freigabe-Link kommt zurueck", /checkoutnow/.test(bestellung.freigabeUrl || ""));
|
||||||
|
pruefe("Betrag korrekt uebermittelt (300.00)", gesehen.betraege[0] === "300.00");
|
||||||
|
pruefe("intent ist CAPTURE, nicht AUTHORIZE", gesehen.intents[0] === "CAPTURE",
|
||||||
|
"-> nur so landet das Geld sofort auf dem Konto");
|
||||||
|
pruefe("Doppel-Schutz-Kennung wird mitgeschickt",
|
||||||
|
(gesehen.requestIds[0] || "").includes("zahlung-abc"));
|
||||||
|
|
||||||
|
const einzug = await m.bestellungEinziehen("ORDER-TEST-1");
|
||||||
|
pruefe("Einzug meldet COMPLETED", einzug.status === "COMPLETED");
|
||||||
|
pruefe("Einzugs-Kennung wird gelesen", einzug.captureId === "CAPTURE-1");
|
||||||
|
pruefe("Bruttobetrag 300,00 EUR", einzug.bruttoCent === 30000);
|
||||||
|
pruefe("Gebuehr getrennt erfasst (10,79 EUR)", einzug.gebuehrCent === 1079);
|
||||||
|
pruefe("Nettobetrag getrennt erfasst (289,21 EUR)", einzug.nettoCent === 28921);
|
||||||
|
pruefe("Brutto = Netto + Gebuehr geht auf", einzug.bruttoCent === einzug.nettoCent + einzug.gebuehrCent);
|
||||||
|
|
||||||
|
const echt = await m.webhookEchtheitPruefen(
|
||||||
|
{ "paypal-transmission-sig": "echte-signatur", "paypal-auth-algo": "SHA256withRSA" },
|
||||||
|
JSON.stringify({ id: "EV-1", event_type: "PAYMENT.CAPTURE.COMPLETED" })
|
||||||
|
);
|
||||||
|
pruefe("echter Webhook wird akzeptiert", echt === true);
|
||||||
|
|
||||||
|
const gefaelscht = await m.webhookEchtheitPruefen(
|
||||||
|
{ "paypal-transmission-sig": "gefaelscht" },
|
||||||
|
JSON.stringify({ id: "EV-2", event_type: "PAYMENT.CAPTURE.COMPLETED" })
|
||||||
|
);
|
||||||
|
pruefe("gefaelschter Webhook wird abgelehnt", gefaelscht === false,
|
||||||
|
"-> sonst koennte jeder eine Zahlung vortaeuschen");
|
||||||
|
|
||||||
|
pruefe("bekannte Ereignisse sind zugeordnet",
|
||||||
|
m.WEBHOOK_EREIGNISSE["PAYMENT.CAPTURE.COMPLETED"] === "zahlung_eingegangen");
|
||||||
|
pruefe("unbekanntes Ereignis wird nicht zugeordnet",
|
||||||
|
m.WEBHOOK_EREIGNISSE["IRGENDWAS.ANDERES"] === undefined);
|
||||||
|
|
||||||
|
globalThis.fetch = echtesFetch;
|
||||||
|
server.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n=== ${bestanden} bestanden, ${gescheitert} gescheitert ===\n`);
|
||||||
|
process.exit(gescheitert === 0 ? 0 : 1);
|
||||||
@@ -15,3 +15,26 @@ SITE_ACCESS_CODE_DIENE=
|
|||||||
# automatischer Start, die Schranke bleibt dauerhaft aktiv.
|
# automatischer Start, die Schranke bleibt dauerhaft aktiv.
|
||||||
# Beispiel (21 Uhr deutscher Sommerzeit): 2026-08-21T21:00:00+02:00
|
# Beispiel (21 Uhr deutscher Sommerzeit): 2026-08-21T21:00:00+02:00
|
||||||
SITE_PUBLIC_LAUNCH_AT=
|
SITE_PUBLIC_LAUNCH_AT=
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Webdesign-Bereich (/webdesign), 22.08.2026 — eigener Zugangsschutz.
|
||||||
|
# Bewusst UNABHAENGIG von SITE_PUBLIC_LAUNCH_AT oben: der oeffentliche Start
|
||||||
|
# des Universe darf diesen Bereich nicht mit aufsperren.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Status: entwurf | vorabpruefung | freigegeben
|
||||||
|
# entwurf = komplett dicht, auch mit richtigem Code kein Zugang
|
||||||
|
# vorabpruefung = Zugang nur ueber die persoenlichen Codes (Standard)
|
||||||
|
# freigegeben = oeffentliche Seiten frei; die Verwaltung bleibt geschuetzt
|
||||||
|
WEBDESIGN_STATUS=vorabpruefung
|
||||||
|
|
||||||
|
# Leer lassen = die SITE_ACCESS_CODE_*-Codes von oben gelten auch hier
|
||||||
|
# (so eingerichtet auf Wunsch vom 22.08.2026). Wird hier spaeter ein eigener
|
||||||
|
# Wert eingetragen, gilt NUR noch dieser -- damit laesst sich der
|
||||||
|
# Webdesign-Zugang getrennt aendern oder entziehen, ohne den Universe-Zugang
|
||||||
|
# anzufassen.
|
||||||
|
WEBDESIGN_CODE_DOGI=
|
||||||
|
WEBDESIGN_CODE_VANVAN=
|
||||||
|
# Eigenes Signatur-Geheimnis fuer die Webdesign-Sitzung. Leer lassen = es
|
||||||
|
# wird SITE_ACCESS_SECRET benutzt. Ein Universe-Cookie funktioniert hier
|
||||||
|
# trotzdem nicht (die Sitzung traegt zusaetzlich bereich="webdesign").
|
||||||
|
WEBDESIGN_ACCESS_SECRET=
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { dirname, join } from "node:path";
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
import { gateMiddleware } from "./gate.js";
|
import { gateMiddleware } from "./gate.js";
|
||||||
|
import { webdesignGate } from "./webdesign-gate.js";
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const SITE_DIR = join(__dirname, "..");
|
const SITE_DIR = join(__dirname, "..");
|
||||||
@@ -37,6 +38,14 @@ app.use(cookieParser());
|
|||||||
app.use(express.json({ limit: "1mb" }));
|
app.use(express.json({ limit: "1mb" }));
|
||||||
app.use(gateMiddleware);
|
app.use(gateMiddleware);
|
||||||
|
|
||||||
|
/* Zugangsschutz für /webdesign (22.08.2026). Muss NACH gateMiddleware und
|
||||||
|
VOR express.static stehen. Eine eigene Schranke ist zwingend: gate.js
|
||||||
|
lässt seit dem öffentlichen Start am 21.08.2026 jeden durch
|
||||||
|
(SITE_PUBLIC_LAUNCH_AT liegt in der Vergangenheit) — der ausdrücklich
|
||||||
|
nicht-öffentliche Webdesign-Bereich wäre darüber sofort für alle
|
||||||
|
lesbar. Ausführliche Begründung im Kopf von webdesign-gate.js. */
|
||||||
|
app.use(webdesignGate);
|
||||||
|
|
||||||
/* Sicherheits-Fix 20.08.2026 (Audit vor dem geplanten öffentlichen Start morgen).
|
/* Sicherheits-Fix 20.08.2026 (Audit vor dem geplanten öffentlichen Start morgen).
|
||||||
SITE_DIR ist der GESAMTE Repo-Ordner (join(__dirname, "..")) — express.static liefert
|
SITE_DIR ist der GESAMTE Repo-Ordner (join(__dirname, "..")) — express.static liefert
|
||||||
ohne diese Sperre daher nicht nur die eigentliche Website aus, sondern auch den Server-
|
ohne diese Sperre daher nicht nur die eigentliche Website aus, sondern auch den Server-
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/* Temporäres Prüfskript (kein Deploy-Bestandteil): macht Screenshots des
|
||||||
|
Webdesign-Bereichs im Handy- und Desktopformat und meldet Konsolenfehler.
|
||||||
|
Aufruf: node tmp-screenshot.mjs <pfad> <name> [--code]
|
||||||
|
--code = vorher an der Zugangswand anmelden. */
|
||||||
|
import { chromium } from "playwright";
|
||||||
|
|
||||||
|
const pfad = process.argv[2] || "/webdesign/zugang.html";
|
||||||
|
const name = process.argv[3] || "seite";
|
||||||
|
const mitCode = process.argv.includes("--code");
|
||||||
|
const BASIS = "http://127.0.0.1:4199";
|
||||||
|
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const kontext = await browser.newContext();
|
||||||
|
const seite = await kontext.newPage();
|
||||||
|
|
||||||
|
const fehler = [];
|
||||||
|
seite.on("console", (m) => { if (m.type() === "error") fehler.push(m.text()); });
|
||||||
|
seite.on("pageerror", (e) => fehler.push("pageerror: " + e.message));
|
||||||
|
|
||||||
|
if (mitCode) {
|
||||||
|
await seite.goto(BASIS + "/webdesign/zugang.html", { waitUntil: "networkidle" });
|
||||||
|
await seite.fill("#zg-code-dogfather", "DOGI-TEST-1");
|
||||||
|
/* Auf die durch location.href ausgeloeste Navigation warten, statt nur
|
||||||
|
auf networkidle -- sonst startet der naechste goto() mitten in einer
|
||||||
|
laufenden Navigation und Chrome bricht beide mit ERR_ABORTED ab. */
|
||||||
|
await Promise.all([
|
||||||
|
seite.waitForURL((u) => !u.pathname.endsWith("zugang.html"), { timeout: 15000 }),
|
||||||
|
seite.click("#zg-form-dogfather button[type=submit]"),
|
||||||
|
]);
|
||||||
|
await seite.waitForLoadState("networkidle");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [breite, hoehe, kuerzel] of [[390, 844, "handy"], [1440, 900, "desktop"]]) {
|
||||||
|
await seite.setViewportSize({ width: breite, height: hoehe });
|
||||||
|
await seite.goto(BASIS + pfad, { waitUntil: "networkidle" });
|
||||||
|
|
||||||
|
/* Marken-Intro wegklicken, falls es laeuft -- sonst liegt es auf dem Bild. */
|
||||||
|
const skip = seite.locator("#wd-intro-skip");
|
||||||
|
if (await skip.isVisible().catch(() => false)) await skip.click();
|
||||||
|
|
||||||
|
/* WICHTIG: Ein fullPage-Screenshot loest KEINEN IntersectionObserver aus.
|
||||||
|
Ohne dieses Durchscrollen bleiben alle .wd-auf-Elemente unsichtbar und
|
||||||
|
das Bild zeigt eine praktisch leere Seite -- was wie ein schwerer
|
||||||
|
Fehler aussieht, aber nur ein Messfehler waere. Deshalb einmal in
|
||||||
|
Schritten nach unten scrollen, damit jeder Abschnitt eingeblendet
|
||||||
|
wird, danach zurueck nach oben. */
|
||||||
|
await seite.evaluate(async () => {
|
||||||
|
const schritt = window.innerHeight * 0.8;
|
||||||
|
for (let y = 0; y < document.body.scrollHeight; y += schritt) {
|
||||||
|
window.scrollTo(0, y);
|
||||||
|
await new Promise((r) => setTimeout(r, 120));
|
||||||
|
}
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
});
|
||||||
|
await seite.waitForTimeout(900); // Einblend-Animationen zu Ende laufen lassen
|
||||||
|
await seite.screenshot({ path: `tmp-${name}-${kuerzel}.png`, fullPage: kuerzel === "desktop" });
|
||||||
|
console.log(`tmp-${name}-${kuerzel}.png`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(fehler.length ? "KONSOLENFEHLER:\n" + fehler.join("\n") : "Keine Konsolenfehler.");
|
||||||
|
await browser.close();
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
/* =====================================================================
|
||||||
|
pruefe-webdesign-handy.mjs — Handy-Abnahme für alle Webdesign-Seiten.
|
||||||
|
|
||||||
|
Prüft je Seite und je Bildschirmbreite (320 / 390 / 430 / 768 / 1440):
|
||||||
|
- kein waagerechter Überlauf (der häufigste Handy-Fehler überhaupt)
|
||||||
|
- kein Element ragt über den rechten Rand hinaus
|
||||||
|
- alle Knöpfe und Links sind mindestens 44px hoch (Touch-Ziel)
|
||||||
|
- keine Konsolenfehler
|
||||||
|
- in allen fünf Sprachen: keine unübersetzten Schlüssel sichtbar
|
||||||
|
|
||||||
|
320px ist bewusst dabei: das ist ein iPhone SE der ersten Generationen
|
||||||
|
und immer noch die Breite, bei der Layouts zuerst brechen.
|
||||||
|
|
||||||
|
Aufruf (lokaler Testserver muss laufen):
|
||||||
|
node pruefe-webdesign-handy.mjs
|
||||||
|
===================================================================== */
|
||||||
|
import { chromium } from "playwright";
|
||||||
|
|
||||||
|
const BASIS = process.env.WD_BASIS || "http://127.0.0.1:4199";
|
||||||
|
const CODE = process.env.WD_CODE || "DOGI-TEST-1";
|
||||||
|
|
||||||
|
const SEITEN = [
|
||||||
|
"/webdesign/",
|
||||||
|
"/webdesign/leistungen.html",
|
||||||
|
"/webdesign/preise.html",
|
||||||
|
"/webdesign/portfolio.html",
|
||||||
|
"/webdesign/ablauf.html",
|
||||||
|
"/webdesign/ueber.html",
|
||||||
|
"/webdesign/faq.html",
|
||||||
|
];
|
||||||
|
const BREITEN = [320, 390, 430, 768, 1440];
|
||||||
|
const SPRACHEN = ["de", "de-CH", "en", "fr", "pt"];
|
||||||
|
|
||||||
|
let ok = 0;
|
||||||
|
const probleme = [];
|
||||||
|
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const kontext = await browser.newContext();
|
||||||
|
const seite = await kontext.newPage();
|
||||||
|
|
||||||
|
const konsole = [];
|
||||||
|
seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); });
|
||||||
|
seite.on("pageerror", (e) => konsole.push("pageerror: " + e.message));
|
||||||
|
|
||||||
|
// --- Anmelden ---
|
||||||
|
await seite.goto(BASIS + "/webdesign/zugang.html", { waitUntil: "networkidle" });
|
||||||
|
await seite.fill("#zg-code-dogfather", CODE);
|
||||||
|
await Promise.all([
|
||||||
|
seite.waitForURL((u) => !u.pathname.endsWith("zugang.html"), { timeout: 15000 }),
|
||||||
|
seite.click("#zg-form-dogfather button[type=submit]"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log("\n=== Handy-Abnahme ===\n");
|
||||||
|
|
||||||
|
for (const pfad of SEITEN) {
|
||||||
|
console.log(pfad);
|
||||||
|
for (const breite of BREITEN) {
|
||||||
|
await seite.setViewportSize({ width: breite, height: 900 });
|
||||||
|
await seite.goto(BASIS + pfad, { waitUntil: "networkidle" });
|
||||||
|
// Marken-Intro wegklicken, es liegt sonst über allem.
|
||||||
|
const skip = seite.locator("#wd-intro-skip");
|
||||||
|
if (await skip.isVisible().catch(() => false)) await skip.click();
|
||||||
|
await seite.waitForTimeout(250);
|
||||||
|
|
||||||
|
const befund = await seite.evaluate((vp) => {
|
||||||
|
const raus = [];
|
||||||
|
// Waagerechter Überlauf des ganzen Dokuments
|
||||||
|
const ueberlauf = document.documentElement.scrollWidth > vp + 1;
|
||||||
|
|
||||||
|
// Einzelne Elemente, die über den rechten Rand ragen
|
||||||
|
for (const el of document.querySelectorAll("body *")) {
|
||||||
|
const s = getComputedStyle(el);
|
||||||
|
if (s.display === "none" || s.visibility === "hidden") continue;
|
||||||
|
// Absichtlich waagerecht scrollbare Leisten ausnehmen
|
||||||
|
if (el.closest(".ls-sprung, .fq-sprung, .wd-tabelle-rahmen")) continue;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
if (r.width === 0 || r.height === 0) continue;
|
||||||
|
if (r.right > vp + 1.5) {
|
||||||
|
raus.push((el.tagName.toLowerCase() + "." + (el.className || "").toString().split(" ")[0])
|
||||||
|
.slice(0, 50) + " bis " + Math.round(r.right) + "px");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zu kleine Touch-Ziele
|
||||||
|
const klein = [];
|
||||||
|
for (const el of document.querySelectorAll("a, button, summary, input, select")) {
|
||||||
|
const s = getComputedStyle(el);
|
||||||
|
if (s.display === "none" || s.visibility === "hidden") continue;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
if (r.height === 0 || r.width === 0) continue;
|
||||||
|
if (r.height < 44) {
|
||||||
|
klein.push((el.tagName.toLowerCase() + " „" + (el.textContent || "").trim().slice(0, 24) + "“ ")
|
||||||
|
+ Math.round(r.height) + "px");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unübersetzte Schlüssel: erscheinen als roher Schlüsselname im Text
|
||||||
|
const roh = [];
|
||||||
|
for (const el of document.querySelectorAll("[data-i18n]")) {
|
||||||
|
const txt = (el.textContent || "").trim();
|
||||||
|
if (txt === el.getAttribute("data-i18n")) roh.push(txt);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ueberlauf, raus: raus.slice(0, 5), klein: klein.slice(0, 5), roh: roh.slice(0, 5) };
|
||||||
|
}, breite);
|
||||||
|
|
||||||
|
const zeile = ` ${String(breite).padStart(4)}px`;
|
||||||
|
const fehler = [];
|
||||||
|
if (befund.ueberlauf) fehler.push("waagerechter Ueberlauf");
|
||||||
|
if (befund.raus.length) fehler.push("ragt heraus: " + befund.raus.join(", "));
|
||||||
|
if (befund.klein.length) fehler.push("Touch-Ziel < 44px: " + befund.klein.join(", "));
|
||||||
|
if (befund.roh.length) fehler.push("unuebersetzt: " + befund.roh.join(", "));
|
||||||
|
|
||||||
|
if (fehler.length) {
|
||||||
|
probleme.push(`${pfad} @ ${breite}px -> ${fehler.join(" | ")}`);
|
||||||
|
console.log(`${zeile} FEHLER: ${fehler.join(" | ")}`);
|
||||||
|
} else {
|
||||||
|
ok++;
|
||||||
|
console.log(`${zeile} ok`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Sprachen: einmal je Sprache die Startseite prüfen ---
|
||||||
|
console.log("\nSprachen (Startseite, 390px)");
|
||||||
|
await seite.setViewportSize({ width: 390, height: 900 });
|
||||||
|
for (const sprache of SPRACHEN) {
|
||||||
|
await seite.goto(BASIS + "/webdesign/", { waitUntil: "networkidle" });
|
||||||
|
const skip = seite.locator("#wd-intro-skip");
|
||||||
|
if (await skip.isVisible().catch(() => false)) await skip.click();
|
||||||
|
await seite.evaluate((s) => window.WD.setSprache(s), sprache);
|
||||||
|
await seite.waitForTimeout(350);
|
||||||
|
const befund = await seite.evaluate((vp) => {
|
||||||
|
const roh = [];
|
||||||
|
for (const el of document.querySelectorAll("[data-i18n]")) {
|
||||||
|
const txt = (el.textContent || "").trim();
|
||||||
|
if (txt === el.getAttribute("data-i18n")) roh.push(txt);
|
||||||
|
}
|
||||||
|
return { roh: roh.slice(0, 5), ueberlauf: document.documentElement.scrollWidth > vp + 1, lang: document.documentElement.lang };
|
||||||
|
}, 390);
|
||||||
|
const fehler = [];
|
||||||
|
if (befund.lang !== sprache) fehler.push("lang-Attribut ist " + befund.lang);
|
||||||
|
if (befund.roh.length) fehler.push("unuebersetzt: " + befund.roh.join(", "));
|
||||||
|
if (befund.ueberlauf) fehler.push("waagerechter Ueberlauf");
|
||||||
|
if (fehler.length) { probleme.push(`Sprache ${sprache} -> ${fehler.join(" | ")}`); console.log(` ${sprache.padEnd(6)} FEHLER: ${fehler.join(" | ")}`); }
|
||||||
|
else { ok++; console.log(` ${sprache.padEnd(6)} ok`); }
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
|
||||||
|
console.log(konsole.length ? "\nKONSOLENFEHLER:\n" + [...new Set(konsole)].join("\n") : "\nKeine Konsolenfehler.");
|
||||||
|
console.log(`\n=== ${ok} ok, ${probleme.length} Probleme ===`);
|
||||||
|
if (probleme.length) probleme.forEach((p) => console.log(" " + p));
|
||||||
|
process.exit(probleme.length === 0 && konsole.length === 0 ? 0 : 1);
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/* Prüft für jede Webdesign-Seite:
|
||||||
|
1. jeder data-i18n-Schlüssel existiert im Wörterbuch
|
||||||
|
2. jeder Schlüssel hat ALLE fünf Sprachen
|
||||||
|
3. keine Überschrift h1-h4 ohne data-i18n (der dokumentierte
|
||||||
|
Bug-Klassiker aus DogiHompage/CLAUDE.md)
|
||||||
|
4. keine unbenutzten Schlüssel (Hinweis, kein Fehler)
|
||||||
|
Aufruf: node tmp-i18n-check.mjs */
|
||||||
|
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
||||||
|
import { chdir } from "node:process";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
/* Immer vom Projektordner aus arbeiten, egal von wo aufgerufen. */
|
||||||
|
chdir(join(dirname(fileURLToPath(import.meta.url)), ".."));
|
||||||
|
|
||||||
|
const SPRACHEN = ["de", "de-CH", "en", "fr", "pt"];
|
||||||
|
const KERN = "assets/js/wd-core.js";
|
||||||
|
|
||||||
|
/* Schlüssel, die wd-core.js selbst mitbringt (Navigation, Fußzeile,
|
||||||
|
Fehlermeldungen) — die dürfen in jeder Seite benutzt werden. */
|
||||||
|
const kernQuelle = readFileSync(KERN, "utf8");
|
||||||
|
const kernSchluessel = new Set(
|
||||||
|
[...kernQuelle.matchAll(/^\s{4}([a-z0-9_]+):\s*\{/gm)].map((m) => m[1])
|
||||||
|
);
|
||||||
|
|
||||||
|
let fehler = 0;
|
||||||
|
const seiten = existsSync("webdesign")
|
||||||
|
? readdirSync("webdesign").filter((f) => f.endsWith(".html"))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
for (const datei of seiten) {
|
||||||
|
const name = datei.replace(/\.html$/, "");
|
||||||
|
const html = readFileSync(`webdesign/${datei}`, "utf8");
|
||||||
|
const i18nDatei = `assets/js/i18n-wd-${name}.js`;
|
||||||
|
|
||||||
|
console.log(`\n--- ${datei} ---`);
|
||||||
|
|
||||||
|
if (!existsSync(i18nDatei)) {
|
||||||
|
console.log(` FEHLER: ${i18nDatei} fehlt`);
|
||||||
|
fehler++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wörterbuch laden (setzt window.I18N_WD)
|
||||||
|
global.window = {};
|
||||||
|
// eslint-disable-next-line no-eval
|
||||||
|
eval(readFileSync(i18nDatei, "utf8"));
|
||||||
|
const woerterbuch = global.window.I18N_WD || {};
|
||||||
|
|
||||||
|
// --- 1+2: benutzte Schlüssel prüfen ---
|
||||||
|
const benutzt = new Set();
|
||||||
|
for (const m of html.matchAll(/data-i18n="([^"]+)"/g)) benutzt.add(m[1]);
|
||||||
|
for (const m of html.matchAll(/data-i18n-attr="([^"]+)"/g)) {
|
||||||
|
m[1].split(";").forEach((teil) => {
|
||||||
|
const pos = teil.indexOf(":");
|
||||||
|
if (pos > 0) benutzt.add(teil.slice(pos + 1).trim());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const titelKey = (html.match(/data-i18n-titel="([^"]+)"/) || [])[1];
|
||||||
|
if (titelKey) benutzt.add(titelKey);
|
||||||
|
// Schlüssel, die nur im Seitenskript per WD.t(...) geholt werden
|
||||||
|
for (const m of html.matchAll(/WD\.t\("([a-z0-9_]+)"\)/g)) benutzt.add(m[1]);
|
||||||
|
|
||||||
|
const fehlend = [];
|
||||||
|
const unvollstaendig = [];
|
||||||
|
for (const k of benutzt) {
|
||||||
|
const eintrag = woerterbuch[k];
|
||||||
|
if (!eintrag) {
|
||||||
|
if (!kernSchluessel.has(k)) fehlend.push(k);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const s of SPRACHEN) if (!eintrag[s]) unvollstaendig.push(`${k}:${s}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3: Überschriften ohne data-i18n ---
|
||||||
|
const ohneI18n = [];
|
||||||
|
for (const m of html.matchAll(/<(h[1-4])\b([^>]*)>([\s\S]*?)<\/\1>/g)) {
|
||||||
|
if (m[2].includes("data-i18n")) continue;
|
||||||
|
const text = m[3].replace(/<[^>]*>/g, "").trim();
|
||||||
|
if (!text) continue; // rein dekorativ
|
||||||
|
if (/^\$\{/.test(text)) continue; // dynamisch aus JS
|
||||||
|
ohneI18n.push(`${m[1]}: ${text.slice(0, 60)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 4: unbenutzte Schlüssel (nur Hinweis) ---
|
||||||
|
const unbenutzt = Object.keys(woerterbuch).filter((k) => !benutzt.has(k));
|
||||||
|
|
||||||
|
console.log(` Schluessel im HTML: ${benutzt.size}, im Woerterbuch: ${Object.keys(woerterbuch).length}`);
|
||||||
|
if (fehlend.length) { console.log(` FEHLENDE Schluessel: ${fehlend.join(", ")}`); fehler += fehlend.length; }
|
||||||
|
if (unvollstaendig.length) { console.log(` FEHLENDE Sprachen: ${unvollstaendig.join(", ")}`); fehler += unvollstaendig.length; }
|
||||||
|
if (ohneI18n.length) { console.log(` Ueberschrift ohne data-i18n:\n ${ohneI18n.join("\n ")}`); fehler += ohneI18n.length; }
|
||||||
|
if (unbenutzt.length) console.log(` Hinweis, unbenutzt: ${unbenutzt.join(", ")}`);
|
||||||
|
if (!fehlend.length && !unvollstaendig.length && !ohneI18n.length) console.log(" alles vollstaendig");
|
||||||
|
|
||||||
|
// --- Tag-Balance grob prüfen ---
|
||||||
|
for (const tag of ["div", "section", "main", "article", "details"]) {
|
||||||
|
const auf = (html.match(new RegExp(`<${tag}\\b`, "g")) || []).length;
|
||||||
|
const zu = (html.match(new RegExp(`</${tag}>`, "g")) || []).length;
|
||||||
|
if (auf !== zu) { console.log(` TAG-UNGLEICHGEWICHT <${tag}>: ${auf} offen, ${zu} geschlossen`); fehler++; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n=== ${fehler === 0 ? "Keine Fehler" : fehler + " Fehler"} ===`);
|
||||||
|
process.exit(fehler === 0 ? 0 : 1);
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/* Prüft die App-Installierbarkeit des Webdesign-Bereichs:
|
||||||
|
- Manifest erreichbar, gültig, Icons laden
|
||||||
|
- Service Worker registriert sich und wird aktiv
|
||||||
|
- HTML wird NICHT zwischengespeichert (Sicherheitsvorgabe)
|
||||||
|
- Bausteine unter /assets/ werden zwischengespeichert */
|
||||||
|
import { chromium } from "playwright";
|
||||||
|
const BASIS = "http://127.0.0.1:4199";
|
||||||
|
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const kontext = await browser.newContext();
|
||||||
|
const seite = await kontext.newPage();
|
||||||
|
const fehler = [];
|
||||||
|
seite.on("console", (m) => { if (m.type() === "error") fehler.push(m.text()); });
|
||||||
|
seite.on("pageerror", (e) => fehler.push("pageerror: " + e.message));
|
||||||
|
|
||||||
|
let ok = 0, schlecht = 0;
|
||||||
|
const pruefe = (name, b, zusatz = "") => {
|
||||||
|
if (b) { ok++; console.log(" ok " + name); }
|
||||||
|
else { schlecht++; console.log(" FEHLT " + name + " " + zusatz); }
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("\n=== App-Installierbarkeit ===\n");
|
||||||
|
|
||||||
|
// --- Manifest ohne Anmeldung erreichbar (Installation von der Zugangswand) ---
|
||||||
|
console.log("1. Manifest");
|
||||||
|
const mRes = await seite.request.get(BASIS + "/webdesign/app.webmanifest");
|
||||||
|
pruefe("ohne Anmeldung erreichbar", mRes.status() === 200, "(war " + mRes.status() + ")");
|
||||||
|
const manifest = await mRes.json().catch(() => null);
|
||||||
|
pruefe("gueltiges JSON", !!manifest);
|
||||||
|
if (manifest) {
|
||||||
|
pruefe("start_url im Webdesign-Bereich", manifest.start_url === "/webdesign/");
|
||||||
|
pruefe("scope begrenzt auf /webdesign/", manifest.scope === "/webdesign/");
|
||||||
|
pruefe("display standalone", manifest.display === "standalone");
|
||||||
|
pruefe("192er-Symbol vorhanden", manifest.icons.some((i) => i.sizes === "192x192"));
|
||||||
|
pruefe("512er-Symbol vorhanden", manifest.icons.some((i) => i.sizes === "512x512"));
|
||||||
|
pruefe("maskierbares Symbol vorhanden", manifest.icons.some((i) => (i.purpose || "").includes("maskable")));
|
||||||
|
for (const symbol of manifest.icons) {
|
||||||
|
const r = await seite.request.get(BASIS + symbol.src);
|
||||||
|
pruefe("Symbol laedt: " + symbol.src, r.status() === 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const swRes = await seite.request.get(BASIS + "/webdesign/sw.js");
|
||||||
|
pruefe("Service Worker ohne Anmeldung erreichbar", swRes.status() === 200, "(war " + swRes.status() + ")");
|
||||||
|
pruefe("wird als JavaScript ausgeliefert",
|
||||||
|
(swRes.headers()["content-type"] || "").includes("javascript"),
|
||||||
|
"(war " + swRes.headers()["content-type"] + ")");
|
||||||
|
|
||||||
|
// --- Registrierung im Browser ---
|
||||||
|
console.log("\n2. Service Worker im Browser");
|
||||||
|
await seite.goto(BASIS + "/webdesign/zugang.html", { waitUntil: "networkidle" });
|
||||||
|
await seite.fill("#zg-code-dogfather", "DOGI-TEST-1");
|
||||||
|
await Promise.all([
|
||||||
|
seite.waitForURL((u) => !u.pathname.endsWith("zugang.html"), { timeout: 15000 }),
|
||||||
|
seite.click("#zg-form-dogfather button[type=submit]"),
|
||||||
|
]);
|
||||||
|
await seite.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
const aktiv = await seite.evaluate(async () => {
|
||||||
|
const reg = await navigator.serviceWorker.ready.catch(() => null);
|
||||||
|
return reg ? { scope: reg.scope, aktiv: !!reg.active } : null;
|
||||||
|
});
|
||||||
|
pruefe("registriert und aktiv", !!(aktiv && aktiv.aktiv));
|
||||||
|
pruefe("Geltungsbereich ist /webdesign/", !!(aktiv && aktiv.scope.endsWith("/webdesign/")), aktiv ? aktiv.scope : "");
|
||||||
|
|
||||||
|
// --- Sicherheitsvorgabe: keine HTML-Seiten im Zwischenspeicher ---
|
||||||
|
console.log("\n3. Sicherheit des Zwischenspeichers");
|
||||||
|
await seite.reload({ waitUntil: "networkidle" });
|
||||||
|
await seite.waitForTimeout(1200);
|
||||||
|
const inhalt = await seite.evaluate(async () => {
|
||||||
|
const namen = await caches.keys();
|
||||||
|
const alles = [];
|
||||||
|
for (const n of namen) {
|
||||||
|
const c = await caches.open(n);
|
||||||
|
for (const anfrage of await c.keys()) alles.push(anfrage.url);
|
||||||
|
}
|
||||||
|
return alles;
|
||||||
|
});
|
||||||
|
console.log(" Zwischenspeicher enthaelt " + inhalt.length + " Eintraege");
|
||||||
|
const htmlDrin = inhalt.filter((u) => u.endsWith(".html") || new URL(u).pathname === "/webdesign/");
|
||||||
|
pruefe("KEINE geschuetzte HTML-Seite zwischengespeichert", htmlDrin.length === 0, htmlDrin.join(", "));
|
||||||
|
pruefe("Stilvorlage ist zwischengespeichert", inhalt.some((u) => u.includes("webdesign.css")));
|
||||||
|
pruefe("Kern-Skript ist zwischengespeichert", inhalt.some((u) => u.includes("wd-core.js")));
|
||||||
|
|
||||||
|
console.log(fehler.length ? "\nKONSOLENFEHLER:\n" + fehler.join("\n") : "\nKeine Konsolenfehler.");
|
||||||
|
await browser.close();
|
||||||
|
console.log(`\n=== ${ok} bestanden, ${schlecht} gescheitert ===\n`);
|
||||||
|
process.exit(schlecht === 0 && fehler.length === 0 ? 0 : 1);
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
/* =====================================================================
|
||||||
|
test-webdesign-gate.mjs — Selbsttest der Webdesign-Zugangsschranke.
|
||||||
|
|
||||||
|
Ausführen (aus dem Ordner server/):
|
||||||
|
node test-webdesign-gate.mjs
|
||||||
|
|
||||||
|
Startet einen eigenen Server auf einem freien Port mit Test-Codes und
|
||||||
|
spielt die sicherheitskritischen Fälle durch. Rührt weder die echte
|
||||||
|
.env noch den laufenden Dienst an.
|
||||||
|
|
||||||
|
Warum ein eigener Test und nicht "einmal im Browser klicken":
|
||||||
|
Die gefährlichen Fälle sind genau die, die man beim Klicken NICHT sieht —
|
||||||
|
ein Universe-Cookie, das hier akzeptiert wird; ein "next", das auf eine
|
||||||
|
fremde Domain zeigt; eine Sperre, die nicht greift.
|
||||||
|
===================================================================== */
|
||||||
|
|
||||||
|
process.env.SITE_ACCESS_SECRET = "test-geheimnis-nur-fuer-diesen-lauf";
|
||||||
|
process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1";
|
||||||
|
process.env.SITE_ACCESS_CODE_VANVAN = "VANVAN-TEST-1";
|
||||||
|
process.env.WEBDESIGN_STATUS = "vorabpruefung";
|
||||||
|
// Wichtig für den Kernfall: das Universe ist öffentlich, /webdesign nicht.
|
||||||
|
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
|
||||||
|
|
||||||
|
const { default: express } = await import("express");
|
||||||
|
const { default: cookieParser } = await import("cookie-parser");
|
||||||
|
const { webdesignGate, wdSigniere, wdPruefe } = await import("./webdesign-gate.js");
|
||||||
|
const { signSession } = await import("./gate.js");
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.set("trust proxy", 1);
|
||||||
|
app.use(cookieParser());
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(webdesignGate);
|
||||||
|
// Ersatz für express.static — antwortet nur, wenn die Schranke durchlässt.
|
||||||
|
app.use((req, res) => res.status(200).send("GEHEIMER INHALT"));
|
||||||
|
|
||||||
|
const server = app.listen(0, "127.0.0.1");
|
||||||
|
await new Promise((r) => server.once("listening", r));
|
||||||
|
const basis = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
|
||||||
|
let bestanden = 0;
|
||||||
|
let gescheitert = 0;
|
||||||
|
|
||||||
|
function pruefe(name, bedingung, zusatz = "") {
|
||||||
|
if (bedingung) {
|
||||||
|
bestanden++;
|
||||||
|
console.log(` ok ${name}`);
|
||||||
|
} else {
|
||||||
|
gescheitert++;
|
||||||
|
console.log(` FEHLT ${name} ${zusatz}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hole(pfad, optionen = {}) {
|
||||||
|
const res = await fetch(basis + pfad, { redirect: "manual", ...optionen });
|
||||||
|
const text = await res.text();
|
||||||
|
let json = null;
|
||||||
|
try { json = JSON.parse(text); } catch { /* kein JSON, egal */ }
|
||||||
|
return { res, text, json, cookies: res.headers.getSetCookie?.() || [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function cookieAus(cookies, name) {
|
||||||
|
const treffer = cookies.find((c) => c.startsWith(name + "="));
|
||||||
|
return treffer ? treffer.split(";")[0].split("=").slice(1).join("=") : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n=== Webdesign-Zugangsschranke ===\n");
|
||||||
|
|
||||||
|
/* --- 1. Der Kernfall: Universe ist öffentlich, /webdesign trotzdem dicht --- */
|
||||||
|
console.log("1. Grundschutz");
|
||||||
|
{
|
||||||
|
const a = await hole("/webdesign/");
|
||||||
|
pruefe("ohne Code -> Umleitung zur Zugangswand", a.res.status === 302, `(war ${a.res.status})`);
|
||||||
|
pruefe("Umleitungsziel ist die Zugangswand",
|
||||||
|
(a.res.headers.get("location") || "").startsWith("/webdesign/zugang.html"));
|
||||||
|
pruefe("Inhalt wird NICHT mitgeschickt", !a.text.includes("GEHEIMER INHALT"));
|
||||||
|
|
||||||
|
const b = await hole("/webdesign/preise.html");
|
||||||
|
pruefe("auch Unterseiten sind dicht", b.res.status === 302);
|
||||||
|
|
||||||
|
const c = await hole("/webdesign/zugang.html");
|
||||||
|
pruefe("Zugangswand selbst ist erreichbar", c.res.status === 200);
|
||||||
|
|
||||||
|
const d = await hole("/index.html");
|
||||||
|
pruefe("Universe bleibt unberührt öffentlich", d.res.status === 200);
|
||||||
|
|
||||||
|
const e = await hole("/webdesign/");
|
||||||
|
pruefe("noindex-Header ist gesetzt",
|
||||||
|
(e.res.headers.get("x-robots-tag") || "").includes("noindex"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 2. Anmeldung --- */
|
||||||
|
console.log("\n2. Anmeldung");
|
||||||
|
let sitzungDogi = null;
|
||||||
|
{
|
||||||
|
const falsch = await hole("/webdesign/zugang-pruefen", {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ code: "irgendwas" }),
|
||||||
|
});
|
||||||
|
pruefe("falscher Code -> 401", falsch.res.status === 401);
|
||||||
|
|
||||||
|
const richtig = await hole("/webdesign/zugang-pruefen", {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ code: "DOGI-TEST-1" }),
|
||||||
|
});
|
||||||
|
pruefe("richtiger Code -> ok", richtig.res.status === 200 && richtig.json?.ok === true);
|
||||||
|
pruefe("Rolle wird gemeldet", richtig.json?.rolle === "dogfather");
|
||||||
|
sitzungDogi = cookieAus(richtig.cookies, "dogiwd_session");
|
||||||
|
pruefe("Sitzungs-Cookie wird gesetzt", !!sitzungDogi);
|
||||||
|
pruefe("Cookie ist httpOnly", richtig.cookies.some((c) => c.startsWith("dogiwd_session=") && /httponly/i.test(c)));
|
||||||
|
pruefe("Cookie ist secure", richtig.cookies.some((c) => c.startsWith("dogiwd_session=") && /secure/i.test(c)));
|
||||||
|
pruefe("Cookie hat KEIN Ablaufdatum (nur Browser-Sitzung)",
|
||||||
|
richtig.cookies.some((c) => c.startsWith("dogiwd_session=") && !/max-age|expires/i.test(c)));
|
||||||
|
|
||||||
|
const vanvan = await hole("/webdesign/zugang-pruefen", {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ code: "VANVAN-TEST-1" }),
|
||||||
|
});
|
||||||
|
pruefe("VanVans Code funktioniert gleichwertig", vanvan.json?.rolle === "vanvan");
|
||||||
|
|
||||||
|
const mit = await hole("/webdesign/", { headers: { Cookie: `dogiwd_session=${sitzungDogi}` } });
|
||||||
|
pruefe("mit gültiger Sitzung -> Inhalt kommt", mit.res.status === 200 && mit.text.includes("GEHEIMER INHALT"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 3. Weiterleitungsziel kann nicht auf eine fremde Seite zeigen --- */
|
||||||
|
console.log("\n3. Weiterleitungsziel");
|
||||||
|
{
|
||||||
|
const faelle = [
|
||||||
|
["//boese-seite.de", "protokoll-relative fremde Domain"],
|
||||||
|
["https://boese-seite.de", "vollstaendige fremde URL"],
|
||||||
|
["/webdesign/../../etc/passwd", "Pfadwechsel nach oben"],
|
||||||
|
["/index.html", "ausserhalb des Bereichs"],
|
||||||
|
["/webdesign\\boese", "Rueckwaertsschraegstrich"],
|
||||||
|
];
|
||||||
|
for (const [wert, was] of faelle) {
|
||||||
|
const a = await hole("/webdesign/zugang-pruefen", {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ code: "DOGI-TEST-1", next: wert }),
|
||||||
|
});
|
||||||
|
pruefe(`abgewehrt: ${was}`, a.json?.next === "/webdesign/", `(wurde ${a.json?.next})`);
|
||||||
|
}
|
||||||
|
const gut = await hole("/webdesign/zugang-pruefen", {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ code: "DOGI-TEST-1", next: "/webdesign/preise.html" }),
|
||||||
|
});
|
||||||
|
pruefe("eigener Pfad wird uebernommen", gut.json?.next === "/webdesign/preise.html");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 4. Ein Universe-Cookie darf hier NICHT gelten --- */
|
||||||
|
console.log("\n4. Trennung vom Universe-Zugang");
|
||||||
|
{
|
||||||
|
const universeToken = await signSession("dogi", process.env.SITE_ACCESS_SECRET);
|
||||||
|
const a = await hole("/webdesign/", { headers: { Cookie: `dogiwd_session=${universeToken}` } });
|
||||||
|
pruefe("Universe-Sitzung oeffnet den Webdesign-Bereich NICHT", a.res.status === 302);
|
||||||
|
|
||||||
|
pruefe("wdPruefe lehnt Universe-Token ab", wdPruefe(universeToken, process.env.SITE_ACCESS_SECRET) === null);
|
||||||
|
|
||||||
|
const fremd = wdSigniere("dogfather", "ein-ganz-anderes-geheimnis");
|
||||||
|
const b = await hole("/webdesign/", { headers: { Cookie: `dogiwd_session=${fremd}` } });
|
||||||
|
pruefe("mit falschem Geheimnis signiert -> abgelehnt", b.res.status === 302);
|
||||||
|
|
||||||
|
const verdreht = sitzungDogi.split(".")[0] + "." + "AAAA";
|
||||||
|
const c = await hole("/webdesign/", { headers: { Cookie: `dogiwd_session=${verdreht}` } });
|
||||||
|
pruefe("verfaelschte Signatur -> abgelehnt", c.res.status === 302);
|
||||||
|
|
||||||
|
const abgelaufen = (() => {
|
||||||
|
const inhalt = Buffer.from(JSON.stringify({ bereich: "webdesign", rolle: "dogfather", exp: Date.now() - 1000 }))
|
||||||
|
.toString("base64url");
|
||||||
|
// absichtlich ohne gueltige Signatur -- muss doppelt scheitern
|
||||||
|
return inhalt + ".xxx";
|
||||||
|
})();
|
||||||
|
const d = await hole("/webdesign/", { headers: { Cookie: `dogiwd_session=${abgelaufen}` } });
|
||||||
|
pruefe("abgelaufene/ungueltige Sitzung -> abgelehnt", d.res.status === 302);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 5. Fehlversuchs-Sperre --- */
|
||||||
|
console.log("\n5. Fehlversuchs-Sperre");
|
||||||
|
{
|
||||||
|
let letzte = null;
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
letzte = await hole("/webdesign/zugang-pruefen", {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ code: "falsch-" + i }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
pruefe("nach fuenf Fehlversuchen greift die Sperre (429)", letzte.res.status === 429, `(war ${letzte.res.status})`);
|
||||||
|
|
||||||
|
const trotzRichtig = await hole("/webdesign/zugang-pruefen", {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ code: "DOGI-TEST-1" }),
|
||||||
|
});
|
||||||
|
pruefe("Sperre gilt auch fuer den richtigen Code", trotzRichtig.res.status === 429);
|
||||||
|
pruefe("Meldung nennt die Wartezeit", /Minute/.test(trotzRichtig.json?.error || ""));
|
||||||
|
|
||||||
|
const andere = await hole("/webdesign/zugang-pruefen", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", "CF-Connecting-IP": "203.0.113.77" },
|
||||||
|
body: JSON.stringify({ code: "DOGI-TEST-1" }),
|
||||||
|
});
|
||||||
|
pruefe("andere Besucher sind NICHT mitgesperrt", andere.res.status === 200,
|
||||||
|
"(genau dieser Fehler trat am 19.08.2026 im Universe auf)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 6. Statusmodi --- */
|
||||||
|
console.log("\n6. Zugangsstatus");
|
||||||
|
{
|
||||||
|
process.env.WEBDESIGN_STATUS = "entwurf";
|
||||||
|
const a = await hole("/webdesign/zugang-pruefen", {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json", "CF-Connecting-IP": "203.0.113.90" },
|
||||||
|
body: JSON.stringify({ code: "DOGI-TEST-1" }),
|
||||||
|
});
|
||||||
|
pruefe("ENTWURF: auch der richtige Code oeffnet nicht", a.res.status === 403);
|
||||||
|
|
||||||
|
process.env.WEBDESIGN_STATUS = "freigegeben";
|
||||||
|
const b = await hole("/webdesign/preise.html");
|
||||||
|
pruefe("FREIGEGEBEN: oeffentliche Seite ohne Code erreichbar", b.res.status === 200);
|
||||||
|
const c = await hole("/webdesign/verwaltung.html");
|
||||||
|
pruefe("FREIGEGEBEN: Verwaltung bleibt trotzdem geschuetzt", c.res.status === 302);
|
||||||
|
const d = await hole("/webdesign/preise.html");
|
||||||
|
pruefe("FREIGEGEBEN: kein noindex mehr", !(d.res.headers.get("x-robots-tag") || "").includes("noindex"));
|
||||||
|
|
||||||
|
process.env.WEBDESIGN_STATUS = "vorabpruefung";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 7. Ohne Geheimnis muss zugesperrt werden, nicht aufgesperrt --- */
|
||||||
|
console.log("\n7. Fehlende Einstellung");
|
||||||
|
{
|
||||||
|
const gemerkt = process.env.SITE_ACCESS_SECRET;
|
||||||
|
delete process.env.SITE_ACCESS_SECRET;
|
||||||
|
const a = await hole("/webdesign/");
|
||||||
|
pruefe("ohne Geheimnis -> 503 statt offener Tuer", a.res.status === 503, `(war ${a.res.status})`);
|
||||||
|
pruefe("Inhalt bleibt geheim", !a.text.includes("GEHEIMER INHALT"));
|
||||||
|
process.env.SITE_ACCESS_SECRET = gemerkt;
|
||||||
|
}
|
||||||
|
|
||||||
|
server.close();
|
||||||
|
console.log(`\n=== ${bestanden} bestanden, ${gescheitert} gescheitert ===\n`);
|
||||||
|
process.exit(gescheitert === 0 ? 0 : 1);
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/* Prüft den Steuerzeichen-Filter aus webdesign-helfer.js isoliert.
|
||||||
|
Bewusst mit String.fromCharCode statt echten Steuerzeichen im Quelltext. */
|
||||||
|
const f = (w, max = 2000) =>
|
||||||
|
String(w).replace(/[^\P{Cc}\n\t]/gu, "").trim().slice(0, max);
|
||||||
|
|
||||||
|
const NUL = String.fromCharCode(0);
|
||||||
|
const BELL = String.fromCharCode(7);
|
||||||
|
const CR = String.fromCharCode(13);
|
||||||
|
const ESC = String.fromCharCode(27);
|
||||||
|
const DEL = String.fromCharCode(127);
|
||||||
|
const NL = "\n";
|
||||||
|
const TAB = "\t";
|
||||||
|
|
||||||
|
const proben = [
|
||||||
|
["einfacher Text", "Hallo Welt", "Hallo Welt"],
|
||||||
|
["Zeilenumbruch bleibt", "Zeile1" + NL + "Zeile2", "Zeile1" + NL + "Zeile2"],
|
||||||
|
["Tabulator bleibt", "Tab" + TAB + "hier", "Tab" + TAB + "hier"],
|
||||||
|
["Nullbyte raus", "Text" + NUL + "mehr", "Textmehr"],
|
||||||
|
["Bell raus", "Bell" + BELL + "X", "BellX"],
|
||||||
|
["Escape raus (Terminal-Trick)", ESC + "[31mrot", "[31mrot"],
|
||||||
|
["DEL raus", "A" + DEL + "B", "AB"],
|
||||||
|
/* Erwartung bewusst ohne führenden Umbruch: der Filter entfernt zwar nur
|
||||||
|
den Wagenrücklauf, das anschließende trim() räumt den dann alleine
|
||||||
|
stehenden Zeilenumbruch am Rand aber mit weg. Genau so ist es gewollt. */
|
||||||
|
["Wagenruecklauf raus", CR + NL + "Text" + CR, "Text"],
|
||||||
|
["Randleerzeichen weg", " Rand ", "Rand"],
|
||||||
|
["Umlaute bleiben", "Umlaute äöüß bleiben", "Umlaute äöüß bleiben"],
|
||||||
|
["Emoji bleibt", "Emoji \u{1F43A} bleibt", "Emoji \u{1F43A} bleibt"],
|
||||||
|
];
|
||||||
|
|
||||||
|
let ok = 0, schlecht = 0;
|
||||||
|
for (const [name, ein, erwartet] of proben) {
|
||||||
|
const raus = f(ein);
|
||||||
|
if (raus === erwartet) { ok++; console.log(" ok " + name); }
|
||||||
|
else { schlecht++; console.log(" FEHLT " + name + " -> " + JSON.stringify(raus) + " statt " + JSON.stringify(erwartet)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const lang = f("x".repeat(5000), 10);
|
||||||
|
if (lang.length === 10) { ok++; console.log(" ok Laengenbegrenzung greift"); }
|
||||||
|
else { schlecht++; console.log(" FEHLT Laengenbegrenzung: " + lang.length); }
|
||||||
|
|
||||||
|
console.log(`\n=== ${ok} ok, ${schlecht} falsch ===`);
|
||||||
|
process.exit(schlecht === 0 ? 0 : 1);
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
/* =====================================================================
|
||||||
|
webdesign-gate.js — Zugangsschutz für den Bereich /webdesign
|
||||||
|
(Masterplan S.4 "ZUGANGSSCHUTZ" und S.14 "VOR DER ÖFFENTLICHEN FREIGABE").
|
||||||
|
|
||||||
|
⚠️ WARUM DAS EINE EIGENE SCHRANKE IST UND NICHT gate.js MITBENUTZT
|
||||||
|
|
||||||
|
gate.js schützt die Universe-Seite. Seit dem öffentlichen Start am
|
||||||
|
21.08.2026 ist es dort aber wirkungslos: die allererste Prüfung in
|
||||||
|
gateMiddleware lautet
|
||||||
|
|
||||||
|
if (ermittleLiveStatus().isLive) return next();
|
||||||
|
|
||||||
|
und SITE_PUBLIC_LAUNCH_AT liegt in der Vergangenheit. Jede Anfrage geht
|
||||||
|
also ohne Code durch. Hätte man /webdesign einfach hinter gate.js
|
||||||
|
gehängt, wäre der komplette, ausdrücklich nicht-öffentliche
|
||||||
|
Webdesign-Bereich ab der ersten Sekunde für jeden im Netz lesbar
|
||||||
|
gewesen — inklusive Preisen, Kundendaten-Ansichten und Verwaltung.
|
||||||
|
|
||||||
|
Deshalb: eigene Middleware, eigenes Sitzungs-Cookie, eigener Status.
|
||||||
|
Der öffentliche Start des Universe hat hier bewusst KEINE Wirkung.
|
||||||
|
|
||||||
|
Die Zugangscodes selbst sind auf Wunsch (22.08.2026) dieselben wie im
|
||||||
|
Universe — aber über eigene Variablen überschreibbar, damit sie später
|
||||||
|
im Verwaltungsbereich getrennt geändert werden können, ohne den
|
||||||
|
Universe-Zugang anzufassen (Masterplan S.13: "Dogfather- und
|
||||||
|
VanVan-Code separat ändern").
|
||||||
|
===================================================================== */
|
||||||
|
|
||||||
|
import { createHmac, timingSafeEqual as cryptoTimingSafeEqual, randomUUID } from "node:crypto";
|
||||||
|
|
||||||
|
export const WD_COOKIE = "dogiwd_session";
|
||||||
|
export const WD_ROLLE_COOKIE = "dogiwd_rolle";
|
||||||
|
|
||||||
|
/* Sitzungsdauer. Bewusst länger als die 12 Stunden im Universe: hier wird
|
||||||
|
inhaltlich gearbeitet (Anfragen prüfen, Texte pflegen), und ein
|
||||||
|
Rauswurf mitten in einer halb ausgefüllten Verwaltungsmaske wäre
|
||||||
|
teurer als das kleine zusätzliche Risiko. Das Cookie selbst bleibt ein
|
||||||
|
reines Sitzungs-Cookie ohne maxAge — beim Schließen des Browsers ist
|
||||||
|
der Zugang trotzdem weg. */
|
||||||
|
export const WD_SESSION_STUNDEN = 24;
|
||||||
|
|
||||||
|
const WD_PRAEFIX = "/webdesign";
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------
|
||||||
|
Signierte Sitzung
|
||||||
|
Das Feld "bereich" ist der Grund, warum ein Universe-Token hier NICHT
|
||||||
|
funktioniert, obwohl beide dasselbe Geheimnis benutzen: die Prüfung
|
||||||
|
unten verlangt bereich === "webdesign". Ohne dieses Feld könnte man
|
||||||
|
sich mit einem gültigen Universe-Cookie in den geschützten Bereich
|
||||||
|
hineinlegen.
|
||||||
|
--------------------------------------------------------------------- */
|
||||||
|
function b64url(buf) {
|
||||||
|
return Buffer.from(buf).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
function b64urlZurueck(str) {
|
||||||
|
return Buffer.from(String(str).replace(/-/g, "+").replace(/_/g, "/"), "base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
function geheimnis() {
|
||||||
|
/* Eigenes Geheimnis bevorzugt; sonst das des Universe. Fehlt beides,
|
||||||
|
wird der Bereich komplett gesperrt (siehe unten) — NIE offen
|
||||||
|
durchgelassen. Das ist der bewusste Unterschied zu gate.js, das bei
|
||||||
|
fehlenden Variablen "fail-open" arbeitet: dort war das richtig (eine
|
||||||
|
öffentliche Seite darf nicht wegen einer fehlenden Variable
|
||||||
|
verschwinden), hier wäre es fatal. */
|
||||||
|
return process.env.WEBDESIGN_ACCESS_SECRET || process.env.SITE_ACCESS_SECRET || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wdSigniere(rolle, secret) {
|
||||||
|
const inhalt = JSON.stringify({
|
||||||
|
bereich: "webdesign",
|
||||||
|
rolle,
|
||||||
|
sid: randomUUID(), // eindeutige Sitzungskennung fürs Protokoll
|
||||||
|
exp: Date.now() + WD_SESSION_STUNDEN * 60 * 60 * 1000,
|
||||||
|
});
|
||||||
|
const nutzlast = b64url(Buffer.from(inhalt, "utf8"));
|
||||||
|
const sig = createHmac("sha256", secret).update(nutzlast).digest();
|
||||||
|
return `${nutzlast}.${b64url(sig)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wdPruefe(token, secret) {
|
||||||
|
if (!token || typeof token !== "string" || !token.includes(".")) return null;
|
||||||
|
const [nutzlast, sigB64] = token.split(".");
|
||||||
|
try {
|
||||||
|
const erwartet = createHmac("sha256", secret).update(nutzlast).digest();
|
||||||
|
const geliefert = b64urlZurueck(sigB64);
|
||||||
|
/* Längen zuerst vergleichen: crypto.timingSafeEqual WIRFT bei
|
||||||
|
unterschiedlich langen Puffern, statt false zu liefern. */
|
||||||
|
if (geliefert.length !== erwartet.length) return null;
|
||||||
|
if (!cryptoTimingSafeEqual(geliefert, erwartet)) return null;
|
||||||
|
|
||||||
|
const daten = JSON.parse(b64urlZurueck(nutzlast).toString("utf8"));
|
||||||
|
if (daten.bereich !== "webdesign") return null; // kein Universe-Token akzeptieren
|
||||||
|
if (!daten.exp || daten.exp < Date.now()) return null;
|
||||||
|
return daten;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------
|
||||||
|
Codes
|
||||||
|
Reihenfolge: eigener Webdesign-Code, sonst der Universe-Code.
|
||||||
|
--------------------------------------------------------------------- */
|
||||||
|
function codeFuer(person) {
|
||||||
|
const eigen = process.env[`WEBDESIGN_CODE_${person}`];
|
||||||
|
const universe = process.env[`SITE_ACCESS_CODE_${person}`];
|
||||||
|
return eigen || universe || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zeitsicherer Vergleich für Zeichenketten unterschiedlicher Länge —
|
||||||
|
dieselbe Umsetzung wie in gate.js und server-internal/lib/auth.js.
|
||||||
|
crypto.timingSafeEqual ist hier nicht direkt brauchbar, weil es bei
|
||||||
|
ungleicher Länge wirft und damit die Länge des Codes verraten würde. */
|
||||||
|
function gleichZeitsicher(a, b) {
|
||||||
|
a = String(a == null ? "" : a);
|
||||||
|
b = String(b == null ? "" : b);
|
||||||
|
const laenge = Math.max(a.length, b.length);
|
||||||
|
let abweichung = a.length === b.length ? 0 : 1;
|
||||||
|
for (let i = 0; i < laenge; i++) {
|
||||||
|
abweichung |= (i < a.length ? a.charCodeAt(i) : 0) ^ (i < b.length ? b.charCodeAt(i) : 0);
|
||||||
|
}
|
||||||
|
return abweichung === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------
|
||||||
|
Fehlversuchs-Sperre (Masterplan S.4, Punkt 5)
|
||||||
|
Im Arbeitsspeicher, pro echter Besucher-IP. Gleiche Begründung wie in
|
||||||
|
gate.js: dieser Server hat keine eigene Datenbank, und ein Neustart
|
||||||
|
setzt die Zählung zurück, was hier unkritisch ist.
|
||||||
|
--------------------------------------------------------------------- */
|
||||||
|
const WD_MAX_VERSUCHE = 5;
|
||||||
|
const WD_SPERRE_MS = 15 * 60 * 1000;
|
||||||
|
const versuche = new Map(); // schluessel -> { anzahl, gesperrtBis, zuletzt }
|
||||||
|
|
||||||
|
function istIpAdresse(wert) {
|
||||||
|
if (typeof wert !== "string") return false;
|
||||||
|
const s = wert.trim();
|
||||||
|
if (!s || s.length > 45) return false;
|
||||||
|
return /^(\d{1,3}\.){3}\d{1,3}$/.test(s) || (s.includes(":") && /^[0-9a-fA-F:]+$/.test(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CF-Connecting-IP ist hier verlässlich, weil die Firewall Port 80/443 nur
|
||||||
|
noch aus den Cloudflare-Netzen zulässt (nachgeprüft am 19.08.2026, siehe
|
||||||
|
ausführlichen Kommentar in gate.js).
|
||||||
|
⚠️ Sollte der Direktzugriff auf den Server jemals wieder geöffnet werden,
|
||||||
|
ist dieser Header wieder fälschbar und diese Stelle muss zurückgebaut
|
||||||
|
werden — genau wie die entsprechende Stelle in gate.js. */
|
||||||
|
function besucherSchluessel(req) {
|
||||||
|
const cf = req.headers["cf-connecting-ip"];
|
||||||
|
if (istIpAdresse(cf)) return cf.trim();
|
||||||
|
return req.ip || req.socket?.remoteAddress || "unbekannt";
|
||||||
|
}
|
||||||
|
|
||||||
|
function aufraeumen() {
|
||||||
|
const jetzt = Date.now();
|
||||||
|
for (const [k, e] of versuche) {
|
||||||
|
const weg = (!e.gesperrtBis || e.gesperrtBis < jetzt) && (e.zuletzt || 0) < jetzt - WD_SPERRE_MS;
|
||||||
|
if (weg) versuche.delete(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function istGesperrt(k) {
|
||||||
|
const e = versuche.get(k);
|
||||||
|
return !!(e && e.gesperrtBis && e.gesperrtBis > Date.now());
|
||||||
|
}
|
||||||
|
function restMinuten(k) {
|
||||||
|
const e = versuche.get(k);
|
||||||
|
if (!e || !e.gesperrtBis) return 0;
|
||||||
|
return Math.max(1, Math.ceil((e.gesperrtBis - Date.now()) / 60000));
|
||||||
|
}
|
||||||
|
function fehlversuch(k) {
|
||||||
|
const e = versuche.get(k) || { anzahl: 0, gesperrtBis: null };
|
||||||
|
e.anzahl += 1;
|
||||||
|
e.zuletzt = Date.now();
|
||||||
|
if (e.anzahl >= WD_MAX_VERSUCHE) e.gesperrtBis = Date.now() + WD_SPERRE_MS;
|
||||||
|
versuche.set(k, e);
|
||||||
|
}
|
||||||
|
function zuruecksetzen(k) { versuche.delete(k); }
|
||||||
|
|
||||||
|
/* Protokollierung (Masterplan S.4: "Fehlversuche werden begrenzt,
|
||||||
|
protokolliert"). Landet in journalctl -u dogiweb.service. Der Code
|
||||||
|
selbst wird NIE mitgeschrieben — auch nicht ein falsch eingegebener,
|
||||||
|
weil das erfahrungsgemäß oft ein richtiger Code mit Tippfehler ist. */
|
||||||
|
function protokoll(ereignis, felder) {
|
||||||
|
const zeit = new Date().toISOString();
|
||||||
|
const rest = Object.entries(felder || {}).map(([k, v]) => `${k}=${v}`).join(" ");
|
||||||
|
console.log(`[webdesign-zugang] ${zeit} ${ereignis} ${rest}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------
|
||||||
|
Zugangsstatus (Masterplan S.4 "ZUGANGSSTATUS")
|
||||||
|
entwurf — komplett dicht, auch mit Code kein Zugang
|
||||||
|
vorabpruefung — Zugang nur über die persönlichen Codes (Standard)
|
||||||
|
freigegeben — öffentliche Inhalte frei; Portal und Verwaltung
|
||||||
|
bleiben durch ihre eigenen Anmeldungen geschützt
|
||||||
|
--------------------------------------------------------------------- */
|
||||||
|
function status() {
|
||||||
|
const wert = String(process.env.WEBDESIGN_STATUS || "vorabpruefung").toLowerCase().trim();
|
||||||
|
return ["entwurf", "vorabpruefung", "freigegeben"].includes(wert) ? wert : "vorabpruefung";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Diese Seiten bleiben auch nach der Freigabe geschützt: sie haben eine
|
||||||
|
eigene Anmeldung, sollen aber zusätzlich gar nicht erst ohne Zugangscode
|
||||||
|
erreichbar sein, solange der Bereich in der Vorabprüfung ist. */
|
||||||
|
const IMMER_GESCHUETZT = ["/webdesign/verwaltung.html"];
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------
|
||||||
|
Anmelde-Endpunkt
|
||||||
|
--------------------------------------------------------------------- */
|
||||||
|
function sicherePfadAngabe(wert) {
|
||||||
|
/* Nur eigene Pfade INNERHALB des Webdesign-Bereichs. "//fremde-seite.de"
|
||||||
|
ist eine protokoll-relative URL, die der Browser als fremde Domain
|
||||||
|
auflöst — genau die Lücke, die am 07.08.2026 in gate.js gefunden
|
||||||
|
wurde. Hier von vornherein ausgeschlossen, zusätzlich verschärft auf
|
||||||
|
das /webdesign-Präfix. */
|
||||||
|
return typeof wert === "string" &&
|
||||||
|
wert.startsWith(WD_PRAEFIX + "/") &&
|
||||||
|
!wert.startsWith("//") &&
|
||||||
|
!wert.includes("\\") &&
|
||||||
|
!wert.includes("..");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function wdAnmeldung(req, res) {
|
||||||
|
const schluessel = besucherSchluessel(req);
|
||||||
|
aufraeumen();
|
||||||
|
|
||||||
|
if (istGesperrt(schluessel)) {
|
||||||
|
const min = restMinuten(schluessel);
|
||||||
|
protokoll("gesperrt", { ip: schluessel, restMin: min });
|
||||||
|
return res.status(429).json({
|
||||||
|
ok: false,
|
||||||
|
error: `Zu viele Fehlversuche. Bitte ${min} Minute${min === 1 ? "" : "n"} warten – dein Code ist danach wieder gültig.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = geheimnis();
|
||||||
|
if (!secret) {
|
||||||
|
protokoll("kein-geheimnis", { ip: schluessel });
|
||||||
|
return res.status(503).json({ ok: false, error: "Zugang ist gerade nicht möglich. Bitte Dogfather Bescheid geben." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status() === "entwurf") {
|
||||||
|
protokoll("entwurf-abgewiesen", { ip: schluessel });
|
||||||
|
return res.status(403).json({ ok: false, error: "Der Bereich ist gerade vollständig gesperrt." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const eingabe = String((req.body && req.body.code) || "").trim();
|
||||||
|
const ziel = sicherePfadAngabe(req.body && req.body.next) ? req.body.next : "/webdesign/";
|
||||||
|
|
||||||
|
let rolle = null;
|
||||||
|
const codeDogi = codeFuer("DOGI");
|
||||||
|
const codeVanvan = codeFuer("VANVAN");
|
||||||
|
if (codeDogi && gleichZeitsicher(eingabe, codeDogi)) rolle = "dogfather";
|
||||||
|
else if (codeVanvan && gleichZeitsicher(eingabe, codeVanvan)) rolle = "vanvan";
|
||||||
|
|
||||||
|
if (!rolle) {
|
||||||
|
fehlversuch(schluessel);
|
||||||
|
const e = versuche.get(schluessel);
|
||||||
|
protokoll("falscher-code", { ip: schluessel, versuch: e ? e.anzahl : 1 });
|
||||||
|
return res.status(401).json({ ok: false, error: "Falscher Zugangscode." });
|
||||||
|
}
|
||||||
|
|
||||||
|
zuruecksetzen(schluessel);
|
||||||
|
protokoll("angemeldet", { ip: schluessel, rolle });
|
||||||
|
|
||||||
|
const token = wdSigniere(rolle, secret);
|
||||||
|
/* httpOnly: das Sitzungs-Cookie darf für JavaScript unsichtbar sein —
|
||||||
|
der Server prüft es, die Seite braucht es nie zu lesen.
|
||||||
|
Kein maxAge → gilt nur für die laufende Browser-Sitzung. */
|
||||||
|
res.cookie(WD_COOKIE, token, { path: "/", httpOnly: true, secure: true, sameSite: "lax" });
|
||||||
|
/* Das Rollen-Cookie ist bewusst LESBAR: die Seite begrüßt damit
|
||||||
|
persönlich (Masterplan S.4). Es trägt keinerlei Berechtigung — wer es
|
||||||
|
fälscht, ändert nur die Anrede, nicht den Zugang. */
|
||||||
|
res.cookie(WD_ROLLE_COOKIE, rolle, { path: "/", secure: true, sameSite: "lax" });
|
||||||
|
return res.json({ ok: true, rolle, next: ziel });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wdAbmeldung(req, res) {
|
||||||
|
res.clearCookie(WD_COOKIE, { path: "/" });
|
||||||
|
res.clearCookie(WD_ROLLE_COOKIE, { path: "/" });
|
||||||
|
protokoll("abgemeldet", { ip: besucherSchluessel(req) });
|
||||||
|
return res.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------
|
||||||
|
Die eigentliche Middleware
|
||||||
|
--------------------------------------------------------------------- */
|
||||||
|
export function webdesignGate(req, res, next) {
|
||||||
|
const pfad = req.path;
|
||||||
|
|
||||||
|
// Alles außerhalb von /webdesign geht diese Schranke nichts an.
|
||||||
|
if (pfad !== WD_PRAEFIX && !pfad.startsWith(WD_PRAEFIX + "/")) return next();
|
||||||
|
|
||||||
|
/* Noindex für den GESAMTEN Bereich, unabhängig vom Anmeldezustand
|
||||||
|
(Masterplan S.14: "Noindex, Zugriffsschutz und interne Linksperre
|
||||||
|
bleiben aktiv, bis Dogfather den Launch ausdrücklich bestätigt").
|
||||||
|
Als HTTP-Header statt nur als <meta>, weil ein Header auch für
|
||||||
|
Nicht-HTML-Antworten (PDF, JSON) gilt und von Suchmaschinen auch dann
|
||||||
|
gelesen wird, wenn die Seite gar nicht erst ausgeliefert wird. */
|
||||||
|
if (status() !== "freigegeben") {
|
||||||
|
res.setHeader("X-Robots-Tag", "noindex, nofollow, noarchive");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anmelden / Abmelden
|
||||||
|
if (pfad === "/webdesign/zugang-pruefen" && req.method === "POST") {
|
||||||
|
return Promise.resolve(wdAnmeldung(req, res)).catch(next);
|
||||||
|
}
|
||||||
|
if (pfad === "/webdesign/zugang-beenden" && req.method === "POST") {
|
||||||
|
return wdAbmeldung(req, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wer bin ich? (für die persönliche Begrüßung)
|
||||||
|
if (pfad === "/webdesign/zugang-status" && req.method === "GET") {
|
||||||
|
const secret = geheimnis();
|
||||||
|
const daten = secret ? wdPruefe(req.cookies?.[WD_COOKIE], secret) : null;
|
||||||
|
return res.json({
|
||||||
|
ok: true,
|
||||||
|
angemeldet: !!daten,
|
||||||
|
rolle: daten ? daten.rolle : null,
|
||||||
|
status: status(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = geheimnis();
|
||||||
|
if (!secret) {
|
||||||
|
/* Bewusst DICHT statt offen (Gegenteil von gate.js, Begründung oben).
|
||||||
|
Ein nicht-öffentlicher Bereich darf niemals wegen einer fehlenden
|
||||||
|
Einstellung aufgehen. */
|
||||||
|
protokoll("gesperrt-kein-geheimnis", { pfad });
|
||||||
|
return res
|
||||||
|
.status(503)
|
||||||
|
.type("text/plain; charset=utf-8")
|
||||||
|
.send("Der Webdesign-Bereich ist noch nicht eingerichtet.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sitzung = wdPruefe(req.cookies?.[WD_COOKIE], secret);
|
||||||
|
|
||||||
|
// Freigegeben: öffentliche Seiten frei, die Verwaltung bleibt geschützt.
|
||||||
|
if (status() === "freigegeben" && !IMMER_GESCHUETZT.includes(pfad)) return next();
|
||||||
|
|
||||||
|
if (sitzung) {
|
||||||
|
// Für nachgelagerte Handler verfügbar machen (z.B. Rollenprüfung).
|
||||||
|
req.wdRolle = sitzung.rolle;
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Die Zugangsseite selbst und alles, was sie zum Anzeigen braucht, muss
|
||||||
|
ohne Sitzung erreichbar sein — sonst sieht man eine nackte Seite ohne
|
||||||
|
Stil und ohne Logo. Die geteilten /assets liegen außerhalb von
|
||||||
|
/webdesign und werden von dieser Schranke ohnehin nicht angefasst. */
|
||||||
|
/* App-Bausteine müssen ebenfalls ohne Sitzung erreichbar sein.
|
||||||
|
Begründung (Lektion aus dem Universe, gate.html am 19.08.2026):
|
||||||
|
Die Zugangswand ist der ERSTE Bildschirm, den man überhaupt sieht —
|
||||||
|
also auch der, von dem aus installiert wird. Ohne erreichbares
|
||||||
|
Manifest und ohne registrierbaren Service Worker bietet Chrome dort
|
||||||
|
gar kein "App installieren" an, und wer vorher installiert, bekommt
|
||||||
|
nur eine nackte Verknüpfung ohne Symbol.
|
||||||
|
Unbedenklich: beide Dateien enthalten keinerlei Kunden-, Projekt-
|
||||||
|
oder Preisdaten, und sw.js speichert bewusst keine HTML-Seiten
|
||||||
|
zwischen (siehe Kopf von webdesign/sw.js).
|
||||||
|
Würde man sie sperren, lieferte die Schranke stattdessen eine
|
||||||
|
302-Umleitung auf zugang.html — der Browser bekäme HTML statt
|
||||||
|
JavaScript und bräche die Registrierung mit einem MIME-Fehler ab. */
|
||||||
|
if (
|
||||||
|
pfad === "/webdesign/zugang.html" ||
|
||||||
|
pfad === "/webdesign/sw.js" ||
|
||||||
|
pfad === "/webdesign/app.webmanifest"
|
||||||
|
) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alles andere zur Zugangswand. Der ursprünglich gewünschte Pfad wird
|
||||||
|
mitgegeben, damit man nach der Eingabe genau dort landet, wo man
|
||||||
|
hinwollte (und nicht immer auf der Startseite). */
|
||||||
|
const gewuenscht = pfad === WD_PRAEFIX || pfad === WD_PRAEFIX + "/" ? "/webdesign/" : pfad;
|
||||||
|
return res.redirect(302, `/webdesign/zugang.html?next=${encodeURIComponent(gewuenscht)}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de" data-i18n-titel="seiten_titel">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Ablauf — Dogfather Webdesign</title>
|
||||||
|
<meta name="description" content="Von der Anfrage bis zur Übergabe: sieben Schritte, jeweils mit dem, was du machst, was ich mache und was am Ende dabei herauskommt." />
|
||||||
|
<meta name="robots" content="noindex, nofollow, noarchive" />
|
||||||
|
<link rel="icon" type="image/png" href="/assets/img/favicon.png" />
|
||||||
|
<meta name="theme-color" content="#05070b" />
|
||||||
|
<link rel="manifest" href="/webdesign/app.webmanifest" />
|
||||||
|
<link rel="apple-touch-icon" href="/assets/img/wd-app-180.png" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Dogfather Webdesign" />
|
||||||
|
<link rel="stylesheet" href="/assets/css/main.css?v=20260822wd" />
|
||||||
|
<link rel="stylesheet" href="/assets/css/webdesign.css?v=20260822wd" />
|
||||||
|
<style>
|
||||||
|
/* Zeitstrahl: die Linie verbindet die Nummernkreise. Sie liegt hinter
|
||||||
|
den Kreisen (z-index) und endet beim letzten Schritt, damit sie nicht
|
||||||
|
ins Leere weiterläuft. */
|
||||||
|
.abl-strahl { position: relative; }
|
||||||
|
.abl-strahl::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute; left: 23px; top: 24px; bottom: 24px; width: 2px;
|
||||||
|
background: linear-gradient(180deg, var(--wd-blau-tief), var(--wd-lila-tief));
|
||||||
|
opacity: .35;
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) { .abl-strahl::before { display: none; } }
|
||||||
|
|
||||||
|
.abl-schritt { position: relative; display: grid; grid-template-columns: 48px 1fr; gap: 1.2rem; margin-bottom: 1.1rem; }
|
||||||
|
@media (max-width: 640px) { .abl-schritt { grid-template-columns: 1fr; gap: .7rem; } }
|
||||||
|
|
||||||
|
.abl-nr {
|
||||||
|
position: relative; z-index: 1;
|
||||||
|
width: 48px; height: 48px; border-radius: 50%;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-family: var(--font-head); font-weight: 800; font-size: 1.1rem;
|
||||||
|
color: var(--wd-blau); background: #0b1320;
|
||||||
|
border: 2px solid var(--wd-rand-stark);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.abl-schritt--lila .abl-nr { color: var(--wd-lila); border-color: rgba(183,157,255,.4); }
|
||||||
|
@media (max-width: 640px) { .abl-nr { width: 40px; height: 40px; font-size: 1rem; } }
|
||||||
|
|
||||||
|
.abl-karte h2 { font-size: clamp(1.25rem, 3vw, 1.6rem); margin-bottom: .9em; }
|
||||||
|
.abl-rollen { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; }
|
||||||
|
@media (max-width: 820px) { .abl-rollen { grid-template-columns: 1fr; gap: .8rem; } }
|
||||||
|
.abl-rolle h3 { font-size: .72rem; letter-spacing: .13em; text-transform: uppercase; margin-bottom: .35em; color: var(--wd-blau); }
|
||||||
|
.abl-schritt--lila .abl-rolle h3 { color: var(--wd-lila); }
|
||||||
|
/* Die dritte Spalte ist das Ergebnis — bekommt einen leichten Rahmen,
|
||||||
|
damit sie sich von "wer macht was" abhebt. */
|
||||||
|
.abl-rolle--erg {
|
||||||
|
background: rgba(127,208,232,.05); border: 1px solid var(--wd-rand);
|
||||||
|
border-radius: 12px; padding: .8rem .9rem; margin: -.8rem -.9rem;
|
||||||
|
}
|
||||||
|
.abl-rolle p { margin: 0; font-size: .98rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="wd">
|
||||||
|
|
||||||
|
<div id="wd-kopf"></div>
|
||||||
|
|
||||||
|
<main id="wd-inhalt">
|
||||||
|
|
||||||
|
<section class="wd-abschnitt wd-abschnitt--eng" style="padding-top:clamp(2.4rem,6vw,4rem)">
|
||||||
|
<div class="wd-container">
|
||||||
|
<span class="wd-eyebrow" data-i18n="ab_eyebrow">Ablauf</span>
|
||||||
|
<h1 data-i18n="ab_h1">Sieben Schritte, keine Überraschungen</h1>
|
||||||
|
<p class="wd-lead" style="max-width:72ch" data-i18n="ab_lead">Jeder Schritt endet mit etwas, das du sehen und prüfen kannst. Du weißt jederzeit, wo dein Projekt steht und was als Nächstes von wem gebraucht wird.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="wd-abschnitt" style="padding-top:0">
|
||||||
|
<div class="wd-container">
|
||||||
|
<div class="abl-strahl">
|
||||||
|
|
||||||
|
<div class="abl-schritt wd-auf">
|
||||||
|
<span class="abl-nr" aria-hidden="true">01</span>
|
||||||
|
<div class="wd-karte abl-karte">
|
||||||
|
<h2 data-i18n="s1_h2">Anfrage</h2>
|
||||||
|
<div class="abl-rollen">
|
||||||
|
<div class="abl-rolle"><h3 data-i18n="du_machst">Du machst</h3><p data-i18n="s1_du">Formular ausfüllen — Ziel, Umfang, Budgetrahmen, Wunschtermin.</p></div>
|
||||||
|
<div class="abl-rolle"><h3 data-i18n="ich_mache">Ich mache</h3><p data-i18n="s1_ich">Anfrage prüfen und innerhalb weniger Tage melden.</p></div>
|
||||||
|
<div class="abl-rolle abl-rolle--erg"><h3 data-i18n="ergebnis">Am Ende steht</h3><p data-i18n="s1_erg">Eine Bestätigung mit Zusammenfassung dessen, was du angegeben hast.</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="abl-schritt abl-schritt--lila wd-auf">
|
||||||
|
<span class="abl-nr" aria-hidden="true">02</span>
|
||||||
|
<div class="wd-karte wd-karte--lila abl-karte">
|
||||||
|
<h2 data-i18n="s2_h2">Briefing</h2>
|
||||||
|
<div class="abl-rollen">
|
||||||
|
<div class="abl-rolle"><h3 data-i18n="du_machst">Du machst</h3><p data-i18n="s2_du">Fragen beantworten und sagen, was du schon an Texten, Bildern und Logo hast.</p></div>
|
||||||
|
<div class="abl-rolle"><h3 data-i18n="ich_mache">Ich mache</h3><p data-i18n="s2_ich">Nachfragen, wo etwas unklar ist — lieber jetzt als mitten im Bau.</p></div>
|
||||||
|
<div class="abl-rolle abl-rolle--erg"><h3 data-i18n="ergebnis">Am Ende steht</h3><p data-i18n="s2_erg">Ein gemeinsames Bild davon, was die Seite können muss.</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="abl-schritt wd-auf">
|
||||||
|
<span class="abl-nr" aria-hidden="true">03</span>
|
||||||
|
<div class="wd-karte abl-karte">
|
||||||
|
<h2 data-i18n="s3_h2">Angebot & Anzahlung</h2>
|
||||||
|
<div class="abl-rollen">
|
||||||
|
<div class="abl-rolle"><h3 data-i18n="du_machst">Du machst</h3><p data-i18n="s3_du">Angebot lesen, Fragen stellen, annehmen oder ablehnen — und bei Annahme die 30 % überweisen.</p></div>
|
||||||
|
<div class="abl-rolle"><h3 data-i18n="ich_mache">Ich mache</h3><p data-i18n="s3_ich">Preis, Umfang, Zeitrahmen und alles, was extra kostet, schriftlich festhalten.</p></div>
|
||||||
|
<div class="abl-rolle abl-rolle--erg"><h3 data-i18n="ergebnis">Am Ende steht</h3><p data-i18n="s3_erg">Ein reservierter Starttermin. Vorher wird kein Zeitfenster blockiert.</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="abl-schritt abl-schritt--lila wd-auf">
|
||||||
|
<span class="abl-nr" aria-hidden="true">04</span>
|
||||||
|
<div class="wd-karte wd-karte--lila abl-karte">
|
||||||
|
<h2 data-i18n="s4_h2">Design</h2>
|
||||||
|
<div class="abl-rollen">
|
||||||
|
<div class="abl-rolle"><h3 data-i18n="du_machst">Du machst</h3><p data-i18n="s4_du">Entwurf ansehen und Rückmeldung geben — am besten gesammelt statt in vielen einzelnen Nachrichten.</p></div>
|
||||||
|
<div class="abl-rolle"><h3 data-i18n="ich_mache">Ich mache</h3><p data-i18n="s4_ich">Aufbau, Farben und Schriften entwerfen — und dir Handy- und Computeransicht zeigen, nicht nur eine davon.</p></div>
|
||||||
|
<div class="abl-rolle abl-rolle--erg"><h3 data-i18n="ergebnis">Am Ende steht</h3><p data-i18n="s4_erg">Ein freigegebener Entwurf. Erst danach wird gebaut.</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="abl-schritt wd-auf">
|
||||||
|
<span class="abl-nr" aria-hidden="true">05</span>
|
||||||
|
<div class="wd-karte abl-karte">
|
||||||
|
<h2 data-i18n="s5_h2">Entwicklung</h2>
|
||||||
|
<div class="abl-rollen">
|
||||||
|
<div class="abl-rolle"><h3 data-i18n="du_machst">Du machst</h3><p data-i18n="s5_du">Fehlende Inhalte nachliefern, falls noch welche offen sind.</p></div>
|
||||||
|
<div class="abl-rolle"><h3 data-i18n="ich_mache">Ich mache</h3><p data-i18n="s5_ich">Die Seite bauen — mit den echten Inhalten, nicht mit Blindtext.</p></div>
|
||||||
|
<div class="abl-rolle abl-rolle--erg"><h3 data-i18n="ergebnis">Am Ende steht</h3><p data-i18n="s5_erg">Eine benutzbare Seite auf einer Vorschauadresse, die nur du und ich kennen.</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="abl-schritt abl-schritt--lila wd-auf">
|
||||||
|
<span class="abl-nr" aria-hidden="true">06</span>
|
||||||
|
<div class="wd-karte wd-karte--lila abl-karte">
|
||||||
|
<h2 data-i18n="s6_h2">Tests</h2>
|
||||||
|
<div class="abl-rollen">
|
||||||
|
<div class="abl-rolle"><h3 data-i18n="du_machst">Du machst</h3><p data-i18n="s6_du">Selbst durchklicken — besonders auf deinem eigenen Handy.</p></div>
|
||||||
|
<div class="abl-rolle"><h3 data-i18n="ich_mache">Ich mache</h3><p data-i18n="s6_ich">Jedes Formular, jeden Link und jeden Fehlerfall durchspielen, auf Handy, Tablet und Computer.</p></div>
|
||||||
|
<div class="abl-rolle abl-rolle--erg"><h3 data-i18n="ergebnis">Am Ende steht</h3><p data-i18n="s6_erg">Eine Liste dessen, was geprüft wurde — und was bewusst nicht enthalten ist.</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="abl-schritt wd-auf">
|
||||||
|
<span class="abl-nr" aria-hidden="true">07</span>
|
||||||
|
<div class="wd-karte abl-karte">
|
||||||
|
<h2 data-i18n="s7_h2">Freigabe & Übergabe</h2>
|
||||||
|
<div class="abl-rollen">
|
||||||
|
<div class="abl-rolle"><h3 data-i18n="du_machst">Du machst</h3><p data-i18n="s7_du">Ausdrücklich freigeben und den Restbetrag begleichen.</p></div>
|
||||||
|
<div class="abl-rolle"><h3 data-i18n="ich_mache">Ich mache</h3><p data-i18n="s7_ich">Live schalten, dich einweisen und die Zugänge übergeben.</p></div>
|
||||||
|
<div class="abl-rolle abl-rolle--erg"><h3 data-i18n="ergebnis">Am Ende steht</h3><p data-i18n="s7_erg">Deine Seite, deine Zugänge, deine Nutzungsrechte. Betreuung ist optional, kein Zwang.</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wd-hinweis wd-auf" style="margin-top:1.6rem">
|
||||||
|
<p class="wd-hinweis-titel" data-i18n="ab_wichtig_titel">Keine Veröffentlichung ohne deine Freigabe</p>
|
||||||
|
<p data-i18n="ab_wichtig_text">Die Seite geht ausschließlich dann online, wenn du das ausdrücklich sagst. Das gilt auch für die Aufnahme ins Portfolio — beides ist getrennt und beides brauchst du nicht zu erlauben.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="wd-abschnitt wd-abschnitt--gehoben">
|
||||||
|
<div class="wd-container wd-container--schmal">
|
||||||
|
<h2 class="wd-auf" data-i18n="ab_dauer_h2">Wie lange dauert das insgesamt?</h2>
|
||||||
|
<p class="wd-lead wd-auf" data-i18n="ab_dauer_p">Die Zeitrahmen auf der Preisseite gelten ab dem Eingang der Anzahlung — nicht ab der ersten Nachricht. Der häufigste Grund für Verzögerungen ist nicht die Technik, sondern dass Texte oder Bilder noch fehlen. Deshalb wird im Formular gleich danach gefragt.</p>
|
||||||
|
<div class="wd-btn-reihe">
|
||||||
|
<a class="wd-btn wd-btn--haupt" href="/webdesign/anfrage.html" data-i18n="nav_anfrage">Projekt anfragen</a>
|
||||||
|
<a class="wd-btn wd-btn--zweit" href="/webdesign/preise.html" data-i18n="ab_zu_preise">Zu den Zeitrahmen</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="wd-fuss"></div>
|
||||||
|
|
||||||
|
<script src="/assets/js/i18n-wd-ablauf.js?v=20260822wd"></script>
|
||||||
|
<script src="/assets/js/wd-core.js?v=20260822wd"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "Dogfather Webdesign",
|
||||||
|
"short_name": "DF Webdesign",
|
||||||
|
"description": "Individuell gebaute Webseiten, persönlich umgesetzt. Projekte anfragen, Angebote und Dateien im eigenen Kundenbereich verwalten.",
|
||||||
|
"lang": "de",
|
||||||
|
"dir": "ltr",
|
||||||
|
"start_url": "/webdesign/",
|
||||||
|
"scope": "/webdesign/",
|
||||||
|
"id": "/webdesign/",
|
||||||
|
"display": "standalone",
|
||||||
|
"display_override": ["window-controls-overlay", "standalone", "minimal-ui"],
|
||||||
|
"orientation": "any",
|
||||||
|
"background_color": "#05070b",
|
||||||
|
"theme_color": "#05070b",
|
||||||
|
"categories": ["business", "productivity"],
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/assets/img/wd-app-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||||
|
{ "src": "/assets/img/wd-app-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||||
|
{ "src": "/assets/img/wd-app-512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||||
|
],
|
||||||
|
"shortcuts": [
|
||||||
|
{
|
||||||
|
"name": "Projekt anfragen",
|
||||||
|
"short_name": "Anfrage",
|
||||||
|
"url": "/webdesign/anfrage.html",
|
||||||
|
"icons": [{ "src": "/assets/img/wd-app-192.png", "sizes": "192x192" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Kundenportal",
|
||||||
|
"short_name": "Portal",
|
||||||
|
"url": "/webdesign/portal.html",
|
||||||
|
"icons": [{ "src": "/assets/img/wd-app-192.png", "sizes": "192x192" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Verwaltung",
|
||||||
|
"short_name": "Verwaltung",
|
||||||
|
"url": "/webdesign/verwaltung.html",
|
||||||
|
"icons": [{ "src": "/assets/img/wd-app-192.png", "sizes": "192x192" }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de" data-i18n-titel="seiten_titel">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Häufige Fragen — Dogfather Webdesign</title>
|
||||||
|
<meta name="description" content="Preise, Dauer, Inhalte, Änderungen, Hosting, Rechte, Zahlung und Veröffentlichung — offen beantwortet." />
|
||||||
|
<meta name="robots" content="noindex, nofollow, noarchive" />
|
||||||
|
<link rel="icon" type="image/png" href="/assets/img/favicon.png" />
|
||||||
|
<meta name="theme-color" content="#05070b" />
|
||||||
|
<link rel="manifest" href="/webdesign/app.webmanifest" />
|
||||||
|
<link rel="apple-touch-icon" href="/assets/img/wd-app-180.png" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Dogfather Webdesign" />
|
||||||
|
<link rel="stylesheet" href="/assets/css/main.css?v=20260822wd" />
|
||||||
|
<link rel="stylesheet" href="/assets/css/webdesign.css?v=20260822wd" />
|
||||||
|
<style>
|
||||||
|
.fq-thema { scroll-margin-top: 90px; }
|
||||||
|
.fq-thema + .fq-thema { margin-top: clamp(2rem, 4vw, 3rem); }
|
||||||
|
.fq-thema > h2 {
|
||||||
|
font-size: clamp(1.2rem, 2.6vw, 1.5rem);
|
||||||
|
padding-bottom: .5rem; margin-bottom: 1.1rem;
|
||||||
|
border-bottom: 1px solid var(--wd-rand);
|
||||||
|
}
|
||||||
|
/* Sprungmarken wie auf der Leistungsseite: waagerecht wischbar statt
|
||||||
|
über vier Zeilen umbrechend. */
|
||||||
|
.fq-sprung { display: flex; gap: .5rem; overflow-x: auto; padding: .2rem .2rem 1rem; scroll-snap-type: x proximity; }
|
||||||
|
.fq-sprung a {
|
||||||
|
flex: 0 0 auto; scroll-snap-align: start;
|
||||||
|
padding: .6rem 1rem; min-height: 44px; display: inline-flex; align-items: center;
|
||||||
|
border-radius: 999px; border: 1px solid var(--wd-rand);
|
||||||
|
font-size: .92rem; font-weight: 600; color: var(--wd-text-mid); white-space: nowrap;
|
||||||
|
}
|
||||||
|
.fq-sprung a:hover { color: var(--wd-text); border-color: var(--wd-rand-stark); background: rgba(127,208,232,.06); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="wd">
|
||||||
|
|
||||||
|
<div id="wd-kopf"></div>
|
||||||
|
|
||||||
|
<main id="wd-inhalt">
|
||||||
|
|
||||||
|
<section class="wd-abschnitt wd-abschnitt--eng" style="padding-top:clamp(2.4rem,6vw,4rem)">
|
||||||
|
<div class="wd-container wd-container--schmal">
|
||||||
|
<span class="wd-eyebrow" data-i18n="fq_eyebrow">Häufige Fragen</span>
|
||||||
|
<h1 data-i18n="fq_h1">Alles, was vorher geklärt sein sollte</h1>
|
||||||
|
<p class="wd-lead" data-i18n="fq_lead">Sortiert nach Thema. Wenn deine Frage nicht dabei ist, schreib sie einfach ins Anfrageformular — sie landet dann hier, wenn sie öfter kommt.</p>
|
||||||
|
|
||||||
|
<nav class="fq-sprung" aria-label="Themen">
|
||||||
|
<a href="#preis" data-i18n="fq_t_preis">Preise & Zahlung</a>
|
||||||
|
<a href="#dauer" data-i18n="fq_t_dauer">Dauer & Änderungen</a>
|
||||||
|
<a href="#inhalt" data-i18n="fq_t_inhalt">Inhalte & Zusammenarbeit</a>
|
||||||
|
<a href="#technik" data-i18n="fq_t_technik">Hosting, Domain & Rechte</a>
|
||||||
|
<a href="#veroeff" data-i18n="fq_t_veroeff">Veröffentlichung & danach</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="wd-abschnitt" style="padding-top:0">
|
||||||
|
<div class="wd-container wd-container--schmal">
|
||||||
|
|
||||||
|
<div class="fq-thema wd-auf" id="preis">
|
||||||
|
<h2 data-i18n="fq_t_preis">Preise & Zahlung</h2>
|
||||||
|
<details class="wd-faq">
|
||||||
|
<summary data-i18n="f1_f">Warum steht kein Festpreis auf der Seite?</summary>
|
||||||
|
<div class="wd-faq-inhalt"><p data-i18n="f1_a">Weil ein ehrlicher Festpreis erst möglich ist, wenn der Umfang bekannt ist.</p></div>
|
||||||
|
</details>
|
||||||
|
<details class="wd-faq">
|
||||||
|
<summary data-i18n="f2_f">Was passiert, wenn ich nach der Anzahlung abspringe?</summary>
|
||||||
|
<div class="wd-faq-inhalt"><p data-i18n="f2_a">Die Anzahlung hat den Zweck, dein Zeitfenster zu reservieren.</p></div>
|
||||||
|
</details>
|
||||||
|
<details class="wd-faq">
|
||||||
|
<summary data-i18n="f3_f">Kommen später versteckte Kosten dazu?</summary>
|
||||||
|
<div class="wd-faq-inhalt"><p data-i18n="f3_a">Von mir nicht — was extra kostet, steht vorher im Angebot.</p></div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fq-thema wd-auf" id="dauer">
|
||||||
|
<h2 data-i18n="fq_t_dauer">Dauer & Änderungen</h2>
|
||||||
|
<details class="wd-faq">
|
||||||
|
<summary data-i18n="f4_f">Woran liegt es, wenn ein Projekt länger dauert?</summary>
|
||||||
|
<div class="wd-faq-inhalt"><p data-i18n="f4_a">In den allermeisten Fällen an fehlenden Inhalten, nicht an der Technik.</p></div>
|
||||||
|
</details>
|
||||||
|
<details class="wd-faq">
|
||||||
|
<summary data-i18n="f5_f">Wie viele Änderungen sind drin?</summary>
|
||||||
|
<div class="wd-faq-inhalt"><p data-i18n="f5_a">Typischerweise zwei Feedbackrunden.</p></div>
|
||||||
|
</details>
|
||||||
|
<details class="wd-faq">
|
||||||
|
<summary data-i18n="f6_f">Kann ich Texte später selbst ändern?</summary>
|
||||||
|
<div class="wd-faq-inhalt"><p data-i18n="f6_a">Ja, das ist ausdrücklich das Ziel.</p></div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fq-thema wd-auf" id="inhalt">
|
||||||
|
<h2 data-i18n="fq_t_inhalt">Inhalte & Zusammenarbeit</h2>
|
||||||
|
<details class="wd-faq">
|
||||||
|
<summary data-i18n="f7_f">Ich habe kein Logo. Ist das ein Problem?</summary>
|
||||||
|
<div class="wd-faq-inhalt"><p data-i18n="f7_a">Kein Hindernis, aber auch nicht kostenlos.</p></div>
|
||||||
|
</details>
|
||||||
|
<details class="wd-faq">
|
||||||
|
<summary data-i18n="f8_f">Kann ich Bilder aus dem Internet verwenden?</summary>
|
||||||
|
<div class="wd-faq-inhalt"><p data-i18n="f8_a">Nur wenn die Lizenz das erlaubt.</p></div>
|
||||||
|
</details>
|
||||||
|
<details class="wd-faq">
|
||||||
|
<summary data-i18n="f9_f">Wie läuft die Kommunikation während des Projekts?</summary>
|
||||||
|
<div class="wd-faq-inhalt"><p data-i18n="f9_a">Über deinen eigenen Kundenbereich auf dieser Seite.</p></div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fq-thema wd-auf" id="technik">
|
||||||
|
<h2 data-i18n="fq_t_technik">Hosting, Domain & Rechte</h2>
|
||||||
|
<details class="wd-faq">
|
||||||
|
<summary data-i18n="f10_f">Brauche ich eine eigene Domain und ein eigenes Hosting?</summary>
|
||||||
|
<div class="wd-faq-inhalt"><p data-i18n="f10_a">Ja, beides gehört dir und läuft auf deinen Namen.</p></div>
|
||||||
|
</details>
|
||||||
|
<details class="wd-faq">
|
||||||
|
<summary data-i18n="f11_f">Bin ich danach von dir abhängig?</summary>
|
||||||
|
<div class="wd-faq-inhalt"><p data-i18n="f11_a">Nein, und das ist Absicht.</p></div>
|
||||||
|
</details>
|
||||||
|
<details class="wd-faq">
|
||||||
|
<summary data-i18n="f12_f">Wie steht es um Datenschutz und Impressum?</summary>
|
||||||
|
<div class="wd-faq-inhalt"><p data-i18n="f12_a">Die technischen Voraussetzungen baue ich ein.</p></div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fq-thema wd-auf" id="veroeff">
|
||||||
|
<h2 data-i18n="fq_t_veroeff">Veröffentlichung & danach</h2>
|
||||||
|
<details class="wd-faq">
|
||||||
|
<summary data-i18n="f13_f">Wird meine Seite ins Portfolio aufgenommen?</summary>
|
||||||
|
<div class="wd-faq-inhalt"><p data-i18n="f13_a">Nur wenn du es erlaubst.</p></div>
|
||||||
|
</details>
|
||||||
|
<details class="wd-faq">
|
||||||
|
<summary data-i18n="f14_f">Was passiert, wenn die Seite ausfällt?</summary>
|
||||||
|
<div class="wd-faq-inhalt"><p data-i18n="f14_a">Mit Betreuung merke ich es in der Regel selbst.</p></div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Gold: Zahlungsinformation, hier als Erinnerung an die zentrale Regel. -->
|
||||||
|
<div class="wd-hinweis wd-auf" style="margin-top:2rem">
|
||||||
|
<p class="wd-hinweis-titel" data-i18n="anzahlung_titel">Wichtig: 30 % Anzahlung</p>
|
||||||
|
<p data-i18n="anzahlung_text">Der Projektstart wird nach Annahme des schriftlichen Angebots und Eingang einer Anzahlung von 30 % des vereinbarten Gesamtpreises reserviert.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="wd-abschnitt wd-abschnitt--gehoben">
|
||||||
|
<div class="wd-container wd-container--schmal" style="text-align:center">
|
||||||
|
<h2 data-i18n="fq_rest_titel">Deine Frage war nicht dabei?</h2>
|
||||||
|
<p class="wd-lead" data-i18n="fq_rest_text">Schreib sie einfach ins Anfrageformular — es gibt dort ein freies Feld dafür. Du kannst mir auch direkt eine E-Mail schreiben, wenn dir das lieber ist.</p>
|
||||||
|
<div class="wd-btn-reihe" style="justify-content:center">
|
||||||
|
<a class="wd-btn wd-btn--haupt" href="/webdesign/anfrage.html" data-i18n="nav_anfrage">Projekt anfragen</a>
|
||||||
|
<a class="wd-btn wd-btn--zweit" href="mailto:[email protected]" data-i18n="fuss_email">E-Mail schreiben</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="wd-fuss"></div>
|
||||||
|
|
||||||
|
<script src="/assets/js/i18n-wd-faq.js?v=20260822wd"></script>
|
||||||
|
<script src="/assets/js/wd-core.js?v=20260822wd"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,480 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de" data-i18n-titel="seiten_titel">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Dogfather Webdesign — individuelle Webseiten, persönlich umgesetzt</title>
|
||||||
|
<meta name="description" content="Individuell gebaute Webseiten statt Baukasten: Onepager ab 300 €, professionelle Websites ab 500 €, Shops ab 1.000 €. Klare Preise, klare Zeitrahmen, eine feste Ansprechperson." />
|
||||||
|
<meta name="robots" content="noindex, nofollow, noarchive" />
|
||||||
|
<link rel="icon" type="image/png" href="/assets/img/favicon.png" />
|
||||||
|
<meta name="theme-color" content="#05070b" />
|
||||||
|
<!-- Als App installierbar auf Handy und Computer. iOS ignoriert das
|
||||||
|
Manifest für das Startbildschirm-Symbol vollständig und nimmt NUR
|
||||||
|
apple-touch-icon — deshalb beides. -->
|
||||||
|
<link rel="manifest" href="/webdesign/app.webmanifest" />
|
||||||
|
<link rel="apple-touch-icon" href="/assets/img/wd-app-180.png" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Dogfather Webdesign" />
|
||||||
|
<link rel="stylesheet" href="/assets/css/main.css?v=20260822wd" />
|
||||||
|
<link rel="stylesheet" href="/assets/css/webdesign.css?v=20260822wd" />
|
||||||
|
<style>
|
||||||
|
/* ---------- Nur auf der Startseite gebraucht ---------- */
|
||||||
|
|
||||||
|
/* Marken-Intro nach erfolgreichem Zugang (Masterplan S.3, Schritt 02).
|
||||||
|
Läuft bewusst NUR EINMAL pro Browser-Sitzung und lässt sich jederzeit
|
||||||
|
wegklicken — ein Vorspann, den man bei jedem Seitenaufruf wieder sieht,
|
||||||
|
wird nach dem zweiten Mal zur Belastung. */
|
||||||
|
.wd-intro {
|
||||||
|
position: fixed; inset: 0; z-index: 999;
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
gap: .3rem; text-align: center;
|
||||||
|
background: radial-gradient(ellipse at 50% 40%, #0d1826 0%, #05070b 68%);
|
||||||
|
animation: wd-intro-weg .7s ease forwards 2.6s;
|
||||||
|
}
|
||||||
|
.wd-intro[hidden] { display: none; }
|
||||||
|
.wd-intro-logo { width: clamp(110px, 20vw, 160px); animation: wd-intro-auf .9s cubic-bezier(.22,1,.36,1) both; }
|
||||||
|
.wd-intro-gruss {
|
||||||
|
font-family: var(--font-head); font-weight: 800;
|
||||||
|
font-size: clamp(1.5rem, 4.5vw, 2.4rem); margin: 1rem 0 0; color: var(--wd-text);
|
||||||
|
animation: wd-intro-auf .9s cubic-bezier(.22,1,.36,1) both .25s;
|
||||||
|
}
|
||||||
|
.wd-intro-unter {
|
||||||
|
font-size: .84rem; letter-spacing: .16em; text-transform: uppercase; font-weight: 700;
|
||||||
|
color: var(--wd-blau); margin: .3rem 0 0;
|
||||||
|
animation: wd-intro-auf .9s cubic-bezier(.22,1,.36,1) both .45s;
|
||||||
|
}
|
||||||
|
.wd-intro-skip {
|
||||||
|
position: absolute; bottom: 2rem; left: 50%; transform: translateX(-50%);
|
||||||
|
background: none; border: 1px solid var(--wd-rand); color: var(--wd-text-soft);
|
||||||
|
border-radius: 999px; padding: .55rem 1.2rem; min-height: 44px; cursor: pointer; font-size: .88rem;
|
||||||
|
}
|
||||||
|
.wd-intro-skip:hover { color: var(--wd-text); border-color: var(--wd-rand-stark); }
|
||||||
|
@keyframes wd-intro-auf { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }
|
||||||
|
@keyframes wd-intro-weg { to { opacity: 0; visibility: hidden; } }
|
||||||
|
/* Bei Bewegungsreduktion gar kein Vorspann — er ist reine Inszenierung
|
||||||
|
und trägt keine Information, die man sonst verpassen würde. */
|
||||||
|
@media (prefers-reduced-motion: reduce) { .wd-intro { display: none !important; } }
|
||||||
|
|
||||||
|
/* Projektkarte */
|
||||||
|
.wd-projekt { overflow: hidden; padding: 0; display: flex; flex-direction: column; }
|
||||||
|
.wd-projekt-bild {
|
||||||
|
width: 100%; aspect-ratio: 16 / 10; object-fit: cover; object-position: top center;
|
||||||
|
border-bottom: 1px solid var(--wd-rand);
|
||||||
|
}
|
||||||
|
.wd-projekt-text { padding: clamp(1.2rem, 2.4vw, 1.7rem); display: flex; flex-direction: column; flex: 1; }
|
||||||
|
.wd-projekt-text h3 { margin-bottom: .3em; }
|
||||||
|
.wd-projekt-zeile { margin: .9rem 0 0; }
|
||||||
|
.wd-projekt-zeile dt {
|
||||||
|
font-size: .72rem; letter-spacing: .12em; text-transform: uppercase;
|
||||||
|
font-weight: 800; color: var(--wd-blau); margin-bottom: .15rem;
|
||||||
|
}
|
||||||
|
.wd-projekt-zeile dd { margin: 0 0 .8rem; color: var(--wd-text-mid); font-size: .98rem; }
|
||||||
|
.wd-projekt-text .wd-btn { margin-top: auto; align-self: flex-start; }
|
||||||
|
|
||||||
|
/* Foto-Block "Warum Dogfather" */
|
||||||
|
.wd-portrait-grid { display: grid; grid-template-columns: .8fr 1.2fr; gap: clamp(1.5rem, 4vw, 3rem); align-items: center; }
|
||||||
|
@media (max-width: 820px) { .wd-portrait-grid { grid-template-columns: 1fr; } }
|
||||||
|
.wd-portrait {
|
||||||
|
width: 100%; max-width: 380px; border-radius: var(--wd-r-gross);
|
||||||
|
aspect-ratio: 4 / 5; object-fit: cover;
|
||||||
|
border: 1px solid var(--wd-rand-stark);
|
||||||
|
box-shadow: var(--wd-schatten-tief);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Abschluss-CTA */
|
||||||
|
.wd-cta-flaeche {
|
||||||
|
text-align: center; border-radius: var(--wd-r-gross);
|
||||||
|
padding: clamp(2.2rem, 5vw, 3.6rem) clamp(1.2rem, 4vw, 3rem);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(20,32,50,.8), rgba(9,15,26,.9)) padding-box,
|
||||||
|
linear-gradient(120deg, rgba(127,208,232,.5), rgba(183,157,255,.4)) border-box;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
box-shadow: var(--wd-schatten-tief);
|
||||||
|
}
|
||||||
|
.wd-cta-flaeche p { max-width: 60ch; margin-left: auto; margin-right: auto; }
|
||||||
|
.wd-cta-flaeche .wd-btn-reihe { justify-content: center; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="wd">
|
||||||
|
|
||||||
|
<!-- Marken-Intro: steht bewusst VOR allem anderen und startet versteckt.
|
||||||
|
Das Skript ganz unten blendet es nur ein, wenn man in dieser
|
||||||
|
Browser-Sitzung gerade erst durch die Zugangswand gekommen ist. -->
|
||||||
|
<div class="wd-intro" id="wd-intro" hidden>
|
||||||
|
<img class="wd-intro-logo" src="/assets/img/logo-dogfather-transparent.png" alt="" width="472" height="476" />
|
||||||
|
<p class="wd-intro-gruss" id="wd-intro-gruss">Willkommen</p>
|
||||||
|
<p class="wd-intro-unter" data-i18n="intro_unter">Dogfather Webdesign — Vorabversion</p>
|
||||||
|
<button type="button" class="wd-intro-skip" id="wd-intro-skip" data-i18n="intro_ueberspringen">Überspringen</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="wd-kopf"></div>
|
||||||
|
|
||||||
|
<main id="wd-inhalt">
|
||||||
|
|
||||||
|
<!-- ================= 01 HERO =================
|
||||||
|
Hero-Regel (Masterplan S.7): ein starkes Versprechen, ein kurzer
|
||||||
|
Nutzen, EINE eindeutige Aktion — ohne Scrollen sichtbar. -->
|
||||||
|
<section class="wd-hero">
|
||||||
|
<div class="wd-container">
|
||||||
|
<div class="wd-hero-inhalt">
|
||||||
|
<span class="wd-eyebrow" data-i18n="hero_eyebrow">Dogfather Webdesign</span>
|
||||||
|
<h1 data-i18n="hero_h1">Deine Webseite soll nicht mithalten.<br /><span class="wd-hero-glanz">Sie soll auffallen.</span></h1>
|
||||||
|
<p class="wd-lead" data-i18n="hero_text">Individuell gebaut statt aus dem Baukasten. Du sprichst von der ersten Idee bis zur fertigen Seite mit einer Person — und weißt vorher, was es kostet und wie lange es dauert.</p>
|
||||||
|
<div class="wd-btn-reihe">
|
||||||
|
<a class="wd-btn wd-btn--haupt" href="/webdesign/anfrage.html" data-i18n="hero_cta">Projekt anfragen</a>
|
||||||
|
<a class="wd-btn wd-btn--zweit" href="/webdesign/portfolio.html" data-i18n="hero_cta2">Beispiele ansehen</a>
|
||||||
|
</div>
|
||||||
|
<div class="wd-hero-belege">
|
||||||
|
<span class="wd-hero-beleg" data-i18n="hero_beleg1">Ab 300 €</span>
|
||||||
|
<span class="wd-hero-beleg" data-i18n="hero_beleg2">Erste Seiten in 5–10 Arbeitstagen</span>
|
||||||
|
<span class="wd-hero-beleg" data-i18n="hero_beleg3">Eine feste Ansprechperson</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ================= 02 VERTRAUEN ================= -->
|
||||||
|
<section class="wd-abschnitt wd-abschnitt--gehoben">
|
||||||
|
<div class="wd-container">
|
||||||
|
<h2 class="wd-auf" data-i18n="vt_h2">Warum das hier anders läuft</h2>
|
||||||
|
<div class="wd-grid wd-grid--4" style="margin-top:2rem">
|
||||||
|
<div class="wd-karte wd-karte--kappe wd-auf">
|
||||||
|
<h3 data-i18n="vt_1_h3">Individuell</h3>
|
||||||
|
<p data-i18n="vt_1_p">Kein fertiges Muster mit ausgetauschtem Logo. Aufbau, Farben und Texte entstehen für dein Vorhaben.</p>
|
||||||
|
</div>
|
||||||
|
<div class="wd-karte wd-karte--lila wd-karte--kappe wd-auf">
|
||||||
|
<h3 data-i18n="vt_2_h3">Persönlich</h3>
|
||||||
|
<p data-i18n="vt_2_p">Du hast eine Ansprechperson — keine Ticketnummer und keine wechselnden Zuständigkeiten.</p>
|
||||||
|
</div>
|
||||||
|
<div class="wd-karte wd-karte--kappe wd-auf">
|
||||||
|
<h3 data-i18n="vt_3_h3">Auf jedem Gerät</h3>
|
||||||
|
<p data-i18n="vt_3_p">Handy zuerst geprüft, dann Tablet und Computer. Nichts wird abgeschnitten, nichts ist zu klein zum Antippen.</p>
|
||||||
|
</div>
|
||||||
|
<div class="wd-karte wd-karte--lila wd-karte--kappe wd-auf">
|
||||||
|
<h3 data-i18n="vt_4_h3">Klare Preise</h3>
|
||||||
|
<p data-i18n="vt_4_p">Einstiegspreise und Zeitrahmen stehen offen auf der Seite. Der Endpreis kommt schriftlich, bevor gearbeitet wird.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ================= 03 LEISTUNGSKARTEN ================= -->
|
||||||
|
<section class="wd-abschnitt">
|
||||||
|
<div class="wd-container">
|
||||||
|
<span class="wd-eyebrow" data-i18n="ls_eyebrow">Leistungen</span>
|
||||||
|
<h2 class="wd-auf" data-i18n="ls_h2">Vier Wege zu deiner Seite</h2>
|
||||||
|
<p class="wd-lead wd-auf" style="max-width:70ch" data-i18n="ls_text">Alle Preise sind Einstiegspunkte. Umfang, Funktionen und Personalisierung können Preis und Dauer verändern — was bei dir gilt, steht im schriftlichen Angebot.</p>
|
||||||
|
|
||||||
|
<div class="wd-grid wd-grid--2" style="margin-top:2.2rem">
|
||||||
|
|
||||||
|
<div class="wd-karte wd-karte--kappe wd-preis-karte wd-auf">
|
||||||
|
<h3 data-i18n="pk_onepager_h3">Onepager</h3>
|
||||||
|
<p class="wd-preis"><span data-i18n="ab_preis">ab</span> 300 €</p>
|
||||||
|
<div class="wd-preis-zeile">
|
||||||
|
<span class="wd-preis-label" data-i18n="dauer">Dauer</span>
|
||||||
|
<span class="wd-preis-wert" data-i18n="pk_onepager_dauer">ca. 5–10 Arbeitstage</span>
|
||||||
|
</div>
|
||||||
|
<p data-i18n="pk_onepager_p">Eine starke einzelne Seite für Person, Angebot, Aktion oder Kampagne.</p>
|
||||||
|
<p class="wd-personalisierung" data-i18n="pk_personalisierung">Personalisierung möglich — verändert Preis und Dauer.</p>
|
||||||
|
<a class="wd-btn wd-btn--zweit wd-btn--klein" href="/webdesign/leistungen.html#onepager" data-i18n="pk_details">Was genau enthalten ist</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wd-karte wd-karte--lila wd-karte--kappe wd-preis-karte wd-auf">
|
||||||
|
<h3 data-i18n="pk_website_h3">Professionelle Website</h3>
|
||||||
|
<p class="wd-preis"><span data-i18n="ab_preis">ab</span> 500 €</p>
|
||||||
|
<div class="wd-preis-zeile">
|
||||||
|
<span class="wd-preis-label" data-i18n="dauer">Dauer</span>
|
||||||
|
<span class="wd-preis-wert" data-i18n="pk_website_dauer">ca. 2–4 Wochen</span>
|
||||||
|
</div>
|
||||||
|
<p data-i18n="pk_website_p">Mehrere Unterseiten für Unternehmen, Creator, Teams oder Projekte.</p>
|
||||||
|
<p class="wd-personalisierung" data-i18n="pk_personalisierung">Personalisierung möglich — verändert Preis und Dauer.</p>
|
||||||
|
<a class="wd-btn wd-btn--zweit wd-btn--klein" href="/webdesign/leistungen.html#website" data-i18n="pk_details">Was genau enthalten ist</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wd-karte wd-karte--kappe wd-preis-karte wd-auf">
|
||||||
|
<h3 data-i18n="pk_shop_h3">Shop & umfangreiche Website</h3>
|
||||||
|
<p class="wd-preis"><span data-i18n="ab_preis">ab</span> 1.000 €</p>
|
||||||
|
<div class="wd-preis-zeile">
|
||||||
|
<span class="wd-preis-label" data-i18n="dauer">Dauer</span>
|
||||||
|
<span class="wd-preis-wert" data-i18n="pk_shop_dauer">ca. 4–8 Wochen</span>
|
||||||
|
</div>
|
||||||
|
<p data-i18n="pk_shop_p">Produktverkauf, Nutzerbereiche und umfangreiche Funktionen.</p>
|
||||||
|
<p class="wd-personalisierung" data-i18n="pk_personalisierung">Personalisierung möglich — verändert Preis und Dauer.</p>
|
||||||
|
<a class="wd-btn wd-btn--zweit wd-btn--klein" href="/webdesign/leistungen.html#shop" data-i18n="pk_details">Was genau enthalten ist</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wd-karte wd-karte--lila wd-karte--kappe wd-preis-karte wd-auf">
|
||||||
|
<h3 data-i18n="pk_verwaltung_h3">Buchhaltungs- & Steuerverwaltungsseiten</h3>
|
||||||
|
<p class="wd-preis"><span data-i18n="ab_preis">ab</span> 1.500 €</p>
|
||||||
|
<div class="wd-preis-zeile">
|
||||||
|
<span class="wd-preis-label" data-i18n="dauer">Dauer</span>
|
||||||
|
<span class="wd-preis-wert" data-i18n="pk_verwaltung_dauer">ca. 6–12 Wochen</span>
|
||||||
|
</div>
|
||||||
|
<p data-i18n="pk_verwaltung_p">Technisches Portal für Belege, Einnahmen, Ausgaben und Dokumente.</p>
|
||||||
|
<p class="wd-personalisierung" data-i18n="pk_personalisierung">Personalisierung möglich — verändert Preis und Dauer.</p>
|
||||||
|
<a class="wd-btn wd-btn--zweit wd-btn--klein" href="/webdesign/leistungen.html#verwaltung" data-i18n="pk_details">Was genau enthalten ist</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wd-grid wd-grid--2" style="margin-top:1.5rem">
|
||||||
|
<div class="wd-karte wd-karte--lila wd-karte--kappe wd-preis-karte wd-auf">
|
||||||
|
<h3 data-i18n="betreuung_h3">Laufende Betreuung</h3>
|
||||||
|
<p class="wd-preis" data-i18n="betreuung_preis">ab 49 € / Monat</p>
|
||||||
|
<p data-i18n="betreuung_p">Kleine Änderungen werden typischerweise innerhalb von ca. 2–5 Arbeitstagen bearbeitet. Umfang und Priorität richten sich nach dem gewählten Paket.</p>
|
||||||
|
</div>
|
||||||
|
<!-- Gold = wichtiger Hinweis / Zahlungsinformation. Bewusst die
|
||||||
|
einzige goldene Fläche auf dieser Seite. -->
|
||||||
|
<div class="wd-hinweis wd-auf">
|
||||||
|
<p class="wd-hinweis-titel" data-i18n="anzahlung_titel">Wichtig: 30 % Anzahlung</p>
|
||||||
|
<p data-i18n="anzahlung_text">Der Projektstart wird nach Annahme des schriftlichen Angebots und Eingang einer Anzahlung von 30 % des vereinbarten Gesamtpreises reserviert.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ================= 04 AUSGEWÄHLTE PROJEKTE ================= -->
|
||||||
|
<section class="wd-abschnitt wd-abschnitt--gehoben">
|
||||||
|
<div class="wd-container">
|
||||||
|
<span class="wd-eyebrow wd-eyebrow--lila" data-i18n="pf_eyebrow">Ausgewählte Projekte</span>
|
||||||
|
<h2 class="wd-auf" data-i18n="pf_h2">Zwei Seiten, die wirklich laufen</h2>
|
||||||
|
<p class="wd-lead wd-auf" style="max-width:70ch" data-i18n="pf_text">Keine erfundenen Beispiele und keine Bewertungen, die es nie gab. Beides sind echte, öffentlich erreichbare Seiten mit allem, was dahintersteckt.</p>
|
||||||
|
|
||||||
|
<div class="wd-grid wd-grid--2" style="margin-top:2.2rem">
|
||||||
|
|
||||||
|
<article class="wd-karte wd-projekt wd-auf">
|
||||||
|
<img class="wd-projekt-bild" src="/assets/img/wd-projekt-universe.jpg"
|
||||||
|
alt="Startseite von dogfather-universe.com" width="1200" height="750" loading="lazy" decoding="async"
|
||||||
|
data-i18n-attr="alt:pf_universe_alt" />
|
||||||
|
<div class="wd-projekt-text">
|
||||||
|
<h3 data-i18n="pf_universe_h3">Dogfather Universe</h3>
|
||||||
|
<p data-i18n="pf_universe_p">Content- und Community-Plattform mit fünf Sprachen, Teamseiten, Streamplan, Bewerbungssystem, Supporter-Abo und eigenem Verwaltungsbereich.</p>
|
||||||
|
<dl class="wd-projekt-zeile">
|
||||||
|
<dt data-i18n="pf_aufgabe">Aufgabe</dt>
|
||||||
|
<dd data-i18n="pf_universe_aufgabe">Drei getrennte Welten unter ein Dach bringen, ohne dass es unübersichtlich wird.</dd>
|
||||||
|
<dt data-i18n="pf_ergebnis">Ergebnis</dt>
|
||||||
|
<dd data-i18n="pf_universe_ergebnis">Öffentlich seit August 2026, läuft auf einem eigenen Server, inhaltlich komplett selbst pflegbar.</dd>
|
||||||
|
</dl>
|
||||||
|
<div class="wd-tags" style="margin-bottom:1.1rem">
|
||||||
|
<span class="wd-tag" data-i18n="pf_tag_mehrsprachig">5 Sprachen</span>
|
||||||
|
<span class="wd-tag" data-i18n="pf_tag_verwaltung">Eigene Verwaltung</span>
|
||||||
|
<span class="wd-tag" data-i18n="pf_tag_abo">Abo & Zahlung</span>
|
||||||
|
</div>
|
||||||
|
<a class="wd-btn wd-btn--zweit wd-btn--klein" href="https://dogfather-universe.com/" target="_blank" rel="noopener" data-i18n="pf_ansehen">Live ansehen</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<!-- Zweites Projekt. Das Vorschaubild ist ein ECHTER Screenshot der
|
||||||
|
laufenden Seite (22.08.2026), kein Mockup. Der interne
|
||||||
|
Hinweisbalken ganz oben wurde bewusst weggeschnitten — er
|
||||||
|
gehört nicht in ein Portfolio. -->
|
||||||
|
<article class="wd-karte wd-karte--lila wd-projekt wd-auf">
|
||||||
|
<img class="wd-projekt-bild" src="/assets/img/wd-projekt-vandiy.jpg"
|
||||||
|
alt="Startseite von vans-diy-bastelbedarf.com" width="1200" height="696" loading="lazy" decoding="async"
|
||||||
|
data-i18n-attr="alt:pf_shop_alt" />
|
||||||
|
<div class="wd-projekt-text">
|
||||||
|
<h3 data-i18n="pf_shop_h3">Van's DIY & Bastelbedarf</h3>
|
||||||
|
<p data-i18n="pf_shop_p">Vollständiger Onlineshop mit Produktverwaltung, Warenkorb, Gutscheincodes, Versandabläufen und einem Adminbereich, in dem Artikel ohne Technikkenntnisse gepflegt werden.</p>
|
||||||
|
<dl class="wd-projekt-zeile">
|
||||||
|
<dt data-i18n="pf_aufgabe">Aufgabe</dt>
|
||||||
|
<dd data-i18n="pf_shop_aufgabe">Ein Shop, den die Inhaberin komplett selbst befüllen kann — ohne Schulung und ohne Angst, etwas kaputtzumachen.</dd>
|
||||||
|
<dt data-i18n="pf_ergebnis">Ergebnis</dt>
|
||||||
|
<dd data-i18n="pf_shop_ergebnis">Läuft auf eigener Domain und eigenem Server; Artikelpflege passiert selbstständig im Adminbereich.</dd>
|
||||||
|
</dl>
|
||||||
|
<div class="wd-tags" style="margin-bottom:1.1rem">
|
||||||
|
<span class="wd-tag wd-tag--lila" data-i18n="pf_tag_shop">Shop & Kasse</span>
|
||||||
|
<span class="wd-tag wd-tag--lila" data-i18n="pf_tag_admin">Selbstpflege</span>
|
||||||
|
<span class="wd-tag wd-tag--lila" data-i18n="pf_tag_gutschein">Gutscheincodes</span>
|
||||||
|
</div>
|
||||||
|
<a class="wd-btn wd-btn--zweit wd-btn--klein" href="https://vans-diy-bastelbedarf.com/" target="_blank" rel="noopener" data-i18n="pf_ansehen">Live ansehen</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wd-btn-reihe" style="margin-top:1.6rem">
|
||||||
|
<a class="wd-btn wd-btn--still" href="/webdesign/portfolio.html" data-i18n="pf_alle">Alle Projekte ansehen</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ================= 05 SO LÄUFT ES AB ================= -->
|
||||||
|
<section class="wd-abschnitt">
|
||||||
|
<div class="wd-container wd-container--schmal">
|
||||||
|
<span class="wd-eyebrow" data-i18n="ab_eyebrow">So läuft es ab</span>
|
||||||
|
<h2 class="wd-auf" data-i18n="ab_h2">Von der Anfrage bis zur Übergabe</h2>
|
||||||
|
|
||||||
|
<div class="wd-schritte" style="margin-top:2rem">
|
||||||
|
<div class="wd-schritt wd-auf">
|
||||||
|
<span class="wd-schritt-nr" aria-hidden="true">01</span>
|
||||||
|
<div><h3 data-i18n="ab_1_h3">Anfrage</h3><p data-i18n="ab_1_p">Du füllst das Formular aus. Es fragt genau das ab, was für ein ehrliches Angebot nötig ist — nicht mehr.</p></div>
|
||||||
|
</div>
|
||||||
|
<div class="wd-schritt wd-auf">
|
||||||
|
<span class="wd-schritt-nr" aria-hidden="true">02</span>
|
||||||
|
<div><h3 data-i18n="ab_2_h3">Briefing</h3><p data-i18n="ab_2_p">Wir klären offene Punkte im Gespräch: Ziel, Zielgruppe, Umfang und was du schon an Inhalten hast.</p></div>
|
||||||
|
</div>
|
||||||
|
<div class="wd-schritt wd-auf">
|
||||||
|
<span class="wd-schritt-nr" aria-hidden="true">03</span>
|
||||||
|
<div><h3 data-i18n="ab_3_h3">Angebot</h3><p data-i18n="ab_3_p">Du bekommst Preis, Zeitrahmen und Umfang schriftlich. Erst wenn du zustimmst und die 30 % Anzahlung da sind, wird dein Termin reserviert.</p></div>
|
||||||
|
</div>
|
||||||
|
<div class="wd-schritt wd-auf">
|
||||||
|
<span class="wd-schritt-nr" aria-hidden="true">04</span>
|
||||||
|
<div><h3 data-i18n="ab_4_h3">Design</h3><p data-i18n="ab_4_p">Du siehst den Entwurf, bevor gebaut wird — auf Handy und Computer. Zwei Feedbackrunden sind üblich eingeplant.</p></div>
|
||||||
|
</div>
|
||||||
|
<div class="wd-schritt wd-auf">
|
||||||
|
<span class="wd-schritt-nr" aria-hidden="true">05</span>
|
||||||
|
<div><h3 data-i18n="ab_5_h3">Entwicklung & Tests</h3><p data-i18n="ab_5_p">Die Seite wird gebaut und danach durchgespielt: jedes Formular, jeder Link, jeder Fehlerfall — auf echten Bildschirmgrößen.</p></div>
|
||||||
|
</div>
|
||||||
|
<div class="wd-schritt wd-auf">
|
||||||
|
<span class="wd-schritt-nr" aria-hidden="true">06</span>
|
||||||
|
<div><h3 data-i18n="ab_6_h3">Übergabe</h3><p data-i18n="ab_6_p">Veröffentlichung erst nach deiner ausdrücklichen Freigabe. Danach eine Einweisung, damit du deine Inhalte selbst pflegen kannst.</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ================= 06 WARUM DOGFATHER ================= -->
|
||||||
|
<section class="wd-abschnitt wd-abschnitt--gehoben">
|
||||||
|
<div class="wd-container">
|
||||||
|
<div class="wd-portrait-grid">
|
||||||
|
<img class="wd-portrait wd-auf" src="/assets/img/wd-dogfather-portrait.jpg"
|
||||||
|
alt="Dogfather" width="900" height="1125" loading="lazy" decoding="async"
|
||||||
|
data-i18n-attr="alt:wd_foto_alt" />
|
||||||
|
<div>
|
||||||
|
<span class="wd-eyebrow" data-i18n="wd_eyebrow">Warum Dogfather</span>
|
||||||
|
<h2 class="wd-auf" data-i18n="wd_h2">Eine Person, die dranbleibt</h2>
|
||||||
|
<p class="wd-auf" data-i18n="wd_p1">Hinter Dogfather Webdesign steht keine Agentur mit wechselnden Zuständigkeiten, sondern eine Person, die dein Projekt von Anfang bis Ende begleitet — und die dieselbe Sorgfalt hineinsteckt, die im Dogfather Universe steckt.</p>
|
||||||
|
<p class="wd-auf" data-i18n="wd_p2">Das heißt auch: keine Versprechen, die später nicht gehalten werden. Was nicht geht, sage ich vorher — nicht, wenn die Anzahlung schon da ist.</p>
|
||||||
|
<div class="wd-btn-reihe">
|
||||||
|
<a class="wd-btn wd-btn--zweit" href="/webdesign/ueber.html" data-i18n="wd_mehr">Mehr über Dogfather</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ================= 07 FAQ ================= -->
|
||||||
|
<section class="wd-abschnitt">
|
||||||
|
<div class="wd-container wd-container--schmal">
|
||||||
|
<span class="wd-eyebrow" data-i18n="faq_eyebrow">Häufige Fragen</span>
|
||||||
|
<h2 class="wd-auf" data-i18n="faq_h2">Das, was fast alle fragen</h2>
|
||||||
|
|
||||||
|
<div style="margin-top:1.8rem">
|
||||||
|
<details class="wd-faq wd-auf">
|
||||||
|
<summary data-i18n="faq_1_f">Ist der genannte Preis der Endpreis?</summary>
|
||||||
|
<div class="wd-faq-inhalt"><p data-i18n="faq_1_a">Nein. Die Beträge auf dieser Seite sind Einstiegspreise. Der verbindliche Endpreis entsteht erst durch ein schriftliches Angebot, nachdem der tatsächliche Umfang geklärt ist. Das Anfrageformular erzeugt keinen Vertrag.</p></div>
|
||||||
|
</details>
|
||||||
|
<details class="wd-faq wd-auf">
|
||||||
|
<summary data-i18n="faq_2_f">Warum 30 % Anzahlung?</summary>
|
||||||
|
<div class="wd-faq-inhalt"><p data-i18n="faq_2_a">Weil damit dein Zeitfenster fest reserviert wird. Solange keine Anzahlung da ist, ist kein Termin blockiert — und ein Projekt, das kurzfristig abspringt, geht sonst auf Kosten aller anderen, die warten.</p></div>
|
||||||
|
</details>
|
||||||
|
<details class="wd-faq wd-auf">
|
||||||
|
<summary data-i18n="faq_3_f">Was ist, wenn ich noch keine Texte und Bilder habe?</summary>
|
||||||
|
<div class="wd-faq-inhalt"><p data-i18n="faq_3_a">Das ist normal und kein Hindernis. Sag es im Anfrageformular ehrlich — dann planen wir es von Anfang an ein, statt später überrascht zu werden. Fehlende Inhalte sind der häufigste Grund, warum Projekte länger dauern als geplant.</p></div>
|
||||||
|
</details>
|
||||||
|
<details class="wd-faq wd-auf">
|
||||||
|
<summary data-i18n="faq_4_f">Gehört die Seite danach mir?</summary>
|
||||||
|
<div class="wd-faq-inhalt"><p data-i18n="faq_4_a">Ja. Nach vollständiger Bezahlung erhältst du die Nutzungsrechte an der für dich erstellten Seite samt Inhalten. Eingesetzte fremde Bausteine (z. B. Schriften oder Bilder aus Bibliotheken) behalten ihre eigene Lizenz — welche das sind, steht im Angebot.</p></div>
|
||||||
|
</details>
|
||||||
|
<details class="wd-faq wd-auf">
|
||||||
|
<summary data-i18n="faq_5_f">Wird ohne meine Zustimmung etwas veröffentlicht?</summary>
|
||||||
|
<div class="wd-faq-inhalt"><p data-i18n="faq_5_a">Nein. Weder die Seite selbst noch ein Beispiel davon im Portfolio. Beides braucht deine ausdrückliche Freigabe, und beides ist getrennt — du kannst die Seite live schalten und trotzdem nicht im Portfolio auftauchen wollen.</p></div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wd-btn-reihe" style="margin-top:1.4rem">
|
||||||
|
<a class="wd-btn wd-btn--still" href="/webdesign/faq.html" data-i18n="faq_alle">Alle Fragen und Antworten</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ================= 08 ABSCHLUSS-CTA ================= -->
|
||||||
|
<section class="wd-abschnitt">
|
||||||
|
<div class="wd-container">
|
||||||
|
<div class="wd-cta-flaeche wd-auf">
|
||||||
|
<h2 data-i18n="cta_h2">Erzähl mir von deinem Vorhaben</h2>
|
||||||
|
<p class="wd-lead" data-i18n="cta_p">Ein paar Minuten für das Formular reichen. Du bekommst danach eine Zusammenfassung, ich melde mich mit Rückfragen oder direkt mit einem Angebot — unverbindlich und ohne Kosten.</p>
|
||||||
|
<div class="wd-btn-reihe">
|
||||||
|
<a class="wd-btn wd-btn--haupt" href="/webdesign/anfrage.html" data-i18n="cta_knopf">Projekt anfragen</a>
|
||||||
|
</div>
|
||||||
|
<p class="wd-klein wd-leise" style="margin:1.2rem 0 0" data-i18n="cta_klein">Unverbindlich · Keine Kosten · Keine automatische Preiszusage</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="wd-fuss"></div>
|
||||||
|
|
||||||
|
<script src="/assets/js/i18n-wd-index.js?v=20260822wd"></script>
|
||||||
|
<script src="/assets/js/wd-core.js?v=20260822wd"></script>
|
||||||
|
<script>
|
||||||
|
/* ---------------------------------------------------------------------
|
||||||
|
Marken-Intro (Masterplan S.3, Schritt 02: "Nach erfolgreichem Code
|
||||||
|
erscheint ein kurzes, hochwertiges Intro mit Dogfather-Branding und
|
||||||
|
direktem Übergang zur Startseite").
|
||||||
|
|
||||||
|
Bewusste Entscheidungen:
|
||||||
|
- Nur EINMAL pro Browser-Sitzung (sessionStorage). Ein Vorspann bei
|
||||||
|
jedem Aufruf wäre nach dem zweiten Mal reine Belastung.
|
||||||
|
- Jederzeit wegklickbar und per Escape schließbar.
|
||||||
|
- Bei prefers-reduced-motion gar nicht erst gezeigt (CSS oben).
|
||||||
|
- Die Anrede kommt aus dem Rollen-Cookie, das die Zugangsprüfung
|
||||||
|
gesetzt hat. Es trägt KEINE Berechtigung — wer es fälscht, ändert
|
||||||
|
nur die Begrüßung, nicht den Zugang.
|
||||||
|
--------------------------------------------------------------------- */
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
var box = document.getElementById("wd-intro");
|
||||||
|
var gruss = document.getElementById("wd-intro-gruss");
|
||||||
|
var skip = document.getElementById("wd-intro-skip");
|
||||||
|
if (!box) return;
|
||||||
|
|
||||||
|
var SCHLUESSEL = "wd-intro-gesehen";
|
||||||
|
var schonGesehen = false;
|
||||||
|
try { schonGesehen = sessionStorage.getItem(SCHLUESSEL) === "ja"; } catch (e) { schonGesehen = true; }
|
||||||
|
|
||||||
|
var reduziert = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
if (schonGesehen || reduziert) return;
|
||||||
|
|
||||||
|
function rolleAusCookie() {
|
||||||
|
var treffer = document.cookie.split(";").map(function (s) { return s.trim(); })
|
||||||
|
.filter(function (s) { return s.indexOf("dogiwd_rolle=") === 0; })[0];
|
||||||
|
return treffer ? decodeURIComponent(treffer.split("=")[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function schliessen() {
|
||||||
|
box.hidden = true;
|
||||||
|
try { sessionStorage.setItem(SCHLUESSEL, "ja"); } catch (e) { /* egal */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function anzeigen() {
|
||||||
|
var rolle = rolleAusCookie();
|
||||||
|
/* Dritter Fall (kein Rollen-Cookie) ist bewusst vorgesehen: das Cookie
|
||||||
|
kann fehlen, wenn der Bereich auf "freigegeben" steht und jemand
|
||||||
|
ohne Anmeldung hereinkommt. Dann eine neutrale Begrüßung statt
|
||||||
|
einer falschen Anrede. */
|
||||||
|
gruss.textContent = rolle === "vanvan"
|
||||||
|
? window.WD.t("intro_gruss_vanvan")
|
||||||
|
: rolle === "dogfather"
|
||||||
|
? window.WD.t("intro_gruss_dogfather")
|
||||||
|
: window.WD.t("intro_gruss_allgemein");
|
||||||
|
box.hidden = false;
|
||||||
|
/* Nach dem Weg-Animieren (CSS: 2.6s Wartezeit + 0.7s Blende) endgültig
|
||||||
|
aus dem Weg räumen — sonst liegt ein unsichtbares Element über der
|
||||||
|
Seite und schluckt jeden Klick. */
|
||||||
|
setTimeout(schliessen, 3400);
|
||||||
|
}
|
||||||
|
|
||||||
|
skip.addEventListener("click", schliessen);
|
||||||
|
document.addEventListener("keydown", function (e) { if (e.key === "Escape") schliessen(); });
|
||||||
|
|
||||||
|
/* Auf wd-core warten, damit die Begrüßung schon in der richtigen Sprache
|
||||||
|
steht statt kurz auf Deutsch aufzublitzen. */
|
||||||
|
if (window.WD) anzeigen();
|
||||||
|
else document.addEventListener("wd-bereit", anzeigen);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de" data-i18n-titel="seiten_titel">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Leistungen — Dogfather Webdesign</title>
|
||||||
|
<meta name="description" content="Onepager, professionelle Website, Shop und Verwaltungsseiten: was jeweils enthalten ist, was extra kostet und wo die Grenze verläuft." />
|
||||||
|
<meta name="robots" content="noindex, nofollow, noarchive" />
|
||||||
|
<link rel="icon" type="image/png" href="/assets/img/favicon.png" />
|
||||||
|
<meta name="theme-color" content="#05070b" />
|
||||||
|
<link rel="manifest" href="/webdesign/app.webmanifest" />
|
||||||
|
<link rel="apple-touch-icon" href="/assets/img/wd-app-180.png" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Dogfather Webdesign" />
|
||||||
|
<link rel="stylesheet" href="/assets/css/main.css?v=20260822wd" />
|
||||||
|
<link rel="stylesheet" href="/assets/css/webdesign.css?v=20260822wd" />
|
||||||
|
<style>
|
||||||
|
/* Sprungmarken-Leiste: auf schmalen Bildschirmen waagerecht wischbar
|
||||||
|
statt umbrechend — vier lange deutsche Paketnamen ergeben sonst vier
|
||||||
|
Zeilen, die den halben ersten Bildschirm fressen. */
|
||||||
|
.ls-sprung {
|
||||||
|
display: flex; gap: .5rem; overflow-x: auto; padding: .2rem .2rem 1rem;
|
||||||
|
scrollbar-width: thin; -webkit-overflow-scrolling: touch;
|
||||||
|
scroll-snap-type: x proximity;
|
||||||
|
}
|
||||||
|
.ls-sprung a {
|
||||||
|
flex: 0 0 auto; scroll-snap-align: start;
|
||||||
|
padding: .6rem 1rem; min-height: 44px; display: inline-flex; align-items: center;
|
||||||
|
border-radius: 999px; border: 1px solid var(--wd-rand);
|
||||||
|
font-size: .92rem; font-weight: 600; color: var(--wd-text-mid); white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ls-sprung a:hover { color: var(--wd-text); border-color: var(--wd-rand-stark); background: rgba(127,208,232,.06); }
|
||||||
|
|
||||||
|
/* scroll-margin-top: sonst verschwindet die Abschnittsüberschrift beim
|
||||||
|
Anspringen unter der klebenden Kopfzeile. */
|
||||||
|
.ls-block { scroll-margin-top: 90px; }
|
||||||
|
.ls-block + .ls-block { margin-top: clamp(2rem, 5vw, 3.4rem); }
|
||||||
|
.ls-block h2 { margin-bottom: .25em; }
|
||||||
|
|
||||||
|
.ls-preiszeile {
|
||||||
|
display: flex; flex-wrap: wrap; align-items: baseline; gap: .4rem 1.4rem;
|
||||||
|
margin: 0 0 1.4rem;
|
||||||
|
}
|
||||||
|
.ls-preis { font-family: var(--font-head); font-weight: 800; font-size: clamp(1.5rem, 3.6vw, 2rem); color: var(--wd-blau); }
|
||||||
|
.ls-block--lila .ls-preis { color: var(--wd-lila); }
|
||||||
|
.ls-dauer { color: var(--wd-text-mid); font-size: 1rem; }
|
||||||
|
|
||||||
|
.ls-raster { display: grid; grid-template-columns: repeat(2, 1fr); gap: clamp(.9rem, 2vw, 1.3rem); }
|
||||||
|
@media (max-width: 780px) { .ls-raster { grid-template-columns: 1fr; } }
|
||||||
|
.ls-feld h3 { font-size: .78rem; letter-spacing: .13em; text-transform: uppercase; color: var(--wd-blau); margin-bottom: .5em; }
|
||||||
|
.ls-block--lila .ls-feld h3 { color: var(--wd-lila); }
|
||||||
|
.ls-feld p { margin: 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="wd">
|
||||||
|
|
||||||
|
<div id="wd-kopf"></div>
|
||||||
|
|
||||||
|
<main id="wd-inhalt">
|
||||||
|
|
||||||
|
<section class="wd-abschnitt wd-abschnitt--eng" style="padding-top:clamp(2.4rem,6vw,4rem)">
|
||||||
|
<div class="wd-container">
|
||||||
|
<span class="wd-eyebrow" data-i18n="ls_eyebrow">Klare Pakete</span>
|
||||||
|
<h1 data-i18n="ls_h1">Was du ungefähr bekommst</h1>
|
||||||
|
<p class="wd-lead" style="max-width:72ch" data-i18n="ls_lead">Keine leeren Werbeversprechen. Zu jeder Kategorie steht hier, für wen sie gedacht ist, was typischerweise drin ist, was extra kostet — und wo die Grenze verläuft.</p>
|
||||||
|
|
||||||
|
<nav class="ls-sprung" aria-label="Abschnitte">
|
||||||
|
<a href="#onepager" data-i18n="op_h2">Onepager</a>
|
||||||
|
<a href="#website" data-i18n="ws_h2">Professionelle Website</a>
|
||||||
|
<a href="#shop" data-i18n="sh_h2">Shop & umfangreiche Website</a>
|
||||||
|
<a href="#verwaltung" data-i18n="vw_h2">Buchhaltungs- & Steuerverwaltungsseiten</a>
|
||||||
|
<a href="#betreuung" data-i18n="bt_h2">Laufende Betreuung</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="wd-abschnitt" style="padding-top:0">
|
||||||
|
<div class="wd-container">
|
||||||
|
|
||||||
|
<!-- ---------------- Onepager ---------------- -->
|
||||||
|
<article class="wd-karte wd-karte--kappe ls-block wd-auf" id="onepager">
|
||||||
|
<h2 data-i18n="op_h2">Onepager</h2>
|
||||||
|
<div class="ls-preiszeile">
|
||||||
|
<span class="ls-preis"><span data-i18n="ab_preis">ab</span> 300 €</span>
|
||||||
|
<span class="ls-dauer"><strong data-i18n="dauer">Dauer</strong>: <span data-i18n="pk_onepager_dauer">ca. 5–10 Arbeitstage</span></span>
|
||||||
|
</div>
|
||||||
|
<p class="wd-personalisierung" style="margin-top:-1rem;margin-bottom:1.4rem" data-i18n="pk_personalisierung">Personalisierung möglich — verändert Preis und Dauer.</p>
|
||||||
|
|
||||||
|
<div class="ls-raster">
|
||||||
|
<div class="ls-feld">
|
||||||
|
<h3 data-i18n="fuer_wen">Für wen</h3>
|
||||||
|
<p data-i18n="op_fuer">Einzelpersonen, Creator, eine einzelne Aktion oder ein klar umrissenes Angebot, das keine Unterseiten braucht.</p>
|
||||||
|
</div>
|
||||||
|
<div class="ls-feld">
|
||||||
|
<h3 data-i18n="enthalten">Typisch enthalten</h3>
|
||||||
|
<ul class="wd-liste">
|
||||||
|
<li data-i18n="op_1">Bis zu ca. fünf Inhaltsbereiche auf einer Seite</li>
|
||||||
|
<li data-i18n="op_2">Hero, Vorstellung, Angebot, Links und Kontakt</li>
|
||||||
|
<li data-i18n="op_3">Responsiv auf Handy, Tablet und Computer</li>
|
||||||
|
<li data-i18n="op_4">Grundstruktur für Suchmaschinen (Titel, Beschreibung, Überschriften)</li>
|
||||||
|
<li data-i18n="op_5">Typischerweise zwei Feedbackrunden</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="ls-feld">
|
||||||
|
<h3 data-i18n="beispiel">Konkretes Beispiel</h3>
|
||||||
|
<p data-i18n="op_beispiel">Eine Seite für einen Creator: oben wer du bist und was du machst, darunter die aktuelle Aktion, deine Kanäle, ein kurzes Kontaktformular. Alles auf einem Bildschirm erreichbar, ohne Menü.</p>
|
||||||
|
</div>
|
||||||
|
<div class="ls-feld">
|
||||||
|
<h3 data-i18n="grenze">Nicht enthalten</h3>
|
||||||
|
<p data-i18n="op_grenze">Kein Shop, keine Nutzerkonten, keine Unterseiten. Wenn absehbar mehr als eine Seite nötig wird, ist die professionelle Website der ehrlichere Weg — auch wenn sie mehr kostet.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wd-notiz" style="margin-top:1.3rem">
|
||||||
|
<p><strong data-i18n="extras">Kostet extra</strong>: <span data-i18n="extras_allgemein">Zusätzliche Sprachen · weitere Unterseiten · Logo- und Bildbearbeitung · Texterstellung · Anbindung an fremde Systeme · zusätzliche Feedbackrunden über die vereinbarten hinaus</span></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wd-btn-reihe" style="margin-top:1.3rem">
|
||||||
|
<a class="wd-btn wd-btn--zweit wd-btn--klein" href="/webdesign/anfrage.html?paket=onepager" data-i18n="jetzt_anfragen">Dieses Paket anfragen</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<!-- ---------------- Professionelle Website ---------------- -->
|
||||||
|
<article class="wd-karte wd-karte--lila wd-karte--kappe ls-block ls-block--lila wd-auf" id="website">
|
||||||
|
<h2 data-i18n="ws_h2">Professionelle Website</h2>
|
||||||
|
<div class="ls-preiszeile">
|
||||||
|
<span class="ls-preis"><span data-i18n="ab_preis">ab</span> 500 €</span>
|
||||||
|
<span class="ls-dauer"><strong data-i18n="dauer">Dauer</strong>: <span data-i18n="pk_website_dauer">ca. 2–4 Wochen</span></span>
|
||||||
|
</div>
|
||||||
|
<p class="wd-personalisierung" style="margin-top:-1rem;margin-bottom:1.4rem" data-i18n="pk_personalisierung">Personalisierung möglich — verändert Preis und Dauer.</p>
|
||||||
|
|
||||||
|
<div class="ls-raster">
|
||||||
|
<div class="ls-feld">
|
||||||
|
<h3 data-i18n="fuer_wen">Für wen</h3>
|
||||||
|
<p data-i18n="ws_fuer">Unternehmen, Teams, Vereine, Creator mit mehreren Themen — überall dort, wo eine einzelne Seite zu voll würde.</p>
|
||||||
|
</div>
|
||||||
|
<div class="ls-feld">
|
||||||
|
<h3 data-i18n="enthalten">Typisch enthalten</h3>
|
||||||
|
<ul class="wd-liste">
|
||||||
|
<li data-i18n="ws_1">Typisch bis zu fünf Kernseiten</li>
|
||||||
|
<li data-i18n="ws_2">Navigation, Formulare, Galerie oder Videos</li>
|
||||||
|
<li data-i18n="ws_3">Responsive Umsetzung und Funktionscheck auf echten Geräten</li>
|
||||||
|
<li data-i18n="ws_4">Unterstützung bei der Veröffentlichung (Domain, Zertifikat, Umzug)</li>
|
||||||
|
<li data-i18n="ws_5">Einweisung, damit du Texte selbst ändern kannst</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="ls-feld">
|
||||||
|
<h3 data-i18n="beispiel">Konkretes Beispiel</h3>
|
||||||
|
<p data-i18n="ws_beispiel">Ein Handwerksbetrieb: Startseite, Leistungen, Referenzen mit Bildern, Über uns, Kontakt mit Anfahrt und Formular. Anfragen landen strukturiert im Postfach statt als formlose Mail.</p>
|
||||||
|
</div>
|
||||||
|
<div class="ls-feld">
|
||||||
|
<h3 data-i18n="grenze">Nicht enthalten</h3>
|
||||||
|
<p data-i18n="ws_grenze">Kein Produktverkauf und keine geschützten Nutzerbereiche. Sobald etwas verkauft oder ein Login gebraucht wird, gilt die nächste Kategorie.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wd-notiz" style="margin-top:1.3rem">
|
||||||
|
<p><strong data-i18n="extras">Kostet extra</strong>: <span data-i18n="extras_allgemein">Zusätzliche Sprachen · weitere Unterseiten · Logo- und Bildbearbeitung · Texterstellung · Anbindung an fremde Systeme · zusätzliche Feedbackrunden über die vereinbarten hinaus</span></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wd-btn-reihe" style="margin-top:1.3rem">
|
||||||
|
<a class="wd-btn wd-btn--zweit wd-btn--klein" href="/webdesign/anfrage.html?paket=website" data-i18n="jetzt_anfragen">Dieses Paket anfragen</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<!-- ---------------- Shop ---------------- -->
|
||||||
|
<article class="wd-karte wd-karte--kappe ls-block wd-auf" id="shop">
|
||||||
|
<h2 data-i18n="sh_h2">Shop & umfangreiche Website</h2>
|
||||||
|
<div class="ls-preiszeile">
|
||||||
|
<span class="ls-preis"><span data-i18n="ab_preis">ab</span> 1.000 €</span>
|
||||||
|
<span class="ls-dauer"><strong data-i18n="dauer">Dauer</strong>: <span data-i18n="pk_shop_dauer">ca. 4–8 Wochen</span></span>
|
||||||
|
</div>
|
||||||
|
<p class="wd-personalisierung" style="margin-top:-1rem;margin-bottom:1.4rem" data-i18n="pk_personalisierung">Personalisierung möglich — verändert Preis und Dauer.</p>
|
||||||
|
|
||||||
|
<div class="ls-raster">
|
||||||
|
<div class="ls-feld">
|
||||||
|
<h3 data-i18n="fuer_wen">Für wen</h3>
|
||||||
|
<p data-i18n="sh_fuer">Alle, die tatsächlich online verkaufen wollen — Handgemachtes, digitale Produkte, Merch — oder einen geschützten Bereich für Kundinnen und Kunden brauchen.</p>
|
||||||
|
</div>
|
||||||
|
<div class="ls-feld">
|
||||||
|
<h3 data-i18n="enthalten">Typisch enthalten</h3>
|
||||||
|
<ul class="wd-liste">
|
||||||
|
<li data-i18n="sh_1">Produkte, Detailseiten, Warenkorb und Kasse</li>
|
||||||
|
<li data-i18n="sh_2">Zahlungs- und Versandabläufe eingerichtet und durchgespielt</li>
|
||||||
|
<li data-i18n="sh_3">Mobile Kundenführung — Kasse funktioniert auf dem Handy wirklich</li>
|
||||||
|
<li data-i18n="sh_4">Adminbereich zur eigenständigen Produktpflege</li>
|
||||||
|
<li data-i18n="sh_5">Einweisung in die Produktpflege</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="ls-feld">
|
||||||
|
<h3 data-i18n="beispiel">Konkretes Beispiel</h3>
|
||||||
|
<p data-i18n="sh_beispiel">Van's DIY & Bastelbedarf: Kategorien, Artikel mit Bildern und Varianten, Warenkorb, Gutscheincodes, Versandregeln — und ein Adminbereich, in dem neue Artikel ohne Technikkenntnisse angelegt werden.</p>
|
||||||
|
</div>
|
||||||
|
<div class="ls-feld">
|
||||||
|
<h3 data-i18n="grenze">Nicht enthalten</h3>
|
||||||
|
<p data-i18n="sh_grenze">Gebühren von Zahlungsanbietern, Kosten für Domain und Server sowie steuerliche Pflichten liegen bei dir. Ich richte die Technik ein, übernehme aber weder Buchhaltung noch Rechtsberatung.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wd-notiz" style="margin-top:1.3rem">
|
||||||
|
<p><strong data-i18n="extras">Kostet extra</strong>: <span data-i18n="extras_allgemein">Zusätzliche Sprachen · weitere Unterseiten · Logo- und Bildbearbeitung · Texterstellung · Anbindung an fremde Systeme · zusätzliche Feedbackrunden über die vereinbarten hinaus</span></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wd-btn-reihe" style="margin-top:1.3rem">
|
||||||
|
<a class="wd-btn wd-btn--zweit wd-btn--klein" href="/webdesign/anfrage.html?paket=shop" data-i18n="jetzt_anfragen">Dieses Paket anfragen</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<!-- ---------------- Verwaltungsseiten ---------------- -->
|
||||||
|
<article class="wd-karte wd-karte--lila wd-karte--kappe ls-block ls-block--lila wd-auf" id="verwaltung">
|
||||||
|
<h2 data-i18n="vw_h2">Buchhaltungs- & Steuerverwaltungsseiten</h2>
|
||||||
|
<div class="ls-preiszeile">
|
||||||
|
<span class="ls-preis"><span data-i18n="ab_preis">ab</span> 1.500 €</span>
|
||||||
|
<span class="ls-dauer"><strong data-i18n="dauer">Dauer</strong>: <span data-i18n="pk_verwaltung_dauer">ca. 6–12 Wochen</span></span>
|
||||||
|
</div>
|
||||||
|
<p class="wd-personalisierung" style="margin-top:-1rem;margin-bottom:1.4rem" data-i18n="pk_personalisierung">Personalisierung möglich — verändert Preis und Dauer.</p>
|
||||||
|
|
||||||
|
<div class="ls-raster">
|
||||||
|
<div class="ls-feld">
|
||||||
|
<h3 data-i18n="fuer_wen">Für wen</h3>
|
||||||
|
<p data-i18n="vw_fuer">Selbstständige und kleine Betriebe, die Belege, Einnahmen und Ausgaben nicht mehr in Ordnern und Tabellen suchen wollen.</p>
|
||||||
|
</div>
|
||||||
|
<div class="ls-feld">
|
||||||
|
<h3 data-i18n="enthalten">Typisch enthalten</h3>
|
||||||
|
<ul class="wd-liste">
|
||||||
|
<li data-i18n="vw_1">Geschützter Login und Benutzerrollen</li>
|
||||||
|
<li data-i18n="vw_2">Belege, Einnahmen, Ausgaben und Dokumente an einem Ort</li>
|
||||||
|
<li data-i18n="vw_3">Filter, Suche, Auswertungen und Export</li>
|
||||||
|
<li data-i18n="vw_4">Nachvollziehbarer Änderungsverlauf mit Zeitstempel</li>
|
||||||
|
<li data-i18n="vw_5">Regelmäßige Sicherungen mit geprüfter Wiederherstellung</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="ls-feld">
|
||||||
|
<h3 data-i18n="beispiel">Konkretes Beispiel</h3>
|
||||||
|
<p data-i18n="vw_beispiel">Ein Portal, in dem du ein Foto eines Belegs hochlädst, Betrag und Kategorie einträgst und am Jahresende alles gefiltert exportierst — statt einen Schuhkarton zur Steuerberatung zu tragen.</p>
|
||||||
|
</div>
|
||||||
|
<div class="ls-feld">
|
||||||
|
<h3 data-i18n="grenze">Nicht enthalten</h3>
|
||||||
|
<p data-i18n="vw_grenze">Das ist ausdrücklich eine technische Organisationslösung. Keine Buchhaltung, keine Steuerberatung, keine Erstellung von Steuererklärungen — dafür braucht es weiterhin eine Fachperson.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Gold: die einzige rechtlich heikle Stelle dieser Seite. -->
|
||||||
|
<div class="wd-hinweis" style="margin-top:1.3rem">
|
||||||
|
<p class="wd-hinweis-titel" data-i18n="abgrenzung_titel">Technische Abgrenzung</p>
|
||||||
|
<p data-i18n="abgrenzung_text">Buchhaltungs- und Steuerverwaltungsseiten sind ausschließlich technische Organisationslösungen. Diese Seite verspricht keine Buchhaltung, keine Steuerberatung und keine Erstellung von Steuererklärungen.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wd-btn-reihe" style="margin-top:1.3rem">
|
||||||
|
<a class="wd-btn wd-btn--zweit wd-btn--klein" href="/webdesign/anfrage.html?paket=verwaltung" data-i18n="jetzt_anfragen">Dieses Paket anfragen</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<!-- ---------------- Betreuung ---------------- -->
|
||||||
|
<article class="wd-karte wd-karte--kappe ls-block wd-auf" id="betreuung">
|
||||||
|
<h2 data-i18n="bt_h2">Laufende Betreuung</h2>
|
||||||
|
<div class="ls-preiszeile">
|
||||||
|
<span class="ls-preis" data-i18n="betreuung_preis">ab 49 € / Monat</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ls-raster">
|
||||||
|
<div class="ls-feld">
|
||||||
|
<h3 data-i18n="fuer_wen">Für wen</h3>
|
||||||
|
<p data-i18n="bt_fuer">Alle, die nach der Übergabe nicht selbst an der Technik schrauben wollen — oder einfach wissen möchten, dass jemand hinschaut.</p>
|
||||||
|
</div>
|
||||||
|
<div class="ls-feld">
|
||||||
|
<h3 data-i18n="enthalten">Typisch enthalten</h3>
|
||||||
|
<ul class="wd-liste">
|
||||||
|
<li data-i18n="bt_1">Kleine Änderungen typischerweise in ca. 2–5 Arbeitstagen</li>
|
||||||
|
<li data-i18n="bt_2">Sicherheitsaktualisierungen und Überwachung der Erreichbarkeit</li>
|
||||||
|
<li data-i18n="bt_3">Regelmäßige Sicherungen, die auch wirklich zurückgespielt werden können</li>
|
||||||
|
<li data-i18n="bt_4">Umfang und Priorität nach gewähltem Paket</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="ls-feld" style="grid-column:1/-1">
|
||||||
|
<h3 data-i18n="grenze">Nicht enthalten</h3>
|
||||||
|
<p data-i18n="bt_grenze">Betreuung ist keine unbegrenzte Weiterentwicklung. Neue Funktionen oder ganze Unterseiten sind eigene Aufträge — der Aufwand wird vorher genannt, du entscheidest.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wd-btn-reihe" style="margin-top:1.3rem">
|
||||||
|
<a class="wd-btn wd-btn--zweit wd-btn--klein" href="/webdesign/anfrage.html?paket=betreuung" data-i18n="jetzt_anfragen">Dieses Paket anfragen</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="wd-abschnitt wd-abschnitt--gehoben">
|
||||||
|
<div class="wd-container wd-container--schmal" style="text-align:center">
|
||||||
|
<h2 data-i18n="cta_h2">Nicht sicher, welches Paket passt?</h2>
|
||||||
|
<p class="wd-lead" data-i18n="cta_p">Beschreib einfach dein Vorhaben. Wenn ein kleineres Paket reicht, sage ich das — auch wenn das weniger Umsatz bedeutet.</p>
|
||||||
|
<div class="wd-btn-reihe" style="justify-content:center">
|
||||||
|
<a class="wd-btn wd-btn--haupt" href="/webdesign/anfrage.html" data-i18n="nav_anfrage">Projekt anfragen</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="wd-fuss"></div>
|
||||||
|
|
||||||
|
<script src="/assets/js/i18n-wd-leistungen.js?v=20260822wd"></script>
|
||||||
|
<script src="/assets/js/wd-core.js?v=20260822wd"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de" data-i18n-titel="seiten_titel">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Portfolio — Dogfather Webdesign</title>
|
||||||
|
<meta name="description" content="Echte Projekte statt erfundener Beispiele: Dogfather Universe und Van's DIY & Bastelbedarf — mit Ausgangslage, Lösung und Ergebnis." />
|
||||||
|
<meta name="robots" content="noindex, nofollow, noarchive" />
|
||||||
|
<link rel="icon" type="image/png" href="/assets/img/favicon.png" />
|
||||||
|
<meta name="theme-color" content="#05070b" />
|
||||||
|
<link rel="manifest" href="/webdesign/app.webmanifest" />
|
||||||
|
<link rel="apple-touch-icon" href="/assets/img/wd-app-180.png" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Dogfather Webdesign" />
|
||||||
|
<link rel="stylesheet" href="/assets/css/main.css?v=20260822wd" />
|
||||||
|
<link rel="stylesheet" href="/assets/css/webdesign.css?v=20260822wd" />
|
||||||
|
<style>
|
||||||
|
.pfo-projekt { overflow: hidden; padding: 0; margin-bottom: clamp(1.4rem, 3vw, 2.2rem); }
|
||||||
|
.pfo-bild {
|
||||||
|
width: 100%; aspect-ratio: 16 / 10; object-fit: cover; object-position: top center;
|
||||||
|
border-bottom: 1px solid var(--wd-rand);
|
||||||
|
}
|
||||||
|
.pfo-inhalt { padding: clamp(1.3rem, 3vw, 2rem); }
|
||||||
|
.pfo-kopf { display: flex; flex-wrap: wrap; align-items: baseline; gap: .6rem 1rem; margin-bottom: 1rem; }
|
||||||
|
.pfo-kopf h2 { margin: 0; font-size: clamp(1.4rem, 3.2vw, 1.9rem); }
|
||||||
|
|
||||||
|
.pfo-block { margin-bottom: 1.1rem; }
|
||||||
|
.pfo-block h3 { font-size: .74rem; letter-spacing: .13em; text-transform: uppercase; color: var(--wd-blau); margin-bottom: .35em; }
|
||||||
|
.wd-karte--lila .pfo-block h3 { color: var(--wd-lila); }
|
||||||
|
.pfo-block p { margin: 0; }
|
||||||
|
|
||||||
|
/* Die Detailangaben stecken in einem aufklappbaren Bereich: die Karte
|
||||||
|
bleibt beim Überfliegen kurz, wer mehr will, klappt auf. Bewusst das
|
||||||
|
native Klapp-Element des Browsers, damit Tastatur und Screenreader
|
||||||
|
ohne Zusatzcode funktionieren.
|
||||||
|
(Der Tag-Name steht hier absichtlich nicht ausgeschrieben — sonst
|
||||||
|
zählt ihn die Tag-Balance-Prüfung in server/pruefe-webdesign-i18n.mjs
|
||||||
|
als öffnendes Element mit und meldet einen Fehler, den es nicht gibt.) */
|
||||||
|
.pfo-mehr { margin-top: .4rem; }
|
||||||
|
.pfo-mehr > summary {
|
||||||
|
cursor: pointer; list-style: none;
|
||||||
|
display: inline-flex; align-items: center; gap: .4rem;
|
||||||
|
min-height: 44px; padding: .5rem .1rem;
|
||||||
|
font-weight: 700; color: var(--wd-blau); font-size: .95rem;
|
||||||
|
}
|
||||||
|
.wd-karte--lila .pfo-mehr > summary { color: var(--wd-lila); }
|
||||||
|
.pfo-mehr > summary::-webkit-details-marker { display: none; }
|
||||||
|
.pfo-mehr > summary::after { content: "▾"; transition: transform var(--wd-zeit) ease; }
|
||||||
|
.pfo-mehr[open] > summary::after { transform: rotate(180deg); }
|
||||||
|
.pfo-mehr-inhalt { padding-top: .6rem; border-top: 1px solid var(--wd-rand); margin-top: .3rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="wd">
|
||||||
|
|
||||||
|
<div id="wd-kopf"></div>
|
||||||
|
|
||||||
|
<main id="wd-inhalt">
|
||||||
|
|
||||||
|
<section class="wd-abschnitt wd-abschnitt--eng" style="padding-top:clamp(2.4rem,6vw,4rem)">
|
||||||
|
<div class="wd-container">
|
||||||
|
<span class="wd-eyebrow" data-i18n="pf_eyebrow">Portfolio</span>
|
||||||
|
<h1 data-i18n="pf_h1">Zwei Projekte, die man anfassen kann</h1>
|
||||||
|
<p class="wd-lead" style="max-width:72ch" data-i18n="pf_lead">Beide Seiten laufen wirklich, unter ihrer eigenen Adresse, auf einem eigenen Server. Die Vorschaubilder sind echte Aufnahmen der laufenden Seiten — keine gezeichneten Entwürfe.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="wd-abschnitt" style="padding-top:0">
|
||||||
|
<div class="wd-container">
|
||||||
|
|
||||||
|
<!-- ---------------- Projekt 1 ---------------- -->
|
||||||
|
<article class="wd-karte pfo-projekt wd-auf">
|
||||||
|
<img class="pfo-bild" src="/assets/img/wd-projekt-universe.jpg"
|
||||||
|
alt="Startseite von dogfather-universe.com" width="1200" height="750" loading="lazy" decoding="async"
|
||||||
|
data-i18n-attr="alt:p1_alt" />
|
||||||
|
<div class="pfo-inhalt">
|
||||||
|
<div class="pfo-kopf">
|
||||||
|
<h2 data-i18n="p1_h2">Dogfather Universe</h2>
|
||||||
|
<span class="wd-tag" data-i18n="p1_art">Umfangreiche Website</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pfo-block">
|
||||||
|
<h3 data-i18n="pf_ausgang">Ausgangslage</h3>
|
||||||
|
<p data-i18n="p1_ausgang">Drei getrennte Welten sollten unter ein Dach, ohne dass Besucher sich verlaufen.</p>
|
||||||
|
</div>
|
||||||
|
<div class="pfo-block">
|
||||||
|
<h3 data-i18n="pf_loesung">Umgesetzte Lösung</h3>
|
||||||
|
<p data-i18n="p1_loesung">Eine Seite mit drei farblich klar getrennten Welten, gemeinsamer Navigation und durchgängig fünf Sprachen.</p>
|
||||||
|
</div>
|
||||||
|
<div class="pfo-block">
|
||||||
|
<h3 data-i18n="pf_ergebnis">Ergebnis</h3>
|
||||||
|
<p data-i18n="p1_ergebnis">Seit August 2026 öffentlich und im täglichen Einsatz.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="pfo-mehr">
|
||||||
|
<summary data-i18n="pf_details">Details ansehen</summary>
|
||||||
|
<div class="pfo-mehr-inhalt">
|
||||||
|
<div class="pfo-block">
|
||||||
|
<h3 data-i18n="pf_funktionen">Wichtigste Funktionen</h3>
|
||||||
|
<ul class="wd-liste">
|
||||||
|
<li data-i18n="p1_f1">Fünf Sprachen, inklusive echtem Schweizerdeutsch</li>
|
||||||
|
<li data-i18n="p1_f2">Verwaltungsbereich mit Rollen und Rechten</li>
|
||||||
|
<li data-i18n="p1_f3">Bewerbungssystem mit Statusverfolgung</li>
|
||||||
|
<li data-i18n="p1_f4">Supporter-Abo mit Zahlungsanbindung</li>
|
||||||
|
<li data-i18n="p1_f5">Automatische Live-Erkennung des Streams</li>
|
||||||
|
<li data-i18n="p1_f6">Als App installierbar</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="pfo-block">
|
||||||
|
<h3 data-i18n="pf_stil">Stilrichtung</h3>
|
||||||
|
<p data-i18n="p1_stil">Dunkel und cinematisch, mit einer eigenen Leitfarbe je Welt.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div class="wd-btn-reihe" style="margin-top:1rem">
|
||||||
|
<a class="wd-btn wd-btn--zweit wd-btn--klein" href="https://dogfather-universe.com/" target="_blank" rel="noopener" data-i18n="pf_ansehen">Live ansehen</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<!-- ---------------- Projekt 2 ---------------- -->
|
||||||
|
<article class="wd-karte wd-karte--lila pfo-projekt wd-auf">
|
||||||
|
<img class="pfo-bild" src="/assets/img/wd-projekt-vandiy.jpg"
|
||||||
|
alt="Startseite von vans-diy-bastelbedarf.com" width="1200" height="696" loading="lazy" decoding="async"
|
||||||
|
data-i18n-attr="alt:p2_alt" />
|
||||||
|
<div class="pfo-inhalt">
|
||||||
|
<div class="pfo-kopf">
|
||||||
|
<h2 data-i18n="p2_h2">Van's DIY & Bastelbedarf</h2>
|
||||||
|
<span class="wd-tag wd-tag--lila" data-i18n="p2_art">Onlineshop</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pfo-block">
|
||||||
|
<h3 data-i18n="pf_ausgang">Ausgangslage</h3>
|
||||||
|
<p data-i18n="p2_ausgang">Handgemachtes wurde bis dahin über Nachrichten verkauft.</p>
|
||||||
|
</div>
|
||||||
|
<div class="pfo-block">
|
||||||
|
<h3 data-i18n="pf_loesung">Umgesetzte Lösung</h3>
|
||||||
|
<p data-i18n="p2_loesung">Ein vollständiger Shop mit Kategorien, Artikeln, Warenkorb und Gutscheincodes.</p>
|
||||||
|
</div>
|
||||||
|
<div class="pfo-block">
|
||||||
|
<h3 data-i18n="pf_ergebnis">Ergebnis</h3>
|
||||||
|
<p data-i18n="p2_ergebnis">Artikel werden selbstständig angelegt und gepflegt, ohne Rückfrage und ohne Wartezeit.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="pfo-mehr">
|
||||||
|
<summary data-i18n="pf_details">Details ansehen</summary>
|
||||||
|
<div class="pfo-mehr-inhalt">
|
||||||
|
<div class="pfo-block">
|
||||||
|
<h3 data-i18n="pf_funktionen">Wichtigste Funktionen</h3>
|
||||||
|
<ul class="wd-liste">
|
||||||
|
<li data-i18n="p2_f1">Kategorien, Artikel und Varianten</li>
|
||||||
|
<li data-i18n="p2_f2">Warenkorb und Kasse, auf dem Handy geprüft</li>
|
||||||
|
<li data-i18n="p2_f3">Gutscheincodes und Versandregeln</li>
|
||||||
|
<li data-i18n="p2_f4">Adminbereich für die eigenständige Artikelpflege</li>
|
||||||
|
<li data-i18n="p2_f5">Mehrsprachig</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="pfo-block">
|
||||||
|
<h3 data-i18n="pf_stil">Stilrichtung</h3>
|
||||||
|
<p data-i18n="p2_stil">Dunkel mit warmen Violett- und Rosétönen, passend zum handgemachten Sortiment.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div class="wd-btn-reihe" style="margin-top:1rem">
|
||||||
|
<a class="wd-btn wd-btn--zweit wd-btn--klein" href="https://vans-diy-bastelbedarf.com/" target="_blank" rel="noopener" data-i18n="pf_ansehen">Live ansehen</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<!-- Ehrliche Leerstelle statt erfundener Kundenstimmen. -->
|
||||||
|
<div class="wd-notiz wd-auf">
|
||||||
|
<p><strong data-i18n="pf_regeln_titel">Was hier bewusst fehlt</strong><br />
|
||||||
|
<span data-i18n="pf_regeln_text">Es steht kein einziges Kundenzitat auf dieser Seite. Nicht, weil es keine zufriedenen Kunden gäbe, sondern weil noch niemand ein Zitat geschrieben und freigegeben hat.</span></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="wd-abschnitt wd-abschnitt--gehoben">
|
||||||
|
<div class="wd-container wd-container--schmal" style="text-align:center">
|
||||||
|
<h2 data-i18n="pf_cta_h2">Soll deins das dritte sein?</h2>
|
||||||
|
<p class="wd-lead" data-i18n="pf_cta_p">Ob dein Projekt hier später auftaucht, entscheidest ausschließlich du — die Aufnahme ins Portfolio ist von der Veröffentlichung deiner Seite getrennt und braucht deine ausdrückliche Erlaubnis.</p>
|
||||||
|
<div class="wd-btn-reihe" style="justify-content:center">
|
||||||
|
<a class="wd-btn wd-btn--haupt" href="/webdesign/anfrage.html" data-i18n="nav_anfrage">Projekt anfragen</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="wd-fuss"></div>
|
||||||
|
|
||||||
|
<script src="/assets/js/i18n-wd-portfolio.js?v=20260822wd"></script>
|
||||||
|
<script src="/assets/js/wd-core.js?v=20260822wd"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de" data-i18n-titel="seiten_titel">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Preise & Zeit — Dogfather Webdesign</title>
|
||||||
|
<meta name="description" content="Einstiegspreise, Zeitrahmen, 30 % Anzahlung und was den Preis nach oben oder unten bewegt — offen aufgeschrieben." />
|
||||||
|
<meta name="robots" content="noindex, nofollow, noarchive" />
|
||||||
|
<link rel="icon" type="image/png" href="/assets/img/favicon.png" />
|
||||||
|
<meta name="theme-color" content="#05070b" />
|
||||||
|
<link rel="manifest" href="/webdesign/app.webmanifest" />
|
||||||
|
<link rel="apple-touch-icon" href="/assets/img/wd-app-180.png" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Dogfather Webdesign" />
|
||||||
|
<link rel="stylesheet" href="/assets/css/main.css?v=20260822wd" />
|
||||||
|
<link rel="stylesheet" href="/assets/css/webdesign.css?v=20260822wd" />
|
||||||
|
<style>
|
||||||
|
.pr-preis-gross { font-family: var(--font-head); font-weight: 800; color: var(--wd-blau); white-space: nowrap; }
|
||||||
|
|
||||||
|
/* Die Übersichtstabelle wird auf dem Handy zu Karten umgebaut, statt
|
||||||
|
waagerecht zu scrollen. Vier Spalten mit langen deutschen Texten sind
|
||||||
|
auf 390px auch mit Scrollbalken nicht lesbar — man verliert beim
|
||||||
|
Wischen sofort den Bezug zur Zeile. Deshalb: jede Zeile wird eine
|
||||||
|
eigene Karte, die Spaltenüberschrift steht per ::before davor. */
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.pr-tabelle, .pr-tabelle tbody, .pr-tabelle tr, .pr-tabelle td { display: block; width: 100%; }
|
||||||
|
.pr-tabelle { min-width: 0; }
|
||||||
|
.pr-tabelle thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
|
||||||
|
.pr-tabelle tr {
|
||||||
|
border: 1px solid var(--wd-rand); border-radius: var(--wd-r);
|
||||||
|
margin-bottom: .8rem; padding: .3rem .2rem; background: rgba(16,26,41,.5);
|
||||||
|
}
|
||||||
|
.pr-tabelle td { border: none; padding: .45rem 1rem; }
|
||||||
|
.pr-tabelle td::before {
|
||||||
|
content: attr(data-titel);
|
||||||
|
display: block; font-size: .68rem; letter-spacing: .12em; text-transform: uppercase;
|
||||||
|
font-weight: 800; color: var(--wd-blau); margin-bottom: .1rem;
|
||||||
|
}
|
||||||
|
/* Die erste Zelle ist der Paketname — der braucht keine Beschriftung. */
|
||||||
|
.pr-tabelle td:first-child { padding-top: .8rem; }
|
||||||
|
.pr-tabelle td:first-child::before { display: none; }
|
||||||
|
.pr-tabelle td:first-child strong { font-size: 1.1rem; }
|
||||||
|
.pr-tabelle td:last-child { padding-bottom: .8rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-zahlung { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; }
|
||||||
|
@media (max-width: 980px) { .pr-zahlung { grid-template-columns: repeat(2, 1fr); } }
|
||||||
|
@media (max-width: 560px) { .pr-zahlung { grid-template-columns: 1fr; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="wd">
|
||||||
|
|
||||||
|
<div id="wd-kopf"></div>
|
||||||
|
|
||||||
|
<main id="wd-inhalt">
|
||||||
|
|
||||||
|
<section class="wd-abschnitt wd-abschnitt--eng" style="padding-top:clamp(2.4rem,6vw,4rem)">
|
||||||
|
<div class="wd-container">
|
||||||
|
<span class="wd-eyebrow" data-i18n="pr_eyebrow">Preise & Zeit</span>
|
||||||
|
<h1 data-i18n="pr_h1">Was es kostet und wie lange es dauert</h1>
|
||||||
|
<p class="wd-lead" style="max-width:72ch" data-i18n="pr_lead">Alle Werte sind transparente Einstiegspunkte. Personalisierung, Funktionsumfang, Materialien und zusätzliche Änderungswünsche können Preis und Dauer verändern.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ---------------- Übersicht ---------------- -->
|
||||||
|
<section class="wd-abschnitt" style="padding-top:0">
|
||||||
|
<div class="wd-container">
|
||||||
|
<h2 class="wd-auf" data-i18n="pr_tabelle_titel">Alles auf einen Blick</h2>
|
||||||
|
<div class="wd-tabelle-rahmen wd-auf" style="margin-top:1.4rem">
|
||||||
|
<table class="wd-tabelle pr-tabelle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col" data-i18n="pr_sp_paket">Paket</th>
|
||||||
|
<th scope="col" data-i18n="pr_sp_preis">Einstiegspreis</th>
|
||||||
|
<th scope="col" data-i18n="pr_sp_dauer">Zeitrahmen</th>
|
||||||
|
<th scope="col" data-i18n="pr_sp_fuer">Gedacht für</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><strong data-i18n="pk_onepager_h3">Onepager</strong></td>
|
||||||
|
<td data-titel="Einstiegspreis" data-i18n-attr="data-titel:pr_sp_preis"><span class="pr-preis-gross">ab 300 €</span></td>
|
||||||
|
<td data-titel="Zeitrahmen" data-i18n-attr="data-titel:pr_sp_dauer"><span data-i18n="pk_onepager_dauer">ca. 5–10 Arbeitstage</span></td>
|
||||||
|
<td data-titel="Gedacht für" data-i18n-attr="data-titel:pr_sp_fuer"><span data-i18n="pr_kurz_onepager">Eine Person, ein Angebot, eine Aktion</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong data-i18n="pk_website_h3">Professionelle Website</strong></td>
|
||||||
|
<td data-titel="Einstiegspreis" data-i18n-attr="data-titel:pr_sp_preis"><span class="pr-preis-gross">ab 500 €</span></td>
|
||||||
|
<td data-titel="Zeitrahmen" data-i18n-attr="data-titel:pr_sp_dauer"><span data-i18n="pk_website_dauer">ca. 2–4 Wochen</span></td>
|
||||||
|
<td data-titel="Gedacht für" data-i18n-attr="data-titel:pr_sp_fuer"><span data-i18n="pr_kurz_website">Firmen, Teams, mehrere Themen</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong data-i18n="pk_shop_h3">Shop & umfangreiche Website</strong></td>
|
||||||
|
<td data-titel="Einstiegspreis" data-i18n-attr="data-titel:pr_sp_preis"><span class="pr-preis-gross">ab 1.000 €</span></td>
|
||||||
|
<td data-titel="Zeitrahmen" data-i18n-attr="data-titel:pr_sp_dauer"><span data-i18n="pk_shop_dauer">ca. 4–8 Wochen</span></td>
|
||||||
|
<td data-titel="Gedacht für" data-i18n-attr="data-titel:pr_sp_fuer"><span data-i18n="pr_kurz_shop">Wer wirklich online verkauft</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong data-i18n="pk_verwaltung_h3">Buchhaltungs- & Steuerverwaltungsseiten</strong></td>
|
||||||
|
<td data-titel="Einstiegspreis" data-i18n-attr="data-titel:pr_sp_preis"><span class="pr-preis-gross">ab 1.500 €</span></td>
|
||||||
|
<td data-titel="Zeitrahmen" data-i18n-attr="data-titel:pr_sp_dauer"><span data-i18n="pk_verwaltung_dauer">ca. 6–12 Wochen</span></td>
|
||||||
|
<td data-titel="Gedacht für" data-i18n-attr="data-titel:pr_sp_fuer"><span data-i18n="pr_kurz_verwaltung">Belege und Zahlen organisieren</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong data-i18n="betreuung_h3">Laufende Betreuung</strong></td>
|
||||||
|
<td data-titel="Einstiegspreis" data-i18n-attr="data-titel:pr_sp_preis"><span class="pr-preis-gross" data-i18n="betreuung_preis">ab 49 € / Monat</span></td>
|
||||||
|
<td data-titel="Zeitrahmen" data-i18n-attr="data-titel:pr_sp_dauer">—</td>
|
||||||
|
<td data-titel="Gedacht für" data-i18n-attr="data-titel:pr_sp_fuer"><span data-i18n="pr_kurz_betreuung">Nach der Übergabe weiterlaufen lassen</span></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p class="wd-personalisierung wd-auf" style="margin-top:.9rem" data-i18n="pk_personalisierung">Personalisierung möglich — verändert Preis und Dauer.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ---------------- Was den Preis bewegt ---------------- -->
|
||||||
|
<section class="wd-abschnitt wd-abschnitt--gehoben">
|
||||||
|
<div class="wd-container">
|
||||||
|
<h2 class="wd-auf" data-i18n="pr_was_h2">Was den Preis bewegt</h2>
|
||||||
|
<div class="wd-grid wd-grid--2" style="margin-top:1.8rem">
|
||||||
|
<div class="wd-karte wd-karte--lila wd-karte--kappe wd-auf">
|
||||||
|
<h3 data-i18n="pr_hoch_h3">Macht es teurer und länger</h3>
|
||||||
|
<ul class="wd-liste">
|
||||||
|
<li data-i18n="pr_hoch_1">Viele Unterseiten oder viele Produkte</li>
|
||||||
|
<li data-i18n="pr_hoch_2">Zusätzliche Sprachen</li>
|
||||||
|
<li data-i18n="pr_hoch_3">Texte und Bilder müssen erst erstellt werden</li>
|
||||||
|
<li data-i18n="pr_hoch_4">Anbindung an fremde Systeme (Kasse, Kalender, Warenwirtschaft)</li>
|
||||||
|
<li data-i18n="pr_hoch_5">Mehr Feedbackrunden als vereinbart</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="wd-karte wd-karte--kappe wd-auf">
|
||||||
|
<h3 data-i18n="pr_runter_h3">Macht es günstiger und schneller</h3>
|
||||||
|
<ul class="wd-liste">
|
||||||
|
<li data-i18n="pr_runter_1">Texte, Logo und Bilder liegen fertig vor</li>
|
||||||
|
<li data-i18n="pr_runter_2">Klare Vorstellung vom Ziel der Seite</li>
|
||||||
|
<li data-i18n="pr_runter_3">Eine Person entscheidet, nicht ein Gremium</li>
|
||||||
|
<li data-i18n="pr_runter_4">Rückmeldungen gebündelt statt tröpfchenweise</li>
|
||||||
|
<li data-i18n="pr_runter_5">Bestehende Domain und bestehendes Hosting</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ---------------- Zahlung ---------------- -->
|
||||||
|
<section class="wd-abschnitt">
|
||||||
|
<div class="wd-container">
|
||||||
|
<h2 class="wd-auf" data-i18n="pr_zahlung_h2">Wie bezahlt wird</h2>
|
||||||
|
|
||||||
|
<div class="pr-zahlung" style="margin-top:1.8rem">
|
||||||
|
<div class="wd-karte wd-karte--kappe wd-auf">
|
||||||
|
<h3 data-i18n="pr_z1_h3">1. Angebot</h3>
|
||||||
|
<p data-i18n="pr_z1_p">Nach dem Briefing bekommst du Preis, Umfang und Zeitrahmen schriftlich. Bis dahin entstehen dir keine Kosten.</p>
|
||||||
|
</div>
|
||||||
|
<div class="wd-karte wd-karte--kappe wd-auf">
|
||||||
|
<h3 data-i18n="pr_z2_h3">2. Anzahlung 30 %</h3>
|
||||||
|
<p data-i18n="pr_z2_p">Mit der Annahme des Angebots und dem Eingang der Anzahlung wird dein Zeitfenster fest reserviert. Erst dann beginnt die Arbeit.</p>
|
||||||
|
</div>
|
||||||
|
<div class="wd-karte wd-karte--kappe wd-auf">
|
||||||
|
<h3 data-i18n="pr_z3_h3">3. Restbetrag</h3>
|
||||||
|
<p data-i18n="pr_z3_p">Der Rest wird nach deiner Abnahme fällig, vor der Veröffentlichung. Die Nutzungsrechte gehen mit der vollständigen Bezahlung an dich über.</p>
|
||||||
|
</div>
|
||||||
|
<div class="wd-karte wd-karte--lila wd-karte--kappe wd-auf">
|
||||||
|
<h3 data-i18n="pr_z4_h3">Betreuung</h3>
|
||||||
|
<p data-i18n="pr_z4_p">Läuft monatlich und ist jederzeit zum Monatsende kündbar. Keine Mindestlaufzeit, keine automatische Verlängerung um ein ganzes Jahr.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Gold: Zahlungsinformation — genau der vorgesehene Zweck. -->
|
||||||
|
<div class="wd-hinweis wd-auf" style="margin-top:1.5rem">
|
||||||
|
<p class="wd-hinweis-titel" data-i18n="anzahlung_titel">Wichtig: 30 % Anzahlung</p>
|
||||||
|
<p data-i18n="anzahlung_text">Der Projektstart wird nach Annahme des schriftlichen Angebots und Eingang einer Anzahlung von 30 % des vereinbarten Gesamtpreises reserviert.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wd-hinweis wd-auf" style="margin-top:1rem">
|
||||||
|
<p class="wd-hinweis-titel" data-i18n="pr_keine_zusage_titel">Keine automatische Preiszusage</p>
|
||||||
|
<p data-i18n="pr_keine_zusage_text">Die Angaben auf dieser Seite sind Richtwerte, kein Vertrag. Auch das Anfrageformular erzeugt keine verbindliche Zusage. Der Endpreis entsteht ausschließlich durch ein schriftliches Angebot nach Prüfung des tatsächlichen Umfangs.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="wd-abschnitt wd-abschnitt--gehoben">
|
||||||
|
<div class="wd-container wd-container--schmal" style="text-align:center">
|
||||||
|
<h2 data-i18n="pr_cta_h2">Passt das zu deinem Budget?</h2>
|
||||||
|
<p class="wd-lead" data-i18n="pr_cta_p">Sag im Formular einfach, welcher Rahmen realistisch ist. Wenn es nicht passt, sage ich das offen — statt ein Angebot zu schreiben, das nachher niemand bezahlen will.</p>
|
||||||
|
<div class="wd-btn-reihe" style="justify-content:center">
|
||||||
|
<a class="wd-btn wd-btn--haupt" href="/webdesign/anfrage.html" data-i18n="nav_anfrage">Projekt anfragen</a>
|
||||||
|
<a class="wd-btn wd-btn--zweit" href="/webdesign/leistungen.html" data-i18n="nav_leistungen">Leistungen</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="wd-fuss"></div>
|
||||||
|
|
||||||
|
<script src="/assets/js/i18n-wd-preise.js?v=20260822wd"></script>
|
||||||
|
<script src="/assets/js/wd-core.js?v=20260822wd"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
/* =====================================================================
|
||||||
|
sw.js — Service Worker NUR für /webdesign/
|
||||||
|
Macht den Bereich auf Handy und Computer als App installierbar und
|
||||||
|
sorgt dafür, dass die Oberfläche auch bei wackeliger Verbindung
|
||||||
|
sofort da ist.
|
||||||
|
|
||||||
|
⚠️ SICHERHEITSENTSCHEIDUNG, DIE HIER DEN AUSSCHLAG GIBT
|
||||||
|
|
||||||
|
Der Webdesign-Bereich ist zugangsgeschützt (server/webdesign-gate.js).
|
||||||
|
Ein Service Worker, der HTML-Seiten zwischenspeichert, würde diesen
|
||||||
|
Schutz aushebeln: Nach dem Abmelden — oder nachdem jemand anders das
|
||||||
|
Gerät in die Hand nimmt — lieferte der Zwischenspeicher die geschützten
|
||||||
|
Seiten samt Preisen, Anfragen und Kundendaten weiterhin aus, ohne dass
|
||||||
|
der Server je gefragt wird. Genau das ist der klassische Fehler bei
|
||||||
|
PWAs hinter einer Anmeldung.
|
||||||
|
|
||||||
|
Deshalb gilt hier strikt:
|
||||||
|
- HTML-Seiten werden NIEMALS zwischengespeichert. Immer Netz.
|
||||||
|
Ist kein Netz da, kommt eine schlichte Offline-Meldung — nicht die
|
||||||
|
zuletzt gesehene, möglicherweise vertrauliche Seite.
|
||||||
|
- Zwischengespeichert werden ausschließlich die neutralen Bausteine
|
||||||
|
unter /assets/ (Stilvorlagen, Skripte, Logo, Icons). Die enthalten
|
||||||
|
keine Kunden- oder Projektdaten und liegen ohnehin außerhalb der
|
||||||
|
Zugangsschranke.
|
||||||
|
- Antworten der API (postfach.dogfather-universe.com) werden
|
||||||
|
ebenfalls nie zwischengespeichert.
|
||||||
|
|
||||||
|
Das kostet Offline-Komfort. Der ist hier aber weniger wert als die
|
||||||
|
Zusicherung, dass nach dem Schließen der Sitzung wirklich nichts mehr
|
||||||
|
einsehbar ist.
|
||||||
|
===================================================================== */
|
||||||
|
|
||||||
|
/* Version im Namen: eine Änderung dieser Zeichenkette wirft beim nächsten
|
||||||
|
Start alle alten Zwischenspeicher weg. Muss bei jeder Änderung an den
|
||||||
|
Dateien unten hochgezählt werden, sonst hängen Nutzer auf einem alten
|
||||||
|
Stand fest. */
|
||||||
|
const CACHE_NAME = "dogfather-webdesign-v1";
|
||||||
|
|
||||||
|
/* Bausteine, die die Oberfläche zum Anzeigen braucht. Bewusst KEINE
|
||||||
|
HTML-Datei in dieser Liste. */
|
||||||
|
const GRUNDGERUEST = [
|
||||||
|
"/assets/css/main.css",
|
||||||
|
"/assets/css/webdesign.css",
|
||||||
|
"/assets/js/wd-core.js",
|
||||||
|
"/assets/img/logo-dogfather-transparent.png",
|
||||||
|
"/assets/img/wd-app-192.png",
|
||||||
|
"/assets/img/wd-app-512.png",
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener("install", (ereignis) => {
|
||||||
|
ereignis.waitUntil(
|
||||||
|
caches.open(CACHE_NAME).then((speicher) =>
|
||||||
|
/* addAll bricht komplett ab, wenn EINE Datei fehlt — dann wäre der
|
||||||
|
Service Worker gar nicht erst installiert und die App nicht
|
||||||
|
installierbar. Deshalb jede Datei einzeln und Fehler schlucken. */
|
||||||
|
Promise.all(
|
||||||
|
GRUNDGERUEST.map((pfad) =>
|
||||||
|
speicher.add(new Request(pfad, { cache: "reload" })).catch(() => {})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
/* Sofort übernehmen statt auf das Schließen aller Tabs zu warten —
|
||||||
|
sonst bleibt nach einem Deploy tagelang die alte Fassung aktiv. */
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (ereignis) => {
|
||||||
|
ereignis.waitUntil(
|
||||||
|
caches.keys()
|
||||||
|
.then((namen) => Promise.all(
|
||||||
|
namen.filter((n) => n !== CACHE_NAME).map((n) => caches.delete(n))
|
||||||
|
))
|
||||||
|
.then(() => self.clients.claim())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Schlichte Offline-Meldung. Bewusst als erzeugte Antwort statt als
|
||||||
|
Datei — so kann sie nie mit echtem Seiteninhalt verwechselt werden.
|
||||||
|
Fünf Sprachen wären hier Überinstrumentierung: der Text erscheint nur
|
||||||
|
ohne Internet und ist absichtlich sehr kurz gehalten. */
|
||||||
|
function offlineSeite() {
|
||||||
|
return new Response(
|
||||||
|
`<!DOCTYPE html><html lang="de"><head><meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Keine Verbindung</title>
|
||||||
|
<style>
|
||||||
|
body{margin:0;min-height:100svh;display:flex;align-items:center;justify-content:center;
|
||||||
|
background:#05070b;color:#eef4fb;font-family:system-ui,sans-serif;text-align:center;padding:2rem}
|
||||||
|
.k{max-width:34ch}
|
||||||
|
h1{font-size:1.4rem;margin:0 0 .6rem}
|
||||||
|
p{color:#b3c2d4;line-height:1.6;margin:0 0 1.4rem}
|
||||||
|
button{min-height:48px;padding:.8rem 1.6rem;border:none;border-radius:999px;
|
||||||
|
background:#7fd0e8;color:#04070c;font-weight:700;font-size:1rem;cursor:pointer}
|
||||||
|
</style></head><body>
|
||||||
|
<div class="k">
|
||||||
|
<h1>Keine Verbindung</h1>
|
||||||
|
<p>Dogfather Webdesign braucht eine Internetverbindung. Sobald du wieder online bist, geht es normal weiter.</p>
|
||||||
|
<button onclick="location.reload()">Erneut versuchen</button>
|
||||||
|
</div></body></html>`,
|
||||||
|
{ status: 503, headers: { "Content-Type": "text/html; charset=utf-8" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.addEventListener("fetch", (ereignis) => {
|
||||||
|
const anfrage = ereignis.request;
|
||||||
|
|
||||||
|
// Nur einfache Abrufe. Absendungen (POST) gehen immer direkt ans Netz.
|
||||||
|
if (anfrage.method !== "GET") return;
|
||||||
|
|
||||||
|
const url = new URL(anfrage.url);
|
||||||
|
|
||||||
|
// Fremde Server (API, externe Bilder) nie anfassen.
|
||||||
|
if (url.origin !== self.location.origin) return;
|
||||||
|
|
||||||
|
/* --- HTML: immer Netz, niemals zwischenspeichern (siehe Kopf) --- */
|
||||||
|
const willHtml = anfrage.mode === "navigate" ||
|
||||||
|
(anfrage.headers.get("accept") || "").includes("text/html");
|
||||||
|
if (willHtml) {
|
||||||
|
ereignis.respondWith(
|
||||||
|
fetch(anfrage).catch(() => offlineSeite())
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Zugangs-Endpunkte niemals zwischenspeichern --- */
|
||||||
|
if (url.pathname.startsWith("/webdesign/zugang-")) return;
|
||||||
|
|
||||||
|
/* --- Neutrale Bausteine: erst Zwischenspeicher, dann Netz ---
|
||||||
|
"stale-while-revalidate": die gespeicherte Fassung geht sofort raus
|
||||||
|
(die Seite baut sich ohne Wartezeit auf), im Hintergrund wird eine
|
||||||
|
frische geholt und für das nächste Mal abgelegt. */
|
||||||
|
if (url.pathname.startsWith("/assets/")) {
|
||||||
|
ereignis.respondWith(
|
||||||
|
caches.open(CACHE_NAME).then(async (speicher) => {
|
||||||
|
const gespeichert = await speicher.match(anfrage);
|
||||||
|
const ausDemNetz = fetch(anfrage)
|
||||||
|
.then((antwort) => {
|
||||||
|
/* Nur vollständige, eigene Antworten ablegen. Teilantworten
|
||||||
|
(206, z.B. bei Video-Sprüngen) lassen sich nicht sinnvoll
|
||||||
|
zwischenspeichern und würden den Speicher beschädigen. */
|
||||||
|
if (antwort && antwort.status === 200 && antwort.type === "basic") {
|
||||||
|
speicher.put(anfrage, antwort.clone());
|
||||||
|
}
|
||||||
|
return antwort;
|
||||||
|
})
|
||||||
|
.catch(() => null);
|
||||||
|
|
||||||
|
return gespeichert || (await ausDemNetz) || Response.error();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
/* Alles Übrige: kein respondWith -> der Browser macht es ganz normal
|
||||||
|
selbst. Das ist die sicherste Voreinstellung. */
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de" data-i18n-titel="seiten_titel">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Über Dogfather — Dogfather Webdesign</title>
|
||||||
|
<meta name="description" content="Keine Agentur, keine Ticketnummer: wer hier baut, wie gearbeitet wird und woran das gelernt wurde." />
|
||||||
|
<meta name="robots" content="noindex, nofollow, noarchive" />
|
||||||
|
<link rel="icon" type="image/png" href="/assets/img/favicon.png" />
|
||||||
|
<meta name="theme-color" content="#05070b" />
|
||||||
|
<link rel="manifest" href="/webdesign/app.webmanifest" />
|
||||||
|
<link rel="apple-touch-icon" href="/assets/img/wd-app-180.png" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Dogfather Webdesign" />
|
||||||
|
<link rel="stylesheet" href="/assets/css/main.css?v=20260822wd" />
|
||||||
|
<link rel="stylesheet" href="/assets/css/webdesign.css?v=20260822wd" />
|
||||||
|
<style>
|
||||||
|
.ue-kopf { display: grid; grid-template-columns: minmax(0, .85fr) minmax(0, 1.15fr); gap: clamp(1.5rem, 4vw, 3rem); align-items: center; }
|
||||||
|
/* Auf dem Handy zuerst der Text, dann das Bild: sonst schiebt ein
|
||||||
|
hohes 4:5-Porträt die Überschrift komplett aus dem ersten Bildschirm. */
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.ue-kopf { grid-template-columns: 1fr; }
|
||||||
|
.ue-kopf-bild { order: 2; margin-top: .5rem; }
|
||||||
|
.ue-kopf-text { order: 1; }
|
||||||
|
}
|
||||||
|
.ue-portrait {
|
||||||
|
width: 100%; max-width: 400px; border-radius: var(--wd-r-gross);
|
||||||
|
aspect-ratio: 4 / 5; object-fit: cover;
|
||||||
|
border: 1px solid var(--wd-rand-stark);
|
||||||
|
box-shadow: var(--wd-schatten-tief);
|
||||||
|
}
|
||||||
|
.ue-fliess p { font-size: clamp(1.02rem, 1.6vw, 1.14rem); max-width: 72ch; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="wd">
|
||||||
|
|
||||||
|
<div id="wd-kopf"></div>
|
||||||
|
|
||||||
|
<main id="wd-inhalt">
|
||||||
|
|
||||||
|
<section class="wd-abschnitt" style="padding-top:clamp(2.4rem,6vw,4rem)">
|
||||||
|
<div class="wd-container">
|
||||||
|
<div class="ue-kopf">
|
||||||
|
<div class="ue-kopf-bild">
|
||||||
|
<img class="ue-portrait wd-auf" src="/assets/img/wd-dogfather-portrait.jpg"
|
||||||
|
alt="Dogfather" width="900" height="1125" fetchpriority="high" decoding="async"
|
||||||
|
data-i18n-attr="alt:ue_foto_alt" />
|
||||||
|
</div>
|
||||||
|
<div class="ue-kopf-text">
|
||||||
|
<span class="wd-eyebrow" data-i18n="ue_eyebrow">Über Dogfather</span>
|
||||||
|
<h1 data-i18n="ue_h1">Wer hier eigentlich baut</h1>
|
||||||
|
<p class="wd-lead" data-i18n="ue_lead">Keine Agentur, kein Callcenter, keine Ticketnummer. Eine Person, die dein Projekt von der ersten Nachricht bis zur Übergabe begleitet — und die danach noch erreichbar ist.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="wd-abschnitt wd-abschnitt--eng" style="padding-top:0">
|
||||||
|
<div class="wd-container wd-container--schmal ue-fliess">
|
||||||
|
<p class="wd-auf" data-i18n="ue_text1">Dogfather ist der Name, unter dem ich seit Jahren im Netz unterwegs bin — als Streamer, als Teil einer Community und mittlerweile auch als jemand, der Webseiten baut. Das eine ist aus dem anderen entstanden: Irgendwann brauchte das eigene Projekt eine richtige Seite, dann brauchte jemand aus dem Umfeld eine, dann noch jemand.</p>
|
||||||
|
<p class="wd-auf" data-i18n="ue_text2">Dadurch sind die Seiten hier nicht am Reißbrett entstanden, sondern an echten Problemen: Eine Shop-Inhaberin, die ihre Artikel selbst pflegen können muss, ohne Angst etwas kaputtzumachen. Ein Team, das seine eigenen Inhalte ändern will, ohne jedes Mal jemanden zu fragen. Genau daran messe ich, ob eine Seite gut ist — nicht daran, wie sie auf einem Werbebild aussieht.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ---------------- Arbeitsweise ---------------- -->
|
||||||
|
<section class="wd-abschnitt wd-abschnitt--gehoben">
|
||||||
|
<div class="wd-container">
|
||||||
|
<h2 class="wd-auf" data-i18n="ue_werte_h2">Wie ich arbeite</h2>
|
||||||
|
<div class="wd-grid wd-grid--2" style="margin-top:1.8rem">
|
||||||
|
<div class="wd-karte wd-karte--kappe wd-auf">
|
||||||
|
<h3 data-i18n="ue_w1_h3">Ehrlich vor gefällig</h3>
|
||||||
|
<p data-i18n="ue_w1_p">Wenn ein kleineres Paket reicht, sage ich das — auch wenn ich damit weniger verdiene. Wenn etwas technisch nicht sinnvoll ist, sage ich das vorher, nicht wenn die Anzahlung schon da ist.</p>
|
||||||
|
</div>
|
||||||
|
<div class="wd-karte wd-karte--lila wd-karte--kappe wd-auf">
|
||||||
|
<h3 data-i18n="ue_w2_h3">Handy zuerst</h3>
|
||||||
|
<p data-i18n="ue_w2_p">Die meisten Besucher kommen mit dem Handy. Deshalb wird dort zuerst geprüft, nicht zuletzt — und zwar auf echten Bildschirmgrößen, nicht nur in einem verkleinerten Fenster am Computer.</p>
|
||||||
|
</div>
|
||||||
|
<div class="wd-karte wd-karte--lila wd-karte--kappe wd-auf">
|
||||||
|
<h3 data-i18n="ue_w3_h3">Fertig heißt fertig</h3>
|
||||||
|
<p data-i18n="ue_w3_p">Keine halb fertigen Unterseiten, keine Platzhalter und keine Links, die ins Leere führen. Eine Seite gilt erst als fertig, wenn jedes Formular, jeder Zugang und jeder Fehlerfall wirklich durchgespielt wurde.</p>
|
||||||
|
</div>
|
||||||
|
<div class="wd-karte wd-karte--kappe wd-auf">
|
||||||
|
<h3 data-i18n="ue_w4_h3">Augenschonend</h3>
|
||||||
|
<p data-i18n="ue_w4_p">Dunkel, kontraststark genug zum Lesen, aber ohne grelle Neonfarben und ohne flackernde Effekte. Wer in den Systemeinstellungen weniger Bewegung wünscht, bekommt sie auch nicht.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ---------------- Erfahrung ---------------- -->
|
||||||
|
<section class="wd-abschnitt">
|
||||||
|
<div class="wd-container">
|
||||||
|
<h2 class="wd-auf" data-i18n="ue_erfahrung_h2">Woran ich das gelernt habe</h2>
|
||||||
|
<div class="wd-grid wd-grid--2" style="margin-top:1.8rem">
|
||||||
|
<div class="wd-karte wd-karte--kappe wd-auf">
|
||||||
|
<h3 data-i18n="ue_e1_h3">Dogfather Universe</h3>
|
||||||
|
<p data-i18n="ue_e1_p">Eine Plattform mit fünf Sprachen, Teamseiten, Bewerbungssystem, Supporter-Abo mit Zahlungsanbindung und einem Verwaltungsbereich, in dem Inhalte ohne Programmierkenntnisse gepflegt werden. Öffentlich seit August 2026, auf einem eigenen Server.</p>
|
||||||
|
</div>
|
||||||
|
<div class="wd-karte wd-karte--lila wd-karte--kappe wd-auf">
|
||||||
|
<h3 data-i18n="ue_e2_h3">Van's DIY & Bastelbedarf</h3>
|
||||||
|
<p data-i18n="ue_e2_p">Ein vollständiger Onlineshop mit Warenkorb, Gutscheincodes und Versandabläufen — und einem Adminbereich, der so gebaut ist, dass die Inhaberin ihn ohne Technikkenntnisse bedient. Genau daran habe ich gelernt, wie viel Aufwand in „einfach zu bedienen“ steckt.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="wd-btn-reihe wd-auf" style="margin-top:1.4rem">
|
||||||
|
<a class="wd-btn wd-btn--zweit" href="/webdesign/portfolio.html" data-i18n="ue_projekte">Projekte ansehen</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wd-notiz wd-auf" style="margin-top:1.6rem">
|
||||||
|
<p><strong data-i18n="ue_team_titel">Nicht allein, aber verantwortlich</strong><br />
|
||||||
|
<span data-i18n="ue_team_text">Bei Rückfragen, Gegenlesen und der Abnahme hilft VanVan mit — sie hat auf diese Vorabversion dieselben Rechte wie ich. Deine Ansprechperson für dein Projekt bleibe trotzdem ich, damit du nie erklären musst, was du letzte Woche schon erklärt hast.</span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="wd-abschnitt wd-abschnitt--gehoben">
|
||||||
|
<div class="wd-container wd-container--schmal" style="text-align:center">
|
||||||
|
<h2 data-i18n="ue_cta_h2">Klingt das nach jemandem, mit dem du arbeiten willst?</h2>
|
||||||
|
<p class="wd-lead" data-i18n="ue_cta_p">Dann erzähl mir von deinem Vorhaben. Das Formular dauert ein paar Minuten und verpflichtet dich zu nichts.</p>
|
||||||
|
<div class="wd-btn-reihe" style="justify-content:center">
|
||||||
|
<a class="wd-btn wd-btn--haupt" href="/webdesign/anfrage.html" data-i18n="nav_anfrage">Projekt anfragen</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="wd-fuss"></div>
|
||||||
|
|
||||||
|
<script src="/assets/js/i18n-wd-ueber.js?v=20260822wd"></script>
|
||||||
|
<script src="/assets/js/wd-core.js?v=20260822wd"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de" data-i18n-titel="seiten_titel">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Zugang — Dogfather Webdesign</title>
|
||||||
|
<!-- Doppelt abgesichert: server/webdesign-gate.js schickt zusätzlich einen
|
||||||
|
X-Robots-Tag-Header. Der Header wirkt auch dann, wenn die Seite gar
|
||||||
|
nicht ausgeliefert wird; dieses meta wirkt auch dann, wenn die Datei
|
||||||
|
einmal ohne den Server geöffnet wird. -->
|
||||||
|
<meta name="robots" content="noindex, nofollow, noarchive" />
|
||||||
|
<link rel="icon" type="image/png" href="/assets/img/favicon.png" />
|
||||||
|
<meta name="theme-color" content="#05070b" />
|
||||||
|
<!-- Auch hier Manifest und Symbol: die Zugangswand ist der ERSTE
|
||||||
|
Bildschirm überhaupt, also auch der, von dem aus installiert wird.
|
||||||
|
Genau dieser Punkt fehlte am 19.08.2026 in der Universe-Zugangsseite
|
||||||
|
und führte dazu, dass eine Installation nur eine nackte Verknüpfung
|
||||||
|
ohne Symbol ergab. -->
|
||||||
|
<link rel="manifest" href="/webdesign/app.webmanifest" />
|
||||||
|
<link rel="apple-touch-icon" href="/assets/img/wd-app-180.png" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Dogfather Webdesign" />
|
||||||
|
<link rel="stylesheet" href="/assets/css/main.css?v=20260822wd" />
|
||||||
|
<link rel="stylesheet" href="/assets/css/webdesign.css?v=20260822wd" />
|
||||||
|
<style>
|
||||||
|
/* =====================================================================
|
||||||
|
Zugangswand — nur hier gebraucht, deshalb bewusst NICHT in
|
||||||
|
webdesign.css (die Datei wird von jeder Seite geladen; Regeln, die
|
||||||
|
genau eine Seite braucht, gehören nicht in jeden Ladevorgang).
|
||||||
|
===================================================================== */
|
||||||
|
.zg-buehne {
|
||||||
|
min-height: calc(100svh - 66px);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
padding: clamp(1.5rem, 5vw, 3.5rem) 0;
|
||||||
|
}
|
||||||
|
.zg-mitte { width: min(940px, 92vw); text-align: center; }
|
||||||
|
|
||||||
|
.zg-logo { width: clamp(120px, 22vw, 176px); margin: 0 auto 1.2rem; position: relative; }
|
||||||
|
.zg-logo img { width: 100%; height: auto; filter: drop-shadow(0 18px 34px rgba(0,0,0,.7)); }
|
||||||
|
/* Ruhige Glut HINTER der freigestellten Logoform — bewusst kein
|
||||||
|
rotierender Ring: der verzerrt sich auf unregelmäßigen Formen
|
||||||
|
(dokumentierte Lektion aus gate.html). */
|
||||||
|
.zg-logo::before {
|
||||||
|
content: ""; position: absolute; inset: -18%; z-index: -1; border-radius: 50%;
|
||||||
|
background: radial-gradient(circle, rgba(127,208,232,.34) 0%, rgba(183,157,255,.18) 46%, transparent 72%);
|
||||||
|
filter: blur(30px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.zg-mitte h1 { margin-bottom: .35em; }
|
||||||
|
.zg-mitte > p { max-width: 56ch; margin-left: auto; margin-right: auto; }
|
||||||
|
|
||||||
|
/* Zwei gleich große Karten — die Gleichwertigkeit ist hier bewusst auch
|
||||||
|
optisch umgesetzt: gleiche Größe, gleiche Struktur, gleicher
|
||||||
|
Rollenhinweis. Unterschiedlich ist nur die Farbwelt, damit man auf
|
||||||
|
einen Blick sieht, welche Karte wem gehört. */
|
||||||
|
.zg-karten {
|
||||||
|
display: grid; grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: clamp(.9rem, 2.4vw, 1.5rem);
|
||||||
|
margin: clamp(1.8rem, 4vw, 2.8rem) 0 1.6rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) { .zg-karten { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
.zg-karte { display: flex; flex-direction: column; }
|
||||||
|
.zg-karte-kopf { display: flex; align-items: center; gap: .9rem; margin-bottom: 1.1rem; }
|
||||||
|
.zg-foto {
|
||||||
|
width: 76px; height: 76px; flex-shrink: 0;
|
||||||
|
border-radius: 50%; object-fit: cover;
|
||||||
|
border: 2px solid var(--wd-rand-stark);
|
||||||
|
box-shadow: 0 0 22px -6px rgba(127,208,232,.5);
|
||||||
|
}
|
||||||
|
.zg-karte--vanvan .zg-foto { border-color: rgba(183,157,255,.55); box-shadow: 0 0 22px -6px rgba(183,157,255,.55); }
|
||||||
|
/* ".wd h2" (webdesign.css) ist spezifischer als eine reine Klasse und
|
||||||
|
würde eine Angabe an ".zg-name" allein schlicht überstimmen — der Name
|
||||||
|
stand dadurch in voller Überschriftengröße da. Deshalb hier bewusst
|
||||||
|
ebenfalls zweistufig geschrieben. */
|
||||||
|
.wd .zg-name { margin: 0; font-size: clamp(1.35rem, 3.2vw, 1.6rem); }
|
||||||
|
.zg-rolle { margin: .1rem 0 0; font-size: .82rem; letter-spacing: .1em; text-transform: uppercase; font-weight: 700; color: var(--wd-blau); }
|
||||||
|
.zg-karte--vanvan .zg-rolle { color: var(--wd-lila); }
|
||||||
|
|
||||||
|
.zg-karte .wd-feld { margin-bottom: .9rem; }
|
||||||
|
/* Code-Eingabe: großzügige Sperrung und mittige Ausrichtung, damit ein
|
||||||
|
kurzer Code nicht verloren am linken Rand klebt. */
|
||||||
|
.zg-karte input {
|
||||||
|
text-align: center; letter-spacing: .16em; font-weight: 700; font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
.zg-karte input::placeholder { letter-spacing: normal; font-weight: 400; }
|
||||||
|
.zg-karte--vanvan input:focus { border-color: var(--wd-lila); box-shadow: 0 0 0 4px rgba(183,157,255,.16); }
|
||||||
|
.zg-karte--vanvan .wd-btn--haupt {
|
||||||
|
background: linear-gradient(135deg, #cdbcff, var(--wd-lila) 55%, #9b7ef0);
|
||||||
|
box-shadow: 0 12px 34px -14px rgba(183,157,255,.8);
|
||||||
|
}
|
||||||
|
.zg-karte .wd-btn { margin-top: auto; }
|
||||||
|
|
||||||
|
.zg-fehler {
|
||||||
|
display: none; margin: .7rem 0 0;
|
||||||
|
font-size: .92rem; font-weight: 600; color: var(--wd-fehler);
|
||||||
|
}
|
||||||
|
.zg-fehler.zg-zeige { display: block; }
|
||||||
|
|
||||||
|
.zg-unten { display: flex; flex-wrap: wrap; gap: 1rem; justify-content: center; align-items: center; margin-top: 1.4rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="wd">
|
||||||
|
|
||||||
|
<div id="wd-kopf" data-wd-kopf="minimal"></div>
|
||||||
|
|
||||||
|
<main class="zg-buehne">
|
||||||
|
<div class="zg-mitte">
|
||||||
|
|
||||||
|
<div class="zg-logo">
|
||||||
|
<img src="/assets/img/logo-dogfather-transparent.png" alt="" width="472" height="476" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="wd-eyebrow" data-i18n="zg_eyebrow">Nicht öffentlich bis zur Freigabe</span>
|
||||||
|
<h1 data-i18n="zg_h1">Willkommen zurück</h1>
|
||||||
|
<p class="wd-lead" data-i18n="zg_text">Diese Vorabversion ist nur für Dogfather und VanVan gedacht. Wähle deine Karte und gib deinen persönlichen Zugangscode ein.</p>
|
||||||
|
|
||||||
|
<div class="zg-karten">
|
||||||
|
|
||||||
|
<!-- Karte 1: Dogfather -->
|
||||||
|
<form class="wd-karte wd-karte--kappe zg-karte zg-karte--dogfather" id="zg-form-dogfather" novalidate>
|
||||||
|
<div class="zg-karte-kopf">
|
||||||
|
<img class="zg-foto" src="/assets/img/wd-dogfather-karte.jpg" alt="" width="76" height="76" />
|
||||||
|
<div>
|
||||||
|
<h2 class="zg-name" data-i18n="zg_dogfather">Dogfather</h2>
|
||||||
|
<p class="zg-rolle" data-i18n="zg_rolle">Volladministrator</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="wd-feld">
|
||||||
|
<!-- Sichtbares Label statt nur Platzhalter: ein Platzhalter
|
||||||
|
verschwindet beim Tippen und ist für Screenreader kein Ersatz. -->
|
||||||
|
<label for="zg-code-dogfather" data-i18n="zg_code_label">Zugangscode</label>
|
||||||
|
<!-- KEIN autofocus (Lektion vom 21.08.2026 aus gate.html): bei zwei
|
||||||
|
gleichberechtigten Karten zieht ein vorausgewähltes Feld auf dem
|
||||||
|
Handy den Cursor und die Ansicht zur falschen Karte, sobald die
|
||||||
|
Seite fertig geladen hat. -->
|
||||||
|
<input type="password" id="zg-code-dogfather" name="code"
|
||||||
|
autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false"
|
||||||
|
inputmode="text" aria-describedby="zg-fehler-dogfather" />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="wd-btn wd-btn--haupt wd-btn--breit" data-i18n="zg_oeffnen">Öffnen</button>
|
||||||
|
<p class="zg-fehler" id="zg-fehler-dogfather" role="alert"></p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Karte 2: VanVan — gleiche Struktur, gleiche Rechte, eigene Farbwelt -->
|
||||||
|
<form class="wd-karte wd-karte--lila wd-karte--kappe zg-karte zg-karte--vanvan" id="zg-form-vanvan" novalidate>
|
||||||
|
<div class="zg-karte-kopf">
|
||||||
|
<img class="zg-foto" src="/assets/img/wd-vanvan-karte.jpg" alt="" width="76" height="76" />
|
||||||
|
<div>
|
||||||
|
<h2 class="zg-name" data-i18n="zg_vanvan">VanVan</h2>
|
||||||
|
<p class="zg-rolle" data-i18n="zg_rolle">Volladministrator</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="wd-feld">
|
||||||
|
<label for="zg-code-vanvan" data-i18n="zg_code_label">Zugangscode</label>
|
||||||
|
<input type="password" id="zg-code-vanvan" name="code"
|
||||||
|
autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false"
|
||||||
|
inputmode="text" aria-describedby="zg-fehler-vanvan" />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="wd-btn wd-btn--haupt wd-btn--breit" data-i18n="zg_oeffnen">Öffnen</button>
|
||||||
|
<p class="zg-fehler" id="zg-fehler-vanvan" role="alert"></p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wd-hinweis" style="text-align:left">
|
||||||
|
<p class="wd-hinweis-titel" data-i18n="zg_sicher_titel">Warum ein Code?</p>
|
||||||
|
<p data-i18n="zg_sicher_text">Solange die Seite nicht freigegeben ist, soll niemand Preise, Beispiele oder Kundendaten sehen können. Der Code wird ausschließlich auf dem Server geprüft und nie im Browser gespeichert. Nach fünf Fehlversuchen greift eine kurze Wartezeit.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="zg-unten">
|
||||||
|
<a class="wd-btn wd-btn--still wd-btn--klein" href="/index.html" data-i18n="zg_zurueck">Zum Dogfather Universe</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/assets/js/i18n-wd-zugang.js?v=20260822wd"></script>
|
||||||
|
<script src="/assets/js/wd-core.js?v=20260822wd"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
/* Zielpfad aus der Adresszeile. Wird zusätzlich SERVERSEITIG geprüft
|
||||||
|
(sicherePfadAngabe in webdesign-gate.js) — diese Prüfung hier ist nur
|
||||||
|
die freundliche erste Stufe, niemals die eigentliche Absicherung. */
|
||||||
|
var params = new URLSearchParams(location.search);
|
||||||
|
var roh = params.get("next") || "/webdesign/";
|
||||||
|
var ziel = (roh.indexOf("/webdesign/") === 0 && roh.indexOf("//") !== 0 && roh.indexOf("\\") < 0 && roh.indexOf("..") < 0)
|
||||||
|
? roh : "/webdesign/";
|
||||||
|
|
||||||
|
var ZEITLIMIT_MS = 12000;
|
||||||
|
|
||||||
|
function binde(formId, inputId, fehlerId) {
|
||||||
|
var form = document.getElementById(formId);
|
||||||
|
var eingabe = document.getElementById(inputId);
|
||||||
|
var fehlerEl = document.getElementById(fehlerId);
|
||||||
|
var knopf = form.querySelector('button[type="submit"]');
|
||||||
|
var laeuft = false;
|
||||||
|
|
||||||
|
function sperren(an, text) {
|
||||||
|
laeuft = an;
|
||||||
|
knopf.disabled = an;
|
||||||
|
knopf.setAttribute("aria-busy", an ? "true" : "false");
|
||||||
|
knopf.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fehler(text) {
|
||||||
|
fehlerEl.textContent = text;
|
||||||
|
fehlerEl.classList.add("zg-zeige");
|
||||||
|
form.querySelector(".wd-feld").classList.add("wd-feld--fehler");
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener("submit", async function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (laeuft) return; // Doppel-Tap ignorieren
|
||||||
|
fehlerEl.classList.remove("zg-zeige");
|
||||||
|
form.querySelector(".wd-feld").classList.remove("wd-feld--fehler");
|
||||||
|
|
||||||
|
var code = eingabe.value.trim();
|
||||||
|
if (!code) {
|
||||||
|
fehler(window.WD.t("zg_leer"));
|
||||||
|
eingabe.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sperren(true, window.WD.t("zg_pruefe"));
|
||||||
|
|
||||||
|
/* AbortController statt AbortSignal.timeout(): letzteres kennen
|
||||||
|
ältere Handy-Browser nicht. Ohne Zeitlimit hängt fetch im
|
||||||
|
Mobilfunk unbegrenzt und es sieht aus, als sei der Tap nie
|
||||||
|
angekommen (genau dieser Report kam am 19.08.2026 zu gate.html). */
|
||||||
|
var abbruch = new AbortController();
|
||||||
|
var timer = setTimeout(function () { abbruch.abort(); }, ZEITLIMIT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
var antwort = await fetch("/webdesign/zugang-pruefen", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ code: code, next: ziel }),
|
||||||
|
signal: abbruch.signal,
|
||||||
|
});
|
||||||
|
var daten = await antwort.json().catch(function () { return null; });
|
||||||
|
|
||||||
|
if (!antwort.ok || !daten || !daten.ok) {
|
||||||
|
fehler((daten && daten.error) || window.WD.t("zg_falsch"));
|
||||||
|
sperren(false, window.WD.t("zg_oeffnen"));
|
||||||
|
eingabe.select();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Erfolg: Knopf bewusst gesperrt lassen — die Seite wechselt
|
||||||
|
gleich, ein weiterer Tap wäre nur verwirrend. */
|
||||||
|
knopf.textContent = window.WD.t("zg_willkommen");
|
||||||
|
location.href = daten.next || "/webdesign/";
|
||||||
|
} catch (err) {
|
||||||
|
fehler(err && err.name === "AbortError"
|
||||||
|
? window.WD.t("fehler_zeit")
|
||||||
|
: window.WD.t("fehler_verbindung"));
|
||||||
|
sperren(false, window.WD.t("zg_oeffnen"));
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Nach einem Sprachwechsel muss auch die Knopfbeschriftung mitziehen —
|
||||||
|
sie wird oben per JS gesetzt und trägt deshalb kein data-i18n. */
|
||||||
|
document.addEventListener("wd-sprache-geaendert", function () {
|
||||||
|
if (!laeuft) knopf.textContent = window.WD.t("zg_oeffnen");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
binde("zg-form-dogfather", "zg-code-dogfather", "zg-fehler-dogfather");
|
||||||
|
binde("zg-form-vanvan", "zg-code-vanvan", "zg-fehler-vanvan");
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||