feat: rebuild landing experience, content and analytics

This commit is contained in:
Evandro Fonseca Junior committed 2026-09-13 04:30:58 -03:00
1 parent d24ee12299
commit 9aff4d1035
51 files changed
+3023 -2595

No files matched your search

+24 -1
View File
@@ -342,11 +342,34 @@ function NotebookScene({
projects,
requestRender,
isVisible,
onStageError,
}: NotebookSceneProps) {
const lidRig = useRef<THREE.Group>(null);
const frame = useRef(getNotebookFrame(progress.current, projects.length));
const materials = useMemo(createLaptopMaterials, []);
const invalidate = useThree((state) => state.invalidate);
const { camera, gl, size, invalidate } = useThree();
useLayoutEffect(() => {
if (!(camera instanceof THREE.PerspectiveCamera)) return;
// Preserve desktop framing; widen portrait views without stretching the LCD.
camera.zoom =
window.innerWidth <= 1024
? Math.min(1, size.width / Math.max(size.height, 1) / 1.5)
: 1;
camera.updateProjectionMatrix();
invalidate();
}, [camera, invalidate, size.width, size.height]);
useEffect(() => {
const canvas = gl.domElement;
const handleContextLost = (event: Event) => {
event.preventDefault();
onStageError?.();
};
canvas.addEventListener("webglcontextlost", handleContextLost);
return () =>
canvas.removeEventListener("webglcontextlost", handleContextLost);
}, [gl, onStageError]);
useEffect(() => () => disposeLaptopMaterials(materials), [materials]);
useEffect(() => {
+66 -22
View File
@@ -1,4 +1,11 @@
import { act, render, screen, waitFor } from "@testing-library/react";
import {
act,
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import { getFeaturedProjects } from "@/lib/portfolio-data";
import NotebookStory, { canUseNotebook3D } from "./NotebookStory";
@@ -29,6 +36,7 @@ jest.mock("gsap/ScrollTrigger", () => ({
const projects = getFeaturedProjects(null);
let intersectionCallback: IntersectionObserverCallback | undefined;
let reducedMotionListener: (() => void) | undefined;
const mockObserve = jest.fn();
const mockDisconnect = jest.fn();
@@ -63,7 +71,11 @@ function mockMatchMedia({
(query === "(hover: hover)" && hover),
media: query,
onchange: null,
addEventListener: jest.fn(),
addEventListener: jest.fn((event: string, callback: () => void) => {
if (query === "(prefers-reduced-motion: reduce)" && event === "change") {
reducedMotionListener = callback;
}
}),
removeEventListener: jest.fn(),
addListener: jest.fn(),
removeListener: jest.fn(),
@@ -80,6 +92,7 @@ describe("NotebookStory", () => {
});
setDocumentHidden(false);
intersectionCallback = undefined;
reducedMotionListener = undefined;
mockObserve.mockReset();
mockDisconnect.mockReset();
class MockIntersectionObserver {
@@ -115,17 +128,53 @@ describe("NotebookStory", () => {
);
});
it("does not initialize the 3D stage on coarse pointers", async () => {
mockMatchMedia({ coarse: true });
it("initializes the 3D stage on a narrow touch viewport without hover", async () => {
mockMatchMedia({ coarse: true, hover: false });
Object.defineProperty(window, "innerWidth", {
configurable: true,
value: 390,
});
render(<NotebookStory projects={projects} />);
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
await waitFor(() =>
expect(screen.queryByTestId("notebook-stage")).not.toBeInTheDocument(),
expect(await screen.findByTestId("notebook-stage")).toBeInTheDocument();
expect(screen.queryByTestId("notebook-fallback")).not.toBeInTheDocument();
});
it("lets touch users select and open a project outside the scaled screen", async () => {
mockMatchMedia({ coarse: true, hover: false });
render(<NotebookStory projects={projects} />);
await screen.findByTestId("notebook-stage");
const controls = screen.getByRole("navigation", {
name: "Navegar projetos do notebook",
});
const button = within(controls).getByRole("button", {
name: /Mostrar SC/i,
});
fireEvent.click(button);
expect(button).toHaveAttribute("aria-pressed", "true");
expect(
within(controls).getByRole("link", { name: /Abrir projeto/i }),
).toHaveAttribute(
"href",
projects.find((project) => project.id === "sc-plus")?.demoUrl,
);
});
it("initializes the 3D stage only on a desktop hover-capable viewport", async () => {
it("returns to static content if reduced motion is enabled during the story", async () => {
mockMatchMedia({ coarse: true, hover: false });
render(<NotebookStory projects={projects} />);
await screen.findByTestId("notebook-stage");
mockMatchMedia({ reduced: true, coarse: true, hover: false });
act(() => reducedMotionListener?.());
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
expect(screen.queryByTestId("notebook-stage")).not.toBeInTheDocument();
});
it("initializes the 3D stage on a desktop viewport", async () => {
render(<NotebookStory projects={projects} />);
await waitFor(() =>
@@ -155,22 +204,19 @@ describe("NotebookStory", () => {
act(() => document.dispatchEvent(new Event("visibilitychange")));
expect(stage).toHaveAttribute("data-is-visible", "false");
});
it("returns to the static fallback when the viewport loses desktop eligibility", async () => {
it("keeps the mounted 3D stage when resizing from desktop to touch mobile", async () => {
render(<NotebookStory projects={projects} />);
await waitFor(() =>
expect(screen.getByTestId("notebook-stage")).toBeInTheDocument(),
);
const stage = await screen.findByTestId("notebook-stage");
Object.defineProperty(window, "innerWidth", {
configurable: true,
value: 800,
value: 360,
});
window.dispatchEvent(new Event("resize"));
mockMatchMedia({ coarse: true, hover: false });
act(() => window.dispatchEvent(new Event("resize")));
await waitFor(() =>
expect(screen.queryByTestId("notebook-stage")).not.toBeInTheDocument(),
);
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
expect(screen.getByTestId("notebook-stage")).toBe(stage);
expect(screen.queryByTestId("notebook-fallback")).not.toBeInTheDocument();
});
it("keeps the static fallback when no mapped projects are available", async () => {
@@ -187,17 +233,15 @@ describe("NotebookStory", () => {
);
});
it("requires desktop hover input without reduced motion", () => {
it("respects reduced motion independently of width and pointer capabilities", () => {
const matchMedia = (query: string) =>
({
matches: query === "(hover: hover)",
}) as MediaQueryList;
expect(canUseNotebook3D({ innerWidth: 1280, matchMedia })).toBe(true);
expect(canUseNotebook3D({ innerWidth: 1024, matchMedia })).toBe(false);
expect(canUseNotebook3D({ matchMedia })).toBe(true);
expect(
canUseNotebook3D({
innerWidth: 1280,
matchMedia: (query) =>
({
matches:
+48 -20
View File
@@ -12,7 +12,7 @@ const NotebookStage3D = dynamic(() => import("./NotebookStage3D"), {
ssr: false,
});
type CapabilityWindow = Pick<Window, "innerWidth" | "matchMedia">;
type CapabilityWindow = Pick<Window, "matchMedia">;
export type NotebookStoryProps = {
projects: PortfolioProjectView[];
@@ -21,12 +21,7 @@ export type NotebookStoryProps = {
export function canUseNotebook3D(view: CapabilityWindow): boolean {
if (typeof view.matchMedia !== "function") return false;
return (
view.innerWidth >= 1025 &&
!view.matchMedia("(prefers-reduced-motion: reduce)").matches &&
!view.matchMedia("(pointer: coarse)").matches &&
view.matchMedia("(hover: hover)").matches
);
return !view.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
export function getNotebookRunwayVh(tabCount: number): number {
@@ -84,21 +79,11 @@ export default function NotebookStory({
updateEngine();
if (typeof window.matchMedia !== "function") return undefined;
const mediaQueries = [
window.matchMedia("(prefers-reduced-motion: reduce)"),
window.matchMedia("(pointer: coarse)"),
window.matchMedia("(hover: hover)"),
];
window.addEventListener("resize", updateEngine, { passive: true });
mediaQueries.forEach((mediaQuery) => {
mediaQuery.addEventListener("change", updateEngine);
});
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
reducedMotion.addEventListener("change", updateEngine);
return () => {
window.removeEventListener("resize", updateEngine);
mediaQueries.forEach((mediaQuery) => {
mediaQuery.removeEventListener("change", updateEngine);
});
reducedMotion.removeEventListener("change", updateEngine);
};
}, [notebookProjects.length]);
@@ -224,6 +209,8 @@ export default function NotebookStory({
[engine, notebookProjects.length],
);
const activeProject = notebookProjects[activeIndex];
if (!engine) {
return (
<section
@@ -235,6 +222,7 @@ export default function NotebookStory({
style={
{
"--notebook-runway": `${getNotebookRunwayVh(notebookProjects.length)}vh`,
"--notebook-mobile-runway": `${getNotebookRunwayVh(notebookProjects.length) * 0.8}svh`,
} as CSSProperties
}
>
@@ -269,6 +257,7 @@ export default function NotebookStory({
style={
{
"--notebook-runway": `${getNotebookRunwayVh(notebookProjects.length)}vh`,
"--notebook-mobile-runway": `${getNotebookRunwayVh(notebookProjects.length) * 0.8}svh`,
} as CSSProperties
}
>
@@ -295,6 +284,45 @@ export default function NotebookStory({
requestRender={requestRenderRef}
projects={notebookProjects}
/>
<nav
className="notebook-story-controls"
aria-label="Navegar projetos do notebook"
>
<div className="notebook-story-project-buttons">
{notebookProjects.map((project, index) => (
<button
key={project.id}
type="button"
aria-label={`Mostrar ${project.title}`}
aria-pressed={index === activeIndex}
onClick={() => handleSelectTab(index)}
>
{String(index + 1).padStart(2, "0")}
</button>
))}
</div>
<p aria-live="polite">{activeProject?.title}</p>
{activeProject?.demoAvailability === "unavailable" ? (
<span aria-disabled="true">
Demo indisponível na última verificação
</span>
) : activeProject?.demoAvailability !== "none" &&
activeProject?.demoUrl ? (
<a
href={activeProject.demoUrl}
rel="noopener noreferrer"
target="_blank"
>
Abrir projeto
{activeProject.demoAvailability === "unknown" && (
<span className="sr-only">
{" "}
— Disponibilidade não verificada
</span>
)}
</a>
) : null}
</nav>
</div>
</div>
</section>