// Общие UI-примитивы BATONS'ON (editorial / minimal)
const { useState, useEffect, useRef, useMemo } = React;
// Плейсхолдер фото
function Ph({ label, ratio }) {
return (
);
}
function Media({ p, ratio }) {
if (p.img) return
;
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 });