Files
Webklar/src/components/home/Kundenportal.tsx
T
rootandClaude Opus 5.5 c9ad9ba2b7 Portal-Screenshots im Geräteformat neu aufgenommen
iPhone 16 Pro 402 x 874 (Safe Area 59/34) und MacBook-Safari-Fläche 1512 x 893,
je @2x, weiter nur mit den Demodaten der Bäckerei Sonnenschein. Kein Versatz
mehr im iPhone; im letzten Schritt bleibt die Statusleiste deckend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 18:54:23 +00:00

406 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 3024 x 1786 (MacBook-Safari-Fläche 1512 x 893 @2x), 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: 945,
alt,
});
/** Handy-Bild: Rohformat 804 x 1748 = iPhone 16 Pro @2x (das lange Bild 804 x 5131), ausgeliefert in 390 / 780 px;
* width/height geben das Seitenverhältnis bei 780 px an. */
const handyBild = (name: string, alt: string, sizes: string, hoehe = 1696): 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", 4978)} />
</div>
) : (
<RahmenBild bild={handyBild(f.handy.name, f.handy.alt, "(min-width: 1200px) 230px, 20vw")} />
)}
</div>
) : null,
)}
</HandyRahmen>
</div>
</div>
</div>
</div>
);
};
// ── 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", 4978)} />
</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="Ablauf in vier Schritten">
{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="Ablauf in vier Schritten, 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;