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
@@ -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(() => {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user