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:
1 parent
69d006334f
commit
1c8cfa5c1c
54 files changed
+4761
-1166
No files matched your search
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user