update screen fix
This commit is contained in:
10
package-lock.json
generated
10
package-lock.json
generated
@@ -8,6 +8,7 @@
|
|||||||
"name": "doormile-next",
|
"name": "doormile-next",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@emailjs/browser": "^4.4.1",
|
||||||
"@react-three/drei": "^10.7.7",
|
"@react-three/drei": "^10.7.7",
|
||||||
"@react-three/fiber": "^9.6.1",
|
"@react-three/fiber": "^9.6.1",
|
||||||
"@react-three/postprocessing": "^3.0.4",
|
"@react-three/postprocessing": "^3.0.4",
|
||||||
@@ -722,6 +723,15 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@emailjs/browser": {
|
||||||
|
"version": "4.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emailjs/browser/-/browser-4.4.1.tgz",
|
||||||
|
"integrity": "sha512-DGSlP9sPvyFba3to2A50kDtZ+pXVp/0rhmqs2LmbMS3I5J8FSOgLwzY2Xb4qfKlOVHh29EAutLYwe5yuEZmEFg==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.10.0",
|
"version": "1.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"test:coverage": "jest --coverage"
|
"test:coverage": "jest --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@emailjs/browser": "^4.4.1",
|
||||||
"@react-three/drei": "^10.7.7",
|
"@react-three/drei": "^10.7.7",
|
||||||
"@react-three/fiber": "^9.6.1",
|
"@react-three/fiber": "^9.6.1",
|
||||||
"@react-three/postprocessing": "^3.0.4",
|
"@react-three/postprocessing": "^3.0.4",
|
||||||
|
|||||||
@@ -2663,7 +2663,7 @@ iframe {
|
|||||||
margin-bottom: 0
|
margin-bottom: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
.wp-block-group.has-background {
|
.dm-block-group.has-background {
|
||||||
border-radius: var(--logico-radius-medium,0)
|
border-radius: var(--logico-radius-medium,0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5487,11 +5487,11 @@ body:not(.block-editor-page) .content-wrapper .widget p {
|
|||||||
margin-top: -.25em
|
margin-top: -.25em
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar .widget-wrapper>.wp-block-title:not(:last-child),.sidebar .wp-block-group>.wp-block-title:not(:last-child) {
|
.sidebar .widget-wrapper>.wp-block-title:not(:last-child),.sidebar .dm-block-group>.wp-block-title:not(:last-child) {
|
||||||
margin: 0 0 .95em
|
margin: 0 0 .95em
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar .widget-wrapper>.wp-block-title:first-child h1,.sidebar .widget-wrapper>.wp-block-title:first-child h2,.sidebar .widget-wrapper>.wp-block-title:first-child h3,.sidebar .widget-wrapper>.wp-block-title:first-child h4,.sidebar .widget-wrapper>.wp-block-title:first-child h5,.sidebar .widget-wrapper>.wp-block-title:first-child h6,.sidebar .wp-block-group>.wp-block-title:first-child h1,.sidebar .wp-block-group>.wp-block-title:first-child h2,.sidebar .wp-block-group>.wp-block-title:first-child h3,.sidebar .wp-block-group>.wp-block-title:first-child h4,.sidebar .wp-block-group>.wp-block-title:first-child h5,.sidebar .wp-block-group>.wp-block-title:first-child h6 {
|
.sidebar .widget-wrapper>.wp-block-title:first-child h1,.sidebar .widget-wrapper>.wp-block-title:first-child h2,.sidebar .widget-wrapper>.wp-block-title:first-child h3,.sidebar .widget-wrapper>.wp-block-title:first-child h4,.sidebar .widget-wrapper>.wp-block-title:first-child h5,.sidebar .widget-wrapper>.wp-block-title:first-child h6,.sidebar .dm-block-group>.wp-block-title:first-child h1,.sidebar .dm-block-group>.wp-block-title:first-child h2,.sidebar .dm-block-group>.wp-block-title:first-child h3,.sidebar .dm-block-group>.wp-block-title:first-child h4,.sidebar .dm-block-group>.wp-block-title:first-child h5,.sidebar .dm-block-group>.wp-block-title:first-child h6 {
|
||||||
margin: 0!important;
|
margin: 0!important;
|
||||||
padding: 0 1.5em 1.05em 0;
|
padding: 0 1.5em 1.05em 0;
|
||||||
border-bottom: solid 1px;
|
border-bottom: solid 1px;
|
||||||
@@ -5501,7 +5501,7 @@ body:not(.block-editor-page) .content-wrapper .widget p {
|
|||||||
font: 600 normal 20px/1.25em var(--logico-body-font-family)
|
font: 600 normal 20px/1.25em var(--logico-body-font-family)
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar .widget-wrapper>.wp-block-title:first-child h1:after,.sidebar .widget-wrapper>.wp-block-title:first-child h2:after,.sidebar .widget-wrapper>.wp-block-title:first-child h3:after,.sidebar .widget-wrapper>.wp-block-title:first-child h4:after,.sidebar .widget-wrapper>.wp-block-title:first-child h5:after,.sidebar .widget-wrapper>.wp-block-title:first-child h6:after,.sidebar .wp-block-group>.wp-block-title:first-child h1:after,.sidebar .wp-block-group>.wp-block-title:first-child h2:after,.sidebar .wp-block-group>.wp-block-title:first-child h3:after,.sidebar .wp-block-group>.wp-block-title:first-child h4:after,.sidebar .wp-block-group>.wp-block-title:first-child h5:after,.sidebar .wp-block-group>.wp-block-title:first-child h6:after {
|
.sidebar .widget-wrapper>.wp-block-title:first-child h1:after,.sidebar .widget-wrapper>.wp-block-title:first-child h2:after,.sidebar .widget-wrapper>.wp-block-title:first-child h3:after,.sidebar .widget-wrapper>.wp-block-title:first-child h4:after,.sidebar .widget-wrapper>.wp-block-title:first-child h5:after,.sidebar .widget-wrapper>.wp-block-title:first-child h6:after,.sidebar .dm-block-group>.wp-block-title:first-child h1:after,.sidebar .dm-block-group>.wp-block-title:first-child h2:after,.sidebar .dm-block-group>.wp-block-title:first-child h3:after,.sidebar .dm-block-group>.wp-block-title:first-child h4:after,.sidebar .dm-block-group>.wp-block-title:first-child h5:after,.sidebar .dm-block-group>.wp-block-title:first-child h6:after {
|
||||||
content: '\e80a';
|
content: '\e80a';
|
||||||
display: block;
|
display: block;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -8089,11 +8089,11 @@ h1:where(.wp-block-heading).has-background,
|
|||||||
/*# sourceURL=local */
|
/*# sourceURL=local */
|
||||||
|
|
||||||
/* STYLE BLOCK 8 */
|
/* STYLE BLOCK 8 */
|
||||||
.wp-block-group {
|
.dm-block-group {
|
||||||
box-sizing: border-box
|
box-sizing: border-box
|
||||||
}
|
}
|
||||||
|
|
||||||
:where(.wp-block-group.wp-block-group-is-layout-constrained) {
|
:where(.dm-block-group.dm-block-group-is-layout-constrained) {
|
||||||
position: relative
|
position: relative
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import emailjs from "@emailjs/browser";
|
||||||
import { ScrollReveal } from "@/animations/Reveal";
|
import { ScrollReveal } from "@/animations/Reveal";
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
@@ -45,15 +46,37 @@ export default function Footer() {
|
|||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||||
|
// Clear any stale success/error message once the user resumes editing.
|
||||||
|
if (formStatus === "success" || formStatus === "error") setFormStatus("idle");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
// EmailJS is fully client-side — only the public-safe credentials are used
|
||||||
|
// (Service ID, Template ID, Public Key). No private/secret key, no backend route.
|
||||||
|
const serviceId = process.env.NEXT_PUBLIC_EMAILJS_SERVICE_ID;
|
||||||
|
const templateId = process.env.NEXT_PUBLIC_EMAILJS_TEMPLATE_ID;
|
||||||
|
const publicKey = process.env.NEXT_PUBLIC_EMAILJS_PUBLIC_KEY;
|
||||||
|
if (!serviceId || !templateId || !publicKey) {
|
||||||
|
console.error("EmailJS env vars are missing — set NEXT_PUBLIC_EMAILJS_* in .env.local");
|
||||||
|
setFormStatus("error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setFormStatus("submitting");
|
setFormStatus("submitting");
|
||||||
try {
|
try {
|
||||||
// Simulate API submission
|
await emailjs.send(
|
||||||
console.log("Footer contact form submitted:", formData);
|
serviceId,
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
templateId,
|
||||||
|
{
|
||||||
|
name: formData.fullName,
|
||||||
|
email: formData.email,
|
||||||
|
subject: formData.subject,
|
||||||
|
message: formData.message,
|
||||||
|
},
|
||||||
|
publicKey,
|
||||||
|
);
|
||||||
setFormStatus("success");
|
setFormStatus("success");
|
||||||
setFormData({ fullName: "", email: "", subject: "", message: "" });
|
setFormData({ fullName: "", email: "", subject: "", message: "" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -285,12 +308,12 @@ export default function Footer() {
|
|||||||
</button>
|
</button>
|
||||||
{formStatus === "success" && (
|
{formStatus === "success" && (
|
||||||
<div style={{ color: "#4caf50", marginTop: "10px", fontSize: "14px" }}>
|
<div style={{ color: "#4caf50", marginTop: "10px", fontSize: "14px" }}>
|
||||||
Message sent successfully!
|
Message sent successfully.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{formStatus === "error" && (
|
{formStatus === "error" && (
|
||||||
<div style={{ color: "#f44336", marginTop: "10px", fontSize: "14px" }}>
|
<div style={{ color: "#f44336", marginTop: "10px", fontSize: "14px" }}>
|
||||||
Something went wrong. Please try again.
|
Failed to send message. Please try again.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ export default function Header() {
|
|||||||
<div className="slide-sidebar-content">
|
<div className="slide-sidebar-content">
|
||||||
<div id="block-37" className="widget widget_block">
|
<div id="block-37" className="widget widget_block">
|
||||||
<div className="widget-wrapper">
|
<div className="widget-wrapper">
|
||||||
<div className="wp-block-group is-layout-constrained wp-block-group-is-layout-constrained">
|
<div className="dm-block-group is-layout-constrained dm-block-group-is-layout-constrained">
|
||||||
<figure className="wp-block-image size-full is-resized">
|
<figure className="wp-block-image size-full is-resized">
|
||||||
<Image
|
<Image
|
||||||
width={305}
|
width={305}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useRef } from "react";
|
import React, { useRef, useEffect } from "react";
|
||||||
import { Canvas, useFrame } from "@react-three/fiber";
|
import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
||||||
import { EffectComposer, Bloom } from "@react-three/postprocessing";
|
import { EffectComposer, Bloom } from "@react-three/postprocessing";
|
||||||
import { KernelSize } from "postprocessing";
|
import { KernelSize } from "postprocessing";
|
||||||
import { COLORS } from "./constants";
|
import { COLORS } from "./constants";
|
||||||
@@ -15,40 +15,78 @@ type Props = {
|
|||||||
progress: React.RefObject<number>;
|
progress: React.RefObject<number>;
|
||||||
reduced?: boolean;
|
reduced?: boolean;
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
|
isTablet?: boolean;
|
||||||
/** Pause the render loop when the section is scrolled off-screen. */
|
/** Pause the render loop when the section is scrolled off-screen. */
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device-specific camera framing. The mobile/tablet scene renders into a much
|
||||||
|
* smaller, near-square block than the desktop full-screen card, so the camera
|
||||||
|
* is pulled back, raised, and widened (higher fov) to keep every node in frame
|
||||||
|
* without clipping. `lerp(start, end, progress)` eases from the chaotic wide
|
||||||
|
* view to the settled framing as the narrative progresses.
|
||||||
|
*/
|
||||||
|
type CameraFraming = {
|
||||||
|
radiusStart: number;
|
||||||
|
radiusEnd: number;
|
||||||
|
heightStart: number;
|
||||||
|
heightEnd: number;
|
||||||
|
lookAtY: number;
|
||||||
|
fov: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FRAMING: Record<"desktop" | "tablet" | "mobile", CameraFraming> = {
|
||||||
|
desktop: { radiusStart: 17, radiusEnd: 13, heightStart: 9, heightEnd: 6.5, lookAtY: 2.4, fov: 50 },
|
||||||
|
tablet: { radiusStart: 19, radiusEnd: 15, heightStart: 9.5, heightEnd: 7, lookAtY: 2.6, fov: 54 },
|
||||||
|
mobile: { radiusStart: 22, radiusEnd: 18, heightStart: 11, heightEnd: 8, lookAtY: 3, fov: 62 },
|
||||||
|
};
|
||||||
|
|
||||||
/** Slow cinematic camera move from a high chaotic view to a settled framing. */
|
/** Slow cinematic camera move from a high chaotic view to a settled framing. */
|
||||||
function CameraRig({ progress }: { progress: React.RefObject<number> }) {
|
function CameraRig({ progress, framing }: { progress: React.RefObject<number>; framing: CameraFraming }) {
|
||||||
const eased = useRef(0);
|
const eased = useRef(0);
|
||||||
|
const camera = useThree((s) => s.camera);
|
||||||
|
|
||||||
|
// Re-frame on device change (orientation / breakpoint crossing): the fov is a
|
||||||
|
// construction-time prop on <Canvas>, so update it imperatively here.
|
||||||
|
useEffect(() => {
|
||||||
|
if ("fov" in camera) {
|
||||||
|
(camera as THREE_PerspectiveCamera).fov = framing.fov;
|
||||||
|
(camera as THREE_PerspectiveCamera).updateProjectionMatrix();
|
||||||
|
}
|
||||||
|
}, [camera, framing.fov]);
|
||||||
|
|
||||||
useFrame((state, dt) => {
|
useFrame((state, dt) => {
|
||||||
const p = progress.current ?? 0;
|
const p = progress.current ?? 0;
|
||||||
eased.current = damp(eased.current, p, 1.5, dt);
|
eased.current = damp(eased.current, p, 1.5, dt);
|
||||||
const e = eased.current;
|
const e = eased.current;
|
||||||
const t = state.clock.elapsedTime;
|
const t = state.clock.elapsedTime;
|
||||||
|
|
||||||
const radius = lerp(17, 13, e);
|
const radius = lerp(framing.radiusStart, framing.radiusEnd, e);
|
||||||
const angle = lerp(-0.5, 0.45, e) + t * 0.02;
|
const angle = lerp(-0.5, 0.45, e) + t * 0.02;
|
||||||
const height = lerp(9, 6.5, e) + Math.sin(t * 0.4) * 0.3;
|
const height = lerp(framing.heightStart, framing.heightEnd, e) + Math.sin(t * 0.4) * 0.3;
|
||||||
|
|
||||||
const cam = state.camera;
|
const cam = state.camera;
|
||||||
cam.position.x = Math.sin(angle) * radius;
|
cam.position.x = Math.sin(angle) * radius;
|
||||||
cam.position.z = Math.cos(angle) * radius;
|
cam.position.z = Math.cos(angle) * radius;
|
||||||
cam.position.y = height;
|
cam.position.y = height;
|
||||||
cam.lookAt(0, 2.4, 0);
|
cam.lookAt(0, framing.lookAtY, 0);
|
||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function OptimizationCanvas({ progress, reduced = false, isMobile = false, active = true }: Props) {
|
// Minimal structural type so we can set fov without importing three's types here.
|
||||||
|
type THREE_PerspectiveCamera = { fov: number; updateProjectionMatrix: () => void };
|
||||||
|
|
||||||
|
function OptimizationCanvas({ progress, reduced = false, isMobile = false, isTablet = false, active = true }: Props) {
|
||||||
const cityCount = isMobile ? 48 : 90;
|
const cityCount = isMobile ? 48 : 90;
|
||||||
|
const framing = isMobile ? FRAMING.mobile : isTablet ? FRAMING.tablet : FRAMING.desktop;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Canvas
|
<Canvas
|
||||||
flat
|
flat
|
||||||
dpr={[1, isMobile || reduced ? 1.25 : 1.5]}
|
dpr={[1, isMobile || reduced ? 1.25 : 1.5]}
|
||||||
camera={{ position: [0, 9, 19], fov: 50, near: 0.1, far: 120 }}
|
camera={{ position: [0, framing.heightStart, framing.radiusStart], fov: framing.fov, near: 0.1, far: 120 }}
|
||||||
gl={{ antialias: !isMobile, powerPreference: "high-performance", alpha: false }}
|
gl={{ antialias: !isMobile, powerPreference: "high-performance", alpha: false }}
|
||||||
frameloop={active ? "always" : "never"}
|
frameloop={active ? "always" : "never"}
|
||||||
>
|
>
|
||||||
@@ -56,7 +94,7 @@ function OptimizationCanvas({ progress, reduced = false, isMobile = false, activ
|
|||||||
<fog attach="fog" args={[COLORS.bg, 18, 52]} />
|
<fog attach="fog" args={[COLORS.bg, 18, 52]} />
|
||||||
<ambientLight intensity={0.6} />
|
<ambientLight intensity={0.6} />
|
||||||
|
|
||||||
<CameraRig progress={progress} />
|
<CameraRig progress={progress} framing={framing} />
|
||||||
<HologramCity progress={progress} count={cityCount} reduced={reduced} />
|
<HologramCity progress={progress} count={cityCount} reduced={reduced} />
|
||||||
<RouteSystem progress={progress} reduced={reduced} isMobile={isMobile} />
|
<RouteSystem progress={progress} reduced={reduced} isMobile={isMobile} />
|
||||||
<VehicleFleet progress={progress} reduced={reduced} />
|
<VehicleFleet progress={progress} reduced={reduced} />
|
||||||
@@ -68,10 +106,10 @@ function OptimizationCanvas({ progress, reduced = false, isMobile = false, activ
|
|||||||
<EffectComposer multisampling={isMobile ? 0 : 2}>
|
<EffectComposer multisampling={isMobile ? 0 : 2}>
|
||||||
<Bloom
|
<Bloom
|
||||||
mipmapBlur
|
mipmapBlur
|
||||||
intensity={isMobile ? 0.7 : 1.0}
|
intensity={isMobile ? 0.5 : 1.0}
|
||||||
luminanceThreshold={0.15}
|
luminanceThreshold={0.15}
|
||||||
luminanceSmoothing={0.04}
|
luminanceSmoothing={0.04}
|
||||||
radius={isMobile ? 0.6 : 0.75}
|
radius={isMobile ? 0.55 : 0.75}
|
||||||
kernelSize={KernelSize.MEDIUM}
|
kernelSize={KernelSize.MEDIUM}
|
||||||
/>
|
/>
|
||||||
</EffectComposer>
|
</EffectComposer>
|
||||||
|
|||||||
@@ -109,6 +109,46 @@ const LiveInsightBar = React.memo(function LiveInsightBar() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Inner content of the "Without Optimization" panel — shared by the desktop
|
||||||
|
* (scroll-reactive motion.aside) and mobile (static aside) layouts. */
|
||||||
|
function WithoutPanelBody() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="dm-opt-panel__badge">
|
||||||
|
<span className="dm-opt-pulse dm-opt-pulse--red" /> System: Congested
|
||||||
|
</div>
|
||||||
|
<h3>Without Optimization</h3>
|
||||||
|
<ul>
|
||||||
|
<li><span className="dm-opt-marker dm-opt-marker--x">✖</span> Chaotic overlapping routes</li>
|
||||||
|
<li><span className="dm-opt-marker dm-opt-marker--x">✖</span> Duplicate & idle trips</li>
|
||||||
|
<li><span className="dm-opt-marker dm-opt-marker--x">✖</span> 8 vehicles required</li>
|
||||||
|
<li><span className="dm-opt-marker dm-opt-marker--x">✖</span> 23 delivery delays</li>
|
||||||
|
<li><span className="dm-opt-marker dm-opt-marker--x">✖</span> +18% cost overrun</li>
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inner content of the "With Doormile AI" panel — shared by both layouts. */
|
||||||
|
function WithPanelBody() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="dm-opt-panel__badge dm-opt-panel__badge--good">
|
||||||
|
<span className="dm-opt-pulse dm-opt-pulse--green" /> System: Optimized
|
||||||
|
</div>
|
||||||
|
<h3>With Doormile AI</h3>
|
||||||
|
<ul>
|
||||||
|
<li><span className="dm-opt-marker dm-opt-marker--ok">✔</span> Optimized route clusters</li>
|
||||||
|
<li><span className="dm-opt-marker dm-opt-marker--ok">✔</span> Intelligent vehicle assignment</li>
|
||||||
|
<li><span className="dm-opt-marker dm-opt-marker--ok">✔</span> Multi-trip & EV planning</li>
|
||||||
|
<li><span className="dm-opt-marker dm-opt-marker--ok">✔</span> Zero delivery delays</li>
|
||||||
|
<li><span className="dm-opt-marker dm-opt-marker--ok">✔</span> 18% cost saved</li>
|
||||||
|
<li><span className="dm-opt-marker dm-opt-marker--ok">✔</span> Carbon footprint reduced</li>
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function OptimizationSection() {
|
export default function OptimizationSection() {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const progressRef = useRef(0);
|
const progressRef = useRef(0);
|
||||||
@@ -119,21 +159,30 @@ export default function OptimizationSection() {
|
|||||||
const [mountScene, setMountScene] = useState(false);
|
const [mountScene, setMountScene] = useState(false);
|
||||||
const [sceneActive, setSceneActive] = useState(false);
|
const [sceneActive, setSceneActive] = useState(false);
|
||||||
const [isMobile, setIsMobile] = useState(false);
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
const [isTablet, setIsTablet] = useState(false);
|
||||||
const [reduced, setReduced] = useState(false);
|
const [reduced, setReduced] = useState(false);
|
||||||
|
|
||||||
|
// Final-state metrics value for the mobile stack: a constant MotionValue so
|
||||||
|
// MetricsPanel renders the optimized numbers without scroll-driven counting.
|
||||||
|
const staticFinal = useMotionValue(1);
|
||||||
|
|
||||||
// Environment detection (client only).
|
// Environment detection (client only).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const mqMobile = window.matchMedia("(max-width: 767px)");
|
const mqMobile = window.matchMedia("(max-width: 767px)");
|
||||||
|
const mqTablet = window.matchMedia("(min-width: 768px) and (max-width: 1024px)");
|
||||||
const mqReduce = window.matchMedia("(prefers-reduced-motion: reduce)");
|
const mqReduce = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||||
const sync = () => {
|
const sync = () => {
|
||||||
setIsMobile(mqMobile.matches);
|
setIsMobile(mqMobile.matches);
|
||||||
|
setIsTablet(mqTablet.matches);
|
||||||
setReduced(mqReduce.matches);
|
setReduced(mqReduce.matches);
|
||||||
};
|
};
|
||||||
sync();
|
sync();
|
||||||
mqMobile.addEventListener("change", sync);
|
mqMobile.addEventListener("change", sync);
|
||||||
|
mqTablet.addEventListener("change", sync);
|
||||||
mqReduce.addEventListener("change", sync);
|
mqReduce.addEventListener("change", sync);
|
||||||
return () => {
|
return () => {
|
||||||
mqMobile.removeEventListener("change", sync);
|
mqMobile.removeEventListener("change", sync);
|
||||||
|
mqTablet.removeEventListener("change", sync);
|
||||||
mqReduce.removeEventListener("change", sync);
|
mqReduce.removeEventListener("change", sync);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
@@ -176,6 +225,10 @@ export default function OptimizationSection() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = containerRef.current;
|
const el = containerRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
// Mobile renders a non-pinned vertical stack (see the `isMobile` branch in
|
||||||
|
// render): no ScrollTrigger pin/scrub at all. Bail before creating one so
|
||||||
|
// pinState stays "before" and the section keeps its natural auto height.
|
||||||
|
if (isMobile) return;
|
||||||
gsap.registerPlugin(ScrollTrigger);
|
gsap.registerPlugin(ScrollTrigger);
|
||||||
|
|
||||||
// NOTE: global Lenis (src/animations/SmoothScroll.tsx) is active on this
|
// NOTE: global Lenis (src/animations/SmoothScroll.tsx) is active on this
|
||||||
@@ -213,7 +266,29 @@ export default function OptimizationSection() {
|
|||||||
clearTimeout(refresh);
|
clearTimeout(refresh);
|
||||||
st.kill();
|
st.kill();
|
||||||
};
|
};
|
||||||
}, [scroll]);
|
}, [scroll, isMobile]);
|
||||||
|
|
||||||
|
// Mobile ambient loop: with no scroll scrub, gently oscillate the shared
|
||||||
|
// progress inside the *optimized* band so the hologram stays "alive" and
|
||||||
|
// coherent with the final metrics. Held static under reduced-motion.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isMobile) return;
|
||||||
|
if (reduced) {
|
||||||
|
progressRef.current = 0.85;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!sceneActive) return;
|
||||||
|
let raf = 0;
|
||||||
|
let start = 0;
|
||||||
|
const tick = (ts: number) => {
|
||||||
|
if (!start) start = ts;
|
||||||
|
const t = (ts - start) / 1000;
|
||||||
|
progressRef.current = 0.76 + Math.sin(t * 0.5) * 0.16; // ~0.60 → 0.92
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
return () => cancelAnimationFrame(raf);
|
||||||
|
}, [isMobile, reduced, sceneActive]);
|
||||||
|
|
||||||
// Overlay reactions to scroll (no React re-render — direct DOM updates).
|
// Overlay reactions to scroll (no React re-render — direct DOM updates).
|
||||||
const leftOpacity = useTransform(scroll, [0.3, 0.55], [1, 0.32]);
|
const leftOpacity = useTransform(scroll, [0.3, 0.55], [1, 0.32]);
|
||||||
@@ -231,9 +306,59 @@ export default function OptimizationSection() {
|
|||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className={`dm-opt is-${pinState}`}
|
className={`dm-opt is-${pinState}${isMobile ? " dm-opt--mobile" : ""}`}
|
||||||
aria-label="AI Logistics Optimization"
|
aria-label="AI Logistics Optimization"
|
||||||
>
|
>
|
||||||
|
{/* ===== MOBILE: non-pinned vertical stack ===== */}
|
||||||
|
{isMobile && (
|
||||||
|
<div className="dm-opt-mobile">
|
||||||
|
<header className="dm-opt-mhead">
|
||||||
|
<div className="dm-opt-eyebrow">
|
||||||
|
<span className="dm-opt-dot" /> Doormile AI Control Tower
|
||||||
|
</div>
|
||||||
|
<h2>AI Logistics Optimization Engine</h2>
|
||||||
|
<p>
|
||||||
|
Watch Doormile's AI engine transform chaotic logistics into precision-optimized delivery networks — reducing distance, fleet size, delays, and cost.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* 1. Without Optimization */}
|
||||||
|
<aside className="dm-opt-panel dm-opt-panel--bad dm-opt-mpanel">
|
||||||
|
<WithoutPanelBody />
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* 2. 3D Visualization (ambient) */}
|
||||||
|
<div className="dm-opt-mobile__scene">
|
||||||
|
{mountScene && (
|
||||||
|
<div className="dm-opt-canvas">
|
||||||
|
<OptimizationCanvas
|
||||||
|
progress={progressRef}
|
||||||
|
reduced={reduced}
|
||||||
|
isMobile
|
||||||
|
active={sceneActive}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="dm-opt-mobile__scene-tag">
|
||||||
|
<span className="dm-opt-dot" /> Live AI optimization
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3. With Doormile AI */}
|
||||||
|
<aside className="dm-opt-panel dm-opt-panel--good dm-opt-mpanel">
|
||||||
|
<WithPanelBody />
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* 4. Metrics — final optimized values, 2-col grid */}
|
||||||
|
<div className="dm-opt-mfoot">
|
||||||
|
<MetricsPanel scroll={staticFinal} />
|
||||||
|
<LiveInsightBar />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ===== DESKTOP / TABLET: pinned scroll experience ===== */}
|
||||||
|
{!isMobile && (
|
||||||
<div className="dm-opt-sticky">
|
<div className="dm-opt-sticky">
|
||||||
<div className="dm-opt-card">
|
<div className="dm-opt-card">
|
||||||
{/* Static backdrop (also the canvas loading state) */}
|
{/* Static backdrop (also the canvas loading state) */}
|
||||||
@@ -246,6 +371,7 @@ export default function OptimizationSection() {
|
|||||||
progress={progressRef}
|
progress={progressRef}
|
||||||
reduced={reduced}
|
reduced={reduced}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
|
isTablet={isTablet}
|
||||||
// Only run the render loop while the section is actually pinned
|
// Only run the render loop while the section is actually pinned
|
||||||
// (filling the viewport). At a workflow seam two sections can both
|
// (filling the viewport). At a workflow seam two sections can both
|
||||||
// satisfy their activeIo margin; without the pin gate their two
|
// satisfy their activeIo margin; without the pin gate their two
|
||||||
@@ -375,35 +501,14 @@ export default function OptimizationSection() {
|
|||||||
className="dm-opt-panel dm-opt-panel--bad"
|
className="dm-opt-panel dm-opt-panel--bad"
|
||||||
style={{ opacity: leftOpacity, filter: leftFilter }}
|
style={{ opacity: leftOpacity, filter: leftFilter }}
|
||||||
>
|
>
|
||||||
<div className="dm-opt-panel__badge">
|
<WithoutPanelBody />
|
||||||
<span className="dm-opt-pulse dm-opt-pulse--red" /> System: Congested
|
|
||||||
</div>
|
|
||||||
<h3>Without Optimization</h3>
|
|
||||||
<ul>
|
|
||||||
<li><span className="dm-opt-marker dm-opt-marker--x">✖</span> Chaotic overlapping routes</li>
|
|
||||||
<li><span className="dm-opt-marker dm-opt-marker--x">✖</span> Duplicate & idle trips</li>
|
|
||||||
<li><span className="dm-opt-marker dm-opt-marker--x">✖</span> 8 vehicles required</li>
|
|
||||||
<li><span className="dm-opt-marker dm-opt-marker--x">✖</span> 23 delivery delays</li>
|
|
||||||
<li><span className="dm-opt-marker dm-opt-marker--x">✖</span> +18% cost overrun</li>
|
|
||||||
</ul>
|
|
||||||
</motion.aside>
|
</motion.aside>
|
||||||
|
|
||||||
<motion.aside
|
<motion.aside
|
||||||
className="dm-opt-panel dm-opt-panel--good"
|
className="dm-opt-panel dm-opt-panel--good"
|
||||||
style={{ opacity: rightOpacity }}
|
style={{ opacity: rightOpacity }}
|
||||||
>
|
>
|
||||||
<div className="dm-opt-panel__badge dm-opt-panel__badge--good">
|
<WithPanelBody />
|
||||||
<span className="dm-opt-pulse dm-opt-pulse--green" /> System: Optimized
|
|
||||||
</div>
|
|
||||||
<h3>With Doormile AI</h3>
|
|
||||||
<ul>
|
|
||||||
<li><span className="dm-opt-marker dm-opt-marker--ok">✔</span> Optimized route clusters</li>
|
|
||||||
<li><span className="dm-opt-marker dm-opt-marker--ok">✔</span> Intelligent vehicle assignment</li>
|
|
||||||
<li><span className="dm-opt-marker dm-opt-marker--ok">✔</span> Multi-trip & EV planning</li>
|
|
||||||
<li><span className="dm-opt-marker dm-opt-marker--ok">✔</span> Zero delivery delays</li>
|
|
||||||
<li><span className="dm-opt-marker dm-opt-marker--ok">✔</span> 18% cost saved</li>
|
|
||||||
<li><span className="dm-opt-marker dm-opt-marker--ok">✔</span> Carbon footprint reduced</li>
|
|
||||||
</ul>
|
|
||||||
</motion.aside>
|
</motion.aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -417,6 +522,7 @@ export default function OptimizationSection() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<style>{styles}</style>
|
<style>{styles}</style>
|
||||||
</section>
|
</section>
|
||||||
@@ -893,6 +999,98 @@ const styles = `
|
|||||||
.dm-opt-insight__text { font-size: 8.5px; }
|
.dm-opt-insight__text { font-size: 8.5px; }
|
||||||
.dm-opt-insight__sep { height: 10px; }
|
.dm-opt-insight__sep { height: 10px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== MOBILE STACKED LAYOUT (<=767px) — non-pinned vertical flow =====
|
||||||
|
Rendered by the isMobile branch as a normal-flow .dm-opt-mobile container
|
||||||
|
(header → Without → 3D → With → metrics). These rules come after the block
|
||||||
|
above so they win for the elements that actually exist on mobile. */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
/* Un-pin: natural height, no fixed sticky, no 200/230vh scroll runway.
|
||||||
|
(.dm-opt--mobile out-specifies the base .dm-opt height rules.) */
|
||||||
|
.dm-opt.dm-opt--mobile { height: auto; }
|
||||||
|
|
||||||
|
.dm-opt-mobile {
|
||||||
|
position: relative;
|
||||||
|
margin: 0 10px;
|
||||||
|
padding: 24px 13px 22px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: linear-gradient(180deg, #06101f 0%, #020617 55%, #030a18 100%);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
border-bottom: none;
|
||||||
|
/* Flat bottom + flush so the Performance card (.dm-wf1-card) butts directly
|
||||||
|
against it as one continuous container (matches Workflow1 ≤767 styles). */
|
||||||
|
border-radius: 20px 20px 0 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.dm-opt-mhead { text-align: center; padding: 0 4px; }
|
||||||
|
.dm-opt-mhead .dm-opt-eyebrow { font-size: 10px; }
|
||||||
|
.dm-opt-mhead h2 {
|
||||||
|
font-family: var(--font-space-grotesk), var(--font-manrope), system-ui, sans-serif;
|
||||||
|
margin: 10px 0 6px !important; padding: 0 !important; color: #F8FAFC !important;
|
||||||
|
font-weight: 700 !important; text-transform: none !important;
|
||||||
|
font-size: clamp(20px, 6.2vw, 26px) !important; line-height: 1.15 !important;
|
||||||
|
letter-spacing: -0.015em !important;
|
||||||
|
}
|
||||||
|
.dm-opt-mhead p {
|
||||||
|
margin: 0 auto !important; padding: 0 !important; color: ${COLORS.textDim} !important;
|
||||||
|
max-width: 40ch; font-size: 12.5px !important; line-height: 1.5 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Comparison panels → full-width static cards, fully visible, readable.
|
||||||
|
Trim the heavy glow (box-shadow) but keep the colored border for identity. */
|
||||||
|
.dm-opt-mobile .dm-opt-panel {
|
||||||
|
width: 100%; box-sizing: border-box;
|
||||||
|
opacity: 1 !important; filter: none !important;
|
||||||
|
padding: 15px 16px; border-radius: 16px; box-shadow: none;
|
||||||
|
}
|
||||||
|
.dm-opt-mobile .dm-opt-panel h3 {
|
||||||
|
font-family: var(--font-space-grotesk), var(--font-manrope), system-ui, sans-serif;
|
||||||
|
font-size: 16px !important; margin: 9px 0 10px !important;
|
||||||
|
}
|
||||||
|
.dm-opt-mobile .dm-opt-panel ul { gap: 8px; }
|
||||||
|
.dm-opt-mobile .dm-opt-panel li { font-size: 12.5px !important; line-height: 1.35 !important; }
|
||||||
|
.dm-opt-mobile .dm-opt-marker { width: 18px; height: 18px; font-size: 10px; border-radius: 6px; }
|
||||||
|
.dm-opt-mobile .dm-opt-panel__badge { font-size: 9.5px; padding: 4px 9px; }
|
||||||
|
|
||||||
|
/* 3D visualization block — contained, ~40vh, premium but not dominant. */
|
||||||
|
.dm-opt-mobile__scene {
|
||||||
|
position: relative; width: 100%; height: 40vh; min-height: 260px; max-height: 360px;
|
||||||
|
border-radius: 16px; overflow: hidden;
|
||||||
|
background: radial-gradient(120% 90% at 50% 30%, ${rgba(COLORS.cyan, 0.06)} 0%, ${COLORS.bg} 70%);
|
||||||
|
border: 1px solid ${rgba(COLORS.cyan, 0.14)};
|
||||||
|
}
|
||||||
|
.dm-opt-mobile__scene .dm-opt-canvas { position: absolute; inset: 0; z-index: 0; }
|
||||||
|
.dm-opt-mobile__scene-tag {
|
||||||
|
position: absolute; left: 10px; top: 10px; z-index: 1;
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
font-size: 9px; letter-spacing: 0.14em; text-transform: uppercase; font-weight: 700;
|
||||||
|
color: #E2E8F0; padding: 4px 9px; border-radius: 999px;
|
||||||
|
background: ${rgba(COLORS.ink, 0.72)}; border: 1px solid ${rgba(COLORS.cyan, 0.22)};
|
||||||
|
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Metrics → 2-col grid (5th card spans full width), final optimized values,
|
||||||
|
readable sizing so nothing truncates. */
|
||||||
|
.dm-opt-mfoot { padding: 0; }
|
||||||
|
.dm-opt-mobile .dm-opt-metrics {
|
||||||
|
grid-template-columns: repeat(2, 1fr); gap: 8px; max-width: none;
|
||||||
|
}
|
||||||
|
.dm-opt-mobile .dm-opt-metric { padding: 12px 13px 11px; border-radius: 12px; }
|
||||||
|
.dm-opt-mobile .dm-opt-metric:last-child { grid-column: 1 / -1; }
|
||||||
|
.dm-opt-mobile .dm-opt-metric__label { font-size: 10px; letter-spacing: 0.02em; }
|
||||||
|
.dm-opt-mobile .dm-opt-metric__value { font-size: clamp(20px, 6.5vw, 26px); }
|
||||||
|
|
||||||
|
/* Insight bar wraps instead of overflowing. */
|
||||||
|
.dm-opt-mobile .dm-opt-insight {
|
||||||
|
flex-wrap: wrap; max-width: none; gap: 6px 10px; padding: 8px 14px; margin-top: 4px;
|
||||||
|
}
|
||||||
|
.dm-opt-mobile .dm-opt-insight__text { font-size: 10px; }
|
||||||
|
}
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.dm-opt-pulse { animation: none; }
|
.dm-opt-pulse { animation: none; }
|
||||||
.dm-opt-metric { animation: none; opacity: 1; transform: none; }
|
.dm-opt-metric { animation: none; opacity: 1; transform: none; }
|
||||||
|
|||||||
@@ -195,28 +195,37 @@ export default function WhyChooseDoormile() {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
.wcd-card-points li {
|
.wcd-section .wcd-card-points li {
|
||||||
position: relative;
|
/* Flex row so the check icon and its label always sit on the same line.
|
||||||
padding-left: 30px;
|
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-size: 16px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
}
|
}
|
||||||
.wcd-card-points li::before {
|
/* Suppress the theme's default fontello list bullet
|
||||||
content: "";
|
(.logico-front-end ul li:before) so only our circle-check SVG renders. */
|
||||||
position: absolute;
|
.wcd-section .wcd-card-points li::before {
|
||||||
left: 0;
|
content: none;
|
||||||
top: 0.18em;
|
display: none;
|
||||||
width: 14px;
|
}
|
||||||
height: 9px;
|
/* Clean circle-check feature icon (inline SVG, see markup below) — replaces
|
||||||
border-left: 2px solid #c01227;
|
the old border-based chevron. Brand red with thin, rounded strokes. */
|
||||||
border-bottom: 2px solid #c01227;
|
.wcd-card-points .wcd-check {
|
||||||
transform: rotate(-45deg) scale(1);
|
flex: 0 0 auto;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
margin-top: 0.12em;
|
||||||
|
color: #c01227;
|
||||||
transition: transform 0.3s ease;
|
transition: transform 0.3s ease;
|
||||||
}
|
}
|
||||||
.wcd-card:hover .wcd-card-points li::before {
|
.wcd-card:hover .wcd-card-points .wcd-check {
|
||||||
transform: rotate(-45deg) scale(1.1);
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1020px) {
|
@media (max-width: 1020px) {
|
||||||
@@ -266,7 +275,22 @@ export default function WhyChooseDoormile() {
|
|||||||
<p className="wcd-card-desc">{stage.desc}</p>
|
<p className="wcd-card-desc">{stage.desc}</p>
|
||||||
<ul className="wcd-card-points">
|
<ul className="wcd-card-points">
|
||||||
{stage.points.map((point) => (
|
{stage.points.map((point) => (
|
||||||
<li key={point}>{point}</li>
|
<li key={point}>
|
||||||
|
<svg
|
||||||
|
className="wcd-check"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="m9 12 2 2 4-4" />
|
||||||
|
</svg>
|
||||||
|
<span>{point}</span>
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import OptimizationSection from "../optimization/OptimizationSection";
|
import OptimizationSection from "../optimization/OptimizationSection";
|
||||||
|
|
||||||
export default function Workflow1() {
|
export default function Workflow1() {
|
||||||
const [activeSlide, setActiveSlide] = useState(0);
|
const [activeSlide, setActiveSlide] = useState(0);
|
||||||
const [paused, setPaused] = useState(false);
|
const [paused, setPaused] = useState(false);
|
||||||
|
const [inView, setInView] = useState(false);
|
||||||
|
const cardRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const slides = [
|
const slides = [
|
||||||
{
|
{
|
||||||
@@ -23,21 +25,36 @@ export default function Workflow1() {
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
// Always begin the storytelling sequence from slide 1 (01/03) on mount — never
|
// Always begin on slide 1 (01/03) on mount. Scrolling away and back does NOT reset
|
||||||
// preserve a previous slide index across remounts / route changes back to MileTruth.
|
// (the component stays mounted) — only a fresh page load / route change back to
|
||||||
|
// MileTruth re-mounts and restarts at slide 1.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActiveSlide(0);
|
setActiveSlide(0);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Auto-advance the carousel every 4s, infinite loop. Keyed on activeSlide so any
|
// Autoplay is gated on visibility: it starts only once the slider card scrolls into
|
||||||
// manual selection resets the timer; pauses while the user hovers the card.
|
// view (not on page load) and stops when it leaves — without touching activeSlide,
|
||||||
|
// so returning to the section resumes from wherever it was, never snapping to slide 1.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (paused) return;
|
const el = cardRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const io = new IntersectionObserver(
|
||||||
|
([entry]) => setInView(entry.isIntersecting),
|
||||||
|
{ threshold: 0.35 }
|
||||||
|
);
|
||||||
|
io.observe(el);
|
||||||
|
return () => io.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Auto-advance every 10s, looping — but only while the card is in view and the user
|
||||||
|
// isn't hovering it. Keyed on activeSlide so a manual jump restarts the 10s dwell.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!inView || paused) return;
|
||||||
const id = setTimeout(() => {
|
const id = setTimeout(() => {
|
||||||
setActiveSlide((prev) => (prev + 1) % slides.length);
|
setActiveSlide((prev) => (prev + 1) % slides.length);
|
||||||
}, 4000);
|
}, 10000);
|
||||||
return () => clearTimeout(id);
|
return () => clearTimeout(id);
|
||||||
}, [activeSlide, paused, slides.length]);
|
}, [activeSlide, inView, paused, slides.length]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="dm-wf1" aria-label="Workflow 1 — Impact of Optimisation & Performance">
|
<section className="dm-wf1" aria-label="Workflow 1 — Impact of Optimisation & Performance">
|
||||||
@@ -47,7 +64,7 @@ export default function Workflow1() {
|
|||||||
|
|
||||||
{/* ── Bottom sub-section: Performance content, flush + colour-matched to the
|
{/* ── Bottom sub-section: Performance content, flush + colour-matched to the
|
||||||
optimisation section above so the whole workflow reads as one container ── */}
|
optimisation section above so the whole workflow reads as one container ── */}
|
||||||
<div className="dm-wf1-card" onMouseEnter={() => setPaused(true)} onMouseLeave={() => setPaused(false)}>
|
<div className="dm-wf1-card" ref={cardRef} onMouseEnter={() => setPaused(true)} onMouseLeave={() => setPaused(false)}>
|
||||||
{/* Left Column: Overlapping Chevron Graphic */}
|
{/* Left Column: Overlapping Chevron Graphic */}
|
||||||
<div className="dm-workflow-left">
|
<div className="dm-workflow-left">
|
||||||
<svg viewBox="0 0 320 280" fill="none" xmlns="http://www.w3.org/2000/svg" className="dm-workflow-svg">
|
<svg viewBox="0 0 320 280" fill="none" xmlns="http://www.w3.org/2000/svg" className="dm-workflow-svg">
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import LogisticsBrainSection from "../logisticsbrain/LogisticsBrainSection";
|
import LogisticsBrainSection from "../logisticsbrain/LogisticsBrainSection";
|
||||||
|
|
||||||
export default function Workflow2() {
|
export default function Workflow2() {
|
||||||
const [activeSlide, setActiveSlide] = useState(0);
|
const [activeSlide, setActiveSlide] = useState(0);
|
||||||
const [paused, setPaused] = useState(false);
|
const [paused, setPaused] = useState(false);
|
||||||
|
const [inView, setInView] = useState(false);
|
||||||
|
const cardRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const slides = [
|
const slides = [
|
||||||
{
|
{
|
||||||
@@ -23,21 +25,36 @@ export default function Workflow2() {
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
// Always begin the storytelling sequence from slide 1 (01/03) on mount — never
|
// Always begin on slide 1 (01/03) on mount. Scrolling away and back does NOT reset
|
||||||
// preserve a previous slide index across remounts / route changes back to MileTruth.
|
// (the component stays mounted) — only a fresh page load / route change back to
|
||||||
|
// MileTruth re-mounts and restarts at slide 1.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActiveSlide(0);
|
setActiveSlide(0);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Auto-advance the carousel every 4s, infinite loop. Keyed on activeSlide so any
|
// Autoplay is gated on visibility: it starts only once the slider card scrolls into
|
||||||
// manual selection resets the timer; pauses while the user hovers the card.
|
// view (not on page load) and stops when it leaves — without touching activeSlide,
|
||||||
|
// so returning to the section resumes from wherever it was, never snapping to slide 1.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (paused) return;
|
const el = cardRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const io = new IntersectionObserver(
|
||||||
|
([entry]) => setInView(entry.isIntersecting),
|
||||||
|
{ threshold: 0.35 }
|
||||||
|
);
|
||||||
|
io.observe(el);
|
||||||
|
return () => io.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Auto-advance every 10s, looping — but only while the card is in view and the user
|
||||||
|
// isn't hovering it. Keyed on activeSlide so a manual jump restarts the 10s dwell.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!inView || paused) return;
|
||||||
const id = setTimeout(() => {
|
const id = setTimeout(() => {
|
||||||
setActiveSlide((prev) => (prev + 1) % slides.length);
|
setActiveSlide((prev) => (prev + 1) % slides.length);
|
||||||
}, 4000);
|
}, 10000);
|
||||||
return () => clearTimeout(id);
|
return () => clearTimeout(id);
|
||||||
}, [activeSlide, paused, slides.length]);
|
}, [activeSlide, inView, paused, slides.length]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="dm-wf2" aria-label="Workflow 2 — How Our Logistics Brain Works & Innovation">
|
<section className="dm-wf2" aria-label="Workflow 2 — How Our Logistics Brain Works & Innovation">
|
||||||
@@ -47,7 +64,7 @@ export default function Workflow2() {
|
|||||||
|
|
||||||
{/* ── Bottom sub-section: Innovation content, flush + colour-matched to the
|
{/* ── Bottom sub-section: Innovation content, flush + colour-matched to the
|
||||||
logistics-brain card above so the whole workflow reads as one container ── */}
|
logistics-brain card above so the whole workflow reads as one container ── */}
|
||||||
<div className="dm-wf2-card" onMouseEnter={() => setPaused(true)} onMouseLeave={() => setPaused(false)}>
|
<div className="dm-wf2-card" ref={cardRef} onMouseEnter={() => setPaused(true)} onMouseLeave={() => setPaused(false)}>
|
||||||
{/* Left Column: Overlapping Chevron Graphic */}
|
{/* Left Column: Overlapping Chevron Graphic */}
|
||||||
<div className="dm-workflow-left">
|
<div className="dm-workflow-left">
|
||||||
<svg viewBox="0 0 320 280" fill="none" xmlns="http://www.w3.org/2000/svg" className="dm-workflow-svg">
|
<svg viewBox="0 0 320 280" fill="none" xmlns="http://www.w3.org/2000/svg" className="dm-workflow-svg">
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import StrategySection from "../strategy/StrategySection";
|
import StrategySection from "../strategy/StrategySection";
|
||||||
|
|
||||||
export default function Workflow3() {
|
export default function Workflow3() {
|
||||||
const [activeSlide, setActiveSlide] = useState(0);
|
const [activeSlide, setActiveSlide] = useState(0);
|
||||||
const [paused, setPaused] = useState(false);
|
const [paused, setPaused] = useState(false);
|
||||||
|
const [inView, setInView] = useState(false);
|
||||||
|
const cardRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const slides = [
|
const slides = [
|
||||||
{
|
{
|
||||||
@@ -23,21 +25,36 @@ export default function Workflow3() {
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
// Always begin the storytelling sequence from slide 1 (01/03) on mount — never
|
// Always begin on slide 1 (01/03) on mount. Scrolling away and back does NOT reset
|
||||||
// preserve a previous slide index across remounts / route changes back to MileTruth.
|
// (the component stays mounted) — only a fresh page load / route change back to
|
||||||
|
// MileTruth re-mounts and restarts at slide 1.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActiveSlide(0);
|
setActiveSlide(0);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Auto-advance the carousel every 4s, infinite loop. Keyed on activeSlide so any
|
// Autoplay is gated on visibility: it starts only once the slider card scrolls into
|
||||||
// manual selection resets the timer; pauses while the user hovers the card.
|
// view (not on page load) and stops when it leaves — without touching activeSlide,
|
||||||
|
// so returning to the section resumes from wherever it was, never snapping to slide 1.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (paused) return;
|
const el = cardRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const io = new IntersectionObserver(
|
||||||
|
([entry]) => setInView(entry.isIntersecting),
|
||||||
|
{ threshold: 0.35 }
|
||||||
|
);
|
||||||
|
io.observe(el);
|
||||||
|
return () => io.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Auto-advance every 10s, looping — but only while the card is in view and the user
|
||||||
|
// isn't hovering it. Keyed on activeSlide so a manual jump restarts the 10s dwell.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!inView || paused) return;
|
||||||
const id = setTimeout(() => {
|
const id = setTimeout(() => {
|
||||||
setActiveSlide((prev) => (prev + 1) % slides.length);
|
setActiveSlide((prev) => (prev + 1) % slides.length);
|
||||||
}, 4000);
|
}, 10000);
|
||||||
return () => clearTimeout(id);
|
return () => clearTimeout(id);
|
||||||
}, [activeSlide, paused, slides.length]);
|
}, [activeSlide, inView, paused, slides.length]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="dm-wf3" aria-label="Workflow 3 — Happier Riders. Higher Fulfillment. & Strategy">
|
<section className="dm-wf3" aria-label="Workflow 3 — Happier Riders. Higher Fulfillment. & Strategy">
|
||||||
@@ -49,7 +66,7 @@ export default function Workflow3() {
|
|||||||
{/* ── Bottom sub-section: Strategy content, flush + pulled up to butt against
|
{/* ── Bottom sub-section: Strategy content, flush + pulled up to butt against
|
||||||
the 3D card's flat bottom so the whole workflow reads as one container —
|
the 3D card's flat bottom so the whole workflow reads as one container —
|
||||||
the same connected structure used in Workflow 1 & 2 ── */}
|
the same connected structure used in Workflow 1 & 2 ── */}
|
||||||
<div className="dm-wf3-card" onMouseEnter={() => setPaused(true)} onMouseLeave={() => setPaused(false)}>
|
<div className="dm-wf3-card" ref={cardRef} onMouseEnter={() => setPaused(true)} onMouseLeave={() => setPaused(false)}>
|
||||||
{/* Left Column: Overlapping Chevron Graphic */}
|
{/* Left Column: Overlapping Chevron Graphic */}
|
||||||
<div className="dm-workflow-left">
|
<div className="dm-workflow-left">
|
||||||
<svg viewBox="0 0 320 280" fill="none" xmlns="http://www.w3.org/2000/svg" className="dm-workflow-svg">
|
<svg viewBox="0 0 320 280" fill="none" xmlns="http://www.w3.org/2000/svg" className="dm-workflow-svg">
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
/** Base classes shared by every page — safe to SSR before route is known. */
|
/** Base classes shared by every page — safe to SSR before route is known. */
|
||||||
export const SHARED_BODY_CLASSES =
|
export const SHARED_BODY_CLASSES =
|
||||||
"wp-singular page-template-default page wp-theme-logico wp-child-theme-logico-child theme-logico woocommerce-no-js ehf-header ehf-footer ehf-template-logico ehf-stylesheet-logico-child logico-front-end logico-theme-style-rounded elementor-default elementor-kit-5 elementor-page";
|
"wp-singular page-template-default page wp-theme-logico wp-child-theme-logico-child theme-logico ehf-header ehf-footer ehf-template-logico ehf-stylesheet-logico-child logico-front-end logico-theme-style-rounded elementor-default elementor-kit-5 elementor-page";
|
||||||
|
|
||||||
const SHARED = SHARED_BODY_CLASSES;
|
const SHARED = SHARED_BODY_CLASSES;
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ const ROUTE_CLASSES: Record<string, string> = {
|
|||||||
"/": `home-page ${SHARED} page-id-61 elementor-page-61 is-home-page`,
|
"/": `home-page ${SHARED} page-id-61 elementor-page-61 is-home-page`,
|
||||||
// PHP source quirk: how-it-works omits elementor-kit-5
|
// PHP source quirk: how-it-works omits elementor-kit-5
|
||||||
"/how-it-works":
|
"/how-it-works":
|
||||||
"wp-singular page-template-default page page-id-59 wp-theme-logico wp-child-theme-logico-child theme-logico woocommerce-no-js ehf-header ehf-footer ehf-template-logico ehf-stylesheet-logico-child logico-front-end logico-theme-style-rounded elementor-default elementor-page elementor-page-59",
|
"wp-singular page-template-default page page-id-59 wp-theme-logico wp-child-theme-logico-child theme-logico ehf-header ehf-footer ehf-template-logico ehf-stylesheet-logico-child logico-front-end logico-theme-style-rounded elementor-default elementor-page elementor-page-59",
|
||||||
"/miletruth": `${SHARED} page-id-59 elementor-page-59`,
|
"/miletruth": `${SHARED} page-id-59 elementor-page-59`,
|
||||||
"/solutions": `${SHARED} page-id-59 elementor-page-59`,
|
"/solutions": `${SHARED} page-id-59 elementor-page-59`,
|
||||||
// PHP source quirk: about-us.php has `home` class (upstream bug, preserved)
|
// PHP source quirk: about-us.php has `home` class (upstream bug, preserved)
|
||||||
|
|||||||
Reference in New Issue
Block a user