279 lines
9.5 KiB
JavaScript
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>
|
|
)
|
|
}
|