/* Shared hooks and small bits */ // Scroll-reveal hook — IntersectionObserver with a fallback for environments // where IO doesn't fire reliably (iframes, headless capture). After a short // timeout, any reveal element that's already in the viewport gets force-shown. function useReveal() { const ref = React.useRef(null); React.useEffect(() => { const el = ref.current; if (!el) return; let shown = false; const show = () => { if (shown) return; shown = true; el.classList.add('in'); }; // If element is already within viewport at mount, just show it after a beat. const tryNow = () => { const r = el.getBoundingClientRect(); if (r.top < window.innerHeight && r.bottom > 0) show(); }; const io = new IntersectionObserver((entries) => { entries.forEach(e => { if (e.isIntersecting) show(); }); }, { threshold: 0.05, rootMargin: '0px 0px -5% 0px' }); io.observe(el); // Fallback: after a tick, force-show anything already on-screen. const t = setTimeout(tryNow, 120); return () => { io.disconnect(); clearTimeout(t); }; }, []); return ref; } function Reveal({ children, delay = 0, as: As = 'div', className = '', ...rest }) { const ref = useReveal(); const style = delay ? { transitionDelay: `${delay}ms` } : undefined; return ( {children} ); } // Magnetic button — element shifts slightly toward the cursor function Magnetic({ children, strength = 18, className = '', ...rest }) { const ref = React.useRef(null); const onMove = (e) => { const el = ref.current; if (!el) return; const r = el.getBoundingClientRect(); const x = e.clientX - (r.left + r.width / 2); const y = e.clientY - (r.top + r.height / 2); el.style.transform = `translate(${(x / r.width) * strength}px, ${(y / r.height) * strength}px)`; }; const onLeave = () => { if (ref.current) ref.current.style.transform = ''; }; return ( {children} ); } // Typing effect — types a string char by char, then optionally calls onDone function useTyping(text, { speed = 18, start = true, onDone } = {}) { const [out, setOut] = React.useState(''); React.useEffect(() => { if (!start) return; setOut(''); let i = 0; let cancelled = false; const tick = () => { if (cancelled) return; i += 1; setOut(text.slice(0, i)); if (i < text.length) setTimeout(tick, speed); else if (onDone) onDone(); }; const t = setTimeout(tick, speed); return () => { cancelled = true; clearTimeout(t); }; }, [text, start]); return out; } // Window utilities const DEFAULT_ROUTE_PATHS = { home: '/', about: '/about/', services: '/services/', cases: '/cases/', articles: '/articles/', prices: '/prices/', platforms: '/platforms/', contact: '/contact/', }; function routeHref(id) { return (window.__ROUTE_PATHS__ || DEFAULT_ROUTE_PATHS)[id] || `/${id}/`; } function routeClick(setRoute, id) { return (e) => { if (typeof setRoute !== 'function') return; e.preventDefault(); setRoute(id); window.scrollTo(0, 0); }; } function getBrandData() { const contact = window.__PUBLIC_DATA__?.contact || {}; const name = String(contact.site_name || 'Winet Lab').trim() || 'Winet Lab'; const domain = String(contact.site_label || '').trim() || 'app.winetlab.site'; return { name, domain, icon: '/favicon.png', iconLight: '/favicon-light.png', }; } function BrandMark() { const brand = getBrandData(); return ( ); } function BrandText({ showDomain = true } = {}) { const brand = getBrandData(); return ( {brand.name} {showDomain && brand.domain ? {` \u00b7 ${brand.domain}`} : null} ); } function BrandLink({ href = '/', onClick, className = 'brand', showDomain = true } = {}) { return ( ); } function FooterBrand({ style } = {}) { const brand = getBrandData(); return (
{brand.name}
); } function scrollToId(id) { const el = document.getElementById(id); if (!el) return; const top = el.getBoundingClientRect().top + window.scrollY - 70; window.scrollTo({ top, behavior: 'smooth' }); } Object.assign(window, { useReveal, Reveal, Magnetic, useTyping, routeHref, routeClick, getBrandData, BrandMark, BrandText, BrandLink, FooterBrand, scrollToId, });