CI / verify (push) Failing after 5m43s
- Biome: formatacao, ordem de imports, max-width duplicado no cabecalho de secao, supressao justificada para nomes de view-transition, ajustes nos scripts de auditoria de SEO - next 16.3.3 -> 16.3.8: GHSA-vcvr-r3jv-pc5j (execucao remota de codigo em next/og ImageResponse, usado nas imagens de compartilhamento) - overrides de brace-expansion (GHSA-qhr7-859c-m2p7, GHSA-6j4f-fj2g-mc7p, GHSA-q2hr-2g5m-vwhr), dependencia de desenvolvimento via jest - visibilidade das cenas le as posicoes a cada quadro e o GSAP remede as passagens quando a pagina muda de altura (fontes, imagens, Experiencia) Co-Authored-By: Claude Opus 5.5 <[email protected]>
134 lines
4.3 KiB
TypeScript
134 lines
4.3 KiB
TypeScript
"use client";
|
|
|
|
import dynamic from "next/dynamic";
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import type { HeroFieldSceneProps } from "./HeroFieldScene";
|
|
|
|
/** Etiquetas presas às camadas da cena (a cena calcula a posição de cada uma a cada quadro). */
|
|
const TAGS = [
|
|
["A tela", "o que sua equipe e seus clientes usam"],
|
|
["As integrações", "seus sistemas conversando entre si"],
|
|
["Os dados", "suas informações organizadas"],
|
|
] as const;
|
|
|
|
const Scene = dynamic<HeroFieldSceneProps>(() => import("./HeroFieldScene"), {
|
|
ssr: false,
|
|
});
|
|
|
|
function supportsWebGL2() {
|
|
if (typeof window.WebGL2RenderingContext === "undefined") return false;
|
|
try {
|
|
const context = document.createElement("canvas").getContext("webgl2");
|
|
context?.getExtension("WEBGL_lose_context")?.loseContext();
|
|
return context !== null;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fundo do hero: liga a rolagem do hero à variável CSS --hero-p (0 no topo, 1 no fim do mergulho)
|
|
* e monta a cena WebGL só quando a página fica ociosa, com WebGL2 e sem preferência por movimento reduzido.
|
|
*/
|
|
export default function HeroField() {
|
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
const progressRef = useRef(0);
|
|
const labelsRef = useRef<(HTMLElement | null)[]>([]);
|
|
const [mount, setMount] = useState(false);
|
|
const [visible, setVisible] = useState(false);
|
|
const [ready, setReady] = useState(false);
|
|
const [failed, setFailed] = useState(false);
|
|
// fim do mergulho: a cena já está no escuro e o hero se dissolve; parar o desenho poupa a GPU
|
|
const [done, setDone] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const section = rootRef.current?.closest<HTMLElement>(".hero");
|
|
if (!section) return;
|
|
let frame = 0;
|
|
const update = () => {
|
|
frame = 0;
|
|
const rect = section.getBoundingClientRect();
|
|
const span = Math.max(
|
|
rect.height - window.innerHeight,
|
|
window.innerHeight * 0.8,
|
|
);
|
|
const p = Math.min(1, Math.max(0, -rect.top / span));
|
|
progressRef.current = p;
|
|
section.style.setProperty("--hero-p", p.toFixed(4));
|
|
setDone(p >= 0.999);
|
|
};
|
|
const queue = () => {
|
|
if (!frame) frame = requestAnimationFrame(update);
|
|
};
|
|
update();
|
|
window.addEventListener("scroll", queue, { passive: true });
|
|
window.addEventListener("resize", queue);
|
|
|
|
let intersecting = false;
|
|
const sync = () => setVisible(intersecting && !document.hidden);
|
|
const observer = new IntersectionObserver(([entry]) => {
|
|
intersecting = entry.isIntersecting;
|
|
sync();
|
|
});
|
|
observer.observe(section);
|
|
document.addEventListener("visibilitychange", sync);
|
|
return () => {
|
|
cancelAnimationFrame(frame);
|
|
window.removeEventListener("scroll", queue);
|
|
window.removeEventListener("resize", queue);
|
|
observer.disconnect();
|
|
document.removeEventListener("visibilitychange", sync);
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
|
if (!supportsWebGL2()) return;
|
|
// Título, botões e hidratação vêm primeiro; o Three.js entra quando o navegador fica ocioso.
|
|
if (typeof window.requestIdleCallback === "function") {
|
|
const id = window.requestIdleCallback(() => setMount(true), {
|
|
timeout: 2500,
|
|
});
|
|
return () => window.cancelIdleCallback(id);
|
|
}
|
|
const id = setTimeout(() => setMount(true), 1200);
|
|
return () => clearTimeout(id);
|
|
}, []);
|
|
|
|
const handleReady = useCallback(() => setReady(true), []);
|
|
const handleError = useCallback(() => setFailed(true), []);
|
|
|
|
return (
|
|
<div
|
|
ref={rootRef}
|
|
aria-hidden="true"
|
|
className="hero-field"
|
|
data-ready={ready && !failed ? "" : undefined}
|
|
>
|
|
{mount && !failed && (
|
|
<Scene
|
|
progressRef={progressRef}
|
|
labelsRef={labelsRef}
|
|
visible={visible && !done}
|
|
onReady={handleReady}
|
|
onError={handleError}
|
|
/>
|
|
)}
|
|
{mount &&
|
|
!failed &&
|
|
TAGS.map(([title, text], index) => (
|
|
<span
|
|
key={title}
|
|
ref={(element) => {
|
|
labelsRef.current[index] = element;
|
|
}}
|
|
className="hero-tag"
|
|
>
|
|
<span className="hero-tag-title">{title}</span>
|
|
{text}
|
|
</span>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|