Migrate the standalone Vite + React Three Fiber experience into the existing Next.js site as the body of the How It Works page, replacing the Miles3 / WhyChooseDoormile / TheDoormileWay content sections while preserving the Elementor hero, global Header/Footer, layout, routing and SEO. - New self-contained module: src/modules/how-it-works-3d/ (R3F scene, hooks, zustand store, animations, curves, constants, utils, scoped CSS). App.jsx → Experience3D.jsx; 3d_scene.jsx → models/Scene3D.jsx. - 32MB GLB moved to public/models/3d_scene_final.glb; useGLTF paths updated. - Client-only entry via dynamic ssr:false loader (Experience3DLoader). - Self-managed fixed pin (tall section + absolute stage toggled absolute(top)→fixed→absolute(bottom) from ScrollTrigger pin state), mirroring the site's StrategySection, since the fixed header + ancestor overflow:hidden break CSS sticky / GSAP pin. - experience.css fully scoped under .dm-hiw-3d to avoid colliding with the site's Elementor CSS. - Global Lenis disabled on /how-it-works; module runs its own tuned Lenis; jump-to-section scroll math made spacer-relative. - Added zustand + maath; ESLint-ignored the ported module. Rendering fixes (root causes found by driving headless Chrome): - Bump three 0.171 → 0.184 to match @react-three/fiber@9.6 / drei@10.7 / postprocessing@6.39 (0.171 silently failed to render this GLB and caused the EffectComposer getContextAttributes().alpha crash). Other 3D routes verified. - EffectComposer: Bloom + Vignette only. SSAO needs a NormalPass (v3 dropped the old `disableNormalPass`), and that extra full-scene pass exhausted the WebGL context on this heavy scene. - Cap Canvas dpr to [1,1.5] to bound framebuffer memory on retina displays. - Defer Canvas mount via IntersectionObserver (mountScene), matching StrategySection, to ease StrictMode/first-render GPU pressure. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
134 lines
4.7 KiB
TypeScript
134 lines
4.7 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect } from "react";
|
|
import { usePathname } from "next/navigation";
|
|
import gsap from "gsap";
|
|
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
|
import Lenis from "lenis";
|
|
|
|
/**
|
|
* SmoothScroll
|
|
* ---------------------------------------------------------------------------
|
|
* One global Lenis instance, driven by a SINGLE rAF source (GSAP's ticker) and
|
|
* kept locked to ScrollTrigger via `lenis.on("scroll", ScrollTrigger.update)`.
|
|
*
|
|
* Enabled on every route. /miletruth's three scroll-driven WebGL sections use a
|
|
* self-managed `position: fixed` pin (toggled from ScrollTrigger.onUpdate via
|
|
* `self.progress`) — NOT GSAP's pin-spacer — so Lenis driving real document
|
|
* scroll keeps their progress correct and just smooths the wheel input. (It was
|
|
* previously gated off here when those sections used GSAP pins; that no longer
|
|
* applies, and native scroll there felt noticeably janky next to every Lenis
|
|
* route.)
|
|
*
|
|
* Still gated OFF on:
|
|
* - touch devices — native momentum is smoother than emulated inertia.
|
|
* - prefers-reduced-motion.
|
|
*
|
|
* Re-evaluates on every route change: the effect cleanup destroys the previous
|
|
* instance and re-inits on the next route.
|
|
*/
|
|
// /how-it-works runs its own tuned Lenis inside the embedded 3D experience
|
|
// (src/modules/how-it-works-3d); the global instance is gated off there so two
|
|
// Lenis instances don't fight over the same document scroll.
|
|
const DISABLED_ROUTES: string[] = ["/how-it-works"];
|
|
|
|
export default function SmoothScroll() {
|
|
const pathname = usePathname();
|
|
|
|
useEffect(() => {
|
|
const routeDisabled = DISABLED_ROUTES.some(
|
|
(r) => pathname === r || pathname.startsWith(`${r}/`),
|
|
);
|
|
const prefersReduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
// Mouse/desktop only — touch devices already have good native momentum.
|
|
const isPointerFine = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
|
|
|
|
let hashTimer: ReturnType<typeof setTimeout>;
|
|
|
|
if (routeDisabled || prefersReduced || !isPointerFine) {
|
|
if (!window.location.hash) {
|
|
window.scrollTo(0, 0);
|
|
} else {
|
|
const scrollToHash = () => {
|
|
try {
|
|
const target = document.querySelector(window.location.hash) as HTMLElement | null;
|
|
if (target) {
|
|
target.scrollIntoView();
|
|
}
|
|
} catch (err) {
|
|
console.warn(err);
|
|
}
|
|
};
|
|
scrollToHash();
|
|
hashTimer = setTimeout(scrollToHash, 100);
|
|
}
|
|
return () => {
|
|
if (hashTimer) clearTimeout(hashTimer);
|
|
};
|
|
}
|
|
|
|
gsap.registerPlugin(ScrollTrigger);
|
|
|
|
// /miletruth is one long stack of tall, pinned 3D sections, each with its own
|
|
// ScrollTrigger `scrub`. The default duration-based momentum (1.05s) compounds
|
|
// with that scrub, so the wheel feels heavy, slow and disconnected. On this
|
|
// route we switch to a snappy `lerp`-based follow + a higher wheel multiplier:
|
|
// the page travels fast and stays tightly locked to the wheel, while each
|
|
// section's scrub supplies the visual smoothing. Other routes keep the softer
|
|
// duration-based feel that suits their normal content.
|
|
const isMileTruth =
|
|
pathname === "/miletruth" || pathname.startsWith("/miletruth/");
|
|
|
|
const lenis = new Lenis(
|
|
isMileTruth
|
|
? {
|
|
lerp: 0.13, // snappy follow (higher = less smoothing lag)
|
|
wheelMultiplier: 1.3, // travel further per wheel tick → fast
|
|
touchMultiplier: 1.6,
|
|
orientation: "vertical",
|
|
gestureOrientation: "vertical",
|
|
smoothWheel: true,
|
|
}
|
|
: {
|
|
duration: 1.05,
|
|
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
|
|
orientation: "vertical",
|
|
gestureOrientation: "vertical",
|
|
smoothWheel: true,
|
|
},
|
|
);
|
|
|
|
if (!window.location.hash) {
|
|
lenis.scrollTo(0, { immediate: true });
|
|
window.scrollTo(0, 0);
|
|
} else {
|
|
const scrollToHash = () => {
|
|
try {
|
|
const target = document.querySelector(window.location.hash) as HTMLElement | null;
|
|
if (target) {
|
|
lenis.scrollTo(target, { immediate: true });
|
|
}
|
|
} catch (err) {
|
|
console.warn(err);
|
|
}
|
|
};
|
|
scrollToHash();
|
|
hashTimer = setTimeout(scrollToHash, 100);
|
|
}
|
|
|
|
lenis.on("scroll", ScrollTrigger.update);
|
|
const tickerCb = (time: number) => lenis.raf(time * 1000); // ticker is seconds, Lenis wants ms
|
|
gsap.ticker.add(tickerCb);
|
|
gsap.ticker.lagSmoothing(0);
|
|
ScrollTrigger.refresh();
|
|
|
|
return () => {
|
|
if (hashTimer) clearTimeout(hashTimer);
|
|
gsap.ticker.remove(tickerCb);
|
|
lenis.destroy();
|
|
};
|
|
}, [pathname]);
|
|
|
|
return null;
|
|
}
|