// app-media.jsx — capturas reais do app (hero + seção das duas interfaces).
//
// Todo o site puxa imagem/vídeo do produto por aqui. Enquanto não houver
// captura, cada slot cai no seu fallback e o layout já fica no lugar certo —
// trocar depois é preencher `src`/`poster` em APP_MEDIA e mais nada.
const { useState: _mUseState, useEffect: _mUseEffect, useRef: _mUseRef } = React;
// ─────────────────────────────────────────────────────────────
// PONTO ÚNICO DE TROCA
// ─────────────────────────────────────────────────────────────
// Para plugar a captura real, preencha `src` (vídeo .mp4/.webm) e/ou
// `poster` (imagem .png/.webp). Com só `poster`, vira imagem estática —
// que já é melhor que mockup. Com os dois, vira vídeo com poster nítido.
//
// vendedorDesktop: { src: 'assets/app-vendedor.mp4', poster: 'assets/app-vendedor.png' }
//
// Proporção alvo: 16/10 (as capturas de desktop devem sair em 1440×900 ou
// 1600×1000 pra não borrar no retina).
// As capturas reais estão em `assets/`, mas ficam DESLIGADAS de propósito na
// seção "As duas pontas". A captura é uma tela cheia de dado miúdo: reduzida
// pra caber em meia coluna, nada nela se lê, e ela não faz o que a seção
// promete — mostrar a venda saindo do app do vendedor e chegando no painel da
// empresa. O painel encenado faz: o cursor clica em "Minhas vendas", abre a
// venda já preenchida pelo link, registra, e a pastinha atravessa até o
// ranking do outro lado. É cena, não screenshot.
//
// Pra voltar a usar a captura em algum slot, basta repor o `poster` (e o
// `src`, se houver vídeo) — o resto do layout não muda.
const APP_MEDIA = {
vendedorDesktop: {
src: null,
poster: null, // assets/app-vendedor.webp
ratio: '1568 / 777',
alt: 'Dashboard do app do vendedor vendas, comissão e meta do mês',
},
empresaDesktop: {
src: null,
poster: null, // assets/app-empresa.webp
ratio: '1568 / 777',
alt: 'Painel do app da empresa equipe, produtos e comissões a pagar',
},
};
// Etiqueta de "captura pendente" só aparece rodando local. Em produção o
// slot fica limpo mesmo sem mídia — nunca vaza aviso de obra pro visitante.
//
// Desligada: nos dois slots das "duas pontas" o painel encenado é a escolha,
// não uma captura que ainda falta — a etiqueta dizia o contrário e ainda
// tapava a primeira linha de KPIs justo na hora de revisar a cena. Volte pra
// `true` quando estiver esperando uma captura de verdade em algum slot.
const MEDIA_DEV = false;
const temMidia = (m) => !!(m && (m.src || m.poster));
window.APP_MEDIA = APP_MEDIA;
// ─────────────────────────────────────────────────────────────
// AppMedia — vídeo/imagem do produto, com fallback enquanto não existe
// ─────────────────────────────────────────────────────────────
// O vídeo só baixa quando chega perto da viewport (mesma tática do
// AmbientVideo do FinalCTA): captura de app é pesada e não pode entrar no
// caminho crítico do primeiro paint.
function AppMedia({ media, fallback, ratio = '16 / 10', radius = 0, label }) {
const ref = _mUseRef(null);
const [perto, setPerto] = _mUseState(false);
// Se a captura declarada em APP_MEDIA não existir no servidor (arquivo ainda
// não subiu, caminho errado, deploy sem a pasta assets), o falha e a
// seção inteira vira um retângulo branco — justamente a seção que existe pra
// mostrar o produto. Aqui a falha cai no mock animado, que é melhor do que
// nada e melhor do que um buraco.
const [falhou, setFalhou] = _mUseState(false);
_mUseEffect(() => {
if (!media || !media.src) return;
const el = ref.current; if (!el) return;
const io = new IntersectionObserver((entries) => {
entries.forEach((e) => { if (e.isIntersecting) { setPerto(true); io.disconnect(); } });
}, { rootMargin: '300px 0px' });
io.observe(el);
return () => io.disconnect();
}, [media && media.src]);
// Autoplay mudo é sempre liberado, mas o play() falha se ainda não houver
// dado decodificado — por isso dispara nos dois eventos.
const tocar = (e) => { const p = e.currentTarget.play(); if (p && p.catch) p.catch(() => {}); };
if (!temMidia(media) || falhou) {
return (
{fallback || }
{MEDIA_DEV && fallback ? : null}
);
}
// A captura traz a própria proporção; o `ratio` do chamador é só o padrão
// pra quando ela não disser nada (evita a moldura pular de altura na troca).
const prop = media.ratio || ratio;
const base = {
display: 'block', width: '100%', height: '100%',
objectFit: 'cover', borderRadius: radius, background: 'var(--surface)',
};
// Screenshot é parado. O movimento vem daqui: deriva lenta + brilho
// atravessando o vidro, dentro de um recorte que não deixa a escala
// vazar pra fora da moldura.
const palco = (miolo) => (
{miolo}
);
if (!media.src) {
return palco(
setFalhou(true)}
style={{ ...base, position: 'absolute', inset: 0 }}
/>
);
}
return palco(
setFalhou(true)}
style={{ ...base, position: 'absolute', inset: 0 }}
/>
);
}
// ─────────────────────────────────────────────────────────────
// CursorTour — cursor que navega a captura
// ─────────────────────────────────────────────────────────────
// O FloatingCursor do projeto persegue elementos `data-cdspot` no DOM; sobre
// uma captura não há DOM pra perseguir. Este anda por coordenadas em % da
// própria imagem, para em cada ponto, clica e acende o alvo.
function CursorTour({ pontos, nome, hue = 220, atrasoMs = 0 }) {
const [i, setI] = _mUseState(-1);
const [clicando, setClicando] = _mUseState(false);
_mUseEffect(() => {
if (!pontos || !pontos.length) return;
const reduz = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduz) { setI(0); return; }
let parado = false;
let idx = -1;
const timers = new Set();
const depois = (ms, fn) => {
const id = setTimeout(() => { timers.delete(id); fn(); }, ms);
timers.add(id);
return id;
};
const ciclo = () => {
if (parado) return;
idx = (idx + 1) % pontos.length;
setI(idx);
// Espera "chegar" no alvo antes de clicar — clicar durante o trajeto
// faz o anel acender no lugar errado.
depois(1350, () => {
if (parado) return;
setClicando(true);
depois(480, () => { if (!parado) setClicando(false); });
});
depois(2900 + Math.random() * 600, ciclo);
};
depois(500 + atrasoMs, ciclo);
return () => { parado = true; timers.forEach(clearTimeout); };
}, [pontos, atrasoMs]);
if (i < 0) return null;
const p = pontos[i];
const cor = `hsl(${hue} 80% 55%)`;
// A pílula com o nome leva letra branca, e 55% de luminosidade não sustenta
// isso: no verde (hue 162) o "Financeiro" ficava em 1,6:1, apagado dentro do
// próprio destaque. O anel e a seta continuam no tom claro — ali a cor é
// preenchimento, não fundo de texto.
const corPilula = `hsl(${hue} 70% 29%)`;
return (
{/* anel no alvo */}
{/* Consequência do clique. O anel sozinho não bastava: acendia e apagava
sem dizer nada. Agora o clique fala o que acabou de acontecer, e o
tour vira uma demonstração em vez de um cursor passeando. */}
{p.r && (
{p.r}
)}
{/* cursor */}
);
}
// ─────────────────────────────────────────────────────────────
// FeedVendas — as notificações caindo sobre a captura
// ─────────────────────────────────────────────────────────────
const FEED_CIDADES = ['São Paulo', 'Curitiba', 'Belo Horizonte', 'Recife', 'Porto Alegre', 'Salvador', 'Goiânia', 'Fortaleza'];
// `accent` pinta o check e o fundo do ícone; `accentTexto` pinta o valor.
// O verde da empresa (#059669) dava 3,8:1 no "+R$ 14.338" de 10,5px, que é
// justamente o número que a notificação existe pra mostrar.
function FeedVendas({ itens, accent, accentTexto, intervaloMs = 2600, atrasoMs = 1200, titulo }) {
const [cards, setCards] = _mUseState([]);
_mUseEffect(() => {
if (!itens || !itens.length) return;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
let vivo = true;
let seq = 0;
let timer = null;
const solta = () => {
if (!vivo) return;
seq += 1;
const meu = seq;
const base = itens[Math.floor(Math.random() * itens.length)];
const valor = Math.floor(1 + Math.random() * 46) * 1000 + Math.floor(Math.random() * 900);
const cidade = FEED_CIDADES[Math.floor(Math.random() * FEED_CIDADES.length)];
setCards((c) => [{ id: meu, valor, cidade, texto: base }, ...c].slice(0, 3));
setTimeout(() => { if (vivo) setCards((c) => c.filter((x) => x.id !== meu)); }, 5200);
};
const inicio = setTimeout(() => { solta(); timer = setInterval(solta, intervaloMs); }, atrasoMs);
return () => { vivo = false; clearTimeout(inicio); if (timer) clearInterval(timer); };
}, [itens, intervaloMs, atrasoMs]);
return (
{titulo && cards.length > 0 && (
{titulo}
)}
{cards.map((n) => (
+{brl(n.valor)}
{n.texto} · {n.cidade}
))}
);
}
// Etiqueta discreta sobre um fallback — só em localhost.
function TagPlaceholder({ label }) {
return (
captura pendente{label ? ` · ${label}` : ''}
);
}
// Slot sem fallback próprio: moldura vazia que segura o layout e diz, em
// dev, qual captura entra ali.
function MediaSlotVazio({ ratio, radius, label, accent = 'var(--indigo)' }) {
return (
{MEDIA_DEV ? (
{label || 'captura do app'}
preencher em APP_MEDIA
) : null}
);
}
// ─────────────────────────────────────────────────────────────
// Moldura de navegador — dá contexto de "isso é o app rodando"
// ─────────────────────────────────────────────────────────────
function JanelaApp({ url, accent, children, glow }) {
return (
{['#E11D48', '#D97706', '#059669'].map((c) => (
))}
{url}
{children}
);
}
// ─────────────────────────────────────────────────────────────
// EscalaAuto — encaixa um painel de largura fixa na coluna
// ─────────────────────────────────────────────────────────────
// Os mocks são desenhados numa largura confortável (900) e reduzidos por
// transform pra caber na coluna, igual a um screenshot. Redesenhar cada
// painel em duas larguras daria dois lugares pra manter — e um deles
// ficaria desatualizado.
function EscalaAuto({ larguraBase, alturaBase, children }) {
const box = _mUseRef(null);
const [larg, setLarg] = _mUseState(larguraBase);
_mUseEffect(() => {
const el = box.current; if (!el) return;
const medir = () => setLarg(el.clientWidth || larguraBase);
medir();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', medir);
return () => window.removeEventListener('resize', medir);
}
const ro = new ResizeObserver(medir);
ro.observe(el);
return () => ro.disconnect();
}, [larguraBase]);
const s = larg / larguraBase;
return (
{/* `zoom`, não `transform: scale`.
Com scale o navegador rasteriza o painel no tamanho original e depois
encolhe o bitmap: a 0,6 de escala o texto de 13px vira uma mancha de
8px, e a seção inteira ganha aquela cara de vídeo em 240p. `zoom`
refaz o layout no tamanho final, então cada letra é desenhada nítida
no tamanho em que vai aparecer. */}
{children}
);
}
// ─────────────────────────────────────────────────────────────
// EmpresaMock — o painel de quem paga a comissão
// ─────────────────────────────────────────────────────────────
// Contraparte do DashboardMock: lá é a venda de uma pessoa, aqui é a
// equipe inteira e o total que sai do caixa no fim do ciclo. Paleta verde
// + ambar, que e a do app da empresa.
const EQUIPE_MOCK = [
{ n: 'Julia Santos', ini: 'JS', hue: 152, vendas: 142800, com: 8568, meta: 92, cursos: 12 },
{ n: 'Marcos Silva', ini: 'MS', hue: 38, vendas: 118400, com: 7104, meta: 78, cursos: 9 },
{ n: 'Carla Oliveira', ini: 'ES', hue: 14, vendas: 96200, com: 5772, meta: 64, cursos: 7 },
{ n: 'Felipe Costa', ini: 'FC', hue: 34, vendas: 71500, com: 4290, meta: 47, cursos: 6 },
];
function EmpresaMock({ width = 900, live = true, arthur = false }) {
const [bump, setBump] = _mUseState(0);
_mUseEffect(() => {
if (!live) return;
const id = setInterval(() => setBump((b) => b + 1), 2400);
return () => clearInterval(id);
}, [live]);
// Arthur não nasce do nada nesta venda: ele já vinha acumulando o ciclo e é
// esta última que o passa na frente do Felipe (R$ 71.500 / R$ 4.290). Por isso
// a linha dele traz o delta da venda que acabou de entrar.
const EQUIPE = arthur
? [
...EQUIPE_MOCK.slice(0, 3),
{ n: 'Arthur Souza', ini: 'AS', hue: 221, vendas: 74182, com: 4451, meta: 53, cursos: 5, novo: true },
]
: EQUIPE_MOCK;
const totalCom = EQUIPE.reduce((s, v) => s + v.com, 0) + (live ? bump * 186 : 0);
const totalVen = EQUIPE.reduce((s, v) => s + v.vendas, 0) + (live ? bump * 3100 : 0);
// `verde` pinta pontos, barras e bordas; `verdeTexto` pinta os valores. São
// dois porque o mesmo #059669 que fica ótimo como bolinha dá 3,5:1 num
// número de 10,5px — e os números aqui são justamente o que se lê.
const verde = '#059669';
const verdeTexto = '#047857';
const roxo = '#D97706'; // nome antigo mantido; hoje e o ambar quente
return (
{/* sidebar */}
Empresa
{[
{ l: 'Visão geral', n: '' },
{ l: 'Equipe', n: '4', active: true },
{ l: 'Produtos', n: '18' },
{ l: 'Comissões', n: '', badge: true },
{ l: 'Contratos', n: '3' },
{ l: 'Relatórios', n: '' },
].map((x) => (
{x.l}
{x.badge && }
{x.n && {x.n} }
))}
{/* fechamento do ciclo */}
Fecha em
6 dias
Repasse dia 05. Nada pendente.
{/* conteúdo */}
{/* KPIs */}
{[
{ l: 'Comissão a pagar', v: brl(totalCom), c: roxo, d: '+4,2%' },
{ l: 'Vendido no ciclo', v: brl(totalVen, { compact: true }), c: verdeTexto, d: '+11%' },
{ l: 'Vendedores ativos', v: '4 / 4', c: 'var(--ink)', d: 'todos' },
].map((k) => (
))}
{/* tabela da equipe */}
Vendedor Vendido Comissão Meta
{EQUIPE.map((v, i) => (
{v.n}
{i === 0 && ▲ 1º }
{v.novo && entrou }
{brl(v.vendas, { compact: true })}
{brl(v.com)}
{v.novo && +R$ 2.682 }
= 80 ? verde : `linear-gradient(90deg, ${roxo}, ${verde})`,
}} />
{v.meta}%
))}
{/* rodapé o número que importa */}
Fechamento pronto {brl(totalCom)} em comissão, 4 vendedores.
Aprovar repasse
);
}
// ─────────────────────────────────────────────────────────────
// CursorRelay — os dois cursores presos ao MESMO relógio da venda
// ─────────────────────────────────────────────────────────────
// O CursorTour antigo andava em loop próprio, sem relação com a venda que
// atravessa a seção: dava pra ver o cursor clicando em "metas" enquanto o
// formulário de outra venda abria sozinho ao lado. Aqui cada cursor tem uma
// posição por fase e some quando não é a vez dele — o vendedor opera até
// registrar, o financeiro só aparece quando a venda chega do outro lado.
// As posições saem do DOM, não de percentuais chutados sobre a captura. Com
// `x: 66%` o cursor apontava pro lugar certo em uma largura de tela só — em
// qualquer outra ele clicava no vazio ao lado do botão. Aqui cada fase nomeia
// o ALVO, e a posição é medida na hora.
const RELAY_CURSOR = {
vendedor: {
1: { alvo: '[data-cdspot="sidebar-vendas"]', x: 8, y: 27, clica: true, r: 'abre Minhas vendas' },
2: { alvo: '[data-cdspot="btn-nova-venda"]', x: 64, y: 22, clica: true, r: 'Nova venda' },
3: { alvo: '[data-cdspot="btn-nova-venda"]', x: 64, y: 22, clica: false, r: '' },
4: { alvo: '[data-cdspot="form-registrar"]', x: 80, y: 34, clica: true, r: 'Registrar venda' },
},
empresa: {
8: { alvo: '[data-cdspot="notif-empresa"]', x: 72, y: 12, clica: true, r: 'abre a venda que chegou' },
9: { alvo: '[data-cdspot="ranking-empresa"]', x: 30, y: 62, clica: true, r: 'confere o ranking' },
},
};
function CursorRelay({ lado, fase, nome, hue = 220 }) {
const capa = _mUseRef(null);
const [pos, setPos] = _mUseState(null);
const [clicando, setClicando] = _mUseState(false);
const alvo = (RELAY_CURSOR[lado] || {})[fase];
_mUseEffect(() => {
if (!alvo) return;
const timers = [];
const medir = () => {
const capaEl = capa.current; if (!capaEl) return;
const raiz = capaEl.parentElement; if (!raiz) return;
const rp = raiz.getBoundingClientRect();
const el = raiz.querySelector(alvo.alvo);
// Sem o elemento (quando o painel é uma captura estática, e não o mock
// ao vivo), cai nas coordenadas em % da moldura.
if (!el) {
setPos({ x: rp.width * alvo.x / 100, y: rp.height * alvo.y / 100 });
return;
}
const r = el.getBoundingClientRect();
setPos({
x: r.left - rp.left + Math.min(r.width * 0.45, 52),
y: r.top - rp.top + r.height / 2,
});
};
// Duas medidas: uma agora e outra depois que a transição do painel
// assenta — no lado da empresa a janela anda pro centro no meio do caminho.
timers.push(setTimeout(medir, 40));
timers.push(setTimeout(medir, 720));
if (alvo.clica) {
timers.push(setTimeout(() => setClicando(true), 980));
timers.push(setTimeout(() => setClicando(false), 1480));
}
return () => timers.forEach(clearTimeout);
}, [fase]);
if (!pos) return
;
const cor = `hsl(${hue} 80% 45%)`;
return (
{clicando && (
)}
{nome}{alvo && alvo.r ? ` · ${alvo.r}` : ''}
);
}
// ─────────────────────────────────────────────────────────────
// InterfacesSection — as duas UIs lado a lado
// ─────────────────────────────────────────────────────────────
// Vende o produto pelo que ele parece, não pelo que ele promete: de um lado
// o painel de quem vende, do outro o de quem paga a comissão.
const INTERFACES = [
{
id: 'vendedor',
// Era "Para quem vende / O painel do vendedor", que descrevia o autônomo.
// A seção não é sobre ele: é sobre as DUAS pontas de uma mesma venda, o
// comissionado e a empresa que paga. Nomear o app certo aqui é o que faz a
// propagação da venda entre as telas fazer sentido.
eyebrow: 'Comissa Comissionado',
titulo: 'Seu vendedor registra',
texto: 'Ele lança a venda no app dele e a comissão sai calculada na hora, pela regra que você cadastrou. Ninguém te manda print, ninguém te cobra no WhatsApp, ninguém digita a mesma venda duas vezes.',
url: 'app.comissa.com.br/dashboard',
accent: '#2563EB',
accentTexto: '#1D4ED8',
glow: 'rgba(37,99,235,0.5)',
slot: 'vendedorDesktop',
label: 'app do vendedor',
bullets: ['A comissão sai no ato da venda, pela sua regra', 'O vendedor vê o que é dele: a receber, pago, histórico', 'Nenhum print, nenhuma planilha, nenhuma cobrança'],
// Coordenadas em % da captura. Se a captura for trocada, reconferir —
// os pontos são amarrados ao layout da imagem, não ao DOM.
cursores: [
// 221 é o matiz de `--indigo` (#2563EB). O cursor é o acento do site
// entrando na captura — se ele destoa, a cena lê como print de outro app.
{ nome: 'Você', hue: 221, atrasoMs: 0, pontos: [
// `r` é o que o clique passa a dizer. Sem isso o cursor acendia um anel
// e nada mais acontecia: como a captura embaixo é estática, o clique não
// tinha consequência nenhuma e a cena lia como robô andando sozinho.
{ x: 4, y: 24.5, w: 8, h: 4, r: 'abre Vendas' },
{ x: 33, y: 25, w: 16, h: 10, r: 'Nova venda' },
{ x: 54, y: 59, w: 11, h: 13, r: 'Registrar — a comissão sai sozinha' },
{ x: 4, y: 65.5, w: 8, h: 4, r: 'entrou na meta: 78%' },
] },
],
feed: { titulo: 'entrando agora', itens: ['Curso Trading Pro', 'Honda Civic 2024', 'Rolex Submariner', 'Apto 2Q Pinheiros', 'iPhone 16 Pro'] },
},
{
id: 'empresa',
eyebrow: 'Comissa Empresa',
titulo: 'Você monitora e paga',
// "efetua o pagamento" virou "paga": é a palavra que o gestor usa. Nada
// nesta seção ganha nada em soar corporativo — ela vende justamente o
// contrário, que o dia do pagamento deixa de ter cerimônia.
texto: 'A mesma venda cai no seu painel no segundo seguinte, já somada ao total do ciclo. Você confere, manda o extrato pro vendedor conferir também e paga — no valor que os dois já viram.',
url: 'empresa.comissa.com.br/equipe',
accent: '#059669',
accentTexto: '#047857',
glow: 'rgba(5,150,105,0.45)',
slot: 'empresaDesktop',
label: 'app da empresa',
bullets: ['Vê cada venda do time no instante em que acontece', 'Sabe o total a pagar antes de fechar o mês', 'Envia o extrato pro vendedor conferir e paga'],
cursores: [
// 162 é o matiz de `--green` (#059669) — o lado empresa é verde de
// propósito, pra separar as duas pontas da venda de relance.
{ nome: 'Financeiro', hue: 162, atrasoMs: 0, pontos: [
{ x: 5, y: 31, w: 9, h: 4, r: 'abre Comissões a pagar' },
{ x: 17, y: 23, w: 14, h: 12, r: 'a pagar esse ciclo: R$ 21.310' },
{ x: 30, y: 31, w: 6, h: 4, r: 'paga os 4 de uma vez' },
{ x: 19, y: 50, w: 20, h: 8, r: 'ranking da equipe' },
] },
],
feed: { titulo: 'a pagar', itens: ['Julia Santos', 'Marcos Silva', 'Carla Oliveira', 'Felipe Costa'] },
},
];
// A venda que atravessa as duas telas. É o argumento inteiro da seção: o
// comissionado registra de um lado e a empresa vê do outro sem ninguém digitar
// de novo. Antes as duas telas só tinham feeds independentes rodando em loop,
// o que mostrava dois apps vivos mas não mostrava que são o MESMO número.
// Aqui um relógio só comanda os dois lados, com a defasagem proposital de
// 1,1s pro olho ler a ordem: primeiro sai, depois chega.
const RELAY_VENDAS = [
{ produto: 'Honda Civic 2024', bruto: 'R$ 118.900', taxa: '3%', com: 'R$ 3.567' },
{ produto: 'Curso Trading Pro', bruto: 'R$ 2.970', taxa: '30%', com: 'R$ 891' },
{ produto: 'Apto 2Q Pinheiros', bruto: 'R$ 740.000', taxa: '2%', com: 'R$ 14.800' },
{ produto: 'Rolex Submariner', bruto: 'R$ 89.400', taxa: '3%', com: 'R$ 2.682' },
];
// As fases contam a história em ORDEM, e cada uma só começa depois que o
// clique anterior aconteceu — era esse o erro: o formulário abria junto com o
// cursor chegando, ou seja, antes de ele clicar em "Nova venda".
//
// 1 cursor vai até Minhas vendas e clica
// 2 cursor vai até "+ Nova venda" e clica
// 3 o formulário abre (já preenchido pelo link)
// 4 cursor desce até "Registrar venda" e clica
// 5 venda registrada
// 6 o app do vendedor desaparece e sobra o pacote — PARADO, pra dar tempo
// de ler o que está dentro dele
// 7 o pacote atravessa
// 8 o Comissa Empresa abre grande e recebe: "Arthur vendeu …"
// 9 a venda sobe no ranking: Arthur entra no lugar do Felipe
// 0 respira e recomeça com outra venda
function useRelayVenda({ periodoMs = 19000, ancora } = {}) {
const [venda, setVenda] = _mUseState(null);
const [fase, setFase] = _mUseState(0);
const [ciclo0, setCiclo0] = _mUseState(0);
const dentroRef = _mUseRef(false);
// Espera a seção entrar no campo de visão, medindo na rolagem. Um
// IntersectionObserver com threshold seria o caminho natural, mas a seção é
// mais alta que a viewport: a fração exigida pode nunca ser satisfeita, e aí
// a cena não começa nunca. Aqui a regra é simples e sempre verdadeira —
// qualquer sobreposição com a tela conta. Não desligo depois: se o visitante
// subir e voltar, a história recomeça do primeiro clique, que é o pedido.
_mUseEffect(() => {
if (!ancora) return;
let raf = 0;
const ver = () => {
const el = ancora.current; if (!el) return;
const r = el.getBoundingClientRect();
const vh = window.innerHeight || 1;
const dentro = r.bottom > vh * 0.1 && r.top < vh * 0.9;
// Só conta a BORDA de entrada: enquanto o visitante rola dentro da seção
// a cena segue seu curso, sem reiniciar a cada pixel.
if (dentro && !dentroRef.current) setCiclo0((n) => n + 1);
dentroRef.current = dentro;
};
const onScroll = () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(ver); };
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll);
ver();
return () => {
window.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onScroll);
cancelAnimationFrame(raf);
};
}, [ancora]);
_mUseEffect(() => {
// Sem animação: mostra o fim da história parado, que é o que ela afirma.
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
setVenda(RELAY_VENDAS[0]); setFase(9);
return;
}
let i = 0, parado = false;
const timers = new Set();
const depois = (ms, fn) => { const id = setTimeout(() => { timers.delete(id); fn(); }, ms); timers.add(id); };
const passo = (ms, f) => depois(ms, () => { if (!parado) setFase(f); });
const ciclo = () => {
if (parado) return;
setVenda(RELAY_VENDAS[i++ % RELAY_VENDAS.length]);
setFase(1);
passo(1900, 2); // cursor sai pro botão Nova venda
passo(3300, 3); // clicou → formulário abre
passo(4600, 4); // cursor desce pro Registrar
passo(6000, 5); // clicou → venda registrada
passo(7000, 6); // o app fecha e vira pacote
passo(9800, 7); // 2,8s parado, depois atravessa
passo(11300, 8); // a empresa abre e recebe
passo(13600, 9); // e a venda sobe no ranking
passo(17800, 0);
depois(periodoMs, ciclo);
};
depois(700, ciclo);
return () => { parado = true; timers.forEach(clearTimeout); };
}, [periodoMs, ciclo0]);
return { venda, fase, chegou: fase >= 8 };
}
// O formulário que o clique em "Nova venda" abre. Os campos já vêm
// preenchidos de propósito: o argumento do produto é que o dado vem do link,
// não do dedo de quem vendeu.
function NovaVendaCard({ venda, fase, accent }) {
const aberto = fase >= 3 && fase <= 6;
const registrado = fase >= 5;
const linhas = venda ? [
['Produto', venda.produto],
['Valor', venda.bruto],
['Comissão', `${venda.taxa} · ${venda.com}`],
] : [];
return (
Nova venda
do link
{linhas.map(([k, v]) => (
{k}
{v}
))}
= 5 ? accent : `${accent}1A`,
color: fase >= 5 ? '#fff' : accent,
transition: 'background .25s ease, color .25s ease',
}}>
{registrado ? 'Venda registrada' : 'Registrar venda'}
);
}
// A travessia. Antes era um risco de 104px com um pontinho correndo dentro,
// no vão entre os dois painéis: quem olhava via um pixel se mexer. Agora o
// dado tem corpo — uma pastinha com o nome da venda sai de dentro do painel de
// quem vendeu, atravessa a seção inteira com o sinal de transmissão piscando
// e entra no painel de quem paga, que responde com a notificação.
function LinhaRelay({ fase, venda }) {
const pronto = fase >= 6;
const voando = fase >= 7;
const entregue = fase >= 8;
return (
{/* o trilho por onde o dado passa */}
{/* a pastinha — fica um tempo parada onde o app estava (fase 6) antes de
atravessar (fase 7): é nesse intervalo que dá pra LER o que ela leva. */}
{/* o sinal: anéis saindo da pasta enquanto ela viaja */}
{voando && !entregue && (
)}
{voando ? 'enviando os dados' : 'venda registrada · pronta pra enviar'}
{venda ? venda.produto : ''}
{venda ? venda.com : ''}
{/* onde a pasta entra: pulso no painel da empresa quando ela chega */}
a mesma venda
);
}
// A notificação no painel de quem paga. Canto superior direito porque é onde
// notificação vive num app de verdade — e com nome, porque "uma venda entrou"
// não é a informação que o financeiro precisa; "quem vendeu o quê" é.
function NotifEmpresa({ venda, fase, accent }) {
const on = fase >= 8;
return (
AS
{/* Quebra em duas linhas em vez de cortar com "…": o nome do produto é
metade da informação, e era exatamente a metade que sumia. */}
Arthur vendeu {venda ? venda.produto : ''}
{venda ? `${venda.bruto} · a pagar ${venda.com}` : ''}
);
}
// O ranking do time dentro do painel de quem paga. Ele existe pra a venda ter
// CONSEQUÊNCIA: a notificação diz que o dado chegou, mas é aqui que ele mexe em
// algo — Arthur entra na quarta posição e o Felipe cai fora dela. Sem isso a
// cena termina num aviso, e aviso ninguém compra.
const RANKING_BASE = [
{ n: 'Julia Santos', v: 'R$ 142.800', c: 'R$ 8.568' },
{ n: 'Marcos Silva', v: 'R$ 118.400', c: 'R$ 7.104' },
{ n: 'Carla Oliveira', v: 'R$ 96.200', c: 'R$ 5.772' },
{ n: 'Felipe Costa', v: 'R$ 71.500', c: 'R$ 4.290' },
];
function RankingEmpresa({ venda, fase, accent }) {
const visivel = fase >= 8;
const entrou = fase >= 9;
const linhas = entrou
? [
...RANKING_BASE.slice(0, 3),
{ n: 'Arthur Souza', v: venda ? venda.bruto : '', c: venda ? venda.com : '', novo: true },
]
: RANKING_BASE;
return (
Ranking do ciclo
ao vivo
{linhas.map((l, i) => (
{i + 1}°
{l.n}
{l.novo && (
entrou
)}
{l.c}
))}
);
}
function SeloRelay({ venda, chegou, lado, accent }) {
const visivel = lado === 'origem' ? !!venda : (!!venda && chegou);
return (
{lado === 'origem' ? 'Venda registrada' : 'Entrou no total a pagar'}
{venda ? `${venda.produto} · ${lado === 'origem' ? venda.bruto : '+' + venda.com}` : ''}
);
}
function InterfacesSection() {
const secRef = _mUseRef(null);
const relay = useRelayVenda({ ancora: secRef });
return (
{/* Duas fileiras em vez de duas colunas com tudo dentro: assim a fileira
das janelas é o único pai da linha de conexão, e o `top: 50%` dela
cai no meio dos painéis em qualquer largura de tela — sem número
mágico pra desencaixar quando a janela muda de tamanho. */}
{/* `minmax(0, 1fr)` e não `1fr`: `1fr` é `minmax(auto, 1fr)`, então a
coluna nunca fica menor que o min-content dela — e dentro tem um
painel de 900px fixos. A grade inteira estourava pra 1860px dentro de
um contêiner de 780, e o que ficava fora da tela era justamente o lado
da empresa recebendo a venda. O `minWidth: 0` nos itens é a outra
metade da mesma regra. */}
{INTERFACES.map((it) => {
// O app inteiro se fecha no momento em que a venda é registrada: a
// janela encolhe pro centro e vira a pastinha que atravessa a seção.
// É a metáfora inteira em um gesto — o painel não "avisa" a empresa,
// ele VIRA o pacote que chega lá.
const fechando = it.id === 'vendedor' && relay.fase >= 6;
// E o painel de quem paga vai pro meio pra RECEBER: a pastinha entra
// nele no centro da seção, e não num card encostado na borda direita.
const recebendo = it.id === 'empresa' && relay.fase >= 7;
return (
{it.id === 'vendedor'
?
: = 9} />}
}
/>
{/* camada viva: o cursor de cada lado, preso à fase da venda */}
{/* O FeedVendas saiu daqui. Ele rodava vendas próprias, sorteadas,
no mesmo canto onde agora entram o formulário e a notificação:
dava pra ver "Rolex Submariner" chegando à esquerda enquanto o
outro lado avisava de um apartamento. Duas vendas diferentes na
tela ao mesmo tempo destroem justamente o argumento da seção,
que é ser a MESMA. O componente segue no arquivo. */}
{it.id === 'vendedor'
? (
)
: (
)}
);
})}
{/* O que a cena acabou de mostrar, em passos numerados. A frase corrida
que estava aqui descrevia o mecanismo; o que fecha a venda é o
PROCESSO que o gestor vai rodar todo mês — e ele cabe em quatro
passos, o que é metade do argumento. */}
{/* Entram um a um quando o bloco chega no campo de leitura (Stagger, o
mesmo da narrativa: rootMargin -26%, 90ms entre cards). Antes eles já
estavam montados quando o leitor chegava — quatro caixas quase
brancas sobre fundo quase branco, que o olho passava direto. A
entrada em cascata é o que faz ler como PASSOS, na ordem, e não como
uma fileira de caixas. */}
{[
{ n: '01', t: 'Ele registra', d: 'A venda entra no app do vendedor com a comissão já calculada.' },
{ n: '02', t: 'Você monitora', d: 'Ela aparece no seu painel na hora, somada ao total do ciclo.' },
// "a análise da comissão" era jargão de dentro de casa: o vendedor
// recebe um extrato. E o card 04 estava em voz passiva ("Pagamento
// no valor…") justo no passo que é a ação do gestor.
{ n: '03', t: 'Envia o extrato', d: 'O vendedor confere venda por venda antes de você pagar.' },
{ n: '04', t: 'Você paga', d: 'O valor que os dois já viram. Não sobra o que contestar.' },
].map((s) => (
// Borda em `--line` (não `--line-2`) e uma sombra baixa: card branco
// com borda de 5% sobre fundo #FBFCFD não tem contorno nenhum — era
// texto solto no branco, não card. O número ganhou um traço índigo
// em cima, que é o que dá a leitura de sequência de relance.
))}
Uma venda, dois painéis, nenhum lançamento repetido. É o mesmo número de ponta a ponta {' '}
— então não existe o que discutir
no dia do pagamento.
{INTERFACES.map((it) => (
{it.eyebrow}
{it.titulo}
{it.texto}
{it.bullets.map((b) => (
{b}
))}
))}
);
}