Files
doormile_react/src/modules/how-it-works-3d/Experience3D.jsx

279 lines
9.5 KiB
JavaScript

"use client";
import React, { useRef, useEffect, useState, useCallback } 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 Promise from './components/sections/Promise'
import { useSceneStore } from './store/useSceneStore'
import { useDeviceCaps } from './utils/deviceTier'
import './styles/experience.css'
import Lenis from 'lenis'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
gsap.registerPlugin(ScrollTrigger)
/**
* Experience3D — the scroll-driven 3D logistics story embedded in the How It
* Works page.
*
* PERF refactor (this pass):
* 1. Device tiering. `useDeviceCaps()` classifies desktop / tablet / mobile and
* a `fallback` flag. The tier flows into the Canvas (dpr/shadows/AA),
* Scene3D (LOD visibility), and ScrollRig (scroll length).
* 2. Static fallback. Reduced-motion / no-WebGL / very-low-memory devices get a
* poster instead of a live WebGL scene.
* 3. No per-frame React renders at this level. This component no longer
* subscribes to `scrollProgress`; the end-of-scroll canvas fade is applied
* imperatively, and the story overlays live in <StorySections>, which only
* re-renders when a section boolean flips.
* 4. Touch-aware smooth scroll. Lenis runs on desktop only (driven by a
* dedicated rAF, not gsap.ticker; syncTouch off). Touch devices use native
* momentum scrolling — emulated inertia on a heavy WebGL page is the main
* cause of mobile/tablet scroll lag.
*/
/**
* Story text panels. Isolated so its boolean subscriptions don't re-render the
* whole experience: each selector returns a boolean, so React only re-renders
* this small component when a section actually enters/leaves its range.
*/
function StorySections() {
// First Mile is active from the very top (progress 0) so its card is visible
// the instant the user enters the section — no scroll required.
const firstActive = useSceneStore((s) => s.scrollProgress < 0.14)
const midActive = useSceneStore((s) => s.scrollProgress >= 0.38 && s.scrollProgress < 0.50)
const lastActive = useSceneStore((s) => s.scrollProgress >= 0.78 && s.scrollProgress < 0.875)
const promiseActive = useSceneStore((s) => s.scrollProgress >= 0.90)
return (
<div className="sections-overlay-container">
<FirstMile active={firstActive} />
<MidMile active={midActive} />
<LastMile active={lastActive} />
<Promise active={promiseActive} />
</div>
)
}
/** Branded loading state shown over the stage while the GLB scene loads. Never
* a blank canvas — fades out once the scene signals ready. */
function BrandedLoader({ hidden }) {
return (
<div
className="dm-hiw-3d-loader"
aria-hidden={hidden}
style={{
position: 'absolute',
inset: 0,
zIndex: 50,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '18px',
background: 'linear-gradient(180deg, #f5f5f7 0%, #e9edf2 100%)',
opacity: hidden ? 0 : 1,
pointerEvents: hidden ? 'none' : 'auto',
transition: 'opacity 0.6s ease',
}}
>
<span
style={{
width: 30,
height: 30,
borderRadius: '50%',
border: '3px solid rgba(192,18,39,0.18)',
borderTopColor: '#c01227',
animation: 'dm-hiw-spin 0.8s linear infinite',
}}
/>
<span style={{ fontWeight: 600, letterSpacing: '0.01em', color: '#1f1f1f', fontSize: '0.95rem' }}>
Loading Doormile Experience
</span>
<style>{`@keyframes dm-hiw-spin{to{transform:rotate(360deg)}}`}</style>
</div>
)
}
/** Lightweight poster shown when a live scene isn't appropriate/possible. */
function StaticFallback() {
return (
<section
className="dm-hiw-3d-fallback"
style={{
position: 'relative',
minHeight: '70vh',
display: 'flex',
alignItems: 'flex-end',
background:
'linear-gradient(180deg, #eef1f5 0%, #dfe5ec 55%, #cfd7e0 100%)',
overflow: 'hidden',
}}
>
{/* Optional poster image; if it 404s we keep the gradient + caption. */}
<img
src="/images/home2-banner-1.webp"
alt="Doormile delivery journey — first mile to last mile"
loading="lazy"
decoding="async"
onError={(e) => {
e.currentTarget.style.display = 'none'
}}
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' }}
/>
<div style={{ position: 'relative', padding: '2rem clamp(1rem, 5vw, 4rem)', maxWidth: 720 }}>
<p style={{ fontWeight: 700, fontSize: 'clamp(1.25rem, 3vw, 2rem)', lineHeight: 1.2, margin: 0 }}>
From first mile to last mile, every delivery tracked.
</p>
</div>
</section>
)
}
export default function Experience3D() {
const setLenis = useSceneStore((state) => state.setLenis)
const caps = useDeviceCaps() // null until mounted on the client
const containerRef = useRef(null)
const canvasWrapperRef = useRef(null)
const [pinState, setPinState] = useState('before')
const [mountScene, setMountScene] = useState(false)
const [sceneReady, setSceneReady] = useState(false)
// Stable callback handed to the in-Canvas readiness signal.
const handleSceneReady = useCallback(() => setSceneReady(true), [])
const tier = caps?.tier ?? 'desktop'
const useFallback = caps?.fallback ?? false
const isTouch = caps?.isTouch ?? false
const liveScene = caps != null && !useFallback
// Defer mounting the WebGL Canvas until the section nears the viewport.
useEffect(() => {
if (!liveScene) return
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()
}, [liveScene])
// (ScrollTrigger refreshing is owned by ScrollRig now — it refreshes on the
// next frame, on every layout settle via ResizeObserver/fonts.ready, and again
// when `ready` flips true. No arbitrary timeouts.)
// Smooth scroll — DESKTOP ONLY. Touch devices keep native momentum (native is
// smoother than emulated inertia on a heavy WebGL page, and avoids the
// touch-scroll lag). Driven by a dedicated rAF rather than gsap.ticker.
useEffect(() => {
if (!liveScene || isTouch) return
const lenis = new Lenis({
duration: 1.2,
lerp: 0.08,
syncTouch: false, // never emulate touch inertia
})
setLenis(lenis)
lenis.on('scroll', ScrollTrigger.update)
let rafId
const raf = (time) => {
lenis.raf(time)
rafId = requestAnimationFrame(raf)
}
rafId = requestAnimationFrame(raf)
ScrollTrigger.refresh()
return () => {
cancelAnimationFrame(rafId)
lenis.destroy()
setLenis(null)
}
}, [liveScene, isTouch, setLenis])
// End-of-scroll canvas fade — applied imperatively so it never triggers a
// React render. Subscribes to the store but only touches the DOM on flip.
useEffect(() => {
if (!mountScene) return
const el = canvasWrapperRef.current
if (!el) return
let lastDim = null
const apply = (p) => {
const dim = p >= 0.92
if (dim !== lastDim) {
lastDim = dim
el.style.opacity = dim ? '0.85' : '1'
}
}
apply(useSceneStore.getState().scrollProgress)
const unsub = useSceneStore.subscribe((s) => apply(s.scrollProgress))
return unsub
}, [mountScene])
// 3D references shared between R3F and the GSAP scroll system.
const truckRef = useRef(null)
const wheelRefs = React.useMemo(
() => [{ current: null }, { current: null }, { current: null }, { current: null }],
[],
)
// Kept for API compatibility (Scene3D no longer wires these; dashboard refs
// were never attached in the generated model — the animation is a no-op).
const dashboardRefs = React.useMemo(
() => ({ bars: [], floorBars: [], pieQuarters: [] }),
[],
)
// Pre-mount (caps unknown) / fallback: render a reserved placeholder or poster.
if (caps == null) {
return <div ref={containerRef} className="dm-hiw-3d" style={{ minHeight: '100vh' }} aria-hidden />
}
if (useFallback) {
return <StaticFallback />
}
return (
<div ref={containerRef} className={`dm-hiw-3d is-${pinState}`}>
<div className="dm-hiw-3d-stage">
<div
ref={canvasWrapperRef}
className="canvas-wrapper"
style={{ transition: 'opacity 0.8s cubic-bezier(0.16, 1, 0.3, 1)' }}
>
{mountScene && (
<Experience
truckRef={truckRef}
wheelRefs={wheelRefs}
dashboardRefs={dashboardRefs}
tier={tier}
onReady={handleSceneReady}
/>
)}
</div>
{/* Branded loader while the GLB loads — no blank canvas. Mounts with the
Canvas, fades out the moment the scene is ready. */}
{mountScene && <BrandedLoader hidden={sceneReady} />}
<Navbar />
<StorySections />
</div>
<ScrollRig dashboardRefs={dashboardRefs} onPinState={setPinState} tier={tier} ready={sceneReady} />
</div>
)
}