feat: rebuild landing experience, content and analytics
This commit is contained in:
1 parent
d24ee12299
commit
9aff4d1035
51 files changed
+3023
-2595
No files matched your search
@@ -0,0 +1,325 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import type { PointerEvent } from "react";
|
||||
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
||||
import type { HeroSystemLayer, HeroSystemSceneProps } from "./HeroSystemScene";
|
||||
|
||||
const DynamicScene = dynamic<HeroSystemSceneProps>(
|
||||
() => import("./HeroSystemScene"),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
const LAYERS: { id: HeroSystemLayer; label: string; description: string }[] = [
|
||||
{
|
||||
id: "overview",
|
||||
label: "Visão geral",
|
||||
description:
|
||||
"Da tela ao que acontece por trás: interface, integrações e dados trabalhando juntos.",
|
||||
},
|
||||
{
|
||||
id: "interface",
|
||||
label: "Interface",
|
||||
description:
|
||||
"A parte que as pessoas usam. Aqui, uma tela real do NutriScan, projeto do portfólio.",
|
||||
},
|
||||
{
|
||||
id: "integrations",
|
||||
label: "Integrações",
|
||||
description:
|
||||
"Conexões entre sistemas para levar informações de uma etapa da operação à próxima.",
|
||||
},
|
||||
{
|
||||
id: "data",
|
||||
label: "Dados",
|
||||
description:
|
||||
"A base que organiza e guarda as informações de que a operação precisa.",
|
||||
},
|
||||
];
|
||||
|
||||
const REST_POSE = { x: 0, y: 0 };
|
||||
type Gesture = {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
startPose: number;
|
||||
width: number;
|
||||
dragging: boolean;
|
||||
};
|
||||
|
||||
export default function HeroSystem() {
|
||||
const descriptionId = useId();
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const frameRef = useRef<number | null>(null);
|
||||
const nextPoseRef = useRef(REST_POSE);
|
||||
const gestureRef = useRef<Gesture | null>(null);
|
||||
const suppressClickRef = useRef(false);
|
||||
const [activeLayer, setActiveLayer] = useState<HeroSystemLayer>("overview");
|
||||
const [pose, setPose] = useState(REST_POSE);
|
||||
const [canRender, setCanRender] = useState(false);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const renderScene = canRender && !failed;
|
||||
const sceneActive = renderScene && ready;
|
||||
|
||||
useEffect(() => {
|
||||
const preference = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
let supported: boolean | undefined;
|
||||
const updateCapability = () => {
|
||||
if (preference.matches) {
|
||||
setCanRender(false);
|
||||
setReady(false);
|
||||
return;
|
||||
}
|
||||
if (supported === undefined) {
|
||||
if (typeof window.WebGL2RenderingContext === "undefined") {
|
||||
supported = false;
|
||||
} else {
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
const context = canvas.getContext("webgl2");
|
||||
supported = context !== null;
|
||||
context?.getExtension("WEBGL_lose_context")?.loseContext();
|
||||
} catch {
|
||||
supported = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
setCanRender(supported);
|
||||
};
|
||||
updateCapability();
|
||||
preference.addEventListener("change", updateCapability);
|
||||
return () => preference.removeEventListener("change", updateCapability);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) return;
|
||||
let intersecting = false;
|
||||
const updateVisibility = () => {
|
||||
setVisible(intersecting && document.visibilityState === "visible");
|
||||
};
|
||||
const observer = new IntersectionObserver(([entry]) => {
|
||||
intersecting = entry.isIntersecting;
|
||||
updateVisibility();
|
||||
});
|
||||
observer.observe(viewport);
|
||||
document.addEventListener("visibilitychange", updateVisibility);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
document.removeEventListener("visibilitychange", updateVisibility);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const queuePose = useCallback((x: number, y: number) => {
|
||||
nextPoseRef.current = {
|
||||
x: Math.max(-1, Math.min(1, x)),
|
||||
y: Math.max(-1, Math.min(1, y)),
|
||||
};
|
||||
if (frameRef.current !== null) return;
|
||||
frameRef.current = window.requestAnimationFrame(() => {
|
||||
frameRef.current = null;
|
||||
setPose(nextPoseRef.current);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const releaseGesture = useCallback(() => {
|
||||
const gesture = gestureRef.current;
|
||||
gestureRef.current = null;
|
||||
const viewport = viewportRef.current;
|
||||
if (gesture && viewport?.hasPointerCapture(gesture.id)) {
|
||||
viewport.releasePointerCapture(gesture.id);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sceneActive || !visible) {
|
||||
releaseGesture();
|
||||
if (frameRef.current !== null)
|
||||
window.cancelAnimationFrame(frameRef.current);
|
||||
frameRef.current = null;
|
||||
nextPoseRef.current = REST_POSE;
|
||||
setPose(REST_POSE);
|
||||
}
|
||||
return () => {
|
||||
releaseGesture();
|
||||
if (frameRef.current !== null)
|
||||
window.cancelAnimationFrame(frameRef.current);
|
||||
frameRef.current = null;
|
||||
};
|
||||
}, [sceneActive, visible, releaseGesture]);
|
||||
|
||||
const handleReady = useCallback(() => setReady(true), []);
|
||||
const handleError = useCallback(() => {
|
||||
setFailed(true);
|
||||
setReady(false);
|
||||
}, []);
|
||||
const selectSceneLayer = useCallback((layer: HeroSystemLayer) => {
|
||||
if (!suppressClickRef.current) setActiveLayer(layer);
|
||||
}, []);
|
||||
|
||||
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!sceneActive || !visible || !event.isPrimary || event.button !== 0)
|
||||
return;
|
||||
suppressClickRef.current = false;
|
||||
gestureRef.current = {
|
||||
id: event.pointerId,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
startPose: nextPoseRef.current.x,
|
||||
width: event.currentTarget.clientWidth,
|
||||
dragging: false,
|
||||
};
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!sceneActive || !visible) return;
|
||||
const gesture = gestureRef.current;
|
||||
if (gesture && gesture.id === event.pointerId) {
|
||||
const dx = event.clientX - gesture.x;
|
||||
const dy = event.clientY - gesture.y;
|
||||
if (!gesture.dragging) {
|
||||
if (Math.abs(dy) > 7 && Math.abs(dy) > Math.abs(dx)) {
|
||||
releaseGesture();
|
||||
return;
|
||||
}
|
||||
if (Math.abs(dx) < 7) return;
|
||||
gesture.dragging = true;
|
||||
suppressClickRef.current = true;
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}
|
||||
queuePose(gesture.startPose + (dx / Math.max(gesture.width, 1)) * 3, 0);
|
||||
return;
|
||||
}
|
||||
if (event.pointerType !== "mouse" || event.buttons !== 0) return;
|
||||
const bounds = event.currentTarget.getBoundingClientRect();
|
||||
queuePose(
|
||||
((event.clientX - bounds.left) / Math.max(bounds.width, 1)) * 2 - 1,
|
||||
((event.clientY - bounds.top) / Math.max(bounds.height, 1)) * 2 - 1,
|
||||
);
|
||||
};
|
||||
|
||||
const cancelGesture = () => {
|
||||
releaseGesture();
|
||||
if (sceneActive && visible) queuePose(0, 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="hero-system min-w-0 w-full">
|
||||
<div
|
||||
ref={viewportRef}
|
||||
className="hero-system-viewport relative isolate h-[350px] w-full overflow-hidden sm:h-[400px] lg:h-[440px]"
|
||||
style={{
|
||||
touchAction: "pan-y",
|
||||
cursor: sceneActive ? "grab" : undefined,
|
||||
}}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={releaseGesture}
|
||||
onPointerCancel={cancelGesture}
|
||||
onLostPointerCapture={releaseGesture}
|
||||
onPointerLeave={() => {
|
||||
if (!gestureRef.current?.dragging) cancelGesture();
|
||||
}}
|
||||
onClickCapture={(event) => {
|
||||
if (suppressClickRef.current) event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`absolute inset-0 flex items-center px-sp-3 ${sceneActive ? "pointer-events-none opacity-0" : "opacity-100"}`}
|
||||
aria-hidden={sceneActive}
|
||||
>
|
||||
<div className="w-full">
|
||||
<div className="mb-sp-3 flex items-center justify-between gap-sp-3 text-[12px] text-white/65">
|
||||
<span className="font-medium text-[#f4f2ed]">
|
||||
Interface real: NutriScan
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative block aspect-[2/1] overflow-hidden rounded-lg border border-white/15 bg-[#151a16] shadow-[0_24px_70px_rgba(0,0,0,0.45)]">
|
||||
<Image
|
||||
src="/assets/notebook/nutriscan.webp"
|
||||
alt="Tela real do projeto NutriScan."
|
||||
fill
|
||||
priority
|
||||
sizes="(min-width: 1024px) 44vw, 92vw"
|
||||
className="object-cover object-top"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{renderScene && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={`absolute inset-0 ${sceneActive ? "opacity-100" : "pointer-events-none opacity-0"}`}
|
||||
>
|
||||
<DynamicScene
|
||||
activeLayer={activeLayer}
|
||||
pose={pose}
|
||||
visible={visible}
|
||||
onReady={handleReady}
|
||||
onError={handleError}
|
||||
onSelectLayer={selectSceneLayer}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{sceneActive && (
|
||||
<p className="pointer-events-none absolute inset-x-0 bottom-sp-3 text-center text-[12px] text-white/60">
|
||||
<span className="hidden sm:inline">
|
||||
Mova ou arraste para explorar
|
||||
</span>
|
||||
<span className="sm:hidden">
|
||||
Arraste para os lados para explorar
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative px-sp-1 sm:px-sp-3">
|
||||
<fieldset
|
||||
aria-label="Explorar camadas do sistema"
|
||||
className="grid grid-cols-2 gap-1 min-[400px]:grid-cols-4"
|
||||
>
|
||||
{LAYERS.map((layer) => (
|
||||
<button
|
||||
key={layer.id}
|
||||
type="button"
|
||||
aria-pressed={activeLayer === layer.id}
|
||||
aria-controls={descriptionId}
|
||||
onClick={() => {
|
||||
suppressClickRef.current = false;
|
||||
setActiveLayer(layer.id);
|
||||
if (layer.id === "overview" && sceneActive && visible)
|
||||
queuePose(0, 0);
|
||||
}}
|
||||
className={`min-h-11 rounded-sm px-sp-2 py-sp-2 text-[12px] font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--slash)] motion-reduce:transition-none sm:text-[13px] ${activeLayer === layer.id ? "bg-[var(--slash)] text-[var(--ink)]" : "bg-white/5 text-white/75 hover:bg-white/10 hover:text-white"}`}
|
||||
>
|
||||
{layer.label}
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
<p
|
||||
id={descriptionId}
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="mt-sp-3 min-h-[4.5em] text-[13px] leading-[1.5] text-white/75 sm:min-h-[3em]"
|
||||
>
|
||||
{LAYERS.find((layer) => layer.id === activeLayer)?.description}
|
||||
</p>
|
||||
<div className="mt-sp-1 flex flex-wrap items-center justify-between gap-x-sp-3">
|
||||
<p className="max-w-[230px] text-[11px] leading-[1.5] text-white/55">
|
||||
Camadas ilustrativas de um sistema
|
||||
</p>
|
||||
<Link
|
||||
href="/cases/nutriscan"
|
||||
className="inline-flex min-h-11 items-center text-[12px] font-medium text-[#f4f2ed] underline decoration-white/35 underline-offset-4 hover:decoration-[var(--slash)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--slash)]"
|
||||
>
|
||||
Interface real: NutriScan
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user