// 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 (
);
}
// ── 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
?
: ;
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 = (
);
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;