fix how it work loading screen

This commit is contained in:
2026-06-10 12:27:57 +05:30
parent d56e710e28
commit 10d73b6d31
15 changed files with 12252 additions and 11968 deletions

View File

@@ -9,6 +9,7 @@ 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'
@@ -18,39 +19,98 @@ 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).
* Experience3D — the scroll-driven 3D logistics story embedded in the How It
* Works page.
*
* 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.
* 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() {
const firstActive = useSceneStore((s) => s.scrollProgress >= 0.02 && 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>
)
}
/** 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/hiw-3d-fallback.jpg"
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 scrollProgress = useSceneStore((state) => state.scrollProgress)
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')
// 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)
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(
@@ -64,104 +124,107 @@ export default function Experience3D() {
)
io.observe(el)
return () => io.disconnect()
}, [])
}, [liveScene])
// Refresh ScrollTrigger when the scene actually mounts. WebGL canvas mounting
// can block the main thread and shift elements, so a refresh here is critical.
// Refresh ScrollTrigger once the scene mounts (canvas creation can shift layout).
useEffect(() => {
if (mountScene) {
const timer = setTimeout(() => {
ScrollTrigger.refresh()
}, 150)
return () => clearTimeout(timer)
}
if (!mountScene) return
const timer = setTimeout(() => ScrollTrigger.refresh(), 150)
return () => clearTimeout(timer)
}, [mountScene])
// Own Lenis instance (global Lenis is gated off for this route).
// 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: true,
syncTouch: false, // never emulate touch inertia
})
setLenis(lenis)
lenis.on('scroll', ScrollTrigger.update)
// Drive Lenis using GSAP's ticker to ensure synchronization with ScrollTrigger
const tickerCb = (time) => lenis.raf(time * 1000)
gsap.ticker.add(tickerCb)
gsap.ticker.lagSmoothing(0)
let rafId
const raf = (time) => {
lenis.raf(time)
rafId = requestAnimationFrame(raf)
}
rafId = requestAnimationFrame(raf)
ScrollTrigger.refresh()
return () => {
gsap.ticker.remove(tickerCb)
cancelAnimationFrame(rafId)
lenis.destroy()
setLenis(null)
}
}, [setLenis])
}, [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: [] }),
[],
)
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 }
]
}), [])
// 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}`}>
{/* Pinned stage: canvas + HTML overlays. Stays fixed across the scroll. */}
<div className="dm-hiw-3d-stage">
<div
ref={canvasWrapperRef}
className="canvas-wrapper"
style={{
opacity: scrollProgress >= 0.92 ? 0.85 : 1.0,
transition: 'opacity 0.8s cubic-bezier(0.16, 1, 0.3, 1)',
}}
style={{ transition: 'opacity 0.8s cubic-bezier(0.16, 1, 0.3, 1)' }}
>
{mountScene && (
<Experience
truckRef={truckRef}
wheelRefs={wheelRefs}
dashboardRefs={dashboardRefs}
tier={tier}
/>
)}
</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.78 && scrollProgress < 0.875} />
{/* Final card: reveals as the journey closes (fills the slot the old
workflow timeline card used to occupy — no blank gap). */}
<Promise active={scrollProgress >= 0.90} />
</div>
<StorySections />
</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} />
<ScrollRig dashboardRefs={dashboardRefs} onPinState={setPinState} tier={tier} />
</div>
)
}