// 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 (
{paths[name]}
);
}
// -----------------------------
// 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 (
<>
{/* Mobile-Menu: immer im DOM, Klasse is-open toggelt fade+slide-Animation */}
>
);
}
// -----------------------------
// Footer
// -----------------------------
function Footer() {
return (
Treibiq
Wir automatisieren Ihre Prozesse mit KI – damit Sie sich auf das Wesentliche konzentrieren. DSGVO-konform, in wenigen Tagen, ohne IT-Abteilung.
Kostenloses Erstgespräch
© 2026 Treibiq. Wir treiben euch voran.
);
}
// -----------------------------
// 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 (
);
}
// 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 }) => (
setTweak(k, value)}>
{label}
);
return (
);
}
// -----------------------------
// 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}
);
}
// -----------------------------
// CTA band (used on most pages)
// -----------------------------
function CtaBand({ title = "Bereit, Zeit zurückzugewinnen?", sub = "Bucht euer kostenloses 30-Minuten-Gespräch. Wir schauen gemeinsam, welcher Workflow bei euch sofort Wirkung hat.", buttonLabel = "Jetzt Termin buchen", buttonHref = "kontakt.html" }) {
return (
);
}
// -----------------------------
// Hero Floating Tool-Icons
// - Initial-Animation: Cards fallen von oben (hinter Header) auf ihre Position
// - Konstante subtile Eigenbewegung (sin/cos je Card mit eigener Phase)
// - Maus-Parallax overlayed auf die Eigenbewegung
// -----------------------------
function HeroFloating() {
const containerRef = useRef(null);
const canvasRef = useRef(null);
// Layout: 13 Tools in 4 Reihen, KEIN Logo darf hinter der Title-Zone
// liegen (x ~18-82%, y ~14-61% bei zentriertem Title max-width 900px).
// Reihe 1 (y < 14%) und Reihe 4 (y > 61%) duerfen quer ueber den ganzen
// Hero. Reihe 2 und 3 sind in der Title-Zone, also nur an den Raendern.
// Order in Array == Index fuer CONNECTIONS-Array.
const tools = [
// Reihe 1 (top, y 0-1%, 5 Logos quer): OBEN ueber dem Title (Title
// startet bei y ~80px, also Icons mit max 62-74px Hoehe muessen
// moeglichst oben sitzen damit sie nicht in den Title hineinragen).
{ src: "assets/tools/gmail.png", top: "1%", left: "6%", depth: 0.9, rot: -4, size: "md", freqX: 0.40, freqY: 0.30, phase: 0.0 },
{ src: "assets/tools/hubspot.webp", top: "1%", left: "22%", depth: 1.0, rot: 6, size: "md", freqX: 0.50, freqY: 0.40, phase: 2.4 },
{ src: "assets/tools/clay.webp", top: "1%", left: "50%", depth: 1.0, rot: 8, size: "", freqX: 0.30, freqY: 0.55, phase: 1.8 },
{ src: "assets/tools/workspace.webp", top: "1%", left: "78%", depth: 0.9, rot: -6, size: "", freqX: 0.35, freqY: 0.50, phase: 3.4 },
{ src: "assets/tools/slack.webp", top: "0%", left: "91%", depth: 1.1, rot: 6, size: "lg", freqX: 0.40, freqY: 0.45, phase: 5.0 },
// Reihe 2 (y 24-28%, 2 Logos NUR an den Raendern wegen Title-Zone)
{ src: "assets/tools/n8n.webp", top: "24%", left: "3%", depth: 1.4, rot: -10, size: "lg", freqX: 0.35, freqY: 0.50, phase: 1.2 },
{ src: "assets/tools/sheets.webp", top: "24%", left: "92%", depth: 0.9, rot: -4, size: "md", freqX: 0.50, freqY: 0.35, phase: 4.1 },
// Reihe 3 (y 48-52%, 2 Logos NUR an den Raendern wegen Title-Zone)
{ src: "assets/tools/whatsapp.webp", top: "50%", left: "4%", depth: 0.7, rot: 4, size: "", freqX: 0.55, freqY: 0.30, phase: 4.2 },
{ src: "assets/tools/telegram.webp", top: "48%", left: "92%", depth: 1.0, rot: 3, size: "md", freqX: 0.45, freqY: 0.50, phase: 3.6 },
// Reihe 4 (y 85-88%, 4 Logos quer): UNTEN unter Title + CTAs
{ src: "assets/tools/ms365.webp", top: "88%", left: "12%", depth: 0.8, rot: -7, size: "", freqX: 0.45, freqY: 0.35, phase: 3.0 },
{ src: "assets/tools/notion.webp", top: "85%", left: "22%", depth: 1.2, rot: -3, size: "md", freqX: 0.40, freqY: 0.45, phase: 0.6 },
{ src: "assets/tools/datev.webp", top: "88%", left: "72%", depth: 0.9, rot: -5, size: "", freqX: 0.40, freqY: 0.40, phase: 4.8 },
{ src: "assets/tools/outlook.webp", top: "88%", left: "90%", depth: 0.7, rot: 2, size: "", freqX: 0.55, freqY: 0.30, phase: 5.7 },
];
// Workflow-Verbindungen: Bezier-Kurven zwischen Tools.
// Indices: 0-4 Reihe 1, 5-6 Reihe 2, 7-8 Reihe 3, 9-12 Reihe 4.
// Mix aus horizontalen Linien + kurzen Saeulen + Diagonalen (X-Cross
// in der Mitte und Back-Loops wie auf Mobile mit [3,1]), damit es nicht
// wie ein steifes Gitter aussieht. KEINE langen Cross-Linien quer
// durchs Bild von Reihe 1 nach Reihe 4.
const CONNECTIONS = [
// Horizontal innerhalb der Reihen
[0, 1], [1, 2], [2, 3], [3, 4], // Reihe 1
[9, 10], [10, 11], [11, 12], // Reihe 4
// Kurze vertikale Verbindungen zwischen Nachbar-Reihen (Saeulen)
[0, 5], [4, 6], // Reihe 1 -> Reihe 2 (Ecken)
[5, 7], [6, 8], // Reihe 2 -> Reihe 3 (Saeulen)
[7, 9], [8, 12], // Reihe 3 -> Reihe 4 (Ecken)
// Diagonal-Cross im Mittelbereich (zwischen Title-Zonen-Raendern):
// n8n -> telegram (/) und sheets -> whatsapp (\) kreuzen sich
// in der Mitte. Ersetzen die alten parallelen Mittel-Horizontalen.
[5, 8], // n8n -> telegram (Diagonale /)
[6, 7], // sheets -> whatsapp (Diagonale \)
// Back-Loops (Pfad geht zurueck zu frueheren Knoten, wie [3,1] Mobile):
[2, 5], // clay (top mitte) -> n8n (row2 links)
[10, 7], // notion (row4 links) -> whatsapp (row3 links)
];
useEffect(() => {
const container = containerRef.current;
const canvas = canvasRef.current;
if (!container || !canvas) return;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const cards = Array.from(container.querySelectorAll(".tool-float"));
if (cards.length === 0) return;
const ctx = canvas.getContext("2d");
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const resizeCanvas = () => {
const rect = container.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
canvas.style.width = rect.width + "px";
canvas.style.height = rect.height + "px";
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
};
resizeCanvas();
// Bezier-Helper: berechnet Punkt auf einer kubischen Bezier-Kurve bei Parameter u in [0,1]
const bezierPoint = (u, p0, p1, p2, p3) => {
const mt = 1 - u;
const mt2 = mt * mt;
const u2 = u * u;
return {
x: mt2 * mt * p0.x + 3 * mt2 * u * p1.x + 3 * mt * u2 * p2.x + u2 * u * p3.x,
y: mt2 * mt * p0.y + 3 * mt2 * u * p1.y + 3 * mt * u2 * p2.y + u2 * u * p3.y,
};
};
// Linien werden sequentiell aufgebaut: jede CONNECTION startet GROWTH_STAGGER
// Sekunden nach der vorherigen und waechst in GROWTH_DURATION Sekunden von
// Logo zu Logo. Waehrend des Wachstums sitzt der Pulse-Punkt an der Spitze
// und "zieht" die Linie. Wenn alle Linien fertig sind, wandert der Pulse
// zyklisch.
const GROWTH_DURATION = 1.4;
const GROWTH_STAGGER = 0.55;
const drawCanvas = (t) => {
const rect = container.getBoundingClientRect();
ctx.clearRect(0, 0, rect.width, rect.height);
// Aktuelle Card-Center berechnen
const centers = cards.map((card) => {
const cr = card.getBoundingClientRect();
return {
x: cr.left - rect.left + cr.width / 2,
y: cr.top - rect.top + cr.height / 2,
};
});
CONNECTIONS.forEach(([a, b], idx) => {
const p0 = centers[a]; const p3 = centers[b];
if (!p0 || !p3) return;
// Wachstums-Progress: 0 = noch nicht gestartet, 1 = voll gezeichnet
const startT = idx * GROWTH_STAGGER;
const rawProgress = (t - startT) / GROWTH_DURATION;
if (rawProgress <= 0) return;
// Easing fuer organisches Wachstum (cubic-out)
const linear = Math.min(1, rawProgress);
const progress = 1 - Math.pow(1 - linear, 3);
// Control-Points: leicht versetzt vertikal damit Linie geschwungen wirkt
const dx = p3.x - p0.x;
const sway = 18 + Math.sin(t * 0.4 + idx * 0.7) * 14;
const dir = idx % 2 === 0 ? 1 : -1;
const cp1 = { x: p0.x + dx * 0.33, y: p0.y + sway * dir };
const cp2 = { x: p0.x + dx * 0.66, y: p3.y - sway * dir };
// Linie progressiv zeichnen: nur bis u = progress
const steps = 48;
const maxStep = Math.max(1, Math.floor(steps * progress));
ctx.beginPath();
for (let i = 0; i <= maxStep; i++) {
const u = (i / steps) * progress;
const pt = bezierPoint(u, p0, cp1, cp2, p3);
if (i === 0) ctx.moveTo(pt.x, pt.y);
else ctx.lineTo(pt.x, pt.y);
}
ctx.strokeStyle = "rgba(83, 74, 183, 0.22)";
ctx.lineWidth = 1.4;
ctx.lineCap = "round";
ctx.stroke();
// Pulse-Punkt: waehrend Wachstum an der Spitze, danach zyklisch.
// Nach 2 vollen Pulse-Zyklen pro Linie sanft auf 30% opacity dimmen,
// damit die Animation nicht permanent vom Lesen ablenkt. Fade-Out
// ueber einen weiteren halben Zyklus damit es nicht abrupt cutet.
let pp;
let pulseAlpha = 1; // Multiplikator auf die Pulse-Farben
if (rawProgress < 1) {
// An der Wachstums-Spitze - voll hell
pp = bezierPoint(progress, p0, cp1, cp2, p3);
} else {
// Zyklisch entlang der fertigen Linie
const speed = 0.12;
const offset = idx * 0.13;
const u = ((t * speed + offset) % 1);
pp = bezierPoint(u, p0, cp1, cp2, p3);
// Zyklen seit Wachstum fertig (kontinuierlich, inkl. Bruchteilen)
const cyclesDone = (t - (startT + GROWTH_DURATION)) * speed;
const DIM_AFTER_CYCLES = 2;
const FADE_DURATION_CYCLES = 0.5;
const DIM_TARGET = 0.3;
if (cyclesDone > DIM_AFTER_CYCLES) {
const fadeT = Math.min(1, (cyclesDone - DIM_AFTER_CYCLES) / FADE_DURATION_CYCLES);
// ease-out cubic - sanfter Abklang
const eased = 1 - Math.pow(1 - fadeT, 3);
pulseAlpha = 1 - (1 - DIM_TARGET) * eased;
}
}
ctx.beginPath();
ctx.arc(pp.x, pp.y, 3.6, 0, Math.PI * 2);
ctx.fillStyle = `rgba(127, 112, 222, ${(0.78 * pulseAlpha).toFixed(3)})`;
ctx.fill();
// Sanfter Glow
ctx.beginPath();
ctx.arc(pp.x, pp.y, 8, 0, Math.PI * 2);
ctx.fillStyle = `rgba(127, 112, 222, ${(0.20 * pulseAlpha).toFixed(3)})`;
ctx.fill();
});
};
// Star-Burst Initial-Animation: Cards starten visuell im Center des Heros
// und expandieren auf ihre finale top/left Position.
// Dafuer setzen wir --bx und --by auf die negative Differenz zur Center.
const setupBurstOrigin = () => {
const containerRect = container.getBoundingClientRect();
const cx = containerRect.width / 2;
const cy = containerRect.height / 2;
cards.forEach((card) => {
const cardRect = card.getBoundingClientRect();
// Card-Center in Container-Koordinaten:
const cardCx = cardRect.left - containerRect.left + cardRect.width / 2;
const cardCy = cardRect.top - containerRect.top + cardRect.height / 2;
// Initial-Offset = vom Container-Center bis zur final-Position invertiert
// damit die Card optisch im Center startet:
const dx = cx - cardCx;
const dy = cy - cardCy;
card.style.setProperty("--bx", `${dx}px`);
card.style.setProperty("--by", `${dy}px`);
});
};
// Cards positionieren bevor die Transition triggert
setupBurstOrigin();
// Nochmal nach kurzer Zeit falls Layout sich aendert
const onResize = () => { setupBurstOrigin(); resizeCanvas(); };
window.addEventListener("resize", onResize, { passive: true });
// Phase 1: Initial-Reveal via CSS-Transition (Cards fliegen aus Center raus)
let lastDelayMs = 0;
cards.forEach((card) => {
const initialDelay = parseFloat(card.dataset.initialDelay) || 0;
const ms = initialDelay * 1000;
lastDelayMs = Math.max(lastDelayMs, ms);
setTimeout(() => card.classList.add("is-in"), ms);
});
if (reduceMotion) return;
// Phase 2: Live-Loop (Eigenbewegung + Maus-Parallax)
const transitionMs = 900;
const liveStartAt = lastDelayMs + transitionMs;
const state = new Map();
cards.forEach((card) => {
state.set(card, {
bx: 0, by: 0,
mxOff: 0, myOff: 0,
depth: parseFloat(card.dataset.depth) || 1,
rot: parseFloat(card.dataset.rot) || 0,
freqX: parseFloat(card.dataset.freqx) || 0.4,
freqY: parseFloat(card.dataset.freqy) || 0.4,
phase: parseFloat(card.dataset.phase) || 0,
});
});
let mx = 0, my = 0;
const isMobile = window.innerWidth < 720;
const updateMouse = (e) => {
const rect = container.getBoundingClientRect();
mx = e.clientX - rect.left - rect.width / 2;
my = e.clientY - rect.top - rect.height / 2;
};
if (!isMobile) {
window.addEventListener("mousemove", updateMouse, { passive: true });
}
let raf = null;
let frameCount = 0;
let liveStarted = false;
let liveStartTime = 0;
// Performance-Fix 2026-05-13: Loop pausiert wenn Hero ausserhalb
// Viewport. Vorher lief das Canvas-Drawing + transform-Updates der
// 14 Tool-Cards permanent, auch wenn der User schon weit unten war.
let isVisible = true;
// Maus-Parallax dezenter (war -0.35)
const sensitivity = -0.10;
const easing = 0.06;
// Eigenbewegung-Amplitude reduziert damit Cards in der enger gepackten
// 2-Saeulen-Anordnung sich auch in Spitzen-Positionen nicht ueberlappen.
const ampX = 6;
const ampY = 5;
setTimeout(() => {
liveStarted = true;
liveStartTime = performance.now();
}, liveStartAt);
const tick = (now) => {
if (!isVisible) { raf = null; return; }
frameCount += 1;
if (frameCount % 2 !== 0) {
raf = requestAnimationFrame(tick);
return;
}
if (liveStarted) {
const t = (now - liveStartTime) / 1000;
cards.forEach((card) => {
const s = state.get(card);
const targetBx = Math.sin(t * s.freqX + s.phase) * ampX;
const targetBy = Math.cos(t * s.freqY + s.phase * 1.3) * ampY;
s.bx += (targetBx - s.bx) * 0.08;
s.by += (targetBy - s.by) * 0.08;
const strength = (s.depth * sensitivity) / 25;
const targetMx = mx * strength;
const targetMy = my * strength;
s.mxOff += (targetMx - s.mxOff) * easing;
s.myOff += (targetMy - s.myOff) * easing;
const totalX = s.bx + s.mxOff;
const totalY = s.by + s.myOff;
const rotSway = Math.sin(t * s.freqX * 0.5 + s.phase) * 1.5;
card.style.transform =
`translate3d(${totalX.toFixed(2)}px, ${totalY.toFixed(2)}px, 0) rotate(${(s.rot + rotSway).toFixed(2)}deg) scale(1)`;
});
// Canvas-Layer mit Bezier-Verbindungen + Pulse-Punkten
drawCanvas(t);
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
// IntersectionObserver: Loop pausieren wenn Hero ausserhalb Viewport.
const io = new IntersectionObserver((entries) => {
isVisible = entries[0].isIntersecting;
if (isVisible && raf === null) {
raf = requestAnimationFrame(tick);
}
}, { threshold: 0, rootMargin: "100px" });
io.observe(container);
return () => {
if (!isMobile) window.removeEventListener("mousemove", updateMouse);
window.removeEventListener("resize", onResize);
if (raf) cancelAnimationFrame(raf);
io.disconnect();
};
}, []);
return (
{tools.map((t, i) => {
const sizeClass = t.size === "md" ? "tool-float--md" : t.size === "lg" ? "tool-float--lg" : "";
// Alle Cards starten gleichzeitig (Burst) - kein Stagger
const initialDelay = 0.15;
return (
);
})}
);
}
// -----------------------------
// AnimatedStatNum: parsed Werte wie "20+", "7 Std", "100%", "1-2"
// und wickelt den Zahl-Anteil in einen so dass
// useAnimations() den Counter beim Eintritt in den Viewport hochzaehlt.
// Prefix und Suffix bleiben statisch, nur die Zahl animiert.
// Sonderfall "1-2" -> prefix "1-", num "2" so dass "1-0" -> "1-2" zaehlt.
// -----------------------------
function AnimatedStatNum({ value }) {
const str = String(value);
// Priorisiert: Range > Tausender-Format > einfache Zahl
// Range "1-2" / "5-10" / "3–6": erste Zahl bleibt, zweite zaehlt hoch
const rangeMatch = str.match(/^(.*?)(\d+(?:[.,]\d+)?)([–—-])(\d+(?:[.,]\d+)?)(.*)$/);
if (rangeMatch) {
const [, pre, a, dash, b, rest] = rangeMatch;
return React.createElement(React.Fragment, null,
(pre || "") + a + dash,
React.createElement("span", {
className: "counter", "data-counter": b.replace(",", "."),
}, "0"),
rest || ""
);
}
// Deutsches Tausender-Format: "1.900", "5.900", "10.000" - Punkt als Tausender,
// bei mindestens 4 Stellen (also 1.000+). Pattern: \d{1,3}(\.\d{3})+
const thousandsMatch = str.match(/^(.*?)(\d{1,3}(?:\.\d{3})+)(.*)$/);
if (thousandsMatch) {
const [, pre, numStr, rest] = thousandsMatch;
const targetNum = parseInt(numStr.replace(/\./g, ""), 10);
return React.createElement(React.Fragment, null,
pre || "",
React.createElement("span", {
className: "counter", "data-counter": String(targetNum),
}, "0"),
rest || ""
);
}
// Einfache Zahl mit optionalem Dezimal-Komma/Punkt
const simpleMatch = str.match(/^([^\d]*)(\d+(?:[.,]\d+)?)(.*)$/);
if (!simpleMatch) return str;
const [, prefix, num, suffix] = simpleMatch;
return React.createElement(React.Fragment, null,
prefix || "",
React.createElement("span", {
className: "counter", "data-counter": num.replace(",", "."),
}, "0"),
suffix || ""
);
}
// Export to window so other Babel files can use them
Object.assign(window, {
Logo, LogoMark, BrandWave, Icon, Header, Footer, Page, CtaBand, UseCaseModal,
TOOLS, ToolLogo, ToolChip, HeroFloating, AnimatedStatNum, AnimatedContent,
TweaksProvider, TweaksPanel, useTweaks,
});