// 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 (
{product.title 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 (
setHover(null)} style={{ display: "block", cursor: "crosshair" }}> {/* y grid */} {yLabels.map((v, i) => { const y = yOf(v); return ( {fmtBRL(v)} ); })} {/* lowest band */} MENOR PREÇO {/* area + line */} {/* lowest marker */} {/* highest marker */} {/* current marker */} {/* x labels */} {xLabels.map((i, k) => ( {monthFmt.format(data[i].d)} ))} {/* hover */} {hover && ( )} {/* 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 });