diff --git a/public/relaunch/portal/desktop-1-projekte-1200.webp b/public/relaunch/portal/desktop-1-projekte-1200.webp new file mode 100644 index 0000000..4b2edbd Binary files /dev/null and b/public/relaunch/portal/desktop-1-projekte-1200.webp differ diff --git a/public/relaunch/portal/desktop-1-projekte-1600.webp b/public/relaunch/portal/desktop-1-projekte-1600.webp new file mode 100644 index 0000000..9bc9b52 Binary files /dev/null and b/public/relaunch/portal/desktop-1-projekte-1600.webp differ diff --git a/public/relaunch/portal/desktop-1-projekte-800.webp b/public/relaunch/portal/desktop-1-projekte-800.webp new file mode 100644 index 0000000..a757415 Binary files /dev/null and b/public/relaunch/portal/desktop-1-projekte-800.webp differ diff --git a/public/relaunch/portal/desktop-2-fortschritt-1200.webp b/public/relaunch/portal/desktop-2-fortschritt-1200.webp new file mode 100644 index 0000000..90f5190 Binary files /dev/null and b/public/relaunch/portal/desktop-2-fortschritt-1200.webp differ diff --git a/public/relaunch/portal/desktop-2-fortschritt-1600.webp b/public/relaunch/portal/desktop-2-fortschritt-1600.webp new file mode 100644 index 0000000..78b7080 Binary files /dev/null and b/public/relaunch/portal/desktop-2-fortschritt-1600.webp differ diff --git a/public/relaunch/portal/desktop-2-fortschritt-800.webp b/public/relaunch/portal/desktop-2-fortschritt-800.webp new file mode 100644 index 0000000..6991817 Binary files /dev/null and b/public/relaunch/portal/desktop-2-fortschritt-800.webp differ diff --git a/public/relaunch/portal/desktop-3b-chat-foto-1200.webp b/public/relaunch/portal/desktop-3b-chat-foto-1200.webp new file mode 100644 index 0000000..960ca78 Binary files /dev/null and b/public/relaunch/portal/desktop-3b-chat-foto-1200.webp differ diff --git a/public/relaunch/portal/desktop-3b-chat-foto-1600.webp b/public/relaunch/portal/desktop-3b-chat-foto-1600.webp new file mode 100644 index 0000000..aba4481 Binary files /dev/null and b/public/relaunch/portal/desktop-3b-chat-foto-1600.webp differ diff --git a/public/relaunch/portal/desktop-3b-chat-foto-800.webp b/public/relaunch/portal/desktop-3b-chat-foto-800.webp new file mode 100644 index 0000000..1d5f90a Binary files /dev/null and b/public/relaunch/portal/desktop-3b-chat-foto-800.webp differ diff --git a/public/relaunch/portal/desktop-4-rechnungen-1200.webp b/public/relaunch/portal/desktop-4-rechnungen-1200.webp new file mode 100644 index 0000000..e60ad9a Binary files /dev/null and b/public/relaunch/portal/desktop-4-rechnungen-1200.webp differ diff --git a/public/relaunch/portal/desktop-4-rechnungen-1600.webp b/public/relaunch/portal/desktop-4-rechnungen-1600.webp new file mode 100644 index 0000000..1c3c550 Binary files /dev/null and b/public/relaunch/portal/desktop-4-rechnungen-1600.webp differ diff --git a/public/relaunch/portal/desktop-4-rechnungen-800.webp b/public/relaunch/portal/desktop-4-rechnungen-800.webp new file mode 100644 index 0000000..96bd432 Binary files /dev/null and b/public/relaunch/portal/desktop-4-rechnungen-800.webp differ diff --git a/public/relaunch/portal/desktop-7-news-pinnwand-1200.webp b/public/relaunch/portal/desktop-7-news-pinnwand-1200.webp new file mode 100644 index 0000000..1019e51 Binary files /dev/null and b/public/relaunch/portal/desktop-7-news-pinnwand-1200.webp differ diff --git a/public/relaunch/portal/desktop-7-news-pinnwand-1600.webp b/public/relaunch/portal/desktop-7-news-pinnwand-1600.webp new file mode 100644 index 0000000..f010504 Binary files /dev/null and b/public/relaunch/portal/desktop-7-news-pinnwand-1600.webp differ diff --git a/public/relaunch/portal/desktop-7-news-pinnwand-800.webp b/public/relaunch/portal/desktop-7-news-pinnwand-800.webp new file mode 100644 index 0000000..6185508 Binary files /dev/null and b/public/relaunch/portal/desktop-7-news-pinnwand-800.webp differ diff --git a/public/relaunch/portal/phone-1-projekte-390.webp b/public/relaunch/portal/phone-1-projekte-390.webp new file mode 100644 index 0000000..2dfb43d Binary files /dev/null and b/public/relaunch/portal/phone-1-projekte-390.webp differ diff --git a/public/relaunch/portal/phone-1-projekte-780.webp b/public/relaunch/portal/phone-1-projekte-780.webp new file mode 100644 index 0000000..ac025ea Binary files /dev/null and b/public/relaunch/portal/phone-1-projekte-780.webp differ diff --git a/public/relaunch/portal/phone-2-projekte-lang-390.webp b/public/relaunch/portal/phone-2-projekte-lang-390.webp new file mode 100644 index 0000000..592f2f2 Binary files /dev/null and b/public/relaunch/portal/phone-2-projekte-lang-390.webp differ diff --git a/public/relaunch/portal/phone-2-projekte-lang-780.webp b/public/relaunch/portal/phone-2-projekte-lang-780.webp new file mode 100644 index 0000000..740d2f6 Binary files /dev/null and b/public/relaunch/portal/phone-2-projekte-lang-780.webp differ diff --git a/public/relaunch/portal/phone-3-projektdetails-390.webp b/public/relaunch/portal/phone-3-projektdetails-390.webp new file mode 100644 index 0000000..4e2c57a Binary files /dev/null and b/public/relaunch/portal/phone-3-projektdetails-390.webp differ diff --git a/public/relaunch/portal/phone-3-projektdetails-780.webp b/public/relaunch/portal/phone-3-projektdetails-780.webp new file mode 100644 index 0000000..a8c4109 Binary files /dev/null and b/public/relaunch/portal/phone-3-projektdetails-780.webp differ diff --git a/public/relaunch/portal/phone-4b-chat-foto-390.webp b/public/relaunch/portal/phone-4b-chat-foto-390.webp new file mode 100644 index 0000000..2017e8c Binary files /dev/null and b/public/relaunch/portal/phone-4b-chat-foto-390.webp differ diff --git a/public/relaunch/portal/phone-4b-chat-foto-780.webp b/public/relaunch/portal/phone-4b-chat-foto-780.webp new file mode 100644 index 0000000..665ff2d Binary files /dev/null and b/public/relaunch/portal/phone-4b-chat-foto-780.webp differ diff --git a/public/relaunch/portal/phone-5-rechnungen-390.webp b/public/relaunch/portal/phone-5-rechnungen-390.webp new file mode 100644 index 0000000..1b35710 Binary files /dev/null and b/public/relaunch/portal/phone-5-rechnungen-390.webp differ diff --git a/public/relaunch/portal/phone-5-rechnungen-780.webp b/public/relaunch/portal/phone-5-rechnungen-780.webp new file mode 100644 index 0000000..4f13c4d Binary files /dev/null and b/public/relaunch/portal/phone-5-rechnungen-780.webp differ diff --git a/public/relaunch/portal/phone-8-news-vorschau-390.webp b/public/relaunch/portal/phone-8-news-vorschau-390.webp new file mode 100644 index 0000000..5e16dac Binary files /dev/null and b/public/relaunch/portal/phone-8-news-vorschau-390.webp differ diff --git a/public/relaunch/portal/phone-8-news-vorschau-780.webp b/public/relaunch/portal/phone-8-news-vorschau-780.webp new file mode 100644 index 0000000..56350d6 Binary files /dev/null and b/public/relaunch/portal/phone-8-news-vorschau-780.webp differ diff --git a/scripts/relaunch-bilder.py b/scripts/relaunch-bilder.py new file mode 100644 index 0000000..9d8c9d7 --- /dev/null +++ b/scripts/relaunch-bilder.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +""" +Relaunch: verkleinert die Rohexporte der Kundenportal-Screenshots für die Website. + + docs/relaunch/assets/portal/*.webp (Rohdateien, doppelte Auflösung) + -> public/relaunch/portal/-.webp + +Aufruf (aus dem Projektverzeichnis): python3 scripts/relaunch-bilder.py + +Nur die Bilder, die der Abschnitt "Kundenportal" (Spezifikation 7.2) wirklich zeigt, landen in public/. +Format und Budgets laut Spezifikation 9.5: + - WebP, Qualität etwa 80, Desktop-Bilder in 800 / 1200 / 1600 px Breite, Handy-Bilder in 390 / 780 px + - ein einzelnes Desktop-Bild höchstens 120 KB, ein einzelnes Handy-Bild höchstens 60 KB + (der lange Screenshot hat ein eigenes, größeres Budget, weil er mehr als dreimal so hoch ist) + - wird ein Budget überschritten, sinkt die Qualität pro Datei schrittweise (nicht unter MIN_QUALITAET) +Braucht Python 3 und Pillow mit WebP-Unterstützung. +""" +from pathlib import Path +import sys + +from PIL import Image + +WURZEL = Path(__file__).resolve().parent.parent +QUELLE = WURZEL / "docs" / "relaunch" / "assets" / "portal" +ZIEL = WURZEL / "public" / "relaunch" / "portal" + +QUALITAET = 80 +MIN_QUALITAET = 46 +SCHRITT = 4 + +KB = 1024 +BUDGET_DESKTOP = 120 * KB +BUDGET_HANDY = 60 * KB +# Der lange Screenshot ist gut dreimal so hoch wie ein Handy-Bild (5428 statt 1688 px). Sein Budget +# wächst mit der Höhe (je Bildschirmhöhe weiterhin unter 60 KB), sonst wäre der Text nur noch Matsch. +BUDGET_LANG = {390: 80 * KB, 780: 150 * KB} + +# Dateiname (ohne .webp) -> (Art, Zielbreiten) +BILDER = { + # Desktop-Fenster (Roh 2880 x 1800) + "desktop-1-projekte": ("desktop", (800, 1200, 1600)), + "desktop-2-fortschritt": ("desktop", (800, 1200, 1600)), + "desktop-3b-chat-foto": ("desktop", (800, 1200, 1600)), + "desktop-4-rechnungen": ("desktop", (800, 1200, 1600)), + "desktop-7-news-pinnwand": ("desktop", (800, 1200, 1600)), + # Handy (Roh 780 x 1688, das lange Bild 780 x 5428) + "phone-1-projekte": ("handy", (390, 780)), + "phone-3-projektdetails": ("handy", (390, 780)), + "phone-4b-chat-foto": ("handy", (390, 780)), + "phone-5-rechnungen": ("handy", (390, 780)), + "phone-8-news-vorschau": ("handy", (390, 780)), + # langer Screenshot: behält sein hohes Seitenverhältnis (eigenes Budget, siehe BUDGET_LANG) + "phone-2-projekte-lang": ("handy", (390, 780)), +} + + +def speichern(bild: Image.Image, ziel: Path, budget: int) -> tuple[int, int]: + """Speichert als WebP; senkt die Qualität, bis das Budget passt. Gibt (Bytes, Qualität) zurück.""" + qualitaet = QUALITAET + while True: + bild.save(ziel, "WEBP", quality=qualitaet, method=6) + groesse = ziel.stat().st_size + if groesse <= budget or qualitaet - SCHRITT < MIN_QUALITAET: + return groesse, qualitaet + qualitaet -= SCHRITT + + +def main() -> int: + ZIEL.mkdir(parents=True, exist_ok=True) + # alte Ausgaben entfernen, damit nichts Verwaistes liegen bleibt + for alt in ZIEL.glob("*.webp"): + alt.unlink() + + fehler = 0 + summe = 0 + print(f"{'Datei':44} {'Pixel':>11} {'KB':>7} {'Q':>3} Budget") + for name, (art, breiten) in BILDER.items(): + quelle = QUELLE / f"{name}.webp" + if not quelle.exists(): + print(f"FEHLT: {quelle}", file=sys.stderr) + fehler += 1 + continue + roh = Image.open(quelle).convert("RGB") + for breite in breiten: + if name.endswith("-lang"): + budget = BUDGET_LANG[breite] + else: + budget = BUDGET_DESKTOP if art == "desktop" else BUDGET_HANDY + if breite > roh.width: + print(f"übersprungen: {name} {breite} (Rohbild nur {roh.width} px breit)") + continue + hoehe = round(roh.height * breite / roh.width) + klein = roh if breite == roh.width else roh.resize((breite, hoehe), Image.LANCZOS) + datei = ZIEL / f"{name}-{breite}.webp" + groesse, qualitaet = speichern(klein, datei, budget) + summe += groesse + ok = "ok" if groesse <= budget else f"ÜBER {budget // KB} KB" + print(f"{datei.name:44} {breite:>5}x{hoehe:<5} {groesse / KB:7.1f} {qualitaet:>3} {ok}") + print(f"Summe aller Dateien: {summe / KB:.0f} KB") + return 1 if fehler else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/components/DeutschlandKarte.tsx b/src/components/DeutschlandKarte.tsx new file mode 100644 index 0000000..57d5369 --- /dev/null +++ b/src/components/DeutschlandKarte.tsx @@ -0,0 +1,130 @@ +import { useLayoutEffect, useRef, useState } from "react"; +import "./deutschland-karte.css"; + +/** + * Umriss von Deutschland als Inline-SVG (Spezifikation 7.1). + * Quelle der Pfaddaten: docs/relaunch/assets/deutschland-umriss.svg (Natural Earth 1:10m, Public Domain, + * Lambert-Azimutal-flächentreu, viewBox 0 0 663 892, 1 Einheit ≈ 1 km). + * + * Der Umriss besteht aus sechs geschlossenen Teilpfaden (Festland, Rügen, Usedom, Fehmarn, Sylt, Föhr/Amrum). + * Jeder ist ein eigener mit pathLength="1", damit alle beim Zeichnen gleichzeitig fertig werden + * (bei einem einzigen Pfad wären die kleinen Inseln schon nach den ersten Millisekunden da). + * Die Pfade beginnen mit absolutem M; die Befehle danach sind unverändert aus der Datei übernommen. + */ +const UMRISS = [ + "M581.4 706.2l-2 5.8 2.5 2-1 2.4 0.4 7-1.8 4.8-2.9 1.2-1.1 4.3-7-4.5-7.7-1.8-4.8 1.5-1 1.4 1.6 4.9-3.3 15.7-9.3 8.2-10.1 2.2-7.6 3.4-7.1 6.3-5.2 1.6-8.8 9.6 1.8 5.4 5.4 5.2 2.6 5.8 5.3 4 4.8 8.5-6.9 14 5.5 1.8 4.1-1 4.2 5.8 0.1 4.1-2.3 4.3-0.6 4.8 0.9 5.3-2.5 3.2-5.4-0.7-11.8-8.6 0.4-5.3 2-1.1-4.8-4 0.7-1.9-8.2-0.7-8.4 5.2-3.1 0.4-4-2.9-1.7-3.9-13.8 1.8-1.2-1.8 1.3-4.1-4.8 3.1 2.4 6.6-2.4 4.6-17.8-0.1-6.3 1.4-2.3 2.7-15-1.2-5 8.5-10.4 1.1-3.5 4.1 1.8 0.8-0.4 1.9-5.8 1.2-5.1 4.7-1.8-0.2 0.1-3-1.6-0.7-8.3 4.5-7.8 0-1.9-2.1 0.3-2.5-3.8-4-3.9-1.8-0.5-0.8 3.1-2.5-2.6-1.7-7.3 2-1.6-1.7-11.8-3.6-3.5 3.6-4.1-0.3-2.1-1.5 0.2-3.6-2.1 0.2-1.1 0.5 1.3 3.4-0.9 5.5 2.2 2.2 0.3 3.8-1.7 4.7-4.3 3.2-4.9 7.3-5 2.7-6 0.8 2.3-3 1.4-8.3-1.4-0.7-8.2 2.2-2.2-5.1 1.7-1.3-0.3-2.5-3.7-5.4-3 0.8-2.8-6.7-8.4 0.7-1.5-0.8-0.4-3.8-2-1.4-2.2 0.7-3.6 5.6-6.9 1.1-4.7-1.4-20.7-13.1-5.7-0.7-1-1.6-12.5-1.1-5.4 2.7-4.7-0.3-3.3-2.8 1.4-1.1-4.3-3.3-2-0.1-0.1 2.8-4 0-1.2-3.2 1-3.8-2.3-0.2-2.8-3.6-2 3.7-1.1-4.2-1.4-0.6-3.1 0.5 0.7 1.8-1.1 0.5-5.5 1-2 4.5-3.6 3.4 0 3 4.9 2.9 8.3-2.4 2.9 0.8-0.5 2.6-1.5-0.7-0.1 4.3-1.5 1.2-2.8-3.7-4.6 1.7-1 2.4-7.1 0.2-3.6-1.3-1.3-2.7-4.1-0.8-4.1 0.6-7 5.2-3.4 0.7-9.8-0.3-0.9-3-5.9-1-4.1 4.2-6.3 1.1-5.4-2.4 1.9 0 1.9-3.4-5.6 1.1 0.4-2.6-5.1-7.6-0.4-3.6 2.1-2.7-0.7-5.7 2.9-6.1-0.1-4.9 2.1-6.3 2.8-3.4 0-3.4-3.1-5.8 0.5-6.8 2.8-7.1 5.7-8 1.3-6.4 3.7-3.3-0.7-7.2 3.1-10.1 2.8-2.3 0.1-10.6 3.2-4.8 8-6.7 1-4 3.4-0.4 1.7-3.2 3.8-1.8 8.5-16.5-8-2.5-11.4-6.5-5.5 0.1-6-1.9-3.4 1.2-6.7-0.4-1.9-2.8-6.3-2.6-3.4-5.1 0.4-2.5-4.4-0.9-0.3-1.2-4.3 0.9-1.4 2.7-2.7 0.6-1.7 2.5-8.7-1.4-5.3-3.2-0.6 2.3-2.3 1.4-1.5-1.4-0.6-7-6.8-3-5.8-0.6 0.2 5.2-1.4 1.6-6.9-1.1-1.7-2 0.3-4.6-1.6 0.6-2.7-7.2-5.7-6.4 0-2.3 1.7-0.9-4.5-7.8-7.6-5-4.2 1 1-12.4 5.2-7.8-1-1.9 7.3-5.9-0.9-2.5 1.5-6.8-7.5-1.8-4.3-3.7-2.3 0.5-1-3-4.4-3.1-3.2-7.9-0.9 1.5-4-12 2.8-9.4-0.4-3.1 3.2-2.1-1.1-3.6 3.2-3 6-2.5 1.2-4 6.4-1-2.4-5.9 1.4-7.3-1.8-3.7 1-1.6-5.7-1.8-3.4 0.8-1-3.3-1.4-0.4-0.6-2.9 5.4-4.5 1.3-3-6.3-1.2-0.8-1.7 1-0.5-5.2-8.2-4.9 0.3 0.1-3.2-2.6-6.6 2.1-0.5 1-4.4 3.4-1.2 0.8-6-4.3-2.7 0.5-4.9-5.3-0.8-3.7 1.2-1.1-8.7 2-0.9 3.9 2.6 3.4-4.7 11.9-8-1.4-1.5 2.3-1.5-1.6-0.6-3.6 2.2-1.6-1 0-4.6 6.9-11.4 3.3-2.8 1.3-4.7-1-1.3 0.7-12.3-6.9-10 1.2-5.4-6-4.4 0.4-3.8-4.9-2.1 2-4.8-2-4.5 10.7-3.6 4.9 1.5-4.1-5 2.4-1.2 6.5 4.2 2.7-0.3 1.5 2.3 3.7 1.3 0.1 1.7 2.6-0.6-0.1-3.6 5.7 1.6 4.7-2.9 14.9-2.9 4.8-5.6 0-2.1-8.9-6.6 0.5-2.8 2.2-0.6 2.8-3.9 5.6-0.9 3.9-5.1 3-1.1 7.1-6.8-0.9-5.5 2.8-8.8-4.6-9.8-5.1 1.9-10.6-2.9-3.1-2.6-1.2-7.4 5-1.8-2.6-2.7 0-4 2.5-1.6 8.7-0.4 10.2 2.2 1.3-2.3 2.5-19.6 7.2-11.8 2.4-7.2-0.8-15.6 2-11.8 2.7-3.1-0.2-2.6 1.8-2.5 2.4-0.4 4.9 2.6-4.1-3.5-15-1.1-2.4-1.4-0.6-11.3 2.2-6.8 0.6-1 3.9 0.7 1.6-2.2-3.4-4.2 0.9-2.7 8.6-7.3 5.3-1.8 10.1-0.8 2.9 1.9 29.6-3.9 5.2 1.7-0.5 4 1.7 4 4.8 5.2-0.6 2.1 3.2 2.2-0.9 3-6.5 2.5 1.7 5.1 4.3 0.9 3.4 4.4 3.6-0.1 3.9-6.9 0.4-3.8-1.8-2.3-4 0.4 2.8-9.7 3.9-0.5 3.7 4.7 10.8 3.9-4.2 6.3-0.5 9.5 1.1 4.8-0.1-13 4-4.7 0.6-3.2-3.6-8.8-1.3-8.4 6.1-17.4 5.2-3.7 8.5 5.7 5.1 1.3 10.2-0.9 6.5-2.6 10.5 0.1 8.3 13.7 5.4 5.9 5.3 10.1 8.6 4.8 8.9 1.5-3.3-2.9-5.9-0.8-7.2-4.1-2.1-2.4-1.2-8.5-6.3-4.3-2.6-9.1-9-6.2-10.7-0.9-4.2-1.7-1.4 1.6-1.5-0.5-4.5-4.5-5.2-11.1 3.8-1.2 5.5 2.7 1.4-0.7 1.5-7.5-2.8-3.4 0.2-1.9-1.6-0.5-6.1 2.6-3-6.2 2.3-5.8-0.4-2.5 5.4-2 2.7-4.8-5.3 3 0.4-1.5-9.7 1-3.9 2.6-3.6-2.4-1.4-5.3 2.7-1.9 3.6-0.3-2.9-2.2-1.1 1.4 1-4.6 15.4-1 7.8-5.2 0.5-4.5-8.4-11.2-3.5 0.8 1.8-2.3-2.9-7.6-7.4-5.5 0.4-2.7-3.1-9.4 1.2-5.8 4.6 0.9 5.9-1.8 5.1 1 18.5 5.5 3.1 5.5 5.7-0.2 3.4-1.7 4.2 1 8.3-6.1 0.2 4 9.7 2.6 4.3 2.6 1.4 3 4.6-1.4-0.3-2.3 3.6 1.2 4.6 8.7-3.1 0-2.6 3 4.3-0.5 2.7 1.3-0.8 11.9-3.9 6-8.1 3.4 2.8 1.5 16.8-3.3 3.9 3.4-1.3 1.3 0.5 5.8-3.2 2.7 0 5.4 2.5-1.9 2.8-8.1 6.1-3.3 2.7 0.2 15.6 8.4 5.9 5.4 2.6 0.6 3-0.6 10.2-7.2 5.4-0.8-2.8 0.8 3.8 1.2 4.5-2.9 2 0.8-3 3.8-1.4-0.7 2.1 14.8-1.8 4.6-11.2 9.2-5.7 0.9-3.4 5 3.2 5.5 4.9 0.5 0.8 3.5 2.4 0.3 8.4-5.4 8-1.2 1.9 3.2 2.7 1.1-0.1 3.7 3.4 0.5 2.6-2.4 8.2 6 0.8-7 1.6-1.5-0.4-2.2 2.2-2.3-0.5-1.6 3.5-0.3 3.3-5.1-0.1-2.9 4.1-5.1 8 0.4 17.8-5.4 0.5 8.9 1.6 1.7-1.5-5.2 3-4-2.6 0.8 0.4-1 4.9-6.9 9.9-7 9.7-19.2 2-1.7 1 2.9 2.8 1 18.7-0.4 2.7 1.6-3.2 2-11.4-1.4-1.5 1.5-3-0.2-1 1.9-1.7 0.1 0.9 2.2-2.7-0.4-1.6 2.1-2.4-1.9-3.4 0.4-0.8 4.8-1.8-0.7-0.2 3.4-1.8 1.2 0.5 4.6 2.9 1.9 3-0.6-3.2-1.4 3.9-4.7 0.3-2.4 5.2-3.2-1.3-1.4 2.1-0.9 1.3 1.1 5.3-2.8 0.2-2.1 2.3-0.4-2 4.7 1.3-3.1 2 2.3 3.3-1.6 2.2 3.7 2.3-0.1 4.6-6.5 5-2.8 1.7 0.4-0.2 3.6 4.5 3.1-1.2 2.2 2.9 7.9 3.6 0.3-1.7 1.6 9.6 2.7 4.2 5.8-1.7 1.6 5.3-1-1.3 2.4 2.3-0.2 3.4 6 2.7 0.7-2-3.7 8-2 6.9-4.2-0.8 2.3 7.5 5.1-2 6.6-1.7 1.1 6.4 5.3 0.8 3.4 4 2.2-5.5 6.5 0.1 2.8 15 10 9.9 1.2 3-2.6 2 1-0.1 2.6-3 2.4 3.4 0.7 4 21.1 4.1 7 4 18.1 2.7 2.9-3.6 7.1 0.9 6.4-2.5 9.4-12.7 10.6 1.8 7.1-2.4 5.2 18 11.4 3.7 5.6 15.1 11.2-2 6.7 1.7 2.3-2.4 5.4-3.3 3.3 0.7 4.4 3.5 7.9-0.3 2 8 3.7 1.1 1.9-0.9 12.8 5.4 4.6-3.6 9.7-0.3 9-5.7 8.1-0.4 4.3 3.5 3.7 1.4 5.6 6.2 6.2 1.1 5.7-1.8 4.5 0 4.1 14.4 5.9 3.3 3.5 1.1 11.1 4.6 6.8 0.4 3.8-5.9 27.7-3 8.6-3.2 4.7-1.3 6.4-3.3 1.3-8.9-3.3 0.6-8.5-5.5 1.3 1.2-7.1-5.2-3.4-1.6-3-1.3 2-4.4 1-3.1-2-4.8-0.2-3.1 6.2 4.7 1.2-0.7 1.8 1.9 1.7 4.6 1.6-0.3 2.8-2 1.9-8 0.5-2.7 3.7-10.6 5.6-5.3 0.4-2.4 2.6-2.2 0-0.5 4.3-3.2 2.3-19.5 2.8-2.8 1.9 0.7 3.2-4.9 7.4-2.9-0.3-2.9-2.8-4.2 6-1.4 0.8-3.8-1.3-2 6.4-2.6 3.3-10.7 0.9-0.6 6.6-3 3.4-9.7-3.9-5.6 1.5-3.1 4.2-12.9 1.1-2.4 2.3-0.5 3-4.7 3.2-4.2 6.4-2.2 10.9-2-1.2-3.3-6.8 0.6-2.5-4.4-1.2-2.2-4.7-5.2-0.2 2.6 4.2-2.1 3.8 7 6.5 0.5 7.2 3.9 4 1.1 3.9 12.2 6.5 2.7 0.2 1.2 1.1-0.2 4.4 4.4 2.7-2.6 8.8-1.5-0.4-0.5 5.7-4.8 4.3 2.7 4.7 5.6 3.1 0.6 5.6 3.3 2.6 2.9 8 3 1.3 1.8 8.9 6.3 4 3.8 6.4 5.7 0.9 2-2 8.5 4.6 1 3 12.8 16.1 8.9 2.4 5.6 5.8 2.2 6.7 4.6 3.4 1.7 0.8 2.3-3.8 6.9 2.4 2.4 5.7 6.5 2.6z", + "M577.2 130.4l-2 3.6 1.8 3.1-7.4 1.3-2.6-0.9-3.2 3.1-3.7 1.2-6.5-0.3-1.3-1.6 7-4.2 0.3-3.2-2.7-4.5 0-2.8 4.5 0.4-0.6 5.4 1.7-2.4 2.7-0.1-0.8 1.6 2.2 0.6-0.2-7.1-1-1.8-1.5 0.7-2.3-4.4-2.4-1-1.9 1.2 0.7 2.2-2.1 3.1-2.2 1 1.1-3.9-0.9-1.5-4.3 3.8-1.4-0.4 2.2-8.9-4.3-5.5 0.5-1.3 2.2-1.7 1.2 0.5 4.8 7.6 9 4.3z", + "M382.9 77.6l3.9 7.2-8.1-1.3-1.2 0.7 0.6 0.7-4.3-0.1-1-4-4.2 0.5-0.5-1.2-1.1 0.4 1.5-6 2.5-2.5 3.1-0.6 5.6 2.3z", + "M541.2 81.8l4.6 5-2.4 7.6-2.1-0.5 1.3-2.3-3.9 0.9 3.7-4-6.1 1.8 4.7-3.2-1.8-0.9-11.7 2.3-7.6 7.3-0.1 3 3.9-2.1-0.5 4.2-6.3-2.2 1.3-3.7-4.2 3.2-3.8-2-0.5-2.3-3.5 0.9 1.7-2.3-4.2-2.9 2-4.5 7.6-1.3-3.8-4.8-3.5 0.6 7.2-6.5-2.6-1.1-0.2-2.5-3 1-2.9-3.7 7.5-1.6 2.6 3.7 0-3.4 4.3-4.1-1 6.9 2.2-1-0.6-3.6 3.8 7.8 5 0.2 0.7-3.1-1.6-3.7 1.3-2.4-5 1.8-5-5.9-7.2 5.6 2.2-8.7-4.4 1 1-2.8 11.2-3.7 1.8 1.5-3.8 4.4 2.9 5.5 13.9-0.7 2.6 4.9-5.6 6.3-0.2 3.5 2.2 4.5z", + "M205.4 46.4l3.5 0.3 2.6 3-1.9 4.5-5.2-0.2-5.7-3.1 2.7-3.7z", + "M197.1 24.9l-0.5 4 2.1 2.1 3.1 0.8 13.9-0.4-1.6 1.2-8-0.6-3.8 2.2-4.4-0.1-2.1-1.5-2.4 1.5-1.7 13.6 1.3-18.9 5.1-13.5 2.8-3.3 3 1.7-4.4 0.7 1.7 0.2 0.5 1.3-4.4 4.8z", +]; + +export const KARTE_BREITE = 663; +export const KARTE_HOEHE = 892; + +/** Grad -> SVG-Koordinaten in der viewBox 0 0 663 892 (Formel aus docs/relaunch/README.md). */ +const R = 6371; +const LON0 = (10 * Math.PI) / 180; +const LAT0 = (52 * Math.PI) / 180; +// eslint-disable-next-line react-refresh/only-export-components -- Hilfsfunktion und Daten gehören zur Karte (Spezifikation 9.3) +export function project(lon: number, lat: number): [number, number] { + const l = (lon * Math.PI) / 180; + const p = (lat * Math.PI) / 180; + const k = Math.sqrt(2 / (1 + Math.sin(LAT0) * Math.sin(p) + Math.cos(LAT0) * Math.cos(p) * Math.cos(l - LON0))); + const x = R * k * Math.cos(p) * Math.sin(l - LON0); + const y = R * k * (Math.cos(LAT0) * Math.sin(p) - Math.sin(LAT0) * Math.cos(p) * Math.cos(l - LON0)); + return [x + 289.794 + 12, 341.92 - y + 12]; +} + +export type KartenPunkt = { + /** Position in der viewBox (siehe project) */ + cx: number; + cy: number; + /** server = roter Punkt mit Ring, buero = hohler Punkt */ + art: "server" | "buero"; + label: string; + sublabel?: string; +}; + +/** Serverstandort Hetzner Falkenstein/Vogtland (50,477 N, 12,371 O), nur Website und Kundenportal. */ +// eslint-disable-next-line react-refresh/only-export-components -- siehe project() +export const FALKENSTEIN: KartenPunkt = { + cx: 469.6, + cy: 520.5, + art: "server", + label: "Falkenstein, Sachsen", + sublabel: "Hier laufen Ihre Website und das Kundenportal.", +}; + +type DeutschlandKarteProps = { + punkte?: KartenPunkt[]; + className?: string; +}; + +/** + * Ablauf der Bewegung: aus = alles sofort sichtbar (reduzierte Bewegung, Grundzustand), + * wartet = versteckt, bis die Karte im Bild ist, laeuft = Umriss zeichnet sich, dann Punkt, Ring, Beschriftung. + * Die Animationen selbst stehen in deutschland-karte.css hinter prefers-reduced-motion: no-preference. + */ +type Lauf = "aus" | "wartet" | "laeuft"; + +const DeutschlandKarte = ({ punkte = [FALKENSTEIN], className }: DeutschlandKarteProps) => { + const wurzel = useRef(null); + const [lauf, setLauf] = useState("aus"); + + // Vor dem ersten Zeichnen entscheiden, damit die fertige Karte nicht kurz aufblitzt + useLayoutEffect(() => { + if (typeof window === "undefined") return; + const reduziert = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; + if (reduziert) return; + const el = wurzel.current; + if (!el || typeof IntersectionObserver === "undefined") { + setLauf("laeuft"); + return; + } + setLauf("wartet"); + const beobachter = new IntersectionObserver( + ([eintrag]) => { + if (eintrag?.isIntersecting) { + setLauf("laeuft"); + beobachter.disconnect(); + } + }, + { threshold: 0.3 }, + ); + beobachter.observe(el); + return () => beobachter.disconnect(); + }, []); + + return ( +
+ {/* Dekoration: die Aussage steht als Text daneben */} + + {punkte.map((p) => ( +
+

{p.label}

+ {p.sublabel &&

{p.sublabel}

} +
+ ))} +
+ ); +}; + +export default DeutschlandKarte; diff --git a/src/components/GeraeteRahmen.tsx b/src/components/GeraeteRahmen.tsx new file mode 100644 index 0000000..19bcdb3 --- /dev/null +++ b/src/components/GeraeteRahmen.tsx @@ -0,0 +1,83 @@ +import type { ReactNode } from "react"; +import "./geraete-rahmen.css"; + +/** Ein Bild samt Größenangaben. Alle Felder landen 1:1 im (Spezifikation 9.5). */ +export type RahmenBildDaten = { + src: string; + srcSet: string; + sizes: string; + /** Breite und Höhe des größten Rohbilds: sie legen nur das Seitenverhältnis fest, damit nichts springt. */ + width: number; + height: number; + alt: string; + /** Standard: lazy. Nur Bilder im ersten Bildschirm bekommen "eager". */ + loading?: "lazy" | "eager"; +}; + +type BildProps = { + bild: RahmenBildDaten; + className?: string; +}; + +/** Das eines Rahmens: immer mit Alt-Text, fester Größe, srcset und asynchroner Dekodierung. */ +export const RahmenBild = ({ bild, className }: BildProps) => ( + {bild.alt} +); + +type RahmenProps = { + /** Ein einzelnes Bild. Für überblendete Bilder stattdessen children (mehrere Ebenen) übergeben. */ + bild?: RahmenBildDaten; + children?: ReactNode; + className?: string; +}; + +type FensterProps = RahmenProps & { + /** Schlichter Adresstext in der Leiste, keine Ampelpunkte. */ + adresse?: string; +}; + +/** + * Desktop-Fenster (Spezifikation 7.2): 1 px Linie, 13 px Rundung, oben eine ruhige Leiste mit + * der Adresse, darunter das Bild im Seitenverhältnis 16:10. Die Farben kommen aus den Tokens, + * so passt der Rahmen auf hellen und dunklen Grund. + */ +export const DesktopFenster = ({ bild, children, className, adresse = "project.webklar.com" }: FensterProps) => ( +
+ {/* reine Dekoration: der Inhalt steht im Alt-Text des Bildes */} + +
{bild ? : children}
+
+); + +/** + * Handy-Rahmen: abgerundete Gerätekontur (etwa 44 px Rundung bei 390 px Breite, alles in cqw, + * damit der Rahmen mit jeder Größe mitwächst), dünner Rand, Bild im Seitenverhältnis 780 x 1688. + * Die Breite bestimmt der Aufrufer (width oder Spalte); der Rahmen füllt sie. + */ +export const HandyRahmen = ({ bild, children, className }: RahmenProps) => ( +
+
+
{bild ? : children}
+
+
+); + +type GeraeteRahmenProps = (FensterProps & { art: "desktop" }) | (RahmenProps & { art: "handy" }); + +/** Ein Einstieg für beide Rahmen: oder art="handy". */ +const GeraeteRahmen = (props: GeraeteRahmenProps) => + props.art === "handy" ? : ; + +export default GeraeteRahmen; diff --git a/src/components/deutschland-karte.css b/src/components/deutschland-karte.css new file mode 100644 index 0000000..63a8904 --- /dev/null +++ b/src/components/deutschland-karte.css @@ -0,0 +1,169 @@ +/* Deutschland-Karte (Spezifikation 7.1): ruhiger Umriss, roter Punkt auf Falkenstein */ + +.wk-karte { + position: relative; + width: 100%; + aspect-ratio: 663 / 892; + /* Strichstärke in viewBox-Einheiten, damit die Linie bei jeder Kartengröße etwa 1,25 px stark ist + (Desktop: Karte ca. 0,75 px je Einheit, Handy ca. 0,43 px je Einheit) */ + --karte-strich: 1.7; + --karte-punkt: 7px; + /* Abstand zwischen Beschriftung und Punkt: Die tschechische Grenze (Ascher Zipfel) liegt knapp links unter dem Punkt */ + --karte-abstand: 22px; +} +@media (max-width: 899.98px) { + .wk-karte { + --karte-strich: 2.9; + --karte-punkt: 10px; + --karte-abstand: 16px; + } +} + +.wk-karte__svg { + display: block; + width: 100%; + height: 100%; + overflow: visible; +} + +.wk-karte__umriss path { + fill: none; + stroke: var(--line-strong); + stroke-width: var(--karte-strich); + stroke-linejoin: round; + stroke-linecap: round; +} + +.wk-karte__kern { + fill: var(--accent); + r: var(--karte-punkt); + transform-box: fill-box; + transform-origin: center; +} +/* Büro (optional, O8): hohler Punkt */ +.wk-karte__punkt--buero .wk-karte__kern { + fill: var(--bg); + stroke: var(--accent); + stroke-width: 2.4; +} +/* Der Ring ist im Grundzustand unsichtbar: ohne Bewegung gibt es ihn nicht */ +.wk-karte__ring { + fill: none; + stroke: var(--accent); + stroke-width: 1.2; + opacity: 0; + r: var(--karte-punkt); + transform-box: fill-box; + transform-origin: center; +} + +/* Beschriftung als echter Text, links neben dem Punkt: dort liegt die leere Fläche innerhalb von + Deutschland. Rechts und darunter verläuft die tschechische Grenze dicht am Punkt. */ +.wk-karte__label { + position: absolute; + width: max-content; + max-width: min(58%, 18rem); + text-align: right; + /* erste Zeile mittig auf Höhe des Punktes, rechter Rand links vom Punkt */ + transform: translate(calc(-100% - var(--karte-abstand)), -0.72em); + pointer-events: none; +} +.wk-karte__titel { + font-size: 1rem; + line-height: 1.25; + font-weight: 600; + color: var(--fg); + letter-spacing: -0.005em; + text-wrap: balance; +} +.wk-karte__zusatz { + margin-top: 0.3rem; + font-size: var(--fs-small); + line-height: 1.4; + color: var(--fg-muted); + text-wrap: balance; +} +/* Handy: Die Karte ist klein, links vom Punkt wäre bis zur Westgrenze zu wenig Platz. Dort steht die + Beschriftung oberhalb des Punktes, über der leeren Fläche von Thüringen und Sachsen. */ +@media (max-width: 899.98px) { + .wk-karte__label { + transform: translate(calc(-100% + 6px), calc(-100% - 14px)); + } +} +@media (min-width: 900px) { + .wk-karte__titel { + font-size: 1.1875rem; + } + .wk-karte__zusatz { + font-size: 0.9375rem; + max-width: 24ch; + margin-left: auto; + } +} + +/* ── Bewegung (nur ohne reduzierte Bewegung) ──────────────────────────────── + Ablauf: Umriss zeichnet sich in 1,6 s, danach erscheinen Punkt und Beschriftung, dann pulsiert + ein Ring dreimal (2,4 s je Durchlauf) und bleibt dann weg. Ausnahmen laut Spezifikation 8.4. + Der Umriss wird über stroke-dashoffset gezeichnet (pathLength="1"). Das Muster „1 2“ mit Startversatz + 1,06 lässt auch bei runden Linienenden nichts übrig, das vor dem Start als Pünktchen sichtbar wäre. */ +@media (prefers-reduced-motion: no-preference) { + .wk-karte[data-lauf="wartet"] .wk-karte__umriss path, + .wk-karte[data-lauf="laeuft"] .wk-karte__umriss path { + stroke-dasharray: 1 2; + stroke-dashoffset: 1.06; + } + .wk-karte[data-lauf="wartet"] .wk-karte__kern, + .wk-karte[data-lauf="wartet"] .wk-karte__label { + opacity: 0; + } + + .wk-karte[data-lauf="laeuft"] .wk-karte__umriss path { + animation: wk-karte-zeichnen 1600ms var(--ease) 150ms forwards; + } + .wk-karte[data-lauf="laeuft"] .wk-karte__kern { + animation: wk-karte-punkt 500ms var(--ease) 1750ms backwards; + } + .wk-karte[data-lauf="laeuft"] .wk-karte__label { + animation: wk-karte-label 600ms var(--ease) 1950ms backwards; + } + .wk-karte[data-lauf="laeuft"] .wk-karte__ring { + animation: wk-karte-ring 2400ms var(--ease) 2250ms 3 forwards; + } +} + +@keyframes wk-karte-zeichnen { + from { + stroke-dashoffset: 1.06; + } + to { + stroke-dashoffset: 0; + } +} +@keyframes wk-karte-punkt { + from { + opacity: 0; + transform: scale(0.3); + } + to { + opacity: 1; + transform: none; + } +} +@keyframes wk-karte-label { + from { + opacity: 0; + } + to { + opacity: 1; + } +} +@keyframes wk-karte-ring { + from { + opacity: 0.55; + transform: scale(1); + } + to { + opacity: 0; + transform: scale(2.6); + } +} diff --git a/src/components/geraete-rahmen.css b/src/components/geraete-rahmen.css new file mode 100644 index 0000000..971bd90 --- /dev/null +++ b/src/components/geraete-rahmen.css @@ -0,0 +1,82 @@ +/* + * Geräte-Rahmen (Spezifikation 7.2, "Rahmen und Bildinhalt"): ein Rahmenstil für alle Bilder. + * Er hängt nur an den Tokens und trägt so auf hellem wie dunklem Grund. Das Portal selbst ist + * dunkel und violett; das ist Bildinhalt, nicht Seitenfarbe. + */ + +.wk-fenster, +.wk-handy__rahmen { + /* sehr weicher Fallschatten statt Leuchtrand; im Dunkeln trägt die Linie den Umriss */ + --rahmen-schatten: 0 1px 2px rgb(0 0 0 / 0.05), 0 32px 64px -32px rgb(0 0 0 / 0.32); +} +.dark .wk-fenster, +.dark .wk-handy__rahmen { + --rahmen-schatten: 0 32px 64px -32px rgb(0 0 0 / 0.7); +} + +/* ── Desktop-Fenster ──────────────────────────────────────────────────── */ + +.wk-fenster { + border: 1px solid var(--line); + border-radius: 13px; + background: var(--bg); + box-shadow: var(--rahmen-schatten); + /* clip statt hidden: Vorfahren von sticky-Elementen dürfen nicht hidden sein (8.4) */ + overflow: clip; +} + +.wk-fenster__leiste { + display: flex; + align-items: center; + justify-content: center; + height: 38px; + padding-inline: 14px; + border-bottom: 1px solid var(--line); + background: var(--bg); + color: var(--fg-muted); + font-size: var(--fs-small); + line-height: 1; + letter-spacing: 0.005em; + white-space: nowrap; +} + +.wk-fenster__bild { + position: relative; + aspect-ratio: 16 / 10; + /* Platzhalter, bis das Bild geladen ist: dunkel wie das Portal */ + background: #0c0b12; +} +.wk-fenster__bild img { + display: block; + width: 100%; + height: 100%; +} + +/* ── Handy-Rahmen ─────────────────────────────────────────────────────── */ + +.wk-handy { + width: 100%; + /* Maßeinheit cqw = 1 % der Rahmenbreite */ + container-type: inline-size; +} + +.wk-handy__rahmen { + padding: 1.7cqw; + border: 1px solid var(--line-strong); + border-radius: 11.4cqw; + background: #0b0b0d; + box-shadow: var(--rahmen-schatten); +} + +.wk-handy__schirm { + position: relative; + aspect-ratio: 780 / 1688; + border-radius: 9.7cqw; + background: #0c0b12; + overflow: clip; +} +.wk-handy__schirm img { + display: block; + width: 100%; + height: auto; +} diff --git a/src/components/home/HeroKarte.tsx b/src/components/home/HeroKarte.tsx index 67c980f..86fc14a 100644 --- a/src/components/home/HeroKarte.tsx +++ b/src/components/home/HeroKarte.tsx @@ -1,10 +1,137 @@ -/** Platzhalter: wird im Relaunch-Workflow gebaut (Spezifikation Abschnitt 7). */ -const HeroKarte = () => ( -
-
-

HeroKarte

-
-
-); +import { useEffect, useRef, useState, useSyncExternalStore, type MouseEvent } from "react"; +import { Link } from "react-router-dom"; +import { Pause } from "lucide-react"; +import DeutschlandKarte from "@/components/DeutschlandKarte"; +import "./hero.css"; + +/** Wechselnder Teil der Überschrift (Spezifikation 7.1). Es wechselt der ganze Teil nach „Wir bauen“ (Ihre/Ihren). */ +const VARIANTEN = ["Ihre Website.", "Ihren Online-Shop.", "Ihre Web-App.", "Ihre Automatisierung."]; +/** Ein Durchlauf: Website → Online-Shop → Web-App → Automatisierung → zurück zu Website, dann Stillstand. */ +const ABLAUF = [0, 1, 2, 3, 0]; +/** Alle 2,4 s ein Wechsel (die Überblendung selbst dauert 400 ms, siehe hero.css) */ +const TAKT_MS = 2400; + +const FESTER_SATZ = "Wir bauen Websites, Online-Shops, Web-Apps und Automatisierungen."; +const REDUZIERT = "(prefers-reduced-motion: reduce)"; + +/** Reagiert auch auf eine Änderung der Systemeinstellung, solange die Seite offen ist. */ +const useReduzierteBewegung = () => + useSyncExternalStore( + (melde) => { + const abfrage = window.matchMedia?.(REDUZIERT); + abfrage?.addEventListener?.("change", melde); + return () => abfrage?.removeEventListener?.("change", melde); + }, + () => window.matchMedia?.(REDUZIERT).matches ?? false, + () => false, + ); + +/** Ein zweiter Klick auf denselben Anker ändert die URL nicht; dann selbst hinscrollen (wie in der Kopfleiste). */ +const zumKundenportal = (e: MouseEvent) => { + if (window.location.pathname === "/" && window.location.hash === "#kundenportal") { + e.preventDefault(); + document.getElementById("kundenportal")?.scrollIntoView(); + } +}; + +const HeroKarte = () => { + const reduziert = useReduzierteBewegung(); + const [schritt, setSchritt] = useState(0); + const [knopfGedrueckt, setKnopfGedrueckt] = useState(false); + const [hover, setHover] = useState(false); + const [fokus, setFokus] = useState(false); + const [verborgen, setVerborgen] = useState( + () => typeof document !== "undefined" && document.visibilityState === "hidden", + ); + const ueberschrift = useRef(null); + + // Solange der Durchlauf nicht zu Ende ist und niemand angehalten hat, ist die Überschrift „in Bewegung“ + const inBewegung = !reduziert && !knopfGedrueckt && schritt < ABLAUF.length - 1; + // Hover oder Fokus auf der Überschrift und ein ausgeblendeter Tab halten den Wechsel nur an + const pausiert = hover || fokus || verborgen; + const aktiv = reduziert ? 0 : ABLAUF[schritt]; + + useEffect(() => { + const aendern = () => setVerborgen(document.visibilityState === "hidden"); + document.addEventListener("visibilitychange", aendern); + return () => document.removeEventListener("visibilitychange", aendern); + }, []); + + useEffect(() => { + if (!inBewegung || pausiert) return; + const timer = window.setTimeout(() => setSchritt((s) => s + 1), TAKT_MS); + return () => window.clearTimeout(timer); + }, [inBewegung, pausiert, schritt]); + + const anhalten = () => { + setKnopfGedrueckt(true); + // Der Knopf verschwindet: Fokus auf die Überschrift, damit er nicht ins Leere fällt + ueberschrift.current?.focus({ preventScroll: true }); + }; + + // Ohne Bewegung steht nur die erste Variante da; sonst liegen alle übereinander (die breiteste bestimmt die Breite) + const sichtbareVarianten = reduziert ? VARIANTEN.slice(0, 1) : VARIANTEN; + + return ( +
+
+
+

Websites, Shops und Apps für kleine Betriebe

+ +
setHover(true)} + onMouseLeave={() => setHover(false)} + onFocus={() => setFokus(true)} + onBlur={() => setFokus(false)} + > +

+ {FESTER_SATZ} + +

+ {inBewegung && ( + + )} +
+ +

+ In unserem Kundenportal sehen Sie jederzeit, wie weit Ihr Projekt ist – mit Live-Vorschau Ihrer Website. +

+ +
+ + Projekt besprechen + + + Kundenportal ansehen + +
+
+ +
+ +
+
+
+ ); +}; export default HeroKarte; diff --git a/src/components/home/Kundenportal.tsx b/src/components/home/Kundenportal.tsx index 44d1c46..7f49254 100644 --- a/src/components/home/Kundenportal.tsx +++ b/src/components/home/Kundenportal.tsx @@ -1,10 +1,404 @@ -/** Platzhalter: wird im Relaunch-Workflow gebaut (Spezifikation Abschnitt 7). */ -const Kundenportal = () => ( -
-
-

Kundenportal

+import { useEffect, useRef, useState, useSyncExternalStore } from "react"; +import WischReihe from "@/components/WischReihe"; +import { DesktopFenster, HandyRahmen, RahmenBild, type RahmenBildDaten } from "@/components/GeraeteRahmen"; +import "./kundenportal.css"; + +/** + * Abschnitt Kundenportal (Spezifikation 7.2). + * Ab 900 px: festgepinnte Bühne mit Funktionsliste links und Desktop-Fenster plus Handy-Rahmen rechts. + * Darunter (unter 900 px): keine Bühne, sondern eine Wisch-Reihe mit je einem Handy-Screenshot. + * Danach die Schrittzeile (ersetzt "So arbeiten wir"). + */ + +const EINLEITUNG = + "Sie sehen, was wir zuletzt erledigt haben, öffnen die Live-Vorschau Ihrer Website und schreiben uns im Chat – mit Fotos, Dateien oder Sprachnachricht. Ihre Rechnungen liegen ebenfalls dort, und aufs Handy installieren Sie das Portal wie eine App."; + +// ── Bilder (erzeugt von scripts/relaunch-bilder.py nach public/relaunch/portal/) ────────────────────────── + +const BILDER = "/relaunch/portal"; +const DESKTOP_BREITEN = [800, 1200, 1600]; +const HANDY_BREITEN = [390, 780]; + +const srcSet = (name: string, breiten: number[]) => + breiten.map((b) => `${BILDER}/${name}-${b}.webp ${b}w`).join(", "); + +/** Desktop-Bild: Rohformat 2880 x 1800, ausgeliefert in 800 / 1200 / 1600 px. */ +const desktopBild = (name: string, alt: string): RahmenBildDaten => ({ + src: `${BILDER}/${name}-1200.webp`, + srcSet: srcSet(name, DESKTOP_BREITEN), + sizes: "(min-width: 1200px) 692px, 50vw", + width: 1600, + height: 1000, + alt, +}); + +/** Handy-Bild: Rohformat 780 x 1688 (das lange Bild 780 x 5428), ausgeliefert in 390 / 780 px. */ +const handyBild = (name: string, alt: string, sizes: string, hoehe = 1688): RahmenBildDaten => ({ + src: `${BILDER}/${name}-780.webp`, + srcSet: srcSet(name, HANDY_BREITEN), + sizes, + width: 780, + height: hoehe, + alt, +}); + +type Funktion = { + id: string; + titel: string; + unterschrift: string; + /** Desktop-Bild; Schritt 6 hat keines, dort blendet das Fenster aus. */ + desktop?: { name: string; alt: string }; + handy: { name: string; alt: string }; + /** Der lange Screenshot, der sich im Handy-Rahmen nach oben schiebt. */ + lang?: boolean; +}; + +// Reihenfolge und Texte laut Spezifikation 7.2. Platzhalter des Portals ("kommt bald") werden nicht beworben, +// weitere Ansichten nur nach Freigabe des Inhabers (O9 = nein). +const FUNKTIONEN: Funktion[] = [ + { + id: "live-vorschau", + titel: "Live-Vorschau", + unterschrift: "Ihre Website, noch während wir daran bauen.", + desktop: { name: "desktop-1-projekte", alt: "Kundenportal: Projektübersicht mit Live-Vorschau der Website" }, + handy: { name: "phone-1-projekte", alt: "Kundenportal auf dem Handy: Projektübersicht mit Live-Vorschau der Website" }, + }, + { + id: "fortschritt", + titel: "Fortschritt", + unterschrift: "Was wir zuletzt erledigt haben – in einfachen Sätzen.", + desktop: { + name: "desktop-2-fortschritt", + alt: "Kundenportal: zuletzt erledigte Arbeiten am Auftrag, in einfachen Sätzen beschrieben", + }, + handy: { + name: "phone-3-projektdetails", + alt: "Kundenportal auf dem Handy: Projektdetails mit den letzten Änderungen", + }, + }, + { + id: "chat", + titel: "Chat", + unterschrift: "Frage, Foto oder Sprachnachricht direkt ans Team.", + desktop: { name: "desktop-3b-chat-foto", alt: "Kundenportal: Chat mit dem Team, im Verlauf ein Foto" }, + handy: { name: "phone-4b-chat-foto", alt: "Kundenportal auf dem Handy: Chat mit Foto" }, + }, + { + id: "rechnungen", + titel: "Rechnungen", + unterschrift: "Ansehen, bezahlen, herunterladen.", + desktop: { name: "desktop-4-rechnungen", alt: "Kundenportal: Rechnungen mit Betrag und Status" }, + handy: { name: "phone-5-rechnungen", alt: "Kundenportal auf dem Handy: Rechnungen zum Ansehen, Bezahlen und Herunterladen" }, + }, + { + id: "funktionen", + titel: "Funktionen", + unterschrift: "Bausteine zum Dazubuchen, zum Beispiel eine News-Pinnwand für Ihre Website.", + desktop: { + name: "desktop-7-news-pinnwand", + alt: "Kundenportal: News-Pinnwand mit Vorschau, wie die Meldung auf der Website aussieht", + }, + handy: { + name: "phone-8-news-vorschau", + alt: "Kundenportal auf dem Handy: Vorschau der News-Pinnwand für die Website", + }, + }, + { + id: "als-app", + titel: "Als App", + unterschrift: "Aufs Handy installieren, fertig.", + handy: { + name: "phone-2-projekte-lang", + alt: "Kundenportal als App auf dem Handy: lange Projektseite mit Vorschau, Status und letzten Änderungen", + }, + lang: true, + }, +]; + +const LETZTER = FUNKTIONEN.length - 1; + +const SCHRITTE = [ + { nr: 1, text: "Kennenlernen" }, + { nr: 2, text: "Angebot mit Preis" }, + { nr: 3, text: "Bauen mit Vorschau und Ihrer Freigabe" }, + { nr: 4, text: "Online – Hosting und Pflege auf Wunsch im Abo" }, +]; + +// ── Hilfsfunktionen ─────────────────────────────────────────────────────────── + +/** Medienabfrage als Wert. Ohne matchMedia (SSR) gilt `standard`. */ +const useMedia = (abfrage: string, standard = false) => + useSyncExternalStore( + (melden) => { + if (typeof window === "undefined" || !window.matchMedia) return () => {}; + const mq = window.matchMedia(abfrage); + mq.addEventListener?.("change", melden); + return () => mq.removeEventListener?.("change", melden); + }, + () => (typeof window !== "undefined" && window.matchMedia ? window.matchMedia(abfrage).matches : standard), + () => standard, + ); + +const begrenze = (wert: number, min: number, max: number) => Math.min(max, Math.max(min, wert)); + +// ── Desktop: festgepinnte Bühne ───────────────────────────────────────────── + +/** + * Die Hülle ist hoch (Scrollstrecke), darin klebt die Bühne (position: sticky). + * Hinter der Bühne liegt eine unsichtbare Spur aus Markern, eine Marke je Funktion. Der IntersectionObserver + * meldet, welche Marke gerade die Bildschirmmitte kreuzt: das ist die aktive Funktion, das Bild blendet über. + * Beim letzten Schritt wird der Scrollfortschritt als CSS-Variable --kp-q (0 bis 1) an die Bühne geschrieben, + * und das CSS schiebt den langen Screenshot per translateY nach oben (nur transform). + */ +const Buehne = () => { + const [aktiv, setAktiv] = useState(0); + // Bilder werden erst eingehängt, wenn ihr Schritt (oder der davor) erreicht ist: spart Daten beim ersten Erreichen + const [hoechster, setHoechster] = useState(0); + const huelle = useRef(null); + const buehne = useRef(null); + const marker = useRef<(HTMLDivElement | null)[]>([]); + + useEffect(() => { + if (typeof IntersectionObserver === "undefined") return; + const beobachter = new IntersectionObserver( + (eintraege) => { + for (const e of eintraege) { + if (!e.isIntersecting) continue; + const i = Number((e.target as HTMLElement).dataset.index); + setAktiv(i); + setHoechster((h) => Math.max(h, i)); + } + }, + // Messlinie in der Bildschirmmitte: die Marke, die sie kreuzt, ist die aktive + { rootMargin: "-50% 0px -50% 0px", threshold: 0 }, + ); + marker.current.forEach((m) => m && beobachter.observe(m)); + return () => beobachter.disconnect(); + }, []); + + // Scrollfortschritt des letzten Schritts (0 bis 1) für den langen Screenshot + useEffect(() => { + const el = buehne.current; + const huellenEl = huelle.current; + const letzterMarker = marker.current[LETZTER]; + if (!el || !huellenEl || !letzterMarker || !window.matchMedia) return; + const ruhig = window.matchMedia("(prefers-reduced-motion: no-preference)"); + let raf = 0; + + const berechne = () => { + raf = 0; + if (!ruhig.matches) { + el.style.setProperty("--kp-q", "0"); + return; + } + const vh = window.innerHeight; + const huelleBox = huellenEl.getBoundingClientRect(); + if (huelleBox.bottom < -vh || huelleBox.top > 2 * vh) return; + const oben = parseFloat(getComputedStyle(el).top) || 0; + const gescrollt = oben - huelleBox.top; // Strecke seit dem Anheften + const strecke = huelleBox.height - el.offsetHeight; // gesamte Pin-Strecke + const seitSchritt6 = vh / 2 - letzterMarker.getBoundingClientRect().top; + const laenge = strecke - (gescrollt - seitSchritt6); // Länge des letzten Schritts + const vorlauf = 0.12 * vh; // erst bewegt sich der Rahmen, dann der Inhalt + const halten = 0.2 * vh; // am Ende bleibt der lange Screenshot kurz stehen + const q = begrenze((seitSchritt6 - vorlauf) / Math.max(1, laenge - vorlauf - halten), 0, 1); + el.style.setProperty("--kp-q", q.toFixed(4)); + }; + const planen = () => { + if (!raf) raf = requestAnimationFrame(berechne); + }; + + window.addEventListener("scroll", planen, { passive: true }); + window.addEventListener("resize", planen); + ruhig.addEventListener?.("change", planen); + berechne(); + return () => { + window.removeEventListener("scroll", planen); + window.removeEventListener("resize", planen); + ruhig.removeEventListener?.("change", planen); + if (raf) cancelAnimationFrame(raf); + }; + }, []); + + /** Klick in der Liste: zur Marke dieser Funktion scrollen (weich oder sofort entscheidet das CSS der Seite). */ + const springe = (i: number) => { + const huellenEl = huelle.current; + const buehnenEl = buehne.current; + const m = marker.current[i]; + if (!huellenEl || !buehnenEl || !m) return; + const oben = parseFloat(getComputedStyle(buehnenEl).top) || 0; + const huelleOben = huellenEl.getBoundingClientRect().top; + // Marke knapp über die Messlinie legen, mindestens aber so weit scrollen, dass die Bühne angeheftet ist + const zurMarke = huelleOben + m.offsetTop - (window.innerHeight / 2 - 1); + const zurBuehne = huelleOben - oben; + window.scrollTo({ top: window.scrollY + Math.max(zurMarke, zurBuehne) }); + }; + + const app = aktiv === LETZTER; + const eingehaengt = (i: number) => i <= hoechster + 1; + + return ( +
+ -
+ ); +}; + +// ── Handy: Wisch-Reihe ─────────────────────────────────────────────────────── + +/** Karte "Als App": der lange Screenshot schiebt sich langsam nach oben, solange die Karte sichtbar ist. */ +const LangeKarte = ({ f }: { f: Funktion }) => { + const ref = useRef(null); + const [sichtbar, setSichtbar] = useState(false); + + useEffect(() => { + const el = ref.current; + if (!el || typeof IntersectionObserver === "undefined") return; + const beobachter = new IntersectionObserver(([e]) => setSichtbar(e.isIntersecting), { threshold: 0.6 }); + beobachter.observe(el); + return () => beobachter.disconnect(); + }, []); + + return ( +
+ +
+ +
+
+

{f.titel}

+

{f.unterschrift}

+
+ ); +}; + +const HandyReihe = () => ( +
+ + {FUNKTIONEN.map((f) => + f.lang ? ( + + ) : ( +
+ +

{f.titel}

+

{f.unterschrift}

+
+ ), + )} +
+
); +// ── Schrittzeile ────────────────────────────────────────────────────────────── + +const Schrittzeile = ({ desktop }: { desktop: boolean }) => + desktop ? ( +
+
    + {SCHRITTE.map((s) => ( +
  1. + {s.nr} + {s.text} +
  2. + ))} +
+
+ ) : ( +
+ + {SCHRITTE.map((s) => ( +
+ {s.nr} + {s.text} +
+ ))} +
+
+ ); + +// ── Abschnitt ───────────────────────────────────────────────────────────────── + +const Kundenportal = () => { + const desktop = useMedia("(min-width: 900px)"); + + return ( +
+
+

+ {/* zwei Sätze, zwei Zeilen: der Umbruch soll nach "Projekt." fallen, nicht mitten im zweiten Satz */} + Ihr Projekt. Live im Kundenportal. +

+

{EINLEITUNG}

+
+ + {desktop ? : } + +
+ ); +}; + export default Kundenportal; diff --git a/src/components/home/Leistungen.tsx b/src/components/home/Leistungen.tsx index 4bc99aa..bb7fe94 100644 --- a/src/components/home/Leistungen.tsx +++ b/src/components/home/Leistungen.tsx @@ -1,10 +1,125 @@ -/** Platzhalter: wird im Relaunch-Workflow gebaut (Spezifikation Abschnitt 7). */ -const Leistungen = () => ( -
-
-

Leistungen

+import { Fragment, useSyncExternalStore, type CSSProperties } from "react"; +import WischReihe from "@/components/WischReihe"; +import "./leistungen.css"; + +/** Spezifikation 7.3 (Standardtext; die Variante für O2 = ja ist noch nicht freigegeben). */ +const EINLEITUNG = "Die meisten Betriebe starten mit einer Website. Wenn mehr nötig ist, bauen wir mehr."; + +const LEISTUNGEN = [ + { + titel: "Website", + text: "Startseite, Unterseiten, Impressum und Datenschutz – gestaltet, getextet und online.", + }, + { + titel: "Online-Shop", + text: "Warenkorb, Kasse, Zahlung und Versand. Auf Wunsch angebunden an Ihre Warenwirtschaft.", + }, + { + titel: "Web-App", + text: "Eigene Software für Ihren Betrieb mit Login, Verwaltung und Auswertungen, im Browser und auf dem Handy.", + }, + { + titel: "Automatisierung", + text: "Was sich ständig wiederholt, erledigt der Rechner: Bestätigungen, Rechnungen, Dokumente auslesen, ein KI-Assistent mit Ihrem Firmenwissen.", + }, +] as const; + +/** Breakpoint für die Desktop-Darstellung (Spezifikation 9.4). */ +const DESKTOP = "(min-width: 900px)"; + +/** Nur eine Darstellung im DOM: Liste ab 900 px, Wisch-Reihe darunter. Ohne matchMedia gilt die Handy-Ansicht. */ +const useDesktop = (): boolean => + useSyncExternalStore( + (melden) => { + if (typeof window.matchMedia !== "function") return () => {}; + const abfrage = window.matchMedia(DESKTOP); + abfrage.addEventListener("change", melden); + return () => abfrage.removeEventListener("change", melden); + }, + () => typeof window.matchMedia === "function" && window.matchMedia(DESKTOP).matches, + () => false, + ); + +/** „01“, „02“ … in Tabellenziffern. */ +const nummer = (index: number) => String(index + 1).padStart(2, "0"); + +/** + * Einleitung, die sich beim Scrollen Wort für Wort aufhellt (Spezifikation 7.3, 8.4). + * Für Screenreader steht der ganze Satz einmal unsichtbar im Text, die Wort-Spans sind aria-hidden. + * Ohne `animation-timeline` oder mit reduzierter Bewegung (siehe leistungen.css) steht der Satz einfach da. + */ +const Einleitung = () => { + const woerter = EINLEITUNG.split(" "); + return ( +

+ {EINLEITUNG} + +

+ ); +}; + +const Eintrag = ({ index }: { index: number }) => { + const { titel, text } = LEISTUNGEN[index]; + return ( +
+ +
+

{titel}

+

{text}

+
-
-); + ); +}; + +/** Spezifikation 7.3: vier nummerierte Textzeilen statt Karten mit Symbolen. */ +const Leistungen = () => { + const desktop = useDesktop(); + + return ( +
+
+
+

+ Was wir für Sie bauen. +

+ +
+ + {desktop ? ( +
    + {LEISTUNGEN.map((l, i) => ( +
  1. + +
  2. + ))} +
+ ) : ( +
+ + {LEISTUNGEN.map((l, i) => ( + + ))} + +
+ )} + +

+ Sie haben schon eine Website? Wir ziehen sie um – mit Domain, Inhalten und Postfächern. +

+
+
+ ); +}; export default Leistungen; diff --git a/src/components/home/Preise.tsx b/src/components/home/Preise.tsx index 07c3615..cd11a14 100644 --- a/src/components/home/Preise.tsx +++ b/src/components/home/Preise.tsx @@ -1,10 +1,52 @@ -/** Platzhalter: wird im Relaunch-Workflow gebaut (Spezifikation Abschnitt 7). */ -const Preise = () => ( -
-
-

Preise

-
-
-); +import { Link } from "react-router-dom"; +import { ABOS, preisText, weitereArbeitTeile } from "@/data/abos"; +import "./preise.css"; + +/** Spezifikation 7.5 (Standardtext; der Preisanker für O1 ist noch nicht freigegeben). */ +const EINLEITUNG = + "Für Ihr Projekt bekommen Sie vorher ein Angebot mit Preis. Für den Betrieb danach gibt es zwei Abos. " + + "Alle Preise sind Endpreise; als Kleinunternehmer (§\u00a019\u00a0UStG) berechnen wir keine Umsatzsteuer."; + +/** Spezifikation 7.5: zwei Abos nebeneinander (Handy untereinander). Zahlen kommen aus src/data/abos.ts. */ +const Preise = () => { + const [vorher, schritte, nachher] = weitereArbeitTeile(); + + return ( +
+
+

+ Was es kostet. Vorher. +

+

{EINLEITUNG}

+ +
+ {ABOS.map((abo) => ( +
+

+ {abo.name} +

+

+ {preisText(abo.preis)}{" "} + im Monat +

+

{abo.beschreibung}

+

+ + {abo.link.text} + +

+
+ ))} +
+ +

+ {vorher} + {schritte} + {nachher} +

+
+
+ ); +}; export default Preise; diff --git a/src/components/home/hero.css b/src/components/home/hero.css new file mode 100644 index 0000000..1e7a395 --- /dev/null +++ b/src/components/home/hero.css @@ -0,0 +1,174 @@ +/* Hero mit Deutschland-Karte (Spezifikation 7.1): ruhige Fläche, große Aussage links, Karte rechts */ + +.wk-hero { + background: var(--bg); + padding-block: clamp(28px, 5svh, 56px) clamp(40px, 7svh, 72px); + /* Sicherheitsnetz: eine unerwartet breite Ersatzschrift darf keine seitliche Rollleiste erzeugen */ + overflow-x: clip; +} + +.wk-hero__raster { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: clamp(32px, 6svh, 48px); +} + +/* ── Text ──────────────────────────────────────────────────────────────── */ + +/* Die Spalte ist Bezugsgröße (cqi) für die Überschrift: die längste Variante passt immer hinein */ +.wk-hero__text { + container-type: inline-size; + min-width: 0; +} + +.wk-hero__zeile { + font-size: 1rem; + margin-bottom: 1.25rem; +} + +/* Schriftgröße: so groß wie --fs-hero, aber nie breiter als die Spalte. 10,1 = Breite von + „Ihre Automatisierung.“ in Schriftgrößen (Instrument Sans 600: 9,7; dazu etwas Reserve für die Systemschrift). + Ohne Container-Einheiten (ältere Browser) gilt eine Näherung über die Fensterbreite. */ +.wk-hero__kopf { + position: relative; + width: fit-content; + max-width: 100%; + font-size: min(var(--fs-hero), 8.8vw, 3.4rem); +} +@supports (width: 1cqi) { + .wk-hero__kopf { + font-size: min(var(--fs-hero), calc(100cqi / 10.1)); + } + /* Systemschrift (Variante A im Entwurf) ist breiter als Instrument Sans: 11 statt 10,1 */ + html[data-schrift="a"] .wk-hero__kopf { + font-size: min(var(--fs-hero), calc(100cqi / 11)); + } +} +.wk-hero .wk-hero__h1 { + font-size: 1em; + line-height: 1.06; + letter-spacing: -0.025em; +} +.wk-hero__h1:focus { + outline: none; +} + +.wk-hero__sichtbar { + display: block; +} +/* Betonung ohne Farbspiel: „Wir bauen“ leise (400, gedämpft), der wechselnde Teil kräftig (600, volle Textfarbe) */ +.wk-hero__fest { + display: block; + font-weight: 400; + color: var(--fg-muted); +} +/* Alle Varianten liegen in einer Gitterzelle übereinander: Breite und Höhe richten sich nach der längsten, + nichts springt */ +.wk-hero__wechsel { + display: grid; + width: max-content; + max-width: 100%; +} +.wk-hero__variante { + grid-area: 1 / 1; + font-weight: 600; + color: var(--fg); + white-space: nowrap; + opacity: 0; +} +.wk-hero__variante[data-aktiv="true"] { + opacity: 1; +} + +/* Überblendung 400 ms, nur Deckkraft und ein kleiner Versatz; ohne Bewegung wechselt nichts */ +@media (prefers-reduced-motion: no-preference) { + .wk-hero__variante { + transform: translateY(0.12em); + transition: + opacity 400ms var(--ease), + transform 400ms var(--ease); + } + .wk-hero__variante[data-aktiv="true"] { + transform: none; + } +} + +/* Pause-Knopf: rechts neben „Wir bauen“, Tippfläche 44 × 44 px, das Symbol selbst bleibt klein. + Die Zeile ist 1,06 em hoch, der Knopf sitzt mittig darin. */ +.wk-hero__pause { + position: absolute; + top: calc((1.06em - 44px) / 2); + left: 5.3em; + display: grid; + place-items: center; + width: 44px; + height: 44px; + color: var(--fg-muted); + -webkit-tap-highlight-color: transparent; + transition: color var(--dur-state) var(--ease); +} +.wk-hero__pause-symbol { + display: grid; + place-items: center; + width: 32px; + height: 32px; + border-radius: 50%; + box-shadow: inset 0 0 0 1px var(--line-strong); +} +@media (hover: hover) and (pointer: fine) { + .wk-hero__pause:hover { + color: var(--fg); + } + .wk-hero__pause:hover .wk-hero__pause-symbol { + box-shadow: inset 0 0 0 1px var(--fg); + } +} + +.wk-hero__lead { + margin-top: 1.5rem; + max-width: 30em; + font-size: clamp(1.1875rem, 1.1rem + 0.35vw, 1.3125rem); +} + +.wk-hero__aktionen { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 2rem; +} +.wk-hero__aktionen .wk-btn { + flex: 1 1 auto; +} + +/* ── Karte ─────────────────────────────────────────────────────────────── */ + +/* Handy: unter dem Text, mittig, 40–46 svh hoch. Die Untergrenze verhindert eine winzige Karte im Querformat. */ +.wk-hero__karte { + --karte-hoehe: clamp(320px, 44svh, 520px); + width: min(100%, calc(var(--karte-hoehe) * 663 / 892)); + margin-inline: auto; +} + +/* ── Desktop ───────────────────────────────────────────────────────────── */ + +@media (min-width: 900px) { + /* Der ganze Hero passt auf einen Bildschirm (unter der 60 px hohen, mitlaufenden Kopfleiste) */ + .wk-hero { + display: flex; + align-items: center; + min-height: calc(100svh - var(--header-h) - 1px); /* 1 px = Trennlinie der Kopfleiste */ + padding-block: clamp(24px, 4svh, 48px); + } + .wk-hero__raster { + grid-template-columns: minmax(0, min(560px, 58%)) minmax(0, 1fr); + align-items: center; + column-gap: clamp(24px, 3vw, 44px); + } + .wk-hero__aktionen .wk-btn { + flex: 0 0 auto; + } + /* Karte etwa 80 svh hoch, soweit die Spalte breit genug ist */ + .wk-hero__karte { + --karte-hoehe: 80svh; + } +} diff --git a/src/components/home/kundenportal.css b/src/components/home/kundenportal.css new file mode 100644 index 0000000..32e2d96 --- /dev/null +++ b/src/components/home/kundenportal.css @@ -0,0 +1,332 @@ +/* + * Abschnitt Kundenportal (Spezifikation 7.2). + * Bewegung: nur transform und opacity, alles hinter prefers-reduced-motion: no-preference, + * der Grundzustand ist statisch und vollständig sichtbar. Hover nur mit Maus. + * Vorfahren der sticky-Bühne nutzen kein overflow: hidden (8.4). + */ + +.kp { + /* Länge eines Funktionsschritts auf der Bühne; fünf davon plus der letzte Schritt ergeben die Scrollstrecke */ + --kp-s: 45svh; + --kp-ende: 120svh; + --kp-oben: calc(var(--header-h) + 24px); + --kp-buehne-h: calc(100svh - var(--header-h) - 48px); +} +@media (prefers-reduced-motion: reduce) { + .kp { + /* nichts bewegt sich im letzten Schritt, also braucht er keine lange Strecke */ + --kp-ende: 50svh; + } +} + +.kp__kopf { + margin-bottom: clamp(40px, 5vw, 72px); +} +.kp__kopf .wk-h2 { + max-width: none; +} +.kp-satz { + display: block; +} + +/* ── Desktop-Bühne (ab 900 px) ────────────────────────────────────────── */ + +.kp-huelle { + position: relative; + height: calc(var(--kp-buehne-h) + 5 * var(--kp-s) + var(--kp-ende)); +} + +/* unsichtbare Spur aus Markern: der IntersectionObserver beobachtet sie, die Bühne klebt darüber */ +.kp-spur { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + pointer-events: none; +} +.kp-marker { + flex: 0 0 var(--kp-s); +} +/* Die erste Marke reicht bis zur Mitte des Bildschirms, wenn die Bühne anklebt: Schritt 1 ist beim Ankommen aktiv */ +.kp-marker:first-child { + flex-basis: calc(50svh - var(--kp-oben) + var(--kp-s)); +} +.kp-marker:last-child { + flex: 1 1 auto; +} + +.kp-buehne { + position: sticky; + top: var(--kp-oben); + height: var(--kp-buehne-h); + display: flex; + align-items: center; +} + +.kp-buehne__innen { + display: grid; + grid-template-columns: minmax(250px, 300px) minmax(0, 1fr); + gap: clamp(32px, 5vw, 64px); + align-items: center; +} + +/* Liste der Funktionen */ +.kp-liste { + display: flex; + flex-direction: column; + gap: 6px; +} +.kp-eintrag { + position: relative; + display: block; + width: 100%; + min-height: 44px; + padding: 10px 0; + text-align: left; + color: var(--fg-muted); + -webkit-tap-highlight-color: transparent; +} +.kp-eintrag__titel { + display: block; + font-size: 1.375rem; + font-weight: 600; + line-height: 1.2; + letter-spacing: -0.01em; + transition: color var(--dur-state) var(--ease); +} +.kp-eintrag__text { + display: block; + margin-top: 4px; + font-size: 0.9375rem; + line-height: 1.4; + text-wrap: pretty; +} +.kp-eintrag[aria-current="step"] .kp-eintrag__titel { + color: var(--fg); +} +/* kleiner roter Punkt als Marker der aktiven Funktion (hängt im Rand links vom Text) */ +.kp-eintrag__punkt { + position: absolute; + left: -20px; + top: 19px; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent); + opacity: 0; + transform: scale(0.4); +} +.kp-eintrag[aria-current="step"] .kp-eintrag__punkt { + opacity: 1; + transform: none; +} +@media (prefers-reduced-motion: no-preference) { + .kp-eintrag__punkt { + transition: + opacity var(--dur-state) var(--ease), + transform var(--dur-state) var(--ease); + } +} +@media (hover: hover) and (pointer: fine) { + .kp-eintrag:hover .kp-eintrag__titel { + color: var(--fg); + } +} +/* niedrige Fenster: die Liste wird enger, damit sie in die Bühne passt (Text bleibt mindestens 14 px) */ +@media (max-height: 800px) { + .kp-liste { + gap: 2px; + } + .kp-eintrag { + padding: 6px 0; + } + .kp-eintrag__titel { + font-size: 1.25rem; + } + .kp-eintrag__text { + margin-top: 2px; + font-size: var(--fs-small); + } + .kp-eintrag__punkt { + top: 15px; + } +} +/* sehr niedrige Fenster: nur die aktive Funktion zeigt ihre Unterschrift */ +@media (max-height: 620px) { + .kp-eintrag:not([aria-current="step"]) .kp-eintrag__text { + display: none; + } +} + +/* Geräte: Desktop-Fenster mit überlappendem Handy-Rahmen unten rechts */ +.kp-geraete { + /* Breite so, dass die Geräte nie höher werden als die Bühne (Seitenverhältnis 100 : 95) */ + width: min(100%, calc((var(--kp-buehne-h) - 8px) / 0.95)); + aspect-ratio: 100 / 95; + justify-self: end; + position: relative; + /* Bezugsgröße (cqw) für die Verschiebung des Handys im letzten Schritt */ + container-type: inline-size; +} +.kp-fenster { + position: absolute; + left: 0; + top: 0; + width: 94%; +} +.kp-handy { + position: absolute; + right: 0; + bottom: 0; + width: 30%; +} + +/* Ebenen, die per Überblendung wechseln (nur die aktive ist sichtbar) */ +.kp-ebenen { + position: absolute; + inset: 0; +} +.kp-lage { + position: absolute; + inset: 0; + opacity: 0; +} +.kp-lage.is-aktiv { + opacity: 1; +} +.kp-lage img { + display: block; + width: 100%; + height: auto; +} + +/* Schritt 6 "Als App": Fenster blendet aus, das Handy rückt in die Mitte und wird größer. + Ohne reduzierte Bewegung geschieht das weich, sonst wird einfach getauscht. */ +.kp-buehne[data-app="ja"] .kp-fenster { + opacity: 0; + pointer-events: none; +} +.kp-buehne[data-app="ja"] .kp-handy { + transform: translate(-35cqw, -16.75cqw) scale(1.5); +} + +/* Der lange Screenshot: gleiche Breite wie der Schirm, 68,9 % seiner Höhe wandern aus dem Bild + ((5428 - 1688) / 5428). Die Verschiebung hängt am Scrollfortschritt (--kp-q), kein eigener Scrollbereich. */ +.kp-lang { + position: absolute; + top: 0; + left: 0; + width: 100%; +} +.kp-lang img { + display: block; + width: 100%; + height: auto; +} + +@media (prefers-reduced-motion: no-preference) { + .kp-lage, + .kp-fenster { + transition: opacity var(--dur-enter) var(--ease); + } + .kp-handy { + transition: transform var(--dur-enter) var(--ease); + } + .kp-buehne .kp-lang { + transform: translateY(calc(var(--kp-q, 0) * -68.9%)); + } + /* die große Ebene nur im letzten Schritt auf die Grafikkarte legen */ + .kp-buehne[data-app="ja"] .kp-lang { + will-change: transform; + } +} + +/* ── Handy: Wisch-Reihe (unter 900 px) ─────────────────────────────────── */ + +.kp-karte { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} +.kp-karte__handy { + width: min(100%, 250px); + margin-bottom: 1.5rem; +} +.kp-karte__titel { + margin-bottom: 0.35rem; +} +.kp-karte__text { + max-width: 26ch; +} + +/* "Als App": der lange Screenshot schiebt sich langsam nach oben, solange die Karte zu sehen ist */ +/* (.kp-lang liegt absolut im Schirm; was unten herausragt, schneidet der Rahmen ab) */ +@media (prefers-reduced-motion: no-preference) { + .kp-karte .kp-lang { + animation: kp-hoch 18s linear both paused; + } + .kp-karte[data-sichtbar="ja"] .kp-lang { + animation-play-state: running; + } +} +@keyframes kp-hoch { + 0%, + 6% { + transform: translateY(0); + } + 100% { + transform: translateY(-68.9%); + } +} + +/* ── Schrittzeile ──────────────────────────────────────────────────────── */ + +.kp-schritte { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0 clamp(24px, 3vw, 40px); + margin-top: clamp(24px, 3vw, 40px); +} +.kp-schritt { + padding-top: 1.1rem; + border-top: 1px solid var(--line-strong); +} +.kp-schritt__nr { + display: block; + margin-bottom: 0.4rem; + font-size: var(--fs-small); + font-weight: 600; + line-height: 1.2; + color: var(--fg-muted); +} +.kp-schritt__text { + display: block; + font-size: 1.1875rem; + line-height: 1.3; + color: var(--fg); + text-wrap: balance; +} + +/* Handy: vier kleine Karten in einer Wisch-Reihe */ +.kp-schritte-handy { + margin-top: clamp(40px, 10vw, 64px); +} +.kp-schritte-reihe .wk-wisch__karte { + flex-basis: 62vw; + max-width: 250px; +} +.kp-schritt-karte { + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 1.5rem; + height: 100%; + min-height: 132px; + padding: 1.25rem; + border-radius: 18px; + background: var(--bg); +} +.kp-schritt-karte .kp-schritt__nr { + margin-bottom: 0; +} diff --git a/src/components/home/leistungen.css b/src/components/home/leistungen.css new file mode 100644 index 0000000..ed7f7f4 --- /dev/null +++ b/src/components/home/leistungen.css @@ -0,0 +1,127 @@ +/* Was wir bauen (Spezifikation 7.3): Überschrift links, nummerierte Textzeilen rechts, ab 900 px. + Darunter eine Wisch-Reihe. Keine Karten mit Symbolen, nur dünne Linien und viel Weißraum. */ + +.wk-leistungen__raster { + display: grid; + grid-template-columns: minmax(0, 1fr); + row-gap: 2.5rem; +} + +.wk-leistungen__kopf .wk-h2 { + max-width: 10ch; +} + +/* Einleitung: Endzustand ist voller Text; die Wörter dimmen nur, solange die Scroll-Animation läuft */ +.wk-leistungen__einleitung { + margin-top: 1.2em; + max-width: 24rem; + font-size: var(--fs-lead); + line-height: 1.38; + color: var(--fg); + text-wrap: pretty; +} + +/* ── Einträge ──────────────────────────────────────────────────────────── */ + +.wk-leistung__nr { + display: block; + font-size: var(--fs-small); + font-weight: 600; + line-height: 1.43; + color: var(--fg-muted); +} +.wk-leistung__text { + margin-top: 0.55rem; + color: var(--fg-muted); +} + +.wk-leistungen__umzug { + max-width: 34rem; + color: var(--fg-muted); +} +.wk-leistungen__umzug strong { + font-weight: 600; + color: var(--fg); +} + +/* Handy: Wisch-Reihe über die volle Breite, Karten mit dünner Linie oben */ +.wk-leistungen__wisch { + margin-inline: calc(-1 * var(--gutter)); +} +.wk-leistungen__wisch .wk-leistung { + height: 100%; + padding-top: 1.25rem; + padding-right: 0.5rem; + border-top: 1px solid var(--line-strong); +} +.wk-leistungen__wisch .wk-leistung__nr { + margin-bottom: 2rem; +} + +/* Desktop: zwei Spalten, Liste rechts */ +@media (min-width: 900px) { + .wk-leistungen__raster { + grid-template-columns: minmax(0, 5fr) minmax(0, 6fr); + column-gap: clamp(3rem, 7vw, 7rem); + row-gap: 0; + } + .wk-leistungen__kopf { + grid-column: 1; + grid-row: 1 / span 2; + } + .wk-leistungen__liste { + grid-column: 2; + grid-row: 1; + list-style: none; + margin: 0; + padding: 0; + /* die erste Linie steht auf Höhe der Überschrift */ + margin-top: 0.6rem; + } + .wk-leistungen__zeile { + border-top: 1px solid var(--line); + } + .wk-leistungen__zeile:last-child { + border-bottom: 1px solid var(--line); + } + .wk-leistungen__zeile .wk-leistung { + display: grid; + grid-template-columns: 3.5rem minmax(0, 1fr); + column-gap: 1rem; + align-items: baseline; + padding-block: 2.1rem 2.35rem; + } + .wk-leistungen__zeile .wk-leistung__text { + max-width: 32rem; + } + .wk-leistungen__umzug { + grid-column: 2; + grid-row: 2; + padding-top: 2.25rem; + padding-left: 4.5rem; + } +} + +/* ── Wort für Wort aufhellen: nur mit Scroll-Animationen und ohne reduzierte Bewegung ─────────── + Alle Wörter hängen an der Ansichts-Zeitleiste des Absatzes, jedes Wort bekommt einen etwas + späteren Abschnitt. Gedimmt 0,18 → voll; nur opacity, wie in Spezifikation 8.4 vorgesehen. */ +@media (prefers-reduced-motion: no-preference) { + @supports (animation-timeline: view()) { + .wk-leistungen__einleitung { + view-timeline: --wk-leistungen-einleitung block; + } + .wk-leistungen__wort { + animation: wk-leistungen-wort linear both; + animation-timeline: --wk-leistungen-einleitung; + animation-range: cover calc(12% + var(--i) * 24% / var(--n)) cover calc(24% + var(--i) * 24% / var(--n)); + } + } +} +@keyframes wk-leistungen-wort { + from { + opacity: 0.18; + } + to { + opacity: 1; + } +} diff --git a/src/components/home/preise.css b/src/components/home/preise.css new file mode 100644 index 0000000..2b84a25 --- /dev/null +++ b/src/components/home/preise.css @@ -0,0 +1,84 @@ +/* Preise (Spezifikation 7.5): zwei ruhige Spalten mit dünner Linie oben, große Preiszahlen in + Tabellenziffern, nichts zählt hoch. Der Abschnitt steht auf --bg-alt, die Blöcke brauchen keine Kästen. */ + +.wk-preise__raster { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 3.5rem; + margin-top: clamp(3rem, 6vw, 5.5rem); +} + +.wk-abo { + display: flex; + flex-direction: column; + padding-top: 1.5rem; + border-top: 1px solid var(--line-strong); +} + +.wk-abo__name { + color: var(--fg-muted); +} + +.wk-abo__preis { + display: flex; + flex-wrap: wrap; + align-items: baseline; + column-gap: 0.7rem; + margin-top: 1.1rem; +} +.wk-abo__betrag { + font-size: clamp(3.5rem, 2.2rem + 4.8vw, 6rem); + font-weight: 600; + line-height: 1; + letter-spacing: -0.035em; + font-variant-numeric: tabular-nums; + white-space: nowrap; + /* optischer Randausgleich: die große Ziffer steht sonst etwas eingerückt */ + margin-left: -0.04em; +} +.wk-abo__einheit { + font-size: var(--fs-lead); + line-height: 1.2; + color: var(--fg-muted); +} + +.wk-abo__text { + margin-top: 1.75rem; + max-width: 30rem; +} + +.wk-abo__aktion { + margin-top: auto; + padding-top: 1.25rem; +} +/* Tippfläche mindestens 44 px hoch */ +.wk-abo__link { + display: inline-flex; + align-items: center; + min-height: 44px; +} + +.wk-preise__weitere { + max-width: var(--measure-text); + margin-top: clamp(3rem, 5vw, 4.5rem); + padding-top: 1.5rem; + border-top: 1px solid var(--line); + font-size: var(--fs-lead); + line-height: 1.38; + text-wrap: pretty; +} + +/* „15-Minuten-Schritten“ nicht am Bindestrich umbrechen */ +.wk-preise__nicht-trennen { + white-space: nowrap; +} + +@media (min-width: 900px) { + .wk-preise__raster { + grid-template-columns: repeat(2, minmax(0, 1fr)); + column-gap: clamp(3rem, 6vw, 6rem); + } + .wk-preise__weitere { + max-width: none; + } +} diff --git a/src/data/abos.ts b/src/data/abos.ts new file mode 100644 index 0000000..93c2eee --- /dev/null +++ b/src/data/abos.ts @@ -0,0 +1,74 @@ +/** + * Eine Quelle für die Abo-Angaben auf der Startseite (Spezifikation 7.5). + * + * Die Seiten /hosting-abo und /wartungs-abo sind Rechtstexte und stehen unverändert für sich (D13). + * Dass Preis, Mindestlaufzeit, Speicher, Arbeitszeit und Stundensatz hier und dort übereinstimmen, + * prüft src/test/relaunch-leistungen-preise.test.tsx. Wer hier etwas ändert, ändert zuerst die Abo-Seite. + */ + +export type AboId = "hosting" | "wartung"; + +export type Abo = { + id: AboId; + /** Name des Abos, wie auf der Abo-Seite */ + name: string; + /** Preis in Euro pro Monat (Endpreis, § 19 UStG, keine Umsatzsteuer) */ + preis: number; + /** Mindestlaufzeit in Monaten */ + laufzeitMonate: number; + /** E-Mail-Speicher in GB: beim Hosting-Abo ein Postfach, beim Wartungs-Abo alle Postfächer zusammen */ + speicherGb: number; + /** Enthaltene Arbeitszeit in Stunden pro Monat (0 = keine) */ + stundenImMonat: number; + /** Wortlaut aus Spezifikation 7.5, Zahlen kommen aus den Feldern oben */ + beschreibung: string; + /** Link zur Abo-Seite */ + link: { text: string; to: string }; +}; + +/** Weitere Arbeit: gilt für beide Abos, steht so auf beiden Abo-Seiten. */ +export const WEITERE_ARBEIT = { + stundensatzEuro: 100, + schrittMinuten: 15, +} as const; + +const HOSTING = { preis: 20, laufzeitMonate: 12, speicherGb: 5, stundenImMonat: 0 } as const; +const WARTUNG = { preis: 50, laufzeitMonate: 12, speicherGb: 5, stundenImMonat: 1 } as const; + +export const ABOS: readonly Abo[] = [ + { + id: "hosting", + name: "Hosting-Abo", + ...HOSTING, + beschreibung: + "Für statische Websites: Hosting mit SSL-Zertifikat, täglicher Datensicherung und Überwachung der " + + `Erreichbarkeit, eine Domain und ein E-Mail-Postfach mit ${HOSTING.speicherGb}\u00a0GB. ` + + `${HOSTING.laufzeitMonate}\u00a0Monate Mindestlaufzeit.`, + link: { text: "Details zum Hosting-Abo", to: "/hosting-abo" }, + }, + { + id: "wartung", + name: "Wartungs-Abo", + ...WARTUNG, + beschreibung: + "Für alle Websites und Web-Anwendungen, auch mit Login, Shop oder Datenbank: zusätzlich " + + "Sicherheitsupdates für die Software Ihrer Website, beliebig viele Postfächer mit zusammen " + + `${WARTUNG.speicherGb}\u00a0GB und eine Stunde Arbeitszeit pro Monat. ${WARTUNG.laufzeitMonate}\u00a0Monate Mindestlaufzeit.`, + link: { text: "Details zum Wartungs-Abo", to: "/wartungs-abo" }, + }, +]; + +/** „20 €“ mit geschütztem Leerzeichen (kein Umbruch zwischen Zahl und Zeichen). */ +export const preisText = (euro: number): string => `${euro}\u00a0€`; + +/** + * Satz unter den beiden Blöcken (Spezifikation 7.5) in drei Teilen: Der mittlere Teil darf beim Umbruch nicht + * getrennt werden (sonst steht „15-“ allein am Zeilenende). Zusammengesetzt ergibt es den Wortlaut aus der Spezifikation. + */ +export const weitereArbeitTeile = (): [string, string, string] => [ + `Weitere Arbeit nur mit Ihrer Freigabe: ${WEITERE_ARBEIT.stundensatzEuro}\u00a0€ pro Stunde, abgerechnet in `, + `${WEITERE_ARBEIT.schrittMinuten}-Minuten-Schritten`, + ".", +]; + +export const weitereArbeitText = (): string => weitereArbeitTeile().join(""); diff --git a/src/pages/AGB.tsx b/src/pages/AGB.tsx index 9e10c77..a0a7754 100644 --- a/src/pages/AGB.tsx +++ b/src/pages/AGB.tsx @@ -1,297 +1,215 @@ import { Link } from "react-router-dom"; -import { Button } from "@/components/ui/button"; -import { ArrowLeft, FileText } from "lucide-react"; import { usePageMeta } from "@/hooks/use-page-meta"; - -const contractDivider = ( -
-); +import "./unterseiten.css"; const AGB = () => { usePageMeta("AGB", "Allgemeine Geschäftsbedingungen der WEBklar GbR."); return ( -
- {/* Header */} -
-
-
- - - WEBklar - +
+
+ {/* Page Header */} +
+ Vertrag +

Kaufvertrag – WEBklar

+

(Modularer Projektvertrag)

+
+

zwischen

+

WEBklar
(im Folgenden „Anbieter“)

+

und

+

Kunde laut Angebot
(im Folgenden „Kunde“)

+
+
+ + {/* Contract Content */} +
+ {/* 1. Vertragsgegenstand */} +
+

1. Vertragsgegenstand

+
    +
  1. Gegenstand dieses Vertrages ist die Erbringung der im Angebot definierten Leistungen.
  2. +
  3. Der Vertrag besteht aus diesem Grundvertrag sowie den ausgewählten Leistungsmodulen.
  4. +
  5. Maßgeblich ist das jeweils angenommene Angebot von WEBklar.
  6. +
+
+ + {/* 2. Leistungsart */} +
+

2. Leistungsart

+
    +
  1. Sämtliche Leistungen von WEBklar stellen Dienst- und Entwicklungsleistungen dar.
  2. +
  3. Ein bestimmter wirtschaftlicher, technischer oder rechtlicher Erfolg wird nicht geschuldet.
  4. +
  5. WEBklar erbringt keinen laufenden Betrieb, sofern dieser nicht explizit vereinbart wurde.
  6. +
+
+ + {/* 3. Leistungsmodul A – Webseite */} +
+

3. Leistungsmodul A – Webseite (einmalig)

+

(aktiv, wenn im Angebot enthalten)

+
    +
  1. WEBklar erstellt eine individuelle Webseite gemäß Angebot.
  2. +
  3. Die Umsetzung erfolgt nach den vom Kunden gelieferten Inhalten und Vorgaben.
  4. +
  5. Zusätzliche Leistungen wie Domain, Hosting, Wartung oder SEO sind nicht Bestandteil, sofern sie nicht gesondert beauftragt wurden.
  6. +
  7. Der Kunde ist nicht berechtigt, Änderungen am Quellcode selbst vorzunehmen.
  8. +
  9. Änderungen erfolgen ausschließlich durch WEBklar gegen gesonderte Vergütung.
  10. +
+
+ + {/* 4. Leistungsmodul B – Automatisierung / Virtualisierung */} +
+

4. Leistungsmodul B – Automatisierung / Virtualisierung (einmalig)

+

(aktiv, wenn im Angebot enthalten)

+
    +
  1. WEBklar entwickelt individuelle Automatisierungen, Apps oder Virtualisierungssysteme.
  2. +
  3. Die Leistung stellt eine reine Entwicklungsleistung dar.
  4. +
  5. Optional kann eine Beratungsleistung Bestandteil des Projektes sein.
  6. +
  7. Ein laufender Betrieb, Monitoring oder Wartung ist nicht geschuldet, außer dies wurde explizit vereinbart.
  8. +
  9. Der Kunde entscheidet über Inhalte, Daten und Prozesse und trägt dafür die rechtliche Verantwortung.
  10. +
+
+ + {/* 5. Leistungsmodul C – Hosting */} +
+

5. Leistungsmodul C – Hosting

+

(aktiv, wenn im Angebot enthalten)

+
    +
  1. + Hosting bietet WEBklar als Hosting-Abo an. Für das Hosting-Abo und das Wartungs-Abo gelten die{" "} + Vertragsbedingungen für die WEBklar-Abos + {" "}mit Leistungsbeschreibungen; sie gehen diesen AGB vor. +
  2. +
  3. Bestehende Hosting-Verträge mit jährlicher Laufzeit behalten die bei ihrem Abschluss vereinbarten Bedingungen. Abweichend davon können Verbraucher solche Verträge nach einer automatischen Verlängerung jederzeit mit einer Frist von einem Monat kündigen.
  4. +
  5. WEBklar ist berechtigt, externe Anbieter (z. B. Rechenzentren) einzusetzen.
  6. +
+
+ + {/* 6. Quellcode und Eigentum */} +
+

6. Quellcode und Eigentum

+
+
+

6.1 Webseite

+
    +
  1. Der Kunde erhält ein einfaches, zeitlich unbegrenztes Nutzungsrecht an der fertigen Webseite.
  2. +
  3. Der Quellcode der Webseite wird nur auf ausdrückliche Anfrage und nach Vereinbarung herausgegeben.
  4. +
  5. Ohne Vereinbarung verbleibt der Quellcode bei WEBklar und wird archiviert.
  6. +
+
+
+

6.2 Apps, Automatisierungen und Backend

+
    +
  1. Der Quellcode von Apps, Automatisierungen und Backend-Systemen verbleibt vollständig bei WEBklar.
  2. +
  3. Eine Herausgabe erfolgt ausschließlich nach gesonderter schriftlicher Vereinbarung.
  4. +
  5. Der Kunde erhält lediglich Zugriff auf die Bedienoberfläche bzw. das Frontend.
  6. +
+
+
+
+ + {/* 7. Mitwirkungspflichten */} +
+

7. Mitwirkungspflichten des Kunden

+
    +
  1. Der Kunde stellt alle benötigten Inhalte, Daten und Freigaben rechtzeitig bereit.
  2. +
  3. Verzögerungen durch fehlende Mitwirkung gehen nicht zu Lasten von WEBklar.
  4. +
  5. WEBklar ist nicht verpflichtet, rechtliche Prüfungen der Inhalte vorzunehmen.
  6. +
+
+ + {/* 8. Abnahme */} +
+

8. Abnahme

+
    +
  1. Nach Fertigstellung wird dem Kunden die Leistung zur Abnahme bereitgestellt.
  2. +
  3. Erfolgt innerhalb von 14 Tagen keine Rückmeldung, gilt die Leistung als abgenommen.
  4. +
  5. Nach Abnahme sind nur noch kostenpflichtige Änderungen möglich.
  6. +
+
+ + {/* 9. Vergütung und Zahlung */} +
+

9. Vergütung und Zahlung

+
    +
  1. Die Vergütung ergibt sich aus dem Angebot.
  2. +
  3. + Wird nach Aufwand abgerechnet, erfasst WEBklar die Arbeitszeit je Arbeitsschritt in Schritten von + 15 Minuten; angefangene 15 Minuten werden aufgerundet. Wirkt eine weitere Person unterstützend mit, + rechnet WEBklar für sie die Hälfte der Arbeitszeit des Arbeitsschritts an, aufgerundet auf volle + 15 Minuten; bei mehreren unterstützenden Personen gilt das für jede. Jeder Arbeitsschritt und jede + Unterstützung ist im Kundenportal einsehbar. +
  4. +
  5. Projektleistungen sind nach Vereinbarung fällig.
  6. +
  7. Für die Abos gelten die Vertragsbedingungen für die WEBklar-Abos.
  8. +
+
+ + {/* 10. Zahlungsverzug */} +
+

10. Zahlungsverzug

+
    +
  1. Bei Zahlungsverzug erfolgen bis zu zwei Mahnungen.
  2. +
  3. Danach ist WEBklar berechtigt: +
      +
    • Leistungen zu sperren
    • +
    • Verzugszinsen zu berechnen
    • +
    • den Vertrag außerordentlich zu kündigen
    • +
    +
  4. +
+
+ + {/* 11. Haftung */} +
+

11. Haftung

+
    +
  1. WEBklar haftet unbeschränkt bei Vorsatz und grober Fahrlässigkeit, bei Verletzung von Leben, Körper oder Gesundheit, nach dem Produkthaftungsgesetz und soweit WEBklar eine Garantie übernommen hat.
  2. +
  3. Bei leicht fahrlässiger Verletzung einer wesentlichen Vertragspflicht haftet WEBklar nur für den vertragstypischen, vorhersehbaren Schaden. Wesentlich sind Pflichten, deren Erfüllung die ordnungsgemäße Durchführung des Vertrags überhaupt erst ermöglicht und auf deren Einhaltung der Kunde regelmäßig vertrauen darf. Im Übrigen ist die Haftung für leichte Fahrlässigkeit ausgeschlossen.
  4. +
  5. Der Kunde bewahrt Kopien der Inhalte und Daten auf, die er WEBklar übergibt oder selbst einpflegt. Für den Verlust solcher Inhalte und Daten haftet WEBklar außer in den Fällen von Absatz 1 nur in Höhe des Aufwands, der für ihre Wiederherstellung nötig gewesen wäre, wenn der Kunde diese Kopien aufbewahrt hätte.
  6. +
  7. Diese Regeln gelten auch zugunsten der Mitarbeiter und Beauftragten von WEBklar.
  8. +
+
+ + {/* 12. Kündigung */} +
+

12. Kündigung

+
    +
  1. Laufzeitverträge können zum Ende der jeweiligen Laufzeit gekündigt werden. Laufzeit und Kündigung der Abos richten sich nach den Vertragsbedingungen für die WEBklar-Abos.
  2. +
  3. Das Recht zur außerordentlichen Kündigung bleibt unberührt.
  4. +
+
+ + {/* 13. Referenzen */} +
+

13. Referenzen

+

+ WEBklar darf das Projekt nur nach ausdrücklicher Zustimmung des Kunden als Referenz verwenden. +

+
+ + {/* 14. Schlussbestimmungen */} +
+

14. Schlussbestimmungen

+
    +
  1. Es gilt deutsches Recht.
  2. +
  3. Gerichtsstand ist der Sitz von WEBklar, soweit zulässig.
  4. +
  5. Sollten einzelne Bestimmungen unwirksam sein, bleibt der Vertrag im Übrigen wirksam.
  6. +
+
+
+ + {/* Back / Contact */} +
+
+ + Zur Startseite - - + + Kontakt
-
- - {/* Main Content */} -
-
-
- {/* Page Header */} -
-
- - Vertrag -
-

- Kaufvertrag – WEBklar -

-

- (Modularer Projektvertrag) -

-
-

zwischen

-

WEBklar
(im Folgenden „Anbieter“)

-

und

-

Kunde laut Angebot
(im Folgenden „Kunde“)

-
-
- - {/* Contract Content */} -
- {/* 1. Vertragsgegenstand */} -
-

- 1. Vertragsgegenstand -

-
    -
  1. Gegenstand dieses Vertrages ist die Erbringung der im Angebot definierten Leistungen.
  2. -
  3. Der Vertrag besteht aus diesem Grundvertrag sowie den ausgewählten Leistungsmodulen.
  4. -
  5. Maßgeblich ist das jeweils angenommene Angebot von WEBklar.
  6. -
-
- {contractDivider} - - {/* 2. Leistungsart */} -
-

- 2. Leistungsart -

-
    -
  1. Sämtliche Leistungen von WEBklar stellen Dienst- und Entwicklungsleistungen dar.
  2. -
  3. Ein bestimmter wirtschaftlicher, technischer oder rechtlicher Erfolg wird nicht geschuldet.
  4. -
  5. WEBklar erbringt keinen laufenden Betrieb, sofern dieser nicht explizit vereinbart wurde.
  6. -
-
- {contractDivider} - - {/* 3. Leistungsmodul A – Webseite */} -
-

- 3. Leistungsmodul A – Webseite (einmalig) -

-

(aktiv, wenn im Angebot enthalten)

-
    -
  1. WEBklar erstellt eine individuelle Webseite gemäß Angebot.
  2. -
  3. Die Umsetzung erfolgt nach den vom Kunden gelieferten Inhalten und Vorgaben.
  4. -
  5. Zusätzliche Leistungen wie Domain, Hosting, Wartung oder SEO sind nicht Bestandteil, sofern sie nicht gesondert beauftragt wurden.
  6. -
  7. Der Kunde ist nicht berechtigt, Änderungen am Quellcode selbst vorzunehmen.
  8. -
  9. Änderungen erfolgen ausschließlich durch WEBklar gegen gesonderte Vergütung.
  10. -
-
- {contractDivider} - - {/* 4. Leistungsmodul B – Automatisierung / Virtualisierung */} -
-

- 4. Leistungsmodul B – Automatisierung / Virtualisierung (einmalig) -

-

(aktiv, wenn im Angebot enthalten)

-
    -
  1. WEBklar entwickelt individuelle Automatisierungen, Apps oder Virtualisierungssysteme.
  2. -
  3. Die Leistung stellt eine reine Entwicklungsleistung dar.
  4. -
  5. Optional kann eine Beratungsleistung Bestandteil des Projektes sein.
  6. -
  7. Ein laufender Betrieb, Monitoring oder Wartung ist nicht geschuldet, außer dies wurde explizit vereinbart.
  8. -
  9. Der Kunde entscheidet über Inhalte, Daten und Prozesse und trägt dafür die rechtliche Verantwortung.
  10. -
-
- {contractDivider} - - {/* 5. Leistungsmodul C – Hosting */} -
-

- 5. Leistungsmodul C – Hosting -

-

(aktiv, wenn im Angebot enthalten)

-
    -
  1. - Hosting bietet WEBklar als Hosting-Abo an. Für das Hosting-Abo und das Wartungs-Abo gelten die{" "} - Vertragsbedingungen für die WEBklar-Abos - {" "}mit Leistungsbeschreibungen; sie gehen diesen AGB vor. -
  2. -
  3. Bestehende Hosting-Verträge mit jährlicher Laufzeit behalten die bei ihrem Abschluss vereinbarten Bedingungen. Abweichend davon können Verbraucher solche Verträge nach einer automatischen Verlängerung jederzeit mit einer Frist von einem Monat kündigen.
  4. -
  5. WEBklar ist berechtigt, externe Anbieter (z. B. Rechenzentren) einzusetzen.
  6. -
-
- {contractDivider} - - {/* 6. Quellcode und Eigentum */} -
-

- 6. Quellcode und Eigentum -

-
-
-

6.1 Webseite

-
    -
  1. Der Kunde erhält ein einfaches, zeitlich unbegrenztes Nutzungsrecht an der fertigen Webseite.
  2. -
  3. Der Quellcode der Webseite wird nur auf ausdrückliche Anfrage und nach Vereinbarung herausgegeben.
  4. -
  5. Ohne Vereinbarung verbleibt der Quellcode bei WEBklar und wird archiviert.
  6. -
-
-
-

6.2 Apps, Automatisierungen und Backend

-
    -
  1. Der Quellcode von Apps, Automatisierungen und Backend-Systemen verbleibt vollständig bei WEBklar.
  2. -
  3. Eine Herausgabe erfolgt ausschließlich nach gesonderter schriftlicher Vereinbarung.
  4. -
  5. Der Kunde erhält lediglich Zugriff auf die Bedienoberfläche bzw. das Frontend.
  6. -
-
-
-
- {contractDivider} - - {/* 7. Mitwirkungspflichten */} -
-

- 7. Mitwirkungspflichten des Kunden -

-
    -
  1. Der Kunde stellt alle benötigten Inhalte, Daten und Freigaben rechtzeitig bereit.
  2. -
  3. Verzögerungen durch fehlende Mitwirkung gehen nicht zu Lasten von WEBklar.
  4. -
  5. WEBklar ist nicht verpflichtet, rechtliche Prüfungen der Inhalte vorzunehmen.
  6. -
-
- {contractDivider} - - {/* 8. Abnahme */} -
-

- 8. Abnahme -

-
    -
  1. Nach Fertigstellung wird dem Kunden die Leistung zur Abnahme bereitgestellt.
  2. -
  3. Erfolgt innerhalb von 14 Tagen keine Rückmeldung, gilt die Leistung als abgenommen.
  4. -
  5. Nach Abnahme sind nur noch kostenpflichtige Änderungen möglich.
  6. -
-
- {contractDivider} - - {/* 9. Vergütung und Zahlung */} -
-

- 9. Vergütung und Zahlung -

-
    -
  1. Die Vergütung ergibt sich aus dem Angebot.
  2. -
  3. - Wird nach Aufwand abgerechnet, erfasst WEBklar die Arbeitszeit je Arbeitsschritt in Schritten von - 15 Minuten; angefangene 15 Minuten werden aufgerundet. Wirkt eine weitere Person unterstützend mit, - rechnet WEBklar für sie die Hälfte der Arbeitszeit des Arbeitsschritts an, aufgerundet auf volle - 15 Minuten; bei mehreren unterstützenden Personen gilt das für jede. Jeder Arbeitsschritt und jede - Unterstützung ist im Kundenportal einsehbar. -
  4. -
  5. Projektleistungen sind nach Vereinbarung fällig.
  6. -
  7. Für die Abos gelten die Vertragsbedingungen für die WEBklar-Abos.
  8. -
-
- {contractDivider} - - {/* 10. Zahlungsverzug */} -
-

- 10. Zahlungsverzug -

-
    -
  1. Bei Zahlungsverzug erfolgen bis zu zwei Mahnungen.
  2. -
  3. Danach ist WEBklar berechtigt: -
      -
    • Leistungen zu sperren
    • -
    • Verzugszinsen zu berechnen
    • -
    • den Vertrag außerordentlich zu kündigen
    • -
    -
  4. -
-
- {contractDivider} - - {/* 11. Haftung */} -
-

- 11. Haftung -

-
    -
  1. WEBklar haftet unbeschränkt bei Vorsatz und grober Fahrlässigkeit, bei Verletzung von Leben, Körper oder Gesundheit, nach dem Produkthaftungsgesetz und soweit WEBklar eine Garantie übernommen hat.
  2. -
  3. Bei leicht fahrlässiger Verletzung einer wesentlichen Vertragspflicht haftet WEBklar nur für den vertragstypischen, vorhersehbaren Schaden. Wesentlich sind Pflichten, deren Erfüllung die ordnungsgemäße Durchführung des Vertrags überhaupt erst ermöglicht und auf deren Einhaltung der Kunde regelmäßig vertrauen darf. Im Übrigen ist die Haftung für leichte Fahrlässigkeit ausgeschlossen.
  4. -
  5. Der Kunde bewahrt Kopien der Inhalte und Daten auf, die er WEBklar übergibt oder selbst einpflegt. Für den Verlust solcher Inhalte und Daten haftet WEBklar außer in den Fällen von Absatz 1 nur in Höhe des Aufwands, der für ihre Wiederherstellung nötig gewesen wäre, wenn der Kunde diese Kopien aufbewahrt hätte.
  6. -
  7. Diese Regeln gelten auch zugunsten der Mitarbeiter und Beauftragten von WEBklar.
  8. -
-
- {contractDivider} - - {/* 12. Kündigung */} -
-

- 12. Kündigung -

-
    -
  1. Laufzeitverträge können zum Ende der jeweiligen Laufzeit gekündigt werden. Laufzeit und Kündigung der Abos richten sich nach den Vertragsbedingungen für die WEBklar-Abos.
  2. -
  3. Das Recht zur außerordentlichen Kündigung bleibt unberührt.
  4. -
-
- {contractDivider} - - {/* 13. Referenzen */} -
-

- 13. Referenzen -

-

- WEBklar darf das Projekt nur nach ausdrücklicher Zustimmung des Kunden als Referenz verwenden. -

-
- {contractDivider} - - {/* 14. Schlussbestimmungen */} -
-

- 14. Schlussbestimmungen -

-
    -
  1. Es gilt deutsches Recht.
  2. -
  3. Gerichtsstand ist der Sitz von WEBklar, soweit zulässig.
  4. -
  5. Sollten einzelne Bestimmungen unwirksam sein, bleibt der Vertrag im Übrigen wirksam.
  6. -
-
-
- - {/* Back / Contact */} -
- - - - - - -
-
-
-
+
); }; diff --git a/src/pages/About.tsx b/src/pages/About.tsx index a1b3126..408e30c 100644 --- a/src/pages/About.tsx +++ b/src/pages/About.tsx @@ -1,9 +1,25 @@ import { Link } from "react-router-dom"; -import { Button } from "@/components/ui/button"; -import { ArrowLeft, ArrowRight } from "lucide-react"; -import CountUp from "@/components/CountUp"; -import BorderGlow from "@/components/BorderGlow"; import { usePageMeta } from "@/hooks/use-page-meta"; +import "./unterseiten.css"; + +/** „Der Unterschied“: nummerierte Textzeilen statt Karten (Phase 2 schreibt die ganze Seite neu). */ +const UNTERSCHIED = [ + { + nr: "01", + titel: "Alles aus einer Hand", + text: "Website, Automation und Schnittstellen aus einer Hand – ein Ansprechpartner statt fünf Agenturen.", + }, + { + nr: "02", + titel: "Systeme statt Inseln", + text: "CRM, Formulare und Website tauschen Daten – ohne manuelles Nachpflegen.", + }, + { + nr: "03", + titel: "Betreuung im Abo", + text: "Nach dem Go-live kümmern wir uns im Wartungs-Abo um Updates und kleine Änderungen.", + }, +]; const About = () => { usePageMeta( @@ -12,182 +28,75 @@ const About = () => { ); return ( -
- {/* Header */} -
-
-
- - - WEBklar - + <> +
+
+
+ Über WEBklar +

Digitalisierung, die im Alltag spürbar wird.

+
+ +
+

+ WEBklar ist die Webagentur für Unternehmen, die wachsen wollen – ohne + dass Geschäftsführung und Team unter Tool-Chaos und manuellen Abläufen leiden. +

+ +

+ Wir verbinden Website, Prozesse und Automatisierung zu einem System. Kein + isoliertes Redesign, kein weiteres Plugin – sondern eine Lösung, die Anfragen, Daten und Abläufe + zusammenführt. +

+
+ +
+

Unser Anspruch: weniger Reibung.

+

Mehr planbares Wachstum.

+
+ +

+ Starten Sie mit einem kostenlosen Gespräch – wir zeigen Ihnen die nächsten Schritte. +

+ +
+ + Projekt besprechen - - + + Zur Startseite
-
+
- {/* Main Content */} -
-
-
-
Über WEBklar
+ {/* Der Unterschied */} +
+
+ Der Unterschied +

+ Warum Unternehmen zu uns wechseln. +

-

- Digitalisierung, die im Alltag spürbar wird. -

- -
-

- WEBklar ist die Webagentur für Unternehmen, die wachsen wollen – ohne - dass Geschäftsführung und Team unter Tool-Chaos und manuellen Abläufen leiden. -

- -

- Wir verbinden{" "} - - Website, Prozesse und Automatisierung - {" "} - zu einem System. Kein isoliertes Redesign, kein weiteres Plugin – sondern - eine Lösung, die Anfragen, Daten und Abläufe zusammenführt. -

- -
-

- Unser Anspruch: weniger Reibung. +

    + {UNTERSCHIED.map((punkt) => ( +
  1. +

    + {punkt.nr}

    -

    - Mehr planbares Wachstum. -

    -
+

{punkt.titel}

+

{punkt.text}

+ + ))} + -

- Starten Sie mit einer kostenlosen Potenzialanalyse – wir zeigen Ihnen die nächsten Schritte. -

-
- - {/* Back / Contact */} -
- - - - - - -
+
+ + Projekt besprechen +
- - {/* Der Unterschied */} -
-
-
-
Der Unterschied
- -

- Warum Unternehmen zu uns wechseln. -

- -
- -
-
- -
-

- Alles aus einer Hand -

-

- Website, Automation und Schnittstellen aus einer Hand – ein Ansprechpartner statt fünf Agenturen. -

-
-
- - -
-
- -
-

- Systeme statt Inseln -

-

- CRM, Formulare und Website tauschen Daten – ohne manuelles Nachpflegen. -

-
-
- - -
-
- -
-

- Betreuung im Abo -

-

- Nach dem Go-live kümmern wir uns im Wartungs-Abo um Updates und kleine Änderungen. -

-
-
-
- - - - -
-
-
-
- + + ); }; diff --git a/src/pages/Contact.tsx b/src/pages/Contact.tsx index 203a96e..dfc0918 100644 --- a/src/pages/Contact.tsx +++ b/src/pages/Contact.tsx @@ -1,23 +1,18 @@ -import { useState } from "react"; -import { Link } from "react-router-dom"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Textarea } from "@/components/ui/textarea"; -import { Label } from "@/components/ui/label"; -import { ArrowLeft, Send } from "lucide-react"; -import { useToast } from "@/hooks/use-toast"; -import { createContactDocument } from "@/lib/appwrite"; -import { trackFormSubmitted } from "@/lib/analytics"; +import { useRef, useState } from "react"; import { usePageMeta } from "@/hooks/use-page-meta"; +import "./unterseiten.css"; + +type Rueckmeldung = { art: "erfolg" | "fehler"; titel: string; text: string }; const Contact = () => { usePageMeta( - "Kontakt & Potenzialanalyse", - "Beschreiben Sie Ihr Projekt – WEBklar meldet sich spätestens am nächsten Werktag mit den nächsten Schritten. Unverbindlich und kostenlos." + "Projekt besprechen", + "Beschreiben Sie Ihr Vorhaben in zwei Sätzen. WEBklar meldet sich spätestens am nächsten Werktag." ); - const { toast } = useToast(); const [isSubmitting, setIsSubmitting] = useState(false); + const [rueckmeldung, setRueckmeldung] = useState(null); + const statusRef = useRef(null); const [formData, setFormData] = useState({ name: "", email: "", @@ -36,190 +31,155 @@ const Contact = () => { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + if (isSubmitting) return; setIsSubmitting(true); + setRueckmeldung(null); try { + // Das Appwrite-SDK wird erst beim Absenden geladen (Spezifikation 9.7) + const { createContactDocument } = await import("@/lib/appwrite"); await createContactDocument(formData); - trackFormSubmitted("contact"); - toast({ - title: "Nachricht gesendet!", - description: "Wir melden uns spätestens am nächsten Werktag bei Ihnen.", + setRueckmeldung({ + art: "erfolg", + titel: "Nachricht gesendet.", + text: "Wir melden uns spätestens am nächsten Werktag bei Ihnen.", }); setFormData({ name: "", email: "", company: "", message: "" }); - } catch (err) { - const message = err instanceof Error ? err.message : "Speichern fehlgeschlagen."; - toast({ - variant: "destructive", - title: "Fehler beim Senden", - description: message, + } catch { + setRueckmeldung({ + art: "fehler", + titel: "Die Nachricht konnte nicht gesendet werden.", + text: "Bitte versuchen Sie es noch einmal oder schreiben Sie uns direkt per E-Mail.", }); } finally { setIsSubmitting(false); + // Rückmeldung ins Bild holen, falls sie unter dem Bildschirmrand erscheint + requestAnimationFrame(() => statusRef.current?.scrollIntoView?.({ block: "nearest" })); } }; return ( -
- {/* Header */} -
-
-
- - - WEBklar - - - - - -
+
+
+
+

Projekt besprechen

+

+ Zwei Sätze genügen. Wir melden uns spätestens am nächsten Werktag. Im ersten Gespräch klären wir + in 15 Minuten, ob und wie wir helfen können – kostenlos und unverbindlich. +

-
- {/* Main Content */} -
-
-
- {/* Header */} -
-
Kontakt
-

- Kostenlose -
- Potenzialanalyse -

-

- Kurz Ihr Ziel beschreiben – wir prüfen, wo Website, Automatisierung - oder Vernetzung den größten Hebel haben. Antwort spätestens am nächsten Werktag, - unverbindlich. -

+
+
+

+ Kontaktformular +

+ +
+
+ + +
+
+ + +
- {/* Contact Form */} - -
-
- - -
-
- - -
-
+
+ + +
-
- - -
+
+ +