// OH Lead — Modo TV v2 · board (React) const { useState: uS, useEffect: uE, useRef: uR, useMemo: uM } = React; // ── helpers ─────────────────────────────── const brl = (cents) => (cents / 100).toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const brl0 = (cents) => Math.round(cents / 100).toLocaleString('pt-BR'); const pct = (x) => Math.round(x * 100) + '%'; const delta = (cur, prev) => (prev ? Math.round(((cur - prev) / prev) * 100) : 0); // conta-progressivo suave. Semeia o valor real na 1ª carga (captura estática // correta) e anima apenas os incrementos ao vivo seguintes. function useCountUp(target, dur = 750) { const tgt = Number.isFinite(target) ? target : 0; const [v, setV] = uS(tgt); const from = uR(tgt); const t0 = uR(0); const raf = uR(0); const seeded = uR(false); uE(() => { if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { setV(tgt); return; } if (!seeded.current) { if (tgt !== 0) { seeded.current = true; from.current = tgt; setV(tgt); } return; } from.current = Number.isFinite(v) ? v : tgt; t0.current = performance.now(); cancelAnimationFrame(raf.current); // trava dentro do intervalo real [from, target] — impede runaway caso um // frame venha com now < t0 (k negativo faria e = 1-(1-k)³ explodir e o valor // dispararia; o `from` seguinte herdaria o valor corrompido, sem volta). const lo = Math.min(from.current, tgt), hi = Math.max(from.current, tgt); const step = (now) => { const k = Math.min(1, Math.max(0, (now - t0.current) / dur)); const e = 1 - Math.pow(1 - k, 3); let nv = from.current + (tgt - from.current) * e; nv = Math.min(hi, Math.max(lo, nv)); setV(k >= 1 ? tgt : nv); if (k < 1) raf.current = requestAnimationFrame(step); }; raf.current = requestAnimationFrame(step); return () => cancelAnimationFrame(raf.current); }, [target]); return v; } function Sparkline({ data, color, w = 70, h = 26 }) { const max = Math.max(1, ...data); const step = w / (data.length - 1); const pts = data.map((d, i) => `${i * step},${h - (d / max) * (h - 3) - 1}`).join(' '); return ( ); } // ── ring de meta ────────────────────────── function MetaRing({ cur, goal, accent }) { const ratio = Math.min(1, goal ? cur / goal : 0); const shown = useCountUp(ratio, 800); const R = 66, C = 2 * Math.PI * R; const done = ratio >= 1; const left = Math.max(0, goal - cur); return (
Meta do dia
{Math.round(shown * 100)}%
da meta
R$ {brl0(cur)}
de R$ {brl0(goal)}
{done ? '✓ Meta batida!' : `faltam R$ ${brl0(left)}`}
); } // ── KPI tile ────────────────────────────── function Kpi({ label, value, accent, deltaPct, spark, accentColor }) { const dCls = deltaPct == null ? 'flat' : deltaPct > 0 ? 'up' : deltaPct < 0 ? 'down' : 'flat'; return (
{label}
{value}
{deltaPct != null && (
{deltaPct > 0 ? '▲' : deltaPct < 0 ? '▼' : '—'} {Math.abs(deltaPct)}%
)} {spark && }
); } // ── gráfico por hora ────────────────────── function HourChart({ hours, accent }) { const W = 1000, H = 300, PT = 16, PB = 30; const IH = H - PT - PB; const max = Math.max(1, ...hours.map((h) => h.checkins + h.signups)); const n = hours.length; const bw = W / n; const barW = Math.min(94, Math.max(8, bw * 0.5)); const nowI = n - 1; return ( {[0.25, 0.5, 0.75, 1].map((f) => ( ))} {hours.map((s, i) => { const x = i * bw + (bw - barW) / 2; const hC = (s.checkins / max) * IH; const hS = (s.signups / max) * IH; const cur = i === nowI; return ( {cur && } {cur && hS > 0 && } ); })} {hours.map((s, i) => ( (n <= 8 || i % 2 === 0) ? ( {s.hour}h ) : null ))} ); } // ── feed ao vivo ────────────────────────── function agoLabel(m) { if (m <= 0) return 'agora'; if (m < 60) return `há ${m} min`; return `há ${Math.floor(m / 60)}h`; } function FeedItem({ it, fresh }) { return (
{it.initials}
{it.name.split(' ')[0]}
{it.isReturn ? 'voltou' : 'novo'} · totem {it.totem} · {it.city.label}
{it.valueCents > 0 ?
R$ {brl0(it.valueCents)}
:
}
{fresh ? ● AGORA : agoLabel(it.minsAgo)}
); } // ── mapa (Leaflet, bolinhas por cidade) ─── const CITY_COORD = { 'Novo Hamburgo': [-29.6783, -51.1309], 'São Leopoldo': [-29.7604, -51.1474], 'Canoas': [-29.9178, -51.1836], 'Estância Velha': [-29.6489, -51.1739], 'Sapucaia do Sul': [-29.8275, -51.1450], 'Portão': [-29.7019, -51.2419], }; function TvMap({ cidades, accent, ping }) { const elRef = uR(null), mapRef = uR(null), layerRef = uR(null), pingRef = uR(null), boundsRef = uR(null); uE(() => { const map = L.map(elRef.current, { zoomControl: false, attributionControl: false, dragging: false, scrollWheelZoom: false, doubleClickZoom: false, boxZoom: false, keyboard: false, touchZoom: false, tap: false, }).setView([-29.75, -51.15], 10); L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { subdomains: 'abcd', maxZoom: 19 }).addTo(map); layerRef.current = L.layerGroup().addTo(map); pingRef.current = L.layerGroup().addTo(map); mapRef.current = map; const t = setTimeout(() => map.invalidateSize(), 140); // recalcula o tamanho quando o container muda (troca de layout, escala, resize) let ro = null; if (typeof ResizeObserver !== 'undefined') { ro = new ResizeObserver(() => { map.invalidateSize(); if (boundsRef.current) map.fitBounds(boundsRef.current, { maxZoom: 11, animate: false }); }); ro.observe(elRef.current); } return () => { clearTimeout(t); if (ro) ro.disconnect(); map.remove(); mapRef.current = null; }; }, []); // marcadores uE(() => { const map = mapRef.current, layer = layerRef.current; if (!map || !layer) return; const items = cidades.map((c) => ({ ...c, coord: CITY_COORD[c.label] })).filter((c) => c.coord); const max = Math.max(1, ...items.map((i) => i.count)); layer.clearLayers(); items.forEach((it) => { L.circleMarker(it.coord, { radius: 7 + Math.sqrt(it.count) * 5, color: accent, weight: 1.5, fillColor: accent, fillOpacity: 0.32 + 0.4 * (it.count / max), }).addTo(layer); }); if (items.length) { boundsRef.current = L.latLngBounds(items.map((i) => i.coord)).pad(0.45); map.fitBounds(boundsRef.current, { maxZoom: 11, animate: false }); } }, [cidades, accent]); // ping ao vivo a cada novo cadastro uE(() => { if (!ping || !ping.city) return; const map = mapRef.current, layer = pingRef.current, coord = CITY_COORD[ping.city]; if (!map || !layer || !coord) return; if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; let r = 8, op = 0.8; const mk = L.circleMarker(coord, { radius: r, color: accent, weight: 2.5, fillColor: accent, fillOpacity: 0.25 }).addTo(layer); const id = setInterval(() => { r += 2.6; op -= 0.05; if (op <= 0) { clearInterval(id); layer.removeLayer(mk); return; } mk.setRadius(r); mk.setStyle({ opacity: op, fillOpacity: op * 0.25 }); }, 40); return () => { clearInterval(id); layer.removeLayer(mk); }; }, [ping]); return
; } // ── board ───────────────────────────────── function TvBoard({ t }) { const accent = t.accent || '#ff6a00'; const showGoal = t.meta !== false; const layout = t.layout || 'compacto'; const devicesN = parseInt(t.totens, 10) || 3; const engineRef = uR(null); const [snap, setSnap] = uS(null); const [burst, setBurst] = uS(0); const [toast, setToast] = uS(null); const [ping, setPing] = uS(null); const [now, setNow] = uS(() => new Date()); // acento como variável CSS uE(() => { document.getElementById('tv-board').style.setProperty('--accent', accent); }, [accent]); // engine uE(() => { const eng = window.TVSim.createEngine({ pace: t.pace || 'busy', devices: devicesN, hourNow: t.dia === 'cheio' ? 20 : undefined }); engineRef.current = eng; setSnap(eng.snapshot()); const unsub = eng.subscribe((s, evt) => { setSnap(s); if (evt && evt.type === 'signup') { setPing({ city: evt.item.city.label, n: evt.item.id }); if (t.celeb !== false) { setBurst((b) => b + 1); setToast({ id: evt.item.id, name: evt.item.name, totem: evt.item.totem }); } } }); eng.start(); return () => { unsub(); eng.stop(); }; }, []); // troca de ritmo sem remontar uE(() => { if (engineRef.current) engineRef.current.setPace(t.pace || 'busy'); }, [t.pace]); // troca da qtd de totens (demo de responsividade) uE(() => { if (engineRef.current) engineRef.current.configure({ devices: devicesN }); }, [devicesN]); // simulação de dia cheio (12 barras no gráfico) uE(() => { if (engineRef.current) engineRef.current.configure({ hourNow: t.dia === 'cheio' ? 20 : null }); }, [t.dia]); // relógio uE(() => { const id = setInterval(() => setNow(new Date()), 1000); return () => clearInterval(id); }, []); // limpa toast uE(() => { if (!toast) return; const id = setTimeout(() => setToast(null), 3000); return () => clearTimeout(id); }, [toast]); // count-ups dos números-herói const rev = useCountUp(snap ? snap.revenueCents : 0, 700); const cad = useCountUp(snap ? snap.signups : 0, 600); const vis = useCountUp(snap ? snap.visits : 0, 600); if (!snap) return null; const goal = showGoal ? snap.goalCents : 0; const revDelta = delta(snap.revenueCents, snap.yRevenueCents); const cadDelta = delta(snap.signups, snap.ySignups); const visDelta = delta(snap.visits, snap.yVisits); const feedItems = snap.feed.slice(-7).reverse(); const maxCity = Math.max(1, ...snap.cidades.map((c) => c.count)); const intColors = [accent, '#3b82f6', '#a78bfa', '#f472b6']; const time = now.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }); const date = now.toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'short' }); const rankN = layout === 'mapa' ? 6 : 4; // ── cards reutilizáveis (posicionados diferente por layout) ── const metaCard = showGoal ? :
Faturamento de hoje
R${brl0(rev)}
; const kpiVisitas = ; const kpiNovos = ; const kpiRetorno = ; const kpiOptin = ; // feed "Atividade ao vivo" (volta à coluna direita no layout "mapa") const liveItems = snap.feed.slice(0, 6); const feedCard = (
Atividade ao vivo
{liveItems.map((it, i) => )}
); // card grande de cadastros (usado no topo da 3ª coluna no layout "compacto") const cadBigCard = (
Cadastros hoje
{Math.round(cad)}
{cadDelta >= 0 ? '▲' : '▼'} {Math.abs(cadDelta)}% vs ontem · meta {snap.pace === 'quiet' ? 8 : 45}
novo cadastro
); const heroCard = (
Faturamento de hoje
R${brl(rev)}
ticket médio R$ {brl(snap.avgTicketCents)} · {snap.withValue} de {snap.signups} com valor
{revDelta >= 0 ? '▲' : '▼'} {Math.abs(revDelta)}%
vs ontem
no mesmo horário
); const chartCard = (
Movimento por hora
Cadastros Check-ins
); const mapCard = (
De onde vêm — cadastros no mapa
Recorde do mês: {snap.records.bestDaySignups} em {snap.records.bestDayLabel} Sequência: {snap.records.streakDays} dias batendo meta
); const totensCard = (
Totens {snap.devices.length}
5 ? ' cols2' : '')}> {snap.devices.map((d) => (
{d.name} {d.online ? {d.signupsToday} hoje : 'offline'}
))}
); const interessesCard = (
Interesses da base
{snap.interesses.map((it, i) => (
{it.label} {pct(it.pct)}
))}
); const bottomRow = (
{totensCard}{interessesCard}
); const rankingCard = (
Ranking de cidades
{snap.cidades.slice(0, rankN).map((c) => (
{c.label} / {c.uf} {c.count}
))}
); const header = (
OH
OHLead· Loja Centro
AO VIVO · atualizado agora
{time}
{date}
); const toastEl = toast ? (
+1 Novo cadastro · {toast.name.split(' ')[0]} no totem {toast.totem}
) : null; // ── layout "mapa": mapa no centro; cadastros sobe p/ coluna 1; chart + ranking 50/50 à direita ── if (layout === 'mapa') { return ( {header}
{cadBigCard}
{kpiVisitas}{kpiRetorno}{kpiOptin}
{heroCard} {mapCard}
{totensCard}{chartCard}
{feedCard}{rankingCard}
{toastEl}
); } // ── layout "compacto" (original): chart no centro; mapa à direita ── return ( {header}
{metaCard}
{kpiVisitas}{kpiNovos}{kpiRetorno}{kpiOptin}
{heroCard} {chartCard} {cadBigCard} {mapCard} {bottomRow} {rankingCard}
{toastEl}
); } window.TvBoard = TvBoard;