);
}
// ─────────────────────────────────────────────────────────────
// PingsDeVenda — as vendas caindo em cima do globo
// ─────────────────────────────────────────────────────────────
// O globo pinga um ponto no mapa; aqui a mesma venda aparece escrita, nas
// bordas da dobra, logo acima da moldura do app. As duas coisas contam a
// mesma história em dois níveis: o mapa diz "está acontecendo em todo lugar",
// o cartão diz quanto entrou e de onde.
const PING_VENDAS = [
{ p: 'Honda Civic 2024', v: 'R$ 3.567', c: 'São Paulo' },
{ p: 'Curso Trading Pro', v: 'R$ 891', c: 'Lisboa' },
{ p: 'Apto 2Q Pinheiros', v: 'R$ 14.800', c: 'Curitiba' },
{ p: 'Rolex Submariner', v: 'R$ 2.682', c: 'Toronto' },
{ p: 'Consórcio imóvel', v: 'R$ 1.240', c: 'Recife' },
{ p: 'Plano anual SaaS', v: 'R$ 420', c: '東京' },
{ p: 'Mentoria 12 meses', v: 'R$ 4.980', c: 'Ciudad de México' },
{ p: 'Seguro auto', v: 'R$ 760', c: 'Porto Alegre' },
];
function PingsDeVenda({ startDelayMs = 2400, intervalMs = 2300, variante = 'hero' }) {
const [cards, setCards] = _useState([]);
const noEscuro = variante === 'escuro';
_useEffect(() => {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
let n = 0, intervalo = 0;
const timers = new Set();
const nasce = () => {
const venda = PING_VENDAS[n % PING_VENDAS.length];
const chave = 'p' + n;
const lado = n % 2 === 0 ? 'esq' : 'dir';
// Os três topos ficam TODOS acima da moldura do app, que começa em 596.
// Antes eram 486/524/562: o terceiro tem 44px de altura, terminava em
// 606 e entrava embaixo do painel — o cartão aparecia cortado ao meio
// pela borda da moldura, e em telas de ~1100px, onde o painel é mais
// largo que o texto, os dois lados batiam. Mexeu na altura do painel?
// Reconferir estes números.
const topo = noEscuro ? [null, null, null][n % 3] : [446, 492, 538][n % 3];
const base = noEscuro ? [26, 74, 122][n % 3] : null;
n++;
setCards((c) => [...c, { chave, venda, lado, topo, base }]);
const t = setTimeout(() => {
timers.delete(t);
setCards((c) => c.filter((x) => x.chave !== chave));
}, 4300);
timers.add(t);
};
const t0 = setTimeout(() => { nasce(); intervalo = setInterval(nasce, intervalMs); }, startDelayMs);
return () => { clearTimeout(t0); clearInterval(intervalo); timers.forEach(clearTimeout); };
}, [startDelayMs, intervalMs]);
return (
{cards.map((c) => (
+{c.venda.v}
{c.venda.p} · {c.venda.c}
))}
);
}
// ─────────────────────────────────────────────────────────────
// PainelRoteiro — o cursor operando o app dentro da moldura do hero
// ─────────────────────────────────────────────────────────────
// A captura do painel é bonita e morta. Aqui alguém usa o produto na frente
// do visitante: abre Minhas vendas, clica em Nova venda, os campos chegam
// prontos do link, registra — e o dado sai voando pro Comissa Empresa, que
// avisa que recebeu. É a promessa do site acontecendo em 10 segundos, sem
// ninguém precisar ler nada.
//
// As posições saem de offsetLeft/offsetTop, não de getBoundingClientRect: a
// moldura do hero é inclinada em 3D, e rect devolve a caixa JÁ transformada —
// o cursor apontava sempre alguns pixels fora do alvo. offset* dá a
// coordenada de layout, que é o mesmo sistema em que este overlay vive.
function posRelativa(el, raiz) {
let x = 0, y = 0, n = el;
while (n && n !== raiz) { x += n.offsetLeft; y += n.offsetTop; n = n.offsetParent; }
return { x, y };
}
const ROTEIRO_VENDA = { produto: 'Honda Civic 2024', bruto: 'R$ 118.900', taxa: '3%', com: 'R$ 3.567' };
function PainelRoteiro({ anchorRef }) {
const [fase, setFase] = _useState(0);
const [cur, setCur] = _useState({ x: 320, y: 60, vis: false, click: false });
const btnRef = _useRef(null);
const capaRef = _useRef(null);
_useEffect(() => {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { setFase(6); return; }
let parado = false;
const timers = new Set();
const depois = (ms, fn) => { const id = setTimeout(() => { timers.delete(id); fn(); }, ms); timers.add(id); };
const irPara = (seletor) => {
const raiz = capaRef.current && capaRef.current.offsetParent;
const alvo = seletor === 'form'
? btnRef.current
: (anchorRef.current && anchorRef.current.querySelector(`[data-cdspot="${seletor}"]`));
if (!alvo || !raiz) return null;
const p = posRelativa(alvo, raiz);
setCur((c) => ({ ...c, vis: true, x: p.x + Math.min(alvo.offsetWidth * 0.5, 64), y: p.y + alvo.offsetHeight / 2 }));
return alvo;
};
const clicar = (el) => {
setCur((c) => ({ ...c, click: true }));
if (el && el.classList) {
el.classList.remove('cd-poked'); void el.offsetWidth; el.classList.add('cd-poked');
depois(950, () => el.classList.remove('cd-poked'));
}
depois(420, () => { if (!parado) setCur((c) => ({ ...c, click: false })); });
};
const ciclo = () => {
if (parado) return;
setFase(1);
const a = irPara('sidebar-vendas');
depois(950, () => { if (!parado) clicar(a); });
depois(1700, () => { if (parado) return; setFase(2); const b = irPara('btn-nova-venda'); depois(900, () => clicar(b)); });
depois(3000, () => { if (!parado) setFase(3); });
depois(4400, () => { if (parado) return; const c = irPara('form'); depois(700, () => clicar(c)); });
depois(5300, () => { if (!parado) setFase(4); });
depois(5900, () => { if (!parado) setFase(5); });
depois(7100, () => { if (!parado) setFase(6); });
depois(10200, () => { if (!parado) setFase(7); });
depois(11200, () => { if (parado) return; setFase(0); setCur((c) => ({ ...c, vis: false })); depois(900, ciclo); });
};
depois(2200, ciclo);
return () => { parado = true; timers.forEach(clearTimeout); };
}, [anchorRef]);
const formAberto = fase >= 3 && fase <= 5;
const registrado = fase >= 4;
const voando = fase >= 5;
const chegou = fase >= 6;
const saindo = fase === 7;
const linhas = [
['Produto', ROTEIRO_VENDA.produto],
['Valor', ROTEIRO_VENDA.bruto],
['Comissão', `${ROTEIRO_VENDA.taxa} · ${ROTEIRO_VENDA.com}`],
];
return (
{/* formulário que o clique em "Nova venda" abre */}
{/* o sinal: rastro que desenha do formulário até o outro app.
Tudo aqui vive DENTRO da moldura de 980px: o hero recorta na largura
real da janela, então qualquer coisa ancorada em `right` negativo
desaparecia pela borda em telas menores que ~1130px — e o que sumia
era justo o fim da história, o outro app avisando que recebeu. */}
{/* a pastinha levando o dado */}
{ROTEIRO_VENDA.produto} · {ROTEIRO_VENDA.com}
{/* o outro app avisando que recebeu */}
{chegou && }
Comissa Empresa
Arthur vendeu {ROTEIRO_VENDA.produto}
{ROTEIRO_VENDA.bruto} · a pagar {ROTEIRO_VENDA.com}
{/* o cursor */}
{cur.click && (
)}
Você
);
}
// ─────────────────────────────────────────────────────────────
// HeroA — Spotlight centralizado
// ─────────────────────────────────────────────────────────────
function HeroA({ width = 1280, height = 960 }) {
const heroRef = _useRef(null);
const painelRef = _useRef(null);
// O painel tem 980px fixos e o hero recorta na largura real da janela. Sem
// isto, em qualquer tela menor que ~1130px o lado direito da moldura — onde
// o roteiro termina, com o Comissa Empresa avisando que recebeu — ficava
// fora da tela. Encolher a moldura inteira mantém a cena completa.
const larguraPainel = Math.max(660, Math.min(980, width - 72));
return (
{/* O globo volta pra trás do painel — e é ele que dá escala à cena: cada
venda que entra pinga na cidade de onde veio. Fica ATRÁS da moldura
de propósito: metade dele some por baixo do painel, o que costura as
duas camadas numa composição só em vez de deixar globo e captura
flutuando lado a lado sem se referirem um ao outro. */}
{/* zIndex 3, acima do painel (2). */}
Novo · comissão calculada em tempo real
O painel que mantém o vendedor no controle.
Carro, curso, relógio, imóvel, etc. O que você vende vai pra um lugar só.
Comissão, meta e cliente na ponta do dedo. Sem planilha. Sem confusão.
window.openModal('signup')}>Testar grátis window.iniciarTour && window.iniciarTour()}> Ver tour de 90s
14 dias grátis·Sem cartão de crédito·Cancela em um clique
{/* O painel reage ao mouse: Tilt3D ancorado no hero inteiro, então ele
acompanha o cursor desde o momento em que o visitante passa por cima
do texto — a tela responde antes de ele clicar em nada. */}
{/* A classe da cascata vive AQUI dentro, e não no pai: `intro-fade-right`
anima `transform`, e o pai usa transform pra se centralizar
(`translateX(-50%)`). Na mesma caixa, o último quadro da animação
zerava a centralização e o painel saltava meia tela pra direita. */}
}
/>
);
}
// ─────────────────────────────────────────────────────────────
// Stats animados do Hero B (vendas processadas / minutos / uptime)
// ─────────────────────────────────────────────────────────────
function HeroLiveStats() {
const vendas = window.useDrift({
start: 4.3, min: 4.05, max: 4.62,
stepMin: 0.02, stepMax: 0.08, upBias: 0.74, intervalMs: 2000,
});
const minutos = window.useDrift({
start: 23, min: 22, max: 31,
stepMin: 0.4, stepMax: 1.4, upBias: 0.68, intervalMs: 2400,
});
const uptime = window.useDrift({
start: 99.99, min: 99.92, max: 99.999,
stepMin: 0.003, stepMax: 0.014, upBias: 0.55, intervalMs: 2800,
});
const stats = [
{
target: vendas, ms: 1700,
fmt: (n) => 'R$ ' + n.toFixed(1).replace('.', ',') + 'M',
l: 'em vendas processadas no Comissa',
},
{
target: minutos, ms: 1800,
fmt: (n) => Math.round(n) + ' min',
l: 'economizados por vendedor / dia*',
},
{
target: uptime, ms: 2000,
fmt: (n) => n.toFixed(2).replace('.', ',') + '%',
l: 'uptime nos últimos 12 meses',
},
];
return (
// 44 e não 56: com 56 a linha terminava em 814px dentro de um hero que
// corta em 840, e os rótulos de duas linhas ficavam sem a última.
);
}
// ─────────────────────────────────────────────────────────────
// Planet — globo focado no Brasil + marcadores verdes pulsantes
// ─────────────────────────────────────────────────────────────
// Contornos grosseiros dos continentes, em [lat, lon]. Não é cartografia — é
// o bastante pra o olho reconhecer o Brasil, os Estados Unidos, a China e a
// Austrália enquanto o globo gira.
const CONTINENTES = [
// América do Sul
[[12,-72],[11,-64],[7,-58],[5,-52],[0,-50],[-5,-35],[-9,-35],[-13,-38],[-18,-39],[-23,-42],[-27,-48],[-33,-53],[-38,-57],[-42,-63],[-50,-68],[-55,-68],[-53,-72],[-45,-74],[-38,-73],[-30,-71],[-23,-70],[-18,-70],[-14,-76],[-6,-81],[0,-80],[6,-77],[9,-75],[12,-72]],
// América do Norte
[[70,-160],[70,-140],[68,-135],[60,-140],[58,-133],[54,-130],[48,-125],[40,-124],[32,-117],[23,-110],[20,-105],[16,-95],[18,-88],[21,-87],[25,-80],[30,-81],[35,-76],[40,-74],[44,-67],[48,-60],[52,-56],[56,-62],[60,-65],[63,-78],[68,-85],[70,-100],[70,-125],[70,-160]],
// Groenlândia
[[83,-30],[80,-20],[75,-20],[70,-25],[65,-40],[70,-52],[76,-60],[80,-55],[83,-30]],
// África
[[35,-6],[37,10],[33,22],[31,32],[22,37],[12,43],[10,51],[2,42],[-5,39],[-15,40],[-25,33],[-34,26],[-34,19],[-28,16],[-18,12],[-8,13],[0,9],[5,0],[10,-15],[15,-17],[22,-17],[28,-13],[32,-9],[35,-6]],
// Eurásia
[[36,-9],[43,-9],[48,-5],[50,2],[58,5],[62,5],[70,20],[70,32],[68,45],[73,60],[76,90],[72,110],[70,130],[66,170],[60,160],[55,140],[50,140],[43,132],[38,126],[30,122],[22,110],[10,105],[15,95],[22,90],[20,72],[25,60],[28,50],[25,38],[30,33],[36,27],[40,26],[40,15],[44,13],[36,-9]],
// Índia
[[24,68],[20,72],[15,74],[8,77],[13,80],[20,86],[22,89],[26,80],[24,68]],
// Austrália
[[-11,131],[-12,137],[-17,141],[-11,143],[-19,147],[-25,153],[-32,153],[-38,148],[-38,141],[-35,136],[-32,129],[-34,116],[-28,114],[-22,114],[-18,122],[-14,127],[-11,131]],
// Japão
[[45,142],[42,141],[38,141],[35,140],[34,136],[35,133],[33,131],[31,130],[33,133],[36,137],[39,140],[42,143],[45,142]],
];
// Fronteiras de países, traço mais fino: é o que transforma "um continente" em
// "o Brasil", "o Canadá", "a Rússia".
const PAISES = [
// Brasil
[[5,-60],[4,-52],[0,-50],[-2,-44],[-5,-37],[-9,-35],[-13,-38],[-18,-39],[-23,-42],[-27,-48],[-33,-53],[-30,-57],[-27,-55],[-24,-54],[-22,-57],[-20,-58],[-16,-58],[-11,-65],[-9,-69],[-4,-70],[0,-70],[2,-67],[5,-64],[5,-60]],
// Estados Unidos
[[49,-123],[49,-95],[48,-88],[45,-83],[43,-79],[45,-71],[45,-67],[41,-70],[39,-74],[35,-76],[31,-81],[25,-80],[29,-83],[30,-89],[29,-94],[26,-97],[29,-103],[32,-108],[32,-115],[35,-120],[40,-124],[46,-124],[49,-123]],
// Canadá
[[49,-123],[49,-95],[48,-88],[45,-83],[43,-79],[45,-71],[45,-67],[50,-64],[52,-56],[56,-62],[60,-65],[63,-78],[68,-85],[70,-100],[70,-125],[69,-140],[60,-139],[54,-130],[49,-123]],
// México
[[32,-115],[32,-108],[29,-103],[26,-97],[21,-97],[18,-94],[16,-95],[18,-88],[21,-87],[19,-90],[16,-92],[14,-92],[18,-102],[21,-105],[26,-109],[31,-114],[32,-115]],
// Argentina
[[-22,-64],[-22,-58],[-27,-55],[-30,-57],[-34,-58],[-38,-58],[-42,-63],[-46,-67],[-50,-68],[-55,-68],[-52,-72],[-45,-71],[-38,-70],[-32,-69],[-27,-66],[-22,-67],[-22,-64]],
// Rússia
[[70,30],[70,60],[76,90],[72,110],[70,130],[66,170],[60,163],[55,140],[50,140],[44,132],[43,131],[48,130],[50,120],[50,110],[50,90],[47,85],[50,80],[54,73],[52,60],[47,48],[44,40],[47,37],[52,32],[55,28],[60,28],[66,30],[70,30]],
// França
[[51,2],[49,5],[47,7],[46,7],[44,7],[43,3],[42,3],[43,-2],[46,-1],[48,-5],[49,-1],[51,2]],
// Península Ibérica
[[43,-9],[43,-2],[42,3],[39,0],[37,-2],[36,-6],[37,-9],[41,-9],[43,-9]],
// China
[[53,124],[48,134],[43,131],[39,124],[35,120],[30,122],[24,118],[21,110],[22,102],[28,98],[28,88],[35,78],[40,75],[45,83],[48,88],[50,110],[53,120],[53,124]],
];
// O mapa de vendas. Começou só com capitais brasileiras, mas o globo gira
// inteiro agora — e um planeta que roda com pontos em um país só fica meia
// volta apagado. Cada praaça aqui é um lugar de onde uma venda pinga.
const BR_CITIES = [
{ name: 'São Paulo', lat: -23.55, lon: -46.63 },
{ name: 'Rio de Janeiro', lat: -22.91, lon: -43.20 },
{ name: 'Brasília', lat: -15.78, lon: -47.93 },
{ name: 'Belo Horizonte', lat: -19.92, lon: -43.94 },
{ name: 'Salvador', lat: -12.97, lon: -38.50 },
{ name: 'Curitiba', lat: -25.43, lon: -49.27 },
{ name: 'Recife', lat: -8.06, lon: -34.87 },
{ name: 'Manaus', lat: -3.10, lon: -60.02 },
{ name: 'Porto Alegre', lat: -30.03, lon: -51.23 },
{ name: 'Fortaleza', lat: -3.73, lon: -38.52 },
{ name: 'Goiânia', lat: -16.68, lon: -49.27 },
{ name: 'Belém', lat: -1.45, lon: -48.50 },
// resto do mundo
{ name: 'Buenos Aires', lat: -34.60, lon: -58.38 },
{ name: 'Santiago', lat: -33.45, lon: -70.67 },
{ name: 'Bogotá', lat: 4.71, lon: -74.07 },
{ name: 'Ciudad de México',lat: 19.43, lon: -99.13 },
{ name: 'Miami', lat: 25.76, lon: -80.19 },
{ name: 'New York', lat: 40.71, lon: -74.01 },
{ name: 'Toronto', lat: 43.65, lon: -79.38 },
{ name: 'Vancouver', lat: 49.28, lon: -123.12 },
{ name: 'Lisboa', lat: 38.72, lon: -9.14 },
{ name: 'Madrid', lat: 40.42, lon: -3.70 },
{ name: 'London', lat: 51.51, lon: -0.13 },
{ name: 'Paris', lat: 48.86, lon: 2.35 },
{ name: 'Berlin', lat: 52.52, lon: 13.40 },
{ name: 'Moskva', lat: 55.76, lon: 37.62 },
{ name: 'Dubai', lat: 25.20, lon: 55.27 },
{ name: 'Lagos', lat: 6.52, lon: 3.38 },
{ name: 'Johannesburg', lat: -26.20, lon: 28.05 },
{ name: 'Mumbai', lat: 19.08, lon: 72.88 },
{ name: 'Shanghai', lat: 31.23, lon: 121.47 },
{ name: 'Seoul', lat: 37.57, lon: 126.98 },
{ name: '東京', lat: 35.68, lon: 139.69 },
{ name: 'Sydney', lat: -33.87, lon: 151.21 },
];
function Planet({ radius = 240, spawnEveryMs = 1400 }) {
const mountRef = _useRef(null);
_useEffect(() => {
if (!window.THREE) return;
const THREE = window.THREE;
const mount = mountRef.current;
if (!mount) return;
const w0 = mount.clientWidth || 600;
const h0 = mount.clientHeight || 720;
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setClearColor(0x000000, 0);
renderer.setSize(w0, h0);
mount.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(38, w0 / h0, 0.1, 2000);
camera.position.z = radius * 3.2;
const planet = new THREE.Group();
scene.add(planet);
// superfície — Terra (base azul clara até a textura carregar)
// Estas cores são as do tema, escritas em 0x porque o three.js não lê
// custom properties. Se `--indigo` mudar, elas mudam junto — senão o globo
// fica sendo o único pedaço da página com a paleta antiga.
const earthGeom = new THREE.SphereGeometry(radius, 64, 64);
const earthMat = new THREE.MeshBasicMaterial({
color: 0xC9DDFF, transparent: true, opacity: 0.17,
});
const earth = new THREE.Mesh(earthGeom, earthMat);
planet.add(earth);
// atmosfera / glow do acento ao redor do planeta (fresnel simples)
const atmoMat = new THREE.MeshBasicMaterial({
color: 0x38BDF8, transparent: true, opacity: 0.10,
side: THREE.BackSide, blending: THREE.AdditiveBlending, depthWrite: false,
});
const atmo = new THREE.Mesh(new THREE.SphereGeometry(radius * 1.14, 48, 48), atmoMat);
planet.add(atmo);
const atmoMat2 = new THREE.MeshBasicMaterial({
color: 0x34D399, transparent: true, opacity: 0.06,
side: THREE.BackSide, blending: THREE.AdditiveBlending, depthWrite: false,
});
const atmo2 = new THREE.Mesh(new THREE.SphereGeometry(radius * 1.28, 48, 48), atmoMat2);
planet.add(atmo2);
// grade de meridianos e paralelos desenhada como linhas de verdade.
// O `wireframe` de uma esfera desenha os TRIÂNGULOS da malha (diagonais
// inclusive), que de perto lia como tela de mosquiteiro. Circunferências
// reais dão o globo técnico de planetário, que é o que a cena quer.
const descartar = [];
const gratMat = new THREE.LineBasicMaterial({
color: 0x60A5FA, transparent: true, opacity: 0.16, depthWrite: false,
});
const grat = new THREE.Group();
const circulo = (fn) => {
const pts = [];
for (let a = 0; a <= 96; a++) pts.push(fn((a / 96) * Math.PI * 2));
const g = new THREE.BufferGeometry().setFromPoints(pts);
descartar.push(g);
grat.add(new THREE.Line(g, gratMat));
};
const R = radius * 1.004;
for (let lat = -60; lat <= 60; lat += 30) {
const rr = R * Math.cos(lat * Math.PI / 180);
const yy = R * Math.sin(lat * Math.PI / 180);
circulo((t) => new THREE.Vector3(Math.cos(t) * rr, yy, Math.sin(t) * rr));
}
for (let lon = 0; lon < 180; lon += 30) {
const cl = Math.cos(lon * Math.PI / 180), sl = Math.sin(lon * Math.PI / 180);
circulo((t) => new THREE.Vector3(Math.cos(t) * R * cl, Math.sin(t) * R, Math.cos(t) * R * sl));
}
// O equador vem mais forte: dá eixo ao globo e evita a sensação de bola
// de linhas todas com o mesmo peso.
const equadorMat = new THREE.LineBasicMaterial({
color: 0x2563EB, transparent: true, opacity: 0.38, depthWrite: false,
});
{
const pts = [];
for (let a = 0; a <= 128; a++) {
const t = (a / 128) * Math.PI * 2;
pts.push(new THREE.Vector3(Math.cos(t) * R, 0, Math.sin(t) * R));
}
const g = new THREE.BufferGeometry().setFromPoints(pts);
descartar.push(g);
grat.add(new THREE.Line(g, equadorMat));
}
planet.add(grat);
// Contornos dos continentes e das fronteiras de países. Não é cartografia:
// são polígonos grosseiros desenhados como linha sobre a esfera, o bastante
// pro olho reconhecer Brasil, Estados Unidos, Canadá, Rússia, França, China
// e Austrália — o argumento é que a venda pinga de qualquer lugar do mundo,
// e isso só se sustenta se o mundo estiver desenhado.
const terraMat = new THREE.LineBasicMaterial({
color: 0x2563EB, transparent: true, opacity: 0.62, depthWrite: false,
});
const paisMat = new THREE.LineBasicMaterial({
color: 0x60A5FA, transparent: true, opacity: 0.44, depthWrite: false,
});
const tracaPoli = (poli, mat, alt) => {
const pts = [];
for (let k = 0; k < poli.length - 1; k++) {
const [la1, lo1] = poli[k], [la2, lo2] = poli[k + 1];
for (let st = 0; st < 6; st++) {
const f = st / 6;
pts.push(latLonToVec3(la1 + (la2 - la1) * f, lo1 + (lo2 - lo1) * f, radius * alt));
}
}
const u = poli[poli.length - 1];
pts.push(latLonToVec3(u[0], u[1], radius * alt));
const g = new THREE.BufferGeometry().setFromPoints(pts);
descartar.push(g);
planet.add(new THREE.Line(g, mat));
};
CONTINENTES.forEach((poli) => tracaPoli(poli, terraMat, 1.006));
PAISES.forEach((poli) => tracaPoli(poli, paisMat, 1.009));
// Ponto dentro do polígono por lançamento de raio (lon, lat como x, y).
const dentroDoPoli = (poli, la, lo) => {
let dentro = false;
for (let i = 0, j = poli.length - 1; i < poli.length; j = i++) {
const yi = poli[i][0], xi = poli[i][1];
const yj = poli[j][0], xj = poli[j][1];
if (((yi > la) !== (yj > la)) && (lo < (xj - xi) * (la - yi) / (yj - yi) + xi)) dentro = !dentro;
}
return dentro;
};
const luzPos = [], luzCor = [];
const quente = new THREE.Color(0xFFD27A);
const fria = new THREE.Color(0x93C5FD);
const massas = CONTINENTES.concat(PAISES);
massas.forEach((poli) => {
let minLa = 90, maxLa = -90, minLo = 180, maxLo = -180;
poli.forEach(([la, lo]) => {
if (la < minLa) minLa = la; if (la > maxLa) maxLa = la;
if (lo < minLo) minLo = lo; if (lo > maxLo) maxLo = lo;
});
let achados = 0, tentativas = 0;
const alvo = 260;
while (achados < alvo && tentativas < alvo * 14) {
tentativas++;
const la = minLa + Math.random() * (maxLa - minLa);
const lo = minLo + Math.random() * (maxLo - minLo);
if (!dentroDoPoli(poli, la, lo)) continue;
achados++;
const v = latLonToVec3(la, lo, radius * 1.002);
luzPos.push(v.x, v.y, v.z);
const c = Math.random() < 0.22 ? fria : quente;
const b = 0.45 + Math.random() * 0.55;
luzCor.push(c.r * b, c.g * b, c.b * b);
}
});
const luzGeom = new THREE.BufferGeometry();
luzGeom.setAttribute('position', new THREE.Float32BufferAttribute(luzPos, 3));
luzGeom.setAttribute('color', new THREE.Float32BufferAttribute(luzCor, 3));
const luzMat = new THREE.PointsMaterial({
size: Math.max(1.1, radius * 0.008), vertexColors: true,
transparent: true, opacity: 0.85, sizeAttenuation: true,
blending: THREE.AdditiveBlending, depthWrite: false,
});
const luzes = new THREE.Points(luzGeom, luzMat);
planet.add(luzes);
// anel orbital externo, inclinado e girando devagar. É o único elemento
// com movimento próprio constante: dá vida ao globo nos segundos em que
// nenhuma venda está pingando.
const anelGeom = new THREE.RingGeometry(radius * 1.46, radius * 1.475, 160);
const anelMat = new THREE.MeshBasicMaterial({
color: 0x38BDF8, transparent: true, opacity: 0.22,
side: THREE.DoubleSide, blending: THREE.AdditiveBlending, depthWrite: false,
});
const anel = new THREE.Mesh(anelGeom, anelMat);
anel.rotation.x = Math.PI / 2.5;
anel.rotation.y = 0.25;
scene.add(anel);
// A foto da Terra saiu de propósito. Ela era do threejs.org, pesava e
// desenhava um planeta escuro em cima de um tema claro: o olho ia nele em
// vez de ir no dashboard, que é o que o hero precisa vender. Sobra a esfera
// de wireframe clara com os marcadores, que sugere "vendas no mundo" sem
// roubar a cena. Bônus: uma requisição externa a menos no carregamento.
// marcadores rodam JUNTO com o globo
const markersGroup = new THREE.Group();
planet.add(markersGroup);
// orientação inicial: Brasil de frente
const brazilLatRad = -14 * Math.PI / 180;
const brazilLonRad = -55 * Math.PI / 180;
// texturas equirectangulares: lon 0 fica no z+ depois desta rotação
const baseRotY = -brazilLonRad - Math.PI / 2;
const baseRotX = -brazilLatRad * 0.6;
planet.rotation.y = baseRotY;
planet.rotation.x = baseRotX;
function latLonToVec3(lat, lon, r) {
const phi = (90 - lat) * Math.PI / 180;
const theta = lon * Math.PI / 180;
return new THREE.Vector3(
r * Math.sin(phi) * Math.cos(theta),
r * Math.cos(phi),
-r * Math.sin(phi) * Math.sin(theta),
);
}
const markers = [];
// Cidades sempre acesas, fracas: o mapa existe antes da primeira venda
// pingar. Sem elas o globo passa segundos completamente vazio e não lia
// como mapa de vendas, só como bola listrada.
const baseMat = new THREE.MeshBasicMaterial({ color: 0x2563EB, transparent: true, opacity: 0.6 });
const baseGeom = new THREE.SphereGeometry(2.6, 8, 8);
BR_CITIES.forEach((c) => {
const d = new THREE.Mesh(baseGeom, baseMat);
d.position.copy(latLonToVec3(c.lat, c.lon, radius * 1.004));
markersGroup.add(d);
});
const spawnMarker = () => {
const c = BR_CITIES[Math.floor(Math.random() * BR_CITIES.length)];
const pos = latLonToVec3(c.lat, c.lon, radius * 1.005);
const normal = pos.clone().normalize();
const dotMat = new THREE.MeshBasicMaterial({
color: 0x10B981, transparent: true, opacity: 1,
});
const dot = new THREE.Mesh(new THREE.SphereGeometry(4.4, 12, 12), dotMat);
dot.position.copy(pos);
markersGroup.add(dot);
const ringMat = new THREE.MeshBasicMaterial({
color: 0x10B981, transparent: true, opacity: 0.9,
side: THREE.DoubleSide, depthWrite: false,
});
const ring = new THREE.Mesh(new THREE.RingGeometry(4, 5.6, 32), ringMat);
ring.position.copy(pos);
ring.lookAt(pos.clone().add(normal));
markersGroup.add(ring);
const beamMat = new THREE.LineBasicMaterial({
color: 0x10B981, transparent: true, opacity: 0.7,
});
const beamGeom = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(0, 0, 0),
normal.clone().multiplyScalar(20),
]);
const beam = new THREE.Line(beamGeom, beamMat);
beam.position.copy(pos);
markersGroup.add(beam);
markers.push({ dot, ring, beam, born: performance.now() });
};
// ── Arcos de venda: curvas brilhando cruzando o planeta ──────
const arcs = [];
const spawnArc = () => {
const ai = Math.floor(Math.random() * BR_CITIES.length);
let bi = Math.floor(Math.random() * BR_CITIES.length);
if (bi === ai) bi = (bi + 1) % BR_CITIES.length;
const a = BR_CITIES[ai], b = BR_CITIES[bi];
const pa = latLonToVec3(a.lat, a.lon, radius * 1.012);
const pb = latLonToVec3(b.lat, b.lon, radius * 1.012);
const mid = pa.clone().add(pb).multiplyScalar(0.5);
const lift = 1.18 + (pa.distanceTo(pb) / (radius * 2)) * 0.6;
mid.normalize().multiplyScalar(radius * lift);
const curve = new THREE.QuadraticBezierCurve3(pa, mid, pb);
const SEG = 48;
const pts = curve.getPoints(SEG);
const geom = new THREE.BufferGeometry().setFromPoints(pts);
const mat = new THREE.LineBasicMaterial({
color: Math.random() < 0.5 ? 0x60A5FA : 0x34D399,
transparent: true, opacity: 0,
blending: THREE.AdditiveBlending, depthWrite: false,
});
const line = new THREE.Line(geom, mat);
geom.setDrawRange(0, 0);
markersGroup.add(line);
const headMat = new THREE.MeshBasicMaterial({
color: 0xE3EDFF, transparent: true, opacity: 0,
blending: THREE.AdditiveBlending, depthWrite: false,
});
const head = new THREE.Mesh(new THREE.SphereGeometry(2.6, 10, 10), headMat);
markersGroup.add(head);
arcs.push({ line, geom, mat, head, headMat, curve, seg: SEG, born: performance.now() });
};
let raf = 0;
let last = performance.now();
let elapsed = 0;
let lastSpawn = 0;
let lastArcSpawn = 0;
const tick = (now) => {
raf = requestAnimationFrame(tick);
const dt = (now - last) / 1000; last = now; elapsed += dt;
// Gira o mundo inteiro, devagar. A oscilação em torno do Brasil fazia
// sentido quando só havia capitais brasileiras marcadas; com o mapa
// inteiro pingando, metade das vendas acontecia nas costas do planeta.
planet.rotation.y = baseRotY + elapsed * 0.075;
planet.rotation.x = baseRotX + Math.sin(elapsed * 0.11) * 0.05;
anel.rotation.z = elapsed * 0.06;
if (now - lastSpawn > spawnEveryMs) {
lastSpawn = now;
spawnMarker();
}
for (let i = markers.length - 1; i >= 0; i--) {
const m = markers[i];
const age = (now - m.born) / 1000;
const life = 3.4;
if (age > life) {
markersGroup.remove(m.dot);
markersGroup.remove(m.ring);
markersGroup.remove(m.beam);
m.dot.geometry.dispose(); m.dot.material.dispose();
m.ring.geometry.dispose(); m.ring.material.dispose();
m.beam.geometry.dispose(); m.beam.material.dispose();
markers.splice(i, 1);
continue;
}
const t = age / life;
m.dot.scale.setScalar(1 + Math.sin(Math.min(1, age * 1.4) * Math.PI) * 0.5);
m.dot.material.opacity = Math.max(0, 1 - t * 0.7);
m.ring.scale.setScalar(1 + age * 3.2);
m.ring.material.opacity = Math.max(0, 0.9 - t);
m.beam.material.opacity = Math.max(0, 0.7 - t * 1.4);
}
// arcos de venda cruzando o planeta
if (now - lastArcSpawn > spawnEveryMs * 1.15) {
lastArcSpawn = now;
spawnArc();
}
for (let i = arcs.length - 1; i >= 0; i--) {
const arc = arcs[i];
const age = (now - arc.born) / 1000;
const alife = 2.8;
if (age > alife) {
markersGroup.remove(arc.line); markersGroup.remove(arc.head);
arc.geom.dispose(); arc.mat.dispose();
arc.head.geometry.dispose(); arc.headMat.dispose();
arcs.splice(i, 1);
continue;
}
const at = age / alife;
const grow = Math.min(1, at / 0.55); // desenha o arco viajando
arc.geom.setDrawRange(0, Math.max(2, Math.floor((arc.seg + 1) * grow)));
const fadeIn = Math.min(1, at / 0.12);
const fadeOut = at > 0.7 ? Math.max(0, (1 - at) / 0.3) : 1;
arc.mat.opacity = 0.7 * fadeIn * fadeOut;
const hp = arc.curve.getPoint(grow);
arc.head.position.copy(hp);
arc.headMat.opacity = (grow < 1 ? 1 : Math.max(0, (1 - at) / 0.3));
}
renderer.render(scene, camera);
};
raf = requestAnimationFrame(tick);
const onResize = () => {
const w = mount.clientWidth, h = mount.clientHeight;
if (!w || !h) return;
renderer.setSize(w, h);
camera.aspect = w / h;
camera.updateProjectionMatrix();
};
window.addEventListener('resize', onResize);
// Pausa o loop quando não há nada pra ver: aba escondida OU globo fora da
// viewport. Sem a segunda condição, o WebGL segue desenhando 60 vezes por
// segundo o resto da página inteira abaixo do hero — a página fica pesada,
// o compositor começa a rasterizar em qualidade baixa (aquele aspecto de
// "240p" nas seções de baixo) e o renderizador chega a travar.
let naTela = true;
const parar = () => { if (raf) { cancelAnimationFrame(raf); raf = 0; } };
const seguir = () => {
if (raf || document.hidden || !naTela) return;
last = performance.now();
raf = requestAnimationFrame(tick);
};
const onVis = () => { document.hidden ? parar() : seguir(); };
document.addEventListener('visibilitychange', onVis);
const io = new IntersectionObserver(([e]) => {
naTela = e.isIntersecting;
naTela ? seguir() : parar();
}, { threshold: 0 });
io.observe(mount);
return () => {
if (raf) cancelAnimationFrame(raf);
io.disconnect();
window.removeEventListener('resize', onResize);
document.removeEventListener('visibilitychange', onVis);
if (renderer.domElement.parentNode === mount) mount.removeChild(renderer.domElement);
earthGeom.dispose(); earthMat.dispose();
atmo.geometry.dispose(); atmo.material.dispose();
atmo2.geometry.dispose(); atmo2.material.dispose();
descartar.forEach((g) => g.dispose());
gratMat.dispose(); equadorMat.dispose(); terraMat.dispose(); paisMat.dispose();
luzGeom.dispose(); luzMat.dispose();
anelGeom.dispose(); anelMat.dispose();
baseGeom.dispose(); baseMat.dispose();
markers.forEach(m => {
m.dot.geometry.dispose(); m.dot.material.dispose();
m.ring.geometry.dispose(); m.ring.material.dispose();
m.beam.geometry.dispose(); m.beam.material.dispose();
});
arcs.forEach(arc => {
arc.geom.dispose(); arc.mat.dispose();
arc.head.geometry.dispose(); arc.headMat.dispose();
});
renderer.dispose();
};
}, [radius, spawnEveryMs]);
return (
);
}
// ─────────────────────────────────────────────────────────────
// SaleNotifications — cards "+R$ X" flutuando ao lado do globo
// ─────────────────────────────────────────────────────────────
// `ancora`: onde o feed encosta dentro da moldura do painel.
// 'rodape' — canto inferior direito, empilhando pra cima (hero B, onde o
// painel aparece inteiro).
// 'topo' — canto superior direito (hero A, onde só a faixa de cima do
// painel fica acima da dobra; no rodapé o feed nasceria fora
// da tela).
function SaleNotifications({ startDelayMs = 3500, intervalMs = 2200, ancora = 'rodape' }) {
const [notes, setNotes] = _useState([]);
const boxRef = _useRef(null);
// Enquanto o feed está fora da tela ou a aba está escondida, não nasce card
// nenhum: cada um é um setState que re-renderiza o hero inteiro, e isso
// seguia rodando com o visitante dez seções abaixo.
const naTela = _useRef(true);
_useEffect(() => {
// Observa o PAI, não a própria caixa: sem nenhum card a caixa tem altura 0,
// e um alvo sem área não reporta interseção de forma confiável — o feed
// ficava esperando pra sempre a autorização pra nascer o primeiro card.
const el = boxRef.current;
const alvo = el && (el.parentElement || el);
if (!alvo) return;
const io = new IntersectionObserver(([e]) => { naTela.current = e.isIntersecting; }, { threshold: 0 });
io.observe(alvo);
return () => io.disconnect();
}, []);
_useEffect(() => {
let mounted = true;
let id = 0;
let interval = null;
const products = [
'Honda Civic 2024', 'Rolex Submariner', 'Curso Trading Pro',
'Apto 2Q Pinheiros', 'iPhone 16 Pro Max', 'MacBook Air M3',
'Yamaha MT-07', 'Patek Nautilus', 'Toyota Corolla XEi',
];
const spawn = () => {
if (!mounted || document.hidden || !naTela.current) return;
id += 1;
const myId = id;
const valor = Math.floor(2 + Math.random() * 78) * 1000 + Math.floor(Math.random() * 999);
const cidade = BR_CITIES[Math.floor(Math.random() * BR_CITIES.length)].name;
const produto = products[Math.floor(Math.random() * products.length)];
setNotes(n => [{ id: myId, valor, cidade, produto }, ...n].slice(0, 3));
setTimeout(() => {
if (!mounted) return;
setNotes(n => n.filter(x => x.id !== myId));
}, 5000);
};
const startTimer = setTimeout(() => {
if (!mounted) return;
spawn();
interval = setInterval(spawn, intervalMs);
}, startDelayMs);
return () => {
mounted = false;
clearTimeout(startTimer);
if (interval) clearInterval(interval);
};
}, [startDelayMs, intervalMs]);
return (
{notes.map(n => (
+R$ {n.valor.toLocaleString('pt-BR')}
{n.produto} · {n.cidade}
))}
);
}
// ─────────────────────────────────────────────────────────────
// HeroB — Split / assimétrico (com card "vendi!" flutuante)
// ─────────────────────────────────────────────────────────────
// Palavra que troca no título do hero, mantendo a linha ancorada.
//
// O `` invisível com a palavra MAIS LONGA reserva a largura: sem ele a
// linha encolhe e estica a cada troca e o bloco inteiro do título dança. A
// palavra visível fica sobreposta, então só ela anima.
//
// Sai da animação com `prefers-reduced-motion` — aí mostra a primeira palavra,
// parada, em vez de piscar.
function HeroCycle({ words, interval = 2600 }) {
const [i, setI] = _useState(0);
const [dentro, setDentro] = _useState(true);
const reduz = typeof window !== 'undefined'
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches;
_useEffect(() => {
if (reduz) return;
const sai = setTimeout(() => setDentro(false), interval - 480);
const vira = setTimeout(() => { setI((v) => (v + 1) % words.length); setDentro(true); }, interval);
return () => { clearTimeout(sai); clearTimeout(vira); };
}, [i, interval, words.length, reduz]);
const maisLonga = words.reduce((a, b) => (b.length > a.length ? b : a), '');
return (
{/* fantasma que segura a largura */}
{maisLonga}{words[i]}
);
}
function HeroB({ width = 1280, height = 900 }) {
const heroRef = _useRef(null);
// Parallax de profundidade no scroll (GSAP ScrollTrigger)
_useEffect(() => {
const gsap = window.gsap, ST = window.ScrollTrigger;
if (!gsap || !ST || !heroRef.current) return;
gsap.registerPlugin(ST);
const mm = window.matchMedia('(min-width: 900px) and (prefers-reduced-motion: no-preference)');
if (!mm.matches) return;
const ctx = gsap.context(() => {
const globe = heroRef.current.querySelector('.intro-globe');
const mock = heroRef.current.querySelector('.hero-parallax-mock');
const st = (extra) => ({ trigger: heroRef.current, start: 'top top', end: 'bottom top', scrub: 0.6, ...extra });
if (globe) gsap.to(globe, { yPercent: 20, ease: 'none', scrollTrigger: st() });
if (mock) gsap.to(mock, { yPercent: -10, ease: 'none', scrollTrigger: st() });
}, heroRef);
return () => ctx.revert();
}, []);
return (
{/* Eyebrow carrega prova, não data: responde "funciona com o que eu já
uso?" dentro da dobra. São 11 plataformas na FAQ — Hotmart, Kiwify,
Eduzz, Guru, Cakto, Monetizze, Ticto, Asaas, Mercado Pago,
Pagar.me e Stripe. Mudou a lista lá, muda o número aqui. */}
Funciona com Hotmart, Kiwify, Asaas e mais 8
Menos planilha. Mais{' '}
{/* "vendas." e "engajamento." saíram: o Comissa não faz vender
mais nem engaja ninguém — ele entrega o número certo. Promessa
que o produto não cumpre queima a página inteira. As 4 que
ficaram são todas verificáveis no app, e a mais longa agora
tem 12 chars (antes 14), então o fantasma de largura do
HeroCycle encolhe em vez de crescer. */}
Venda por link, Pix na mão ou contrato assinado: a comissão sai{' '}
pronta, com meta e follow-up no mesmo
painel. Sem planilha, sem “confia em mim”.
window.openModal('signup')}>Criar minha conta grátis window.scrollToId('demo')}> Ver o tour de 90s
{/* microcopy do CTA — usa a mesma cascata de entrada (o seletor
`.intro-cta-row > *` só pega elemento, por isso o ) */}
{/* `--muted` e não `--muted-2`: a 12,5px o cinza mais claro dá 3,5:1
sobre o fundo, abaixo do mínimo legível. Esta linha mata a
objeção de risco bem na hora do clique — é a última que pode
estar apagada. */}
{/* Mesmas 3 garantias da GarantiaSection (narrative.jsx), trazidas
pra dobra: o risco tem que morrer antes do clique, não 8 seções
depois. Mudou lá, muda aqui. */}
14 dias grátis · sem cartão · cancela em um clique
{/* mini stats ao vivo */}
{/* coluna direita dashboard saindo da borda */}
{/* Slot da captura real do app. Sem mídia em APP_MEDIA, cai
no DashboardMock — o painel animado que já estava aqui.
Trocar = preencher `vendedorDesktop` em app-media.jsx.
Os cursores vêm por cima: a captura é parada, quem dá
vida é a camada, igual na seção das interfaces. */}
}
/>
{temMidia(APP_MEDIA.vendedorDesktop) && (INTERFACES.find((x) => x.id === 'vendedor').cursores || []).map((c) => (
))}
{/* As vendas confirmadas entram aqui, dentro da moldura do
painel — não na margem ao lado dele. */}
O Comissa é pra quem vende carro, curso, relógio, imóvel, infoproduto.
Acompanha a comissão, bate a meta, e te lembra do cliente que sumiu.
{' '}Você vende, a gente cuida do resto.
{[
{ i: , l: 'Bateu a meta? Comissão na conta.' },
{ i: , l: 'Cliente sumiu? A gente te avisa.' },
{ i: , l: 'Quanto falta pra meta de hoje?' },
{ i: , l: 'Follow-up agendado, automágico.' },
{ i: , l: 'Lance uma venda em 5 segundos.' },
{ i: , l: 'O time todo no mesmo painel.' },
{ i: , l: '⌘K e tá feito.' },
{ i: , l: 'Quem vende mais? Veja o ranking.' },
].map((p) => (
{p.i}{p.l}
))}