/* Shared utilities, hooks, and primitives for all 3 LP variants */ /* --- Scroll reveal hook --- */ function useReveal(threshold = 0.15) { const ref = React.useRef(null); const [shown, setShown] = React.useState(false); React.useEffect(() => { if (!ref.current) return; const io = new IntersectionObserver( (entries) => { entries.forEach((e) => { if (e.isIntersecting) { setShown(true); io.unobserve(e.target); } }); }, { threshold, root: null, rootMargin: '0px 0px -10% 0px' } ); io.observe(ref.current); return () => io.disconnect(); }, [threshold]); return [ref, shown]; } /* --- Scroll progress (0..1 within element) --- */ function useScrollProgress(scrollerRef) { const ref = React.useRef(null); const [p, setP] = React.useState(0); React.useEffect(() => { const scroller = scrollerRef?.current; if (!ref.current || !scroller) return; const el = ref.current; function update() { const rect = el.getBoundingClientRect(); const scrollerRect = scroller.getBoundingClientRect(); const start = scrollerRect.top + scrollerRect.height; // when top of el reaches bottom of scroller const top = rect.top; // relative to viewport (which is scroller in this case for the iframe) // simpler: use scroller scrollTop relative to element offsetTop const offsetTop = el.offsetTop; const scrollerH = scroller.clientHeight; const scrollTop = scroller.scrollTop; const elH = el.offsetHeight; const startAt = offsetTop - scrollerH; const endAt = offsetTop + elH; const raw = (scrollTop - startAt) / (endAt - startAt); setP(Math.max(0, Math.min(1, raw))); } update(); scroller.addEventListener('scroll', update, { passive: true }); window.addEventListener('resize', update); return () => { scroller.removeEventListener('scroll', update); window.removeEventListener('resize', update); }; }, [scrollerRef]); return [ref, p]; } /* --- Count up on reveal --- */ function CountUp({ to, duration = 1200, suffix = '' }) { const [ref, shown] = useReveal(0.4); const [n, setN] = React.useState(0); React.useEffect(() => { if (!shown) return; let raf; const start = performance.now(); const tick = (t) => { const p = Math.min(1, (t - start) / duration); const eased = 1 - Math.pow(1 - p, 3); setN(Math.round(to * eased)); if (p < 1) raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [shown, to, duration]); return {n}{suffix}; } /* --- Reveal wrapper --- */ function Reveal({ children, delay = 0, y = 24, className = '', as: As = 'div' }) { const [ref, shown] = useReveal(); return ( {children} ); } /* --- Placeholder image (striped) --- Used until real product photos are dropped in. Pass `kind` to label it. */ function Placeholder({ ratio = '4 / 3', kind = 'photo', tone = 'warm', accent }) { const stripes = { warm: ['#e8dfd1', '#dfd3c0'], green: ['#dfe5d8', '#d4dccb'], mono: ['#e8e6e2', '#dcdad6'], dark: ['#2a2622', '#322d28'], }; const [a, b] = stripes[tone] || stripes.warm; const fg = tone === 'dark' ? 'rgba(255,255,255,.55)' : 'rgba(0,0,0,.45)'; return (
{kind}
{accent && (
)}
); } /* --- Vertical writing helper --- */ function Tategaki({ children, style }) { return ( {children} ); } /* --- Mobile frame chrome (status bar etc) --- */ function MobileChrome({ children, bg = '#fff', innerRef }) { return (
{/* status bar */}
9:41
{/* notch */}
{/* scrollable content */}
{children}
{/* home indicator */}
); } Object.assign(window, { useReveal, useScrollProgress, CountUp, Reveal, Placeholder, Tategaki, MobileChrome, });