// Дополнительные интерактивы: кастомный курсор, magnetic-кнопка, hover-карточка с tilt,
// бегущая строка-маркер, число-счётчик при появлении.
// Кастомный курсор — два кольца, ловят hover на интерактивных элементах
function CustomCursor() {
const dot = React.useRef(); const ring = React.useRef();
const [hover, setHover] = React.useState(false);
const [label, setLabel] = React.useState('');
React.useEffect(() => {
let x=0,y=0,rx=0,ry=0,raf;
const onMove = (e) => {
x = e.clientX; y = e.clientY;
const t = e.target.closest('[data-cursor]');
setHover(!!t);
setLabel(t ? (t.getAttribute('data-cursor') || '') : '');
};
const tick = () => {
rx += (x - rx) * 0.18; ry += (y - ry) * 0.18;
if (dot.current) dot.current.style.transform = `translate(${x-3}px,${y-3}px)`;
if (ring.current) ring.current.style.transform = `translate(${rx-18}px,${ry-18}px) scale(${hover?1.6:1})`;
raf = requestAnimationFrame(tick);
};
window.addEventListener('mousemove', onMove);
raf = requestAnimationFrame(tick);
return () => { window.removeEventListener('mousemove', onMove); cancelAnimationFrame(raf); };
}, [hover]);
return (
<>
{hover && label && (
{label}
)}
>
);
}
// Магнитная кнопка — притягивается к курсору
function MagnetBtn({ children, onClick, style }) {
const ref = React.useRef();
const onMove = (e) => {
const r = ref.current.getBoundingClientRect();
const x = e.clientX - (r.left + r.width/2);
const y = e.clientY - (r.top + r.height/2);
ref.current.style.transform = `translate(${x*0.25}px, ${y*0.4}px)`;
};
const onLeave = () => { ref.current.style.transform = 'translate(0,0)'; };
return (
);
}
// Tilt-карточка
function TiltCard({ children, style, label }) {
const ref = React.useRef();
const onMove = (e) => {
const r = ref.current.getBoundingClientRect();
const x = (e.clientX - r.left)/r.width - 0.5;
const y = (e.clientY - r.top)/r.height - 0.5;
ref.current.style.transform = `perspective(900px) rotateY(${x*8}deg) rotateX(${-y*8}deg) translateY(-4px)`;
};
const onLeave = () => { ref.current.style.transform = 'perspective(900px) rotateY(0) rotateX(0) translateY(0)'; };
return (
{children}
);
}
// Бегущая строка
function Marquee({ items, speed = 60 }) {
const arr = [...items, ...items, ...items];
return (
{arr.map((it,i) => (
{it}✦
))}
);
}
// Счётчик-число (анимируется при появлении)
function CountUp({ to, suffix='', dur=1400 }) {
const [v, setV] = React.useState(0);
const ref = React.useRef(); const started = React.useRef(false);
React.useEffect(() => {
const io = new IntersectionObserver((es) => {
es.forEach(e => {
if (e.isIntersecting && !started.current) {
started.current = true;
const t0 = performance.now();
const tick = () => {
const p = Math.min(1, (performance.now()-t0)/dur);
const eased = 1 - Math.pow(1-p, 3);
setV(Math.round(to * eased));
if (p < 1) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}
});
}, { threshold: 0.5 });
if (ref.current) io.observe(ref.current);
return () => io.disconnect();
}, [to, dur]);
return {v}{suffix};
}
// Hover-выделение строки услуг (раскрытие справа описания)
function ServiceRow({ n, title, desc, price }) {
const [open, setOpen] = React.useState(false);
return (
setOpen(true)} onMouseLeave={()=>setOpen(false)}
data-cursor="узнать"
style={{ display:'grid', gridTemplateColumns:'70px 1fr auto', alignItems:'center',
padding:'24px 4px', borderTop:'1.5px solid var(--ink)', cursor:'pointer',
background: open ? 'var(--accent)' : 'transparent',
color: open ? 'var(--bg)' : 'var(--ink)',
transition:'background .25s, color .25s, padding .25s',
paddingLeft: open ? 24 : 4 }}>
№ 0{n}
);
}
Object.assign(window, { CustomCursor, MagnetBtn, TiltCard, Marquee, CountUp, ServiceRow });