132 lines
6.4 KiB
TypeScript
132 lines
6.4 KiB
TypeScript
"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 <html>). 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 <Shell/> 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:
|
||
* <ProcessContact/> 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<HTMLDivElement>(null);
|
||
const tag = useRef<HTMLDivElement>(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“. <html> 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<HTMLElement>("main [data-sale]");
|
||
if (!flag || !anchor?.dataset.sale) return;
|
||
const [value, stub] = flag.querySelectorAll<HTMLElement>("[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 (
|
||
<>
|
||
<div
|
||
ref={bar}
|
||
aria-hidden="true"
|
||
className="pointer-events-none fixed inset-x-0 z-50 h-px origin-left scale-x-0 bg-accent transition-opacity duration-300 [[data-menu-open]_&]:opacity-0"
|
||
style={{ top: "var(--header-h, 60px)" }}
|
||
/>
|
||
{/* Ein Gutschein im Kleinstformat: runde Ecken wie der große, und quer durch die
|
||
Mitte dieselbe Reißkante – hier hochkant, weil das Fähnchen breiter als hoch
|
||
ist. Rechts hinter der Perforation sitzt der Abriss.
|
||
|
||
Anders als beim großen Gutschein sind es keine gestanzten Kerben, sondern nur
|
||
die gestrichelte Linie: Eine Kerbe muss den Grund dahinter zeigen, und dieses
|
||
Fähnchen schwebt über der Seite – mal über Tinte, mal über Elfenbein. Eine
|
||
Kerbe in fester Farbe wäre auf einem von beiden Gründen ein Fleck.
|
||
|
||
Rot, weil Rot reduziert heißt und Gold das nicht sagen kann. `aria-hidden` wie
|
||
der Balken: Dass der Vorteil an der Anfrage hängt, steht als Text im Formular
|
||
darunter, nicht nur in dieser Farbe. Leer und durchsichtig, solange die Seite
|
||
keinen Gutschein trägt. */}
|
||
<div
|
||
ref={tag}
|
||
aria-hidden="true"
|
||
className="pointer-events-none fixed left-0 z-50 flex -translate-y-1/2 items-stretch overflow-hidden rounded-[5px] bg-sale font-mono text-[10px] uppercase leading-none tracking-[0.12em] text-ivory opacity-0 [[data-menu-open]_&]:!opacity-0"
|
||
style={{ top: "var(--header-h, 60px)" }}
|
||
>
|
||
<span data-cell="" className="px-2 py-[5px]" />
|
||
<span className="border-l border-dashed border-ivory/55" />
|
||
<span data-cell="" className="px-[7px] py-[5px]" />
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
export default ScrollProgress;
|