"use client"; import { useLayoutEffect, useRef } from "react"; import { gsap, ScrollTrigger, EASE, MM } from "@/lib/gsap"; /** * 1 px `--accent` line directly under the header (the header writes * `--header-h` on ). Grows with the page scroll; purely decorative. * * Scrubbed by ScrollTrigger, not by a second animation runtime: this was the * only reason `useScroll` (framer-motion) shipped on every route. * * --------------------------------------------------------------------------- * Reduziert-Fähnchen * * Trägt eine Seite einen Gutschein, hängt an der Spitze des Balkens ein rotes * Fähnchen mit dem Nachlass – dort, wo der Leser seine eigene Position abliest. * Es blendet sich erst ein, wenn der Abschnitt mit dem Formular ins Bild kommt: * Bis dahin sagt der Balken „so weit bist du“, ab dort zusätzlich „und hier wird * er eingelöst“. * * Diese Komponente hängt in und läuft auf JEDER Seite – auf dem * Impressum darf kein „−20 %“ kleben. Statt einen Zustand durch das Layout zu * fädeln (was der App-Router nicht hergibt), liest sie den Text aus dem DOM: * schreibt ihn als `data-sale` an den Kontaktabschnitt, wenn * ein Gutschein übergeben wurde. Denselben Weg geht schon die Regel in * globals.css, die den Kopf-CTA auf der Messeseite ausblendet. */ export function ScrollProgress() { const bar = useRef(null); const tag = useRef(null); useLayoutEffect(() => { const el = bar.current; if (!el) return; const mm = gsap.matchMedia(); mm.add({ reduce: MM.reduce, ok: MM.ok }, (ctx) => { /* Reduced motion too: the bar reports position, it does not animate for effect – the scrub follows the reader's own scrolling either way. */ /* Die Strecke wird gemessen, nicht aus einem Element abgeleitet. Vorher stand hier `trigger: document.documentElement` mit „top top“ bis „bottom bottom“. trägt aber `h-full`, ist also genau einen Bildschirm hoch – Anfang und Ende fielen zusammen, die Strecke war null und der Balken blieb auf 0. Auf dem Desktop fiel das nicht auf, weil Lenis dort seine eigene Position meldet; auf Mobil ist Lenis aus (SmoothScroll.tsx überlässt Touch dem nativen Scrollen), und der Balken zeichnete überhaupt nicht. `maxScroll` fragt den tatsächlichen Scrollweg ab, `invalidateOnRefresh` rechnet ihn neu, wenn die Seite ihre Höhe ändert. */ const scrub = { start: 0, end: () => ScrollTrigger.maxScroll(window), scrub: true, invalidateOnRefresh: true, } as const; gsap.set(el, { scaleX: 0, transformOrigin: "0 50%" }); gsap.to(el, { scaleX: 1, ease: EASE.none, scrollTrigger: scrub }); /* Das Fähnchen steht immer im Dokument und wird hier beschriftet, statt seinen Text durch React-State zu schicken: Ein `setState` im Effekt wäre ein zweiter Durchlauf für eine Angabe, die sich nach dem ersten Rendern nie mehr ändert (und react-hooks verbietet es zu Recht). Ohne `data-sale` bleibt es leer und durchsichtig – auf dem Impressum klebt kein „−20 %“. */ const flag = tag.current; const anchor = document.querySelector("main [data-sale]"); if (!flag || !anchor?.dataset.sale) return; const [value, stub] = flag.querySelectorAll("[data-cell]"); value.textContent = anchor.dataset.sale; stub.textContent = anchor.dataset.saleStub ?? ""; /* Der Balken selbst wird über `scaleX` gezogen – ein Kind darin würde mitgestaucht. Das Fähnchen läuft deshalb eigenständig über dieselbe Strecke; `xPercent: -100` legt seine rechte Kante auf die Spitze, damit es am Ende nicht über den Fensterrand hinausragt. Der Endwert ist eine Funktion, `invalidateOnRefresh` rechnet ihn beim Größenwechsel neu. */ gsap.set(flag, { opacity: 0, xPercent: -100 }); gsap.fromTo( flag, { x: 0 }, { x: () => window.innerWidth, ease: EASE.none, scrollTrigger: { ...scrub } }, ); /* Nur `opacity` – nichts, was Fokus oder Tab-Reihenfolge anfasst. Bei reduzierter Bewegung ohne Überblendung, aber mit demselben Ergebnis. */ const fade = ctx.conditions?.reduce ? 0 : 0.4; ScrollTrigger.create({ trigger: anchor, start: "top 90%", onEnter: () => gsap.to(flag, { opacity: 1, duration: fade, ease: EASE.out }), onLeaveBack: () => gsap.to(flag, { opacity: 0, duration: fade, ease: EASE.out }), }); }); return () => mm.revert(); }, []); return ( <>