// Общие UI-примитивы BATONS'ON (editorial / minimal) const { useState, useEffect, useRef, useMemo } = React; // Плейсхолдер фото function Ph({ label, ratio }) { return (
{label}
); } function Media({ p, ratio }) { if (p.img) return {p.name}; return ; } function Stars({ value = 5, sold }) { return ( {"★".repeat(Math.round(value))} {value.toFixed(1)} {sold != null && · {sold.toLocaleString("ru-RU")}} ); } function Badge({ children }) { return children ? {children} : null; } function Swatches({ colors = [], active, onPick, max }) { const shown = max ? colors.slice(0, max) : colors; const extra = max ? colors.length - shown.length : 0; return (
{shown.map((c, i) => (
); } // цикл индексов 0..n-1 со сменой function useCycle(n, ms, offset = 0) { const [i, setI] = useState(offset % n); useEffect(() => { const t = setInterval(() => setI((x) => (x + 1) % n), ms); return () => clearInterval(t); }, [n, ms]); return i; } // Видео, которое перелистывает ролики (разные и меняются) function RotatingVideo({ reels, ms = 6000, offset = 0, poster }) { const idx = useCycle(reels.length, ms, offset); return (
{reels.map((src, i) => (
); } // Карточка товара — premium function ProductCard({ p, onOpen, onAdd }) { const [ci, setCi] = useState(0); const [wish, setWish] = useState(false); const [added, setAdded] = useState(false); const [mi, setMi] = useState(0); const addedT = useRef(null); const mediaRef = useRef(null); const videoRef = useRef(null); const gallery = p.mediaItems ? getProductMedia(p) : p.videoFirst && p.video ? [{ type: "video", src: p.video }, ...(p.images || [p.img]).filter(Boolean).map((img) => ({ type: "image", img }))] : null; const active = gallery ? gallery[Math.min(mi, gallery.length - 1)] : null; const playCurrentVideo = () => { const video = videoRef.current; if (!video) return; video.muted = true; video.volume = 0; video.play().catch(() => {}); }; const pauseCurrentVideo = () => videoRef.current && videoRef.current.pause(); useEffect(() => { setMi(0); }, [p.id]); useEffect(() => { if (!gallery || !active || active.type !== "video" || !mediaRef.current) return; const media = mediaRef.current; const isTouch = window.matchMedia("(hover: none), (pointer: coarse)").matches; if (!isTouch) { if (media.matches(":hover")) playCurrentVideo(); return; } if (!("IntersectionObserver" in window)) { playCurrentVideo(); return; } const observer = new IntersectionObserver(([entry]) => { if (entry.isIntersecting) playCurrentVideo(); else pauseCurrentVideo(); }, { threshold: 0.45 }); observer.observe(media); return () => observer.disconnect(); }, [p.id, mi]); const open = () => onOpen(p); const quickAdd = (e) => { e.stopPropagation(); onAdd(p, { color: ci }); setAdded(true); clearTimeout(addedT.current); addedT.current = setTimeout(() => setAdded(false), 1500); }; const moveMedia = (e, next) => { e.preventDefault(); e.stopPropagation(); setMi(next); }; const discount = p.old ? Math.round((1 - p.price / p.old) * 100) : 0; return (
{ if (e.key === "Enter" && e.target === e.currentTarget) { e.preventDefault(); open(); } }}>
{ if (window.matchMedia("(hover: hover)").matches) playCurrentVideo(); }} onMouseLeave={() => { if (window.matchMedia("(hover: hover)").matches) pauseCurrentVideo(); }}> {active ? active.type === "video" ?

{p.name}

{p.brand && BATONS'ON}
{RUB(p.price)} {p.old && {RUB(p.old)}}
); } Object.assign(window, { Ph, Media, Stars, Badge, Swatches, ProductCard, useCycle, RotatingVideo });