import { lazy, Suspense, useEffect, useRef, useState, useSyncExternalStore, version, type ComponentType, type ReactNode, type RefObject } from "react"; import WischReihe from "@/components/WischReihe"; import { DesktopFenster, HandyRahmen, RahmenBild, type RahmenBildDaten } from "@/components/GeraeteRahmen"; import { useDemoLauf, useFeinerZeiger, useImBild } from "./demos/gemeinsam"; import type { TerminZustand } from "./demos/termin/ablauf"; import type { HeatmapZustand } from "./demos/heatmap/ablauf"; import type { PinnwandZustand } from "./demos/pinnwand/ablauf"; 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"). * Die Stationen 5 bis 7 (Terminplaner, Website-Analyse, News-Pinnwand) zeigen statt eines Bildes eine * Live-Demo: auf dem MacBook die erfundene Kunden-Website, auf dem iPhone das Portal (Spezifikation * 2026-10-04-funktionen-demos). Die Demos werden per React.lazy erst geladen, wenn die Bühne ihre Station fast erreicht. */ 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 DemoArt = "termin" | "heatmap" | "pinnwand"; type Funktion = { id: string; titel: string; /** Ein Satz unter dem Titel (Portal-Stationen); die Funktionen haben stattdessen Stichpunkte. */ unterschrift?: string; /** Stichpunkte unter dem Titel (die drei Funktionen, Wunsch des Inhabers 2026-10-04). */ stichpunkte?: string[]; /** Bedienhinweis zur Live-Demo, je nachdem, ob es eine Maus gibt (nur Website-Analyse). */ bedienhinweis?: { maus: string; tippen: string }; /** Desktop-Bild; Demo-Stationen zeigen stattdessen die Website, "Als App" 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; /** Live-Demo statt Desktop-Bild: welche erfundene Kunden-Website im MacBook läuft. */ demo?: DemoArt; /** Adresse in der Safari-Leiste (reservierte Endung .example, es ist kein echter Betrieb gemeint). */ adresse?: string; /** Beschreibung der Beispiel-Website für Screenreader (die Website selbst ist aria-hidden). */ beschreibung?: string; /** Hinweis unter dem MacBook (Terminplaner und Pinnwand; bei der Website-Analyse steht dort die Diagnose). */ hinweis?: string; }; /** Mitte des Knopfes "Aufhängen" im Portal-Screenshot der News-Pinnwand (Designpunkte der iPhone-Fläche 402 x 874). */ const AUFHAENGEN_PUNKT = { x: 201, y: 613 }; // 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: "terminplaner", titel: "Terminplaner", stichpunkte: ["Gäste buchen direkt auf Ihrer Website", "Termine und Anfragen im Portal", "Kalender-Abo, z. B. Google Kalender"], demo: "termin", adresse: "salon-schnittstelle.example", beschreibung: "Beispiel-Website eines Friseursalons mit Buchungsformular und einem Kalender, in dem der neue Termin erscheint", hinweis: "Kalender-Abo: Den Link einmal in Google Kalender, Apple Kalender oder Outlook einfügen. Neue Termine erscheinen dort mit etwas Verzögerung.", handy: { name: "phone-9-termine", alt: "Kundenportal von WEBklar auf dem Handy: Der Terminplaner zeigt die Termine des Donnerstags, darunter eine bestätigte Buchung, eine neue Anfrage, die Sie annehmen, ablehnen oder verschieben können, und einen weiteren bestätigten Termin.", }, }, { id: "website-analyse", titel: "Website-Analyse", stichpunkte: ["Heatmap: wohin Besucher klicken", "Aufrufe, Herkunft und Geräte", "Im Wartungs-Abo enthalten"], bedienhinweis: { maus: "Zeigen Sie mit der Maus auf die Seite.", tippen: "Tippen Sie auf eine Stelle der Seite." }, demo: "heatmap", adresse: "boulderhalle-ueberhang.example", beschreibung: "Beispiel-Website einer Boulderhalle, darüber eine Heatmap: Je häufiger eine Stelle angeklickt wird, desto wärmer ihre Farbe", handy: { name: "phone-10-analyse", alt: "Kundenportal von WEBklar auf dem Handy: Die Website-Analyse zeigt für die letzten 30 Tage Seitenaufrufe, Besucher, Kontakt-Klicks und die Zustimmungsquote, darunter ein Diagramm mit den Seitenaufrufen pro Tag.", }, }, { id: "news-pinnwand", titel: "News-Pinnwand", stichpunkte: ["Text und Foto, vom Handy aus", "Hängt direkt auf Ihrer Website", "Laufzeit je Zettel wählbar"], demo: "pinnwand", adresse: "musterbach.example", beschreibung: "Beispiel-Website einer Ortsgemeinde mit einer Pinnwand, an der ein neuer Zettel aufgehängt wird", hinweis: "Neuer Zettel: Er erscheint nach dem Aufhängen auf der Website.", handy: { name: "phone-11-news-musterbach", alt: "Kundenportal von WEBklar auf dem Handy: Ein neuer Zettel für die News-Pinnwand mit Überschrift und Text ist ausgefüllt, darunter der Knopf zum Aufhängen auf der 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; /** Stationen vor "Als App": normale Schritte und längere Demo-Schritte (bestimmen die Scrollstrecke der Hülle). */ const ANZAHL_DEMO = FUNKTIONEN.filter((f) => f.demo).length; const ANZAHL_NORMAL = LETZTER - ANZAHL_DEMO; /** Überpunkt der drei Funktionen in der Liste der Bühne (Wunsch des Inhabers 2026-10-04): klappt auf, sobald eine * der drei erreicht ist; die Unterpunkte zeigen nur ihren Titel. */ const GRUPPE = { titel: "Funktionen", text: "Bausteine für Ihre Website." }; const DEMO_INDIZES = FUNKTIONEN.flatMap((f, i) => (f.demo ? [i] : [])); // ── Demos (eigener Chunk, erst geladen, wenn die Bühne die Station fast erreicht) ────────────────────── /** * React.lazy mit `vorladen`: Der Chunk wird beim Einhängen der Station angestoßen, nicht erst, wenn die Komponente * zum ersten Mal gerendert wird. So laden Website, Handy-Ebene und Taktgeber gleichzeitig (kein Wasserfall). */ const lazyMitVorladen = (laden: () => Promise<{ default: ComponentType

}>) => { let versprechen: ReturnType | undefined; const lade = () => (versprechen ??= laden()); const Komponente = lazy(lade); return Object.assign(Komponente, { vorladen: () => void lade().catch(() => {}) }); }; const TerminSteuerung = lazyMitVorladen(() => import("./demos/steuerung").then((m) => ({ default: m.TerminSteuerung }))); const HeatmapSteuerung = lazyMitVorladen(() => import("./demos/steuerung").then((m) => ({ default: m.HeatmapSteuerung }))); const PinnwandSteuerung = lazyMitVorladen(() => import("./demos/steuerung").then((m) => ({ default: m.PinnwandSteuerung }))); const TerminSeite = lazyMitVorladen(() => import("./demos/termin/TerminSeite")); const TerminHandy = lazyMitVorladen(() => import("./demos/termin/TerminHandy")); const HeatmapSeite = lazyMitVorladen(() => import("./demos/heatmap/HeatmapSeite")); const HeatmapDiagnose = lazyMitVorladen(() => import("./demos/heatmap/HeatmapDiagnose")); const PinnwandSeite = lazyMitVorladen(() => import("./demos/pinnwand/PinnwandSeite")); const PinnwandHandy = lazyMitVorladen(() => import("./demos/pinnwand/PinnwandHandy")); /** Chunks je Demo; die Website-Chunks (Seite, Diagnose) und der Taktgeber laden gleichzeitig. */ const VORLADEN: Record void)[]> = { termin: [TerminSteuerung.vorladen, TerminSeite.vorladen, TerminHandy.vorladen], heatmap: [HeatmapSteuerung.vorladen, HeatmapSeite.vorladen, HeatmapDiagnose.vorladen], pinnwand: [PinnwandSteuerung.vorladen, PinnwandSeite.vorladen, PinnwandHandy.vorladen], }; /** Hintergrund der Beispiel-Websites, solange ihr Chunk lädt (statt einer schwarzen Fläche im MacBook). */ const PLATZHALTER: Record = { termin: "#f6f1ea", heatmap: "#141414", pinnwand: "#f7f2e6" }; const Platzhalter = ({ art }: { art: DemoArt }) => (

); }; // ── 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}

); }; /** Kartenrahmen einer Demo-Karte: Handy mit der Website, Titel, Unterschrift und der Hinweis auf das erfundene Beispiel. */ const DemoKarteRahmen = ({ f, geraetRef, nachher, children, }: { f: Funktion; /** Das Handy der Karte: nach seiner Sichtbarkeit richtet sich der Ablauf (die Karte selbst kann höher sein als der Bildschirm) */ geraetRef: RefObject; /** Unter dem Text (Heatmap: die Diagnose) */ nachher?: ReactNode; children: ReactNode; }) => (
: null}>{children}

{f.titel}

{beispielText(f)}

{f.bedienhinweis ?

{f.bedienhinweis.tippen}

: null} {nachher}
); /** * Eigener Ablauf je Karte: Die Website wird geladen, sobald das Handy der Karte (angeschnitten) in Sicht kommt, * und läuft, solange es zu mindestens 60 % im Bild ist. Maßgeblich ist das Handy, nicht die ganze Karte: * Die Heatmap-Karte ist mit der Diagnose höher als kleine Bildschirme, 60 % der Karte würden dort nie erreicht. */ const useKarte = (art: DemoArt) => { const ref = useRef(null); const nah = useImBild(ref, 0); const imBild = useImBild(ref, 0.6); const [gesehen, setGesehen] = useState(false); useEffect(() => { if (nah) setGesehen(true); }, [nah]); // alle Chunks der Demo gleichzeitig laden, sobald die Karte angeschnitten in Sicht kommt useEffect(() => { if (gesehen) VORLADEN[art].forEach((vorladen) => vorladen()); }, [gesehen, art]); const lauf = useDemoLauf(true, imBild); return { ref, gesehen, ...lauf }; }; const TerminKarte = ({ f }: { f: Funktion }) => { const k = useKarte("termin"); const [z, setZ] = useState(null); return ( {k.gesehen ? : null} {k.gesehen ? : null} ); }; const PinnwandKarte = ({ f }: { f: Funktion }) => { const k = useKarte("pinnwand"); const [z, setZ] = useState(null); return ( {k.gesehen ? : null} {k.gesehen ? : null} ); }; /** Heatmap-Karte: Tippen auf eine Zone wählt sie, darunter erscheint die kompakte Diagnose. */ const HeatmapKarte = ({ f }: { f: Funktion }) => { const k = useKarte("heatmap"); const [z, setZ] = useState(null); return ( {k.gesehen ? : null} } > {k.gesehen ? : null} {k.gesehen ? : null} ); }; const HandyReihe = () => (
{FUNKTIONEN.map((f) => { if (f.lang) return ; if (f.demo === "termin") return ; if (f.demo === "heatmap") return ; if (f.demo === "pinnwand") return ; return (

{f.titel}

); })}
); // ── 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;