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
@@ -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;
|
||||
Reference in new issue
Block a user