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(null); const buehne = useRef(null); const marker = useRef<(HTMLDivElement | null)[]>([]); useEffect(() => { if (typeof IntersectionObserver === "undefined") return; const beobachter = new IntersectionObserver( (eintraege) => { for (const e of eintraege) { if (!e.isIntersecting) continue; const i = Number((e.target as HTMLElement).dataset.index); setAktiv(i); setHoechster((h) => Math.max(h, i)); } }, // Messlinie in der Bildschirmmitte: die Marke, die sie kreuzt, ist die aktive { rootMargin: "-50% 0px -50% 0px", threshold: 0 }, ); marker.current.forEach((m) => m && beobachter.observe(m)); return () => beobachter.disconnect(); }, []); // Scrollfortschritt des letzten Schritts (0 bis 1) für den langen Screenshot useEffect(() => { const el = buehne.current; const huellenEl = huelle.current; const letzterMarker = marker.current[LETZTER]; if (!el || !huellenEl || !letzterMarker || !window.matchMedia) return; const ruhig = window.matchMedia("(prefers-reduced-motion: no-preference)"); let raf = 0; const berechne = () => { raf = 0; if (!ruhig.matches) { el.style.setProperty("--kp-q", "0"); return; } const vh = window.innerHeight; const huelleBox = huellenEl.getBoundingClientRect(); if (huelleBox.bottom < -vh || huelleBox.top > 2 * vh) return; const oben = parseFloat(getComputedStyle(el).top) || 0; const gescrollt = oben - huelleBox.top; // Strecke seit dem Anheften const strecke = huelleBox.height - el.offsetHeight; // gesamte Pin-Strecke const seitSchritt6 = vh / 2 - letzterMarker.getBoundingClientRect().top; const laenge = strecke - (gescrollt - seitSchritt6); // Länge des letzten Schritts const vorlauf = 0.12 * vh; // erst bewegt sich der Rahmen, dann der Inhalt const halten = 0.2 * vh; // am Ende bleibt der lange Screenshot kurz stehen const q = begrenze((seitSchritt6 - vorlauf) / Math.max(1, laenge - vorlauf - halten), 0, 1); el.style.setProperty("--kp-q", q.toFixed(4)); }; const planen = () => { if (!raf) raf = requestAnimationFrame(berechne); }; window.addEventListener("scroll", planen, { passive: true }); window.addEventListener("resize", planen); ruhig.addEventListener?.("change", planen); berechne(); return () => { window.removeEventListener("scroll", planen); window.removeEventListener("resize", planen); ruhig.removeEventListener?.("change", planen); if (raf) cancelAnimationFrame(raf); }; }, []); /** Klick in der Liste: zur Marke dieser Funktion scrollen (weich oder sofort entscheidet das CSS der Seite). */ const springe = (i: number) => { const huellenEl = huelle.current; const buehnenEl = buehne.current; const m = marker.current[i]; if (!huellenEl || !buehnenEl || !m) return; const oben = parseFloat(getComputedStyle(buehnenEl).top) || 0; const huelleOben = huellenEl.getBoundingClientRect().top; // Marke knapp über die Messlinie legen, mindestens aber so weit scrollen, dass die Bühne angeheftet ist const zurMarke = huelleOben + m.offsetTop - (window.innerHeight / 2 - 1); const zurBuehne = huelleOben - oben; window.scrollTo({ top: window.scrollY + Math.max(zurMarke, zurBuehne) }); }; const app = aktiv === LETZTER; const eingehaengt = (i: number) => i <= hoechster + 1; return (
); }; // ── Handy: Wisch-Reihe ─────────────────────────────────────────────────────── /** Karte "Als App": der lange Screenshot schiebt sich langsam nach oben, solange die Karte sichtbar ist. */ const LangeKarte = ({ f }: { f: Funktion }) => { const ref = useRef(null); const [sichtbar, setSichtbar] = useState(false); useEffect(() => { const el = ref.current; if (!el || typeof IntersectionObserver === "undefined") return; const beobachter = new IntersectionObserver(([e]) => setSichtbar(e.isIntersecting), { threshold: 0.6 }); beobachter.observe(el); return () => beobachter.disconnect(); }, []); return (

{f.titel}

{f.unterschrift}

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

{f.titel}

{f.unterschrift}

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

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

{EINLEITUNG}

{desktop ? : }
); }; export default Kundenportal;