// shared.jsx — Header, Footer, Tweaks, common helpers. // Loaded on every page before the page-specific app file. // Components and helpers are exported to window so other Babel scripts can reach them. // Beim Reload immer ganz oben starten (kein Scroll-Restore). So sehen User // die Initial-Choreographie der Animationen jedes Mal von vorne, statt // mitten in der Seite zu landen wo schon viele Reveals "is-in" sind. if (typeof window !== "undefined") { if ("scrollRestoration" in history) { history.scrollRestoration = "manual"; } // Sicherheits-Reset: Browser koennte ohne manual-Restore noch scrollen window.scrollTo(0, 0); // Auch nach DOM-Load nochmal scrollen, falls ein async resource // (Font, Bild) zwischendrin den Layout-Anker verschiebt window.addEventListener("load", () => window.scrollTo(0, 0), { once: true }); } const { useState, useEffect, useLayoutEffect, useMemo, useRef, createContext, useContext } = React; // ----------------------------- // Logo (inline SVG — Wave-Stack mit drei gestaffelten Wellen) // Passend zur Wortmarke "Treibiq" und zum public/assets/logo-full.svg // ----------------------------- function LogoMark({ size = 26 }) { // Brand-Image-1 ist das offizielle Wave-Mark als PNG (3 Wellen, voll // ausgearbeitet, kein SVG-Clipping-Risiko mehr). // Aspect-Ratio: 441 x 278 = 1.586. Width = size * 1.586. return ( ); } function Logo({ small = false }) { return ( Treibiq ); } function BrandWave() { return ( ); } // ----------------------------- // Icons (inline SVG, lucide-style) // ----------------------------- function Icon({ name, size = 20, stroke = 1.6 }) { const paths = { arrow: , check: , plus: <>, close: <>, menu: <>, bell: <>, play: , sparkle: <>, mail: <>, file: <>, chart: <>, users: <>, handshake: <>, shield: , server: <>, zap: , clock: <>, pin: <>, phone: , }; return ( ); } // ----------------------------- // AnimatedContent: React-Bits-aehnliche Reveal-Animation Komponente, // vanilla JS port (statt GSAP). Wrapper-Div bekommt initial translate + // scale + opacity, IntersectionObserver triggert die Transition zu // "Resting State" (translate0, scale1, opacity1). Easings sind cubic- // bezier-Approximationen der GSAP-Ease-Curves. // API kompatibel mit reactbits.dev/animations/animated-content - subset // (disappear-features weggelassen, brauchen wir aktuell nicht). // ----------------------------- const ANIM_EASE_MAP = { "power1.out": "cubic-bezier(0.25, 0.46, 0.45, 0.94)", "power2.out": "cubic-bezier(0.165, 0.84, 0.44, 1)", "power3.out": "cubic-bezier(0.215, 0.61, 0.355, 1)", "power4.out": "cubic-bezier(0.165, 0.84, 0.44, 1)", "expo.out": "cubic-bezier(0.19, 1, 0.22, 1)", "circ.out": "cubic-bezier(0.075, 0.82, 0.165, 1)", "back.out": "cubic-bezier(0.34, 1.56, 0.64, 1)", "bounce.out": "cubic-bezier(0.34, 1.56, 0.64, 1)", "sine.out": "cubic-bezier(0.39, 0.575, 0.565, 1)", }; function AnimatedContent(props) { const children = props.children; const distance = props.distance != null ? props.distance : 100; const direction = props.direction || "vertical"; const reverse = props.reverse || false; const duration = props.duration != null ? props.duration : 0.8; const ease = props.ease || "power3.out"; const initialOpacity = props.initialOpacity != null ? props.initialOpacity : 0; const animateOpacity = props.animateOpacity !== false; const scale = props.scale != null ? props.scale : 1; const threshold = props.threshold != null ? props.threshold : 0.1; const delay = props.delay || 0; const onComplete = props.onComplete; const className = props.className || ""; const extraStyle = props.style || {}; const ref = useRef(null); // Hardening 2026-05-15: useMemo fuer das gemergde Style-Objekt. // Vorher entstand auf JEDEM Render ein neues Object literal, das // React's Style-Reconciliation als geaenderte style-Prop interpretieren // konnte und damit die per Imperativ gesetzten inline-Styles (opacity, // transform, transition) potentiell ueberschrieb. Indem wir per useMemo // die Referenz nur bei echtem extraStyle-Change neu erzeugen, bleibt // React's commit-Phase bei Re-Renders idempotent fuer die style-Prop. // Hinweis: wir vergleichen NICHT tief, sondern verlassen uns auf den // Eltern-Aufrufer ein stabiles extraStyle zu liefern - das ist auch im // Original-Verhalten implizit so vorausgesetzt gewesen. const baseStyle = useMemo( () => Object.assign({ visibility: "hidden" }, extraStyle), [extraStyle] ); // Hardening 2026-05-15: useLayoutEffect statt useEffect fuer den // Initial-Style-Setup. useLayoutEffect laeuft synchron NACH dem DOM- // commit, aber VOR dem Browser-Paint. Damit ist garantiert dass der // User nie das Element ohne Transform / mit visibility:hidden sieht. // Bei useEffect (asynchron, nach Paint) konnte es einen Frame // Visibility-Flash geben in dem das Element ohne Initial-Transform // rendert - wahrnehmbar als "Sprung" wenn die Pakete-Karten remounten. useLayoutEffect(() => { const el = ref.current; if (!el) return; if (typeof window === "undefined" || typeof IntersectionObserver === "undefined") return; const reduceMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches; const cssEase = ANIM_EASE_MAP[ease] || ANIM_EASE_MAP["power3.out"]; const axis = direction === "horizontal" ? "X" : "Y"; // Distance kann Number (px) ODER String (CSS-Expression wie "100% + 24px") // sein. Bei String wird calc() drumrum gelegt damit translate eine valide // Single-Value-Expression bekommt. Damit lassen sich viewport-abhaengige // Distanzen (z.B. exakte Spaltenbreite via 100% own-width) ausdruecken. const distanceCss = typeof distance === "number" ? `${distance}px` : distance; const offsetCss = reverse ? `calc(-1 * (${distanceCss}))` : `calc(${distanceCss})`; if (reduceMotion) { el.style.opacity = "1"; el.style.transform = "none"; el.style.visibility = "visible"; if (onComplete) onComplete(); return; } // Initial-State setzen bevor Transition aktiv ist. Weil wir in // useLayoutEffect sind, sieht der Browser den Initial-State sicher // beim ersten Paint, kein Flash moeglich. el.style.opacity = animateOpacity ? String(initialOpacity) : "1"; el.style.transform = `translate${axis}(${offsetCss}) scale(${scale})`; el.style.visibility = "visible"; // Doppel-RAF: Browser muss den Initial-State sicher rendern, dann erst // wird die Transition-Property gesetzt und der IO scharfgeschaltet. // Vorher: IO wurde synchron erzeugt und konnte feuern BEVOR die // Transition gesetzt war (= Element snappt instant, keine Animation). // // Hardening 2026-05-15: RAF-IDs werden jetzt getrackt und im Cleanup // cancelled. Vorher liefen die geRAF-ed Callbacks bei schnellem // mount+unmount weiter und checkten nur das cancelled-Flag - das // kostete RAF-Quota und verzoegerte parallele Mounts (race conditions // bei mehreren AnimatedContent-Instanzen im selben Frame, z.B. // gestaffelte Pricing-Cards). Mit cancelAnimationFrame entstehen // saubere Cleanups ohne dass die Browser-Animation-Queue muellt. let completeTimer = null; let io = null; let cancelled = false; let rafId1 = 0; let rafId2 = 0; rafId1 = requestAnimationFrame(() => { rafId1 = 0; rafId2 = requestAnimationFrame(() => { rafId2 = 0; if (cancelled || !ref.current) return; el.style.transition = `opacity ${duration}s ${cssEase} ${delay}s, transform ${duration}s ${cssEase} ${delay}s`; // IO wird hier INNERHALB des zweiten RAF erzeugt - so ist // garantiert dass Initial-State + Transition gesetzt sind // bevor der erste IO-Callback feuern kann. Auch wenn das // Element schon im Viewport ist, kommt der Callback frueh- // estens im naechsten Frame nach observe(). io = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (!entry.isIntersecting) return; io.unobserve(entry.target); el.style.opacity = "1"; el.style.transform = "translate(0, 0) scale(1)"; if (onComplete) { completeTimer = setTimeout(onComplete, (duration + delay) * 1000); } }); }, { threshold } ); io.observe(el); }); }); return () => { cancelled = true; if (rafId1) cancelAnimationFrame(rafId1); if (rafId2) cancelAnimationFrame(rafId2); if (io) io.disconnect(); if (completeTimer) clearTimeout(completeTimer); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return (
{children}
); } // ----------------------------- // Header (with sticky-on-scroll) // ----------------------------- function Header({ active }) { const [scrolled, setScrolled] = useState(false); const [menuOpen, setMenuOpen] = useState(false); useEffect(() => { // Hysterese verhindert Rattern am Threshold: einmal scrolled bleibt // scrolled bis man wirklich wieder am Top ist (< 2px). Einmal oben // bleibt oben bis man deutlich gescrollt hat (> 40px). Dazwischen // kein toggle - keine Glitch-Loop durch Header-Reflow. // rAF dedup verhindert dass der Handler mehrmals pro Frame feuert. let ticking = false; const onScroll = () => { if (ticking) return; ticking = true; requestAnimationFrame(() => { const y = window.scrollY; setScrolled((prev) => { if (!prev && y > 40) return true; if (prev && y < 2) return false; return prev; }); ticking = false; }); }; onScroll(); window.addEventListener("scroll", onScroll, { passive: true }); return () => window.removeEventListener("scroll", onScroll); }, []); // Body-Scroll-Lock wenn Mobile-Menu offen + Escape-Taste schliesst useEffect(() => { if (menuOpen) { document.body.classList.add("menu-open"); const onEsc = (e) => { if (e.key === "Escape") setMenuOpen(false); }; window.addEventListener("keydown", onEsc); return () => { document.body.classList.remove("menu-open"); window.removeEventListener("keydown", onEsc); }; } }, [menuOpen]); const links = [ { href: "use-cases.html", label: "Use Cases", key: "use-cases" }, { href: "leistungen.html", label: "Leistungen", key: "leistungen" }, { href: "pakete.html", label: "Preise", key: "pakete" }, { href: "ueber-uns.html", label: "Über uns", key: "ueber-uns" }, { href: "kontakt.html", label: "Kontakt", key: "kontakt" }, ]; return ( <>
Erstgespräch buchen
{/* Mobile-Menu: immer im DOM, Klasse is-open toggelt fade+slide-Animation */}
Erstgespräch buchen
); } // ----------------------------- // Footer // ----------------------------- function Footer() { return ( ); } // ----------------------------- // Tools logos (simpleicons CDN) // ----------------------------- const TOOLS = { gmail: { name: "Gmail", slug: "gmail", color: "EA4335" }, outlook: { name: "Outlook", slug: "microsoftoutlook", color: "0078D4" }, openai: { name: "GPT-4", slug: "openai", color: "412991" }, n8n: { name: "n8n", slug: "n8n", color: "EA4B71" }, hubspot: { name: "HubSpot", slug: "hubspot", color: "FF7A59" }, pipedrive:{ name: "Pipedrive", slug: "pipedrive", color: "1A1A1A" }, notion: { name: "Notion", slug: "notion", color: "000000" }, slack: { name: "Slack", slug: "slack", color: "4A154B" }, sheets: { name: "Sheets", slug: "googlesheets", color: "34A853" }, powerbi: { name: "Power BI", slug: "powerbi", color: "F2C811" }, workspace:{ name: "Workspace", slug: "googleworkspace", color: "4285F4" }, ms365: { name: "MS 365", slug: "microsoft365", color: "D83B01" }, datev: { name: "DATEV", slug: null, color: "00945E" }, sap: { name: "SAP", slug: "sap", color: "0FAAFF" }, zapier: { name: "Zapier", slug: "zapier", color: "FF4F00" }, airtable: { name: "Airtable", slug: "airtable", color: "18BFFF" }, stripe: { name: "Stripe", slug: "stripe", color: "635BFF" }, }; // Lokale Tool-Logos (assets/tools/{slug}) nutzen statt simpleicons CDN. // Die Assets sind in _legacy/assets/tools/ und enthalten transparente Hintergruende // plus angepasste Logos fuer DATEV, Clay, Mantle, LGM (Wikimedia + custom-tiles). const LOCAL_TOOL_FILES = { gmail: "assets/tools/gmail.png", outlook: "assets/tools/outlook.webp", n8n: "assets/tools/n8n.webp", hubspot: "assets/tools/hubspot.webp", pipedrive: "assets/tools/pipedrive.webp", notion: "assets/tools/notion.webp", slack: "assets/tools/slack.webp", sheets: "assets/tools/sheets.webp", powerbi: "assets/tools/powerbi.webp", workspace: "assets/tools/workspace.webp", ms365: "assets/tools/ms365.webp", datev: "assets/tools/datev.webp", sap: "assets/tools/sap.png", whatsapp: "assets/tools/whatsapp.webp", telegram: "assets/tools/telegram.webp", mail: "assets/tools/mail.webp", phone: "assets/tools/phone.webp", clay: "assets/tools/clay.webp", mantle: "assets/tools/mantle.webp", lgm: "assets/tools/lgm.webp", }; function ToolLogo({ tool, size = 18, loading = "lazy" }) { const t = TOOLS[tool]; if (!t) return null; const localSrc = LOCAL_TOOL_FILES[tool]; if (localSrc) { return ( {t.name} ); } // Fallback: First-Letter-Tile (fuer tools ohne lokales PNG) return ( {t.name.charAt(0)} ); } function ToolChip({ tool, size = "md", logoOnly = false }) { const t = TOOLS[tool]; if (!t) return null; if (logoOnly) { // Nur Logo, groesser, in einem runden weissen Tile mit Soft-Shadow const logoSize = size === "lg" ? 32 : size === "sm" ? 22 : 28; return ( ); } const dims = size === "sm" ? { fs: 11.5, pad: "5px 10px 5px 8px", logo: 13 } : { fs: 13, pad: "8px 14px 8px 10px", logo: 18 }; return ( {t.name} ); } // ----------------------------- // Tweaks panel (keys persisted in localStorage; not file-based) // ----------------------------- const TweakCtx = createContext(null); function TweaksProvider({ children }) { const defaults = { accent: "violet", density: "default", heroVariant: "video", darkBand: true }; const [tweaks, setTweaks] = useState(() => { try { const stored = JSON.parse(localStorage.getItem("treibiq-tweaks") || "{}"); return { ...defaults, ...stored }; } catch { return defaults; } }); const [open, setOpen] = useState(false); useEffect(() => { localStorage.setItem("treibiq-tweaks", JSON.stringify(tweaks)); document.documentElement.setAttribute("data-accent", tweaks.accent === "violet" ? "" : tweaks.accent); document.documentElement.setAttribute("data-density", tweaks.density === "default" ? "" : tweaks.density); }, [tweaks]); // Edit-mode protocol useEffect(() => { const handler = (e) => { if (!e.data || typeof e.data !== "object") return; if (e.data.type === "__activate_edit_mode") setOpen(true); if (e.data.type === "__deactivate_edit_mode") setOpen(false); }; window.addEventListener("message", handler); window.parent.postMessage({ type: "__edit_mode_available" }, "*"); return () => window.removeEventListener("message", handler); }, []); const setTweak = (k, v) => setTweaks((t) => ({ ...t, [k]: v })); return ( {children} ); } const useTweaks = () => useContext(TweakCtx); function TweaksPanel() { const { tweaks, setTweak, open, setOpen } = useTweaks(); if (!open) return null; const close = () => { setOpen(false); window.parent.postMessage({ type: "__edit_mode_dismissed" }, "*"); }; const Chip = ({ k, value, label }) => ( ); return (

Tweaks

); } // ----------------------------- // Use case modal (shared across pages) // ----------------------------- function UseCaseModal({ wf, onClose }) { if (!wf) return null; useEffect(() => { const onKey = (e) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose]); return (
e.stopPropagation()}>
Use Case · {wf.num}

{wf.title}

{wf.problem || wf.body}

{wf.tools.map((t, i) => ( {i < wf.tools.length - 1 && } ))}
Zeitersparnis
{wf.hours} Std / Woche
Aufbauzeit
1–2 Wochen
Status
● Live bei Kunden
); } // ----------------------------- // Animation-Hook: Initial-Load-Choreographie + Scroll-Reveals + Word-Stagger + Counter // IntersectionObserver-basiert, ohne externe Library. // ----------------------------- function useAnimations() { useEffect(() => { if (typeof window === "undefined") return; if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; const setTemporaryWillChange = (el) => { if (!el || el.dataset.willChangeManaged === "1") return; el.dataset.willChangeManaged = "1"; el.style.willChange = "opacity, transform"; let done = false; const clear = () => { if (done) return; done = true; el.style.willChange = ""; delete el.dataset.willChangeManaged; el.removeEventListener("transitionend", clear); }; el.addEventListener("transitionend", clear, { once: true }); setTimeout(clear, 1800); }; const prepareReveal = (el) => { if (!el) return; if (el.classList.contains("reveal-stagger")) { Array.from(el.children).forEach(setTemporaryWillChange); } if ( el.classList.contains("reveal") || el.classList.contains("reveal-left") || el.classList.contains("reveal-right") || el.classList.contains("hero-word") ) { setTemporaryWillChange(el); } }; const markIn = (el) => { if (!el || el.classList.contains("is-in")) return; prepareReveal(el); el.classList.add("is-in"); }; // Counter-Animation: zaehlt von 0 hoch zum Target. // Audit Jannik 2026-05-12: vorher Overshoot-runter, jetzt klassisch // unten-nach-oben. Dauer skaliert mit Target-Groesse. const activeCounters = new Map(); let counterRaf = null; const counterEase = (t) => 1 - Math.pow(1 - t, 3); const tickCounters = (now) => { activeCounters.forEach((state, el) => { const t = Math.min((now - state.start) / state.dur, 1); const val = state.target * counterEase(t); el.textContent = state.isInt ? Math.round(val).toLocaleString("de-DE") : val.toFixed(1).replace(".", ","); if (t >= 1) activeCounters.delete(el); }); counterRaf = activeCounters.size > 0 ? requestAnimationFrame(tickCounters) : null; }; const animateCounter = (el) => { if (el.dataset.counted === "1") return; el.dataset.counted = "1"; const target = parseFloat(el.dataset.counter || "0"); if (isNaN(target)) return; if (target === 0) { el.textContent = "0"; return; } // Kleine Zahlen ~700ms, grosse bis 1100ms. Etwas laenger als vorher // damit der Hochzaehl-Effekt sichtbar ist. const dur = Math.min(1100, 700 + Math.log10(Math.max(target, 2)) * 130); const isInt = Number.isInteger(target); activeCounters.set(el, { target, dur, isInt, start: performance.now() }); if (counterRaf === null) counterRaf = requestAnimationFrame(tickCounters); }; // Schritt 1: Klassen auf semantische Elemente hinzufuegen const apply = () => { // 1a) Standard-Reveal: einzelne Elemente fade-up const revealSelectors = [ ".section-head", ".page-head__inner", ]; revealSelectors.forEach((sel) => { document.querySelectorAll(sel).forEach((el) => { if (!el.classList.contains("reveal")) el.classList.add("reveal"); }); }); // 1b) Hero-Bereich + Welcome-Section: linke Spalte von links, rechte Spalte von rechts document.querySelectorAll(".hero__copy, .welcome__copy").forEach((el) => { if (!el.classList.contains("reveal-left")) el.classList.add("reveal-left"); }); document.querySelectorAll(".hero__visual, .hero__stage, .welcome__visual").forEach((el) => { if (!el.classList.contains("reveal-right")) el.classList.add("reveal-right"); }); // 1c) Stagger-Container with from-sides (Cards springen alternierend) const fromSidesSelectors = [ ".wf-grid", ".features", ".pakete-grid", ".team-grid", ".values-grid", ".bio-grid", ".contact-split", ".split", ]; fromSidesSelectors.forEach((sel) => { document.querySelectorAll(sel).forEach((el) => { if (!el.classList.contains("reveal-stagger")) { el.classList.add("reveal-stagger"); el.classList.add("from-sides"); } }); }); // 1d) Standard-Stagger (ohne sides) fuer Steps/Stats/Process // Plus alle Zenith-Pattern-Grids: stats-row (4 Ghost-Cards), services-grid // (6 Feature-Cards), service-grid (Use-Case-Cards mit Bild), testimonials-grid // (Quote-Cards), process-split__steps (numerated Cards). const plainStaggerSelectors = [ ".steps", ".stat-band", ".process", ".stats-row", ".services-grid", ".service-grid", ".testimonials-grid", ".process-split__steps", ".why-grid", ".workflow-grid", ".comparison-strip", ".stack-grid", ".features", ".pkg-grid", ]; plainStaggerSelectors.forEach((sel) => { document.querySelectorAll(sel).forEach((el) => { if (!el.classList.contains("reveal-stagger")) el.classList.add("reveal-stagger"); }); }); // 1e) Hero-Title Wort-Stagger // ACHTUNG: Inline-Elemente wie die background-clip:text nutzen // (Gradient-Text) duerfen NICHT recursive zerlegt werden, sonst // bricht das Rendering. Stattdessen werden sie als ganzes als // ein .hero-word eingewickelt. const heroTitle = document.querySelector(".hero__title"); if (heroTitle && !heroTitle.dataset.staggered) { heroTitle.dataset.staggered = "1"; const PRESERVE_TAGS = ["EM", "STRONG", "B", "I", "MARK"]; const wrapTextNodes = (node) => { if (node.nodeType === Node.TEXT_NODE) { const txt = node.textContent; if (!txt.trim()) return null; const frag = document.createDocumentFragment(); txt.split(/(\s+)/).forEach((part) => { if (/^\s+$/.test(part)) { frag.appendChild(document.createTextNode(part)); } else if (part) { const span = document.createElement("span"); span.className = "hero-word"; span.textContent = part; frag.appendChild(span); } }); return frag; } if (node.nodeType === Node.ELEMENT_NODE) { // Inline-Style-Elemente komplett als ein hero-word wrappen if (PRESERVE_TAGS.includes(node.tagName)) { const wrapper = document.createElement("span"); wrapper.className = "hero-word hero-word--preserve"; wrapper.appendChild(node.cloneNode(true)); return wrapper; } const clone = node.cloneNode(false); node.childNodes.forEach((child) => { const wrapped = wrapTextNodes(child); if (wrapped) clone.appendChild(wrapped); else clone.appendChild(child.cloneNode(true)); }); return clone; } return node.cloneNode(true); }; const tmp = document.createElement("div"); tmp.innerHTML = heroTitle.innerHTML; const newFrag = document.createDocumentFragment(); tmp.childNodes.forEach((c) => { const r = wrapTextNodes(c); if (r) newFrag.appendChild(r); }); heroTitle.innerHTML = ""; heroTitle.appendChild(newFrag); // Delay pro Wort, schnell aber wahrnehmbar heroTitle.querySelectorAll(".hero-word").forEach((w, i) => { w.style.transitionDelay = `${80 + i * 50}ms`; }); } }; apply(); // Schritt 2: IntersectionObserver fuer normale Scroll-Reveals const obs = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { markIn(entry.target); obs.unobserve(entry.target); } }); }, { threshold: 0.1, rootMargin: "0px 0px -5% 0px" } ); // Dedicated Observer fuer "late triggers" - elements die explizit erst // animieren sollen wenn man richtig drauf ist (nicht schon beim Anpeeken). // Hoehere threshold + groesserer negative root-margin am unteren Edge. const lateObs = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { markIn(entry.target); lateObs.unobserve(entry.target); } }); }, { threshold: 0.25, rootMargin: "0px 0px -120px 0px" } ); const isLateTarget = (el) => el.classList.contains("faq__list--delayed") || el.classList.contains("process-split__steps--delayed"); document .querySelectorAll(".reveal, .reveal-left, .reveal-right, .reveal-stagger") .forEach((el) => { if (isLateTarget(el)) lateObs.observe(el); else obs.observe(el); }); // Schritt 3: Initial-Load-Choreographie // Alle Elemente die im ersten Viewport sind, kriegen .is-in mit kleinen Delays. // Stellt sicher dass auch wenn IntersectionObserver lazy ist, der Hero animiert. const initialAnimate = () => { const vh = window.innerHeight; const initialEls = document.querySelectorAll( ".reveal, .reveal-left, .reveal-right, .reveal-stagger, .hero-word" ); let idx = 0; initialEls.forEach((el) => { const rect = el.getBoundingClientRect(); if (rect.top < vh * 0.95) { // Im Viewport sichtbar const delay = el.classList.contains("hero-word") ? 0 : Math.min(idx * 90, 600); setTimeout(() => markIn(el), delay); idx += 1; // Counter im Viewport sofort starten - sowohl auf el selbst als auch // auf Kindern. AnimatedStatNum rendert die als // Child der reveal-stagger Karte, also muss man auch children scannen. if (el.dataset && el.dataset.counter) { setTimeout(() => animateCounter(el), 300 + delay); } const childCounters = el.querySelectorAll("[data-counter]"); if (childCounters.length > 0) { childCounters.forEach((cc) => { if (cc.dataset.counted === "1") return; // Delay setzen sodass Counter startet wenn die Karte sichtbar wird. // markIn passiert bei `delay`ms, plus CSS transition-delay des // staggered childs - der getStaggerDelayMs Helper rechnet das aus. const totalDelay = delay + 100 + getStaggerDelayMs(cc); setTimeout(() => animateCounter(cc), totalDelay); }); } } }); }; // Trigger nach 80ms, damit React-Render durch ist und CSS settled setTimeout(initialAnimate, 80); // Safety-Net: nach 2.5 Sekunden ALLE noch nicht animierten Reveals zwangsaktivieren // (verhindert dass Elemente unsichtbar bleiben falls Observer / MutationObserver lagged) // EXKLUSIVE der `--delayed`-Varianten (faq__list, process-split__steps) - die // sollen wirklich erst triggern wenn man drauf scrollt, nicht durch das // safety-timeout zwangsweise eingeblendet werden. setTimeout(() => { document .querySelectorAll( ".reveal:not(.is-in):not(.faq__list--delayed):not(.process-split__steps--delayed), .reveal-left:not(.is-in), .reveal-right:not(.is-in), .reveal-stagger:not(.is-in):not(.faq__list--delayed):not(.process-split__steps--delayed)" ) .forEach(markIn); document.querySelectorAll(".hero-word:not(.is-in)").forEach(markIn); }, 2500); // Schritt 4: Number-Counter // threshold 0 (any visibility) statt 0.5 - bei kleinen inline-spans // (z.B. innerhalb von .pkg__price-num) feuert // 50%-threshold oft nie, wenn die span nur ein paar px hoch ist. // rootMargin: vorher -60px am unteren Edge - das hat bei kleinen Counter- // spans (~24px hoch) dazu gefuehrt, dass die Intersection auf der // Home-Page nicht zuverlaessig getriggert hat. Jetzt 0px, dafuer // verlassen wir uns auf die initial-Pass + Scroll-In-Pass Kombination. const counterObs = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (!entry.isIntersecting) return; // Falls counter-span in einem reveal-stagger Container sitzt, // warten bis die Karte sichtbar ist (sonst zaehlt der Counter // auf einem opacity:0 Element runter und ist beim Reveal schon fertig). scheduleCounterWithStaggerSync(entry.target); counterObs.unobserve(entry.target); }); }, { threshold: 0, rootMargin: "0px 0px 0px 0px" } ); // Helper: ermittelt die total transition-delay einer Counter-Span basierend // auf der Position innerhalb des reveal-stagger Parents. So koennen wir // den Counter genau dann starten wenn die Karte sichtbar wird. const getStaggerDelayMs = (el) => { // Walk up the tree um die direkte Card unterhalb eines .reveal-stagger // Containers zu finden. let card = el; while (card && card.parentElement) { const parent = card.parentElement; if (parent.classList && parent.classList.contains("reveal-stagger")) { const siblings = Array.from(parent.children); const idx = siblings.indexOf(card); if (idx < 0) return 0; // CSS-Werte aus site.css: 0/70/140/210/280/350/420/490ms (>=8 capped at 490) const steps = [0, 70, 140, 210, 280, 350, 420]; return idx < steps.length ? steps[idx] : 490; } card = parent; } return 0; }; const scheduleCounterWithStaggerSync = (el) => { if (el.dataset.counted === "1") return; const stagger = el.closest(".reveal-stagger"); if (stagger && !stagger.classList.contains("is-in")) { // Parent noch nicht enthuellt - poll bis is-in gesetzt wird, // dann starte Counter mit dem passenden Stagger-Delay. const pollStart = performance.now(); const startWhenIn = () => { if (el.dataset.counted === "1") return; if (stagger.classList.contains("is-in")) { const delayMs = getStaggerDelayMs(el); setTimeout(() => animateCounter(el), delayMs + 80); return; } // Safety-Net: nach 3s zwangsstarten - sonst koennte ein nie // sichtbarer reveal-stagger den Counter ewig blockieren. if (performance.now() - pollStart > 3000) { animateCounter(el); return; } requestAnimationFrame(startWhenIn); }; requestAnimationFrame(startWhenIn); } else { // Parent ist bereits is-in (oder es gibt keinen stagger). // Kurze Verzoegerung damit der erste Render durch ist. setTimeout(() => animateCounter(el), 120); } }; // Counter-Pass: alle [data-counter] elements direkt durchgehen. // - Im viewport: sofort animieren (sync mit stagger-reveal) // - Ausserhalb: observen, animiert beim scroll-in document.querySelectorAll("[data-counter]").forEach((el) => { const rect = el.getBoundingClientRect(); const inViewport = rect.top < window.innerHeight && rect.bottom > 0; if (inViewport) { scheduleCounterWithStaggerSync(el); } else { counterObs.observe(el); } }); // Schritt 5: MutationObserver fuer dynamisch dazu kommende Elemente // (z.B. Pakete-Toggle wechselt Einmalig <-> Retainer und rendered die // counter-spans neu - die brauchen eine neue Animation) const mutationRoot = document.querySelector("main") || document.getElementById("app") || document.body; const mutationObs = new MutationObserver(() => { apply(); mutationRoot .querySelectorAll(".reveal:not(.is-in), .reveal-left:not(.is-in), .reveal-right:not(.is-in), .reveal-stagger:not(.is-in)") .forEach((el) => { if (!el.dataset.observed) { el.dataset.observed = "1"; if (isLateTarget(el)) lateObs.observe(el); else obs.observe(el); } }); // Frische counter-spans (z.B. nach Toggle-Switch) mutationRoot.querySelectorAll("[data-counter]").forEach((el) => { if (el.dataset.counted === "1" || el.dataset.counterObserved === "1") return; el.dataset.counterObserved = "1"; const rect = el.getBoundingClientRect(); if (rect.top < window.innerHeight && rect.bottom > 0) { scheduleCounterWithStaggerSync(el); } else { counterObs.observe(el); } }); }); mutationObs.observe(mutationRoot, { childList: true, subtree: true }); return () => { obs.disconnect(); lateObs.disconnect(); counterObs.disconnect(); mutationObs.disconnect(); activeCounters.clear(); if (counterRaf) cancelAnimationFrame(counterRaf); }; }, []); } function useAnimationVisibilityPauser() { useEffect(() => { if (typeof window === "undefined" || typeof IntersectionObserver === "undefined") return; const hosts = Array.from(new Set( document.querySelectorAll([ ".testimonials-carousel", ".logos-carousel", ".cta-band", ".hero-mobile-flow", ".hero__stage", ".hero__wave", ".hero-floating", ".wf-canvas-block", ].join(", ")) )); if (hosts.length === 0) return; const setOffscreen = (el, isOffscreen) => { el.classList.toggle("is-offscreen", isOffscreen); }; const initialMargin = 160; hosts.forEach((el) => { const rect = el.getBoundingClientRect(); setOffscreen(el, rect.bottom < -initialMargin || rect.top > window.innerHeight + initialMargin); }); const io = new IntersectionObserver( (entries) => { entries.forEach((entry) => setOffscreen(entry.target, !entry.isIntersecting)); }, { threshold: 0, rootMargin: "160px 0px" } ); hosts.forEach((el) => io.observe(el)); return () => { io.disconnect(); hosts.forEach((el) => el.classList.remove("is-offscreen")); }; }, []); } // ----------------------------- // Page wrapper — easy boilerplate per page // ----------------------------- function Page({ active, children }) { useAnimations(); useAnimationVisibilityPauser(); return (
{children}