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>
159 lines
5.6 KiB
JavaScript
159 lines
5.6 KiB
JavaScript
"use client";
|
|
|
|
import React, { useRef, useEffect, useState } from 'react'
|
|
import Experience from './components/Experience'
|
|
import ScrollRig from './components/ScrollRig'
|
|
import Navbar from './components/ui/Navbar'
|
|
import FirstMile from './components/sections/FirstMile'
|
|
import MidMile from './components/sections/MidMile'
|
|
import LastMile from './components/sections/LastMile'
|
|
import Analytics from './components/sections/Analytics'
|
|
import { useSceneStore } from './store/useSceneStore'
|
|
import './styles/experience.css'
|
|
|
|
import Lenis from 'lenis'
|
|
import gsap from 'gsap'
|
|
import { ScrollTrigger } from 'gsap/ScrollTrigger'
|
|
|
|
gsap.registerPlugin(ScrollTrigger)
|
|
|
|
/**
|
|
* Experience3D
|
|
* ---------------------------------------------------------------------------
|
|
* The full scroll-driven 3D logistics story, ported from the standalone Vite
|
|
* app's App.jsx and embedded as the body of the How It Works page (below the
|
|
* existing Elementor hero, above the global Footer).
|
|
*
|
|
* Two integration changes vs. the standalone app:
|
|
* 1. Self-managed fixed pin. The site has a fixed header and an ancestor with
|
|
* `overflow:hidden`, both of which break CSS `position: sticky`. So this is
|
|
* a tall `position:relative` section (`.dm-hiw-3d`, its height supplied by
|
|
* the 900vh ScrollRig spacer) with an absolutely-positioned `.dm-hiw-3d-stage`
|
|
* toggled absolute(top) → fixed → absolute(bottom) via the ScrollTrigger pin
|
|
* state — the same approach the site's other 3D sections use (StrategySection).
|
|
* 2. The global Lenis is disabled on `/how-it-works` (SmoothScroll.tsx) so the
|
|
* experience runs its own tuned Lenis here without a second instance fighting
|
|
* it. The internal "Scroll to start" Hero overlay is dropped because the page
|
|
* keeps the Elementor HowItWorksHero above this section.
|
|
*/
|
|
export default function Experience3D() {
|
|
const scrollProgress = useSceneStore((state) => state.scrollProgress)
|
|
const setLenis = useSceneStore((state) => state.setLenis)
|
|
|
|
const containerRef = useRef(null)
|
|
const [pinState, setPinState] = useState('before')
|
|
// Defer mounting the WebGL Canvas until the section nears the viewport. This
|
|
// mirrors the site's other 3D sections (StrategySection's `mountScene`): besides
|
|
// saving the heavy 32MB scene until needed, it keeps the Canvas out of React
|
|
// StrictMode's initial synchronous double-mount, which otherwise creates and
|
|
// immediately loses the WebGL context in dev ("THREE.WebGLRenderer: Context Lost"),
|
|
// leaving a blank canvas. Once mounted it stays mounted.
|
|
const [mountScene, setMountScene] = useState(false)
|
|
|
|
useEffect(() => {
|
|
const el = containerRef.current
|
|
if (!el) return
|
|
const io = new IntersectionObserver(
|
|
(entries) => {
|
|
if (entries.some((e) => e.isIntersecting)) {
|
|
setMountScene(true)
|
|
io.disconnect()
|
|
}
|
|
},
|
|
{ rootMargin: '200% 0px' }, // mount well before it scrolls into view
|
|
)
|
|
io.observe(el)
|
|
return () => io.disconnect()
|
|
}, [])
|
|
|
|
// Own Lenis instance (global Lenis is gated off for this route).
|
|
useEffect(() => {
|
|
const lenis = new Lenis({
|
|
duration: 1.2,
|
|
lerp: 0.08,
|
|
syncTouch: true,
|
|
})
|
|
|
|
setLenis(lenis)
|
|
lenis.on('scroll', ScrollTrigger.update)
|
|
|
|
let rafId
|
|
function raf(time) {
|
|
lenis.raf(time)
|
|
rafId = requestAnimationFrame(raf)
|
|
}
|
|
rafId = requestAnimationFrame(raf)
|
|
|
|
gsap.ticker.lagSmoothing(0)
|
|
ScrollTrigger.refresh()
|
|
|
|
return () => {
|
|
cancelAnimationFrame(rafId)
|
|
lenis.destroy()
|
|
setLenis(null)
|
|
}
|
|
}, [setLenis])
|
|
|
|
// 3D references shared between R3F and the GSAP scroll system.
|
|
const truckRef = useRef(null)
|
|
|
|
const wheelRefs = React.useMemo(() => [
|
|
{ current: null }, // FR
|
|
{ current: null }, // FL
|
|
{ current: null }, // RL
|
|
{ current: null }, // RR
|
|
], [])
|
|
|
|
const dashboardRefs = React.useMemo(() => ({
|
|
bars: [
|
|
{ current: null }, { current: null }, { current: null },
|
|
{ current: null }, { current: null }, { current: null }
|
|
],
|
|
floorBars: [
|
|
{ current: null }, { current: null }, { current: null },
|
|
{ current: null }, { current: null }
|
|
],
|
|
pieQuarters: [
|
|
{ current: null }, { current: null }, { current: null }, { current: null }
|
|
]
|
|
}), [])
|
|
|
|
return (
|
|
<div ref={containerRef} className={`dm-hiw-3d is-${pinState}`}>
|
|
{/* Pinned stage: canvas + HTML overlays. Stays fixed across the scroll. */}
|
|
<div className="dm-hiw-3d-stage">
|
|
<div
|
|
className="canvas-wrapper"
|
|
style={{
|
|
opacity: scrollProgress >= 0.92 ? 0.85 : 1.0,
|
|
transition: 'opacity 0.8s cubic-bezier(0.16, 1, 0.3, 1)',
|
|
}}
|
|
>
|
|
{mountScene && (
|
|
<Experience
|
|
truckRef={truckRef}
|
|
wheelRefs={wheelRefs}
|
|
dashboardRefs={dashboardRefs}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* In-experience section navigation */}
|
|
<Navbar />
|
|
|
|
{/* Story stage text panels (revealed at their scroll ranges) */}
|
|
<div className="sections-overlay-container">
|
|
<FirstMile active={scrollProgress >= 0.02 && scrollProgress < 0.14} />
|
|
<MidMile active={scrollProgress >= 0.38 && scrollProgress < 0.50} />
|
|
<LastMile active={scrollProgress >= 0.80 && scrollProgress < 0.92} />
|
|
<Analytics active={scrollProgress >= 0.94} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* GSAP scroll system: 900vh in-flow spacer that gives the section its
|
|
height, drives scroll progress, and reports pin state. */}
|
|
<ScrollRig dashboardRefs={dashboardRefs} onPinState={setPinState} />
|
|
</div>
|
|
)
|
|
}
|