Compare commits
2 Commits
e93785f2b6
...
0ef51540e9
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ef51540e9 | |||
| 3d53f82e7b |
@@ -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/**",
|
||||
]),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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
36
package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
BIN
public/models/3d_scene_final.glb
Normal file
BIN
public/models/3d_scene_final.glb
Normal file
Binary file not shown.
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
1–2 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>
|
||||
|
||||
158
src/modules/how-it-works-3d/Experience3D.jsx
Normal file
158
src/modules/how-it-works-3d/Experience3D.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
20
src/modules/how-it-works-3d/Experience3DLoader.tsx
Normal file
20
src/modules/how-it-works-3d/Experience3DLoader.tsx
Normal 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 />;
|
||||
}
|
||||
21
src/modules/how-it-works-3d/animations/cameraTimeline.js
Normal file
21
src/modules/how-it-works-3d/animations/cameraTimeline.js
Normal 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
|
||||
}
|
||||
24
src/modules/how-it-works-3d/animations/dashboardAnimation.js
Normal file
24
src/modules/how-it-works-3d/animations/dashboardAnimation.js
Normal 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
|
||||
}
|
||||
})
|
||||
}
|
||||
18
src/modules/how-it-works-3d/animations/truckTimeline.js
Normal file
18
src/modules/how-it-works-3d/animations/truckTimeline.js
Normal 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
|
||||
}
|
||||
14
src/modules/how-it-works-3d/animations/wheelAnimation.js
Normal file
14
src/modules/how-it-works-3d/animations/wheelAnimation.js
Normal 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
|
||||
}
|
||||
})
|
||||
}
|
||||
48
src/modules/how-it-works-3d/components/CameraRig.jsx
Normal file
48
src/modules/how-it-works-3d/components/CameraRig.jsx
Normal 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
|
||||
}
|
||||
143
src/modules/how-it-works-3d/components/Experience.jsx
Normal file
143
src/modules/how-it-works-3d/components/Experience.jsx
Normal 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>
|
||||
)
|
||||
})
|
||||
|
||||
103
src/modules/how-it-works-3d/components/ScrollRig.jsx
Normal file
103
src/modules/how-it-works-3d/components/ScrollRig.jsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
93
src/modules/how-it-works-3d/components/StreetLights.jsx
Normal file
93
src/modules/how-it-works-3d/components/StreetLights.jsx
Normal 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>
|
||||
)
|
||||
})
|
||||
175
src/modules/how-it-works-3d/components/TruckAnimation.jsx
Normal file
175
src/modules/how-it-works-3d/components/TruckAnimation.jsx
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
52
src/modules/how-it-works-3d/components/sections/LastMile.jsx
Normal file
52
src/modules/how-it-works-3d/components/sections/LastMile.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
52
src/modules/how-it-works-3d/components/sections/MidMile.jsx
Normal file
52
src/modules/how-it-works-3d/components/sections/MidMile.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
22
src/modules/how-it-works-3d/components/ui/Hero.jsx
Normal file
22
src/modules/how-it-works-3d/components/ui/Hero.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
38
src/modules/how-it-works-3d/components/ui/Navbar.jsx
Normal file
38
src/modules/how-it-works-3d/components/ui/Navbar.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
89
src/modules/how-it-works-3d/components/ui/RevealCard.jsx
Normal file
89
src/modules/how-it-works-3d/components/ui/RevealCard.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
35
src/modules/how-it-works-3d/constants/cameraPositions.js
Normal file
35
src/modules/how-it-works-3d/constants/cameraPositions.js
Normal 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),
|
||||
},
|
||||
]
|
||||
12
src/modules/how-it-works-3d/constants/colors.js
Normal file
12
src/modules/how-it-works-3d/constants/colors.js
Normal 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)',
|
||||
}
|
||||
34
src/modules/how-it-works-3d/constants/sectionConfig.js
Normal file
34
src/modules/how-it-works-3d/constants/sectionConfig.js
Normal 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,
|
||||
},
|
||||
]
|
||||
27
src/modules/how-it-works-3d/curves/truckPath.js
Normal file
27
src/modules/how-it-works-3d/curves/truckPath.js
Normal 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)
|
||||
164
src/modules/how-it-works-3d/hooks/useCameraAnimation.js
Normal file
164
src/modules/how-it-works-3d/hooks/useCameraAnimation.js
Normal 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
|
||||
16
src/modules/how-it-works-3d/hooks/useScrollProgress.js
Normal file
16
src/modules/how-it-works-3d/hooks/useScrollProgress.js
Normal 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
|
||||
52
src/modules/how-it-works-3d/hooks/useTruckMovement.js
Normal file
52
src/modules/how-it-works-3d/hooks/useTruckMovement.js
Normal 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
|
||||
11596
src/modules/how-it-works-3d/models/Scene3D.jsx
Normal file
11596
src/modules/how-it-works-3d/models/Scene3D.jsx
Normal file
File diff suppressed because it is too large
Load Diff
14
src/modules/how-it-works-3d/store/useSceneStore.js
Normal file
14
src/modules/how-it-works-3d/store/useSceneStore.js
Normal 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 }),
|
||||
}))
|
||||
485
src/modules/how-it-works-3d/styles/experience.css
Normal file
485
src/modules/how-it-works-3d/styles/experience.css
Normal 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;
|
||||
}
|
||||
}
|
||||
89
src/modules/how-it-works-3d/utils/audioHelper.js
Normal file
89
src/modules/how-it-works-3d/utils/audioHelper.js
Normal 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);
|
||||
}
|
||||
};
|
||||
15
src/modules/how-it-works-3d/utils/easing.js
Normal file
15
src/modules/how-it-works-3d/utils/easing.js
Normal 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)
|
||||
}
|
||||
41
src/modules/how-it-works-3d/utils/helpers.js
Normal file
41
src/modules/how-it-works-3d/utils/helpers.js
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user