// components.jsx — Reusable bits para YetiShopping
const { useState, useMemo, useEffect, useRef } = React;
// ─────────────────────────────────────────────────────────────────────────────
// StoreBadge — pílula colorida com o nome da loja
function StoreBadge({ store, size = "sm" }) {
const s = STORES[store];
if (!s) return null;
const pad = size === "lg" ? "6px 12px" : size === "md" ? "4px 9px" : "2px 7px";
const fs = size === "lg" ? 13 : size === "md" ? 12 : 10.5;
return (
{size === "lg" ? s.name : s.name}
);
}
// ─────────────────────────────────────────────────────────────────────────────
// ProductImage — imagem real (Lomadee/CJ) ou gradiente como fallback
function ProductImage({ product, size = 180, rounded = 18 }) {
const [imgErr, setImgErr] = React.useState(false);
const hasRealImg = product.image && !imgErr;
if (hasRealImg) {
return (

setImgErr(true)}
style={{ width: "100%", height: "100%", objectFit: "contain", padding: 8 }}
/>
);
}
// Fallback: gradiente + ícone (para produtos sem imagem ou skeleton)
const [c1, c2] = IMG_THEMES[product.cat] || IMG_THEMES.product;
const icon = ({ console:"🎮", phone:"📱", tv:"📺", laptop:"💻", audio:"🎧",
kitchen:"🍳", shoe:"👟", chair:"🪑", speaker:"🔊", monitor:"🖥️",
watch:"⌚", camera:"📷" })[product.cat] || "📦";
return (
{!product._skeleton && (
<>
{icon}
>
)}
);
}
// ─────────────────────────────────────────────────────────────────────────────
// SparkLine — gráfico mini de tendência
function SparkLine({ data, w = 120, h = 32, stroke = "#0E2A47", fillTop = "#FFC72C", fillBottom = null }) {
if (!data || data.length < 2) return null;
const min = Math.min(...data.map(d => d.p));
const max = Math.max(...data.map(d => d.p));
const range = max - min || 1;
const points = data.map((d, i) => {
const x = (i / (data.length - 1)) * w;
const y = h - ((d.p - min) / range) * (h - 4) - 2;
return [x, y];
});
const path = points.map((p, i) => (i === 0 ? `M${p[0]},${p[1]}` : `L${p[0]},${p[1]}`)).join(" ");
const fillPath = `${path} L${w},${h} L0,${h} Z`;
const last = points[points.length - 1];
return (
);
}
// ─────────────────────────────────────────────────────────────────────────────
// PriceChart — gráfico grande estilo CCC, com hover crosshair
function PriceChart({ history, currentPrice, lowestEver, highestEver, height = 280, period = "180" }) {
const ref = useRef(null);
const [hover, setHover] = useState(null);
const [width, setWidth] = useState(800);
useEffect(() => {
if (!ref.current) return;
const ro = new ResizeObserver(([e]) => setWidth(e.contentRect.width));
ro.observe(ref.current);
return () => ro.disconnect();
}, []);
// Filtrar por período
const data = useMemo(() => {
if (period === "all") return history;
const days = parseInt(period, 10);
return history.slice(-days);
}, [history, period]);
const PAD = { l: 56, r: 16, t: 16, b: 32 };
const W = width;
const H = height;
const PW = W - PAD.l - PAD.r;
const PH = H - PAD.t - PAD.b;
const prices = data.map(d => d.p);
let min = Math.min(...prices);
let max = Math.max(...prices);
const span = (max - min) || 1;
// pad y range
min = min - span * 0.1;
max = max + span * 0.1;
const xOf = i => PAD.l + (i / (data.length - 1)) * PW;
const yOf = p => PAD.t + (1 - (p - min) / (max - min)) * PH;
const path = data.map((d, i) => `${i === 0 ? "M" : "L"}${xOf(i)},${yOf(d.p)}`).join(" ");
const areaPath = `${path} L${xOf(data.length - 1)},${PAD.t + PH} L${xOf(0)},${PAD.t + PH} Z`;
// y ticks
const yTicks = 4;
const yStep = (max - min) / yTicks;
const yLabels = Array.from({ length: yTicks + 1 }, (_, i) => Math.round(min + i * yStep));
// x ticks
const xLabels = [0, 0.25, 0.5, 0.75, 1].map(t => Math.floor(t * (data.length - 1)));
// lowest/highest indices
const loIdx = data.reduce((best, d, i) => d.p < data[best].p ? i : best, 0);
const hiIdx = data.reduce((best, d, i) => d.p > data[best].p ? i : best, 0);
function handleMove(e) {
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
if (x < PAD.l || x > PAD.l + PW) { setHover(null); return; }
const i = Math.round(((x - PAD.l) / PW) * (data.length - 1));
setHover({ i: Math.max(0, Math.min(data.length - 1, i)) });
}
const monthFmt = new Intl.DateTimeFormat("pt-BR", { day: "2-digit", month: "short" });
return (
{/* hover tooltip */}
{hover && (
{new Intl.DateTimeFormat("pt-BR", { day: "2-digit", month: "long", year: "numeric" }).format(data[hover.i].d)}
{fmtBRL(data[hover.i].p)}
)}
);
}
// ─────────────────────────────────────────────────────────────────────────────
// ProductCard — card de produto para grids (suporta produtos reais + mock)
function ProductCard({ product, onClick }) {
if (product._skeleton) {
return (
);
}
const dropPct = product.base > 0 ? Math.round(((product.base - product.current) / product.base) * 100) : 0;
const isLow = product.lowestEver > 0 && product.current <= product.lowestEver * 1.03;
return (
);
}
// ─────────────────────────────────────────────────────────────────────────────
// StoreLogosStrip — fileira de logos de loja como prova
function StoreLogosStrip({ stores, label = "Comparamos preços em:" }) {
return (
{label}
{(stores || Object.keys(STORES)).map(id => {
const s = STORES[id];
return (
{s.name}
);
})}
);
}
// Export pra outros scripts
Object.assign(window, { StoreBadge, ProductImage, SparkLine, PriceChart, ProductCard, StoreLogosStrip });