Relaunch Phase 1: Hero mit Deutschland-Karte, Kundenportal-Bühne, Leistungen, Preise, Unterseiten im neuen Layout

- Hero: wechselnde Überschrift (ein Durchlauf, Pause-Knopf), Karte zeichnet
  sich, roter Punkt auf Falkenstein
- Kundenportal: Bühne mit Browserfenster und Handy-Rahmen (Desktop),
  Wisch-Reihe (Handy), Schrittzeile statt „So arbeiten wir“
- Leistungen als nummerierte Zeilen, Preise aus src/data/abos.ts
- Unterseiten ohne eigenen Kopfbereich, Wortlaut der Rechtstexte unverändert
- Tests für Hero, Kundenportal, Leistungen/Preise und Layout

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-01 15:28:38 +00:00
1 parent 69d006334f
commit 1c8cfa5c1c
54 files changed
+4761 -1166

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

+104
View File
@@ -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/<name>-<breite>.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())
+130
View File
@@ -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 <path> 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.Line truncated
"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<HTMLDivElement>(null);
const [lauf, setLauf] = useState<Lauf>("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 (
<div ref={wurzel} className={`wk-karte${className ? ` ${className}` : ""}`} data-lauf={lauf}>
{/* Dekoration: die Aussage steht als Text daneben */}
<svg className="wk-karte__svg" viewBox={`0 0 ${KARTE_BREITE} ${KARTE_HOEHE}`} aria-hidden="true" focusable="false">
<g className="wk-karte__umriss">
{UMRISS.map((d, i) => (
<path key={i} d={d} pathLength={1} />
))}
</g>
{punkte.map((p) => (
<g key={`${p.cx}-${p.cy}`} className={`wk-karte__punkt wk-karte__punkt--${p.art}`}>
{p.art === "server" && <circle className="wk-karte__ring" cx={p.cx} cy={p.cy} r={7} />}
<circle className="wk-karte__kern" cx={p.cx} cy={p.cy} r={7} />
</g>
))}
</svg>
{punkte.map((p) => (
<div
key={`${p.cx}-${p.cy}`}
className="wk-karte__label"
style={{ left: `${(p.cx / KARTE_BREITE) * 100}%`, top: `${(p.cy / KARTE_HOEHE) * 100}%` }}
>
<p className="wk-karte__titel">{p.label}</p>
{p.sublabel && <p className="wk-karte__zusatz">{p.sublabel}</p>}
</div>
))}
</div>
);
};
export default DeutschlandKarte;
+83
View File
@@ -0,0 +1,83 @@
import type { ReactNode } from "react";
import "./geraete-rahmen.css";
/** Ein Bild samt Größenangaben. Alle Felder landen 1:1 im <img> (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 <img> eines Rahmens: immer mit Alt-Text, fester Größe, srcset und asynchroner Dekodierung. */
export const RahmenBild = ({ bild, className }: BildProps) => (
<img
className={className}
src={bild.src}
srcSet={bild.srcSet}
sizes={bild.sizes}
width={bild.width}
height={bild.height}
alt={bild.alt}
loading={bild.loading ?? "lazy"}
decoding="async"
/>
);
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) => (
<div className={`wk-fenster${className ? ` ${className}` : ""}`}>
{/* reine Dekoration: der Inhalt steht im Alt-Text des Bildes */}
<div className="wk-fenster__leiste" aria-hidden="true">
{adresse}
</div>
<div className="wk-fenster__bild">{bild ? <RahmenBild bild={bild} /> : children}</div>
</div>
);
/**
* 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) => (
<div className={`wk-handy${className ? ` ${className}` : ""}`}>
<div className="wk-handy__rahmen">
<div className="wk-handy__schirm">{bild ? <RahmenBild bild={bild} /> : children}</div>
</div>
</div>
);
type GeraeteRahmenProps = (FensterProps & { art: "desktop" }) | (RahmenProps & { art: "handy" });
/** Ein Einstieg für beide Rahmen: <GeraeteRahmen art="desktop" .../> oder art="handy". */
const GeraeteRahmen = (props: GeraeteRahmenProps) =>
props.art === "handy" ? <HandyRahmen {...props} /> : <DesktopFenster {...props} />;
export default GeraeteRahmen;
+169
View File
@@ -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);
}
}
+82
View File
@@ -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;
}
+135 -8
View File
@@ -1,10 +1,137 @@
/** Platzhalter: wird im Relaunch-Workflow gebaut (Spezifikation Abschnitt 7). */
const HeroKarte = () => (
<section className="wk-section">
<div className="wk-container">
<h2 className="wk-h2">HeroKarte</h2>
</div>
</section>
);
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<HTMLAnchorElement>) => {
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<HTMLHeadingElement>(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 (
<section className="wk-hero">
<div className="wk-container wk-hero__raster">
<div className="wk-hero__text">
<p className="wk-eyebrow wk-hero__zeile">Websites, Shops und Apps für kleine Betriebe</p>
<div
className="wk-hero__kopf"
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
onFocus={() => setFokus(true)}
onBlur={() => setFokus(false)}
>
<h1 ref={ueberschrift} className="wk-h1 wk-hero__h1" tabIndex={-1}>
<span className="wk-visually-hidden">{FESTER_SATZ}</span>
<span className="wk-hero__sichtbar" aria-hidden="true">
<span className="wk-hero__fest">Wir bauen</span>
<span className="wk-hero__wechsel">
{sichtbareVarianten.map((text, i) => (
<span key={text} className="wk-hero__variante" data-aktiv={i === aktiv}>
{text}
</span>
))}
</span>
</span>
</h1>
{inBewegung && (
<button
type="button"
className="wk-hero__pause"
aria-label="Wechselnde Überschrift anhalten"
onClick={anhalten}
>
<span className="wk-hero__pause-symbol" aria-hidden="true">
<Pause size={14} strokeWidth={2.4} />
</span>
</button>
)}
</div>
<p className="wk-lead wk-hero__lead">
In unserem Kundenportal sehen Sie jederzeit, wie weit Ihr Projekt ist – mit Live-Vorschau Ihrer Website.
</p>
<div className="wk-hero__aktionen">
<Link to="/kontakt" className="wk-btn wk-btn--primary">
Projekt besprechen
</Link>
<Link to="/#kundenportal" className="wk-btn wk-btn--secondary" onClick={zumKundenportal}>
Kundenportal ansehen
</Link>
</div>
</div>
<div className="wk-hero__karte">
<DeutschlandKarte />
</div>
</div>
</section>
);
};
export default HeroKarte;
+400 -6
View File
@@ -1,10 +1,404 @@
/** Platzhalter: wird im Relaunch-Workflow gebaut (Spezifikation Abschnitt 7). */
const Kundenportal = () => (
<section id="kundenportal" className="wk-section">
<div className="wk-container">
<h2 className="wk-h2">Kundenportal</h2>
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<HTMLDivElement>(null);
const buehne = useRef<HTMLDivElement>(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 (
<div className="kp-huelle" ref={huelle}>
<div className="kp-spur" aria-hidden="true">
{FUNKTIONEN.map((f, i) => (
<div key={f.id} className="kp-marker" data-index={i} ref={(el) => (marker.current[i] = el)} />
))}
</div>
<div className="kp-buehne" ref={buehne} data-app={app ? "ja" : "nein"}>
<div className="wk-container kp-buehne__innen">
<ol className="kp-liste" aria-label="Funktionen des Kundenportals">
{FUNKTIONEN.map((f, i) => (
<li key={f.id}>
<button
type="button"
className="kp-eintrag"
aria-current={i === aktiv ? "step" : undefined}
onClick={() => springe(i)}
>
<span className="kp-eintrag__punkt" aria-hidden="true" />
<span className="kp-eintrag__titel">{f.titel}</span>
<span className="kp-eintrag__text">{f.unterschrift}</span>
</button>
</li>
))}
</ol>
<div className="kp-geraete">
<DesktopFenster className="kp-fenster">
<div className="kp-ebenen" aria-hidden={app || undefined}>
{FUNKTIONEN.map((f, i) =>
f.desktop && eingehaengt(i) ? (
<div
key={f.id}
className={`kp-lage${i === aktiv ? " is-aktiv" : ""}`}
aria-hidden={i !== aktiv || undefined}
>
<RahmenBild bild={desktopBild(f.desktop.name, f.desktop.alt)} />
</div>
) : null,
)}
</div>
</DesktopFenster>
<HandyRahmen className="kp-handy">
{FUNKTIONEN.map((f, i) =>
eingehaengt(i) ? (
<div
key={f.id}
className={`kp-lage${i === aktiv ? " is-aktiv" : ""}`}
aria-hidden={i !== aktiv || undefined}
>
{f.lang ? (
<div className="kp-lang">
<RahmenBild bild={handyBild(f.handy.name, f.handy.alt, "(min-width: 1200px) 340px, 30vw", 5428)} />
</div>
) : (
<RahmenBild bild={handyBild(f.handy.name, f.handy.alt, "(min-width: 1200px) 230px, 20vw")} />
)}
</div>
) : null,
)}
</HandyRahmen>
</div>
</div>
</div>
</div>
</section>
);
};
// ── 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<HTMLDivElement>(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 (
<div className="kp-karte" ref={ref} data-sichtbar={sichtbar ? "ja" : "nein"}>
<HandyRahmen className="kp-karte__handy">
<div className="kp-lang">
<RahmenBild bild={handyBild(f.handy.name, f.handy.alt, "250px", 5428)} />
</div>
</HandyRahmen>
<h3 className="wk-h3 kp-karte__titel">{f.titel}</h3>
<p className="wk-muted kp-karte__text">{f.unterschrift}</p>
</div>
);
};
const HandyReihe = () => (
<div className="kp-reihe">
<WischReihe ariaLabel="Sechs Funktionen des Kundenportals, seitlich wischen">
{FUNKTIONEN.map((f) =>
f.lang ? (
<LangeKarte key={f.id} f={f} />
) : (
<div key={f.id} className="kp-karte">
<HandyRahmen className="kp-karte__handy" bild={handyBild(f.handy.name, f.handy.alt, "250px")} />
<h3 className="wk-h3 kp-karte__titel">{f.titel}</h3>
<p className="wk-muted kp-karte__text">{f.unterschrift}</p>
</div>
),
)}
</WischReihe>
</div>
);
// ── Schrittzeile ──────────────────────────────────────────────────────────────
const Schrittzeile = ({ desktop }: { desktop: boolean }) =>
desktop ? (
<div className="wk-container">
<ol className="kp-schritte wk-reveal" aria-label="So arbeiten wir">
{SCHRITTE.map((s) => (
<li key={s.nr} className="kp-schritt">
<span className="kp-schritt__nr wk-num">{s.nr}</span>
<span className="kp-schritt__text">{s.text}</span>
</li>
))}
</ol>
</div>
) : (
<div className="kp-schritte-handy">
<WischReihe ariaLabel="So arbeiten wir, vier Schritte, seitlich wischen" className="kp-schritte-reihe">
{SCHRITTE.map((s) => (
<div key={s.nr} className="kp-schritt-karte">
<span className="kp-schritt__nr wk-num">{s.nr}</span>
<span className="kp-schritt__text">{s.text}</span>
</div>
))}
</WischReihe>
</div>
);
// ── Abschnitt ─────────────────────────────────────────────────────────────────
const Kundenportal = () => {
const desktop = useMedia("(min-width: 900px)");
return (
<section id="kundenportal" className="wk-section wk-section--alt kp" aria-labelledby="kundenportal-titel">
<div className="wk-container kp__kopf wk-reveal">
<h2 id="kundenportal-titel" className="wk-h2">
{/* zwei Sätze, zwei Zeilen: der Umbruch soll nach "Projekt." fallen, nicht mitten im zweiten Satz */}
<span className="kp-satz">Ihr Projekt.</span> <span className="kp-satz">Live im Kundenportal.</span>
</h2>
<p className="wk-lead">{EINLEITUNG}</p>
</div>
{desktop ? <Buehne /> : <HandyReihe />}
<Schrittzeile desktop={desktop} />
</section>
);
};
export default Kundenportal;
+122 -7
View File
@@ -1,10 +1,125 @@
/** Platzhalter: wird im Relaunch-Workflow gebaut (Spezifikation Abschnitt 7). */
const Leistungen = () => (
<section id="leistungen" className="wk-section">
<div className="wk-container">
<h2 className="wk-h2">Leistungen</h2>
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 (
<p className="wk-leistungen__einleitung">
<span className="wk-visually-hidden">{EINLEITUNG}</span>
<span aria-hidden="true">
{woerter.map((wort, i) => (
<Fragment key={i}>
<span className="wk-leistungen__wort" style={{ "--i": i, "--n": woerter.length } as CSSProperties}>
{wort}
</span>
{i < woerter.length - 1 ? " " : null}
</Fragment>
))}
</span>
</p>
);
};
const Eintrag = ({ index }: { index: number }) => {
const { titel, text } = LEISTUNGEN[index];
return (
<div className="wk-leistung">
<span className="wk-leistung__nr wk-num" aria-hidden="true">
{nummer(index)}
</span>
<div className="wk-leistung__inhalt">
<h3 className="wk-h3 wk-leistung__titel">{titel}</h3>
<p className="wk-leistung__text">{text}</p>
</div>
</div>
</section>
);
);
};
/** Spezifikation 7.3: vier nummerierte Textzeilen statt Karten mit Symbolen. */
const Leistungen = () => {
const desktop = useDesktop();
return (
<section id="leistungen" className="wk-section wk-leistungen" aria-labelledby="leistungen-titel">
<div className="wk-container wk-leistungen__raster">
<div className="wk-leistungen__kopf">
<h2 id="leistungen-titel" className="wk-h2 wk-reveal">
Was wir für Sie bauen.
</h2>
<Einleitung />
</div>
{desktop ? (
<ol className="wk-leistungen__liste" role="list">
{LEISTUNGEN.map((l, i) => (
<li key={l.titel} className="wk-leistungen__zeile wk-reveal">
<Eintrag index={i} />
</li>
))}
</ol>
) : (
<div className="wk-leistungen__wisch">
<WischReihe ariaLabel="Vier Leistungen, seitlich wischen">
{LEISTUNGEN.map((l, i) => (
<Eintrag key={l.titel} index={i} />
))}
</WischReihe>
</div>
)}
<p className="wk-leistungen__umzug wk-reveal">
<strong>Sie haben schon eine Website?</strong> Wir ziehen sie um – mit Domain, Inhalten und Postfächern.
</p>
</div>
</section>
);
};
export default Leistungen;
+50 -8
View File
@@ -1,10 +1,52 @@
/** Platzhalter: wird im Relaunch-Workflow gebaut (Spezifikation Abschnitt 7). */
const Preise = () => (
<section id="preise" className="wk-section">
<div className="wk-container">
<h2 className="wk-h2">Preise</h2>
</div>
</section>
);
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 (
<section id="preise" className="wk-section wk-section--alt wk-preise" aria-labelledby="preise-titel">
<div className="wk-container">
<h2 id="preise-titel" className="wk-h2 wk-reveal">
Was es kostet. Vorher.
</h2>
<p className="wk-lead wk-reveal">{EINLEITUNG}</p>
<div className="wk-preise__raster">
{ABOS.map((abo) => (
<article key={abo.id} className="wk-abo wk-reveal" aria-labelledby={`abo-${abo.id}`}>
<h3 id={`abo-${abo.id}`} className="wk-h3 wk-abo__name">
{abo.name}
</h3>
<p className="wk-abo__preis">
<span className="wk-abo__betrag wk-num">{preisText(abo.preis)}</span>{" "}
<span className="wk-abo__einheit">im Monat</span>
</p>
<p className="wk-abo__text">{abo.beschreibung}</p>
<p className="wk-abo__aktion">
<Link to={abo.link.to} className="wk-link wk-abo__link">
{abo.link.text}
</Link>
</p>
</article>
))}
</div>
<p className="wk-preise__weitere wk-reveal">
{vorher}
<span className="wk-preise__nicht-trennen">{schritte}</span>
{nachher}
</p>
</div>
</section>
);
};
export default Preise;
+174
View File
@@ -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;
}
}
+332
View File
@@ -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;
}
+127
View File
@@ -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;
}
}
+84
View File
@@ -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;
}
}
+74
View File
@@ -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("");
+201 -283
View File
@@ -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 = (
<div className="my-8 border-t border-border" aria-hidden />
);
import "./unterseiten.css";
const AGB = () => {
usePageMeta("AGB", "Allgemeine Geschäftsbedingungen der WEBklar GbR.");
return (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="fixed top-0 left-0 right-0 z-50 glass-nav py-4">
<div className="container mx-auto px-6">
<div className="flex items-center justify-between">
<Link to="/" className="flex items-center gap-2 group">
<span className="text-xl font-display font-medium text-foreground tracking-normal">
WEBklar
</span>
<div className="wk-seite">
<div className="wk-container">
{/* Page Header */}
<div className="wk-seite__kopf">
<span className="wk-eyebrow">Vertrag</span>
<h1 className="wk-seite__titel">Kaufvertrag – WEBklar</h1>
<p className="wk-seite__lead">(Modularer Projektvertrag)</p>
<div className="wk-parteien">
<p><strong>zwischen</strong></p>
<p>WEBklar<br /><small>(im Folgenden „Anbieter“)</small></p>
<p><strong>und</strong></p>
<p>Kunde laut Angebot<br /><small>(im Folgenden „Kunde“)</small></p>
</div>
</div>
{/* Contract Content */}
<article className="wk-text">
{/* 1. Vertragsgegenstand */}
<section>
<h2>1. Vertragsgegenstand</h2>
<ol>
<li>Gegenstand dieses Vertrages ist die Erbringung der im Angebot definierten Leistungen.</li>
<li>Der Vertrag besteht aus diesem Grundvertrag sowie den ausgewählten Leistungsmodulen.</li>
<li>Maßgeblich ist das jeweils angenommene Angebot von WEBklar.</li>
</ol>
</section>
{/* 2. Leistungsart */}
<section>
<h2>2. Leistungsart</h2>
<ol>
<li>Sämtliche Leistungen von WEBklar stellen Dienst- und Entwicklungsleistungen dar.</li>
<li>Ein bestimmter wirtschaftlicher, technischer oder rechtlicher Erfolg wird nicht geschuldet.</li>
<li>WEBklar erbringt keinen laufenden Betrieb, sofern dieser nicht explizit vereinbart wurde.</li>
</ol>
</section>
{/* 3. Leistungsmodul A – Webseite */}
<section>
<h2>3. Leistungsmodul A – Webseite (einmalig)</h2>
<p className="wk-text__zusatz">(aktiv, wenn im Angebot enthalten)</p>
<ol>
<li>WEBklar erstellt eine individuelle Webseite gemäß Angebot.</li>
<li>Die Umsetzung erfolgt nach den vom Kunden gelieferten Inhalten und Vorgaben.</li>
<li>Zusätzliche Leistungen wie Domain, Hosting, Wartung oder SEO sind nicht Bestandteil, sofern sie nicht gesondert beauftragt wurden.</li>
<li>Der Kunde ist nicht berechtigt, Änderungen am Quellcode selbst vorzunehmen.</li>
<li>Änderungen erfolgen ausschließlich durch WEBklar gegen gesonderte Vergütung.</li>
</ol>
</section>
{/* 4. Leistungsmodul B – Automatisierung / Virtualisierung */}
<section>
<h2>4. Leistungsmodul B – Automatisierung / Virtualisierung (einmalig)</h2>
<p className="wk-text__zusatz">(aktiv, wenn im Angebot enthalten)</p>
<ol>
<li>WEBklar entwickelt individuelle Automatisierungen, Apps oder Virtualisierungssysteme.</li>
<li>Die Leistung stellt eine reine Entwicklungsleistung dar.</li>
<li>Optional kann eine Beratungsleistung Bestandteil des Projektes sein.</li>
<li>Ein laufender Betrieb, Monitoring oder Wartung ist nicht geschuldet, außer dies wurde explizit vereinbart.</li>
<li>Der Kunde entscheidet über Inhalte, Daten und Prozesse und trägt dafür die rechtliche Verantwortung.</li>
</ol>
</section>
{/* 5. Leistungsmodul C – Hosting */}
<section>
<h2>5. Leistungsmodul C – Hosting</h2>
<p className="wk-text__zusatz">(aktiv, wenn im Angebot enthalten)</p>
<ol>
<li>
Hosting bietet WEBklar als Hosting-Abo an. Für das Hosting-Abo und das Wartungs-Abo gelten die{" "}
<a href="https://project.webklar.com/abo-bedingungen.html">Vertragsbedingungen für die WEBklar-Abos</a>
{" "}mit Leistungsbeschreibungen; sie gehen diesen AGB vor.
</li>
<li>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.</li>
<li>WEBklar ist berechtigt, externe Anbieter (z. B. Rechenzentren) einzusetzen.</li>
</ol>
</section>
{/* 6. Quellcode und Eigentum */}
<section>
<h2>6. Quellcode und Eigentum</h2>
<div>
<div className="wk-text__gruppe">
<h3>6.1 Webseite</h3>
<ol>
<li>Der Kunde erhält ein einfaches, zeitlich unbegrenztes Nutzungsrecht an der fertigen Webseite.</li>
<li>Der Quellcode der Webseite wird nur auf ausdrückliche Anfrage und nach Vereinbarung herausgegeben.</li>
<li>Ohne Vereinbarung verbleibt der Quellcode bei WEBklar und wird archiviert.</li>
</ol>
</div>
<div className="wk-text__gruppe">
<h3>6.2 Apps, Automatisierungen und Backend</h3>
<ol>
<li>Der Quellcode von Apps, Automatisierungen und Backend-Systemen verbleibt vollständig bei WEBklar.</li>
<li>Eine Herausgabe erfolgt ausschließlich nach gesonderter schriftlicher Vereinbarung.</li>
<li>Der Kunde erhält lediglich Zugriff auf die Bedienoberfläche bzw. das Frontend.</li>
</ol>
</div>
</div>
</section>
{/* 7. Mitwirkungspflichten */}
<section>
<h2>7. Mitwirkungspflichten des Kunden</h2>
<ol>
<li>Der Kunde stellt alle benötigten Inhalte, Daten und Freigaben rechtzeitig bereit.</li>
<li>Verzögerungen durch fehlende Mitwirkung gehen nicht zu Lasten von WEBklar.</li>
<li>WEBklar ist nicht verpflichtet, rechtliche Prüfungen der Inhalte vorzunehmen.</li>
</ol>
</section>
{/* 8. Abnahme */}
<section>
<h2>8. Abnahme</h2>
<ol>
<li>Nach Fertigstellung wird dem Kunden die Leistung zur Abnahme bereitgestellt.</li>
<li>Erfolgt innerhalb von 14 Tagen keine Rückmeldung, gilt die Leistung als abgenommen.</li>
<li>Nach Abnahme sind nur noch kostenpflichtige Änderungen möglich.</li>
</ol>
</section>
{/* 9. Vergütung und Zahlung */}
<section>
<h2>9. Vergütung und Zahlung</h2>
<ol>
<li>Die Vergütung ergibt sich aus dem Angebot.</li>
<li>
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.
</li>
<li>Projektleistungen sind nach Vereinbarung fällig.</li>
<li>Für die Abos gelten die Vertragsbedingungen für die WEBklar-Abos.</li>
</ol>
</section>
{/* 10. Zahlungsverzug */}
<section>
<h2>10. Zahlungsverzug</h2>
<ol>
<li>Bei Zahlungsverzug erfolgen bis zu zwei Mahnungen.</li>
<li>Danach ist WEBklar berechtigt:
<ul>
<li>Leistungen zu sperren</li>
<li>Verzugszinsen zu berechnen</li>
<li>den Vertrag außerordentlich zu kündigen</li>
</ul>
</li>
</ol>
</section>
{/* 11. Haftung */}
<section>
<h2>11. Haftung</h2>
<ol>
<li>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.</li>
<li>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.</li>
<li>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.</li>
<li>Diese Regeln gelten auch zugunsten der Mitarbeiter und Beauftragten von WEBklar.</li>
</ol>
</section>
{/* 12. Kündigung */}
<section>
<h2>12. Kündigung</h2>
<ol>
<li>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.</li>
<li>Das Recht zur außerordentlichen Kündigung bleibt unberührt.</li>
</ol>
</section>
{/* 13. Referenzen */}
<section>
<h2>13. Referenzen</h2>
<p>
WEBklar darf das Projekt nur nach ausdrücklicher Zustimmung des Kunden als Referenz verwenden.
</p>
</section>
{/* 14. Schlussbestimmungen */}
<section>
<h2>14. Schlussbestimmungen</h2>
<ol>
<li>Es gilt deutsches Recht.</li>
<li>Gerichtsstand ist der Sitz von WEBklar, soweit zulässig.</li>
<li>Sollten einzelne Bestimmungen unwirksam sein, bleibt der Vertrag im Übrigen wirksam.</li>
</ol>
</section>
</article>
{/* Back / Contact */}
<div className="wk-seite__ende">
<div className="wk-seite__aktionen">
<Link to="/" className="wk-btn wk-btn--secondary">
Zur Startseite
</Link>
<Link to="/">
<Button
variant="ghost"
className="text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Zurück
</Button>
<Link to="/kontakt" className="wk-btn wk-btn--primary">
Kontakt
</Link>
</div>
</div>
</header>
{/* Main Content */}
<main className="pt-32 pb-24">
<div className="container mx-auto px-6">
<div className="max-w-3xl mx-auto">
{/* Page Header */}
<div className="mb-12">
<div className="label-tag mb-4 flex items-center gap-2">
<FileText className="w-4 h-4" />
Vertrag
</div>
<h1 className="text-4xl md:text-5xl font-display font-medium text-foreground tracking-tight uppercase mb-2">
Kaufvertrag – WEBklar
</h1>
<p className="text-muted-foreground text-lg mb-8">
(Modularer Projektvertrag)
</p>
<div className="text-muted-foreground space-y-1">
<p><strong className="text-foreground">zwischen</strong></p>
<p>WEBklar<br /><span className="text-sm">(im Folgenden „Anbieter“)</span></p>
<p className="pt-2"><strong className="text-foreground">und</strong></p>
<p>Kunde laut Angebot<br /><span className="text-sm">(im Folgenden „Kunde“)</span></p>
</div>
</div>
{/* Contract Content */}
<article className="space-y-8 text-foreground">
{/* 1. Vertragsgegenstand */}
<section>
<h2 className="text-lg font-display font-medium uppercase tracking-tight text-foreground mb-4">
1. Vertragsgegenstand
</h2>
<ol className="list-decimal list-inside space-y-2 text-muted-foreground leading-relaxed [&>li]:pl-2">
<li>Gegenstand dieses Vertrages ist die Erbringung der im Angebot definierten Leistungen.</li>
<li>Der Vertrag besteht aus diesem Grundvertrag sowie den ausgewählten Leistungsmodulen.</li>
<li>Maßgeblich ist das jeweils angenommene Angebot von WEBklar.</li>
</ol>
</section>
{contractDivider}
{/* 2. Leistungsart */}
<section>
<h2 className="text-lg font-display font-medium uppercase tracking-tight text-foreground mb-4">
2. Leistungsart
</h2>
<ol className="list-decimal list-inside space-y-2 text-muted-foreground leading-relaxed [&>li]:pl-2">
<li>Sämtliche Leistungen von WEBklar stellen Dienst- und Entwicklungsleistungen dar.</li>
<li>Ein bestimmter wirtschaftlicher, technischer oder rechtlicher Erfolg wird nicht geschuldet.</li>
<li>WEBklar erbringt keinen laufenden Betrieb, sofern dieser nicht explizit vereinbart wurde.</li>
</ol>
</section>
{contractDivider}
{/* 3. Leistungsmodul A – Webseite */}
<section>
<h2 className="text-lg font-display font-medium uppercase tracking-tight text-foreground mb-2">
3. Leistungsmodul A – Webseite (einmalig)
</h2>
<p className="text-sm text-muted-foreground mb-4">(aktiv, wenn im Angebot enthalten)</p>
<ol className="list-decimal list-inside space-y-2 text-muted-foreground leading-relaxed [&>li]:pl-2">
<li>WEBklar erstellt eine individuelle Webseite gemäß Angebot.</li>
<li>Die Umsetzung erfolgt nach den vom Kunden gelieferten Inhalten und Vorgaben.</li>
<li>Zusätzliche Leistungen wie Domain, Hosting, Wartung oder SEO sind nicht Bestandteil, sofern sie nicht gesondert beauftragt wurden.</li>
<li>Der Kunde ist nicht berechtigt, Änderungen am Quellcode selbst vorzunehmen.</li>
<li>Änderungen erfolgen ausschließlich durch WEBklar gegen gesonderte Vergütung.</li>
</ol>
</section>
{contractDivider}
{/* 4. Leistungsmodul B – Automatisierung / Virtualisierung */}
<section>
<h2 className="text-lg font-display font-medium uppercase tracking-tight text-foreground mb-2">
4. Leistungsmodul B – Automatisierung / Virtualisierung (einmalig)
</h2>
<p className="text-sm text-muted-foreground mb-4">(aktiv, wenn im Angebot enthalten)</p>
<ol className="list-decimal list-inside space-y-2 text-muted-foreground leading-relaxed [&>li]:pl-2">
<li>WEBklar entwickelt individuelle Automatisierungen, Apps oder Virtualisierungssysteme.</li>
<li>Die Leistung stellt eine reine Entwicklungsleistung dar.</li>
<li>Optional kann eine Beratungsleistung Bestandteil des Projektes sein.</li>
<li>Ein laufender Betrieb, Monitoring oder Wartung ist nicht geschuldet, außer dies wurde explizit vereinbart.</li>
<li>Der Kunde entscheidet über Inhalte, Daten und Prozesse und trägt dafür die rechtliche Verantwortung.</li>
</ol>
</section>
{contractDivider}
{/* 5. Leistungsmodul C – Hosting */}
<section>
<h2 className="text-lg font-display font-medium uppercase tracking-tight text-foreground mb-2">
5. Leistungsmodul C – Hosting
</h2>
<p className="text-sm text-muted-foreground mb-4">(aktiv, wenn im Angebot enthalten)</p>
<ol className="list-decimal list-inside space-y-2 text-muted-foreground leading-relaxed [&>li]:pl-2">
<li>
Hosting bietet WEBklar als Hosting-Abo an. Für das Hosting-Abo und das Wartungs-Abo gelten die{" "}
<a href="https://project.webklar.com/abo-bedingungen.html" className="text-foreground underline hover:no-underline">Vertragsbedingungen für die WEBklar-Abos</a>
{" "}mit Leistungsbeschreibungen; sie gehen diesen AGB vor.
</li>
<li>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.</li>
<li>WEBklar ist berechtigt, externe Anbieter (z. B. Rechenzentren) einzusetzen.</li>
</ol>
</section>
{contractDivider}
{/* 6. Quellcode und Eigentum */}
<section>
<h2 className="text-lg font-display font-medium uppercase tracking-tight text-foreground mb-4">
6. Quellcode und Eigentum
</h2>
<div className="space-y-4 text-muted-foreground">
<div>
<h3 className="text-foreground font-medium mb-2">6.1 Webseite</h3>
<ol className="list-decimal list-inside space-y-2 leading-relaxed [&>li]:pl-2">
<li>Der Kunde erhält ein einfaches, zeitlich unbegrenztes Nutzungsrecht an der fertigen Webseite.</li>
<li>Der Quellcode der Webseite wird nur auf ausdrückliche Anfrage und nach Vereinbarung herausgegeben.</li>
<li>Ohne Vereinbarung verbleibt der Quellcode bei WEBklar und wird archiviert.</li>
</ol>
</div>
<div>
<h3 className="text-foreground font-medium mb-2">6.2 Apps, Automatisierungen und Backend</h3>
<ol className="list-decimal list-inside space-y-2 leading-relaxed [&>li]:pl-2">
<li>Der Quellcode von Apps, Automatisierungen und Backend-Systemen verbleibt vollständig bei WEBklar.</li>
<li>Eine Herausgabe erfolgt ausschließlich nach gesonderter schriftlicher Vereinbarung.</li>
<li>Der Kunde erhält lediglich Zugriff auf die Bedienoberfläche bzw. das Frontend.</li>
</ol>
</div>
</div>
</section>
{contractDivider}
{/* 7. Mitwirkungspflichten */}
<section>
<h2 className="text-lg font-display font-medium uppercase tracking-tight text-foreground mb-4">
7. Mitwirkungspflichten des Kunden
</h2>
<ol className="list-decimal list-inside space-y-2 text-muted-foreground leading-relaxed [&>li]:pl-2">
<li>Der Kunde stellt alle benötigten Inhalte, Daten und Freigaben rechtzeitig bereit.</li>
<li>Verzögerungen durch fehlende Mitwirkung gehen nicht zu Lasten von WEBklar.</li>
<li>WEBklar ist nicht verpflichtet, rechtliche Prüfungen der Inhalte vorzunehmen.</li>
</ol>
</section>
{contractDivider}
{/* 8. Abnahme */}
<section>
<h2 className="text-lg font-display font-medium uppercase tracking-tight text-foreground mb-4">
8. Abnahme
</h2>
<ol className="list-decimal list-inside space-y-2 text-muted-foreground leading-relaxed [&>li]:pl-2">
<li>Nach Fertigstellung wird dem Kunden die Leistung zur Abnahme bereitgestellt.</li>
<li>Erfolgt innerhalb von 14 Tagen keine Rückmeldung, gilt die Leistung als abgenommen.</li>
<li>Nach Abnahme sind nur noch kostenpflichtige Änderungen möglich.</li>
</ol>
</section>
{contractDivider}
{/* 9. Vergütung und Zahlung */}
<section>
<h2 className="text-lg font-display font-medium uppercase tracking-tight text-foreground mb-4">
9. Vergütung und Zahlung
</h2>
<ol className="list-decimal list-inside space-y-2 text-muted-foreground leading-relaxed [&>li]:pl-2">
<li>Die Vergütung ergibt sich aus dem Angebot.</li>
<li>
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.
</li>
<li>Projektleistungen sind nach Vereinbarung fällig.</li>
<li>Für die Abos gelten die Vertragsbedingungen für die WEBklar-Abos.</li>
</ol>
</section>
{contractDivider}
{/* 10. Zahlungsverzug */}
<section>
<h2 className="text-lg font-display font-medium uppercase tracking-tight text-foreground mb-4">
10. Zahlungsverzug
</h2>
<ol className="list-decimal list-inside space-y-2 text-muted-foreground leading-relaxed [&>li]:pl-2">
<li>Bei Zahlungsverzug erfolgen bis zu zwei Mahnungen.</li>
<li>Danach ist WEBklar berechtigt:
<ul className="list-disc list-inside mt-2 space-y-1 pl-2">
<li>Leistungen zu sperren</li>
<li>Verzugszinsen zu berechnen</li>
<li>den Vertrag außerordentlich zu kündigen</li>
</ul>
</li>
</ol>
</section>
{contractDivider}
{/* 11. Haftung */}
<section>
<h2 className="text-lg font-display font-medium uppercase tracking-tight text-foreground mb-4">
11. Haftung
</h2>
<ol className="list-decimal list-inside space-y-2 text-muted-foreground leading-relaxed [&>li]:pl-2">
<li>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.</li>
<li>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.</li>
<li>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.</li>
<li>Diese Regeln gelten auch zugunsten der Mitarbeiter und Beauftragten von WEBklar.</li>
</ol>
</section>
{contractDivider}
{/* 12. Kündigung */}
<section>
<h2 className="text-lg font-display font-medium uppercase tracking-tight text-foreground mb-4">
12. Kündigung
</h2>
<ol className="list-decimal list-inside space-y-2 text-muted-foreground leading-relaxed [&>li]:pl-2">
<li>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.</li>
<li>Das Recht zur außerordentlichen Kündigung bleibt unberührt.</li>
</ol>
</section>
{contractDivider}
{/* 13. Referenzen */}
<section>
<h2 className="text-lg font-display font-medium uppercase tracking-tight text-foreground mb-4">
13. Referenzen
</h2>
<p className="text-muted-foreground leading-relaxed">
WEBklar darf das Projekt nur nach ausdrücklicher Zustimmung des Kunden als Referenz verwenden.
</p>
</section>
{contractDivider}
{/* 14. Schlussbestimmungen */}
<section>
<h2 className="text-lg font-display font-medium uppercase tracking-tight text-foreground mb-4">
14. Schlussbestimmungen
</h2>
<ol className="list-decimal list-inside space-y-2 text-muted-foreground leading-relaxed [&>li]:pl-2">
<li>Es gilt deutsches Recht.</li>
<li>Gerichtsstand ist der Sitz von WEBklar, soweit zulässig.</li>
<li>Sollten einzelne Bestimmungen unwirksam sein, bleibt der Vertrag im Übrigen wirksam.</li>
</ol>
</section>
</article>
{/* Back / Contact */}
<div className="mt-16 pt-12 border-t border-border flex flex-wrap gap-4">
<Link to="/">
<Button variant="outline" className="rounded-full">
Zur Startseite
</Button>
</Link>
<Link to="/kontakt">
<Button className="btn-minimal rounded-full">
Kontakt
</Button>
</Link>
</div>
</div>
</div>
</main>
</div>
</div>
);
};
+79 -170
View File
@@ -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 (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="fixed top-0 left-0 right-0 z-50 glass-nav py-4">
<div className="container mx-auto px-6">
<div className="flex items-center justify-between">
<Link to="/" className="flex items-center gap-2 group">
<span className="text-xl font-display font-medium text-foreground tracking-normal">
WEBklar
</span>
<>
<div className="wk-seite">
<div className="wk-container">
<div className="wk-seite__kopf">
<span className="wk-eyebrow">Über WEBklar</span>
<h1 className="wk-seite__titel">Digitalisierung, die im Alltag spürbar wird.</h1>
</div>
<div className="wk-ueber__text">
<p>
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.
</p>
<p>
Wir verbinden <strong>Website, Prozesse und Automatisierung</strong> zu einem System. Kein
isoliertes Redesign, kein weiteres Plugin – sondern eine Lösung, die Anfragen, Daten und Abläufe
zusammenführt.
</p>
</div>
<div className="wk-ueber__aussage">
<p>Unser Anspruch: weniger Reibung.</p>
<p>Mehr planbares Wachstum.</p>
</div>
<p className="wk-ueber__schluss">
Starten Sie mit einem kostenlosen Gespräch – wir zeigen Ihnen die nächsten Schritte.
</p>
<div className="wk-seite__aktionen">
<Link to="/kontakt" className="wk-btn wk-btn--primary">
Projekt besprechen
</Link>
<Link to="/">
<Button
variant="ghost"
className="text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Zurück
</Button>
<Link to="/" className="wk-btn wk-btn--secondary">
Zur Startseite
</Link>
</div>
</div>
</header>
</div>
{/* Main Content */}
<main className="pt-32 pb-24">
<div className="container mx-auto px-6">
<div className="max-w-4xl mx-auto text-center">
<div className="label-tag mb-4">Über WEBklar</div>
{/* Der Unterschied */}
<section className="wk-section wk-section--alt" aria-labelledby="unterschied-titel">
<div className="wk-container">
<span className="wk-eyebrow">Der Unterschied</span>
<h2 id="unterschied-titel" className="wk-h2">
Warum Unternehmen zu uns wechseln.
</h2>
<h1 className="text-4xl md:text-5xl lg:text-6xl font-display font-medium text-foreground tracking-tight uppercase mb-12">
Digitalisierung, die im Alltag spürbar wird.
</h1>
<div className="space-y-8 text-lg md:text-xl text-muted-foreground leading-relaxed">
<p>
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.
</p>
<p>
Wir verbinden{" "}
<span className="text-foreground font-medium">
Website, Prozesse und Automatisierung
</span>{" "}
zu einem System. Kein isoliertes Redesign, kein weiteres Plugin – sondern
eine Lösung, die Anfragen, Daten und Abläufe zusammenführt.
</p>
<div className="pt-8 border-t border-border">
<p className="text-2xl md:text-3xl text-foreground font-display font-medium uppercase tracking-tight">
Unser Anspruch: weniger Reibung.
<ol className="wk-unterschied" role="list">
{UNTERSCHIED.map((punkt) => (
<li key={punkt.nr}>
<p className="wk-unterschied__nr" aria-hidden>
{punkt.nr}
</p>
<p className="text-2xl md:text-3xl text-muted-foreground font-display font-medium uppercase tracking-tight mt-2">
Mehr planbares Wachstum.
</p>
</div>
<h3 className="wk-h3">{punkt.titel}</h3>
<p>{punkt.text}</p>
</li>
))}
</ol>
<p className="text-foreground font-medium pt-4">
Starten Sie mit einer kostenlosen Potenzialanalyse – wir zeigen Ihnen die nächsten Schritte.
</p>
</div>
{/* Back / Contact */}
<div className="mt-16 pt-12 border-t border-border flex flex-wrap justify-center gap-4">
<Link to="/">
<Button variant="outline" className="rounded-full">
Zur Startseite
</Button>
</Link>
<Link to="/kontakt">
<Button className="btn-minimal rounded-full">
Potenzialanalyse starten
</Button>
</Link>
</div>
<div className="wk-seite__aktionen">
<Link to="/kontakt" className="wk-btn wk-btn--primary">
Projekt besprechen
</Link>
</div>
</div>
{/* Der Unterschied */}
<div className="mt-24 py-24 md:py-32 bg-secondary/20 relative">
<div className="container mx-auto px-6">
<div className="max-w-4xl mx-auto text-center">
<div className="label-tag mb-4">Der Unterschied</div>
<h2 className="text-4xl md:text-5xl lg:text-6xl font-display font-medium text-foreground tracking-tight uppercase mb-12">
Warum Unternehmen zu uns wechseln.
</h2>
<div className="grid md:grid-cols-3 gap-8 mb-12">
<BorderGlow
edgeSensitivity={30}
glowColor="40 80 80"
backgroundColor="hsl(0 0% 4%)"
lightBackgroundColor="hsl(0 0% 96%)"
borderRadius={8}
glowRadius={30}
glowIntensity={0.8}
coneSpread={25}
colors={['#c084fc', '#f472b6', '#38bdf8']}
>
<div className="p-6">
<div className="text-4xl font-display font-medium text-foreground mb-2">
<CountUp from={0} to={1} duration={1} padMinLength={2} startWhen={true} />
</div>
<h3 className="text-lg font-display font-medium text-foreground uppercase tracking-tight mb-2">
Alles aus einer Hand
</h3>
<p className="text-muted-foreground text-sm">
Website, Automation und Schnittstellen aus einer Hand – ein Ansprechpartner statt fünf Agenturen.
</p>
</div>
</BorderGlow>
<BorderGlow
edgeSensitivity={30}
glowColor="40 80 80"
backgroundColor="hsl(0 0% 4%)"
lightBackgroundColor="hsl(0 0% 96%)"
borderRadius={8}
glowRadius={30}
glowIntensity={0.8}
coneSpread={25}
colors={['#c084fc', '#f472b6', '#38bdf8']}
>
<div className="p-6">
<div className="text-4xl font-display font-medium text-foreground mb-2">
<CountUp from={0} to={2} duration={1} padMinLength={2} startWhen={true} />
</div>
<h3 className="text-lg font-display font-medium text-foreground uppercase tracking-tight mb-2">
Systeme statt Inseln
</h3>
<p className="text-muted-foreground text-sm">
CRM, Formulare und Website tauschen Daten – ohne manuelles Nachpflegen.
</p>
</div>
</BorderGlow>
<BorderGlow
edgeSensitivity={30}
glowColor="40 80 80"
backgroundColor="hsl(0 0% 4%)"
lightBackgroundColor="hsl(0 0% 96%)"
borderRadius={8}
glowRadius={30}
glowIntensity={0.8}
coneSpread={25}
colors={['#c084fc', '#f472b6', '#38bdf8']}
>
<div className="p-6">
<div className="text-4xl font-display font-medium text-foreground mb-2">
<CountUp from={0} to={3} duration={1} padMinLength={2} startWhen={true} />
</div>
<h3 className="text-lg font-display font-medium text-foreground uppercase tracking-tight mb-2">
Betreuung im Abo
</h3>
<p className="text-muted-foreground text-sm">
Nach dem Go-live kümmern wir uns im Wartungs-Abo um Updates und kleine Änderungen.
</p>
</div>
</BorderGlow>
</div>
<Link to="/kontakt">
<Button
size="lg"
className="btn-minimal rounded-full px-8 py-6 text-base font-medium group"
>
Potenzialanalyse starten
<ArrowRight className="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform" />
</Button>
</Link>
</div>
</div>
</div>
</main>
</div>
</section>
</>
);
};
+131 -171
View File
@@ -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<Rueckmeldung | null>(null);
const statusRef = useRef<HTMLDivElement>(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 (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="fixed top-0 left-0 right-0 z-50 glass-nav py-4">
<div className="container mx-auto px-6">
<div className="flex items-center justify-between">
<Link to="/" className="flex items-center gap-2 group">
<span className="text-xl font-display font-medium text-foreground tracking-normal">
WEBklar
</span>
</Link>
<Link to="/">
<Button
variant="ghost"
className="text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Zurück
</Button>
</Link>
</div>
<div className="wk-seite">
<div className="wk-container">
<div className="wk-seite__kopf">
<h1 className="wk-seite__titel wk-seite__titel--gross">Projekt besprechen</h1>
<p className="wk-seite__lead">
Zwei Sätze genügen. Wir melden uns spätestens am nächsten Werktag. Im ersten Gespräch klären wir
in&nbsp;15&nbsp;Minuten, ob und wie wir helfen können – kostenlos und unverbindlich.
</p>
</div>
</header>
{/* Main Content */}
<main className="pt-32 pb-24">
<div className="container mx-auto px-6">
<div className="max-w-2xl mx-auto">
{/* Header */}
<div className="mb-12">
<div className="label-tag mb-4">Kontakt</div>
<h1 className="text-4xl md:text-5xl lg:text-6xl font-display font-medium text-foreground tracking-tight uppercase mb-6">
Kostenlose
<br />
<span className="text-muted-foreground">Potenzialanalyse</span>
</h1>
<p className="text-muted-foreground text-lg">
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.
</p>
<div className="wk-kontakt-seite__raster">
<form onSubmit={handleSubmit} className="wk-form" aria-labelledby="kontakt-formular-titel">
<h2 id="kontakt-formular-titel" className="wk-visually-hidden">
Kontaktformular
</h2>
<div className="wk-form__paar">
<div className="wk-feld">
<label htmlFor="name">Name</label>
<input
id="name"
name="name"
type="text"
required
autoComplete="name"
value={formData.name}
onChange={handleChange}
placeholder="Ihr Name"
/>
</div>
<div className="wk-feld">
<label htmlFor="email">E-Mail</label>
<input
id="email"
name="email"
type="email"
required
autoComplete="email"
value={formData.email}
onChange={handleChange}
placeholder="ihre@email.de"
/>
</div>
</div>
{/* Contact Form */}
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="name" className="text-sm uppercase tracking-wider text-muted-foreground">
Name *
</Label>
<Input
id="name"
name="name"
type="text"
required
value={formData.name}
onChange={handleChange}
className="bg-card border-border focus:border-foreground transition-colors h-12"
placeholder="Ihr Name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email" className="text-sm uppercase tracking-wider text-muted-foreground">
E-Mail *
</Label>
<Input
id="email"
name="email"
type="email"
required
value={formData.email}
onChange={handleChange}
className="bg-card border-border focus:border-foreground transition-colors h-12"
placeholder="ihre@email.de"
/>
</div>
</div>
<div className="wk-feld">
<label htmlFor="company">
Unternehmen <span className="wk-feld__optional">(optional)</span>
</label>
<input
id="company"
name="company"
type="text"
autoComplete="organization"
value={formData.company}
onChange={handleChange}
placeholder="Name Ihres Betriebs"
/>
</div>
<div className="space-y-2">
<Label htmlFor="company" className="text-sm uppercase tracking-wider text-muted-foreground">
Unternehmen
</Label>
<Input
id="company"
name="company"
type="text"
value={formData.company}
onChange={handleChange}
className="bg-card border-border focus:border-foreground transition-colors h-12"
placeholder="Ihr Unternehmen (optional)"
/>
</div>
<div className="wk-feld">
<label htmlFor="message">Nachricht</label>
<textarea
id="message"
name="message"
required
value={formData.message}
onChange={handleChange}
placeholder="z. B. neue Website, Online-Shop, Web-App oder Automatisierung …"
/>
</div>
<div className="space-y-2">
<Label htmlFor="message" className="text-sm uppercase tracking-wider text-muted-foreground">
Nachricht *
</Label>
<Textarea
id="message"
name="message"
required
value={formData.message}
onChange={handleChange}
className="bg-card border-border focus:border-foreground transition-colors min-h-[180px] resize-none"
placeholder="z. B. neue Website, CRM-Anbindung, weniger manuelle Abläufe …"
/>
</div>
<div className="wk-form__senden">
<button type="submit" className="wk-btn wk-btn--primary" aria-disabled={isSubmitting}>
{isSubmitting ? "Wird gesendet …" : "Nachricht senden"}
</button>
</div>
<Button
type="submit"
disabled={isSubmitting}
className="btn-minimal rounded-full px-8 py-6 text-base font-medium group w-full md:w-auto"
{/* Rückmeldung für Screenreader: der Bereich steht immer im Dokument, nur der Inhalt wechselt */}
<div ref={statusRef} className="wk-form__status" aria-live="polite" aria-atomic="true" data-art={rueckmeldung?.art}>
{rueckmeldung && (
<>
<strong>{rueckmeldung.titel}</strong>
<span>{rueckmeldung.text}</span>
</>
)}
</div>
<p className="wk-form__datenschutz wk-muted wk-small">
Wir verwenden Ihre Angaben nur, um Ihre Anfrage zu beantworten. Mehr dazu in unserer{" "}
<a
href="https://project.webklar.com/datenschutz.html"
target="_blank"
rel="noopener noreferrer"
>
{isSubmitting ? (
"Wird gesendet..."
) : (
<>
Nachricht senden
<Send className="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform" />
</>
)}
</Button>
Datenschutzerklärung
</a>
.
</p>
</form>
<p className="text-sm leading-relaxed text-muted-foreground">
Wir verwenden Ihre Angaben nur, um Ihre Anfrage zu beantworten. Mehr dazu in unserer{" "}
<a
href="https://project.webklar.com/datenschutz.html"
target="_blank"
rel="noopener noreferrer"
className="text-foreground underline underline-offset-4 hover:no-underline"
>
Datenschutzerklärung
</a>
.
</p>
</form>
{/* Contact Info */}
<div className="mt-16 pt-12 border-t border-border">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div>
<div className="label-tag mb-2">E-Mail</div>
<a
href="mailto:support@webklar.com"
className="text-foreground hover:text-muted-foreground transition-colors"
>
support@webklar.com
</a>
</div>
<div>
<div className="label-tag mb-2">Telefon</div>
<a
href="tel:+4917623726355"
className="text-foreground hover:text-muted-foreground transition-colors"
>
<div className="wk-direkt">
<h2>Direkt erreichen</h2>
<dl>
<div>
<dt>E-Mail</dt>
<dd>
<a href="mailto:support@webklar.com">support@webklar.com</a>
</dd>
</div>
<div>
<dt>Telefon</dt>
<dd>
<a href="tel:+4917623726355" className="wk-num">
0176 23726355
</a>
</div>
</dd>
</div>
</div>
</dl>
</div>
</div>
</main>
</div>
</div>
);
};
+119 -157
View File
@@ -1,14 +1,10 @@
import { Link } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { ArrowLeft, Server } from "lucide-react";
import { usePageMeta } from "@/hooks/use-page-meta";
import "./unterseiten.css";
const PORTAL = "https://project.webklar.com";
const STEUER = "Endpreis, gemäß § 19 UStG ohne Umsatzsteuer";
const divider = <div className="my-8 border-t border-border" aria-hidden />;
const linkClass = "text-foreground underline hover:no-underline";
const leistungen = [
{
titel: "Bereitstellung Ihrer Website",
@@ -35,170 +31,136 @@ const HostingAbo = () => {
);
return (
<div className="min-h-screen bg-background">
<header className="fixed top-0 left-0 right-0 z-50 glass-nav py-4">
<div className="container mx-auto px-6">
<div className="flex items-center justify-between">
<Link to="/" className="flex items-center gap-2 group">
<span className="text-xl font-display font-medium text-foreground tracking-normal">WEBklar</span>
</Link>
<Button asChild variant="ghost" className="text-muted-foreground hover:text-foreground">
<Link to="/">
<ArrowLeft className="w-4 h-4 mr-2" />
Zurück
</Link>
</Button>
</div>
<div className="wk-seite">
<div className="wk-container">
<div className="wk-seite__kopf">
<span className="wk-eyebrow">Leistungen</span>
<h1 className="wk-seite__titel">Hosting-Abo</h1>
<p className="wk-seite__lead">
Ihre statische Website auf unserem Server in Deutschland: 20,00 € pro Monat, 12 Monate Mindestlaufzeit.
</p>
<p className="wk-seite__hinweis">Alle Preise sind Endpreise, gemäß § 19 UStG ohne Umsatzsteuer.</p>
</div>
</header>
<main className="pt-32 pb-24">
<div className="container mx-auto px-6">
<div className="max-w-3xl mx-auto">
<div className="mb-12">
<div className="label-tag mb-4 flex items-center gap-2">
<Server className="w-4 h-4" />
Leistungen
</div>
<h1 className="text-4xl md:text-5xl font-display font-medium text-foreground tracking-tight uppercase mb-2">
Hosting-Abo
</h1>
<p className="text-muted-foreground text-lg">
Ihre statische Website auf unserem Server in Deutschland: 20,00 € pro Monat, 12 Monate Mindestlaufzeit.
<article className="wk-text">
<section>
<h2>Das ist enthalten</h2>
<ul className="wk-merkmale">
{leistungen.map((l) => (
<li key={l.titel}>
<p className="wk-merkmale__titel">{l.titel}</p>
<p className="wk-merkmale__text">{l.text}</p>
</li>
))}
</ul>
</section>
<section>
<h2>Nur für statische Websites</h2>
<div>
<p>
Das Hosting-Abo ist für statische Websites bestimmt, die wir erstellt oder zur Betreuung übernommen haben.
Statisch heißt: Der Webserver liefert die Dateien Ihrer Website (HTML, CSS, JavaScript, Bilder) unverändert
aus. Dazu gehören auch die React- und Vite-Websites, die wir erstellen.
</p>
<p>
Nicht möglich sind ein Content-Management-System, ein Login, eine Datenbank und eigener serverseitiger Code
(etwa PHP). Kontakt über E-Mail-Links auf der Website ist möglich. Formulare, die Eingaben an einen Server
übermitteln, sind nicht enthalten; eine Lösung dafür bieten wir auf Anfrage gesondert an.
</p>
<p>
Updates und Pflege der Website selbst sind nicht enthalten; bei statischen Websites sind sie für den sicheren
Betrieb in der Regel nicht nötig. Braucht Ihre Website dynamische Teile oder wünschen Sie laufende Pflege und
eine Stunde Arbeitszeit pro Monat, ist unser{" "}
<Link to="/wartungs-abo">Wartungs-Abo</Link> das Richtige.
</p>
<p className="text-muted-foreground text-sm mt-1">Alle Preise sind Endpreise, gemäß § 19 UStG ohne Umsatzsteuer.</p>
</div>
</section>
<article className="space-y-8 text-foreground">
<section>
<h2 className="text-lg font-display font-medium uppercase tracking-tight mb-4">Das ist enthalten</h2>
<ul className="grid gap-5 sm:grid-cols-2">
{leistungen.map((l) => (
<li key={l.titel} className="rounded-2xl border border-border p-5">
<p className="font-medium mb-1">{l.titel}</p>
<p className="text-muted-foreground text-sm leading-relaxed">{l.text}</p>
</li>
))}
</ul>
</section>
{divider}
<section>
<h2>Preis und Laufzeit</h2>
<ul>
<li>20,00 € pro Monat, monatlich im Voraus. {STEUER}.</li>
<li>Mindestlaufzeit 12 Monate, kündbar zum Ende der Mindestlaufzeit bis einen Monat vorher; danach jederzeit zum Ende des Abrechnungsmonats. Bei Geschäftsaufgabe Sonderkündigung mit einem Monat Frist.</li>
<li>Zahlung per Kreditkarte oder SEPA-Lastschrift über unseren Zahlungsdienstleister Stripe.</li>
<li>Ein Stundenkontingent ist nicht enthalten. Änderungen an Ihrer Website und andere Aufträge führen wir nur nach Ihrer Freigabe aus: 100 € pro Stunde ({STEUER}), abgerechnet in 15-Minuten-Schritten. Vorher nennen wir Ihnen den geschätzten Aufwand.</li>
</ul>
</section>
<section>
<h2 className="text-lg font-display font-medium uppercase tracking-tight mb-3">Nur für statische Websites</h2>
<div className="text-muted-foreground leading-relaxed space-y-3">
<p>
Das Hosting-Abo ist für statische Websites bestimmt, die wir erstellt oder zur Betreuung übernommen haben.
Statisch heißt: Der Webserver liefert die Dateien Ihrer Website (HTML, CSS, JavaScript, Bilder) unverändert
aus. Dazu gehören auch die React- und Vite-Websites, die wir erstellen.
</p>
<p>
Nicht möglich sind ein Content-Management-System, ein Login, eine Datenbank und eigener serverseitiger Code
(etwa PHP). Kontakt über E-Mail-Links auf der Website ist möglich. Formulare, die Eingaben an einen Server
übermitteln, sind nicht enthalten; eine Lösung dafür bieten wir auf Anfrage gesondert an.
</p>
<p>
Updates und Pflege der Website selbst sind nicht enthalten; bei statischen Websites sind sie für den sicheren
Betrieb in der Regel nicht nötig. Braucht Ihre Website dynamische Teile oder wünschen Sie laufende Pflege und
eine Stunde Arbeitszeit pro Monat, ist unser{" "}
<Link to="/wartungs-abo" className={linkClass}>Wartungs-Abo</Link> das Richtige.
</p>
</div>
</section>
{divider}
<section>
<h2>E-Mail-Zusatzspeicher</h2>
<p>
Brauchen Sie mehr Platz für E-Mails, buchen Sie im Kundenportal unter „Verträge“ Zusatzspeicher: Pakete von je
5 GB für 1,00 € pro Monat ({STEUER}), bis zu 10 Pakete. Bestellen und verringern können Sie die Pakete dort,
sobald Ihr Abo begonnen hat, solange es nicht gekündigt ist und keine Zahlung offen ist. Eine Verringerung wirkt
zum Ende des Abrechnungsmonats. Einzelheiten:{" "}
<a href={`${PORTAL}/abo-bedingungen.html#zusatzspeicher`}>§ 10 der Vertragsbedingungen</a>.
</p>
</section>
<section>
<h2 className="text-lg font-display font-medium uppercase tracking-tight mb-3">Preis und Laufzeit</h2>
<ul className="text-muted-foreground leading-relaxed space-y-1 list-disc pl-5">
<li>20,00 € pro Monat, monatlich im Voraus. {STEUER}.</li>
<li>Mindestlaufzeit 12 Monate, kündbar zum Ende der Mindestlaufzeit bis einen Monat vorher; danach jederzeit zum Ende des Abrechnungsmonats. Bei Geschäftsaufgabe Sonderkündigung mit einem Monat Frist.</li>
<li>Zahlung per Kreditkarte oder SEPA-Lastschrift über unseren Zahlungsdienstleister Stripe.</li>
<li>Ein Stundenkontingent ist nicht enthalten. Änderungen an Ihrer Website und andere Aufträge führen wir nur nach Ihrer Freigabe aus: 100 € pro Stunde ({STEUER}), abgerechnet in 15-Minuten-Schritten. Vorher nennen wir Ihnen den geschätzten Aufwand.</li>
</ul>
</section>
{divider}
<section>
<h2>Wechsel ins Wartungs-Abo</h2>
<p>
Braucht Ihre Website später dynamische Teile oder möchten Sie Pflege und Arbeitszeit dazubuchen, wechseln Sie
im Kundenportal unter „Verträge“ ins{" "}
<Link to="/wartungs-abo">Wartungs-Abo</Link>, auch während der Mindestlaufzeit: sobald
Ihr Hosting-Abo begonnen hat, solange es nicht gekündigt ist und keine Zahlung offen ist. Mit dem Wechsel
beginnt eine neue Mindestlaufzeit von 12 Monaten. Einzelheiten:{" "}
<a href={`${PORTAL}/abo-bedingungen.html#wechsel`}>§ 9 der Vertragsbedingungen</a>.
</p>
</section>
<section>
<h2 className="text-lg font-display font-medium uppercase tracking-tight mb-3">E-Mail-Zusatzspeicher</h2>
<p className="text-muted-foreground leading-relaxed">
Brauchen Sie mehr Platz für E-Mails, buchen Sie im Kundenportal unter „Verträge“ Zusatzspeicher: Pakete von je
5 GB für 1,00 € pro Monat ({STEUER}), bis zu 10 Pakete. Bestellen und verringern können Sie die Pakete dort,
sobald Ihr Abo begonnen hat, solange es nicht gekündigt ist und keine Zahlung offen ist. Eine Verringerung wirkt
zum Ende des Abrechnungsmonats. Einzelheiten:{" "}
<a href={`${PORTAL}/abo-bedingungen.html#zusatzspeicher`} className={linkClass}>§ 10 der Vertragsbedingungen</a>.
</p>
</section>
{divider}
<section>
<h2>So schließen Sie das Abo ab</h2>
<p>
Das Hosting-Abo schließen Sie in unserem Kundenportal ab. Den Zugang richten wir Ihnen ein, sprechen Sie uns
einfach an. Nach der Anmeldung finden Sie das Angebot im Bereich „Verträge“. Im Kundenportal sehen Sie danach
jederzeit Ihre Verträge und Rechnungen, buchen Zusatzspeicher und schicken uns Aufträge über den Chat.
</p>
</section>
<section>
<h2 className="text-lg font-display font-medium uppercase tracking-tight mb-3">Wechsel ins Wartungs-Abo</h2>
<p className="text-muted-foreground leading-relaxed">
Braucht Ihre Website später dynamische Teile oder möchten Sie Pflege und Arbeitszeit dazubuchen, wechseln Sie
im Kundenportal unter „Verträge“ ins{" "}
<Link to="/wartungs-abo" className={linkClass}>Wartungs-Abo</Link>, auch während der Mindestlaufzeit: sobald
Ihr Hosting-Abo begonnen hat, solange es nicht gekündigt ist und keine Zahlung offen ist. Mit dem Wechsel
beginnt eine neue Mindestlaufzeit von 12 Monaten. Einzelheiten:{" "}
<a href={`${PORTAL}/abo-bedingungen.html#wechsel`} className={linkClass}>§ 9 der Vertragsbedingungen</a>.
</p>
</section>
{divider}
<section>
<h2 className="text-lg font-display font-medium uppercase tracking-tight mb-3">So schließen Sie das Abo ab</h2>
<p className="text-muted-foreground leading-relaxed">
Das Hosting-Abo schließen Sie in unserem Kundenportal ab. Den Zugang richten wir Ihnen ein, sprechen Sie uns
einfach an. Nach der Anmeldung finden Sie das Angebot im Bereich „Verträge“. Im Kundenportal sehen Sie danach
jederzeit Ihre Verträge und Rechnungen, buchen Zusatzspeicher und schicken uns Aufträge über den Chat.
</p>
</section>
{divider}
<section>
<h2 className="text-lg font-display font-medium uppercase tracking-tight mb-3">Widerruf und Kündigung</h2>
<p className="text-muted-foreground leading-relaxed">
Verbraucher können jede Bestellung innerhalb von 14 Tagen ab Vertragsschluss widerrufen: den Abschluss des
Abos, den Wechsel ins Wartungs-Abo und die Bestellung von Zusatzspeicher. Bereits gezahlte Beträge erstatten
wir dann nach der{" "}
<a href={`${PORTAL}/abo-bedingungen.html#widerruf`} className={linkClass}>Widerrufsbelehrung</a>.
Kündigen können Sie online ohne Anmeldung oder per E-Mail.
</p>
<div className="mt-4 flex flex-wrap gap-3">
<Button asChild variant="outline" className="rounded-full">
<a href={`${PORTAL}/kuendigen.html`}>Verträge hier kündigen</a>
</Button>
<Button asChild variant="outline" className="rounded-full">
<a href={`${PORTAL}/widerrufen.html`}>Vertrag widerrufen</a>
</Button>
</div>
</section>
{divider}
<section>
<h2 className="text-lg font-display font-medium uppercase tracking-tight mb-3">Unterlagen und Kontakt</h2>
<p className="text-muted-foreground leading-relaxed">
<a href={`${PORTAL}/abo-bedingungen.html`} className={linkClass}>Vertragsbedingungen für die WEBklar-Abos</a>
{" · "}
<a href={`${PORTAL}/abo-bedingungen.html#hosting`} className={linkClass}>Leistungsbeschreibung A (Hosting-Abo)</a>
{" · "}
<a href={`${PORTAL}/WEBklar-Abo-Vertragsunterlagen.pdf`} className={linkClass}>Vertragsunterlagen als PDF</a>
{" · "}
<a href={`${PORTAL}/datenschutz.html`} className={linkClass}>Datenschutz</a>
<br />
WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler · Telefon 0176 23726355 · E-Mail support@webklar.com
</p>
</section>
</article>
<div className="mt-16 pt-12 border-t border-border flex flex-wrap gap-4">
<Button asChild className="btn-minimal rounded-full">
<a href={`${PORTAL}/login.html`}>Zum Kundenportal</a>
</Button>
<Button asChild variant="outline" className="rounded-full">
<Link to="/kontakt">Kontakt aufnehmen</Link>
</Button>
<section>
<h2>Widerruf und Kündigung</h2>
<p>
Verbraucher können jede Bestellung innerhalb von 14 Tagen ab Vertragsschluss widerrufen: den Abschluss des
Abos, den Wechsel ins Wartungs-Abo und die Bestellung von Zusatzspeicher. Bereits gezahlte Beträge erstatten
wir dann nach der{" "}
<a href={`${PORTAL}/abo-bedingungen.html#widerruf`}>Widerrufsbelehrung</a>.
Kündigen können Sie online ohne Anmeldung oder per E-Mail.
</p>
<div className="wk-seite__aktionen">
<a href={`${PORTAL}/kuendigen.html`} className="wk-btn wk-btn--secondary">Verträge hier kündigen</a>
<a href={`${PORTAL}/widerrufen.html`} className="wk-btn wk-btn--secondary">Vertrag widerrufen</a>
</div>
</section>
<section>
<h2>Unterlagen und Kontakt</h2>
<p>
<a href={`${PORTAL}/abo-bedingungen.html`}>Vertragsbedingungen für die WEBklar-Abos</a>
{" · "}
<a href={`${PORTAL}/abo-bedingungen.html#hosting`}>Leistungsbeschreibung A (Hosting-Abo)</a>
{" · "}
<a href={`${PORTAL}/WEBklar-Abo-Vertragsunterlagen.pdf`}>Vertragsunterlagen als PDF</a>
{" · "}
<a href={`${PORTAL}/datenschutz.html`}>Datenschutz</a>
<br />
WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler · Telefon 0176 23726355 · E-Mail support@webklar.com
</p>
</section>
</article>
<div className="wk-seite__ende">
<div className="wk-seite__aktionen">
<a href={`${PORTAL}/login.html`} className="wk-btn wk-btn--primary">
Zum Kundenportal
</a>
<Link to="/kontakt" className="wk-btn wk-btn--secondary">
Kontakt aufnehmen
</Link>
</div>
</div>
</main>
</div>
</div>
);
};
+102 -172
View File
@@ -1,186 +1,116 @@
import { Link } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { ArrowLeft, Scale } from "lucide-react";
import { usePageMeta } from "@/hooks/use-page-meta";
const impressumDivider = (
<div className="my-8 border-t border-border" aria-hidden />
);
import "./unterseiten.css";
const Impressum = () => {
usePageMeta("Impressum", "Impressum der WEBklar GbR, Winnweiler.");
return (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="fixed top-0 left-0 right-0 z-50 glass-nav py-4">
<div className="container mx-auto px-6">
<div className="flex items-center justify-between">
<Link to="/" className="flex items-center gap-2 group">
<span className="text-xl font-display font-medium text-foreground tracking-normal">
WEBklar
</span>
<div className="wk-seite">
<div className="wk-container">
{/* Page Header */}
<div className="wk-seite__kopf">
<span className="wk-eyebrow">Rechtliches</span>
<h1 className="wk-seite__titel">Impressum</h1>
<p className="wk-seite__lead">Angaben gemäß § 5 DDG</p>
</div>
{/* Impressum Content */}
<article className="wk-text">
<section>
<h2>WEBklar GbR</h2>
<p>
Am Schwimmbad 10<br />
67722 Winnweiler<br />
Deutschland
</p>
</section>
<section>
<h2>Vertreten durch</h2>
<p>
die Gesellschafter Kenso Grimm und Justin Klein<br />
(jeder einzeln vertretungsberechtigt)
</p>
</section>
<section>
<h2>Kontakt</h2>
<p>
Telefon: 0176 23726355<br />
E-Mail: support@webklar.com
</p>
</section>
<section>
<h2>Rechtsform</h2>
<p>
Gesellschaft bürgerlichen Rechts (GbR)
</p>
</section>
<section>
<h2>Umsatzsteuer</h2>
<p>
Gemäß § 19 UStG wird keine Umsatzsteuer erhoben (Kleinunternehmerregelung).
</p>
</section>
<section>
<h2>Haftung für Inhalte</h2>
<p>
Für eigene Inhalte auf diesen Seiten sind wir nach den allgemeinen Gesetzen verantwortlich.<br />
Für fremde Informationen, die wir lediglich übermitteln oder speichern, gelten die Haftungsbeschränkungen der Artikel 4 bis 6 der Verordnung (EU) 2022/2065 (Gesetz über digitale Dienste). Wir sind nicht verpflichtet, solche Informationen zu überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen.
</p>
</section>
<section>
<h2>Haftung für Links</h2>
<p>
Unsere Webseite enthält Links zu externen Webseiten Dritter, auf deren Inhalte wir keinen Einfluss haben.<br />
Deshalb können wir für diese fremden Inhalte auch keine Gewähr übernehmen.<br />
Für die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der Seiten verantwortlich.
</p>
</section>
<section>
<h2>Urheberrecht</h2>
<p>
Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem deutschen Urheberrecht.<br />
Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen der schriftlichen Zustimmung des jeweiligen Autors bzw. Erstellers.
</p>
</section>
<section>
<h2>Verbraucherstreitbeilegung</h2>
<p>
Wir sind nicht verpflichtet und nicht bereit, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.
</p>
</section>
<section>
<h2>Datenschutz</h2>
<p>
Informationen zur Verarbeitung personenbezogener Daten finden Sie in unserer{" "}
<a href="https://project.webklar.com/datenschutz.html">
Datenschutzerklärung
</a>.
</p>
</section>
</article>
{/* Back / Contact */}
<div className="wk-seite__ende">
<div className="wk-seite__aktionen">
<Link to="/" className="wk-btn wk-btn--secondary">
Zur Startseite
</Link>
<Link to="/">
<Button
variant="ghost"
className="text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Zurück
</Button>
<Link to="/kontakt" className="wk-btn wk-btn--primary">
Kontakt
</Link>
</div>
</div>
</header>
{/* Main Content */}
<main className="pt-32 pb-24">
<div className="container mx-auto px-6">
<div className="max-w-3xl mx-auto">
{/* Page Header */}
<div className="mb-12">
<div className="label-tag mb-4 flex items-center gap-2">
<Scale className="w-4 h-4" />
Rechtliches
</div>
<h1 className="text-4xl md:text-5xl font-display font-medium text-foreground tracking-tight uppercase mb-2">
Impressum
</h1>
<p className="text-muted-foreground text-lg">
Angaben gemäß § 5 DDG
</p>
</div>
{/* Impressum Content */}
<article className="space-y-8 text-foreground">
<section>
<h2 className="text-lg font-display font-medium uppercase tracking-tight text-foreground mb-3">
WEBklar GbR
</h2>
<p className="text-muted-foreground leading-relaxed whitespace-pre-line">
Am Schwimmbad 10<br />
67722 Winnweiler<br />
Deutschland
</p>
</section>
{impressumDivider}
<section>
<h2 className="text-lg font-display font-medium uppercase tracking-tight text-foreground mb-3">
Vertreten durch
</h2>
<p className="text-muted-foreground leading-relaxed">
die Gesellschafter Kenso Grimm und Justin Klein<br />
(jeder einzeln vertretungsberechtigt)
</p>
</section>
{impressumDivider}
<section>
<h2 className="text-lg font-display font-medium uppercase tracking-tight text-foreground mb-3">
Kontakt
</h2>
<p className="text-muted-foreground leading-relaxed">
Telefon: 0176 23726355<br />
E-Mail: support@webklar.com
</p>
</section>
{impressumDivider}
<section>
<h2 className="text-lg font-display font-medium uppercase tracking-tight text-foreground mb-3">
Rechtsform
</h2>
<p className="text-muted-foreground leading-relaxed">
Gesellschaft bürgerlichen Rechts (GbR)
</p>
</section>
{impressumDivider}
<section>
<h2 className="text-lg font-display font-medium uppercase tracking-tight text-foreground mb-3">
Umsatzsteuer
</h2>
<p className="text-muted-foreground leading-relaxed">
Gemäß § 19 UStG wird keine Umsatzsteuer erhoben (Kleinunternehmerregelung).
</p>
</section>
{impressumDivider}
<section>
<h2 className="text-lg font-display font-medium uppercase tracking-tight text-foreground mb-3">
Haftung für Inhalte
</h2>
<p className="text-muted-foreground leading-relaxed space-y-2">
Für eigene Inhalte auf diesen Seiten sind wir nach den allgemeinen Gesetzen verantwortlich.<br />
Für fremde Informationen, die wir lediglich übermitteln oder speichern, gelten die Haftungsbeschränkungen der Artikel 4 bis 6 der Verordnung (EU) 2022/2065 (Gesetz über digitale Dienste). Wir sind nicht verpflichtet, solche Informationen zu überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen.
</p>
</section>
{impressumDivider}
<section>
<h2 className="text-lg font-display font-medium uppercase tracking-tight text-foreground mb-3">
Haftung für Links
</h2>
<p className="text-muted-foreground leading-relaxed space-y-2">
Unsere Webseite enthält Links zu externen Webseiten Dritter, auf deren Inhalte wir keinen Einfluss haben.<br />
Deshalb können wir für diese fremden Inhalte auch keine Gewähr übernehmen.<br />
Für die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der Seiten verantwortlich.
</p>
</section>
{impressumDivider}
<section>
<h2 className="text-lg font-display font-medium uppercase tracking-tight text-foreground mb-3">
Urheberrecht
</h2>
<p className="text-muted-foreground leading-relaxed space-y-2">
Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem deutschen Urheberrecht.<br />
Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen der schriftlichen Zustimmung des jeweiligen Autors bzw. Erstellers.
</p>
</section>
{impressumDivider}
<section>
<h2 className="text-lg font-display font-medium uppercase tracking-tight text-foreground mb-3">
Verbraucherstreitbeilegung
</h2>
<p className="text-muted-foreground leading-relaxed space-y-2">
Wir sind nicht verpflichtet und nicht bereit, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.
</p>
</section>
{impressumDivider}
<section>
<h2 className="text-lg font-display font-medium uppercase tracking-tight text-foreground mb-3">
Datenschutz
</h2>
<p className="text-muted-foreground leading-relaxed">
Informationen zur Verarbeitung personenbezogener Daten finden Sie in unserer{" "}
<a href="https://project.webklar.com/datenschutz.html" className="text-foreground underline hover:no-underline">
Datenschutzerklärung
</a>.
</p>
</section>
</article>
{/* Back / Contact */}
<div className="mt-16 pt-12 border-t border-border flex flex-wrap gap-4">
<Link to="/">
<Button variant="outline" className="rounded-full">
Zur Startseite
</Button>
</Link>
<Link to="/kontakt">
<Button className="btn-minimal rounded-full">
Kontakt
</Button>
</Link>
</div>
</div>
</div>
</main>
</div>
</div>
);
};
+12 -7
View File
@@ -1,17 +1,22 @@
import { Link } from "react-router-dom";
import { usePageMeta } from "@/hooks/use-page-meta";
import "./unterseiten.css";
const NotFound = () => {
usePageMeta("Seite nicht gefunden");
return (
<div className="flex min-h-[70vh] items-center justify-center bg-background px-6 text-foreground">
<div className="text-center">
<h1 className="mb-4 text-4xl font-bold">Seite nicht gefunden</h1>
<p className="mb-6 text-xl text-muted-foreground">Diese Seite gibt es nicht oder nicht mehr.</p>
<Link to="/" className="text-foreground underline underline-offset-4 hover:no-underline">
Zur Startseite
</Link>
<div className="wk-seite wk-nichtda">
<div className="wk-container">
<div className="wk-seite__kopf">
<h1 className="wk-seite__titel">Seite nicht gefunden</h1>
<p className="wk-seite__lead">Diese Seite gibt es nicht oder nicht mehr.</p>
<div className="wk-seite__aktionen">
<Link to="/" className="wk-btn wk-btn--primary">
Zur Startseite
</Link>
</div>
</div>
</div>
</div>
);
+109 -146
View File
@@ -1,14 +1,10 @@
import { Link } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { ArrowLeft, ShieldCheck } from "lucide-react";
import { usePageMeta } from "@/hooks/use-page-meta";
import "./unterseiten.css";
const PORTAL = "https://project.webklar.com";
const STEUER = "Endpreis, gemäß § 19 UStG ohne Umsatzsteuer";
const divider = <div className="my-8 border-t border-border" aria-hidden />;
const linkClass = "text-foreground underline hover:no-underline";
const leistungen = [
{
titel: "Betrieb & Pflege Ihrer Website",
@@ -35,150 +31,117 @@ const WartungsAbo = () => {
);
return (
<div className="min-h-screen bg-background">
<header className="fixed top-0 left-0 right-0 z-50 glass-nav py-4">
<div className="container mx-auto px-6">
<div className="flex items-center justify-between">
<Link to="/" className="flex items-center gap-2 group">
<span className="text-xl font-display font-medium text-foreground tracking-normal">WEBklar</span>
<div className="wk-seite">
<div className="wk-container">
<div className="wk-seite__kopf">
<span className="wk-eyebrow">Leistungen</span>
<h1 className="wk-seite__titel">Wartungs-Abo</h1>
<p className="wk-seite__lead">
Betrieb, Pflege und Betreuung Ihrer Website: 50,00 € pro Monat, 12 Monate Mindestlaufzeit.
</p>
<p className="wk-seite__hinweis">Alle Preise sind Endpreise, gemäß § 19 UStG ohne Umsatzsteuer.</p>
</div>
<article className="wk-text">
<section>
<h2>Das ist enthalten</h2>
<ul className="wk-merkmale">
{leistungen.map((l) => (
<li key={l.titel}>
<p className="wk-merkmale__titel">{l.titel}</p>
<p className="wk-merkmale__text">{l.text}</p>
</li>
))}
</ul>
</section>
<section>
<h2>Preis und Laufzeit</h2>
<ul>
<li>50,00 € pro Monat, monatlich im Voraus. {STEUER}.</li>
<li>Mindestlaufzeit 12 Monate, kündbar zum Ende der Mindestlaufzeit bis einen Monat vorher; danach jederzeit zum Ende des Abrechnungsmonats. Bei Geschäftsaufgabe Sonderkündigung mit einem Monat Frist.</li>
<li>Zahlung per Kreditkarte oder SEPA-Lastschrift über unseren Zahlungsdienstleister Stripe.</li>
<li>Arbeit über die enthaltene Stunde hinaus nur nach Ihrer Freigabe, 100 € pro Stunde ({STEUER}), abgerechnet in 15-Minuten-Schritten.</li>
</ul>
</section>
<section>
<h2>E-Mail-Postfächer und Zusatzspeicher</h2>
<p>
Im Wartungs-Abo richten wir Ihnen beliebig viele Postfächer unter Ihrer Domain ein, mit zusammen 5 GB
Speicher. Wie der Speicher auf die Postfächer verteilt wird, bestimmen Sie. Brauchen Sie mehr Platz, buchen Sie
im Kundenportal unter „Verträge“ Zusatzspeicher: Pakete von je 5 GB für 1,00 € pro Monat ({STEUER}), bis zu
10 Pakete. Bestellen und verringern können Sie die Pakete dort, sobald Ihr Abo begonnen hat, solange es nicht
gekündigt ist und keine Zahlung offen ist. Eine Verringerung wirkt zum Ende des Abrechnungsmonats.
Einzelheiten:{" "}
<a href={`${PORTAL}/abo-bedingungen.html#zusatzspeicher`}>§ 10 der Vertragsbedingungen</a>.
</p>
</section>
<section>
<h2>Hosting-Abo für statische Websites</h2>
<p>
Sie haben eine statische Website und brauchen weder Pflege noch Arbeitszeit? Dann genügt vielleicht unser{" "}
<Link to="/hosting-abo">Hosting-Abo</Link> für 20,00 € pro Monat ({STEUER}). Vom
Hosting- ins Wartungs-Abo wechseln Sie im Kundenportal, sobald das Hosting-Abo begonnen hat, solange es nicht
gekündigt ist und keine Zahlung offen ist; mit dem Wechsel beginnt eine neue Mindestlaufzeit von 12 Monaten.
Ein Wechsel vom Wartungs- ins Hosting-Abo ist auf Anfrage nach Ablauf der Mindestlaufzeit zum Ende eines
Abrechnungsmonats möglich, wenn Ihre Website statisch ist. Einzelheiten in{" "}
<a href={`${PORTAL}/abo-bedingungen.html#wechsel`}>§ 9 der Vertragsbedingungen</a>.
</p>
</section>
<section>
<h2>So schließen Sie das Abo ab</h2>
<p>
Das Wartungs-Abo schließen Sie in unserem Kundenportal ab. Den Zugang richten wir Ihnen ein, sprechen Sie uns
einfach an. Nach der Anmeldung finden Sie das Angebot im Bereich „Verträge“. Im Kundenportal sehen Sie danach
jederzeit Ihr Stundenkontingent und Ihre Rechnungen und schicken uns Aufträge über den Chat.
</p>
</section>
<section>
<h2>Widerruf und Kündigung</h2>
<p>
Verbraucher können jede Bestellung innerhalb von 14 Tagen ab Vertragsschluss widerrufen: den Abschluss des
Abos, den Wechsel ins Wartungs-Abo und die Bestellung von Zusatzspeicher. Bereits gezahlte Beträge erstatten
wir dann nach der{" "}
<a href={`${PORTAL}/abo-bedingungen.html#widerruf`}>Widerrufsbelehrung</a>.
Kündigen können Sie online ohne Anmeldung oder per E-Mail.
</p>
<div className="wk-seite__aktionen">
<a href={`${PORTAL}/kuendigen.html`} className="wk-btn wk-btn--secondary">Verträge hier kündigen</a>
<a href={`${PORTAL}/widerrufen.html`} className="wk-btn wk-btn--secondary">Vertrag widerrufen</a>
</div>
</section>
<section>
<h2>Unterlagen und Kontakt</h2>
<p>
<a href={`${PORTAL}/abo-bedingungen.html`}>Vertragsbedingungen für die WEBklar-Abos</a>
{" · "}
<a href={`${PORTAL}/abo-bedingungen.html#wartung`}>Leistungsbeschreibung B (Wartungs-Abo)</a>
{" · "}
<a href={`${PORTAL}/WEBklar-Abo-Vertragsunterlagen.pdf`}>Vertragsunterlagen als PDF</a>
{" · "}
<a href={`${PORTAL}/datenschutz.html`}>Datenschutz</a>
<br />
WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler · Telefon 0176 23726355 · E-Mail support@webklar.com
</p>
</section>
</article>
<div className="wk-seite__ende">
<div className="wk-seite__aktionen">
<a href={`${PORTAL}/login.html`} className="wk-btn wk-btn--primary">
Zum Kundenportal
</a>
<Link to="/kontakt" className="wk-btn wk-btn--secondary">
Kontakt aufnehmen
</Link>
<Button asChild variant="ghost" className="text-muted-foreground hover:text-foreground">
<Link to="/">
<ArrowLeft className="w-4 h-4 mr-2" />
Zurück
</Link>
</Button>
</div>
</div>
</header>
<main className="pt-32 pb-24">
<div className="container mx-auto px-6">
<div className="max-w-3xl mx-auto">
<div className="mb-12">
<div className="label-tag mb-4 flex items-center gap-2">
<ShieldCheck className="w-4 h-4" />
Leistungen
</div>
<h1 className="text-4xl md:text-5xl font-display font-medium text-foreground tracking-tight uppercase mb-2">
Wartungs-Abo
</h1>
<p className="text-muted-foreground text-lg">
Betrieb, Pflege und Betreuung Ihrer Website: 50,00 € pro Monat, 12 Monate Mindestlaufzeit.
</p>
<p className="text-muted-foreground text-sm mt-1">Alle Preise sind Endpreise, gemäß § 19 UStG ohne Umsatzsteuer.</p>
</div>
<article className="space-y-8 text-foreground">
<section>
<h2 className="text-lg font-display font-medium uppercase tracking-tight mb-4">Das ist enthalten</h2>
<ul className="grid gap-5 sm:grid-cols-2">
{leistungen.map((l) => (
<li key={l.titel} className="rounded-2xl border border-border p-5">
<p className="font-medium mb-1">{l.titel}</p>
<p className="text-muted-foreground text-sm leading-relaxed">{l.text}</p>
</li>
))}
</ul>
</section>
{divider}
<section>
<h2 className="text-lg font-display font-medium uppercase tracking-tight mb-3">Preis und Laufzeit</h2>
<ul className="text-muted-foreground leading-relaxed space-y-1 list-disc pl-5">
<li>50,00 € pro Monat, monatlich im Voraus. {STEUER}.</li>
<li>Mindestlaufzeit 12 Monate, kündbar zum Ende der Mindestlaufzeit bis einen Monat vorher; danach jederzeit zum Ende des Abrechnungsmonats. Bei Geschäftsaufgabe Sonderkündigung mit einem Monat Frist.</li>
<li>Zahlung per Kreditkarte oder SEPA-Lastschrift über unseren Zahlungsdienstleister Stripe.</li>
<li>Arbeit über die enthaltene Stunde hinaus nur nach Ihrer Freigabe, 100 € pro Stunde ({STEUER}), abgerechnet in 15-Minuten-Schritten.</li>
</ul>
</section>
{divider}
<section>
<h2 className="text-lg font-display font-medium uppercase tracking-tight mb-3">E-Mail-Postfächer und Zusatzspeicher</h2>
<p className="text-muted-foreground leading-relaxed">
Im Wartungs-Abo richten wir Ihnen beliebig viele Postfächer unter Ihrer Domain ein, mit zusammen 5 GB
Speicher. Wie der Speicher auf die Postfächer verteilt wird, bestimmen Sie. Brauchen Sie mehr Platz, buchen Sie
im Kundenportal unter „Verträge“ Zusatzspeicher: Pakete von je 5 GB für 1,00 € pro Monat ({STEUER}), bis zu
10 Pakete. Bestellen und verringern können Sie die Pakete dort, sobald Ihr Abo begonnen hat, solange es nicht
gekündigt ist und keine Zahlung offen ist. Eine Verringerung wirkt zum Ende des Abrechnungsmonats.
Einzelheiten:{" "}
<a href={`${PORTAL}/abo-bedingungen.html#zusatzspeicher`} className={linkClass}>§ 10 der Vertragsbedingungen</a>.
</p>
</section>
{divider}
<section>
<h2 className="text-lg font-display font-medium uppercase tracking-tight mb-3">Hosting-Abo für statische Websites</h2>
<p className="text-muted-foreground leading-relaxed">
Sie haben eine statische Website und brauchen weder Pflege noch Arbeitszeit? Dann genügt vielleicht unser{" "}
<Link to="/hosting-abo" className={linkClass}>Hosting-Abo</Link> für 20,00 € pro Monat ({STEUER}). Vom
Hosting- ins Wartungs-Abo wechseln Sie im Kundenportal, sobald das Hosting-Abo begonnen hat, solange es nicht
gekündigt ist und keine Zahlung offen ist; mit dem Wechsel beginnt eine neue Mindestlaufzeit von 12 Monaten.
Ein Wechsel vom Wartungs- ins Hosting-Abo ist auf Anfrage nach Ablauf der Mindestlaufzeit zum Ende eines
Abrechnungsmonats möglich, wenn Ihre Website statisch ist. Einzelheiten in{" "}
<a href={`${PORTAL}/abo-bedingungen.html#wechsel`} className={linkClass}>§ 9 der Vertragsbedingungen</a>.
</p>
</section>
{divider}
<section>
<h2 className="text-lg font-display font-medium uppercase tracking-tight mb-3">So schließen Sie das Abo ab</h2>
<p className="text-muted-foreground leading-relaxed">
Das Wartungs-Abo schließen Sie in unserem Kundenportal ab. Den Zugang richten wir Ihnen ein, sprechen Sie uns
einfach an. Nach der Anmeldung finden Sie das Angebot im Bereich „Verträge“. Im Kundenportal sehen Sie danach
jederzeit Ihr Stundenkontingent und Ihre Rechnungen und schicken uns Aufträge über den Chat.
</p>
</section>
{divider}
<section>
<h2 className="text-lg font-display font-medium uppercase tracking-tight mb-3">Widerruf und Kündigung</h2>
<p className="text-muted-foreground leading-relaxed">
Verbraucher können jede Bestellung innerhalb von 14 Tagen ab Vertragsschluss widerrufen: den Abschluss des
Abos, den Wechsel ins Wartungs-Abo und die Bestellung von Zusatzspeicher. Bereits gezahlte Beträge erstatten
wir dann nach der{" "}
<a href={`${PORTAL}/abo-bedingungen.html#widerruf`} className={linkClass}>Widerrufsbelehrung</a>.
Kündigen können Sie online ohne Anmeldung oder per E-Mail.
</p>
<div className="mt-4 flex flex-wrap gap-3">
<Button asChild variant="outline" className="rounded-full">
<a href={`${PORTAL}/kuendigen.html`}>Verträge hier kündigen</a>
</Button>
<Button asChild variant="outline" className="rounded-full">
<a href={`${PORTAL}/widerrufen.html`}>Vertrag widerrufen</a>
</Button>
</div>
</section>
{divider}
<section>
<h2 className="text-lg font-display font-medium uppercase tracking-tight mb-3">Unterlagen und Kontakt</h2>
<p className="text-muted-foreground leading-relaxed">
<a href={`${PORTAL}/abo-bedingungen.html`} className={linkClass}>Vertragsbedingungen für die WEBklar-Abos</a>
{" · "}
<a href={`${PORTAL}/abo-bedingungen.html#wartung`} className={linkClass}>Leistungsbeschreibung B (Wartungs-Abo)</a>
{" · "}
<a href={`${PORTAL}/WEBklar-Abo-Vertragsunterlagen.pdf`} className={linkClass}>Vertragsunterlagen als PDF</a>
{" · "}
<a href={`${PORTAL}/datenschutz.html`} className={linkClass}>Datenschutz</a>
<br />
WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler · Telefon 0176 23726355 · E-Mail support@webklar.com
</p>
</section>
</article>
<div className="mt-16 pt-12 border-t border-border flex flex-wrap gap-4">
<Button asChild className="btn-minimal rounded-full">
<a href={`${PORTAL}/login.html`}>Zum Kundenportal</a>
</Button>
<Button asChild variant="outline" className="rounded-full">
<Link to="/kontakt">Kontakt aufnehmen</Link>
</Button>
</div>
</div>
</div>
</main>
</div>
</div>
);
};
+485
View File
@@ -0,0 +1,485 @@
/*
* Unterseiten im neuen Layout (Spezifikation 6.4, 8 und 9.6):
* Kontakt, Über uns, 404 und die Rechtsseiten (AGB, Impressum, Wartungs-Abo, Hosting-Abo).
* Kopfleiste und Footer kommen aus dem gemeinsamen Layout (src/routes.tsx); hier steht nur der Inhalt.
* Farben, Größen und Abstände kommen aus den Token in src/styles/relaunch.css.
*/
/* ── Seitenrahmen und Kopf ─────────────────────────────────────────────── */
.wk-seite {
padding-block: clamp(48px, 7vw, 104px) var(--section-space);
background: var(--bg);
}
.wk-seite__kopf {
max-width: var(--measure-text);
margin-bottom: clamp(40px, 6vw, 80px);
}
.wk-seite__titel {
font-size: var(--fs-h2);
line-height: 1.07;
font-weight: 600;
letter-spacing: -0.015em;
text-wrap: balance;
hyphens: manual;
}
.wk-seite__titel--gross {
font-size: var(--fs-hero);
line-height: 1.05;
letter-spacing: -0.02em;
}
.wk-seite__lead {
margin-top: 1.2em;
font-size: var(--fs-lead);
line-height: 1.38;
color: var(--fg-muted);
text-wrap: pretty;
}
.wk-seite__hinweis {
margin-top: 0.75rem;
font-size: var(--fs-small);
line-height: 1.43;
color: var(--fg-muted);
}
.wk-seite__aktionen {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 1.75rem;
}
/* Abschluss der Rechtsseiten: Buttons unter dem Text */
.wk-seite__ende {
margin-top: clamp(40px, 6vw, 72px);
}
.wk-seite__ende .wk-seite__aktionen {
margin-top: 0;
}
/* Parteien des Kaufvertrags (AGB) */
.wk-parteien {
margin-top: 2rem;
font-size: var(--fs-body);
line-height: 1.4;
}
.wk-parteien p + p {
margin-top: 0.15rem;
}
.wk-parteien p:nth-child(odd):not(:first-child) {
margin-top: 1rem;
}
.wk-parteien strong {
font-weight: 400;
color: var(--fg-muted);
}
.wk-parteien small {
font-size: var(--fs-small);
color: var(--fg-muted);
}
/* ── Fließtext der Rechtsseiten: Überschrift links, Text rechts ────────── */
.wk-text {
font-size: var(--fs-body);
line-height: 1.55;
color: var(--fg);
}
.wk-text > section {
display: grid;
grid-template-columns: minmax(0, 1fr);
padding-block: 2.25rem;
border-top: 1px solid var(--line);
}
.wk-text > section:last-child {
padding-bottom: 0;
}
.wk-text h2 {
font-size: var(--fs-h3);
line-height: 1.25;
font-weight: 600;
letter-spacing: -0.01em;
text-wrap: balance;
}
.wk-text h3 {
font-size: var(--fs-body);
line-height: 1.4;
font-weight: 600;
}
.wk-text > section > * + * {
margin-top: 1rem;
}
.wk-text p {
text-wrap: pretty;
}
.wk-text p + p {
margin-top: 0.85rem;
}
.wk-text__zusatz {
font-size: var(--fs-small);
line-height: 1.43;
color: var(--fg-muted);
}
.wk-text ol {
list-style: decimal;
padding-left: 1.45em;
}
.wk-text ul {
list-style: disc;
padding-left: 1.3em;
}
.wk-text li {
padding-left: 0.3em;
}
.wk-text li + li {
margin-top: 0.6rem;
}
.wk-text li::marker {
color: var(--fg-muted);
font-variant-numeric: tabular-nums;
}
.wk-text li > ul {
margin-top: 0.5rem;
}
.wk-text li > ul > li + li {
margin-top: 0.25rem;
}
.wk-text a {
color: var(--fg);
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 3px;
text-decoration-color: var(--line-strong);
overflow-wrap: anywhere;
transition: text-decoration-color var(--dur-state) var(--ease);
}
@media (hover: hover) and (pointer: fine) {
.wk-text a:hover {
text-decoration-color: var(--fg);
}
}
/* Buttons im Text behalten die Button-Farben */
.wk-text a.wk-btn {
text-decoration: none;
}
.wk-text a.wk-btn--primary {
color: var(--bg);
}
/* Unterabschnitte (AGB § 6) */
.wk-text__gruppe + .wk-text__gruppe {
margin-top: 1.75rem;
}
.wk-text__gruppe h3 {
margin-bottom: 0.6rem;
}
/* „Das ist enthalten“ (Abo-Seiten): Zeilen statt Kästen */
.wk-text .wk-merkmale {
list-style: none;
padding-left: 0;
}
.wk-text .wk-merkmale > li {
padding-left: 0;
}
.wk-text .wk-merkmale > li + li {
margin-top: 1.25rem;
padding-top: 1.25rem;
border-top: 1px solid var(--line);
}
.wk-merkmale__titel {
font-weight: 600;
}
.wk-merkmale__text {
margin-top: 0.35rem;
color: var(--fg-muted);
font-size: var(--fs-body);
}
.wk-text .wk-seite__aktionen {
margin-top: 1.5rem;
}
@media (min-width: 900px) {
.wk-text > section {
grid-template-columns: minmax(0, 5fr) minmax(0, 8fr);
column-gap: clamp(2rem, 5vw, 5rem);
padding-block: 3rem;
}
/* Überschrift bleibt beim Lesen des langen Abschnitts neben dem Text stehen */
.wk-text > section > h2 {
grid-column: 1;
grid-row: 1 / span 12;
align-self: start;
position: sticky;
top: calc(var(--header-h) + 1.5rem);
}
.wk-text > section > :not(h2) {
grid-column: 2;
}
.wk-text > section > h2 + * {
margin-top: 0;
}
}
/* ── Kontakt ───────────────────────────────────────────────────────────── */
.wk-kontakt-seite__raster {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: clamp(48px, 7vw, 72px);
}
@media (min-width: 900px) {
.wk-kontakt-seite__raster {
grid-template-columns: minmax(0, 7fr) minmax(0, 4fr);
column-gap: clamp(3rem, 7vw, 7rem);
align-items: start;
}
}
.wk-form {
display: grid;
gap: 1.5rem;
}
.wk-form__paar {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 1.5rem;
}
@media (min-width: 640px) {
.wk-form__paar {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.wk-feld label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.9375rem;
font-weight: 600;
line-height: 1.3;
}
.wk-feld__optional {
font-weight: 400;
color: var(--fg-muted);
}
.wk-feld input,
.wk-feld textarea {
display: block;
width: 100%;
min-height: 52px;
padding: 0.75rem 1rem;
font: inherit;
font-size: 1.0625rem; /* mindestens 16 px, sonst zoomt iOS */
line-height: 1.4;
color: var(--fg);
background: var(--bg);
border: 1px solid var(--line-strong);
border-radius: 12px;
appearance: none;
transition: border-color var(--dur-state) var(--ease);
}
.wk-feld textarea {
min-height: 9.5rem;
resize: vertical;
}
.wk-feld input::placeholder,
.wk-feld textarea::placeholder {
color: var(--fg-muted);
opacity: 1;
}
.wk-feld input:focus,
.wk-feld textarea:focus {
border-color: var(--fg);
}
@media (hover: hover) and (pointer: fine) {
.wk-feld input:hover,
.wk-feld textarea:hover {
border-color: var(--fg);
}
}
.wk-form__senden {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1rem;
}
.wk-form__senden .wk-btn[aria-disabled="true"] {
opacity: 0.6;
cursor: progress;
}
.wk-form__datenschutz {
max-width: 34rem;
}
.wk-form__datenschutz a {
color: var(--fg);
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 3px;
text-decoration-color: var(--line-strong);
}
@media (hover: hover) and (pointer: fine) {
.wk-form__datenschutz a:hover {
text-decoration-color: var(--fg);
}
}
/* Rückmeldung nach dem Absenden (aria-live): Erfolg neutral, Fehler in Rot */
/* Leer bleibt der Bereich im Dokument (sonst liest ein Screenreader die erste Meldung nicht vor), nimmt aber keinen Platz ein */
.wk-form__status:empty {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
border: 0;
}
.wk-form__status {
padding-left: 1rem;
border-left: 2px solid var(--fg);
line-height: 1.45;
}
.wk-form__status[data-art="fehler"] {
border-left-color: var(--accent);
color: var(--accent-text);
}
.wk-form__status strong {
display: block;
font-weight: 600;
}
/* E-Mail und Telefon: einmal, neben dem Formular */
.wk-direkt h2 {
font-size: var(--fs-small);
font-weight: 600;
letter-spacing: 0.005em;
color: var(--fg-muted);
}
.wk-direkt dl {
margin-top: 1rem;
display: grid;
gap: 1.5rem;
}
.wk-direkt dt {
font-size: var(--fs-small);
color: var(--fg-muted);
}
.wk-direkt dd {
margin-top: 0.15rem;
}
.wk-direkt a {
display: inline-flex;
align-items: center;
min-height: 44px;
font-size: clamp(1.125rem, 1.05rem + 0.3vw, 1.375rem);
font-weight: 600;
letter-spacing: -0.01em;
overflow-wrap: anywhere;
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 5px;
text-decoration-color: var(--line-strong);
transition: text-decoration-color var(--dur-state) var(--ease);
}
@media (hover: hover) and (pointer: fine) {
.wk-direkt a:hover {
text-decoration-color: var(--fg);
}
}
/* ── Über uns (Phase 2 schreibt die Seite neu) ─────────────────────────── */
.wk-ueber__text {
max-width: var(--measure-text);
font-size: var(--fs-lead);
line-height: 1.42;
color: var(--fg-muted);
}
.wk-ueber__text > * + * {
margin-top: 1.5rem;
}
.wk-ueber__text strong {
font-weight: 600;
color: var(--fg);
}
.wk-ueber__aussage {
margin-top: clamp(40px, 6vw, 72px);
max-width: 24ch;
font-size: clamp(1.75rem, 1.35rem + 1.6vw, 2.75rem);
line-height: 1.12;
font-weight: 600;
letter-spacing: -0.015em;
text-wrap: balance;
}
.wk-ueber__aussage p:first-child {
color: var(--fg);
}
.wk-ueber__aussage p:last-child {
color: var(--fg-muted);
}
.wk-ueber__schluss {
max-width: var(--measure-text);
margin-top: clamp(32px, 5vw, 56px);
font-size: var(--fs-lead);
line-height: 1.38;
font-weight: 600;
text-wrap: pretty;
}
/* Nummerierte Textzeilen statt Karten */
.wk-unterschied {
margin-top: clamp(32px, 5vw, 56px);
list-style: none;
padding: 0;
border-top: 1px solid var(--line-strong);
}
.wk-unterschied li {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 0.35rem 2rem;
padding-block: 1.75rem;
border-bottom: 1px solid var(--line);
}
.wk-unterschied__nr {
font-size: var(--fs-small);
font-weight: 600;
color: var(--fg-muted);
font-variant-numeric: tabular-nums;
}
.wk-unterschied p:not(.wk-unterschied__nr) {
color: var(--fg-muted);
max-width: 34rem;
}
@media (min-width: 900px) {
.wk-unterschied li {
grid-template-columns: 4rem minmax(0, 5fr) minmax(0, 6fr);
align-items: baseline;
padding-block: 2.25rem;
}
}
/* ── 404 ───────────────────────────────────────────────────────────────── */
.wk-nichtda {
min-height: 60svh;
display: flex;
align-items: center;
}
.wk-nichtda .wk-seite__kopf {
margin-bottom: 0;
}
+349
View File
@@ -0,0 +1,349 @@
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { render, screen, fireEvent, act, cleanup } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import HeroKarte from "@/components/home/HeroKarte";
import DeutschlandKarte, { FALKENSTEIN, project } from "@/components/DeutschlandKarte";
const routerFuture = { v7_startTransition: true, v7_relativeSplatPath: true };
const zeige = () =>
render(
<MemoryRouter future={routerFuture}>
<HeroKarte />
</MemoryRouter>,
);
const TAKT = 2400;
const FESTER_SATZ = "Wir bauen Websites, Online-Shops, Web-Apps und Automatisierungen.";
/** Bewegungswunsch des Systems festlegen (setup.ts liefert sonst überall „keine Angabe“). */
const originalMatchMedia = window.matchMedia;
const setzeReduziert = (reduziert: boolean) => {
window.matchMedia = ((query: string) => ({
matches: reduziert && query.includes("prefers-reduced-motion: reduce"),
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
})) as typeof window.matchMedia;
};
/** Der Text der gerade sichtbaren Variante des wechselnden Überschriftenteils. */
const sichtbareVariante = () =>
document.querySelector<HTMLElement>('.wk-hero__variante[data-aktiv="true"]')?.textContent ?? null;
const alleVarianten = () => Array.from(document.querySelectorAll(".wk-hero__variante")).map((e) => e.textContent);
const pausenKnopf = () => screen.queryByRole("button", { name: "Wechselnde Überschrift anhalten" });
/** Einen Takt verstreichen lassen (in einem eigenen act, damit der nächste Timer erst danach gesetzt wird). */
const takt = () => act(() => void vi.advanceTimersByTime(TAKT));
beforeEach(() => {
vi.useFakeTimers();
setzeReduziert(false);
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.unstubAllGlobals();
window.matchMedia = originalMatchMedia;
});
describe("Hero: Text und Überschrift (Spezifikation 7.1)", () => {
it("zeigt die kleine Zeile, den Untertext und beide Buttons mit den richtigen Zielen", () => {
zeige();
expect(screen.getByText("Websites, Shops und Apps für kleine Betriebe")).toBeInTheDocument();
expect(
screen.getByText(
"In unserem Kundenportal sehen Sie jederzeit, wie weit Ihr Projekt ist – mit Live-Vorschau Ihrer Website.",
),
).toBeInTheDocument();
const hauptknopf = screen.getByRole("link", { name: "Projekt besprechen" });
expect(hauptknopf).toHaveAttribute("href", "/kontakt");
expect(hauptknopf).toHaveClass("wk-btn--primary");
const zweitknopf = screen.getByRole("link", { name: "Kundenportal ansehen" });
expect(zweitknopf).toHaveAttribute("href", "/#kundenportal");
expect(zweitknopf).toHaveClass("wk-btn--secondary");
});
it("hat genau ein h1, dessen Name für Screenreader der feste Satz ist", () => {
zeige();
const h1 = screen.getAllByRole("heading", { level: 1 });
expect(h1).toHaveLength(1);
expect(h1[0]).toHaveAccessibleName(FESTER_SATZ);
// der sichtbare, wechselnde Teil ist für Screenreader ausgeblendet
const sichtbar = h1[0].querySelector(".wk-hero__sichtbar");
expect(sichtbar).toHaveAttribute("aria-hidden", "true");
expect(sichtbar).toHaveTextContent("Wir bauen");
});
it("legt alle vier Varianten übereinander in eine Gitterzelle (nichts springt)", () => {
zeige();
expect(alleVarianten()).toEqual(["Ihre Website.", "Ihren Online-Shop.", "Ihre Web-App.", "Ihre Automatisierung."]);
const css = readFileSync(join(process.cwd(), "src/components/home/hero.css"), "utf8");
expect(css).toMatch(/\.wk-hero__wechsel\s*{[^}]*display:\s*grid/);
expect(css).toMatch(/\.wk-hero__variante\s*{[^}]*grid-area:\s*1\s*\/\s*1/);
});
});
describe("Hero: wechselnde Überschrift", () => {
it("startet bei „Ihre Website.“ und läuft alle 2,4 s einmal durch", () => {
zeige();
expect(sichtbareVariante()).toBe("Ihre Website.");
act(() => void vi.advanceTimersByTime(TAKT - 1));
expect(sichtbareVariante()).toBe("Ihre Website.");
act(() => void vi.advanceTimersByTime(1));
expect(sichtbareVariante()).toBe("Ihren Online-Shop.");
takt();
expect(sichtbareVariante()).toBe("Ihre Web-App.");
takt();
expect(sichtbareVariante()).toBe("Ihre Automatisierung.");
takt();
expect(sichtbareVariante()).toBe("Ihre Website.");
});
it("bleibt nach einem Durchlauf bei „Ihre Website.“ stehen, und der Pause-Knopf verschwindet", () => {
zeige();
expect(pausenKnopf()).toBeInTheDocument();
for (let i = 0; i < 3; i++) takt();
expect(pausenKnopf()).toBeInTheDocument(); // letzter Wechsel steht noch aus
takt();
expect(sichtbareVariante()).toBe("Ihre Website.");
expect(pausenKnopf()).toBeNull();
// danach ändert sich nichts mehr
for (let i = 0; i < 6; i++) takt();
expect(sichtbareVariante()).toBe("Ihre Website.");
expect(vi.getTimerCount()).toBe(0);
});
it("der Pause-Knopf hat die geforderte Beschriftung und hält den Wechsel an", () => {
zeige();
takt();
expect(sichtbareVariante()).toBe("Ihren Online-Shop.");
const knopf = pausenKnopf();
expect(knopf).toHaveAttribute("type", "button");
act(() => knopf!.click());
expect(pausenKnopf()).toBeNull();
for (let i = 0; i < 5; i++) takt();
expect(sichtbareVariante()).toBe("Ihren Online-Shop.");
expect(vi.getTimerCount()).toBe(0);
});
it("Fokus auf die Überschrift (hier der Pause-Knopf) hält an und setzt beim Verlassen fort", () => {
zeige();
const knopf = pausenKnopf()!;
act(() => knopf.focus());
for (let i = 0; i < 3; i++) takt();
expect(sichtbareVariante()).toBe("Ihre Website.");
expect(pausenKnopf()).toBeInTheDocument();
act(() => knopf.blur());
takt();
expect(sichtbareVariante()).toBe("Ihren Online-Shop.");
});
it("Hover auf der Überschrift hält an und setzt beim Verlassen fort", () => {
zeige();
const h1 = screen.getByRole("heading", { level: 1 });
fireEvent.mouseEnter(h1.closest(".wk-hero__kopf")!);
for (let i = 0; i < 3; i++) takt();
expect(sichtbareVariante()).toBe("Ihre Website.");
fireEvent.mouseLeave(h1.closest(".wk-hero__kopf")!);
takt();
expect(sichtbareVariante()).toBe("Ihren Online-Shop.");
});
it("pausiert, solange der Tab nicht sichtbar ist", () => {
const sichtbarkeit = vi.spyOn(document, "visibilityState", "get");
zeige();
sichtbarkeit.mockReturnValue("hidden");
act(() => void document.dispatchEvent(new Event("visibilitychange")));
for (let i = 0; i < 3; i++) takt();
expect(sichtbareVariante()).toBe("Ihre Website.");
sichtbarkeit.mockReturnValue("visible");
act(() => void document.dispatchEvent(new Event("visibilitychange")));
takt();
expect(sichtbareVariante()).toBe("Ihren Online-Shop.");
});
it("bei reduzierter Bewegung steht fest „Ihre Website.“ da, ohne Knopf und ohne Timer", () => {
setzeReduziert(true);
zeige();
expect(sichtbareVariante()).toBe("Ihre Website.");
expect(alleVarianten()).toEqual(["Ihre Website."]);
expect(pausenKnopf()).toBeNull();
expect(screen.queryAllByRole("button")).toHaveLength(0);
for (let i = 0; i < 6; i++) takt();
expect(sichtbareVariante()).toBe("Ihre Website.");
expect(vi.getTimerCount()).toBe(0);
// der Satz für Screenreader bleibt derselbe
expect(screen.getByRole("heading", { level: 1 })).toHaveAccessibleName(FESTER_SATZ);
});
});
describe("Deutschland-Karte", () => {
it("project() rechnet Falkenstein (12,371 O / 50,477 N) auf die Kartenposition um", () => {
const [x, y] = project(12.371, 50.477);
expect(x).toBeCloseTo(469.6, 1);
expect(y).toBeCloseTo(520.5, 1);
expect([FALKENSTEIN.cx, FALKENSTEIN.cy]).toEqual([469.6, 520.5]);
});
it("project() setzt auch das Büro Winnweiler (49,567 N / 7,850 O) an die dokumentierte Stelle", () => {
const [x, y] = project(7.85, 49.567);
expect(x).toBeCloseTo(146.7, 0);
expect(y).toBeCloseTo(622.2, 0);
});
it("zeichnet nur den Punkt Falkenstein und die Beschriftung als echten Text", () => {
const { container } = render(<DeutschlandKarte />);
const kreise = container.querySelectorAll("circle.wk-karte__kern");
expect(kreise).toHaveLength(1);
expect(kreise[0]).toHaveAttribute("cx", "469.6");
expect(kreise[0]).toHaveAttribute("cy", "520.5");
// Beschriftung ist HTML-Text (nicht im SVG), also skalierbar und von Screenreadern lesbar
const titel = screen.getByText("Falkenstein, Sachsen");
expect(titel.closest("svg")).toBeNull();
expect(screen.getByText("Hier laufen Ihre Website und das Kundenportal.")).toBeInTheDocument();
// Position in Prozent der viewBox (469,6 / 663 und 520,5 / 892)
const label = titel.closest(".wk-karte__label") as HTMLElement;
expect(parseFloat(label.style.left)).toBeCloseTo(70.83, 1);
expect(parseFloat(label.style.top)).toBeCloseTo(58.35, 1);
});
it("das SVG ist Dekoration (aria-hidden), hat die viewBox 0 0 663 892 und pathLength 1 ohne non-scaling-stroke", () => {
const { container } = render(<DeutschlandKarte />);
const svg = container.querySelector("svg")!;
expect(svg).toHaveAttribute("aria-hidden", "true");
expect(svg).toHaveAttribute("viewBox", "0 0 663 892");
const pfade = container.querySelectorAll(".wk-karte__umriss path");
expect(pfade.length).toBeGreaterThan(0);
for (const pfad of Array.from(pfade)) {
expect(pfad).toHaveAttribute("pathLength", "1");
expect(pfad).not.toHaveAttribute("vector-effect");
expect(pfad.getAttribute("d")).toMatch(/^M[\d.]+ [\d.]+/);
}
expect(container.querySelector("svg title, svg desc")).toBeNull();
});
it("kann weitere Punkte zeigen (Büro als hohler Punkt, ohne Ring)", () => {
const [cx, cy] = project(7.85, 49.567);
const { container } = render(
<DeutschlandKarte
punkte={[FALKENSTEIN, { cx, cy, art: "buero", label: "Winnweiler", sublabel: "Hier sitzen wir." }]}
/>,
);
expect(container.querySelectorAll("circle.wk-karte__kern")).toHaveLength(2);
expect(container.querySelectorAll("circle.wk-karte__ring")).toHaveLength(1);
expect(container.querySelector(".wk-karte__punkt--buero")).not.toBeNull();
expect(screen.getByText("Winnweiler")).toBeInTheDocument();
});
it("wartet mit der Bewegung, bis die Karte im Bild ist; bei reduzierter Bewegung ist sofort alles da", () => {
let melden: ((e: { isIntersecting: boolean }[]) => void) | undefined;
const trennen = vi.fn();
class Beobachter {
constructor(f: typeof melden) {
melden = f;
}
observe() {}
unobserve() {}
disconnect() {
trennen();
}
takeRecords() {
return [];
}
}
vi.stubGlobal("IntersectionObserver", Beobachter);
const { container, unmount } = render(<DeutschlandKarte />);
const karte = container.querySelector(".wk-karte")!;
expect(karte).toHaveAttribute("data-lauf", "wartet");
act(() => melden?.([{ isIntersecting: false }]));
expect(karte).toHaveAttribute("data-lauf", "wartet");
act(() => melden?.([{ isIntersecting: true }]));
expect(karte).toHaveAttribute("data-lauf", "laeuft");
expect(trennen).toHaveBeenCalled();
unmount();
setzeReduziert(true);
const zweite = render(<DeutschlandKarte />);
expect(zweite.container.querySelector(".wk-karte")).toHaveAttribute("data-lauf", "aus");
});
});
describe("Hero und Karte: CSS-Regeln (Spezifikation 8.4 und 9)", () => {
const dateien = ["src/components/home/hero.css", "src/components/deutschland-karte.css"].map((pfad) => ({
pfad,
css: readFileSync(join(process.cwd(), pfad), "utf8").replace(/\/\*[\s\S]*?\*\//g, ""),
}));
/** Liefert zu jeder Position im CSS die offenen at-Regeln (@media …), in denen sie steht. */
const umgebung = (css: string, position: number) => {
const stapel: string[] = [];
for (let i = 0; i < position; i++) {
if (css[i] === "{") {
const anfang = Math.max(css.lastIndexOf("}", i - 1), css.lastIndexOf("{", i - 1), css.lastIndexOf(";", i - 1)) + 1;
stapel.push(css.slice(anfang, i).trim());
} else if (css[i] === "}") stapel.pop();
}
return stapel.filter((s) => s.startsWith("@")).join(" | ");
};
it("Animationen und Übergänge laufen nur hinter prefers-reduced-motion: no-preference", () => {
for (const { pfad, css } of dateien) {
for (const treffer of css.matchAll(/(?<![-\w])(animation|transition)\s*:/g)) {
// Farbübergänge für Zustände (200 ms) sind erlaubt, alles andere nur mit no-preference
const kontext = umgebung(css, treffer.index!);
const zeile = css.slice(treffer.index, css.indexOf(";", treffer.index));
const nurFarbe = treffer[1] === "transition" && /^transition:\s*color\s/.test(zeile);
if (!nurFarbe) expect(kontext, `${pfad}: ${zeile}`).toContain("prefers-reduced-motion: no-preference");
}
}
});
it("animiert nur transform, opacity und stroke-dashoffset", () => {
for (const { pfad, css } of dateien) {
for (const k of css.matchAll(/@keyframes\s+([\w-]+)\s*{((?:[^{}]*{[^{}]*})*)\s*}/g)) {
const eigenschaften = new Set(Array.from(k[2].matchAll(/([\w-]+)\s*:/g)).map((m) => m[1]));
for (const e of eigenschaften) {
expect(["opacity", "transform", "stroke-dashoffset"], `${pfad} @keyframes ${k[1]}`).toContain(e);
}
}
}
});
it("Hover-Regeln stehen nur in (hover: hover) and (pointer: fine)", () => {
for (const { pfad, css } of dateien) {
for (const treffer of css.matchAll(/:hover/g)) {
expect(umgebung(css, treffer.index!), pfad).toContain("(hover: hover) and (pointer: fine)");
}
}
});
it("keine Schrift unter 14 px", () => {
for (const { pfad, css } of dateien) {
for (const m of css.matchAll(/font-size:\s*([\d.]+)(rem|px)/g)) {
const px = parseFloat(m[1]) * (m[2] === "rem" ? 16 : 1);
expect(px, `${pfad}: ${m[0]}`).toBeGreaterThanOrEqual(14);
}
}
});
it("Pause-Knopf hat eine Tippfläche von mindestens 44 × 44 px", () => {
const css = dateien[0].css;
const regel = css.match(/\.wk-hero__pause\s*{([^}]*)}/)![1];
expect(regel).toMatch(/width:\s*44px/);
expect(regel).toMatch(/height:\s*44px/);
});
});
+402
View File
@@ -0,0 +1,402 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, within, act, cleanup } from "@testing-library/react";
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import Kundenportal from "@/components/home/Kundenportal";
import GeraeteRahmen, { DesktopFenster, HandyRahmen } from "@/components/GeraeteRahmen";
// Spezifikation 7.2 (Kundenportal) und 9.5 (Bilder)
const WURZEL = `${process.cwd()}/`;
const BILDORDNER = join(WURZEL, "public/relaunch/portal");
const TITEL = ["Live-Vorschau", "Fortschritt", "Chat", "Rechnungen", "Funktionen", "Als App"];
const UNTERSCHRIFTEN = [
"Ihre Website, noch während wir daran bauen.",
"Was wir zuletzt erledigt haben – in einfachen Sätzen.",
"Frage, Foto oder Sprachnachricht direkt ans Team.",
"Ansehen, bezahlen, herunterladen.",
"Bausteine zum Dazubuchen, zum Beispiel eine News-Pinnwand für Ihre Website.",
"Aufs Handy installieren, fertig.",
];
const SCHRITTE = [
"Kennenlernen",
"Angebot mit Preis",
"Bauen mit Vorschau und Ihrer Freigabe",
"Online – Hosting und Pflege auf Wunsch im Abo",
];
/** matchMedia: nur die Abfrage "ab 900 px" ist wahr, wenn desktop = true. */
const mitBreite = (desktop: boolean) => {
const vorher = window.matchMedia;
window.matchMedia = ((query: string) => ({
matches: desktop && query.includes("min-width: 900px"),
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
})) as typeof window.matchMedia;
return () => {
window.matchMedia = vorher;
};
};
let zuruecksetzen: (() => void) | undefined;
const zeigeHandy = () => {
zuruecksetzen = mitBreite(false);
return render(<Kundenportal />);
};
const zeigeDesktop = () => {
zuruecksetzen = mitBreite(true);
return render(<Kundenportal />);
};
afterEach(() => {
cleanup();
zuruecksetzen?.();
zuruecksetzen = undefined;
vi.unstubAllGlobals();
});
describe("Kundenportal: Text und Reihenfolge", () => {
it("hat den Anker #kundenportal, die Überschrift und die Einleitung aus der Spezifikation", () => {
const { container } = zeigeHandy();
const abschnitt = container.querySelector("section#kundenportal");
expect(abschnitt).not.toBeNull();
expect(abschnitt).toHaveClass("wk-section--alt");
const h2 = screen.getByRole("heading", { level: 2, name: "Ihr Projekt. Live im Kundenportal." });
expect(abschnitt).toContainElement(h2);
expect(container).toHaveTextContent(
"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.",
);
});
it("Handy: die sechs Funktionen stehen in der richtigen Reihenfolge mit ihren Unterschriften", () => {
zeigeHandy();
expect(screen.getAllByRole("heading", { level: 3 }).map((h) => h.textContent)).toEqual(TITEL);
for (const text of UNTERSCHRIFTEN) expect(screen.getByText(text)).toBeInTheDocument();
});
it("Desktop: die Liste der Bühne hat dieselben sechs Funktionen in derselben Reihenfolge", () => {
zeigeDesktop();
const liste = screen.getByRole("list", { name: "Funktionen des Kundenportals" });
const eintraege = within(liste).getAllByRole("button");
expect(eintraege).toHaveLength(6);
TITEL.forEach((titel, i) => {
expect(eintraege[i]).toHaveTextContent(new RegExp(`^${titel}`));
expect(eintraege[i]).toHaveTextContent(UNTERSCHRIFTEN[i]);
});
});
it.each([
["Handy", zeigeHandy],
["Desktop", zeigeDesktop],
])("%s: bewirbt weder Terminplaner noch Analyse, Stundenkonto oder Verträge", (_name, zeige) => {
const { container } = zeige();
const alts = Array.from(container.querySelectorAll("img")).map((img) => img.getAttribute("alt") ?? "");
const gesamt = `${container.textContent} ${alts.join(" ")}`;
expect(gesamt).not.toMatch(/Terminplaner|Analyse|Stundenkonto|Verträge|Vertrag/i);
});
});
describe("Kundenportal: Bilder (Spezifikation 9.5)", () => {
it.each([
["Handy", zeigeHandy],
["Desktop", zeigeDesktop],
])("%s: jedes Bild hat Alt-Text, Breite, Höhe, srcset, sizes und lädt träge und asynchron", (_name, zeige) => {
const { container } = zeige();
const bilder = Array.from(container.querySelectorAll("img"));
expect(bilder.length).toBeGreaterThan(0);
for (const bild of bilder) {
expect(bild.getAttribute("alt")?.trim().length).toBeGreaterThan(10);
expect(Number(bild.getAttribute("width"))).toBeGreaterThan(0);
expect(Number(bild.getAttribute("height"))).toBeGreaterThan(0);
expect(bild.getAttribute("srcset")).toMatch(/\d+w/);
expect(bild.getAttribute("sizes")).toBeTruthy();
expect(bild).toHaveAttribute("loading", "lazy");
expect(bild).toHaveAttribute("decoding", "async");
}
});
it("Handy: sechs Bilder, nur Handy-Aufnahmen; das lange Bild behält sein hohes Seitenverhältnis", () => {
const { container } = zeigeHandy();
const bilder = Array.from(container.querySelectorAll("img"));
expect(bilder).toHaveLength(6);
for (const bild of bilder.slice(0, 5)) {
expect(bild.getAttribute("width")).toBe("780");
expect(bild.getAttribute("height")).toBe("1688");
}
expect(bilder[5].getAttribute("src")).toContain("phone-2-projekte-lang");
expect(bilder[5].getAttribute("height")).toBe("5428");
});
it("Desktop: zu Beginn hängen nur die ersten beiden Schritte ihre Bilder ein (spart Daten)", () => {
const { container } = zeigeDesktop();
const bilder = Array.from(container.querySelectorAll("img"));
// je zwei Desktop- und zwei Handy-Bilder
expect(bilder.map((b) => b.getAttribute("src"))).toEqual([
"/relaunch/portal/desktop-1-projekte-1200.webp",
"/relaunch/portal/desktop-2-fortschritt-1200.webp",
"/relaunch/portal/phone-1-projekte-780.webp",
"/relaunch/portal/phone-3-projektdetails-780.webp",
]);
});
it("alle Dateien aus den srcset-Listen gibt es und sie halten die Budgets ein", () => {
for (const zeige of [zeigeHandy, zeigeDesktop]) {
const { container, unmount } = zeige();
for (const bild of Array.from(container.querySelectorAll("img"))) {
const urls = [bild.getAttribute("src")!, ...bild.getAttribute("srcset")!.split(",").map((e) => e.trim().split(" ")[0])];
for (const url of urls) {
const datei = join(WURZEL, "public", url);
expect(existsSync(datei), url).toBe(true);
const kb = statSync(datei).size / 1024;
const art = url.includes("/desktop-") ? 120 : url.includes("-lang-") ? 150 : 60;
expect(kb, `${url} (${kb.toFixed(0)} KB)`).toBeLessThanOrEqual(art);
}
}
unmount();
zuruecksetzen?.();
}
});
it("in public/ liegen nur die Bilder, die der Abschnitt zeigt (keine Rohexporte, keine unbeworbenen Ansichten)", () => {
const skript = readFileSync(join(WURZEL, "scripts/relaunch-bilder.py"), "utf8");
expect(skript).toContain("desktop-7-news-pinnwand");
for (const nicht of ["desktop-5-stundenkonto", "desktop-6-funktionen", "phone-6-vertraege", "phone-0-login"]) {
expect(skript, nicht).not.toContain(nicht);
expect(readdirSync(BILDORDNER).filter((d) => d.startsWith(nicht)), nicht).toEqual([]);
}
// nur Breiten aus der Spezifikation (Desktop 800/1200/1600, Handy 390/780)
for (const datei of readdirSync(BILDORDNER)) {
expect(datei).toMatch(/^(desktop-.+-(800|1200|1600)|phone-.+-(390|780))\.webp$/);
}
});
});
describe("Kundenportal: Schrittzeile", () => {
it("Desktop: eine nummerierte Liste mit vier Schritten", () => {
zeigeDesktop();
const liste = screen.getByRole("list", { name: /So arbeiten wir/ });
const punkte = within(liste).getAllByRole("listitem");
expect(punkte).toHaveLength(4);
punkte.forEach((punkt, i) => {
expect(punkt).toHaveTextContent(`${i + 1}${SCHRITTE[i]}`);
});
});
it("Handy: vier kleine Karten in einer Wisch-Reihe", () => {
zeigeHandy();
const reihe = screen.getByRole("list", { name: /So arbeiten wir/ });
const karten = within(reihe).getAllByRole("listitem");
expect(karten).toHaveLength(4);
karten.forEach((karte, i) => {
expect(karte).toHaveTextContent(`${i + 1}${SCHRITTE[i]}`);
});
});
});
describe("Kundenportal: Wisch-Reihen und Bedienung", () => {
it("Handy: beide Wisch-Reihen sind per Tastatur erreichbar und beschriftet, höchstens 7 Karten", () => {
zeigeHandy();
const reihen = screen.getAllByRole("list").filter((l) => l.classList.contains("wk-wisch__reihe"));
expect(reihen).toHaveLength(2);
for (const reihe of reihen) {
expect(reihe).toHaveAttribute("tabindex", "0");
expect(reihe.getAttribute("aria-label")).toMatch(/wischen/);
expect(reihe.children.length).toBeLessThanOrEqual(7);
}
expect(reihen[0].children).toHaveLength(6);
});
it("Desktop: keine Wisch-Reihe, dafür die Bühne", () => {
const { container } = zeigeDesktop();
expect(container.querySelector(".wk-wisch")).toBeNull();
expect(container.querySelector(".kp-buehne")).not.toBeNull();
// Vorfahren der sticky-Bühne: nie overflow hidden
for (const el of [container.querySelector("#kundenportal"), container.querySelector(".kp-huelle")]) {
expect((el as HTMLElement).style.overflow).not.toBe("hidden");
}
});
});
describe("Kundenportal: Bühne wechselt mit dem IntersectionObserver", () => {
type Eintrag = { rueckruf: IntersectionObserverCallback; optionen?: IntersectionObserverInit; ziele: Element[] };
const beobachter: Eintrag[] = [];
const beobachterAttrappe = () => {
beobachter.length = 0;
vi.stubGlobal(
"IntersectionObserver",
class {
eintrag: Eintrag;
constructor(rueckruf: IntersectionObserverCallback, optionen?: IntersectionObserverInit) {
this.eintrag = { rueckruf, optionen, ziele: [] };
beobachter.push(this.eintrag);
}
observe(ziel: Element) {
this.eintrag.ziele.push(ziel);
}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
},
);
};
const meldeMarke = (index: number) => {
const spur = beobachter.find((b) => b.optionen?.rootMargin === "-50% 0px -50% 0px");
expect(spur, "Beobachter auf der Spur aus Markern").toBeDefined();
expect(spur!.ziele).toHaveLength(6);
act(() => {
spur!.rueckruf([{ isIntersecting: true, target: spur!.ziele[index] } as unknown as IntersectionObserverEntry], {} as IntersectionObserver);
});
};
const aktiverEintrag = () =>
screen
.getAllByRole("button")
.find((b) => b.getAttribute("aria-current") === "step")
?.textContent;
it("die aktive Funktion ist hervorgehoben, die Bilder folgen, bei Schritt 6 blendet das Fenster aus", () => {
beobachterAttrappe();
const { container } = zeigeDesktop();
expect(aktiverEintrag()).toMatch(/^Live-Vorschau/);
meldeMarke(3);
expect(aktiverEintrag()).toMatch(/^Rechnungen/);
const lagen = Array.from(container.querySelectorAll(".kp-fenster .kp-lage"));
expect(lagen.filter((l) => l.classList.contains("is-aktiv"))).toHaveLength(1);
expect(lagen.find((l) => l.classList.contains("is-aktiv"))?.querySelector("img")?.getAttribute("src")).toContain(
"desktop-4-rechnungen",
);
// nicht sichtbare Ebenen sind für Screenreader ausgeblendet
expect(lagen.filter((l) => !l.classList.contains("is-aktiv")).every((l) => l.getAttribute("aria-hidden") === "true")).toBe(true);
expect(container.querySelector(".kp-buehne")).toHaveAttribute("data-app", "nein");
meldeMarke(5);
expect(aktiverEintrag()).toMatch(/^Als App/);
expect(container.querySelector(".kp-buehne")).toHaveAttribute("data-app", "ja");
expect(container.querySelector(".kp-fenster .kp-ebenen")).toHaveAttribute("aria-hidden", "true");
// der lange Screenshot ist jetzt eingehängt und aktiv
const lang = container.querySelector(".kp-handy .kp-lage.is-aktiv .kp-lang img");
expect(lang?.getAttribute("src")).toContain("phone-2-projekte-lang");
// alle früheren Bilder sind inzwischen eingehängt
expect(container.querySelectorAll("img").length).toBe(5 + 6);
});
});
describe("GeraeteRahmen", () => {
it("Desktop-Fenster: schlichte Adresse project.webklar.com, kein Ampel-Symbol, Bild darunter", () => {
const { container } = render(
<DesktopFenster
bild={{ src: "/a.webp", srcSet: "/a.webp 800w", sizes: "800px", width: 1600, height: 1000, alt: "Beispielbild des Portals" }}
/>,
);
expect(screen.getByText("project.webklar.com")).toBeInTheDocument();
expect(container.querySelector("svg")).toBeNull();
const bild = screen.getByRole("img", { name: "Beispielbild des Portals" });
expect(bild).toHaveAttribute("width", "1600");
expect(bild).toHaveAttribute("height", "1000");
expect(container.querySelector(".wk-fenster__leiste")).toContainHTML("project.webklar.com");
});
it("Handy-Rahmen: Bild im Rahmen, über den Einstieg mit art wählbar", () => {
const daten = { src: "/b.webp", srcSet: "/b.webp 780w", sizes: "250px", width: 780, height: 1688, alt: "Handybild des Portals" };
const { container } = render(
<>
<HandyRahmen bild={daten} />
<GeraeteRahmen art="handy" bild={{ ...daten, alt: "Zweites Handybild" }} />
<GeraeteRahmen art="desktop" bild={{ ...daten, alt: "Fensterbild" }} />
</>,
);
expect(container.querySelectorAll(".wk-handy .wk-handy__schirm img")).toHaveLength(2);
expect(container.querySelectorAll(".wk-fenster img")).toHaveLength(1);
expect(screen.getByAltText("Handybild des Portals")).toHaveAttribute("height", "1688");
});
});
describe("Kundenportal: CSS-Regeln (Spezifikation 8.4)", () => {
const css = ["src/components/home/kundenportal.css", "src/components/geraete-rahmen.css"].map((pfad) => ({
pfad,
text: readFileSync(join(WURZEL, pfad), "utf8"),
}));
/** Liefert zu jeder Deklaration (Eigenschaft: Wert) die Kette der umgebenden @media-Bedingungen. */
const deklarationen = (text: string) => {
const ohneKommentar = text.replace(/\/\*[\s\S]*?\*\//g, "");
const treffer: { eigenschaft: string; wert: string; media: string[]; selektor: string }[] = [];
const stapel: { art: "media" | "regel" | "andere"; text: string }[] = [];
let puffer = "";
for (const zeichen of ohneKommentar) {
if (zeichen === "{") {
const kopf = puffer.trim();
stapel.push({ art: kopf.startsWith("@media") ? "media" : kopf.startsWith("@") ? "andere" : "regel", text: kopf });
puffer = "";
} else if (zeichen === "}") {
const rest = puffer.trim();
if (rest) {
const eigenschaft = rest.split(":")[0].trim();
treffer.push({
eigenschaft,
wert: rest.slice(rest.indexOf(":") + 1).trim(),
media: stapel.filter((s) => s.art === "media").map((s) => s.text),
selektor: stapel.filter((s) => s.art === "regel").map((s) => s.text).join(" "),
});
}
stapel.pop();
puffer = "";
} else if (zeichen === ";") {
const eigenschaft = puffer.trim().split(":")[0].trim();
if (eigenschaft)
treffer.push({
eigenschaft,
wert: puffer.slice(puffer.indexOf(":") + 1).trim(),
media: stapel.filter((s) => s.art === "media").map((s) => s.text),
selektor: stapel.filter((s) => s.art === "regel").map((s) => s.text).join(" "),
});
puffer = "";
} else {
puffer += zeichen;
}
}
return treffer;
};
it("jede transition und animation steht hinter prefers-reduced-motion: no-preference (nur reine Farbwechsel dürfen davor stehen)", () => {
for (const { pfad, text } of css) {
const bewegung = deklarationen(text)
.filter((x) => /^(transition|animation)(-|$)/.test(x.eigenschaft))
.filter((x) => !(x.eigenschaft === "transition" && /^(background-)?color\b/.test(x.wert)));
for (const d of bewegung) {
expect(d.media.join(" "), `${pfad}: ${d.selektor} { ${d.eigenschaft} }`).toContain("prefers-reduced-motion: no-preference");
}
}
});
it("Hover-Regeln nur für Maus (hover: hover) and (pointer: fine); nirgends overflow: hidden", () => {
for (const { pfad, text } of css) {
const hover = deklarationen(text).filter((x) => x.selektor.includes(":hover"));
for (const d of hover) expect(d.media.join(" "), `${pfad}: ${d.selektor}`).toContain("(hover: hover) and (pointer: fine)");
expect(text.replace(/\/\*[\s\S]*?\*\//g, ""), pfad).not.toMatch(/overflow(-[xy])?:\s*hidden/);
}
});
it("es wird nur transform und opacity bewegt (keine Layout-Eigenschaften in Übergängen)", () => {
for (const { pfad, text } of css) {
for (const m of text.matchAll(/transition(?:-property)?:\s*([^;]+);/g)) {
const eigenschaften = m[1]
.split(",")
.map((teil) => teil.trim().split(/\s+/)[0])
.filter(Boolean);
for (const e of eigenschaften) expect(["opacity", "transform", "color", "background-color"], `${pfad}: ${m[0]}`).toContain(e);
}
}
});
});
+279
View File
@@ -0,0 +1,279 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, within, fireEvent, cleanup } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { AppRoutes } from "@/routes";
import { NAVIGATION } from "@/components/site/SiteHeader";
import { projekte } from "@/data/projekte";
// Netzwerk gibt es in jsdom nicht: das Kontaktformular-Backend wird durch eine Attrappe ersetzt.
vi.mock("@/lib/appwrite", () => ({ createContactDocument: vi.fn().mockResolvedValue(undefined) }));
// Dieselben Future-Flags wie der BrowserRouter in App.tsx, sonst warnt React Router in der Testausgabe.
const routerFuture = { v7_startTransition: true, v7_relativeSplatPath: true };
/** Alle Seiten im gemeinsamen Layout (src/routes.tsx). /datenschutz leitet nur weiter und hat kein Layout. */
const ROUTEN = ["/", "/kontakt", "/ueber-uns", "/agb", "/impressum", "/wartungs-abo", "/hosting-abo", "/gibt-es-nicht"];
const UNTERSEITEN = ROUTEN.slice(1);
const seite = (pfad: string) =>
render(
<MemoryRouter initialEntries={[pfad]} future={routerFuture}>
<AppRoutes />
</MemoryRouter>,
);
const lies = (pfad: string) => readFileSync(join(process.cwd(), pfad), "utf8");
afterEach(() => {
cleanup();
document.body.style.overflow = "";
});
describe.each(ROUTEN)("Layout auf %s", (pfad) => {
it("hat genau eine Kopfleiste mit der Hauptnavigation", () => {
seite(pfad);
const kopf = screen.getByRole("banner");
expect(screen.getAllByRole("banner")).toHaveLength(1);
const nav = within(kopf).getByRole("navigation", { name: "Hauptnavigation" });
for (const name of ["Kundenportal", "Was wir bauen", "Preise", "Über uns"]) {
expect(within(nav).getByRole("link", { name })).toBeInTheDocument();
}
// „Projekte“ gibt es nur mit freigegebenen Referenzen (src/data/projekte.ts)
expect(within(nav).queryByRole("link", { name: "Projekte" } as const) !== null).toBe(projekte.length > 0);
expect(within(kopf).getByRole("link", { name: /WEBklar – zur Startseite/ })).toHaveAttribute("href", "/");
expect(within(kopf).getAllByRole("link", { name: "Projekt besprechen" })[0]).toHaveAttribute("href", "/kontakt");
});
it("hat genau einen Footer mit Impressum, Datenschutz, AGB, Kündigen und Widerrufen", () => {
seite(pfad);
const footer = screen.getByRole("contentinfo");
expect(screen.getAllByRole("contentinfo")).toHaveLength(1);
for (const name of ["Impressum", "Datenschutz", "AGB", "Verträge hier kündigen", "Vertrag widerrufen"]) {
expect(within(footer).getByRole("link", { name })).toBeInTheDocument();
}
});
it("hat genau ein main (mit dem Sprungziel #inhalt) und genau ein h1", () => {
seite(pfad);
const inhalt = screen.getByRole("main");
expect(screen.getAllByRole("main")).toHaveLength(1);
expect(inhalt).toHaveAttribute("id", "inhalt");
expect(screen.getAllByRole("heading", { level: 1 })).toHaveLength(1);
expect(screen.getByRole("link", { name: "Zum Inhalt springen" })).toHaveAttribute("href", "#inhalt");
});
it("Kopfleiste und Footer stehen außerhalb von main, in der Reihenfolge Kopf, Inhalt, Footer", () => {
seite(pfad);
const kopf = screen.getByRole("banner");
const inhalt = screen.getByRole("main");
const footer = screen.getByRole("contentinfo");
expect(inhalt).not.toContainElement(kopf);
expect(inhalt).not.toContainElement(footer);
expect(kopf.compareDocumentPosition(inhalt) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(inhalt.compareDocumentPosition(footer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
});
describe("Navigation", () => {
it("die Einträge der Kopfleiste verweisen auf die Anker der Startseite (Spezifikation 6.1)", () => {
seite("/");
const nav = within(screen.getByRole("banner")).getByRole("navigation", { name: "Hauptnavigation" });
const erwartet = NAVIGATION.map(({ label, to }) => [label, to]);
expect(erwartet).toEqual(
expect.arrayContaining([
["Kundenportal", "/#kundenportal"],
["Was wir bauen", "/#leistungen"],
["Preise", "/#preise"],
["Über uns", "/#ueber-uns"],
]),
);
for (const [name, ziel] of erwartet) {
expect(within(nav).getByRole("link", { name })).toHaveAttribute("href", ziel);
}
});
it("jede Unterseite lädt keine eigene Kopfzeile mit Zurück-Knopf mehr (alter glass-nav-Block)", () => {
for (const pfad of UNTERSEITEN) {
const { container } = seite(pfad);
expect(container.querySelector(".glass-nav"), pfad).toBeNull();
expect(screen.queryByRole("link", { name: "Zurück" }), pfad).toBeNull();
expect(screen.queryByRole("button", { name: "Zurück" }), pfad).toBeNull();
cleanup();
}
});
});
describe("Handy-Menü", () => {
it("der Menü-Knopf hat aria-expanded und öffnet und schließt das Menü", () => {
seite("/kontakt");
const knopf = screen.getByRole("button", { name: "Menü öffnen" });
expect(knopf).toHaveAttribute("aria-expanded", "false");
expect(knopf).toHaveAttribute("aria-controls");
expect(screen.queryByRole("navigation", { name: "Menü" })).toBeNull();
fireEvent.click(knopf);
const geoeffnet = screen.getByRole("button", { name: "Menü schließen" });
expect(geoeffnet).toHaveAttribute("aria-expanded", "true");
const menue = screen.getByRole("navigation", { name: "Menü" });
expect(document.getElementById(geoeffnet.getAttribute("aria-controls") as string)).toContainElement(menue);
for (const name of ["Kundenportal", "Was wir bauen", "Preise", "Über uns"]) {
expect(within(menue).getByRole("link", { name })).toBeInTheDocument();
}
// Seite ist gesperrt, solange das Menü offen ist
expect(document.body.style.overflow).toBe("hidden");
fireEvent.click(geoeffnet);
expect(screen.getByRole("button", { name: "Menü öffnen" })).toHaveAttribute("aria-expanded", "false");
expect(document.body.style.overflow).not.toBe("hidden");
});
it("Escape schließt das Menü und gibt den Fokus an den Knopf zurück", () => {
seite("/");
fireEvent.click(screen.getByRole("button", { name: "Menü öffnen" }));
expect(screen.getByRole("button", { name: "Menü schließen" })).toHaveAttribute("aria-expanded", "true");
fireEvent.keyDown(window, { key: "Escape" });
const knopf = screen.getByRole("button", { name: "Menü öffnen" });
expect(knopf).toHaveAttribute("aria-expanded", "false");
expect(knopf).toHaveFocus();
});
it("enthält den Button „Projekt besprechen“ und den Hell/Dunkel-Schalter; ein Klick auf einen Link schließt es", () => {
seite("/impressum");
fireEvent.click(screen.getByRole("button", { name: "Menü öffnen" }));
const menue = screen.getByRole("navigation", { name: "Menü" }).parentElement as HTMLElement;
expect(within(menue).getByRole("link", { name: "Projekt besprechen" })).toHaveAttribute("href", "/kontakt");
expect(within(menue).getByRole("button", { name: /Modus einschalten/ })).toBeInTheDocument();
fireEvent.click(within(menue).getByRole("link", { name: "Preise" }));
expect(screen.getByRole("button", { name: "Menü öffnen" })).toHaveAttribute("aria-expanded", "false");
});
it("Menü-Knopf und Schalter sind mindestens 44 × 44 px groß (Spezifikation 9.6)", () => {
const css = lies("src/components/site/site.css");
for (const klasse of [".wk-header__menuknopf", ".wk-icon-btn"]) {
const block = css.match(new RegExp(`${klasse.replace(".", "\\.")}\\s*\\{([^}]*)\\}`))?.[1] ?? "";
expect(block, klasse).toMatch(/width:\s*44px/);
expect(block, klasse).toMatch(/height:\s*44px/);
}
});
});
describe("/kontakt im neuen Layout", () => {
it("hat den Datenschutzhinweis mit Link und nennt das Wort »Potenzialanalyse« nirgends", () => {
seite("/kontakt");
const formular = screen.getByRole("button", { name: "Nachricht senden" }).closest("form") as HTMLFormElement;
expect(formular).toHaveTextContent(
"Wir verwenden Ihre Angaben nur, um Ihre Anfrage zu beantworten. Mehr dazu in unserer Datenschutzerklärung.",
);
expect(within(formular).getByRole("link", { name: "Datenschutzerklärung" })).toHaveAttribute(
"href",
"https://project.webklar.com/datenschutz.html",
);
expect(document.body).not.toHaveTextContent(/Potenzialanalyse/);
});
it("Felder haben Beschriftung, passendes autocomplete und mindestens 16 px Schrift", () => {
seite("/kontakt");
expect(screen.getByLabelText(/^Name/)).toHaveAttribute("autocomplete", "name");
expect(screen.getByLabelText(/^E-Mail/)).toHaveAttribute("autocomplete", "email");
expect(screen.getByLabelText(/^E-Mail/)).toHaveAttribute("type", "email");
expect(screen.getByLabelText(/^Unternehmen/)).toHaveAttribute("autocomplete", "organization");
for (const name of [/^Name/, /^E-Mail/, /^Nachricht/]) expect(screen.getByLabelText(name)).toBeRequired();
expect(screen.getByLabelText(/^Unternehmen/)).not.toBeRequired();
// iOS zoomt bei weniger als 16 px: rem-Wert der Felder prüfen
const css = lies("src/pages/unterseiten.css");
const block = css.match(/\.wk-feld input,\s*\.wk-feld textarea\s*\{([^}]*)\}/)?.[1] ?? "";
const rem = Number(block.match(/font-size:\s*([\d.]+)rem/)?.[1]);
expect(rem * 16).toBeGreaterThanOrEqual(16);
});
it("Senden-Knopf ist ein wk-btn, E-Mail und Telefon stehen genau einmal da", () => {
seite("/kontakt");
expect(screen.getByRole("button", { name: "Nachricht senden" })).toHaveClass("wk-btn", "wk-btn--primary");
const haupt = screen.getByRole("main");
expect(within(haupt).getAllByRole("link", { name: "support@webklar.com" })).toHaveLength(1);
const telefon = within(haupt).getAllByRole("link", { name: "0176 23726355" });
expect(telefon).toHaveLength(1);
expect(telefon[0]).toHaveAttribute("href", "tel:+4917623726355");
expect(within(haupt).getByRole("link", { name: "support@webklar.com" })).toHaveAttribute(
"href",
"mailto:support@webklar.com",
);
});
it("die Rückmeldung steht in einem aria-live-Bereich, der schon vor dem Absenden im Dokument ist", () => {
seite("/kontakt");
const bereich = document.querySelector("[aria-live]");
expect(bereich).not.toBeNull();
expect(bereich).toBeEmptyDOMElement();
expect(bereich).toHaveAttribute("aria-live", "polite");
});
});
describe("Quelltext der Unterseiten", () => {
const SEITEN = ["Contact", "About", "NotFound", "AGB", "Impressum", "WartungsAbo", "HostingAbo"].map((name) => ({
name,
text: lies(`src/pages/${name}.tsx`),
}));
it.each(SEITEN)("$name: kein eigenes main, keine eigene Kopfleiste, keine Versalien, keine Alt-Buttons, keine Effekte", ({ text }) => {
expect(text).not.toMatch(/<main\b/);
expect(text).not.toMatch(/<header\b/);
expect(text).not.toMatch(/\buppercase\b|tracking-(wider|widest)|font-display|label-tag/);
expect(text).not.toMatch(/glass-nav|btn-minimal|btn-wrapper|className="btn\b/);
expect(text).not.toMatch(/BorderGlow|CountUp|FlickeringGrid|PixelBlast|LightRays|Silk/);
// Seitenrahmen kommt aus dem neuen Layout
expect(text).toMatch(/wk-seite/);
});
it("die Seiten tragen ihr Stylesheet selbst und laden nichts von fremden Servern", () => {
const css = lies("src/pages/unterseiten.css");
expect(css).not.toMatch(/https?:\/\/|@import/);
for (const { name, text } of SEITEN) expect(text, name).toContain('import "./unterseiten.css"');
});
it("Bewegung nur über transform und opacity, Hover nur mit feinem Zeiger", () => {
const css = lies("src/pages/unterseiten.css");
// alle Übergänge der Unterseiten betreffen nur Farben (kein Layout)
for (const [, wert] of css.matchAll(/transition:\s*([^;]+);/g)) {
expect(wert).toMatch(/^(border-color|text-decoration-color)\b/);
}
// oberste Blöcke (Regel oder @media) zerlegen; :hover darf nur in @media (hover: hover) and (pointer: fine) stehen
const bloecke: { kopf: string; inhalt: string }[] = [];
let tiefe = 0;
let start = 0;
let kopfStart = 0;
let kopf = "";
const ohneKommentare = css.replace(/\/\*[\s\S]*?\*\//g, "");
for (let i = 0; i < ohneKommentare.length; i++) {
const z = ohneKommentare[i];
if (z === "{") {
if (tiefe === 0) {
kopf = ohneKommentare.slice(kopfStart, i).trim();
start = i + 1;
}
tiefe++;
} else if (z === "}") {
tiefe--;
if (tiefe === 0) {
bloecke.push({ kopf, inhalt: ohneKommentare.slice(start, i) });
kopfStart = i + 1;
}
}
}
expect(bloecke.length).toBeGreaterThan(20);
let hoverRegeln = 0;
for (const { kopf: k, inhalt } of bloecke) {
const text = `${k}${inhalt}`;
if (!text.includes(":hover")) continue;
hoverRegeln++;
expect(k, `${k} enthält :hover`).toBe("@media (hover: hover) and (pointer: fine)");
}
expect(hoverRegeln).toBeGreaterThan(0);
});
});
@@ -0,0 +1,331 @@
import { describe, it, expect, afterEach } from "vitest";
import { render, screen, within, cleanup } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import Leistungen from "@/components/home/Leistungen";
import Preise from "@/components/home/Preise";
import HostingAbo from "@/pages/HostingAbo";
import WartungsAbo from "@/pages/WartungsAbo";
import { ABOS, WEITERE_ARBEIT, weitereArbeitText, type Abo } from "@/data/abos";
// Dieselben Future-Flags wie der BrowserRouter in App.tsx, sonst warnt React Router in der Testausgabe.
const routerFuture = { v7_startTransition: true, v7_relativeSplatPath: true };
const zeige = (ui: React.ReactElement) => render(<MemoryRouter future={routerFuture}>{ui}</MemoryRouter>);
/** Text ohne geschützte Leerzeichen und mit einfachen Leerzeichen, damit Vergleiche stabil sind. */
const norm = (text: string | null | undefined) => (text ?? "").replace(/\u00a0/g, " ").replace(/\s+/g, " ").trim();
const WURZEL = `${process.cwd()}/`;
const lies = (pfad: string) => readFileSync(join(WURZEL, pfad), "utf8");
const MEINE_DATEIEN = [
"src/data/abos.ts",
"src/components/home/Leistungen.tsx",
"src/components/home/Preise.tsx",
"src/components/home/leistungen.css",
"src/components/home/preise.css",
];
const originalMatchMedia = window.matchMedia;
/** Simuliert einen Bildschirm ab 900 px (Desktop-Darstellung) oder darunter (Handy). */
const stelleBreiteEin = (desktop: boolean) => {
window.matchMedia = ((query: string) => ({
matches: desktop && query === "(min-width: 900px)",
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
})) as typeof window.matchMedia;
};
afterEach(() => {
cleanup();
window.matchMedia = originalMatchMedia;
});
// ── Was wir bauen ───────────────────────────────────────────────────────────
const ERWARTET = [
["Website", "Startseite, Unterseiten, Impressum und Datenschutz – gestaltet, getextet und online."],
["Online-Shop", "Warenkorb, Kasse, Zahlung und Versand. Auf Wunsch angebunden an Ihre Warenwirtschaft."],
[
"Web-App",
"Eigene Software für Ihren Betrieb mit Login, Verwaltung und Auswertungen, im Browser und auf dem Handy.",
],
[
"Automatisierung",
"Was sich ständig wiederholt, erledigt der Rechner: Bestätigungen, Rechnungen, Dokumente auslesen, ein KI-Assistent mit Ihrem Firmenwissen.",
],
] as const;
const EINLEITUNG = "Die meisten Betriebe starten mit einer Website. Wenn mehr nötig ist, bauen wir mehr.";
describe("Leistungen (Spezifikation 7.3)", () => {
it("ist ein Abschnitt mit Anker #leistungen und der Überschrift als h2", () => {
const { container } = zeige(<Leistungen />);
const abschnitt = container.querySelector("section#leistungen");
expect(abschnitt).not.toBeNull();
const h2 = within(abschnitt as HTMLElement).getByRole("heading", { level: 2 });
expect(h2).toHaveTextContent("Was wir für Sie bauen.");
expect(abschnitt).toHaveAttribute("aria-labelledby", h2.id);
// die Kopfleiste verlinkt genau diesen Anker
expect(lies("src/components/site/SiteHeader.tsx")).toContain("/#leistungen");
});
it("zeigt die vier Einträge in der richtigen Reihenfolge, auf dem Handy als Wisch-Reihe", () => {
stelleBreiteEin(false);
const { container } = zeige(<Leistungen />);
const reihe = screen.getByRole("list", { name: "Vier Leistungen, seitlich wischen" });
expect(reihe).toHaveAttribute("tabindex", "0");
const karten = within(reihe).getAllByRole("listitem");
expect(karten).toHaveLength(4);
karten.forEach((karte, i) => {
expect(within(karte).getByRole("heading", { level: 3 })).toHaveTextContent(ERWARTET[i][0]);
expect(karte).toHaveTextContent(ERWARTET[i][1]);
expect(karte).toHaveTextContent(`0${i + 1}`);
});
// Zähler „1 / 4“ unter der Reihe, keine Desktop-Liste daneben
expect(container.querySelector(".wk-wisch__zaehler")).toHaveTextContent("1 / 4");
expect(container.querySelector(".wk-leistungen__liste")).toBeNull();
});
it("zeigt am Desktop eine nummerierte Liste aus Textzeilen und keine Wisch-Reihe mehr", () => {
stelleBreiteEin(true);
const { container } = zeige(<Leistungen />);
expect(container.querySelector(".wk-wisch")).toBeNull();
const liste = container.querySelector("ol.wk-leistungen__liste") as HTMLElement;
expect(liste).not.toBeNull();
const zeilen = within(liste).getAllByRole("listitem");
expect(zeilen).toHaveLength(4);
zeilen.forEach((zeile, i) => {
expect(within(zeile).getByRole("heading", { level: 3 })).toHaveTextContent(ERWARTET[i][0]);
expect(zeile).toHaveTextContent(ERWARTET[i][1]);
expect(zeile.querySelector(".wk-num")).toHaveTextContent(`0${i + 1}`);
});
// keine Karten mit Symbolen: kein Bild, kein SVG in der Liste
expect(liste.querySelector("svg, img")).toBeNull();
});
it("hat die Schlusszeile über den Umzug bestehender Websites", () => {
const { container } = zeige(<Leistungen />);
expect(norm(container.querySelector(".wk-leistungen__umzug")?.textContent)).toBe(
"Sie haben schon eine Website? Wir ziehen sie um – mit Domain, Inhalten und Postfächern.",
);
});
it("die Einleitung hat höchstens 30 Wörter und steht für Screenreader einmal im Ganzen im Text", () => {
const { container } = zeige(<Leistungen />);
expect(EINLEITUNG.split(/\s+/).length).toBeLessThanOrEqual(30);
const absatz = container.querySelector(".wk-leistungen__einleitung") as HTMLElement;
// genau ein sichtbarer Satz für Screenreader …
const ganz = within(absatz).getByText(EINLEITUNG);
expect(ganz).toHaveClass("wk-visually-hidden");
expect(ganz.closest("[aria-hidden]")).toBeNull();
// … die Wort-Spans für den Effekt sind für Screenreader versteckt und ergeben denselben Satz
const woerter = absatz.querySelectorAll(".wk-leistungen__wort");
expect(woerter).toHaveLength(EINLEITUNG.split(" ").length);
woerter.forEach((w) => expect(w.closest('[aria-hidden="true"]')).not.toBeNull());
expect(norm(Array.from(woerter).map((w) => w.textContent).join(" "))).toBe(EINLEITUNG);
});
it("das Aufhellen läuft nur mit Scroll-Animationen, ohne reduzierte Bewegung und nur über opacity", () => {
const css = lies("src/components/home/leistungen.css");
const ohneKommentare = css.replace(/\/\*[\s\S]*?\*\//g, "");
const media = ohneKommentare.indexOf("@media (prefers-reduced-motion: no-preference)");
const supports = ohneKommentare.indexOf("@supports (animation-timeline: view())");
const zeitleiste = ohneKommentare.search(/^\s*(animation-timeline|view-timeline)\s*:/m);
expect(media).toBeGreaterThan(-1);
expect(supports).toBeGreaterThan(media);
// jede Zeitleiste steht hinter @media (no-preference) und @supports (siehe Spezifikation 8.4)
expect(zeitleiste).toBeGreaterThan(supports);
// gedimmt 0,18 → voll, sonst wird nichts animiert
const keyframes = ohneKommentare.match(/@keyframes[^{]+\{([\s\S]*?\})\s*\}/);
expect(keyframes?.[0]).toContain("opacity: 0.18");
expect(keyframes?.[0]).toContain("opacity: 1");
expect(keyframes?.[0]).not.toMatch(/transform|margin|width|height|top|left/);
// keine Hover-Effekte, die nicht hinter (hover: hover) stehen
expect(ohneKommentare).not.toContain(":hover");
});
});
// ── Preise ──────────────────────────────────────────────────────────────────
describe("Preise (Spezifikation 7.5)", () => {
it("ist ein Abschnitt mit Anker #preise, h2, Einleitung und genau zwei Abo-Blöcken", () => {
const { container } = zeige(<Preise />);
const abschnitt = container.querySelector("section#preise") as HTMLElement;
expect(abschnitt).not.toBeNull();
const h2 = within(abschnitt).getByRole("heading", { level: 2 });
expect(h2).toHaveTextContent("Was es kostet. Vorher.");
expect(abschnitt).toHaveAttribute("aria-labelledby", h2.id);
expect(lies("src/components/site/SiteHeader.tsx")).toContain("/#preise");
expect(norm(abschnitt.querySelector(".wk-lead")?.textContent)).toBe(
"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 (§ 19 UStG) berechnen wir keine Umsatzsteuer.",
);
const blocks = within(abschnitt).getAllByRole("article");
expect(blocks.map((b) => within(b).getByRole("heading", { level: 3 }).textContent)).toEqual([
"Hosting-Abo",
"Wartungs-Abo",
]);
});
it("nennt Preis, Wortlaut und Link je Block wie in der Spezifikation", () => {
zeige(<Preise />);
const hosting = screen.getByRole("article", { name: "Hosting-Abo" });
expect(norm(within(hosting).getByText(/im Monat/).parentElement?.textContent)).toBe("20 € im Monat");
expect(norm(hosting.querySelector(".wk-abo__text")?.textContent)).toBe(
"Für statische Websites: Hosting mit SSL-Zertifikat, täglicher Datensicherung und Überwachung der Erreichbarkeit, " +
"eine Domain und ein E-Mail-Postfach mit 5 GB. 12 Monate Mindestlaufzeit.",
);
expect(within(hosting).getByRole("link", { name: "Details zum Hosting-Abo" })).toHaveAttribute(
"href",
"/hosting-abo",
);
const wartung = screen.getByRole("article", { name: "Wartungs-Abo" });
expect(norm(within(wartung).getByText(/im Monat/).parentElement?.textContent)).toBe("50 € im Monat");
expect(norm(wartung.querySelector(".wk-abo__text")?.textContent)).toBe(
"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 5 GB und eine Stunde Arbeitszeit pro Monat. " +
"12 Monate Mindestlaufzeit.",
);
expect(within(wartung).getByRole("link", { name: "Details zum Wartungs-Abo" })).toHaveAttribute(
"href",
"/wartungs-abo",
);
});
it("hat die Zeile zu weiterer Arbeit mit 100 € pro Stunde in 15-Minuten-Schritten", () => {
const { container } = zeige(<Preise />);
expect(norm(container.querySelector(".wk-preise__weitere")?.textContent)).toBe(
"Weitere Arbeit nur mit Ihrer Freigabe: 100 € pro Stunde, abgerechnet in 15-Minuten-Schritten.",
);
expect(norm(weitereArbeitText())).toBe(norm(container.querySelector(".wk-preise__weitere")?.textContent));
});
it("zählt nichts hoch und trägt Preise in Tabellenziffern", () => {
const { container } = zeige(<Preise />);
container.querySelectorAll(".wk-abo__betrag").forEach((betrag) => expect(betrag).toHaveClass("wk-num"));
expect(lies("src/components/home/Preise.tsx")).not.toMatch(/CountUp|useSpring|requestAnimationFrame/);
expect(lies("src/components/home/preise.css")).toMatch(/tabular-nums|wk-num/);
});
});
// ── Eine Quelle: Startseite und Abo-Seiten sagen dasselbe ───────────────────
type Angaben = { preis: number; laufzeit: number; speicherGb: number; stunden: number };
const zahl = (muster: RegExp, text: string): number => {
const treffer = muster.exec(text);
if (!treffer) throw new Error(`Nicht gefunden: ${muster} in »${text.slice(0, 120)}…«`);
return Number(treffer[1]);
};
/** Liest die Angaben aus der gerenderten Abo-Seite (die Seiten selbst bleiben unverändert). */
const ausAboSeite = (seite: "hosting" | "wartung"): Angaben & { stundensatz: number; schritt: number } => {
const { unmount } = zeige(seite === "hosting" ? <HostingAbo /> : <WartungsAbo />);
const text = norm(document.body.textContent);
unmount();
return {
preis: zahl(/(\d+),00 € pro Monat, \d+ Monate Mindestlaufzeit/, text),
laufzeit: zahl(/\d+,00 € pro Monat, (\d+) Monate Mindestlaufzeit/, text),
speicherGb: zahl(/(\d+) GB Speicher/, text),
stunden: /(\d+) Stunde Arbeitszeit pro Monat/.test(text) ? zahl(/(\d+) Stunde Arbeitszeit pro Monat/, text) : 0,
stundensatz: zahl(/(\d+) € pro Stunde/, text),
schritt: zahl(/(\d+)-Minuten-Schritten/, text),
};
};
/** Liest die Angaben eines Blocks aus der gerenderten Startseite. */
const ausStartseite = (abo: Abo): Angaben => {
zeige(<Preise />);
const block = norm(screen.getByRole("article", { name: abo.name }).textContent);
const stunden = /(eine|\d+) Stunde Arbeitszeit pro Monat/.exec(block);
return {
preis: zahl(/(\d+) € ?im Monat/, block),
laufzeit: zahl(/(\d+) Monate Mindestlaufzeit/, block),
speicherGb: zahl(/(\d+) GB/, block),
stunden: stunden ? (stunden[1] === "eine" ? 1 : Number(stunden[1])) : 0,
};
};
describe("Preise auf der Startseite stimmen mit den Abo-Seiten überein", () => {
for (const abo of ABOS) {
it(`${abo.name}: Preis, Mindestlaufzeit, Speicher und Arbeitszeit sind auf Startseite, Abo-Seite und im Datenmodul gleich`, () => {
const seite = ausAboSeite(abo.id);
const start = ausStartseite(abo);
const daten: Angaben = {
preis: abo.preis,
laufzeit: abo.laufzeitMonate,
speicherGb: abo.speicherGb,
stunden: abo.stundenImMonat,
};
expect(start).toEqual({ preis: seite.preis, laufzeit: seite.laufzeit, speicherGb: seite.speicherGb, stunden: seite.stunden });
expect(start).toEqual(daten);
});
it(`${abo.name}: der Link der Startseite führt auf die Abo-Seite, die es gibt`, () => {
zeige(<Preise />);
const link = screen.getByRole("link", { name: abo.link.text });
expect(link).toHaveAttribute("href", abo.link.to);
expect(lies("src/routes.tsx")).toContain(`path="${abo.link.to}"`);
});
}
it("Stundensatz und Abrechnungsschritt gelten für beide Abos und stehen auf beiden Abo-Seiten gleich", () => {
for (const abo of ABOS) {
const seite = ausAboSeite(abo.id);
expect(seite.stundensatz).toBe(WEITERE_ARBEIT.stundensatzEuro);
expect(seite.schritt).toBe(WEITERE_ARBEIT.schrittMinuten);
}
});
it("Preise.tsx enthält keine eigenen Eurobeträge, alles kommt aus src/data/abos.ts", () => {
expect(lies("src/components/home/Preise.tsx")).not.toMatch(/\d\s*(€|Euro)/);
});
});
// ── Verbotene Wörter (Spezifikation 8.5 Punkt 5) und Quelltext ──────────────
describe("Wortwahl und Quelltext der beiden Abschnitte", () => {
const VERBOTEN =
/maßgeschneidert|ganzheitlich|innovativ|nahtlos|nächste[nsrm]? Level|\bHebel\b|Tool-Inseln|Pipeline|Potenzialanalyse/i;
const UNBELEGT = /99[,.]9|24 Stunden|unsplash|garantiert|Erfolgsgarantie/i;
it("kein verbotenes Wort in den eigenen Dateien", () => {
for (const pfad of MEINE_DATEIEN) {
expect(lies(pfad), pfad).not.toMatch(VERBOTEN);
expect(lies(pfad), pfad).not.toMatch(UNBELEGT);
}
});
it("kein verbotenes Wort im gerenderten Text beider Abschnitte (Handy und Desktop)", () => {
for (const desktop of [false, true]) {
stelleBreiteEin(desktop);
const { container } = zeige(
<>
<Leistungen />
<Preise />
</>,
);
expect(container.textContent).not.toMatch(VERBOTEN);
expect(container.textContent).not.toMatch(UNBELEGT);
cleanup();
}
});
it("keine Effekt-Bausteine, keine Fremdanfragen, keine Schatten und Farbverläufe", () => {
for (const pfad of MEINE_DATEIEN) {
const text = lies(pfad);
expect(text, pfad).not.toMatch(/https?:\/\//);
expect(text, pfad).not.toMatch(/BorderGlow|LightRays|PixelBlast|Silk|FlickeringGrid|CountUp/);
expect(text, pfad).not.toMatch(/box-shadow|text-shadow|gradient|backdrop-filter|blur\(/);
}
});
});
+96 -31
View File
@@ -16,8 +16,8 @@ import Process from "@/components/Process";
import KontaktAbschnitt from "@/components/Contact";
import About from "@/pages/About";
import { TextHoverEffect } from "@/components/ui/text-hover-effect";
import { Toaster } from "@/components/ui/toaster";
import { ScrollToTop } from "@/App";
import { createContactDocument } from "@/lib/appwrite";
// WebGL, Canvas und Netzwerk laden wir in jsdom nicht: die Effekt-Komponenten und
// das Kontaktformular-Backend werden durch Attrappen ersetzt.
@@ -128,11 +128,13 @@ describe("B2 Projekte-Bereich ausgeblendet", () => {
it("die Startseite hat keinen Projekte-Abschnitt und keinen Nav-Link dorthin", () => {
const { container } = seite("/");
expect(container.querySelector("#projects")).toBeNull();
expect(container.querySelector("#projekte")).toBeNull();
expect(container.querySelector('a[href$="#projects"]')).toBeNull();
expect(container.querySelector('a[href$="#projekte"]')).toBeNull();
expect(screen.queryByRole("link", { name: "Projekte" })).toBeNull();
expect(screen.queryByText("Verbatim Labs")).toBeNull();
// die übrigen Nav-Punkte bleiben
for (const name of ["Über uns", "Leistungen", "Ablauf"]) {
// die übrigen Nav-Punkte der neuen Kopfleiste (Spezifikation 6.1) bleiben
for (const name of ["Kundenportal", "Was wir bauen", "Preise", "Über uns"]) {
expect(screen.getAllByRole("link", { name }).length).toBeGreaterThan(0);
}
});
@@ -162,51 +164,79 @@ describe("B3 Eine Telefonnummer überall", () => {
});
describe("B4 Antwortzeit: spätestens am nächsten Werktag", () => {
// Spezifikation 6.5: Titel und Beschreibung der Startseite und von /kontakt
const STARTSEITE_TITEL = "WEBklar – Websites, Shops und Apps für kleine Betriebe";
const STARTSEITE =
"WEBklar digitalisiert KMU: Website, Prozesse und Systeme aus einer Hand. Kostenlose Potenzialanalyse – Antwort spätestens am nächsten Werktag.";
const KONTAKT =
"Beschreiben Sie Ihr Projekt – WEBklar meldet sich spätestens am nächsten Werktag mit den nächsten Schritten. Unverbindlich und kostenlos.";
"WEBklar baut Websites, Online-Shops und Web-Apps. Im Kundenportal sehen Sie jeden Schritt, mit Live-Vorschau Ihrer Website.";
const KONTAKT = "Beschreiben Sie Ihr Vorhaben in zwei Sätzen. WEBklar meldet sich spätestens am nächsten Werktag.";
it("kein 24-Stunden-Versprechen mehr in Quelltext und index.html (Tailwind-Klassen wie mt-24 h-48 zählen nicht)", () => {
expect(vorkommen(/(?<![\w-])24[\s-]*(Stunden|Std\b|h(?![\w-]))/i)).toEqual([]);
});
it("index.html: Beschreibung mit der neuen Antwortzeit", () => {
expect(lies("index.html")).toContain(`content="${STARTSEITE}"`);
it("index.html: Titel und Beschreibung der Startseite ohne »Potenzialanalyse«", () => {
const html = lies("index.html");
expect(html).toContain(STARTSEITE_TITEL);
expect(html).toContain(`content="${STARTSEITE}"`);
expect(html).not.toMatch(/Potenzialanalyse/);
});
it("Standard-Beschreibung des Meta-Hooks und der Startseite", () => {
it("Standard-Beschreibung des Meta-Hooks", () => {
const Probe = () => {
usePageMeta("Probe");
return null;
};
render(<Probe />);
expect(meta("description")).toBe(STARTSEITE);
cleanup();
});
it("die Startseite setzt Titel und Beschreibung aus Spezifikation 6.5", () => {
seite("/");
expect(document.title).toBe(STARTSEITE_TITEL);
expect(meta("description")).toBe(STARTSEITE);
});
it("Kontaktseite: Meta-Beschreibung und Einleitung", () => {
it("Kontaktseite: Titel, Meta-Beschreibung und Einleitung", () => {
seite("/kontakt");
expect(document.title).toBe("Projekt besprechen | WEBklar");
expect(meta("description")).toBe(KONTAKT);
expect(screen.getByText(/den größten Hebel haben\./)).toHaveTextContent(
"den größten Hebel haben. Antwort spätestens am nächsten Werktag, unverbindlich.",
expect(screen.getByRole("heading", { level: 1, name: "Projekt besprechen" })).toBeInTheDocument();
expect(screen.getByText(/^Zwei Sätze genügen\./)).toHaveTextContent(
"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.",
);
});
it("Kontaktformular: Erfolgsmeldung mit der neuen Antwortzeit", async () => {
render(
<MemoryRouter initialEntries={["/kontakt"]} future={routerFuture}>
<AppRoutes />
<Toaster />
</MemoryRouter>,
);
/** Füllt die Pflichtfelder von /kontakt aus und sendet das Formular ab. */
const kontaktAbsenden = () => {
seite("/kontakt");
fireEvent.change(screen.getByLabelText(/^Name/), { target: { value: "Erika Muster" } });
fireEvent.change(screen.getByLabelText(/^E-Mail/), { target: { value: "erika@example.de" } });
fireEvent.change(screen.getByLabelText(/^Nachricht/), { target: { value: "Wir brauchen eine neue Website." } });
fireEvent.click(screen.getByRole("button", { name: /Nachricht senden/ }));
expect(await screen.findByText("Wir melden uns spätestens am nächsten Werktag bei Ihnen.")).toBeInTheDocument();
};
it("Kontaktformular: Erfolgsmeldung mit der neuen Antwortzeit steht in einem aria-live-Bereich", async () => {
kontaktAbsenden();
const meldung = await screen.findByText("Wir melden uns spätestens am nächsten Werktag bei Ihnen.");
expect(meldung.closest("[aria-live]")).toHaveAttribute("aria-live", "polite");
expect(createContactDocument).toHaveBeenCalledWith({
name: "Erika Muster",
email: "erika@example.de",
company: "",
message: "Wir brauchen eine neue Website.",
});
// nach dem Senden ist das Formular wieder leer
expect(screen.getByLabelText(/^Name/)).toHaveValue("");
});
it("Kontaktformular: bei einem Fehler bleibt die Eingabe stehen und eine verständliche Meldung erscheint", async () => {
vi.mocked(createContactDocument).mockRejectedValueOnce(new Error("Appwrite ist nicht konfiguriert."));
kontaktAbsenden();
const meldung = await screen.findByText(/Bitte versuchen Sie es noch einmal/);
expect(meldung.closest("[aria-live]")).toHaveAttribute("aria-live", "polite");
// keine technische Fehlermeldung für Besucher
expect(document.body).not.toHaveTextContent(/Appwrite|VITE_/);
expect(screen.getByLabelText(/^Name/)).toHaveValue("Erika Muster");
});
});
@@ -259,6 +289,15 @@ describe("B5 Erfolgsversprechen entschärft", () => {
).toBeInTheDocument();
expect(screen.queryByText("Langfristige Partnerschaft")).toBeNull();
});
it("Über uns: ohne Glüh-Rahmen und hochzählende Zahlen, ohne »Potenzialanalyse« (Spezifikation 8.5)", () => {
const { container } = zeige(<About />);
expect(container.querySelector(".border-glow-card")).toBeNull();
expect(lies("src/pages/About.tsx")).not.toMatch(/BorderGlow|CountUp/);
expect(container).not.toHaveTextContent(/Potenzialanalyse/);
expect(screen.getByRole("heading", { level: 1 })).toBeInTheDocument();
expect(screen.getAllByRole("link", { name: "Projekt besprechen" }).length).toBeGreaterThan(0);
});
});
describe("C1 Footer auf allen Unterseiten", () => {
@@ -334,11 +373,11 @@ describe("C5 404-Seite", () => {
expect(screen.queryByText(/Oops|Return to Home|Page not found/)).toBeNull();
});
it("nutzt die Theme-Farben statt eines festen grauen Hintergrunds", () => {
it("steht im Seitenrahmen der Unterseiten mit Theme-Farben, der Link ist ein Button", () => {
const { container } = seite("/gibt-es-nicht");
expect(container.querySelector(".bg-background.text-foreground")).not.toBeNull();
expect(container.querySelector(".wk-seite")).not.toBeNull();
expect(container.querySelector(".bg-muted")).toBeNull();
expect(screen.getByRole("link", { name: "Zur Startseite" })).toHaveClass("text-foreground");
expect(screen.getByRole("link", { name: "Zur Startseite" })).toHaveClass("wk-btn", "wk-btn--primary");
});
it("Textfarben der Seite erreichen auf dem Hintergrund in beiden Themes mindestens 4,5:1", () => {
@@ -350,6 +389,34 @@ describe("C5 404-Seite", () => {
});
});
// ── Kontrast der Relaunch-Token (Spezifikation 8.2), gelesen aus src/styles/relaunch.css ──
/** Hex-Token ("--fg: #1d1d1f") eines Themes aus relaunch.css. */
const relaunchFarbe = (thema: Thema, name: string): RGB => {
const css = lies("src/styles/relaunch.css");
const block = css.match(thema === "dunkel" ? /\.dark\s*\{([^}]*)\}/ : /:root\s*\{([^}]*)\}/)?.[1] ?? "";
const wert = block.match(new RegExp(`--${name}:\\s*(#[0-9a-fA-F]{6})\\s*;`));
if (!wert) throw new Error(`Token --${name} fehlt im Theme ${thema}`);
return farbe(wert[1]);
};
describe("C6 Kontrast-Token des Relaunchs", () => {
it.each(THEMEN)("%s: Text, Nebentext und roter Text erreichen auf --bg und --bg-alt mindestens 4,5:1", (thema) => {
for (const flaeche of ["bg", "bg-alt"]) {
for (const text of ["fg", "fg-muted", "accent-text", "focus"]) {
expect(kontrast(relaunchFarbe(thema, text), relaunchFarbe(thema, flaeche))).toBeGreaterThanOrEqual(4.5);
}
}
});
it.each(THEMEN)("%s: Kartenpunkt und Rahmenlinien (Nicht-Text) erreichen auf beiden Flächen mindestens 3:1", (thema) => {
for (const flaeche of ["bg", "bg-alt"]) {
for (const linie of ["accent", "line-strong"]) {
expect(kontrast(relaunchFarbe(thema, linie), relaunchFarbe(thema, flaeche))).toBeGreaterThanOrEqual(3);
}
}
});
});
describe("C4 Schreibweise WEBklar", () => {
it("kein sichtbares »Webklar« mehr im Quelltext (URLs, Mailadressen und Kleinschreibung ausgenommen)", () => {
expect(vorkommen(/(?<![\w./@:-])Webklar(?![\w@.-])/)).toEqual([]);
@@ -378,12 +445,11 @@ describe("C2 Datenschutzhinweis am Kontaktformular", () => {
expect(within(hinweis).getByRole("link", { name: "Datenschutzerklärung" })).toHaveAttribute("href", DATENSCHUTZ);
});
it("ist kleiner, gedämpfter Text mit gut lesbarem Link (Farb-Token siehe C5)", () => {
it("ist kleiner, gedämpfter Text (14 px) mit unterstrichenem Link (Farb-Token siehe C6)", () => {
seite("/kontakt");
const hinweis = screen.getByRole("link", { name: "Datenschutzerklärung" }).closest("p") as HTMLElement;
expect(hinweis).toHaveClass("text-muted-foreground");
expect(hinweis.className).toMatch(/text-(xs|sm)/);
expect(screen.getByRole("link", { name: "Datenschutzerklärung" })).toHaveClass("text-foreground", "underline");
expect(hinweis).toHaveClass("wk-muted", "wk-small");
expect(lies("src/pages/unterseiten.css")).toMatch(/\.wk-form__datenschutz a\s*\{[^}]*text-decoration:\s*underline/);
});
});
@@ -603,7 +669,6 @@ describe("A1 Karten (BorderGlow) im hellen Modus lesbar", () => {
const KARTEN: { name: string; ui: React.ReactElement; anzahl: number; dunkel: string }[] = [
{ name: "Values (Was Sie bekommen)", ui: <Values />, anzahl: 6, dunkel: "hsl(0 0% 6%)" },
{ name: "Kontaktabschnitt (Der nächste Schritt)", ui: <KontaktAbschnitt />, anzahl: 1, dunkel: "#120F17" },
{ name: "Über uns (Der Unterschied)", ui: <About />, anzahl: 3, dunkel: "hsl(0 0% 4%)" },
];
it.each(KARTEN)("$name: dunkle Kartenfarbe unverändert, helle Variante vorhanden", ({ ui, anzahl, dunkel }) => {
@@ -669,8 +734,8 @@ describe("A1 Karten (BorderGlow) im hellen Modus lesbar", () => {
});
describe("Nacharbeit nach der Prüfung", () => {
it("99,9 steht nirgends mehr im Quelltext", () => {
expect(vorkommen(/99[,.]9/)).toEqual([]);
it("99,9 steht nirgends mehr im Quelltext (der CSS-Haltepunkt 899.98px zählt nicht)", () => {
expect(vorkommen(/99[,.]9(?!8px)/)).toEqual([]);
});
it("ScrollToTop springt bei einem Anker zum Abschnitt (Footer-Link »Leistungen« von einer Unterseite)", async () => {