/* Motion — two signature moments only: the hero headline reveal and counting
stats. Everything else on the page is a calm scroll-fade. */
const prefersReduced = () =>
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
/* Count a number up when it scrolls into view. Parses "+1500" / "+1M" / "98%"
and preserves the prefix/suffix exactly as written. */
function useCountUp(ref, raw, duration = 1600) {
React.useEffect(() => {
const el = ref.current;
if (!el) return;
const m = String(raw).match(/^([^\d]*)([\d.]+)(.*)$/);
if (!m || prefersReduced()) return;
const [, prefix, numStr, suffix] = m;
const target = parseFloat(numStr);
const decimals = (numStr.split('.')[1] || '').length;
let raf, started = false;
const io = new IntersectionObserver((entries) => {
entries.forEach((e) => {
if (!e.isIntersecting || started) return;
started = true;
const t0 = performance.now();
const tick = (now) => {
const p = Math.min(1, (now - t0) / duration);
const eased = 1 - Math.pow(1 - p, 3);
const val = target * eased;
el.textContent = prefix + val.toFixed(decimals) + suffix;
if (p < 1) raf = requestAnimationFrame(tick);
};
el.textContent = prefix + '0' + suffix;
raf = requestAnimationFrame(tick);
io.unobserve(e.target);
});
}, { threshold: 0.5 });
io.observe(el);
return () => { io.disconnect(); if (raf) cancelAnimationFrame(raf); };
}, [raw]);
}
function CountUp({ value, className }) {
const ref = React.useRef(null);
useCountUp(ref, value);
return {value};
}
/* Split a string into per-word spans so CSS can cascade a reveal across them.
The inter-word space is its OWN element: a trailing space inside an
inline-block with overflow:hidden gets collapsed away by the browser,
which runs the words together. */
function SplitWords({ text, className, delay = 0, step = 55 }) {
const words = String(text).split(' ');
return (
{words.map((w, i) => (
{w}
{i < words.length - 1 ? : null}
))}
);
}
/* Duration from the track's real width, so the belt always moves at a
constant px/second no matter how many chips it holds. */
function useMarquee(speed = 55) {
const ref = React.useRef(null);
React.useEffect(() => {
const el = ref.current;
if (!el) return;
const apply = () => {
const span = el.scrollWidth / 3;
if (span > 0) el.style.animationDuration = (span / speed).toFixed(2) + 's';
};
apply();
const ro = new ResizeObserver(apply);
ro.observe(el);
return () => ro.disconnect();
}, [speed]);
return ref;
}
Object.assign(window, { useMarquee, useCountUp, CountUp, SplitWords, prefersReduced });