Compare commits

...

2 Commits

Author SHA1 Message Date
0ef51540e9 update how it works 2026-06-08 22:21:42 +05:30
3d53f82e7b feat(how-it-works): integrate scroll-driven 3D experience
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>
2026-06-08 20:47:10 +05:30
40 changed files with 14067 additions and 124 deletions

View File

@@ -14,6 +14,9 @@ const eslintConfig = defineConfig([
"next-env.d.ts",
// Vendored third-party JS shipped to /public is not ours to lint.
"public/**",
// Ported 3D experience (incl. the ~11.6k-line gltfjsx-generated model) — kept
// as faithful .jsx/.js from the standalone app; not linted to ours rules.
"src/modules/how-it-works-3d/**",
]),
]);

View File

@@ -2,6 +2,14 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "export",
// Required by the How It Works 3D experience. React StrictMode double-invokes
// mount/effects in dev, which tears down and re-creates the WebGL context of
// the heavy 32MB scene mid-initialization — the context is lost ("THREE.
// WebGLRenderer: Context Lost") and the canvas stays blank. This is a known
// React-Three-Fiber + StrictMode incompatibility. Disabling it is a DEV-ONLY
// change (production never runs StrictMode's double-mount) and does not affect
// any other page's runtime behavior.
reactStrictMode: false,
images: {
unoptimized: true,
formats: ["image/avif", "image/webp"],

36
package-lock.json generated
View File

@@ -16,11 +16,13 @@
"gsap": "^3.15.0",
"leaflet": "^1.9.4",
"lenis": "^1.3.23",
"maath": "^0.10.8",
"next": "16.2.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-leaflet": "^5.0.0",
"three": "^0.171.0"
"three": "0.171.0",
"zustand": "^5.0.14"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -31,7 +33,7 @@
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/three": "^0.171.0",
"@types/three": "^0.184.0",
"eslint": "^9",
"eslint-config-next": "16.2.6",
"jest": "^30.4.2",
@@ -726,6 +728,12 @@
"node": ">=18"
}
},
"node_modules/@dimforge/rapier3d-compat": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
"license": "Apache-2.0"
},
"node_modules/@emailjs/browser": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/@emailjs/browser/-/browser-4.4.1.tgz",
@@ -3085,17 +3093,17 @@
"license": "MIT"
},
"node_modules/@types/three": {
"version": "0.171.0",
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.171.0.tgz",
"integrity": "sha512-oLuT1SAsT+CUg/wxUTFHo0K3NtJLnx9sJhZWQJp/0uXqFpzSk1hRHmvWvpaAWSfvx2db0lVKZ5/wV0I0isD2mQ==",
"version": "0.184.1",
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.1.tgz",
"integrity": "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==",
"license": "MIT",
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
"@types/stats.js": "*",
"@types/webxr": "*",
"@webgpu/types": "*",
"@types/webxr": ">=0.5.17",
"fflate": "~0.8.2",
"meshoptimizer": "~0.18.1"
"meshoptimizer": "~1.1.1"
}
},
"node_modules/@types/tough-cookie": {
@@ -3761,12 +3769,6 @@
"react": ">= 16.8.0"
}
},
"node_modules/@webgpu/types": {
"version": "0.1.70",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.70.tgz",
"integrity": "sha512-LFiNHHKMvmAEvwVew3JLJmTdShhbdwRFSImUshGhE2mGE8ybQzIo63l5uRp+YKnNx+8Qno8Kf6gN+DKMreIJCA==",
"license": "BSD-3-Clause"
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -8754,9 +8756,9 @@
}
},
"node_modules/meshoptimizer": {
"version": "0.18.1",
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz",
"integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
"integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
"license": "MIT"
},
"node_modules/micromatch": {

View File

@@ -21,11 +21,13 @@
"gsap": "^3.15.0",
"leaflet": "^1.9.4",
"lenis": "^1.3.23",
"maath": "^0.10.8",
"next": "16.2.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-leaflet": "^5.0.0",
"three": "^0.171.0"
"three": "0.171.0",
"zustand": "^5.0.14"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -36,7 +38,7 @@
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/three": "^0.171.0",
"@types/three": "^0.184.0",
"eslint": "^9",
"eslint-config-next": "16.2.6",
"jest": "^30.4.2",

Binary file not shown.

View File

@@ -27,7 +27,10 @@ import Lenis from "lenis";
* Re-evaluates on every route change: the effect cleanup destroys the previous
* instance and re-inits on the next route.
*/
const DISABLED_ROUTES: string[] = [];
// /how-it-works runs its own tuned Lenis inside the embedded 3D experience
// (src/modules/how-it-works-3d); the global instance is gated off there so two
// Lenis instances don't fight over the same document scroll.
const DISABLED_ROUTES: string[] = ["/how-it-works"];
export default function SmoothScroll() {
const pathname = usePathname();

View File

@@ -1,8 +1,6 @@
import React from "react";
import HowItWorksHero from "../../components/sections/HowItWorksHero";
import Miles3 from "../../components/sections/Miles3";
import WhyChooseDoormile from "../../components/sections/WhyChooseDoormile";
import TheDoormileWay from "../../components/sections/TheDoormileWay";
import Experience3DLoader from "@/modules/how-it-works-3d/Experience3DLoader";
export const metadata = {
title: "How It Works Doormile",
@@ -16,9 +14,10 @@ export default function HowItWorksPage() {
<div className="content-inner">
<div data-elementor-type="wp-page" data-elementor-id="59" className="elementor elementor-59">
<HowItWorksHero />
<Miles3 />
<WhyChooseDoormile />
<TheDoormileWay />
{/* The first/mid/last-mile story is now told by the scroll-driven 3D
experience, which replaces the former Miles3 / WhyChooseDoormile /
TheDoormileWay content sections on this page. */}
<Experience3DLoader />
</div>
</div>
</div>

View File

@@ -19,7 +19,9 @@ export default function HowItWorksHero() {
return (
<>
<style dangerouslySetInnerHTML={{ __html: `
<style
dangerouslySetInnerHTML={{
__html: `
.howits-hero-custom-bg.elementor-repeater-item-3264830,
.howits-hero-custom-bg.elementor-repeater-item-6867061 {
background-image: linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0.1)), url('/images/home1-slide-1.png') !important;
@@ -219,19 +221,44 @@ export default function HowItWorksHero() {
display: none !important;
}
}
`}} />
`,
}}
/>
<div className="elementor-element elementor-element-741f56c e-con-full e-flex cut-corner-no sticky-container-off e-con e-parent" data-id="741f56c" data-element_type="container" data-e-type="container">
<div className="elementor-element elementor-element-6c7cbcb elementor-widget elementor-widget-logico_content_slider" data-id="6c7cbcb" data-element_type="widget" data-e-type="widget" data-widget_type="logico_content_slider.default">
<div
className="elementor-element elementor-element-741f56c e-con-full e-flex cut-corner-no sticky-container-off e-con e-parent"
data-id="741f56c"
data-element_type="container"
data-e-type="container"
>
<div
className="elementor-element elementor-element-6c7cbcb elementor-widget elementor-widget-logico_content_slider"
data-id="6c7cbcb"
data-element_type="widget"
data-e-type="widget"
data-widget_type="logico_content_slider.default"
>
<div className="elementor-widget-container">
<div className="logico-content-slider-widget">
<div className="content-slider-wrapper">
<div className="content-slider-container">
<div className="content-slider owl-carousel owl-theme nav-view-vertical nav-h-position-right nav-v-position-bottom owl-loaded owl-drag">
<div className="owl-stage-outer" style={{ position: "relative", overflow: "hidden", height: "800px" }}>
<div className="owl-stage" style={{ position: "relative", width: "100%", height: "100%" }}>
<div
className="owl-stage-outer"
style={{
position: "relative",
overflow: "hidden",
height: "800px",
}}
>
<div
className="owl-stage"
style={{
position: "relative",
width: "100%",
height: "100%",
}}
>
{/* Slide 1 */}
<div
className={`owl-item ${activeSlide === 0 ? "active" : ""}`}
@@ -239,20 +266,30 @@ export default function HowItWorksHero() {
position: "relative",
width: "100%",
opacity: activeSlide === 0 ? 1 : 0,
visibility: activeSlide === 0 ? "visible" : "hidden",
transition: "opacity 0.8s ease-in-out, visibility 0.8s ease-in-out",
zIndex: activeSlide === 0 ? 2 : 1
visibility:
activeSlide === 0 ? "visible" : "hidden",
transition:
"opacity 0.8s ease-in-out, visibility 0.8s ease-in-out",
zIndex: activeSlide === 0 ? 2 : 1,
}}
>
<div className="content-item slider-item elementor-repeater-item-3264830 slide-style-standard howits-hero-custom-bg">
<div className="slide-content">
<div className="slide-content-inner">
<h1 className="content-slider-item-heading logico-content-wrapper-1">
<span className="heading-content">One Journey. Complete<br />Control.</span>
<span className="heading-content">
One Journey. Complete
<br />
Control.
</span>
</h1>
<div className="content-slider-item-text logico-content-wrapper-2">
<div className="text-content">
<p>See how Doormile connects first, mid, and last mile into a seamless delivery experience powered by MileTruth AI.</p>
<p>
See how Doormile connects first, mid, and
last mile into a seamless delivery
experience powered by MileTruth AI.
</p>
</div>
</div>
</div>
@@ -269,27 +306,36 @@ export default function HowItWorksHero() {
left: 0,
width: "100%",
opacity: activeSlide === 1 ? 1 : 0,
visibility: activeSlide === 1 ? "visible" : "hidden",
transition: "opacity 0.8s ease-in-out, visibility 0.8s ease-in-out",
zIndex: activeSlide === 1 ? 2 : 1
visibility:
activeSlide === 1 ? "visible" : "hidden",
transition:
"opacity 0.8s ease-in-out, visibility 0.8s ease-in-out",
zIndex: activeSlide === 1 ? 2 : 1,
}}
>
<div className="content-item slider-item elementor-repeater-item-6867061 slide-style-standard howits-hero-custom-bg">
<div className="slide-content">
<div className="slide-content-inner">
<h1 className="content-slider-item-heading logico-content-wrapper-1">
<span className="heading-content">A New Freight<br />Experience</span>
<span className="heading-content">
A New Logisitics
<br />
Experience
</span>
</h1>
<div className="content-slider-item-text logico-content-wrapper-2">
<div className="text-content">
<p>See how Doormile connects first, mid, and last mile into a seamless delivery experience powered by MileTruth AI.</p>
<p>
See how Doormile connects first, mid, and
last mile into a seamless delivery
experience powered by MileTruth AI.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -298,42 +344,98 @@ export default function HowItWorksHero() {
<button
type="button"
className="owl-next"
onClick={() => setActiveSlide((prev) => (prev === 0 ? 1 : 0))}
onClick={() =>
setActiveSlide((prev) => (prev === 0 ? 1 : 0))
}
aria-label="Next"
style={{ cursor: "pointer", border: "none", outline: "none" }}
style={{
cursor: "pointer",
border: "none",
outline: "none",
}}
/>
<button
type="button"
className="owl-prev"
onClick={() => setActiveSlide((prev) => (prev === 0 ? 1 : 0))}
onClick={() =>
setActiveSlide((prev) => (prev === 0 ? 1 : 0))
}
aria-label="Previous"
style={{ cursor: "pointer", border: "none", outline: "none" }}
style={{
cursor: "pointer",
border: "none",
outline: "none",
}}
/>
</div>
{/* Progress indicators */}
<div className="slider-footer slider-footer-position-after slider-footer-width-full slider-footer-view-inside">
<div className="slider-footer-content">
<div className="slider-pagination" style={{ display: "flex", justifyContent: "flex-end", alignItems: "center", gap: "10px" }}>
<div className="slider-progress-wrapper" style={{ marginRight: "35px", display: "flex", flexDirection: "column", alignItems: "flex-start" }}>
<div style={{ fontSize: "16px", fontWeight: 600, color: "#FFFFFF", marginBottom: "4px" }}>
<span className="slider-progress-current">{activeSlide === 0 ? "01" : "02"}</span>
<div
className="slider-pagination"
style={{
display: "flex",
justifyContent: "flex-end",
alignItems: "center",
gap: "10px",
}}
>
<div
className="slider-progress-wrapper"
style={{
marginRight: "35px",
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
}}
>
<div
style={{
fontSize: "16px",
fontWeight: 600,
color: "#FFFFFF",
marginBottom: "4px",
}}
>
<span className="slider-progress-current">
{activeSlide === 0 ? "01" : "02"}
</span>
{" / "}
<span className="slider-progress-all" style={{ opacity: 0.6 }}>02</span>
<span
className="slider-progress-all"
style={{ opacity: 0.6 }}
>
02
</span>
</div>
{/* Progress line — red bar slides to match the active slide (mirrors the home hero) */}
<div style={{ width: "80px", height: "2px", background: "rgba(255, 255, 255, 0.2)", position: "relative", borderRadius: "1px", overflow: "hidden" }}>
<div style={{
position: "absolute",
left: activeSlide === 0 ? "0" : "50%",
width: "50%",
height: "100%",
background: "#c01227",
transition: "left 0.3s ease"
}} />
<div
style={{
width: "80px",
height: "2px",
background: "rgba(255, 255, 255, 0.2)",
position: "relative",
borderRadius: "1px",
overflow: "hidden",
}}
>
<div
style={{
position: "absolute",
left: activeSlide === 0 ? "0" : "50%",
width: "50%",
height: "100%",
background: "#c01227",
transition: "left 0.3s ease",
}}
/>
</div>
</div>
<div className="owl-dots owl-dots-6c7cbcb" style={{ display: "none" }}>
<div
className="owl-dots owl-dots-6c7cbcb"
style={{ display: "none" }}
>
<button
type="button"
role="button"
@@ -354,7 +456,6 @@ export default function HowItWorksHero() {
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -2,35 +2,57 @@ import React from "react";
import Image from "next/image";
import { ScrollReveal } from "../../animations/Reveal";
type Stat = {
value: string;
label: string;
};
type Stage = {
img: string;
label: string;
title: string;
subtitle?: string;
desc: string;
points: string[];
stats?: Stat[];
};
const STAGES: Stage[] = [
{
img: "/images/first-mile-approach.jpg",
label: "01 / First Mile",
title: "Origin to Hub",
desc: "We collect freight directly from your facility, optimise vehicle assignment in real time, and consolidate loads for maximum efficiency before they reach the hub.",
label: "Stage 01",
title: "First Mile Warehouse",
subtitle: "Consolidation & Prep",
desc: "Incoming shipments are securely loaded, checked, and queued for transfer in our high-capacity fulfillment centers.",
points: ["AI-scheduled pickups", "Dynamic load consolidation", "Yard & dock management"],
stats: [
{ value: "14,250", label: "Parcels Processed" },
{ value: "99.98%", label: "Sorting Accuracy" }
]
},
{
img: "/images/mid-mile-approach.jpg",
label: "02 / Mid Mile",
title: "Hub to Hub Transit",
desc: "Freight moves between hubs on optimised line-haul routes. Real-time tracking, cross-docking, and SLA monitoring keep every shipment on schedule.",
label: "Stage 02",
title: "Mid Mile Transit",
subtitle: "Hub-to-Hub Transport",
desc: "Freight is routed dynamically through our network of strategically located hubs using automated sortation and linehaul scheduling.",
points: ["Optimised line-haul routing", "Cross-docking & sortation", "Live SLA monitoring"],
stats: [
{ value: "1,240+", label: "Daily Line-Hauls" },
{ value: "98.5%", label: "SLA Adherence" }
]
},
{
img: "/images/last-mile-approach.jpg",
label: "03 / Last Mile",
title: "Hub to Doorstep",
desc: "The final and most complex phase. We optimise multi-stop routes, deliver within precise windows, and capture digital proof of delivery at every door.",
label: "Stage 03",
title: "Last Mile Delivery",
subtitle: "Hub to Doorstep",
desc: "The final handoff. Our routing engine optimizes multi-stop itineraries to deliver parcels directly to customers' doorsteps in record time.",
points: ["Multi-stop route optimisation", "Precise delivery windows", "Digital proof of delivery"],
stats: [
{ value: "450K+", label: "Happy Deliveries" },
{ value: "2.8 Hours", label: "Average Turnaround" }
]
},
];
@@ -40,18 +62,12 @@ export default function WhyChooseDoormile() {
<style dangerouslySetInnerHTML={{ __html: `
/* =====================================================================
"Why Businesses Choose Doormile" — First / Mid / Last Mile stage cards.
Dark rounded card on the white page (consistent with the Miles3
section), each stage shown with a photo, numbered red label, title,
description and a red-checkmark feature list.
Card titles are <h3>; theme-core forces a dark color on bare headings
(.logico-front-end h3:not([class*=logico-title-h]) @ (0,2,1)), so the
white title rule is prefixed to outrank it.
===================================================================== */
.wcd-section {
display: flex;
flex-direction: column;
width: auto;
margin: -250px 20px 20px 20px; /* Snug pull-up overlap to touch Miles3 columns without covering their text! */
margin: -250px 20px 20px 20px;
background-color: #1F1F1F;
border-radius: 0 0 25px 25px;
padding: 50px 0 110px;
@@ -63,7 +79,6 @@ export default function WhyChooseDoormile() {
padding: 0 50px;
box-sizing: border-box;
}
/* Centered header block (eyebrow + heading) with a faint map backdrop */
.wcd-head {
position: relative;
text-align: center;
@@ -78,16 +93,16 @@ export default function WhyChooseDoormile() {
content: "";
position: absolute;
left: 50%;
top: 75%; /* Shifted down to the bottom of the header block to overlap the top of the cards */
top: 75%;
transform: translateX(-50%);
width: min(1180px, 95%);
aspect-ratio: 2 / 1;
background: url('/images/bg-map.png') center / contain no-repeat;
opacity: 0.06; /* Elegant faint visibility */
filter: invert(1); /* Invert dark map dots to white/light-gray to make them visible on the #1F1F1F background */
opacity: 0.06;
filter: invert(1);
z-index: 0;
pointer-events: none;
animation: wcd-float 20s ease-in-out infinite; /* Premium floating map animation */
animation: wcd-float 20s ease-in-out infinite;
}
.wcd-card-wrapper {
display: flex;
@@ -127,18 +142,23 @@ export default function WhyChooseDoormile() {
width: 100%;
}
/* Premium Glassmorphism & Card Interaction */
.wcd-card {
display: flex;
flex-direction: column;
background: rgba(255, 255, 255, 0.02);
background: linear-gradient(135deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.01) 100%);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 20px;
overflow: hidden;
transition: border-color 0.4s ease, box-shadow 0.4s ease, transform 0.4s cubic-bezier(0.165, 0.84, 0.44, 1);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
transition: border-color 0.4s cubic-bezier(0.165, 0.84, 0.44, 1),
box-shadow 0.4s cubic-bezier(0.165, 0.84, 0.44, 1),
transform 0.4s cubic-bezier(0.165, 0.84, 0.44, 1);
}
.wcd-card:hover {
border-color: #c01227 !important;
box-shadow: 0 10px 30px rgba(192, 18, 39, 0.25) !important;
box-shadow: 0 20px 40px rgba(192, 18, 39, 0.15), inset 0 0 20px rgba(255, 255, 255, 0.02) !important;
transform: translateY(-8px);
}
.wcd-card-media {
@@ -152,75 +172,106 @@ export default function WhyChooseDoormile() {
transition: transform 0.6s cubic-bezier(0.165, 0.84, 0.44, 1);
}
.wcd-card:hover .wcd-card-media img {
transform: scale(1.06);
transform: scale(1.05);
}
/* Body Data Container (Unified Div) */
.wcd-card-body {
display: flex;
flex-direction: column;
flex: 1;
padding: 40px;
}
/* Modern Pill Badge for Label */
.wcd-card-label {
display: inline-flex;
align-items: center;
align-self: flex-start;
padding: 6px 14px;
background: rgba(192, 18, 39, 0.08);
border: 1px solid rgba(192, 18, 39, 0.25);
border-radius: 100px;
font-family: var(--font-manrope), "Manrope", sans-serif;
font-size: 14px;
font-size: 11px;
font-weight: 700;
letter-spacing: 1px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #c01227;
color: #ff3344;
margin: 0 0 20px;
transition: all 0.3s ease;
}
.wcd-card:hover .wcd-card-label {
background: rgba(192, 18, 39, 0.18);
border-color: #c01227;
box-shadow: 0 0 10px rgba(192, 18, 39, 0.15);
}
.logico-front-end .wcd-section h3.wcd-card-title {
font-family: var(--font-manrope), "Manrope", sans-serif;
font-size: 32px;
font-size: 26px;
font-weight: 700;
line-height: 1.1;
line-height: 1.2;
letter-spacing: -0.02em;
text-transform: uppercase;
color: #FFFFFF;
-webkit-text-fill-color: #FFFFFF;
margin: 0 0 22px;
margin: 0 0 4px;
}
.wcd-card-subtitle {
font-family: var(--font-manrope), "Manrope", sans-serif;
font-size: 13px;
font-weight: 600;
color: rgba(255, 255, 255, 0.55);
margin: 0 0 20px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.wcd-card-divider {
height: 1px;
background: linear-gradient(90deg, rgba(255, 255, 255, 0.12) 0%, rgba(255, 255, 255, 0.02) 100%);
margin: 0 0 20px;
width: 100%;
}
.wcd-card-desc {
font-size: 17px;
font-size: 15px;
font-weight: 400;
line-height: 1.6;
color: rgba(255, 255, 255, 0.6);
margin: 0 0 34px;
color: rgba(255, 255, 255, 0.65);
margin: 0 0 24px;
}
.wcd-card-points {
list-style: none;
margin: auto 0 0;
margin: 0 0 30px;
padding: 0;
display: flex;
flex-direction: column;
gap: 16px;
gap: 12px;
}
.wcd-section .wcd-card-points li {
/* Flex row so the check icon and its label always sit on the same line.
Scoped with .wcd-section to outrank the global ".logico-front-end ul li"
theme rule, which adds 1.7em padding + a fontello bullet icon. */
display: flex;
align-items: flex-start;
gap: 12px;
padding-left: 0;
font-size: 16px;
font-weight: 700;
font-size: 15px;
font-weight: 600;
line-height: 1.3;
color: #FFFFFF;
}
/* Suppress the theme's default fontello list bullet
(.logico-front-end ul li:before) so only our circle-check SVG renders. */
.wcd-section .wcd-card-points li::before {
content: none;
display: none;
}
/* Clean circle-check feature icon (inline SVG, see markup below) — replaces
the old border-based chevron. Brand red with thin, rounded strokes. */
.wcd-card-points .wcd-check {
flex: 0 0 auto;
width: 18px;
height: 18px;
margin-top: 0.12em;
margin-top: 0.08em;
color: #c01227;
transition: transform 0.3s ease;
}
@@ -228,16 +279,47 @@ export default function WhyChooseDoormile() {
transform: scale(1.1);
}
/* Integrated Stats Section */
.wcd-card-stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-top: auto;
padding-top: 24px;
border-top: 1px dashed rgba(255, 255, 255, 0.12);
}
.wcd-stat-item {
display: flex;
flex-direction: column;
}
.wcd-stat-value {
font-family: var(--font-manrope), "Manrope", sans-serif;
font-size: 24px;
font-weight: 800;
color: #FFFFFF;
line-height: 1.1;
letter-spacing: -0.02em;
transition: color 0.3s ease;
}
.wcd-card:hover .wcd-stat-value {
color: #ff3344;
}
.wcd-stat-label {
font-size: 11px;
font-weight: 600;
color: rgba(255, 255, 255, 0.45);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-top: 4px;
line-height: 1.3;
}
@media (max-width: 1020px) {
/* No pull-up overlap on mobile/tablet: the Miles3 cards stack into a
12 col layout, so a negative margin-top covers the last card's
text. Both sections share #1F1F1F + equal side margins, so butting
them at margin-top:0 keeps the seamless dark panel. */
.wcd-section { margin: 0 15px 15px 15px; padding: 40px 0 80px; }
.wcd-inner { padding: 0 30px; }
.wcd-grid { grid-template-columns: 1fr; gap: 24px; }
.wcd-card-body { padding: 32px; }
.logico-front-end .wcd-section h3.wcd-card-title { font-size: 28px; }
.logico-front-end .wcd-section h3.wcd-card-title { font-size: 24px; }
}
@media (max-width: 480px) {
.wcd-section { margin: 0 12px 12px 12px; border-radius: 0 0 20px 20px; padding: 30px 0 64px; }
@@ -273,10 +355,20 @@ export default function WhyChooseDoormile() {
sizes="(max-width: 1020px) 100vw, 33vw"
/>
</div>
{/* Single Parent Div for All Data Content */}
<div className="wcd-card-body">
<div className="wcd-card-label">{stage.label}</div>
<h3 className="wcd-card-title">{stage.title}</h3>
{stage.subtitle && (
<div className="wcd-card-subtitle">{stage.subtitle}</div>
)}
<div className="wcd-card-divider" />
<p className="wcd-card-desc">{stage.desc}</p>
<ul className="wcd-card-points">
{stage.points.map((point) => (
<li key={point}>
@@ -297,6 +389,18 @@ export default function WhyChooseDoormile() {
</li>
))}
</ul>
{/* Integrated Statistics Grid */}
{stage.stats && stage.stats.length > 0 && (
<div className="wcd-card-stats">
{stage.stats.map((stat) => (
<div key={stat.label} className="wcd-stat-item">
<span className="wcd-stat-value">{stat.value}</span>
<span className="wcd-stat-label">{stat.label}</span>
</div>
))}
</div>
)}
</div>
</article>
</ScrollReveal>

View File

@@ -0,0 +1,158 @@
"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>
)
}

View File

@@ -0,0 +1,20 @@
"use client";
import dynamic from "next/dynamic";
/**
* Client-only loader for the 3D How It Works experience.
*
* `ssr: false` is required: the experience uses React Three Fiber, a Zustand
* store, Lenis, and `window`/`AudioContext` — all client-only. The 100vh
* placeholder reserves space so the page doesn't jump while the (large) GLB
* scene and WebGL bundle load.
*/
const Experience3D = dynamic(() => import("./Experience3D"), {
ssr: false,
loading: () => <div style={{ minHeight: "100vh" }} aria-hidden />,
});
export default function Experience3DLoader() {
return <Experience3D />;
}

View File

@@ -0,0 +1,21 @@
import gsap from 'gsap'
// Optional GSAP timeline utility to animate custom camera effects (like micro-shake)
export const playCameraTransition = (camera, target, duration = 1.0) => {
if (!camera) return
const tl = gsap.timeline()
// Add a subtle drift to camera position to make it feel organic and premium
tl.to(camera.position, {
x: '+=0.3',
y: '+=0.1',
z: '-=0.2',
duration: duration,
yoyo: true,
repeat: 1,
ease: 'power1.inOut',
})
return tl
}

View File

@@ -0,0 +1,24 @@
import { clamp } from '../utils/helpers'
export const animateDashboard = (bars, pieQuarters, progress) => {
// progress is 0 at scrollProgress = 0.75, and 1 at scrollProgress = 1.0
// Scale bar charts on their Y axis with a staggered effect
bars.forEach((barRef, index) => {
if (barRef.current) {
const delay = index * 0.08
const scaleY = clamp((progress - delay) / 0.5, 0, 1)
// Interpolate scale Y
barRef.current.scale.y = scaleY
}
})
// Rotate pie chart quarters around their local Y axis
pieQuarters.forEach((quarterRef, index) => {
if (quarterRef.current) {
// Rotate based on progress (offset each slice slightly for dynamic feeling)
const rotationSpeed = 2 + index * 0.5
quarterRef.current.rotation.y = -0.709 + progress * Math.PI * 2 * rotationSpeed
}
})
}

View File

@@ -0,0 +1,18 @@
import gsap from 'gsap'
// Play a subtle engine idle vibration when the truck is active
export const playTruckEngineVibration = (truckGroup, isActive = true) => {
if (!truckGroup) return null
if (isActive) {
return gsap.to(truckGroup.position, {
y: '+=0.015',
duration: 0.08,
yoyo: true,
repeat: -1,
ease: 'sine.inOut',
})
}
return null
}

View File

@@ -0,0 +1,14 @@
export const animateWheels = (wheelRefs, rotation) => {
if (!wheelRefs || wheelRefs.length === 0) return
wheelRefs.forEach((wheelRef, index) => {
if (wheelRef.current) {
// Y-axis is the axle for these wheel meshes.
// Odd indices (1, 3) are left side wheels; even indices (0, 2) are right side wheels.
// Since left-side wheel groups are rotated 180 degrees in GLTF to face outward,
// we invert the spin direction for one side so they all roll forward together.
const direction = (index % 2 === 0) ? 1 : -1
wheelRef.current.rotation.y = rotation * direction
}
})
}

View File

@@ -0,0 +1,48 @@
import React, { useRef } from 'react'
import { useFrame } from '@react-three/fiber'
import * as THREE from 'three'
import { useSceneStore } from '../store/useSceneStore'
import { useCameraAnimation } from '../hooks/useCameraAnimation'
import { easing } from 'maath'
export default function CameraRig() {
const scrollProgress = useSceneStore((state) => state.scrollProgress)
const { position: targetPosition, target: lookAtTarget } = useCameraAnimation(scrollProgress)
// Track the current focus point of the camera in a ref so we can interpolate it smoothly
const currentLookAt = useRef(new THREE.Vector3(19.7, 4.4, -31.08))
useFrame((state, delta) => {
const { camera } = state
// maath's easing.damp3 divides by delta internally; a delta of 0 (coincident
// or first frames) yields NaN that poisons the damper and would push the
// camera to NaN — blanking the whole scene. Clamp delta to a safe range.
const dt = Number.isFinite(delta) && delta > 0 ? Math.min(delta, 0.1) : 1 / 60
// Smoothly damp the camera position towards the target position
easing.damp3(camera.position, targetPosition, 0.35, dt)
// Smoothly damp the camera focus target (lookAt)
easing.damp3(currentLookAt.current, lookAtTarget, 0.25, dt)
// Defensive recovery: if anything upstream produced a non-finite value, snap
// back to the target so the camera never gets stuck at NaN (black screen).
if (!Number.isFinite(camera.position.x)) camera.position.copy(targetPosition)
if (!Number.isFinite(currentLookAt.current.x)) currentLookAt.current.copy(lookAtTarget)
// Apply lookAt orientation using the interpolated target vector
camera.lookAt(currentLookAt.current)
// Responsive aspect ratio adjustments: increase FOV on portrait screens to zoom out and keep truck & buildings in frame
const aspect = state.size.width / state.size.height
if (aspect < 1.0) {
camera.fov = Math.min(75, 45 / Math.sqrt(aspect))
} else {
camera.fov = 45
}
camera.updateProjectionMatrix()
})
return null
}

View File

@@ -0,0 +1,143 @@
import React, { useRef, useEffect } from 'react'
import { Canvas, useFrame } from '@react-three/fiber'
import { Environment, SoftShadows } from '@react-three/drei'
import * as THREE from 'three'
import { Model as SceneModel } from '../models/Scene3D'
import CameraRig from './CameraRig'
import TruckAnimation from './TruckAnimation'
import StreetLights from './StreetLights'
import { useSceneStore } from '../store/useSceneStore'
const dayBgColor = new THREE.Color('#f5f5f7')
const nightBgColor = new THREE.Color('#010103') // Pitch black sky with a tiny touch of midnight slate
const dayAmbientColor = new THREE.Color('#ffffff')
const nightAmbientColor = new THREE.Color('#000000') // Pitch black ambient
const dayDirColor = new THREE.Color('#ffffff')
const nightDirColor = new THREE.Color('#000000') // Pitch black sun/moon directional light
const tempColor = new THREE.Color()
// Dynamic lighting rig that centers the shadow frustum on the moving truck
const SceneLighting = React.memo(function SceneLighting({ truckRef }) {
const dirLightRef = useRef()
const ambientLightRef = useRef()
const targetRef = useRef()
useEffect(() => {
if (dirLightRef.current && targetRef.current) {
dirLightRef.current.target = targetRef.current
}
}, [])
useFrame((state) => {
// 1. Center shadows on the truck
if (dirLightRef.current && targetRef.current && truckRef.current) {
const truckPos = new THREE.Vector3()
truckRef.current.getWorldPosition(truckPos)
targetRef.current.position.copy(truckPos)
targetRef.current.updateMatrixWorld()
dirLightRef.current.position.set(truckPos.x + 10, truckPos.y + 20, truckPos.z + 10)
}
// 2. Day-to-Night transition calculations (disabled: keeping day view throughout the scroll)
const nightFactor = 0
// 3. Mutate scene background color & environment intensity
if (state.scene) {
state.scene.background = tempColor.lerpColors(dayBgColor, nightBgColor, nightFactor)
state.scene.environmentIntensity = 1.0 - nightFactor * 1.0 // Fades completely to 0.0
}
// 4. Update lights properties
if (ambientLightRef.current) {
ambientLightRef.current.intensity = 0.45 - nightFactor * 0.45 // Fades completely to 0.0
ambientLightRef.current.color.lerpColors(dayAmbientColor, nightAmbientColor, nightFactor)
}
if (dirLightRef.current) {
dirLightRef.current.intensity = 1.5 - nightFactor * 1.5 // Fades completely to 0.0
dirLightRef.current.color.lerpColors(dayDirColor, nightDirColor, nightFactor)
}
})
return (
<group>
<ambientLight ref={ambientLightRef} intensity={0.45} />
<directionalLight
ref={dirLightRef}
castShadow
position={[10, 20, 10]}
intensity={1.5}
shadow-mapSize-width={2048}
shadow-mapSize-height={2048}
shadow-camera-far={100}
shadow-camera-left={-35}
shadow-camera-right={35}
shadow-camera-top={35}
shadow-camera-bottom={-35}
shadow-bias={-0.0001}
/>
<object3D ref={targetRef} />
</group>
)
})
export default React.memo(function Experience({ dashboardRefs, wheelRefs, truckRef }) {
return (
<div style={{ width: '100%', height: '100%', position: 'absolute', top: 0, left: 0 }}>
<Canvas
shadows
// Cap the device-pixel-ratio: uncapped, a retina display renders this
// heavy 32MB scene into a 2x (or 3x) framebuffer, multiplying GPU memory
// and risking WebGL context loss. [1, 1.5] keeps it crisp but bounded
// matching the dpr caps the site's other R3F canvases use.
dpr={[1, 1.5]}
camera={{ position: [32, 12, -18], fov: 45 }}
gl={{ antialias: true, powerPreference: 'high-performance' }}
>
<color attach="background" args={['#f5f5f7']} />
{/* Soft shadows */}
<SoftShadows size={10} samples={12} focus={1.0} />
{/* Dynamic ambient and shadow-tracking directional lights */}
<SceneLighting truckRef={truckRef} />
{/* Focused street lights along the road */}
<StreetLights />
{/* Environment preset */}
<Environment preset="city" />
{/* Main 3D logistics scene model */}
<SceneModel
dashboardRefs={dashboardRefs}
truckRef={truckRef}
wheelRefs={wheelRefs}
/>
{/* Delivery truck model animation controller */}
<TruckAnimation truckRef={truckRef} wheelRefs={wheelRefs} />
{/* Dynamic camera rig with damping and target interpolation */}
<CameraRig />
{/* Post-processing (EffectComposer/Bloom/Vignette) intentionally omitted.
@react-three/postprocessing's EffectComposer reads
`renderer.getContextAttributes().alpha` while initializing its buffers;
under Next dev's React StrictMode the canvas's WebGL context is torn
down and re-created, so that read hits a null context and throws
"Cannot read properties of null (reading 'alpha')", crashing the whole
scene. Dropping the composer renders the scene directly (lighting +
shadows + environment carry the look). To re-add Bloom later, set
`reactStrictMode: false` in next.config.ts and restore a Bloom-only
composer. */}
</Canvas>
</div>
)
})

View File

@@ -0,0 +1,103 @@
import React, { useEffect, useRef } from 'react'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
import { useSceneStore } from '../store/useSceneStore'
import { animateDashboard } from '../animations/dashboardAnimation'
import { playRevealChime } from '../utils/audioHelper'
gsap.registerPlugin(ScrollTrigger)
export default function ScrollRig({ dashboardRefs, onPinState }) {
const setScrollProgress = useSceneStore((state) => state.setScrollProgress)
const setActiveSection = useSceneStore((state) => state.setActiveSection)
const lenis = useSceneStore((state) => state.lenis)
const containerRef = useRef(null)
const activeSectionRef = useRef(0)
const pinStateRef = useRef('before')
useEffect(() => {
const element = containerRef.current
if (!element) return
// Create the ScrollTrigger to track the scrolling progress of the 900vh height container
const trigger = ScrollTrigger.create({
trigger: element,
start: 'top top',
end: 'bottom bottom',
scrub: 2.5, // Even slower, weightier scroll follow for premium feel
invalidateOnRefresh: true,
onUpdate: (self) => {
const progress = self.progress
setScrollProgress(progress)
// Report pin state so the parent toggles the stage between
// absolute(top) → fixed → absolute(bottom). Mirrors StrategySection.
const ns = progress <= 0.0002 ? 'before' : progress >= 0.9998 ? 'after' : 'pinned'
if (ns !== pinStateRef.current) {
pinStateRef.current = ns
onPinState?.(ns)
}
// Determine the active stage section
// Section 0 (First Mile): 0% to 12%
// Section 1 (Mid Mile): 12% to 50%
// Section 2 (Last Mile): 50% to 76%
// Section 3 (Analytics): 76% to 100%
let section = 0
if (progress >= 0.92) {
section = 3
} else if (progress >= 0.50) {
section = 2
} else if (progress >= 0.12) {
section = 1
}
if (section !== activeSectionRef.current) {
playRevealChime()
activeSectionRef.current = section
}
setActiveSection(section)
// Trigger dashboard animations inside R3F when entering the analytics stage (progress >= 0.92)
if (dashboardRefs) {
if (progress >= 0.92) {
const dashboardProgress = (progress - 0.92) / 0.08
animateDashboard(
dashboardRefs.bars || [],
dashboardRefs.pieQuarters || [],
dashboardProgress
)
} else {
// Keep reset when out of analytics section
animateDashboard(
dashboardRefs.bars || [],
dashboardRefs.pieQuarters || [],
0
)
}
}
},
})
return () => {
trigger.kill()
}
}, [setScrollProgress, setActiveSection, dashboardRefs, lenis, onPinState])
return (
<div
ref={containerRef}
id="scroll-trigger-trigger"
style={{
// In normal flow so it gives the `.dm-hiw-3d` section its 900vh height
// (the footer follows cleanly after it). The pinned stage is a separate
// absolutely/fixed-positioned sibling.
position: 'relative',
width: '100%',
height: '900vh', // Optimized scroll length for faster, smoother travel
pointerEvents: 'none', // Allow interacting with the R3F Canvas underneath
zIndex: 0,
}}
/>
)
}

View File

@@ -0,0 +1,93 @@
import React, { useRef, useEffect } from 'react'
import { useFrame } from '@react-three/fiber'
import * as THREE from 'three'
import { useSceneStore } from '../store/useSceneStore'
// The exact calculated world coordinates of the 10 street light heads in the scene
const streetLightsData = [
{ pos: [0, 4.2, -4.56], target: [0, 0, -4.56] },
{ pos: [9.113, 4.2, 0.944], target: [9.113, 0, 0.944] },
{ pos: [-10.158, 4.2, -9.874], target: [-10.158, 0, -9.874] },
{ pos: [3.513, 4.2, 9.195], target: [3.513, 0, 9.195] },
{ pos: [3.96, 4.2, -21.17], target: [3.96, 0, -21.17] },
{ pos: [12.25, 4.2, -16.7], target: [12.25, 0, -16.7] },
{ pos: [3.052, 4.2, -12.335], target: [3.052, 0, -12.335] },
{ pos: [-2.03, 4.2, -16.89], target: [-2.03, 0, -16.89] },
{ pos: [-27.151, 3.98, -9], target: [-27.151, 0, -9] }
]
const bulbOffColor = new THREE.Color('#333333')
const bulbOnColor = new THREE.Color('#ffdf6d')
const emissiveOffColor = new THREE.Color('#000000')
const emissiveOnColor = new THREE.Color('#ffdf6d')
function SingleStreetLight({ pos, targetPos }) {
const lightRef = useRef()
const targetRef = useRef()
const bulbRef = useRef()
useEffect(() => {
if (lightRef.current && targetRef.current) {
lightRef.current.target = targetRef.current
lightRef.current.target.updateMatrixWorld()
}
}, [])
useFrame(() => {
// Day-to-Night factor (disabled: streetlights stay off)
const nightFactor = 0
// Smoothly scale spotlights intensity
if (lightRef.current) {
lightRef.current.intensity = nightFactor * 12.0
}
// Interpolate light bulb material colors to simulate glowing filament
if (bulbRef.current) {
bulbRef.current.material.color.lerpColors(bulbOffColor, bulbOnColor, nightFactor)
bulbRef.current.material.emissive.lerpColors(emissiveOffColor, emissiveOnColor, nightFactor)
}
})
return (
<group>
{/* Spotlight casting cone of light downward */}
<spotLight
ref={lightRef}
position={pos}
intensity={0}
distance={12}
angle={Math.PI / 4.5}
penumbra={0.6}
decay={1.2}
color="#ffdf6d"
castShadow={false} // Disabled for peak frame rate, main shadow is cast by directionalLight
/>
{/* Glowing bulb mesh placed exactly at the light coordinates */}
<mesh ref={bulbRef} position={pos}>
<sphereGeometry args={[0.16, 16, 16]} />
<meshStandardMaterial
color="#333333"
emissive="#000000"
emissiveIntensity={3.5}
roughness={0.1}
/>
</mesh>
<object3D ref={targetRef} position={targetPos} />
</group>
)
}
export default React.memo(function StreetLights() {
return (
<group>
{streetLightsData.map((light, index) => (
<SingleStreetLight
key={index}
pos={light.pos}
targetPos={light.target}
/>
))}
</group>
)
})

View File

@@ -0,0 +1,175 @@
import React, { useEffect, useRef } from 'react'
import { useFrame } from '@react-three/fiber'
import * as THREE from 'three'
import { useSceneStore } from '../store/useSceneStore'
import { useTruckMovement } from '../hooks/useTruckMovement'
import { animateWheels } from '../animations/wheelAnimation'
import { easing } from 'maath'
import { truckPath } from '../curves/truckPath'
export default function TruckAnimation({ truckRef, wheelRefs }) {
const scrollProgress = useSceneStore((state) => state.scrollProgress)
const activeSection = useSceneStore((state) => state.activeSection)
const setTruckProgress = useSceneStore((state) => state.setTruckProgress)
const { truckProgress } = useTruckMovement(scrollProgress)
const initialized = useRef(false)
// Sync truck progress to the global store
useEffect(() => {
setTruckProgress(truckProgress)
}, [truckProgress, setTruckProgress])
// Float trackers for 1D progress and direction detection
const dampedProgressRef = useRef(0)
const lastScrollProgressRef = useRef(0)
const isReversingRef = useRef(false)
// Tracker for smooth 180-degree yaw rotation (prevents glitches by pivoting Y rotation angle directly)
const extraRotationRef = useRef(0)
// Track wheel rotation accumulation
const accumulatedRotationRef = useRef(0)
const lastDampedProgressRef = useRef(0)
useFrame((state, delta) => {
if (!truckRef.current) return
// r3f can emit delta === 0 (coincident frames, the first frame, or after a
// long main-thread block while the 32MB scene parses). maath's easing.damp
// divides by delta internally, so a 0 yields NaN/Infinity that poisons the
// damper's stored velocity — and from then on truckPath.getPoint(NaN) throws
// "Cannot read properties of undefined (reading 'x')". Clamp delta to a safe
// positive range before any damping.
const dt = Number.isFinite(delta) && delta > 0 ? Math.min(delta, 0.1) : 1 / 60
// Detect scroll direction changes from the actual page scroll progress
const deltaScroll = scrollProgress - lastScrollProgressRef.current
if (deltaScroll < -0.0001) {
isReversingRef.current = true
} else if (deltaScroll > 0.0001) {
isReversingRef.current = false
}
lastScrollProgressRef.current = scrollProgress
// Ensure correct parent-child structure and orientation for the truck (runs reactively on re-renders)
const innerGroup = truckRef.current.children[0]
if (innerGroup && truckRef.current.children.length > 1) {
const siblings = [...truckRef.current.children].slice(1)
siblings.forEach((sibling) => {
innerGroup.attach(sibling)
})
innerGroup.rotation.set(0, -Math.PI / 2, 0)
// Disable frustum culling on all child meshes so the truck/shadow is always visible
truckRef.current.traverse((child) => {
if (child.isMesh) {
child.frustumCulled = false
child.castShadow = true
child.receiveShadow = true
}
})
}
// Run one-time state initialization for progress trackers
if (!initialized.current) {
dampedProgressRef.current = truckProgress
lastDampedProgressRef.current = truckProgress
lastScrollProgressRef.current = scrollProgress
isReversingRef.current = false
extraRotationRef.current = 0
const position = truckPath.getPoint(dampedProgressRef.current)
let lookAtTargetVector
if (dampedProgressRef.current >= 0.99) {
const tangent = truckPath.getTangent(1.0)
const endPoint = truckPath.getPoint(1.0)
lookAtTargetVector = new THREE.Vector3().copy(endPoint).addScaledVector(tangent, 1.0)
} else {
const ahead = Math.min(dampedProgressRef.current + 0.01, 1.0)
lookAtTargetVector = truckPath.getPoint(ahead)
}
truckRef.current.position.copy(position)
if (truckRef.current.position.distanceToSquared(lookAtTargetVector) > 0.0001) {
truckRef.current.lookAt(lookAtTargetVector)
}
initialized.current = true
}
// Smoothly damp the 1D progress scalar along the curve path
easing.damp(dampedProgressRef, 'current', truckProgress, 0.30, dt)
// Defensive: keep the spline parameter a finite value in [0,1]. getPoint(NaN)
// or an out-of-range t reads an undefined curve point and throws.
if (!Number.isFinite(dampedProgressRef.current)) {
dampedProgressRef.current = truckProgress
if (dampedProgressRef.__damp) dampedProgressRef.__damp = {} // clear any poisoned velocity
}
dampedProgressRef.current = THREE.MathUtils.clamp(dampedProgressRef.current, 0, 1)
// Evaluate the 3D position and orientation directly on the spline curve
const position = truckPath.getPoint(dampedProgressRef.current)
let lookAtTargetVector
if (dampedProgressRef.current >= 0.99) {
const tangent = truckPath.getTangent(1.0)
const endPoint = truckPath.getPoint(1.0)
lookAtTargetVector = new THREE.Vector3().copy(endPoint).addScaledVector(tangent, 1.0)
} else {
const ahead = Math.min(dampedProgressRef.current + 0.01, 1.0)
lookAtTargetVector = truckPath.getPoint(ahead)
}
// Update position and base forward rotation directly (ensures 100% spline compliance, zero corner cutting)
truckRef.current.position.copy(position)
if (truckRef.current.position.distanceToSquared(lookAtTargetVector) > 0.0001) {
truckRef.current.lookAt(lookAtTargetVector)
}
// Determine target extra rotation:
// - 0 radians when moving forward
// - Math.PI radians (180 degrees) when reversing
// We disable U-turns at the extreme start and end of the path to keep the truck stable at warehouse/delivery spots
let targetExtraRotation = 0
if (dampedProgressRef.current > 0.05 && dampedProgressRef.current < 0.95) {
if (isReversingRef.current) {
targetExtraRotation = Math.PI
}
}
// Smoothly damp the extra rotation angle directly (prevents pitch/roll glitches or 3D target collapse)
easing.damp(extraRotationRef, 'current', targetExtraRotation, 0.20, dt)
// Apply the yaw pivot around the local vertical axis
truckRef.current.rotateY(extraRotationRef.current)
// Calculate progress delta for wheels and audio
const deltaDamped = Math.abs(dampedProgressRef.current - lastDampedProgressRef.current)
lastDampedProgressRef.current = dampedProgressRef.current
// Accumulate wheel rotation based on absolute movement delta so they always roll forward locally
const isMoving = dampedProgressRef.current > 0.001 && dampedProgressRef.current < 0.999
if (isMoving) {
accumulatedRotationRef.current += deltaDamped * 250 // spinFactor
}
// Spin wheels
animateWheels(wheelRefs, accumulatedRotationRef.current)
// Add engine vibration to the inner group to prevent coordinate pollution on the root group
if (truckRef.current.children && truckRef.current.children[0]) {
const innerGroup = truckRef.current.children[0]
innerGroup.position.y = Math.sin(state.clock.getElapsedTime() * 45) * 0.003
}
})
return null
}

View File

@@ -0,0 +1,46 @@
import React from 'react'
import RevealCard from '../ui/RevealCard'
export default function Analytics({ active }) {
return (
<RevealCard active={active} id="analytics-section">
<div className="section-badge">Workflow</div>
<h2 className="section-title">Doormile Insights</h2>
<h3 className="section-subtitle">3-Mile Logistics Ecosystem</h3>
<div className="workflow-steps">
<div className="workflow-step">
<div className="step-number-container">
<span className="step-number">01</span>
<div className="step-line"></div>
</div>
<div className="step-content">
<h4 className="step-title">First Mile</h4>
<p className="step-description">Incoming shipments are securely loaded, checked, and consolidated at initial fulfillment hubs.</p>
</div>
</div>
<div className="workflow-step">
<div className="step-number-container">
<span className="step-number">02</span>
<div className="step-line"></div>
</div>
<div className="step-content">
<h4 className="step-title">Mid Mile</h4>
<p className="step-description">Consolidated goods travel between primary distribution nodes via optimized express transit corridors.</p>
</div>
</div>
<div className="workflow-step">
<div className="step-number-container">
<span className="step-number">03</span>
</div>
<div className="step-content">
<h4 className="step-title">Last Mile</h4>
<p className="step-description">Local delivery units organize doorstep routes to transport packages to final customers.</p>
</div>
</div>
</div>
</RevealCard>
)
}

View File

@@ -0,0 +1,25 @@
import React from 'react'
import { sections } from '../../constants/sectionConfig'
import RevealCard from '../ui/RevealCard'
export default function FirstMile({ active }) {
const config = sections[0]
return (
<RevealCard active={active} id="first-mile-section">
<div className="section-badge">Stage 01</div>
<h2 className="section-title">{config.title}</h2>
<h3 className="section-subtitle">{config.subtitle}</h3>
<p className="section-description">{config.description}</p>
<div className="section-metrics">
<div className="metric-item">
<span className="metric-value">14,250</span>
<span className="metric-label">Parcels Processed</span>
</div>
<div className="metric-item">
<span className="metric-value">99.98%</span>
<span className="metric-label">Sorting Accuracy</span>
</div>
</div>
</RevealCard>
)
}

View File

@@ -0,0 +1,52 @@
import React from 'react'
import { sections } from '../../constants/sectionConfig'
import { useSceneStore } from '../../store/useSceneStore'
import RevealCard from '../ui/RevealCard'
import { progressToScrollY } from '../../utils/helpers'
export default function LastMile({ active }) {
const config = sections[2]
const lenis = useSceneStore((state) => state.lenis)
const handleClose = () => {
// Smoothly scroll to 97% progress, which is inside the Analytics Dashboard section.
// Relative to the experience spacer (the section sits below the page hero).
lenis?.scrollTo(progressToScrollY(0.97), { duration: 1.5 })
}
return (
<RevealCard active={active} id="last-mile-section">
<div className="section-badge">Stage 03</div>
<h2 className="section-title">{config.title}</h2>
<h3 className="section-subtitle">{config.subtitle}</h3>
<p className="section-description">{config.description}</p>
<div className="section-metrics">
<div className="metric-item">
<span className="metric-value">12.5 min</span>
<span className="metric-label">Avg. Delivery window</span>
</div>
<div className="metric-item">
<span className="metric-value">99.4%</span>
<span className="metric-label">On-Time Rate</span>
</div>
</div>
<button className="section-close-btn" onClick={handleClose}>
View Analytics
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
style={{ marginLeft: '6px' }}
>
<line x1="5" y1="12" x2="19" y2="12"></line>
<polyline points="12 5 19 12 12 19"></polyline>
</svg>
</button>
</RevealCard>
)
}

View File

@@ -0,0 +1,52 @@
import React from 'react'
import { sections } from '../../constants/sectionConfig'
import { useSceneStore } from '../../store/useSceneStore'
import RevealCard from '../ui/RevealCard'
import { progressToScrollY } from '../../utils/helpers'
export default function MidMile({ active }) {
const config = sections[1]
const lenis = useSceneStore((state) => state.lenis)
const handleClose = () => {
// Smoothly scroll to 57.5% progress, which is just after the truck resumes moving (at 57%).
// Relative to the experience spacer (the section sits below the page hero).
lenis?.scrollTo(progressToScrollY(0.575), { duration: 1.5 })
}
return (
<RevealCard active={active} id="mid-mile-section">
<div className="section-badge">Stage 02</div>
<h2 className="section-title">{config.title}</h2>
<h3 className="section-subtitle">{config.subtitle}</h3>
<p className="section-description">{config.description}</p>
<div className="section-metrics">
<div className="metric-item">
<span className="metric-value">4.2 hr</span>
<span className="metric-label">Avg. Transit Time</span>
</div>
<div className="metric-item">
<span className="metric-value">220 kw</span>
<span className="metric-label">Solar Output (Self-powered)</span>
</div>
</div>
<button className="section-close-btn" onClick={handleClose}>
Continue Journey
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
style={{ marginLeft: '6px' }}
>
<line x1="5" y1="12" x2="19" y2="12"></line>
<polyline points="12 5 19 12 12 19"></polyline>
</svg>
</button>
</RevealCard>
)
}

View File

@@ -0,0 +1,22 @@
import React from 'react'
import { useSceneStore } from '../../store/useSceneStore'
export default function Hero() {
const lenis = useSceneStore((state) => state.lenis)
const handleScrollToStart = () => {
// Scroll down to the first active transition point
lenis?.scrollTo(window.innerHeight * 0.5, { duration: 1.5 })
}
return (
<div className="hero-overlay" id="home-hero">
{/* Dynamic mouse scrolling indicator */}
<div className="scroll-indicator" onClick={handleScrollToStart}>
<div className="mouse-frame">
<div className="mouse-dot" />
</div>
<div className="scroll-text">Scroll to start</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,38 @@
import React from 'react'
import { useSceneStore } from '../../store/useSceneStore'
import { progressToScrollY } from '../../utils/helpers'
export default function Navbar() {
const activeSection = useSceneStore((state) => state.activeSection)
const lenis = useSceneStore((state) => state.lenis)
const handleNavClick = (index) => {
// Map index (0, 1, 2, 3) to the stable parking progress percentages (0.0, 0.38, 0.76, 0.97).
const sectionFractions = [0, 0.38, 0.76, 0.97]
const targetProgress = sectionFractions[index]
// Relative to the experience spacer (the section sits below the page hero).
lenis?.scrollTo(progressToScrollY(targetProgress), { duration: 1.5 })
}
const navItems = [
{ label: 'First Mile', index: 0 },
{ label: 'Mid Mile', index: 1 },
{ label: 'Last Mile', index: 2 },
{ label: 'Analytics', index: 3 },
]
return (
<div className="side-navigation" id="main-navbar">
{navItems.map((item) => (
<button
key={item.index}
onClick={() => handleNavClick(item.index)}
className={`side-nav-item ${activeSection === item.index ? 'active' : ''}`}
>
<span className="side-nav-label">{item.label}</span>
<span className="side-nav-dot" />
</button>
))}
</div>
)
}

View File

@@ -0,0 +1,89 @@
import React, { useEffect, useRef } from 'react'
import gsap from 'gsap'
export default function RevealCard({ children, active, id, className = "" }) {
const cardRef = useRef(null)
useEffect(() => {
const card = cardRef.current
if (!card) return
// Find all target children inside the card to create a staggered entrance
const animTargets = card.querySelectorAll(
'.section-badge, .section-title, .section-subtitle, .section-description, .section-metrics, .section-close-btn, .workflow-step'
)
const isAnalytics = id === 'analytics-section'
if (active) {
// Clean up any ongoing animations first
gsap.killTweensOf([card, animTargets])
// Animate card container in
gsap.to(card, {
xPercent: isAnalytics ? -50 : 0,
yPercent: isAnalytics ? -50 : 0,
y: 0,
scale: 1,
opacity: 1,
duration: 0.85,
ease: 'power4.out',
})
// Stagger child elements reveal
gsap.fromTo(
animTargets,
{
y: 15,
opacity: 0
},
{
y: 0,
opacity: 1,
duration: 0.6,
stagger: 0.08,
ease: 'power3.out',
delay: 0.1, // brief delay to let card body expand first
}
)
} else {
// Kill active tweens
gsap.killTweensOf([card, animTargets])
// Animate card container out
gsap.to(card, {
xPercent: isAnalytics ? -50 : 0,
yPercent: isAnalytics ? -50 : 0,
y: isAnalytics ? 18 : 20,
scale: 0.96,
opacity: 0,
duration: 0.5,
ease: 'power3.inOut',
})
// Smoothly hide child elements
gsap.to(animTargets, {
y: 10,
opacity: 0,
duration: 0.35,
ease: 'power2.in',
})
}
}, [active, id])
return (
<div
ref={cardRef}
id={id}
className={`section-panel ${active ? 'active' : ''} ${className}`}
style={{
opacity: 0,
transform: id === 'analytics-section'
? 'translate(-50%, -50%) translateY(18px) scale(0.96)'
: 'translateY(20px) scale(0.96)',
}}
>
{children}
</div>
)
}

View File

@@ -0,0 +1,35 @@
import * as THREE from 'three'
// Premium Apple-inspired cinematic keyframes looking directly at the front of each building
export const cameraPositions = [
{
// Stage 01: First Mile Warehouse (Front-on view of loading bays, lowered target to center truck)
progress: 0.0,
position: new THREE.Vector3(19.727, 7.5, -14.0),
target: new THREE.Vector3(19.727, 2.0, -31.02),
},
{
// Transition 01: Highway Cruise (Looking down at the highway joining road)
progress: 0.25,
position: new THREE.Vector3(0.0, 12.0, -12.0),
target: new THREE.Vector3(6.447, 2.0, -19.06),
},
{
// Stage 02: Mid Mile Hub (Front-on view of loading bays, lowered target to center truck)
progress: 0.5,
position: new THREE.Vector3(-19.146, 6.5, 10.0),
target: new THREE.Vector3(-19.146, 1.5, -6.00),
},
{
// Stage 03: Last Mile Delivery Center (Front-on view of local hub, lowered target to center truck)
progress: 0.75,
position: new THREE.Vector3(19.263, 5.5, 27.0),
target: new THREE.Vector3(19.263, 1.2, 4.0),
},
{
// Stage 04: Centralized Dashboard (Front-on view of the analytics monitor screen)
progress: 1.0,
position: new THREE.Vector3(-13.5, 5.0, 31.0),
target: new THREE.Vector3(-7.7, 3.8, 25.4),
},
]

View File

@@ -0,0 +1,12 @@
export const colors = {
primary: '#0071e3', // Apple Blue
secondary: '#86868b', // Apple Gray
background: '#ffffff', // White
backgroundDark: '#f5f5f7', // Off-white/Light gray
text: '#1d1d1f', // Premium dark gray (text)
textMuted: '#86868b', // Subtitle text
border: '#d2d2d7', // Thin borders
success: '#34c759', // Apple Green
accentBlue: '#2997ff', // Bright active blue
cardBg: 'rgba(255, 255, 255, 0.8)',
}

View File

@@ -0,0 +1,34 @@
export const sections = [
{
id: 'first-mile',
title: 'First Mile Warehouse',
subtitle: 'Consolidation & Prep',
description: 'Incoming shipments are securely loaded, checked, and queued for transfer in our high-capacity fulfillment centers.',
progressStart: 0.0,
progressEnd: 0.25,
},
{
id: 'mid-mile',
title: 'Mid Mile Hub',
subtitle: 'Sorting & Direct Dispatch',
description: 'Consolidated goods travel between primary distribution nodes. Heavy logistics lanes sorting thousands of parcels per hour.',
progressStart: 0.25,
progressEnd: 0.5,
},
{
id: 'last-mile',
title: 'Last Mile Delivery',
subtitle: 'Doorstep Courier Services',
description: 'Local delivery units take over, planning optimal paths to transport packages directly to customer doorsteps.',
progressStart: 0.5,
progressEnd: 0.75,
},
{
id: 'analytics',
title: 'Fulfillment Analytics',
subtitle: 'Real-Time Operational Insights',
description: 'A fully centralized dashboard monitoring transit times, fleet coordinates, carbon footprint, and delivery success rates.',
progressStart: 0.75,
progressEnd: 1.0,
},
]

View File

@@ -0,0 +1,27 @@
import * as THREE from 'three'
// Exact coordinates extracted from the white road lane markers and building platform heights
export const truckPoints = [
new THREE.Vector3(15.5, 0.45, -26.5), // Start on road lane in front of First Mile warehouse
new THREE.Vector3(13.399, 0.324, -24.742), // Road lane start in front of warehouse
new THREE.Vector3(11.211, 0.178, -22.973), // Road lane marker
new THREE.Vector3(8.823, 0.111, -20.949), // Road lane marker
new THREE.Vector3(6.447, 0.059, -19.06), // Road lane marker
new THREE.Vector3(3.786, 0.072, -17.002), // Joining main road
new THREE.Vector3(0.732, 0.124, -14.955), // Road lane marker
new THREE.Vector3(-2.156, 0.124, -12.903), // Road lane marker
new THREE.Vector3(-4.417, 0.124, -10.929), // Road lane marker
new THREE.Vector3(-5.896, 0.124, -8.052), // Road lane marker
new THREE.Vector3(-5.985, 0.124, -5.497), // Stopped on road in front of Mid Mile hub
new THREE.Vector3(-4.362, 0.124, -3.25), // Road lane marker
new THREE.Vector3(-1.448, 0.124, -1.234), // Road lane marker
new THREE.Vector3(2.539, 0.124, 0.986), // Road lane marker
new THREE.Vector3(6.686, 0.124, 3.379), // Road lane marker
new THREE.Vector3(8.213, 0.124, 6.14), // Road lane marker
new THREE.Vector3(7.976, 0.124, 9.176), // Road lane marker
new THREE.Vector3(6.424, 0.124, 12.428), // Road lane marker
new THREE.Vector3(3.883, 0.124, 15.769), // Road lane marker
new THREE.Vector3(1.241, 0.124, 19.056) // Stopped in front of Last Mile hub
]
export const truckPath = new THREE.CatmullRomCurve3(truckPoints)

View File

@@ -0,0 +1,164 @@
import { useMemo } from 'react'
import * as THREE from 'three'
import { truckPath } from '../curves/truckPath'
import { clamp } from '../utils/helpers'
export const useCameraAnimation = (scrollProgress) => {
const cameraState = useMemo(() => {
// 1. Calculate the truck position corresponding to the current scroll progress
// Use the exact same piecewise mapping to keep camera follow 100% synchronized
let truckProgress = 0
if (scrollProgress < 0.14) {
truckProgress = 0.0
} else if (scrollProgress >= 0.14 && scrollProgress < 0.38) {
truckProgress = 0.5 * (scrollProgress - 0.14) / 0.24
} else if (scrollProgress >= 0.38 && scrollProgress < 0.50) {
truckProgress = 0.5
} else if (scrollProgress >= 0.50 && scrollProgress < 0.76) {
truckProgress = 0.5 + 0.5 * (scrollProgress - 0.50) / 0.26
} else {
truckProgress = 1.0
}
const truckPos = truckPath.getPoint(truckProgress)
const firstMileViewWhole = {
position: new THREE.Vector3(38.0, 15.0, -10.0),
target: new THREE.Vector3(24.377, 4.0, -39.303)
}
const firstMileViewFront = {
position: new THREE.Vector3(7.0, 3.0, -19.0),
target: new THREE.Vector3(15.5, 1.5, -26.5)
}
const midMileView = {
position: new THREE.Vector3(-7.0, 7.5, 8.0),
target: new THREE.Vector3(-19.146, 2.5, -9.0)
}
const lastMileViewClose = {
position: new THREE.Vector3(-3.5, 4.0, 15.0),
target: new THREE.Vector3(8.0, 2.0, 20.0)
}
const lastMileViewZoomedOut = {
position: new THREE.Vector3(-10.4, 5.2, 12.0),
target: new THREE.Vector3(8.0, 2.0, 20.0)
}
const analyticsView = {
position: new THREE.Vector3(-13.5, 5.0, 31.0),
target: new THREE.Vector3(-7.7, 3.5, 25.4)
}
// 3. Calculate local coordinate axes of the truck based on the spline tangent
const forward = truckPath.getTangent(truckProgress).normalize()
const up = new THREE.Vector3(0, 1, 0)
const right = new THREE.Vector3().crossVectors(forward, up).normalize()
// Cruise 1: Front-left follow perspective (facing the oncoming truck, zoomed out follow)
const cruise1Pos = truckPos.clone()
.addScaledVector(forward, 7.2)
.addScaledVector(up, 3.2)
.addScaledVector(right, -3.0)
const cruise1Target = truckPos.clone()
// Cruise 2: Front-right follow perspective (facing the oncoming truck, zoomed out follow)
const cruise2Pos = truckPos.clone()
.addScaledVector(forward, 7.2)
.addScaledVector(up, 3.2)
.addScaledVector(right, 3.0)
const cruise2Target = truckPos.clone()
const position = new THREE.Vector3()
const target = new THREE.Vector3()
// 4. Smoothly blend positions and targets depending on active scroll boundaries
if (scrollProgress < 0.04) {
// Step 1: Zoomed out overview of the whole building
position.copy(firstMileViewWhole.position)
target.copy(firstMileViewWhole.target)
}
else if (scrollProgress >= 0.04 && scrollProgress < 0.14) {
// Step 2: Camera moves to the front close-up view of the building
const alpha = (scrollProgress - 0.04) / 0.10
const smoothAlpha = alpha * alpha * (3 - 2 * alpha)
position.lerpVectors(firstMileViewWhole.position, firstMileViewFront.position, smoothAlpha)
target.lerpVectors(firstMileViewWhole.target, firstMileViewFront.target, smoothAlpha)
}
else if (scrollProgress >= 0.14 && scrollProgress < 0.18) {
// Step 3: Truck starts moving, camera blends to close follow tracking
const alpha = (scrollProgress - 0.14) / 0.04
const smoothAlpha = alpha * alpha * (3 - 2 * alpha)
position.lerpVectors(firstMileViewFront.position, cruise1Pos, smoothAlpha)
target.lerpVectors(firstMileViewFront.target, cruise1Target, smoothAlpha)
}
else if (scrollProgress >= 0.18 && scrollProgress < 0.34) {
// Cruise 1: Close follow tracking
position.copy(cruise1Pos)
target.copy(cruise1Target)
}
else if (scrollProgress >= 0.34 && scrollProgress < 0.38) {
// Blend: Cruise 1 Follow -> Mid Mile Building
const alpha = (scrollProgress - 0.34) / 0.04
const smoothAlpha = alpha * alpha * (3 - 2 * alpha)
position.lerpVectors(cruise1Pos, midMileView.position, smoothAlpha)
target.lerpVectors(cruise1Target, midMileView.target, smoothAlpha)
}
else if (scrollProgress >= 0.38 && scrollProgress < 0.50) {
// Mid Mile Building focus
position.copy(midMileView.position)
target.copy(midMileView.target)
}
else if (scrollProgress >= 0.50 && scrollProgress < 0.54) {
// Blend: Mid Mile Building -> Cruise 2 Follow
const alpha = (scrollProgress - 0.50) / 0.04
const smoothAlpha = alpha * alpha * (3 - 2 * alpha)
position.lerpVectors(midMileView.position, cruise2Pos, smoothAlpha)
target.lerpVectors(midMileView.target, cruise2Target, smoothAlpha)
}
else if (scrollProgress >= 0.54 && scrollProgress < 0.72) {
// Cruise 2: Close follow tracking
position.copy(cruise2Pos)
target.copy(cruise2Target)
}
else if (scrollProgress >= 0.72 && scrollProgress < 0.76) {
// Blend: Cruise 2 Follow -> Last Mile Building Close-up
const alpha = (scrollProgress - 0.72) / 0.04
const smoothAlpha = alpha * alpha * (3 - 2 * alpha)
position.lerpVectors(cruise2Pos, lastMileViewClose.position, smoothAlpha)
target.lerpVectors(cruise2Target, lastMileViewClose.target, smoothAlpha)
}
else if (scrollProgress >= 0.76 && scrollProgress < 0.92) {
// Last Mile Building Stop Sequence:
// - 0.76 to 0.80: Parked close-up view of the truck and building
// - 0.80 to 0.84: Zoom out transition back along the camera viewing axis
// - 0.84 to 0.92: Zoomed-out overview of the final delivery stage (card stays frozen here)
if (scrollProgress < 0.80) {
position.copy(lastMileViewClose.position)
target.copy(lastMileViewClose.target)
} else if (scrollProgress >= 0.80 && scrollProgress < 0.84) {
const alpha = (scrollProgress - 0.80) / 0.04
const smoothAlpha = alpha * alpha * (3 - 2 * alpha)
position.lerpVectors(lastMileViewClose.position, lastMileViewZoomedOut.position, smoothAlpha)
target.lerpVectors(lastMileViewClose.target, lastMileViewZoomedOut.target, smoothAlpha)
} else {
position.copy(lastMileViewZoomedOut.position)
target.copy(lastMileViewZoomedOut.target)
}
}
else if (scrollProgress >= 0.92 && scrollProgress < 0.96) {
// Blend: Last Mile Building Zoomed-Out -> Analytics Dashboard screen
const alpha = (scrollProgress - 0.92) / 0.04
const smoothAlpha = alpha * alpha * (3 - 2 * alpha)
position.lerpVectors(lastMileViewZoomedOut.position, analyticsView.position, smoothAlpha)
target.lerpVectors(lastMileViewZoomedOut.target, analyticsView.target, smoothAlpha)
}
else {
// Analytics Dashboard screen focus
position.copy(analyticsView.position)
target.copy(analyticsView.target)
}
return { position, target }
}, [scrollProgress])
return cameraState
}
export default useCameraAnimation

View File

@@ -0,0 +1,16 @@
import { useSceneStore } from '../store/useSceneStore'
export const useScrollProgress = () => {
const scrollProgress = useSceneStore((state) => state.scrollProgress)
const activeSection = useSceneStore((state) => state.activeSection)
const setScrollProgress = useSceneStore((state) => state.setScrollProgress)
const setActiveSection = useSceneStore((state) => state.setActiveSection)
return {
scrollProgress,
activeSection,
setScrollProgress,
setActiveSection,
}
}
export default useScrollProgress

View File

@@ -0,0 +1,52 @@
import { useMemo } from 'react'
import * as THREE from 'three'
import { truckPath } from '../curves/truckPath'
import { clamp } from '../utils/helpers'
export const useTruckMovement = (scrollProgress) => {
// Piecewise mapping of scroll progress to make the truck stop at Mid Mile:
// - 0% to 25%: Parked at First Mile (progress = 0)
// - 25% to 45%: Driving from First Mile to Mid Mile (progress 0 -> 0.5)
// - 45% to 55%: Parked at Mid Mile (progress = 0.5)
// - 55% to 75%: Driving from Mid Mile to Last Mile (progress 0.5 -> 1.0)
// - 75% to 100%: Parked at Last Mile (progress = 1.0)
const truckProgress = useMemo(() => {
if (scrollProgress < 0.14) {
return 0.0
}
if (scrollProgress >= 0.14 && scrollProgress < 0.38) {
return 0.5 * (scrollProgress - 0.14) / 0.24
}
if (scrollProgress >= 0.38 && scrollProgress < 0.50) {
return 0.5
}
if (scrollProgress >= 0.50 && scrollProgress < 0.76) {
return 0.5 + 0.5 * (scrollProgress - 0.50) / 0.26
}
return 1.0
}, [scrollProgress])
// Get current position on the curve
const position = useMemo(() => {
return truckPath.getPoint(truckProgress)
}, [truckProgress])
// Get lookAt target (a point slightly ahead on the curve, using tangent at the end to prevent matrix collapse)
const lookAtTarget = useMemo(() => {
if (truckProgress >= 0.99) {
const tangent = truckPath.getTangent(1.0)
const endPoint = truckPath.getPoint(1.0)
return new THREE.Vector3().copy(endPoint).addScaledVector(tangent, 1.0)
}
const ahead = Math.min(truckProgress + 0.01, 1.0)
return truckPath.getPoint(ahead)
}, [truckProgress])
return {
truckProgress,
position,
lookAtTarget,
}
}
export default useTruckMovement

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
import { create } from 'zustand'
export const useSceneStore = create((set) => ({
scrollProgress: 0,
activeSection: 0, // 0: First Mile, 1: Mid Mile, 2: Last Mile, 3: Analytics
truckProgress: 0,
cameraTarget: [19.727, 4.397, -31.08], // Initial target, e.g. First Mile warehouse
lenis: null,
setScrollProgress: (progress) => set({ scrollProgress: progress }),
setActiveSection: (section) => set({ activeSection: section }),
setTruckProgress: (progress) => set({ truckProgress: progress }),
setCameraTarget: (target) => set({ cameraTarget: target }),
setLenis: (lenis) => set({ lenis }),
}))

View File

@@ -0,0 +1,485 @@
/* ============================================================================
How It Works — 3D experience styles.
Ported from the standalone Vite app's index.css and FULLY SCOPED under
`.dm-hiw-3d` so nothing bleeds into the surrounding Next.js site. The original
had global `:root` / `body` / `::-webkit-scrollbar` rules and generic class
names (.navbar, .btn-primary, .section-title, .loader-overlay) that would
collide with the site's Elementor CSS — every selector below is therefore
prefixed with `.dm-hiw-3d`.
Pinning mirrors the site's existing scroll-driven 3D sections (see
StrategySection): a tall `position:relative` section + an absolutely
positioned stage toggled absolute(top) → fixed → absolute(bottom) from
ScrollTrigger pin state. CSS `position: sticky` / GSAP pin are unreliable
here because the site has a fixed header and an ancestor `overflow:hidden`.
============================================================================ */
/* ---- Doormile card design tokens (single system for all four stages) ----
Brand red #c01227, ink #1F1F1F, fonts inherited from the site (Manrope body /
Space Grotesk headings, exposed as CSS vars on <html> by layout.tsx). */
.dm-hiw-3d {
--dm-red: #c01227;
--dm-red-soft: rgba(192, 18, 39, 0.10);
--dm-ink: #1f1f1f;
--dm-body: #4b5563;
--dm-muted: #8a8f98;
--dm-card-bg: rgba(255, 255, 255, 0.86);
--dm-card-border: rgba(15, 23, 42, 0.08);
--dm-card-radius: 18px;
--dm-card-shadow: 0 24px 60px -28px rgba(15, 23, 42, 0.45);
--dm-font-head: var(--font-space-grotesk), var(--font-manrope), system-ui, sans-serif;
--dm-font-body: var(--font-manrope), system-ui, -apple-system, sans-serif;
}
/* ---- Section shell + self-managed fixed pin ---- */
.dm-hiw-3d {
position: relative;
width: 100%;
font-family: var(--dm-font-body);
line-height: 1.5;
font-weight: 400;
color: var(--dm-ink);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.dm-hiw-3d-stage {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100vh;
overflow: hidden;
will-change: transform;
transform: translateZ(0);
backface-visibility: hidden;
}
.dm-hiw-3d.is-pinned .dm-hiw-3d-stage {
position: fixed;
top: 0;
left: 0;
}
.dm-hiw-3d.is-after .dm-hiw-3d-stage {
position: absolute;
top: auto;
bottom: 0;
}
.dm-hiw-3d .canvas-wrapper {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 1;
pointer-events: auto;
}
/* Floating Vertical Side Navigation */
.dm-hiw-3d .side-navigation {
position: absolute;
right: 28px;
top: 50%;
transform: translateY(-50%);
z-index: 100;
display: flex;
flex-direction: column;
gap: 16px;
background: transparent;
padding: 18px 10px;
}
.dm-hiw-3d .side-nav-item {
background: none;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
padding: 4px 6px;
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
outline: none;
}
.dm-hiw-3d .side-nav-label {
font-family: inherit;
font-size: 10px;
font-weight: 600;
color: #86868b;
text-transform: uppercase;
letter-spacing: 0.8px;
opacity: 0;
transform: translateX(8px) scale(0.9);
transition: all 0.25s cubic-bezier(0.25, 0.8, 0.25, 1);
pointer-events: none;
}
.dm-hiw-3d .side-nav-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background-color: rgba(0, 0, 0, 0.15);
transition: all 0.25s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.dm-hiw-3d .side-nav-item:hover .side-nav-label {
opacity: 1;
transform: translateX(0) scale(1);
}
.dm-hiw-3d .side-nav-item:hover .side-nav-dot {
background-color: #1d1d1f;
transform: scale(1.15);
}
.dm-hiw-3d .side-nav-item.active .side-nav-label {
color: var(--dm-red);
}
.dm-hiw-3d .side-nav-item.active .side-nav-dot {
background-color: var(--dm-red);
transform: scale(1.3);
box-shadow: 0 0 8px rgba(192, 18, 39, 0.35);
}
.dm-hiw-3d .section-close-btn {
margin-top: 18px;
background-color: var(--dm-red);
color: #ffffff;
border: none;
font-family: var(--dm-font-body);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.01em;
padding: 9px 18px;
border-radius: 999px;
cursor: pointer;
transition: background-color 0.25s ease, box-shadow 0.25s ease, transform 0.25s ease;
box-shadow: 0 8px 18px -8px rgba(192, 18, 39, 0.55);
display: inline-flex;
align-items: center;
justify-content: center;
width: auto;
}
.dm-hiw-3d .section-close-btn:hover {
background-color: #a30f20;
box-shadow: 0 10px 22px -8px rgba(192, 18, 39, 0.7);
transform: translateY(-1px);
}
.dm-hiw-3d .section-close-btn:active {
transform: translateY(0);
}
/* ---- Story stage text panels ---- */
.dm-hiw-3d .sections-overlay-container {
position: absolute;
inset: 0;
z-index: 8;
pointer-events: none; /* Let clicks pass to 3D canvas */
display: flex;
align-items: center;
}
/* Side placement keeps the moving truck / scene visible in the centre, with the
card vertically centred (top/bottom:0 + margin:auto works with the GSAP reveal,
which only animates translateY/scale). */
.dm-hiw-3d #first-mile-section,
.dm-hiw-3d #mid-mile-section,
.dm-hiw-3d #last-mile-section {
top: 0;
bottom: 0;
height: max-content;
margin-top: auto;
margin-bottom: auto;
}
.dm-hiw-3d #first-mile-section,
.dm-hiw-3d #last-mile-section {
left: clamp(24px, 5vw, 72px);
}
.dm-hiw-3d #mid-mile-section {
right: clamp(24px, 5vw, 72px);
}
/* Final ecosystem panel — same card system, centred, a touch wider for its
timeline, and reduced from the old 500px so it no longer blocks the scene. */
.dm-hiw-3d #analytics-section {
left: 50%;
right: auto;
top: 50%;
transform: translate(-50%, -50%) translateY(18px) scale(0.97);
width: min(400px, 90vw);
max-height: calc(100vh - 140px);
}
/* ---- Unified card — all four stages share this exact chrome ---- */
.dm-hiw-3d .section-panel {
position: absolute;
width: min(340px, 31vw);
max-height: calc(100vh - 168px); /* never taller than the viewport */
overflow-y: auto;
padding: 24px 26px 26px;
background: var(--dm-card-bg);
backdrop-filter: blur(0px);
-webkit-backdrop-filter: blur(0px);
border: 1px solid var(--dm-card-border);
border-top: 3px solid var(--dm-red); /* brand accent that unifies every card */
border-radius: var(--dm-card-radius);
box-shadow: var(--dm-card-shadow);
opacity: 0;
transform: translateY(18px) scale(0.97);
visibility: hidden;
transition: backdrop-filter 0.9s cubic-bezier(0.16, 1, 0.3, 1),
-webkit-backdrop-filter 0.9s cubic-bezier(0.16, 1, 0.3, 1),
visibility 0.9s;
pointer-events: none;
box-sizing: border-box;
}
.dm-hiw-3d .section-panel.active {
visibility: visible;
backdrop-filter: blur(22px) saturate(140%);
-webkit-backdrop-filter: blur(22px) saturate(140%);
pointer-events: auto;
}
/* Typography — one scale across all cards. !important defeats the page's
Elementor h2/h3 styles (loaded via /css/site.css with their own !important),
which were inflating these headings to ~80px and overflowing the cards. */
.dm-hiw-3d .section-badge {
display: inline-block;
font-family: var(--dm-font-body) !important;
font-size: 10.5px !important;
text-transform: uppercase !important;
font-weight: 700 !important;
letter-spacing: 0.14em !important;
color: var(--dm-red) !important;
margin: 0 0 10px !important;
}
.dm-hiw-3d .section-title {
font-family: var(--dm-font-head) !important;
font-size: clamp(18px, 1.45vw, 22px) !important;
font-weight: 700 !important;
line-height: 1.15 !important;
letter-spacing: -0.015em !important;
text-transform: none !important;
color: var(--dm-ink) !important;
margin: 0 0 4px !important;
}
.dm-hiw-3d .section-subtitle {
font-family: var(--dm-font-body) !important;
font-size: 13px !important;
font-weight: 500 !important;
line-height: 1.35 !important;
letter-spacing: 0 !important;
text-transform: none !important;
color: var(--dm-muted) !important;
margin: 0 0 14px !important;
}
.dm-hiw-3d .section-description {
font-family: var(--dm-font-body) !important;
font-size: 13px !important;
font-weight: 400 !important;
line-height: 1.55 !important;
color: var(--dm-body) !important;
margin: 0 0 18px !important;
}
.dm-hiw-3d .section-metrics {
display: flex;
gap: 18px;
border-top: 1px solid rgba(15, 23, 42, 0.08);
padding-top: 16px;
}
.dm-hiw-3d .metric-item {
display: flex;
flex-direction: column;
gap: 3px;
flex: 1;
}
.dm-hiw-3d .metric-value {
font-family: var(--dm-font-head) !important;
font-size: 18px !important;
font-weight: 700 !important;
line-height: 1.1 !important;
letter-spacing: -0.01em !important;
color: var(--dm-ink) !important;
}
.dm-hiw-3d .metric-label {
font-family: var(--dm-font-body) !important;
font-size: 9.5px !important;
font-weight: 600 !important;
text-transform: uppercase !important;
letter-spacing: 0.07em !important;
color: var(--dm-muted) !important;
}
.dm-hiw-3d .font-green .metric-value { color: #1f9d57 !important; }
/* ---- Animations (keyframes left global; uniquely named) ---- */
@keyframes dmHiwScrollWheel {
0% { top: 6px; opacity: 1; height: 6px; }
50% { top: 14px; opacity: 0.3; height: 4px; }
100% { top: 6px; opacity: 1; height: 6px; }
}
@keyframes dmHiwPulseGreen {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}
@keyframes dmHiwMoveArrow {
0%, 100% { transform: translateX(0); }
50% { transform: translateX(8px); }
}
/* ---- Final-panel timeline (inside #analytics-section) ---- */
.dm-hiw-3d .workflow-steps {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 16px;
}
.dm-hiw-3d .workflow-step { display: flex; gap: 14px; }
.dm-hiw-3d .step-number-container {
display: flex;
flex-direction: column;
align-items: center;
width: 24px;
}
.dm-hiw-3d .step-number {
font-family: var(--dm-font-head);
font-size: 11px;
font-weight: 700;
color: var(--dm-red);
background: var(--dm-red-soft);
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(192, 18, 39, 0.2);
flex-shrink: 0;
}
.dm-hiw-3d .step-line {
width: 2px;
flex-grow: 1;
background: linear-gradient(to bottom, var(--dm-red) 0%, rgba(192, 18, 39, 0.12) 100%);
margin: 5px 0;
min-height: 16px;
border-radius: 2px;
}
.dm-hiw-3d .step-content { flex-grow: 1; padding-bottom: 14px; }
.dm-hiw-3d .step-title {
font-family: var(--dm-font-head) !important;
font-size: 14px !important;
font-weight: 700 !important;
line-height: 1.2 !important;
color: var(--dm-ink) !important;
margin: 1px 0 3px !important;
}
.dm-hiw-3d .step-description {
font-family: var(--dm-font-body) !important;
font-size: 12px !important;
line-height: 1.5 !important;
color: var(--dm-body) !important;
margin: 0 !important;
}
/* ---- Responsive ---- */
@media (max-width: 1024px) {
.dm-hiw-3d .sections-overlay-container {
padding-left: 0;
padding-right: 0;
align-items: flex-end;
padding-bottom: 50px;
}
.dm-hiw-3d .section-panel {
padding: 20px 22px 22px;
border-radius: 16px;
}
/* Tablet/mobile: cards bottom-centred so the truck/scene stays visible above. */
.dm-hiw-3d #first-mile-section,
.dm-hiw-3d #mid-mile-section,
.dm-hiw-3d #last-mile-section,
.dm-hiw-3d #analytics-section {
left: 50% !important;
right: auto !important;
top: auto !important;
bottom: 64px !important;
transform: translateX(-50%) translateY(18px) scale(0.97) !important;
width: min(380px, calc(100vw - 40px)) !important;
max-height: calc(100vh - 200px) !important;
}
.dm-hiw-3d #first-mile-section.active,
.dm-hiw-3d #mid-mile-section.active,
.dm-hiw-3d #last-mile-section.active,
.dm-hiw-3d #analytics-section.active {
transform: translateX(-50%) translateY(0) scale(1) !important;
}
.dm-hiw-3d .side-navigation {
bottom: 12px;
top: auto;
right: auto;
left: 50%;
transform: translateX(-50%);
flex-direction: row;
gap: 18px;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 20px;
padding: 8px 16px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
}
.dm-hiw-3d .side-nav-item { justify-content: center; padding: 0; }
.dm-hiw-3d .side-nav-label { display: none; }
}
@media (max-width: 400px) {
.dm-hiw-3d .section-panel,
.dm-hiw-3d #analytics-section {
padding: 16px 18px 18px !important;
width: min(340px, calc(100vw - 28px)) !important;
bottom: 56px !important;
}
.dm-hiw-3d .section-badge {
font-size: 9.5px !important;
margin-bottom: 6px !important;
}
.dm-hiw-3d .section-title {
font-size: 18px !important;
}
.dm-hiw-3d .section-subtitle {
font-size: 12.5px !important;
margin-bottom: 10px !important;
}
.dm-hiw-3d .section-description {
font-size: 12px !important;
line-height: 1.45 !important;
margin-bottom: 14px !important;
}
.dm-hiw-3d .section-metrics {
padding-top: 12px !important;
gap: 14px !important;
}
.dm-hiw-3d .metric-value { font-size: 16px !important; }
.dm-hiw-3d .metric-label { font-size: 9px !important; }
.dm-hiw-3d .step-title { font-size: 13px !important; }
.dm-hiw-3d .step-description { font-size: 11.5px !important; }
.dm-hiw-3d .side-navigation {
bottom: 8px !important;
gap: 12px !important;
padding: 6px 12px !important;
}
.dm-hiw-3d .side-nav-dot { width: 6px !important; height: 6px !important; }
.dm-hiw-3d #first-mile-section,
.dm-hiw-3d #mid-mile-section,
.dm-hiw-3d #last-mile-section,
.dm-hiw-3d #analytics-section {
left: 50% !important;
right: auto !important;
bottom: 56px !important;
width: min(340px, calc(100vw - 28px)) !important;
transform: translateX(-50%) translateY(18px) scale(0.97) !important;
}
.dm-hiw-3d #first-mile-section.active,
.dm-hiw-3d #mid-mile-section.active,
.dm-hiw-3d #last-mile-section.active,
.dm-hiw-3d #analytics-section.active {
transform: translateX(-50%) translateY(0) scale(1) !important;
}
}

View File

@@ -0,0 +1,89 @@
let audioContext = null;
let isUnlocked = false;
// Initialize and unlock audio context
export const initAudio = () => {
if (isUnlocked) return;
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
if (!AudioContextClass) return;
if (!audioContext) {
audioContext = new AudioContextClass();
}
// Resume context if suspended (browser autoplay policy)
if (audioContext.state === 'suspended') {
audioContext.resume().then(() => {
isUnlocked = true;
cleanupListeners();
}).catch(() => {});
} else {
isUnlocked = true;
cleanupListeners();
}
};
const cleanupListeners = () => {
window.removeEventListener('click', initAudio);
window.removeEventListener('keydown', initAudio);
window.removeEventListener('touchstart', initAudio);
window.removeEventListener('wheel', initAudio);
};
// Add listeners for early activation
if (typeof window !== 'undefined') {
window.addEventListener('click', initAudio, { passive: true });
window.addEventListener('keydown', initAudio, { passive: true });
window.addEventListener('touchstart', initAudio, { passive: true });
window.addEventListener('wheel', initAudio, { passive: true });
}
// Play a high-tech UI chime sound for card reveal
export const playRevealChime = () => {
try {
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
if (!AudioContextClass) return;
if (!audioContext) {
audioContext = new AudioContextClass();
}
if (audioContext.state === 'suspended') {
audioContext.resume().catch(() => {});
}
const now = audioContext.currentTime;
// Master Volume node with exponential decay
const masterGain = audioContext.createGain();
masterGain.gain.setValueAtTime(0, now);
masterGain.gain.linearRampToValueAtTime(0.15, now + 0.04); // subtle fade-in to avoid clicking
masterGain.gain.exponentialRampToValueAtTime(0.0001, now + 0.4); // smooth tail decay
// Warm base oscillator (triangle wave)
const baseOsc = audioContext.createOscillator();
baseOsc.type = 'triangle';
baseOsc.frequency.setValueAtTime(329.63, now); // E4 pitch
baseOsc.frequency.exponentialRampToValueAtTime(523.25, now + 0.25); // Slide up to C5
// High harmonic chime oscillator (sine wave)
const chimeOsc = audioContext.createOscillator();
chimeOsc.type = 'sine';
chimeOsc.frequency.setValueAtTime(659.25, now); // E5 pitch
chimeOsc.frequency.exponentialRampToValueAtTime(1046.50, now + 0.25); // Slide up to C6
// Connect nodes
baseOsc.connect(masterGain);
chimeOsc.connect(masterGain);
masterGain.connect(audioContext.destination);
// Play oscillators
baseOsc.start(now);
baseOsc.stop(now + 0.4);
chimeOsc.start(now);
chimeOsc.stop(now + 0.4);
} catch (error) {
console.warn('Playback of reveal chime failed:', error);
}
};

View File

@@ -0,0 +1,15 @@
export const easeInOutCubic = (t) => {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2
}
export const easeOutQuad = (t) => {
return t * (2 - t)
}
export const easeInQuad = (t) => {
return t * t
}
export const easeOutCubic = (t) => {
return 1 - Math.pow(1 - t, 3)
}

View File

@@ -0,0 +1,41 @@
import * as THREE from 'three'
// Linear interpolation
export const lerp = (start, end, amt) => {
return (1 - amt) * start + amt * end
}
// Map a number from [inMin, inMax] to [outMin, outMax]
export const mapRange = (value, inMin, inMax, outMin, outMax) => {
const result = ((value - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin
return isNaN(result) ? outMin : result
}
// Clamp a number between min and max
export const clamp = (value, min, max) => {
return Math.min(Math.max(value, min), max)
}
// Vector3 interpolation helper
export const lerpVectors = (v1, v2, alpha, outVector = new THREE.Vector3()) => {
outVector.x = lerp(v1.x, v2.x, alpha)
outVector.y = lerp(v1.y, v2.y, alpha)
outVector.z = lerp(v1.z, v2.z, alpha)
return outVector
}
// Convert a normalized scroll progress (0..1) within the experience into an
// absolute document scrollY, relative to the 900vh ScrollRig spacer. Because the
// experience now sits below the page hero (not at document top), jump-to-section
// targets must be measured from the spacer's offset rather than the document top.
// The spacer (#scroll-trigger-trigger) maps progress over the scroll span
// [spacerTop, spacerTop + (spacerHeight - viewportHeight)] — matching the
// ScrollTrigger start:'top top' / end:'bottom bottom' on the same element.
export const progressToScrollY = (progress) => {
if (typeof document === 'undefined') return 0
const rig = document.getElementById('scroll-trigger-trigger')
if (!rig) return 0
const top = rig.getBoundingClientRect().top + window.scrollY
const scrollable = rig.offsetHeight - window.innerHeight
return top + clamp(progress, 0, 1) * scrollable
}