/* 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,
});