"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 , 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 (
) } /** 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 ( ) } /** Lightweight poster shown when a live scene isn't appropriate/possible. */ function StaticFallback() { return (
{/* Optional poster image; if it 404s we keep the gradient + caption. */} Doormile delivery journey — first mile to last mile { e.currentTarget.style.display = 'none' }} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' }} />

From first mile to last mile, every delivery tracked.

) } 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
} if (useFallback) { return } return (
{mountScene && ( )}
{/* Branded loader while the GLB loads — no blank canvas. Mounts with the Canvas, fades out the moment the scene is ready. */} {mountScene &&
) }