update miletruth page and remove unwanted files
This commit is contained in:
61
src/animations/SmoothScroll.tsx
Normal file
61
src/animations/SmoothScroll.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import gsap from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import Lenis from "lenis";
|
||||
|
||||
/**
|
||||
* SmoothScroll
|
||||
* ---------------------------------------------------------------------------
|
||||
* One global Lenis instance, driven by a SINGLE rAF source (GSAP's ticker) and
|
||||
* kept locked to ScrollTrigger. Deliberately gated OFF on:
|
||||
* - /miletruth — it stacks 3 pinned WebGL sections; JS scroll-smoothing there
|
||||
* fights the pins and causes the very lag we're trying to remove. Native
|
||||
* scroll is used on that route.
|
||||
* - touch devices — native momentum is smoother than emulated inertia.
|
||||
* - prefers-reduced-motion.
|
||||
*
|
||||
* Re-evaluates on every route change: the effect cleanup destroys the previous
|
||||
* instance, so entering /miletruth tears Lenis down and leaving it re-inits.
|
||||
*/
|
||||
const DISABLED_ROUTES = ["/miletruth"];
|
||||
|
||||
export default function SmoothScroll() {
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
const routeDisabled = DISABLED_ROUTES.some(
|
||||
(r) => pathname === r || pathname.startsWith(`${r}/`),
|
||||
);
|
||||
const prefersReduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
// Mouse/desktop only — touch devices already have good native momentum.
|
||||
const isPointerFine = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
|
||||
|
||||
if (routeDisabled || prefersReduced || !isPointerFine) return;
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
const lenis = new Lenis({
|
||||
duration: 1.05,
|
||||
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
|
||||
orientation: "vertical",
|
||||
gestureOrientation: "vertical",
|
||||
smoothWheel: true,
|
||||
});
|
||||
|
||||
lenis.on("scroll", ScrollTrigger.update);
|
||||
const tickerCb = (time: number) => lenis.raf(time * 1000); // ticker is seconds, Lenis wants ms
|
||||
gsap.ticker.add(tickerCb);
|
||||
gsap.ticker.lagSmoothing(0);
|
||||
ScrollTrigger.refresh();
|
||||
|
||||
return () => {
|
||||
gsap.ticker.remove(tickerCb);
|
||||
lenis.destroy();
|
||||
};
|
||||
}, [pathname]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -10,6 +10,26 @@ html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Lenis global smooth scroll (src/animations/SmoothScroll.tsx). These classes are
|
||||
only present on routes/devices where Lenis is active; on /miletruth and touch
|
||||
devices Lenis is off and native scroll-behavior:smooth (above) applies. */
|
||||
html.lenis,
|
||||
html.lenis body {
|
||||
height: auto;
|
||||
}
|
||||
.lenis.lenis-smooth {
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
.lenis.lenis-smooth [data-lenis-prevent] {
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.lenis.lenis-stopped {
|
||||
overflow: hidden;
|
||||
}
|
||||
.lenis.lenis-smooth iframe {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
*, *::before, *::after {
|
||||
font-family: var(--font-manrope), "Manrope", system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import BodyOverlay from "@/components/layout/BodyOverlay";
|
||||
import { HeaderUIProvider } from "@/components/layout/HeaderUIProvider";
|
||||
import { SHARED_BODY_CLASSES } from "@/lib/bodyClasses";
|
||||
import AnimationProvider from "@/animations/AnimationProvider";
|
||||
import SmoothScroll from "@/animations/SmoothScroll";
|
||||
import LoadingScreen from "@/components/layout/LoadingScreen";
|
||||
|
||||
const manrope = Manrope({
|
||||
subsets: ["latin"],
|
||||
@@ -95,7 +97,9 @@ export default function RootLayout({
|
||||
*/}
|
||||
<body className={SHARED_BODY_CLASSES}>
|
||||
<BodyClasses />
|
||||
<LoadingScreen />
|
||||
<AnimationProvider>
|
||||
<SmoothScroll />
|
||||
<HeaderUIProvider>
|
||||
<BodyOverlay />
|
||||
<div className="body-container">
|
||||
|
||||
@@ -3,7 +3,6 @@ import MileTruthHero from "../../components/sections/MileTruthHero";
|
||||
import Workflow1 from "../../components/sections/Workflow1";
|
||||
import Workflow2 from "../../components/sections/Workflow2";
|
||||
import Workflow3 from "../../components/sections/Workflow3";
|
||||
import LogisticsBrainSection from "../../components/logisticsbrain/LogisticsBrainSection";
|
||||
|
||||
export const metadata = {
|
||||
title: "MileTruth – Doormile",
|
||||
@@ -20,7 +19,6 @@ export default function MileTruthPage() {
|
||||
<Workflow1 />
|
||||
<Workflow2 />
|
||||
<Workflow3 />
|
||||
<LogisticsBrainSection />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* Menu open/close + sidebar state is read from HeaderUIProvider so BodyOverlay (sibling at body level) can react.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { usePathname } from "next/navigation";
|
||||
@@ -48,7 +48,7 @@ export default function Header() {
|
||||
// - on doc.ready: $('.header-hide-until-scroll').addClass('header-visible-scrolled')
|
||||
// - on scroll: toggleClass('dm-header-scrolled', scrollTop > 50)
|
||||
const [visibleScrolled, setVisibleScrolled] = useState(false);
|
||||
const [dmHeaderScrolled, setDmHeaderScrolled] = useState(false);
|
||||
const isScrolledRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handle = requestAnimationFrame(() => {
|
||||
@@ -58,10 +58,31 @@ export default function Header() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setDmHeaderScrolled(window.scrollY > 50);
|
||||
let rafId: number | null = null;
|
||||
const onScroll = () => {
|
||||
if (rafId !== null) return;
|
||||
rafId = requestAnimationFrame(() => {
|
||||
const scrolled = window.scrollY > 50;
|
||||
if (isScrolledRef.current !== scrolled) {
|
||||
isScrolledRef.current = scrolled;
|
||||
const headerEl = document.querySelector(".header-hide-until-scroll");
|
||||
if (headerEl) {
|
||||
if (scrolled) {
|
||||
headerEl.classList.add("dm-header-scrolled");
|
||||
} else {
|
||||
headerEl.classList.remove("dm-header-scrolled");
|
||||
}
|
||||
}
|
||||
}
|
||||
rafId = null;
|
||||
});
|
||||
};
|
||||
onScroll();
|
||||
window.addEventListener("scroll", onScroll);
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", onScroll);
|
||||
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Mirror of header.php $header_style (line 21): inline opacity:0;visibility:hidden on home (before JS adds .header-visible-scrolled)
|
||||
@@ -78,7 +99,7 @@ export default function Header() {
|
||||
"e-parent",
|
||||
"header-hide-until-scroll",
|
||||
visibleScrolled ? "header-visible-scrolled" : "",
|
||||
dmHeaderScrolled ? "dm-header-scrolled" : "",
|
||||
isScrolledRef.current ? "dm-header-scrolled" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
114
src/components/layout/LoadingScreen.tsx
Normal file
114
src/components/layout/LoadingScreen.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
|
||||
/**
|
||||
* LoadingScreen
|
||||
* ---------------------------------------------------------------------------
|
||||
* Native reimplementation of the legacy WordPress page-loader: a black
|
||||
* full-screen overlay with a centered, pulsing Doormile logo that fades out.
|
||||
*
|
||||
* Shows on initial load (until the window finishes loading, min ~450ms to avoid
|
||||
* a flash, capped at 2.5s so it never blocks) and again briefly on each route
|
||||
* navigation. CWV-safe: fixed/out-of-flow (no layout shift), logo is priority,
|
||||
* and it never delays hydration.
|
||||
*/
|
||||
type Phase = "visible" | "hiding" | "gone";
|
||||
|
||||
const MIN_SHOW_MS = 450;
|
||||
const MAX_SHOW_MS = 2500;
|
||||
const NAV_SHOW_MS = 520;
|
||||
|
||||
export default function LoadingScreen() {
|
||||
const pathname = usePathname();
|
||||
const [phase, setPhase] = useState<Phase>("visible");
|
||||
const isFirstRender = useRef(true);
|
||||
|
||||
// Initial load: hide once the page is ready.
|
||||
useEffect(() => {
|
||||
const start = performance.now();
|
||||
let began = false;
|
||||
let fadeTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
const begin = () => {
|
||||
if (began) return;
|
||||
began = true;
|
||||
const wait = Math.max(0, MIN_SHOW_MS - (performance.now() - start));
|
||||
fadeTimer = setTimeout(() => setPhase("hiding"), wait);
|
||||
};
|
||||
|
||||
const cap = setTimeout(begin, MAX_SHOW_MS);
|
||||
const onReady = () => begin();
|
||||
|
||||
if (document.readyState === "complete") begin();
|
||||
else window.addEventListener("load", onReady, { once: true });
|
||||
|
||||
return () => {
|
||||
clearTimeout(cap);
|
||||
clearTimeout(fadeTimer);
|
||||
window.removeEventListener("load", onReady);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Route navigations: flash the loader briefly for an app-like transition.
|
||||
useEffect(() => {
|
||||
if (isFirstRender.current) {
|
||||
isFirstRender.current = false;
|
||||
return;
|
||||
}
|
||||
setPhase("visible");
|
||||
const t = setTimeout(() => setPhase("hiding"), NAV_SHOW_MS);
|
||||
return () => clearTimeout(t);
|
||||
}, [pathname]);
|
||||
|
||||
if (phase === "gone") return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`dm-loader${phase === "hiding" ? " is-hiding" : ""}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label="Loading"
|
||||
onTransitionEnd={(e) => {
|
||||
if (e.propertyName === "opacity" && phase === "hiding") setPhase("gone");
|
||||
}}
|
||||
>
|
||||
<div className="dm-loader__pulse">
|
||||
<Image
|
||||
src="/images/preloader.png"
|
||||
alt="Doormile"
|
||||
width={200}
|
||||
height={38}
|
||||
priority
|
||||
className="dm-loader__logo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.dm-loader {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100000;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #000;
|
||||
opacity: 1;
|
||||
transition: opacity 0.32s ease;
|
||||
will-change: opacity;
|
||||
}
|
||||
.dm-loader.is-hiding { opacity: 0; pointer-events: none; }
|
||||
.dm-loader__pulse { animation: dmLoaderPulse 1.5s linear infinite; }
|
||||
.dm-loader__logo { width: clamp(140px, 18vw, 200px); height: auto; }
|
||||
@keyframes dmLoaderPulse {
|
||||
50% { transform: scale(0.85); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dm-loader__pulse { animation: none; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useRef } from "react";
|
||||
import { Canvas, useFrame } from "@react-three/fiber";
|
||||
import { EffectComposer, Bloom, Vignette } from "@react-three/postprocessing";
|
||||
import { EffectComposer, Bloom } from "@react-three/postprocessing";
|
||||
import { KernelSize } from "postprocessing";
|
||||
import * as THREE from "three";
|
||||
import { C, WAYPOINTS } from "./theme";
|
||||
@@ -85,7 +85,7 @@ function LogisticsBrainCanvas({ progress, reduced = false, isMobile = false, act
|
||||
return (
|
||||
<Canvas
|
||||
flat
|
||||
dpr={[1, isMobile || reduced ? 1.3 : 1.7]}
|
||||
dpr={[1, isMobile || reduced ? 1.25 : 1.5]}
|
||||
camera={{ position: WAYPOINTS[0].pos, fov: 52, near: 0.1, far: 200 }}
|
||||
gl={{ antialias: !isMobile, powerPreference: "high-performance", alpha: false }}
|
||||
frameloop={active ? "always" : "never"}
|
||||
@@ -115,7 +115,6 @@ function LogisticsBrainCanvas({ progress, reduced = false, isMobile = false, act
|
||||
radius={isMobile ? 0.65 : 0.82}
|
||||
kernelSize={KernelSize.MEDIUM}
|
||||
/>
|
||||
<Vignette eskil={false} offset={0.22} darkness={0.62} />
|
||||
</EffectComposer>
|
||||
)}
|
||||
</Canvas>
|
||||
|
||||
@@ -77,7 +77,7 @@ function StoryCard({
|
||||
* progress value that drives the R3F scene, the camera spline and this overlay
|
||||
* in lockstep, so the whole thing reads as one continuous shot.
|
||||
*/
|
||||
export default function LogisticsBrainSection() {
|
||||
export default function LogisticsBrainSection({ connected = false }: { connected?: boolean } = {}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const progressRef = useRef(0);
|
||||
const scroll = useMotionValue(0);
|
||||
@@ -110,7 +110,7 @@ export default function LogisticsBrainSection() {
|
||||
mountIo.disconnect();
|
||||
}
|
||||
},
|
||||
{ rootMargin: "120% 0px" },
|
||||
{ rootMargin: "70% 0px" },
|
||||
);
|
||||
const activeIo = new IntersectionObserver(
|
||||
(entries) => setSceneActive(entries.some((e) => e.isIntersecting)),
|
||||
@@ -164,12 +164,6 @@ export default function LogisticsBrainSection() {
|
||||
const p5o = useTransform(scroll, [0.75, 0.78, 0.855, 0.875], [0, 1, 1, 0]);
|
||||
const p5y = useTransform(scroll, [0.75, 0.79], [26, 0]);
|
||||
|
||||
// Readability scrim behind the lower-left story pillars. The bright street-level
|
||||
// city (esp. the EV beat) leaves the text with no contrast, so we darken the
|
||||
// bottom-left corner across all pillar beats and fade it out for the intro hint
|
||||
// and the centered finale.
|
||||
const scrimOpacity = useTransform(scroll, [0.08, 0.13, 0.84, P.finale], [0, 1, 1, 0]);
|
||||
|
||||
const finaleOpacity = useTransform(scroll, [P.finale - 0.02, P.finale + 0.04], [0, 1]);
|
||||
const finaleY = useTransform(scroll, [P.finale - 0.02, P.finale + 0.06], [40, 0]);
|
||||
const taglineOpacity = useTransform(scroll, [P.finale + 0.04, P.finale + 0.1], [0, 1]);
|
||||
@@ -178,7 +172,7 @@ export default function LogisticsBrainSection() {
|
||||
const cost = useTransform(scroll, [P.finale, 0.97], [0, 18]);
|
||||
|
||||
return (
|
||||
<section ref={containerRef} className={`dm-lb is-${pinState}`} aria-label="Logistics Brain — one intelligent system">
|
||||
<section ref={containerRef} className={`dm-lb is-${pinState}${connected ? " is-connected" : ""}`} aria-label="Logistics Brain — one intelligent system">
|
||||
<div className="dm-lb-sticky">
|
||||
<div className="dm-lb-card">
|
||||
{mountScene && (
|
||||
@@ -186,9 +180,6 @@ export default function LogisticsBrainSection() {
|
||||
<LogisticsBrainCanvas progress={progressRef} reduced={reduced} isMobile={isMobile} active={sceneActive} />
|
||||
</div>
|
||||
)}
|
||||
<div className="dm-lb-vignette" aria-hidden />
|
||||
<motion.div className="dm-lb-scrim" style={{ opacity: scrimOpacity }} aria-hidden />
|
||||
|
||||
<div className="dm-lb-ui">
|
||||
{/* Persistent header: what this is + where we are in the workflow */}
|
||||
<motion.div className="dm-lb-top" style={{ opacity: headerOpacity }}>
|
||||
@@ -280,10 +271,6 @@ export default function LogisticsBrainSection() {
|
||||
<span className="dm-lb-logo__mark" />
|
||||
MileTruth
|
||||
</motion.div>
|
||||
<motion.p className="dm-lb-tagline" style={{ opacity: taglineOpacity }}>
|
||||
This isn't just software.<br />
|
||||
<strong>This is your logistics brain.</strong>
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -309,32 +296,36 @@ const styles = `
|
||||
}
|
||||
@media (max-width: 767px) { .dm-lb-card { inset: 10px !important; border-radius: 20px !important; } }
|
||||
|
||||
/* Connected mode (inside Workflow 2): flatten the card's bottom edge and flush it to
|
||||
the section's bottom so the Innovation card below butts directly against it, reading
|
||||
as one continuous container — mirrors the Optimisation → Performance seam in Workflow 1. */
|
||||
.dm-lb.is-connected .dm-lb-card {
|
||||
top: 16px !important; left: 16px !important; right: 16px !important; bottom: 0 !important;
|
||||
border-radius: 28px 28px 0 0 !important; border-bottom: none !important;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.dm-lb.is-connected .dm-lb-card {
|
||||
top: 10px !important; left: 10px !important; right: 10px !important; bottom: 0 !important;
|
||||
border-radius: 20px 20px 0 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.dm-lb-canvas { position: absolute; inset: 0; z-index: 1; }
|
||||
.dm-lb-canvas canvas { display: block; }
|
||||
.dm-lb-vignette { position: absolute; inset: 0; z-index: 2; pointer-events: none;
|
||||
background: radial-gradient(130% 110% at 50% 45%, transparent 58%, rgba(3,4,10,0.9) 100%),
|
||||
linear-gradient(180deg, rgba(3,4,10,0.55) 0%, transparent 18%, transparent 70%, rgba(3,4,10,0.92) 100%); }
|
||||
|
||||
/* Lower-left readability scrim — keeps the story pillars legible over the bright
|
||||
street-level skyline. Anchored to the bottom-left corner where the pillars sit. */
|
||||
.dm-lb-scrim { position: absolute; inset: 0; z-index: 3; pointer-events: none;
|
||||
background:
|
||||
linear-gradient(to top right, rgba(3,4,10,0.94) 0%, rgba(3,4,10,0.74) 20%, rgba(3,4,10,0.34) 40%, transparent 60%),
|
||||
linear-gradient(0deg, rgba(3,4,10,0.6) 0%, transparent 38%); }
|
||||
@media (max-width: 767px) {
|
||||
.dm-lb-scrim { background: linear-gradient(0deg, rgba(3,4,10,0.92) 0%, rgba(3,4,10,0.55) 28%, transparent 52%); }
|
||||
}
|
||||
|
||||
.dm-lb-ui { position: absolute; inset: 0; z-index: 4; pointer-events: none;
|
||||
font-family: var(--font-space-grotesk), var(--font-manrope), system-ui, sans-serif; color: #eaf2ff; }
|
||||
|
||||
/* ---- Persistent header: title + 6-step engine rail ---- */
|
||||
.dm-lb-top { position: absolute; top: clamp(16px, 3.5vh, 34px); left: 0; right: 0;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 0 16px; }
|
||||
/* ---- Persistent header: title + 6-step engine rail ----
|
||||
Offset the bar below the site's fixed navbar (~104px desktop / ~100px mobile once
|
||||
.dm-header-scrolled is active). The section pins full-viewport, so without this the
|
||||
eyebrow badge would sit under the navbar (z-index 10000) and get clipped. */
|
||||
.dm-lb-top { position: absolute; top: clamp(96px, 13vh, 128px); left: 0; right: 0; z-index: 5;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 0 16px; overflow: visible; }
|
||||
.dm-lb-eyebrow {
|
||||
display: inline-flex; align-items: center; gap: 8px; font-size: 11px; letter-spacing: 0.28em; text-transform: uppercase;
|
||||
color: #F2667A; padding: 6px 16px; border-radius: 999px; background: rgba(192,18,39,0.10);
|
||||
border: 1px solid rgba(226,53,66,0.32); backdrop-filter: blur(8px); white-space: nowrap; }
|
||||
display: inline-flex; align-items: center; gap: 8px; font-size: 11px; line-height: 1.35; letter-spacing: 0.28em; text-transform: uppercase;
|
||||
color: #F2667A; padding: 9px 18px; border-radius: 999px; background: rgba(192,18,39,0.10);
|
||||
border: 1px solid rgba(226,53,66,0.32); backdrop-filter: blur(8px); white-space: nowrap; overflow: visible; }
|
||||
.dm-lb-dot { width: 6px; height: 6px; border-radius: 50%; background: #E2354A; box-shadow: 0 0 10px #E2354A; }
|
||||
|
||||
.dm-lb-rail { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; max-width: 940px; }
|
||||
@@ -362,8 +353,9 @@ const styles = `
|
||||
.dm-lb-card-story { position: absolute; left: clamp(18px, 4vw, 56px); bottom: clamp(26px, 7vh, 64px);
|
||||
width: min(440px, 84vw); pointer-events: auto; will-change: opacity, transform;
|
||||
padding: 18px 20px; border-radius: 18px;
|
||||
background: rgba(14,8,10,0.6); border: 1px solid rgba(226,53,66,0.22);
|
||||
backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
|
||||
background: rgba(14,8,10,0.9); border: 1px solid rgba(226,53,66,0.22);
|
||||
/* backdrop blur removed — this card cross-fades/translates on scroll, so the blur
|
||||
was recomputed every frame; a near-opaque fill keeps the look at no per-frame cost. */
|
||||
box-shadow: 0 24px 64px -30px rgba(0,0,0,0.92); }
|
||||
.dm-lb-card-story__head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
|
||||
.dm-lb-pillar__num { font-size: 12px; font-weight: 700; letter-spacing: 0.1em; color: #ffffff;
|
||||
@@ -418,8 +410,8 @@ const styles = `
|
||||
.dm-lb-finale { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 0 20px; }
|
||||
.dm-lb-kpis { display: flex; gap: clamp(14px, 2.4vw, 28px); margin-bottom: clamp(28px, 6vh, 56px); flex-wrap: wrap; justify-content: center; }
|
||||
.dm-lb-kpi { display: flex; flex-direction: column; align-items: center; gap: 8px; min-width: clamp(150px, 18vw, 210px);
|
||||
padding: 22px 26px; border-radius: 18px; background: rgba(16,9,11,0.6); border: 1px solid rgba(226,53,66,0.28);
|
||||
backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); box-shadow: 0 24px 60px -28px rgba(0,0,0,0.9); }
|
||||
padding: 22px 26px; border-radius: 18px; background: rgba(16,9,11,0.9); border: 1px solid rgba(226,53,66,0.28);
|
||||
box-shadow: 0 24px 60px -28px rgba(0,0,0,0.9); }
|
||||
.dm-lb-kpi--green { border-color: rgba(34,197,94,0.4); }
|
||||
.dm-lb-kpi__num { font-size: clamp(38px, 5.5vw, 72px); font-weight: 800; line-height: 1; letter-spacing: -0.03em;
|
||||
color: #fff; text-shadow: 0 0 32px rgba(226,53,66,0.55), 0 0 12px rgba(192,18,39,0.5); }
|
||||
@@ -431,11 +423,6 @@ const styles = `
|
||||
.dm-lb-logo__mark { width: clamp(20px, 2.4vw, 30px); height: clamp(20px, 2.4vw, 30px); border-radius: 8px;
|
||||
background: conic-gradient(from 140deg, #E2354A, #C01227, #8A0E1F, #C8102E, #E2354A);
|
||||
box-shadow: 0 0 28px rgba(192,18,39,0.75); }
|
||||
.dm-lb-tagline { margin: 0; font-size: clamp(15px, 1.9vw, 24px); line-height: 1.4; font-weight: 400;
|
||||
color: rgba(240,224,226,0.78); letter-spacing: 0.02em; }
|
||||
.dm-lb-tagline strong { display: inline-block; margin-top: 4px; font-weight: 700; color: #fff;
|
||||
background: linear-gradient(90deg, #E2354A, #C01227, #C8102E); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;
|
||||
text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
|
||||
/* Hide the step titles on narrower screens so the rail stays a single tidy row of numbers. */
|
||||
@media (max-width: 1000px) {
|
||||
|
||||
48
src/components/logisticsbrain/math.test.ts
Normal file
48
src/components/logisticsbrain/math.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { clamp01, lerp, smoothstep, damp, seeded } from "./math";
|
||||
|
||||
// The Logistics Brain scene intentionally keeps its own copy of these helpers
|
||||
// (see the note in math.ts about Turbopack cross-folder const-arrow imports),
|
||||
// so they are covered independently from optimization/math.
|
||||
|
||||
describe("logisticsbrain/math — clamp01() & lerp()", () => {
|
||||
it("clamps to [0,1]", () => {
|
||||
expect(clamp01(-1)).toBe(0);
|
||||
expect(clamp01(0.5)).toBe(0.5);
|
||||
expect(clamp01(2)).toBe(1);
|
||||
});
|
||||
|
||||
it("interpolates linearly", () => {
|
||||
expect(lerp(0, 10, 0.5)).toBe(5);
|
||||
expect(lerp(-4, 4, 0.5)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("logisticsbrain/math — smoothstep()", () => {
|
||||
it("hits edges and midpoint", () => {
|
||||
expect(smoothstep(0, 1, 0)).toBe(0);
|
||||
expect(smoothstep(0, 1, 1)).toBe(1);
|
||||
expect(smoothstep(0, 1, 0.5)).toBeCloseTo(0.5, 10);
|
||||
});
|
||||
|
||||
it("guards equal edges (no NaN)", () => {
|
||||
expect(Number.isNaN(smoothstep(2, 2, 2))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("logisticsbrain/math — damp()", () => {
|
||||
it("is a no-op at dt=0 and converges with large lambda*dt", () => {
|
||||
expect(damp(5, 20, 4, 0)).toBe(5);
|
||||
expect(damp(0, 100, 50, 1)).toBeCloseTo(100, 4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("logisticsbrain/math — seeded()", () => {
|
||||
it("is deterministic and bounded to [0,1)", () => {
|
||||
expect(seeded(42)).toBe(seeded(42));
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const v = seeded(i);
|
||||
expect(v).toBeGreaterThanOrEqual(0);
|
||||
expect(v).toBeLessThan(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -12,18 +12,7 @@ type Props = {
|
||||
const COUNT_START = 0.7;
|
||||
const COUNT_END = 0.97;
|
||||
|
||||
function Metric({ kpi, scroll, index }: { kpi: Kpi; scroll: MotionValue<number>; index: number }) {
|
||||
const [jitter, setJitter] = React.useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
// Tiny micro-fluctuation between -0.4% and +0.4%
|
||||
const val = (Math.random() - 0.5) * 0.008;
|
||||
setJitter(val);
|
||||
}, 800 + Math.random() * 600); // slightly staggered updates
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
function Metric({ kpi, scroll, index, jitter }: { kpi: Kpi; scroll: MotionValue<number>; index: number; jitter: number }) {
|
||||
// Single reactive string — updates without re-rendering React.
|
||||
const value = useTransform(scroll, (v) => {
|
||||
const t = Math.min(1, Math.max(0, (v - COUNT_START) / (COUNT_END - COUNT_START)));
|
||||
@@ -140,10 +129,24 @@ function Metric({ kpi, scroll, index }: { kpi: Kpi; scroll: MotionValue<number>;
|
||||
const MetricCard = React.memo(Metric);
|
||||
|
||||
function MetricsPanel({ scroll }: Props) {
|
||||
// One shared interval drives the live "operational" micro-fluctuation for all
|
||||
// cards (was 5 independent timers). Paused under reduced-motion and when the
|
||||
// tab is hidden.
|
||||
const [jitters, setJitters] = React.useState<number[]>(() => KPIS.map(() => 0));
|
||||
|
||||
React.useEffect(() => {
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||
const id = setInterval(() => {
|
||||
if (document.hidden) return;
|
||||
setJitters(KPIS.map(() => (Math.random() - 0.5) * 0.008));
|
||||
}, 1100);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="dm-opt-metrics" role="group" aria-label="Optimization results">
|
||||
{KPIS.map((kpi, i) => (
|
||||
<MetricCard key={kpi.key} kpi={kpi} scroll={scroll} index={i} />
|
||||
<MetricCard key={kpi.key} kpi={kpi} scroll={scroll} index={i} jitter={jitters[i]} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useRef } from "react";
|
||||
import { Canvas, useFrame } from "@react-three/fiber";
|
||||
import { EffectComposer, Bloom, Vignette } from "@react-three/postprocessing";
|
||||
import { EffectComposer, Bloom } from "@react-three/postprocessing";
|
||||
import { KernelSize } from "postprocessing";
|
||||
import { COLORS } from "./constants";
|
||||
import { damp, lerp } from "./math";
|
||||
@@ -47,7 +47,7 @@ function OptimizationCanvas({ progress, reduced = false, isMobile = false, activ
|
||||
return (
|
||||
<Canvas
|
||||
flat
|
||||
dpr={[1, isMobile || reduced ? 1.3 : 1.6]}
|
||||
dpr={[1, isMobile || reduced ? 1.25 : 1.5]}
|
||||
camera={{ position: [0, 9, 19], fov: 50, near: 0.1, far: 120 }}
|
||||
gl={{ antialias: !isMobile, powerPreference: "high-performance", alpha: false }}
|
||||
frameloop={active ? "always" : "never"}
|
||||
@@ -74,7 +74,6 @@ function OptimizationCanvas({ progress, reduced = false, isMobile = false, activ
|
||||
radius={isMobile ? 0.6 : 0.75}
|
||||
kernelSize={KernelSize.MEDIUM}
|
||||
/>
|
||||
<Vignette eskil={false} offset={0.25} darkness={0.5} />
|
||||
</EffectComposer>
|
||||
)}
|
||||
</Canvas>
|
||||
|
||||
@@ -5,7 +5,6 @@ import dynamic from "next/dynamic";
|
||||
import { motion, AnimatePresence, useMotionValue, useTransform } from "framer-motion";
|
||||
import gsap from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import Lenis from "lenis";
|
||||
import {
|
||||
COLORS,
|
||||
PHASE_LABELS,
|
||||
@@ -130,7 +129,7 @@ export default function OptimizationSection() {
|
||||
mountIo.disconnect();
|
||||
}
|
||||
},
|
||||
{ rootMargin: "120% 0px" },
|
||||
{ rootMargin: "70% 0px" },
|
||||
);
|
||||
const activeIo = new IntersectionObserver(
|
||||
(entries) => setSceneActive(entries.some((e) => e.isIntersecting)),
|
||||
@@ -155,23 +154,11 @@ export default function OptimizationSection() {
|
||||
if (!el) return;
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
// Smooth scroll (Lenis), driven by a SINGLE rAF source — GSAP's ticker.
|
||||
// Previously lenis.raf() was called from both a manual requestAnimationFrame
|
||||
// loop AND gsap.ticker, double-stepping the integrator every frame, which is
|
||||
// what made scrolling stutter. One source keeps Lenis + ScrollTrigger locked.
|
||||
const lenis = new Lenis({
|
||||
duration: 1.05,
|
||||
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
|
||||
orientation: "vertical",
|
||||
gestureOrientation: "vertical",
|
||||
smoothWheel: true,
|
||||
});
|
||||
|
||||
lenis.on("scroll", ScrollTrigger.update);
|
||||
const tickerCb = (time: number) => lenis.raf(time * 1000); // ticker is in seconds, Lenis wants ms
|
||||
gsap.ticker.add(tickerCb);
|
||||
gsap.ticker.lagSmoothing(0);
|
||||
|
||||
// NOTE: /miletruth runs on native scroll (no Lenis). Smooth-scrolling this
|
||||
// page fought the three stacked pinned WebGL sections and caused scroll lag;
|
||||
// global Lenis (src/animations/SmoothScroll.tsx) is intentionally disabled on
|
||||
// this route. ScrollTrigger's scrub + self-managed fixed pin work as-is on
|
||||
// native scroll.
|
||||
let lastPhase: PhaseKey = "chaos";
|
||||
let lastPin: "before" | "pinned" | "after" = "before";
|
||||
const st = ScrollTrigger.create({
|
||||
@@ -201,8 +188,6 @@ export default function OptimizationSection() {
|
||||
return () => {
|
||||
clearTimeout(refresh);
|
||||
st.kill();
|
||||
gsap.ticker.remove(tickerCb);
|
||||
lenis.destroy();
|
||||
};
|
||||
}, [scroll]);
|
||||
|
||||
@@ -211,7 +196,8 @@ export default function OptimizationSection() {
|
||||
const leftBlur = useTransform(scroll, [0.3, 0.55], [0, 3]);
|
||||
const leftFilter = useTransform(leftBlur, (b) => `blur(${b}px)`);
|
||||
const rightOpacity = useTransform(scroll, [0.42, 0.66], [0.36, 1]);
|
||||
const scanWidth = useTransform(scroll, [0, 1], ["0%", "100%"]);
|
||||
// GPU transform (scaleX) instead of animating width, which forces layout each frame.
|
||||
const scanScaleX = useTransform(scroll, [0, 1], [0, 1]);
|
||||
const scanLineY = useTransform(scroll, [0.2, 0.42], ["8%", "92%"]);
|
||||
const scanLineOpacity = useTransform(scroll, [0.18, 0.22, 0.42, 0.46], [0, 1, 1, 0]);
|
||||
const dividerOpacity = useTransform(scroll, [0.45, 0.6], [0.15, 0.75]);
|
||||
@@ -350,7 +336,7 @@ export default function OptimizationSection() {
|
||||
{/* Scan progress bar */}
|
||||
<div className="dm-opt-progress">
|
||||
<div className="dm-opt-progress__track">
|
||||
<motion.div className="dm-opt-progress__fill" style={{ width: scanWidth }} />
|
||||
<motion.div className="dm-opt-progress__fill" style={{ scaleX: scanScaleX }} />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -470,17 +456,20 @@ const styles = `
|
||||
}
|
||||
/* Animated subtle grid pattern */
|
||||
.dm-opt-card::before {
|
||||
content: ""; position: absolute; inset: 0; z-index: 0; pointer-events: none;
|
||||
/* Expanded one tile beyond the (clipped) card so we can drift it via a GPU
|
||||
transform instead of animating background-position (which repaints each frame). */
|
||||
content: ""; position: absolute; inset: -60px; z-index: 0; pointer-events: none;
|
||||
opacity: 0.035;
|
||||
background-image:
|
||||
linear-gradient(${rgba(COLORS.cyan, 0.5)} 1px, transparent 1px),
|
||||
linear-gradient(90deg, ${rgba(COLORS.cyan, 0.5)} 1px, transparent 1px);
|
||||
background-size: 60px 60px;
|
||||
animation: dmOptGridDrift 25s linear infinite;
|
||||
will-change: transform;
|
||||
}
|
||||
@keyframes dmOptGridDrift {
|
||||
0% { background-position: 0 0; }
|
||||
100% { background-position: 60px 60px; }
|
||||
0% { transform: translate3d(0, 0, 0); }
|
||||
100% { transform: translate3d(60px, 60px, 0); }
|
||||
}
|
||||
/* Radial center glow behind 3D scene */
|
||||
.dm-opt-card::after {
|
||||
@@ -669,7 +658,7 @@ const styles = `
|
||||
background: ${rgba(COLORS.cyan, 0.10)}; max-width: 420px; margin: 0 auto;
|
||||
}
|
||||
.dm-opt-progress__fill {
|
||||
height: 100%; border-radius: 999px;
|
||||
height: 100%; width: 100%; transform-origin: left center; border-radius: 999px;
|
||||
background: linear-gradient(90deg, ${COLORS.cyan}, ${COLORS.green});
|
||||
box-shadow: 0 0 12px ${rgba(COLORS.cyan, 0.6)};
|
||||
}
|
||||
@@ -697,9 +686,10 @@ const styles = `
|
||||
.dm-opt-panel {
|
||||
pointer-events: auto; width: clamp(230px, 26vw, 340px);
|
||||
padding: 18px 20px; border-radius: 20px;
|
||||
background: ${rgba(COLORS.ink, 0.72)};
|
||||
background: ${rgba(COLORS.ink, 0.92)};
|
||||
border: 1px solid ${rgba(COLORS.slate, 0.22)};
|
||||
backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
|
||||
/* backdrop blur removed — panel opacity is scroll-driven, so the blur was
|
||||
recomputed every scroll frame; a near-opaque fill reads the same and is free. */
|
||||
}
|
||||
.dm-opt-panel--bad {
|
||||
border-color: ${rgba(COLORS.red, 0.45)};
|
||||
@@ -772,9 +762,9 @@ const styles = `
|
||||
}
|
||||
.dm-opt-metric {
|
||||
position: relative; padding: 14px 14px 12px; border-radius: 16px;
|
||||
background: ${rgba(COLORS.ink, 0.72)};
|
||||
background: ${rgba(COLORS.ink, 0.92)};
|
||||
border: 1px solid ${rgba(COLORS.slate, 0.25)};
|
||||
backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
|
||||
/* backdrop blur removed — 5 metric cards animate on scroll; per-frame blur recompute was a hotspot. */
|
||||
overflow: hidden;
|
||||
opacity: 0; transform: translateY(22px);
|
||||
animation: dmOptCardIn 0.6s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
|
||||
58
src/components/optimization/constants.test.ts
Normal file
58
src/components/optimization/constants.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { rgba, phaseFromProgress, PHASES, PHASE_LABELS, type PhaseKey } from "./constants";
|
||||
|
||||
describe("optimization/constants — rgba()", () => {
|
||||
it("converts a hex string to an rgba() string", () => {
|
||||
expect(rgba("#22C55E", 0.5)).toBe("rgba(34, 197, 94, 0.5)");
|
||||
expect(rgba("#000000", 1)).toBe("rgba(0, 0, 0, 1)");
|
||||
expect(rgba("#FFFFFF", 0)).toBe("rgba(255, 255, 255, 0)");
|
||||
});
|
||||
|
||||
it("is case-insensitive for hex digits", () => {
|
||||
expect(rgba("#ffffff", 1)).toBe(rgba("#FFFFFF", 1));
|
||||
});
|
||||
});
|
||||
|
||||
describe("optimization/constants — phaseFromProgress()", () => {
|
||||
it("returns the right phase at each threshold", () => {
|
||||
expect(phaseFromProgress(PHASES.chaos)).toBe("chaos");
|
||||
expect(phaseFromProgress(PHASES.scan)).toBe("scan");
|
||||
expect(phaseFromProgress(PHASES.dissolve)).toBe("dissolve");
|
||||
expect(phaseFromProgress(PHASES.optimize)).toBe("optimize");
|
||||
expect(phaseFromProgress(PHASES.reorganize)).toBe("reorganize");
|
||||
expect(phaseFromProgress(PHASES.metrics)).toBe("metrics");
|
||||
});
|
||||
|
||||
it("returns the previous phase just below a threshold", () => {
|
||||
expect(phaseFromProgress(PHASES.scan - 0.001)).toBe("chaos");
|
||||
expect(phaseFromProgress(PHASES.dissolve - 0.001)).toBe("scan");
|
||||
expect(phaseFromProgress(PHASES.metrics - 0.001)).toBe("reorganize");
|
||||
});
|
||||
|
||||
it("handles the 0 and 1 extremes", () => {
|
||||
expect(phaseFromProgress(0)).toBe("chaos");
|
||||
expect(phaseFromProgress(1)).toBe("metrics");
|
||||
});
|
||||
|
||||
it("only ever returns a defined phase key across a sweep", () => {
|
||||
const keys = Object.keys(PHASES) as PhaseKey[];
|
||||
for (let p = 0; p <= 1.0001; p += 0.02) {
|
||||
expect(keys).toContain(phaseFromProgress(p));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("optimization/constants — phase tables", () => {
|
||||
it("has strictly increasing phase thresholds", () => {
|
||||
const vals = Object.values(PHASES);
|
||||
for (let i = 1; i < vals.length; i++) {
|
||||
expect(vals[i]).toBeGreaterThan(vals[i - 1]);
|
||||
}
|
||||
});
|
||||
|
||||
it("has a label for every phase key", () => {
|
||||
for (const key of Object.keys(PHASES) as PhaseKey[]) {
|
||||
expect(typeof PHASE_LABELS[key]).toBe("string");
|
||||
expect(PHASE_LABELS[key].length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
99
src/components/optimization/math.test.ts
Normal file
99
src/components/optimization/math.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { clamp01, lerp, remap, smoothstep, damp, seeded } from "./math";
|
||||
|
||||
describe("optimization/math — clamp01()", () => {
|
||||
it("clamps to [0,1]", () => {
|
||||
expect(clamp01(-2)).toBe(0);
|
||||
expect(clamp01(0)).toBe(0);
|
||||
expect(clamp01(0.37)).toBe(0.37);
|
||||
expect(clamp01(1)).toBe(1);
|
||||
expect(clamp01(5)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("optimization/math — lerp()", () => {
|
||||
it("interpolates linearly", () => {
|
||||
expect(lerp(0, 10, 0)).toBe(0);
|
||||
expect(lerp(0, 10, 1)).toBe(10);
|
||||
expect(lerp(0, 10, 0.5)).toBe(5);
|
||||
expect(lerp(2, 4, 0.25)).toBe(2.5);
|
||||
});
|
||||
|
||||
it("extrapolates past the endpoints", () => {
|
||||
expect(lerp(0, 10, 2)).toBe(20);
|
||||
expect(lerp(0, 10, -1)).toBe(-10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("optimization/math — remap()", () => {
|
||||
it("remaps and clamps to the output range", () => {
|
||||
expect(remap(5, 0, 10, 0, 100)).toBe(50);
|
||||
expect(remap(-5, 0, 10, 0, 100)).toBe(0);
|
||||
expect(remap(15, 0, 10, 0, 100)).toBe(100);
|
||||
});
|
||||
|
||||
it("defaults the output range to [0,1]", () => {
|
||||
expect(remap(2.5, 0, 10)).toBeCloseTo(0.25, 10);
|
||||
});
|
||||
|
||||
it("guards against a zero-width input range (no NaN)", () => {
|
||||
const v = remap(5, 2, 2, 0, 1);
|
||||
expect(Number.isNaN(v)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("optimization/math — smoothstep()", () => {
|
||||
it("hits 0 and 1 at the edges and 0.5 at the middle", () => {
|
||||
expect(smoothstep(0, 1, 0)).toBe(0);
|
||||
expect(smoothstep(0, 1, 1)).toBe(1);
|
||||
expect(smoothstep(0, 1, 0.5)).toBeCloseTo(0.5, 10);
|
||||
});
|
||||
|
||||
it("clamps outside the edges", () => {
|
||||
expect(smoothstep(0, 1, -1)).toBe(0);
|
||||
expect(smoothstep(0, 1, 2)).toBe(1);
|
||||
});
|
||||
|
||||
it("is monotonically non-decreasing", () => {
|
||||
let prev = -Infinity;
|
||||
for (let x = -0.2; x <= 1.2; x += 0.1) {
|
||||
const v = smoothstep(0, 1, x);
|
||||
expect(v).toBeGreaterThanOrEqual(prev);
|
||||
prev = v;
|
||||
}
|
||||
});
|
||||
|
||||
it("guards against equal edges (no NaN)", () => {
|
||||
expect(Number.isNaN(smoothstep(1, 1, 1))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("optimization/math — damp()", () => {
|
||||
it("does not move when dt is 0", () => {
|
||||
expect(damp(3, 10, 5, 0)).toBe(3);
|
||||
});
|
||||
|
||||
it("moves toward the target and converges with large lambda*dt", () => {
|
||||
const once = damp(0, 10, 1, 0.5);
|
||||
expect(once).toBeGreaterThan(0);
|
||||
expect(once).toBeLessThan(10);
|
||||
expect(damp(0, 10, 50, 1)).toBeCloseTo(10, 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("optimization/math — seeded()", () => {
|
||||
it("is deterministic for a given input", () => {
|
||||
expect(seeded(7)).toBe(seeded(7));
|
||||
});
|
||||
|
||||
it("returns values within [0,1)", () => {
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const v = seeded(i);
|
||||
expect(v).toBeGreaterThanOrEqual(0);
|
||||
expect(v).toBeLessThan(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("produces different values for different seeds", () => {
|
||||
expect(seeded(1)).not.toBe(seeded(2));
|
||||
});
|
||||
});
|
||||
@@ -74,8 +74,10 @@ export default function CompetitiveEdge() {
|
||||
<div className="container">
|
||||
{/* Section Header */}
|
||||
<div className="comparison-header" ref={headerRef}>
|
||||
<div className="advantage-badge">DoorMile Advantage</div>
|
||||
<h2 className="moat-heading">WHERE DOORMILE SITS AND WHY IT WINS</h2>
|
||||
<div className="advantage-eyebrow-container">
|
||||
<span className="advantage-eyebrow">/ DoorMile wins/</span>
|
||||
</div>
|
||||
<h2 className="moat-heading" data-text="WHERE DOORMILE SITS AND WHY IT WINS">WHERE DOORMILE SITS AND WHY IT WINS</h2>
|
||||
<p className="moat-desc">
|
||||
A side-by-side technical capabilities comparison showing how operational fleet ownership and dynamic AI planning disrupt basic aggregators.
|
||||
</p>
|
||||
@@ -253,59 +255,61 @@ export default function CompetitiveEdge() {
|
||||
|
||||
/* Section Header Layout */
|
||||
.comparison-header {
|
||||
text-align: center;
|
||||
text-align: left;
|
||||
margin-bottom: 60px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* DoorMile Advantage Badge */
|
||||
.advantage-badge {
|
||||
.advantage-eyebrow-container {
|
||||
width: 100%;
|
||||
border-bottom: 2px solid rgba(0, 0, 0, 0.16);
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
/* DoorMile Advantage Eyebrow */
|
||||
.advantage-eyebrow {
|
||||
font-family: 'Manrope', sans-serif;
|
||||
font-weight: 800;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.12em;
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: #c8102e;
|
||||
background: rgba(200, 16, 46, 0.06);
|
||||
padding: 6px 14px;
|
||||
border-radius: 30px;
|
||||
margin: 0 auto 24px auto;
|
||||
border: 1.5px solid rgba(200, 16, 46, 0.15);
|
||||
color: #060606ff;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Centered heading with bottom accent underline */
|
||||
/* Outlined heading style with clean duplicate layering hack */
|
||||
.moat-heading {
|
||||
font-family: 'Manrope', sans-serif;
|
||||
font-size: clamp(2rem, 3.4vw, 3.2rem); /* Slightly enlarged for premium visual weight */
|
||||
font-weight: 800;
|
||||
line-height: 1.15;
|
||||
color: #111111;
|
||||
margin: 0 auto 20px auto;
|
||||
letter-spacing: -0.03em;
|
||||
position: relative;
|
||||
font-family: var(--font-syne), 'Syne', sans-serif !important;
|
||||
font-size: clamp(2.4rem, 6.8vw, 6.6rem) !important;
|
||||
font-weight: 800 !important;
|
||||
line-height: 1.1 !important;
|
||||
color: #fafafa !important; /* solid background color to cover inner overlapping outlines */
|
||||
margin: 0 0 24px 0;
|
||||
letter-spacing: -0.02em;
|
||||
text-transform: uppercase;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Centered horizontal red underline accent decoration */
|
||||
.moat-heading::after {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 72px;
|
||||
height: 4px;
|
||||
background: #c8102e;
|
||||
margin-top: 18px;
|
||||
border-radius: 2px;
|
||||
content: attr(data-text) !important;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: -1;
|
||||
color: transparent !important;
|
||||
-webkit-text-stroke: 2.2px #c8102e;
|
||||
-webkit-text-fill-color: transparent !important;
|
||||
pointer-events: none;
|
||||
display: block !important; /* override any old display: none */
|
||||
}
|
||||
|
||||
.moat-desc {
|
||||
@@ -313,9 +317,9 @@ export default function CompetitiveEdge() {
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.65;
|
||||
color: #585c67;
|
||||
margin: 16px auto 0 auto !important;
|
||||
max-width: 760px !important;
|
||||
text-align: center !important;
|
||||
margin: 16px 0 0 0 !important;
|
||||
max-width: 820px !important;
|
||||
text-align: left !important;
|
||||
}
|
||||
|
||||
/* Spacious table styling wrapper (100% width on Desktop) */
|
||||
|
||||
@@ -1,405 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { ScrollReveal } from "@/animations/Reveal";
|
||||
import emailjs from "@emailjs/browser";
|
||||
|
||||
// Type definitions for EmailJS template parameters
|
||||
interface EmailJSTemplateParams extends Record<string, unknown> {
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
company: string;
|
||||
subject: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export default function ContactForm() {
|
||||
const socialIconSpacing = {
|
||||
"--grid-column-gap": "52px",
|
||||
"--grid-row-gap": "18px",
|
||||
columnGap: "52px",
|
||||
rowGap: "18px",
|
||||
} as React.CSSProperties;
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
fullName: "",
|
||||
email: "",
|
||||
subject: "",
|
||||
message: "",
|
||||
});
|
||||
const [formStatus, setFormStatus] = useState<"idle" | "submitting" | "success" | "error">("idle");
|
||||
const [errorMessage, setErrorMessage] = useState<string>("");
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
// Pre-submission validation function
|
||||
const validateForm = (): string | null => {
|
||||
if (!formData.fullName.trim()) {
|
||||
return "Full name is required.";
|
||||
}
|
||||
if (formData.fullName.trim().length < 2) {
|
||||
return "Full name must be at least 2 characters.";
|
||||
}
|
||||
|
||||
if (!formData.email.trim()) {
|
||||
return "Email is required.";
|
||||
}
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(formData.email.trim())) {
|
||||
return "Please enter a valid email address.";
|
||||
}
|
||||
|
||||
if (!formData.subject.trim()) {
|
||||
return "Subject is required.";
|
||||
}
|
||||
|
||||
if (!formData.message.trim()) {
|
||||
return "Message is required.";
|
||||
}
|
||||
if (formData.message.trim().length < 10) {
|
||||
return "Message must be at least 10 characters.";
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErrorMessage("");
|
||||
|
||||
// Validate inputs before submitting to EmailJS
|
||||
const validationError = validateForm();
|
||||
if (validationError) {
|
||||
setErrorMessage(validationError);
|
||||
setFormStatus("error");
|
||||
return;
|
||||
}
|
||||
|
||||
setFormStatus("submitting");
|
||||
|
||||
// Fetch credentials from Next.js environment variables
|
||||
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 credentials are not configured in environment variables.");
|
||||
setErrorMessage("Email service configuration error. Please contact the administrator.");
|
||||
setFormStatus("error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Map form fields to EmailJS template variables matching user's template config
|
||||
const templateParams: EmailJSTemplateParams = {
|
||||
name: formData.fullName.trim(),
|
||||
email: formData.email.trim(),
|
||||
phone: "", // phone is not available in the current contact form UI
|
||||
company: "", // company is not available in the current contact form UI
|
||||
subject: formData.subject.trim(),
|
||||
message: formData.message.trim(),
|
||||
};
|
||||
|
||||
// Send email via EmailJS API
|
||||
await emailjs.send(serviceId, templateId, templateParams, publicKey);
|
||||
|
||||
setFormStatus("success");
|
||||
// Reset form fields after successful submission
|
||||
setFormData({ fullName: "", email: "", subject: "", message: "" });
|
||||
} catch (error) {
|
||||
console.error("EmailJS Error:", error);
|
||||
setErrorMessage("Failed to send message. Please try again later.");
|
||||
setFormStatus("error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="elementor elementor-6585">
|
||||
<style dangerouslySetInnerHTML={{ __html: `
|
||||
/* ---- Clean contact form (scoped to this section) ---- */
|
||||
.elementor-6585 .elementor-element.elementor-element-a5c503d {
|
||||
--padding-top: 60px;
|
||||
--padding-bottom: 60px;
|
||||
--padding-left: 60px;
|
||||
--padding-right: 60px;
|
||||
}
|
||||
.elementor-6585 .elementor-element.elementor-element-0e6fedf > .elementor-widget-container {
|
||||
margin: 4px 0 0 0;
|
||||
}
|
||||
|
||||
/* drop the legacy notched / floating-label borders */
|
||||
.elementor-6585 .wpforms-form .logico-form-field:before,
|
||||
.elementor-6585 .wpforms-form .logico-form-field:after,
|
||||
.elementor-6585 .wpforms-form .logico-label-placeholder { display: none !important; }
|
||||
|
||||
/* even field rhythm */
|
||||
.elementor-6585 .wpforms-form .wpforms-field-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.elementor-6585 .wpforms-form .wpforms-field { padding: 0 !important; margin: 0 !important; }
|
||||
|
||||
/* labels stay for screen readers; placeholders carry the visible text */
|
||||
.elementor-6585 .wpforms-form .wpforms-field-label {
|
||||
position: absolute !important;
|
||||
width: 1px; height: 1px;
|
||||
padding: 0; margin: -1px;
|
||||
overflow: hidden; clip: rect(0 0 0 0);
|
||||
white-space: nowrap; border: 0;
|
||||
}
|
||||
|
||||
/* clean rounded inputs */
|
||||
.elementor-6585 .wpforms-form input[type="text"],
|
||||
.elementor-6585 .wpforms-form input[type="email"],
|
||||
.elementor-6585 .wpforms-form textarea {
|
||||
width: 100%;
|
||||
border: 1px solid #e3e3e3 !important;
|
||||
border-radius: 12px !important;
|
||||
padding: 16px 20px !important;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
color: #111;
|
||||
background: #fff;
|
||||
box-shadow: none !important;
|
||||
transition: border-color .25s ease;
|
||||
}
|
||||
.elementor-6585 .wpforms-form textarea { min-height: 150px; resize: vertical; }
|
||||
.elementor-6585 .wpforms-form input::placeholder,
|
||||
.elementor-6585 .wpforms-form textarea::placeholder { color: #9a9a9a; opacity: 1; }
|
||||
.elementor-6585 .wpforms-form input:focus,
|
||||
.elementor-6585 .wpforms-form textarea:focus { border-color: #c01227 !important; outline: none; }
|
||||
|
||||
.elementor-6585 .wpforms-form .wpforms-submit-container { padding-top: 26px !important; }
|
||||
|
||||
@media (max-width: 1020px) {
|
||||
.elementor-6585 .elementor-element.elementor-element-a5c503d {
|
||||
--padding-top: 40px;
|
||||
--padding-bottom: 40px;
|
||||
--padding-left: 32px;
|
||||
--padding-right: 32px;
|
||||
}
|
||||
}
|
||||
` }} />
|
||||
<div className="elementor-element elementor-element-3cd920c e-con-full e-flex cut-corner-no sticky-container-off e-con e-parent" data-id="3cd920c" data-element_type="container" data-e-type="container">
|
||||
<div className="elementor-element elementor-element-b29b8fc e-flex e-con-boxed cut-corner-no sticky-container-off e-con e-child" data-id="b29b8fc" data-element_type="container" data-e-type="container" data-settings="{"background_background":"classic"}">
|
||||
<div className="e-con-inner">
|
||||
<div className="elementor-element elementor-element-ef6fa6d e-con-full e-flex cut-corner-no sticky-container-off e-con e-child" data-id="ef6fa6d" data-element_type="container" data-e-type="container">
|
||||
{/* Left Dark Panel */}
|
||||
<div className="elementor-element elementor-element-9990148 e-con-full e-flex cut-corner-no sticky-container-off e-con e-child" data-id="9990148" data-element_type="container" data-e-type="container">
|
||||
<div className="elementor-element elementor-element-8899bdf elementor-absolute elementor-widget elementor-widget-image" data-id="8899bdf" data-element_type="widget" data-e-type="widget" data-settings="{"_position":"absolute"}" data-widget_type="image.default">
|
||||
<div className="elementor-widget-container">
|
||||
<Image width={965} height={474} src="/images/bg-map.png" className="attachment-full size-full wp-image-1148" alt="" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="elementor-element elementor-element-a0e7516 elementor-widget elementor-widget-logico_heading" data-id="a0e7516" data-element_type="widget" data-e-type="widget" data-widget_type="logico_heading.default">
|
||||
<div className="elementor-widget-container">
|
||||
<div className="logico-title">/ get in touch /</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="elementor-element elementor-element-51cdf4f elementor-widget elementor-widget-logico_heading" data-id="51cdf4f" data-element_type="widget" data-e-type="widget" data-widget_type="logico_heading.default">
|
||||
<div className="elementor-widget-container">
|
||||
<ScrollReveal delay={0.1} duration={0.8} yOffset={25}>
|
||||
<h3 className="logico-title">
|
||||
We are always ready to help you and answer your questions
|
||||
</h3>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</div>
|
||||
<div className="elementor-element elementor-element-670d1b2 elementor-widget elementor-widget-text-editor" data-id="670d1b2" data-element_type="widget" data-e-type="widget" data-widget_type="text-editor.default">
|
||||
<div className="elementor-widget-container">
|
||||
<p>Connecting businesses with fast, secure, smart deliveries.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="elementor-element elementor-element-2631b42 e-flex e-con-boxed cut-corner-no sticky-container-off e-con e-child" data-id="2631b42" data-element_type="container" data-e-type="container">
|
||||
<div className="e-con-inner">
|
||||
<div className="elementor-element elementor-element-df89993 e-con-full e-flex cut-corner-no sticky-container-off e-con e-child" data-id="df89993" data-element_type="container" data-e-type="container">
|
||||
<div className="elementor-element elementor-element-69b6892 elementor-widget elementor-widget-logico_heading" data-id="69b6892" data-element_type="widget" data-e-type="widget" data-widget_type="logico_heading.default">
|
||||
<div className="elementor-widget-container">
|
||||
<div className="logico-title">Call Center</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="elementor-element elementor-element-87be926 elementor-widget elementor-widget-text-editor" data-id="87be926" data-element_type="widget" data-e-type="widget" data-widget_type="text-editor.default">
|
||||
<div className="elementor-widget-container">
|
||||
<p>Tel : +91 86886 97941</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="elementor-element elementor-element-f5d8e63 e-con-full e-flex cut-corner-no sticky-container-off e-con e-child" data-id="f5d8e63" data-element_type="container" data-e-type="container">
|
||||
<div className="elementor-element elementor-element-774e540 elementor-widget elementor-widget-logico_heading" data-id="774e540" data-element_type="widget" data-e-type="widget" data-widget_type="logico_heading.default">
|
||||
<div className="elementor-widget-container">
|
||||
<div className="logico-title">Our Location</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="elementor-element elementor-element-9c1cf03 elementor-widget elementor-widget-text-editor" data-id="9c1cf03" data-element_type="widget" data-e-type="widget" data-widget_type="text-editor.default">
|
||||
<div className="elementor-widget-container">
|
||||
<p>5th Floor, Vision Ultima, Street No.3, Jayabheri Enclave, Gachibowli, Hyderabad, Telangana 500032.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="elementor-element elementor-element-645be8d e-flex e-con-boxed cut-corner-no sticky-container-off e-con e-child" data-id="645be8d" data-element_type="container" data-e-type="container">
|
||||
<div className="e-con-inner">
|
||||
<div className="elementor-element elementor-element-a96d151 e-con-full e-flex cut-corner-no sticky-container-off e-con e-child" data-id="a96d151" data-element_type="container" data-e-type="container">
|
||||
<div className="elementor-element elementor-element-37e647f elementor-widget elementor-widget-logico_heading" data-id="37e647f" data-element_type="widget" data-e-type="widget" data-widget_type="logico_heading.default">
|
||||
<div className="elementor-widget-container">
|
||||
<div className="logico-title">Email</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="elementor-element elementor-element-ba67644 elementor-widget elementor-widget-text-editor" data-id="ba67644" data-element_type="widget" data-e-type="widget" data-widget_type="text-editor.default">
|
||||
<div className="elementor-widget-container">
|
||||
<p>
|
||||
<a href="mailto:care@doormile.com">care@doormile.com</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="elementor-element elementor-element-9ba4b82 e-con-full e-flex cut-corner-no sticky-container-off e-con e-child" data-id="9ba4b82" data-element_type="container" data-e-type="container">
|
||||
<div className="elementor-element elementor-element-e9a5d79 elementor-widget elementor-widget-logico_heading" data-id="e9a5d79" data-element_type="widget" data-e-type="widget" data-widget_type="logico_heading.default">
|
||||
<div className="elementor-widget-container">
|
||||
<div className="logico-title">Social network</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="elementor-element elementor-element-a6bccba elementor-shape-square elementor-grid-0 elementor-widget elementor-widget-social-icons" data-id="a6bccba" data-element_type="widget" data-e-type="widget" data-widget_type="social-icons.default">
|
||||
<div className="elementor-widget-container">
|
||||
<div className="elementor-social-icons-wrapper elementor-grid" role="list" style={socialIconSpacing}>
|
||||
<span className="elementor-grid-item" role="listitem" style={{padding:"0 15px"}}>
|
||||
<a className="elementor-icon elementor-social-icon elementor-social-icon-facebook-f elementor-repeater-item-3fbe893" href="https://www.facebook.com" target="_blank" rel="noopener noreferrer">
|
||||
<span className="elementor-screen-only">Facebook</span>
|
||||
<svg aria-hidden="true" className="e-font-icon-svg e-fab-facebook-f" viewBox="0 0 320 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M279.14 288l14.22-92.66h-88.91v-60.13c0-25.35 12.42-50.06 52.24-50.06h40.42V6.26S260.43 0 225.36 0c-73.22 0-121.08 44.38-121.08 124.72v70.62H22.89V288h81.39v224h100.17V288z"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</span>
|
||||
<span className="elementor-grid-item" role="listitem" style={{padding:"0 15px"}}>
|
||||
<a className="elementor-icon elementor-social-icon elementor-social-icon-x-twitter elementor-repeater-item-64ac94e" href="https://x.com" target="_blank" rel="noopener noreferrer">
|
||||
<span className="elementor-screen-only">X</span>
|
||||
<svg aria-hidden="true" className="e-font-icon-svg e-fab-x-twitter" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M389.2 48h70.6L305.6 224.2 487 464H345L233.7 318.6 106.5 464H35.8L200.7 275.5 26.8 48H172.4L272.9 180.9 389.2 48zM364.4 421.8h39.1L151.1 88h-42L364.4 421.8z"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</span>
|
||||
<span className="elementor-grid-item" role="listitem" style={{padding:"0 15px"}}>
|
||||
<a className="elementor-icon elementor-social-icon elementor-social-icon-linkedin-in elementor-repeater-item-38e1bcc" href="https://www.linkedin.com" target="_blank" rel="noopener noreferrer">
|
||||
<span className="elementor-screen-only">LinkedIn</span>
|
||||
<svg aria-hidden="true" className="e-font-icon-svg e-fab-linkedin-in" viewBox="0 0 448 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M100.28 448H7.4V148.9h92.88zM53.79 108.1C24.09 108.1 0 83.5 0 53.8a53.79 53.79 0 0 1 107.58 0c0 29.7-24.1 54.3-53.79 54.3zM447.9 448h-92.68V302.4c0-34.7-.7-79.2-48.29-79.2-48.29 0-55.69 37.7-55.69 76.7V448h-92.78V148.9h89.08v40.8h1.3c12.4-23.5 42.69-48.3 87.88-48.3 94 0 111.28 61.9 111.28 142.3V448z"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</span>
|
||||
<span className="elementor-grid-item" role="listitem" style={{padding:"0 15px"}}>
|
||||
<a className="elementor-icon elementor-social-icon elementor-social-icon-youtube elementor-repeater-item-b0d5e1f" href="https://www.youtube.com" target="_blank" rel="noopener noreferrer">
|
||||
<span className="elementor-screen-only">YouTube</span>
|
||||
<svg aria-hidden="true" className="e-font-icon-svg e-fab-youtube" viewBox="0 0 576 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M549.655 124.083c-6.281-23.65-24.787-42.276-48.284-48.597C458.781 64 288 64 288 64S117.22 64 74.629 75.486c-23.497 6.322-42.003 24.947-48.284 48.597-11.412 42.867-11.412 132.305-11.412 132.305s0 89.438 11.412 132.305c6.281 23.65 24.787 41.5 48.284 47.821C117.22 448 288 448 288 448s170.78 0 213.371-11.486c23.497-6.321 42.003-24.171 48.284-47.821 11.412-42.867 11.412-132.305 11.412-132.305s0-89.438-11.412-132.305zm-317.51 213.508V175.185l142.739 81.205-142.739 81.201z"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right White Form Card */}
|
||||
<div className="elementor-element elementor-element-a5c503d e-con-full e-flex cut-corner-no sticky-container-off e-con e-child" data-id="a5c503d" data-element_type="container" data-e-type="container" data-settings="{"background_background":"classic"}">
|
||||
<div className="elementor-element elementor-element-535776a elementor-widget elementor-widget-logico_heading" data-id="535776a" data-element_type="widget" data-e-type="widget" data-widget_type="logico_heading.default">
|
||||
<div className="elementor-widget-container">
|
||||
<div className="logico-title">Get in Touch</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="elementor-element elementor-element-0e6fedf elementor-widget elementor-widget-logico_wpforms" data-id="0e6fedf" data-element_type="widget" data-e-type="widget" data-widget_type="logico_wpforms.default">
|
||||
<div className="elementor-widget-container">
|
||||
<div className="logico-wpforms-widget">
|
||||
<div className="wpforms-container wpforms-render-modern" id="wpforms-369-contact">
|
||||
<form id="wpforms-form-369-contact" className="wpforms-validate wpforms-form" onSubmit={handleSubmit} noValidate>
|
||||
<div className="wpforms-field-container">
|
||||
<div className="wpforms-field logico-form-field">
|
||||
<label className="wpforms-field-label" htmlFor="contact-field-name">Full name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="contact-field-name"
|
||||
name="fullName"
|
||||
placeholder="Full name"
|
||||
value={formData.fullName}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="wpforms-field logico-form-field">
|
||||
<label className="wpforms-field-label" htmlFor="contact-field-email">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="contact-field-email"
|
||||
name="email"
|
||||
placeholder="Email"
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="wpforms-field logico-form-field">
|
||||
<label className="wpforms-field-label" htmlFor="contact-field-subject">Subject</label>
|
||||
<input
|
||||
type="text"
|
||||
id="contact-field-subject"
|
||||
name="subject"
|
||||
placeholder="Subject"
|
||||
value={formData.subject}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="wpforms-field logico-form-field">
|
||||
<label className="wpforms-field-label" htmlFor="contact-field-message">Message</label>
|
||||
<textarea
|
||||
id="contact-field-message"
|
||||
name="message"
|
||||
placeholder="Message"
|
||||
value={formData.message}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div className="wpforms-submit-container">
|
||||
<button
|
||||
type="submit"
|
||||
id="wpforms-submit-369-contact"
|
||||
className="logico-alter-button wpforms-submit"
|
||||
disabled={formStatus === "submitting"}
|
||||
>
|
||||
{formStatus === "submitting" ? "Sending..." : "Send a message"}
|
||||
</button>
|
||||
{formStatus === "success" && (
|
||||
<div style={{ color: "#4caf50", marginTop: "10px", fontSize: "14px" }}>
|
||||
Message sent successfully!
|
||||
</div>
|
||||
)}
|
||||
{formStatus === "error" && (
|
||||
<div style={{ color: "#f44336", marginTop: "10px", fontSize: "14px" }}>
|
||||
{errorMessage || "Something went wrong. Please try again."}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* EV-Native Design background — drifting SQUARE particles with 3D depth.
|
||||
* Each square has a depth (0.3–1): nearer squares are larger, brighter, and
|
||||
* parallax further on mouse-move + a slow auto-sway, creating a layered 3D
|
||||
* field. Mix of brand-red (#dc2626) and soft gray/white squares. Full section,
|
||||
* pointer-events:none, behind content.
|
||||
*/
|
||||
export default function EVParticles() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const parent = canvas?.parentElement;
|
||||
if (!canvas || !parent) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
||||
const COUNT = 120;
|
||||
type P = { x: number; y: number; vx: number; vy: number; s: number; red: boolean; a: number; d: number };
|
||||
let particles: P[] = [];
|
||||
let w = 0, h = 0, raf = 0, startTs = 0;
|
||||
|
||||
// mouse parallax (canvas is pointer-events:none, so track on window)
|
||||
const mouse = { x: 0, y: 0 };
|
||||
const cur = { x: 0, y: 0 };
|
||||
const onMove = (e: MouseEvent) => {
|
||||
mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
|
||||
mouse.y = (e.clientY / window.innerHeight) * 2 - 1;
|
||||
};
|
||||
|
||||
const init = () => {
|
||||
particles = [];
|
||||
for (let i = 0; i < COUNT; i++) {
|
||||
const t = Math.random();
|
||||
const s = t > 0.92 ? 12 + Math.random() * 6 : t > 0.68 ? 7 + Math.random() * 5 : 3 + Math.random() * 4;
|
||||
const red = Math.random() < 0.3;
|
||||
particles.push({
|
||||
x: Math.random() * w,
|
||||
y: Math.random() * h,
|
||||
vx: (Math.random() - 0.5) * 0.22,
|
||||
vy: (Math.random() - 0.5) * 0.22,
|
||||
s,
|
||||
red,
|
||||
a: red ? Math.random() * 0.4 + 0.35 : Math.random() * 0.28 + 0.12,
|
||||
d: Math.random() * 0.7 + 0.3, // depth 0.3 (far) .. 1 (near)
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const resize = () => {
|
||||
const rect = parent.getBoundingClientRect();
|
||||
w = Math.max(1, rect.width);
|
||||
h = Math.max(1, rect.height);
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
canvas.width = Math.round(w * dpr);
|
||||
canvas.height = Math.round(h * dpr);
|
||||
canvas.style.width = w + "px";
|
||||
canvas.style.height = h + "px";
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
if (!particles.length) init();
|
||||
};
|
||||
|
||||
const render = (time: number, move: boolean) => {
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// ease the parallax origin toward the pointer
|
||||
cur.x += (mouse.x - cur.x) * 0.04;
|
||||
cur.y += (mouse.y - cur.y) * 0.04;
|
||||
// slow auto-sway so the 3D depth animates even without mouse input
|
||||
const swayX = Math.sin(time * 0.00026) * 0.6;
|
||||
const swayY = Math.cos(time * 0.0002) * 0.45;
|
||||
const ox = (cur.x + swayX) * 34;
|
||||
const oy = (cur.y + swayY) * 26;
|
||||
|
||||
for (const p of particles) {
|
||||
if (move) {
|
||||
p.x += p.vx * p.d;
|
||||
p.y += p.vy * p.d;
|
||||
const m = p.s + 6;
|
||||
if (p.x < -m) p.x = w + m;
|
||||
else if (p.x > w + m) p.x = -m;
|
||||
if (p.y < -m) p.y = h + m;
|
||||
else if (p.y > h + m) p.y = -m;
|
||||
}
|
||||
// depth-scaled size + parallax offset (near squares move/scale more)
|
||||
const size = p.s * (0.55 + p.d * 0.75);
|
||||
const dx = p.x + ox * p.d;
|
||||
const dy = p.y + oy * p.d;
|
||||
const alpha = p.a * (0.55 + p.d * 0.45);
|
||||
|
||||
if (p.red) {
|
||||
ctx.shadowColor = "rgba(220,38,38,0.55)";
|
||||
ctx.shadowBlur = 8 * p.d;
|
||||
ctx.fillStyle = `rgba(220,38,38,${alpha})`;
|
||||
} else {
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.fillStyle = `rgba(200,202,210,${alpha})`;
|
||||
}
|
||||
ctx.fillRect(dx - size / 2, dy - size / 2, size, size);
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
};
|
||||
|
||||
const loop = (ts: number) => {
|
||||
if (!startTs) startTs = ts;
|
||||
render(ts - startTs, true);
|
||||
raf = requestAnimationFrame(loop);
|
||||
};
|
||||
|
||||
resize();
|
||||
init();
|
||||
if (reduced) {
|
||||
render(0, false);
|
||||
} else {
|
||||
window.addEventListener("mousemove", onMove, { passive: true });
|
||||
raf = requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
resize();
|
||||
if (reduced) render(0, false);
|
||||
});
|
||||
ro.observe(parent);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
ro.disconnect();
|
||||
window.removeEventListener("mousemove", onMove);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <canvas ref={canvasRef} className="evnd__canvas" aria-hidden="true" />;
|
||||
}
|
||||
@@ -8,46 +8,50 @@ if (typeof window !== "undefined") {
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
}
|
||||
|
||||
const PILLS: { value: string; label: string }[] = [
|
||||
{ value: "100%", label: "Electric Fleet" },
|
||||
{ value: "Live", label: "Route Sync" },
|
||||
{ value: "Real-time", label: "Battery Monitoring" },
|
||||
];
|
||||
|
||||
const MINI_STATS: { value: number; decimals?: number; suffix: string; label: string }[] = [
|
||||
{ value: 94, suffix: "K+", label: "Routes Optimised" },
|
||||
{ value: 23, suffix: "%", label: "Avg Battery Saved" },
|
||||
{ value: 1.4, decimals: 1, suffix: "x", label: "Charging Stops Saved" },
|
||||
];
|
||||
|
||||
const FEATURES: { icon: string; title: string; desc: string }[] = [
|
||||
const FEATURES: { icon: React.ReactNode; title: string; desc: string }[] = [
|
||||
{
|
||||
icon: "⚡",
|
||||
icon: (
|
||||
<svg className="evnd-icon" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
|
||||
</svg>
|
||||
),
|
||||
title: "Battery-Aware Routing",
|
||||
desc: "Battery level, health, and degradation are first-class inputs to route optimization — not afterthoughts.",
|
||||
},
|
||||
{
|
||||
icon: "🔌",
|
||||
icon: (
|
||||
<svg className="evnd-icon" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 10h-1.28A6 6 0 0 0 12 5V3M12 5V3M6 10h1.28A6 6 0 0 0 12 5M12 18v2M12 18v2M8 10v6a4 4 0 0 0 8 0v-6" />
|
||||
</svg>
|
||||
),
|
||||
title: "Charging Integration",
|
||||
desc: "Seamlessly integrate charging stops without compromising delivery windows or SLA commitments.",
|
||||
},
|
||||
{
|
||||
icon: "⛰",
|
||||
icon: (
|
||||
<svg className="evnd-icon" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m8 3 4 8 5-5 5 15H2L8 3z" />
|
||||
</svg>
|
||||
),
|
||||
title: "Energy-Optimized Paths",
|
||||
desc: "Factor in elevation, speed limits, payload weight, and live weather for maximum range efficiency.",
|
||||
},
|
||||
{
|
||||
icon: "🛡",
|
||||
icon: (
|
||||
<svg className="evnd-icon" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
</svg>
|
||||
),
|
||||
title: "Predictable Operations",
|
||||
desc: "EVs become predictable assets, not operational risks. Full visibility from depot to doorstep.",
|
||||
},
|
||||
];
|
||||
|
||||
const BOTTOM_STATS: { value: number; decimals?: number; suffix: string; label: string }[] = [
|
||||
{ value: 120, suffix: "K+", label: "Deliveries Completed" },
|
||||
{ value: 98, suffix: "%", label: "On-Time Rate" },
|
||||
{ value: 31, suffix: "%", label: "Range Efficiency Gain" },
|
||||
{ value: 340, suffix: "ms", label: "Avg Route Calc Time" },
|
||||
{ value: 99.9, decimals: 1, suffix: "%", label: "SLA Compliance" },
|
||||
{ value: 42, suffix: "%", label: "Distance Saved" },
|
||||
{ value: 37, suffix: "%", label: "Fewer Vehicles" },
|
||||
{ value: 45, suffix: "ms", label: "Dispatch Latency" },
|
||||
];
|
||||
|
||||
/** Count-up that fires once when scrolled ~20% into view (ease-out cubic). */
|
||||
@@ -143,7 +147,7 @@ export default function EVSection() {
|
||||
<style dangerouslySetInnerHTML={{ __html: `
|
||||
/* ============================================================
|
||||
EV-Native Design — redesigned section
|
||||
bg #0d0d0d · red #dc2626 / #ef4444 · Syne + DM Sans
|
||||
bg #080808 · red #ef4444 · Manrope
|
||||
============================================================ */
|
||||
|
||||
#evnd, #evnd * { font-family: "Manrope", Sans-serif !important; }
|
||||
@@ -152,12 +156,12 @@ export default function EVSection() {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
background: #0d0d0d;
|
||||
background: #080808;
|
||||
/* flat top so it connects seamlessly to the banner above; rounded
|
||||
bottom only, and no top margin so there is no white gap */
|
||||
border-radius: 0 0 clamp(16px, 2vw, 28px) clamp(16px, 2vw, 28px);
|
||||
margin: 0 0 clamp(28px, 5vw, 64px);
|
||||
padding: 56px 48px 64px;
|
||||
padding: 64px 48px clamp(48px, 6vw, 80px);
|
||||
}
|
||||
/* subtle diagonal light band for depth (matches reference) */
|
||||
.evnd::before {
|
||||
@@ -166,181 +170,206 @@ export default function EVSection() {
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(120deg, transparent 28%, rgba(255,255,255,0.025) 50%, transparent 72%);
|
||||
}
|
||||
.evnd__canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(120deg, transparent 28%, rgba(255,255,255,0.015) 50%, transparent 72%);
|
||||
}
|
||||
.evnd__inner { position: relative; z-index: 1; max-width: 1280px; margin: 0 auto; }
|
||||
|
||||
/* ---- TOP ROW ---- */
|
||||
.evnd__top {
|
||||
/* ---- MAIN GRID ---- */
|
||||
.evnd__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr 1fr;
|
||||
gap: 44px;
|
||||
grid-template-columns: 1.15fr 1fr;
|
||||
gap: clamp(32px, 4vw, 56px);
|
||||
align-items: center;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.evnd__left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.evnd__right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.evnd__eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: #dc2626 !important;
|
||||
font-weight: 700;
|
||||
color: #ef4444 !important;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.evnd__eyebrow::before { content: ''; width: 24px; height: 2px; background: #dc2626; }
|
||||
.evnd__eyebrow::before {
|
||||
content: '';
|
||||
width: 16px;
|
||||
height: 2px;
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.evnd__title {
|
||||
color: #fff !important;
|
||||
font-weight: 800 !important;
|
||||
font-size: clamp(30px, 4.4vw, 56px) !important;
|
||||
line-height: 1.04 !important;
|
||||
font-size: clamp(32px, 3.8vw, 48px) !important;
|
||||
line-height: 1.15 !important;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 0;
|
||||
margin: 0 0 36px 0;
|
||||
}
|
||||
.evnd__title .accent { color: #ef4444 !important; }
|
||||
|
||||
.evnd__pills { display: flex; flex-direction: column; gap: 12px; }
|
||||
.evnd__pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 13px 20px;
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 100px;
|
||||
@media (min-width: 768px) {
|
||||
.evnd__title {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
.evnd__pill .dot {
|
||||
flex: 0 0 auto;
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #22c55e;
|
||||
box-shadow: 0 0 8px #22c55e;
|
||||
animation: evndBlink 1.4s ease-in-out infinite;
|
||||
.evnd__title .accent {
|
||||
color: #ef4444 !important;
|
||||
}
|
||||
.evnd__pill b { color: #ef4444 !important; font-weight: 800; font-size: 15px; }
|
||||
.evnd__pill span { color: rgba(255,255,255,0.62) !important; font-size: 13px; }
|
||||
|
||||
/* ---- MAIN GRID ---- */
|
||||
.evnd__grid { display: grid; grid-template-columns: 1fr 1fr; gap: 40px; align-items: start; }
|
||||
|
||||
.evnd__media { position: relative; }
|
||||
.evnd__media {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
.evnd__glow {
|
||||
position: absolute;
|
||||
left: 50%; bottom: -4%;
|
||||
width: 72%; height: 64px;
|
||||
width: 80%; height: 80px;
|
||||
transform: translateX(-50%);
|
||||
background: radial-gradient(50% 50% at 50% 50%, rgba(220,38,38,0.5), transparent 72%);
|
||||
filter: blur(30px);
|
||||
background: radial-gradient(50% 50% at 50% 50%, rgba(239,68,68,0.3), transparent 72%);
|
||||
filter: blur(35px);
|
||||
z-index: 0;
|
||||
animation: evndGlow 4s ease-in-out infinite;
|
||||
}
|
||||
.evnd__imgwrap { position: relative; z-index: 1; animation: evndFloat 7s ease-in-out infinite; will-change: transform; }
|
||||
.evnd__imgwrap {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
box-shadow: 0 30px 60px -25px rgba(0,0,0,0.85);
|
||||
}
|
||||
.evnd__img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 30px 60px -25px rgba(0,0,0,0.7);
|
||||
object-fit: cover;
|
||||
transition: transform 0.8s cubic-bezier(0.25, 1, 0.5, 1);
|
||||
}
|
||||
.evnd__imgwrap:hover .evnd__img {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
/* Badge overlay styling */
|
||||
.evnd__badge {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 10px 14px;
|
||||
background: rgba(10,10,10,0.88);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
padding: 12px 16px;
|
||||
background: rgba(13,13,13,0.72);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 8px;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
.evnd__badge b { color: #ef4444 !important; font-weight: 800; font-size: 24px; line-height: 1; }
|
||||
.evnd__badge span { color: rgba(255,255,255,0.55) !important; font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; }
|
||||
.evnd__badge--tl { top: 14px; left: 14px; }
|
||||
.evnd__badge--br { bottom: 14px; right: 14px; }
|
||||
|
||||
.evnd__ministats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-top: 20px; }
|
||||
.evnd__mini {
|
||||
position: relative;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
border-radius: 10px;
|
||||
padding: 18px 14px 14px;
|
||||
overflow: hidden;
|
||||
.evnd__badge b {
|
||||
color: #ef4444 !important;
|
||||
font-weight: 800;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
}
|
||||
.evnd__mini::before {
|
||||
content: '';
|
||||
position: absolute; top: 0; left: 0; right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, #dc2626, transparent);
|
||||
.evnd__badge span {
|
||||
color: rgba(255,255,255,0.7) !important;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.evnd__mini b { display: block; color: #fff !important; font-weight: 800; font-size: clamp(20px, 2.3vw, 28px); line-height: 1; margin-bottom: 6px; }
|
||||
.evnd__mini span { color: rgba(255,255,255,0.5) !important; font-size: 11px; letter-spacing: 0.04em; text-transform: uppercase; line-height: 1.3; display: block; }
|
||||
.evnd__badge--tl { top: 20px; left: 20px; }
|
||||
.evnd__badge--br { bottom: 20px; right: 20px; }
|
||||
|
||||
/* ---- Feature cards ---- */
|
||||
.evnd__features { display: flex; flex-direction: column; gap: 10px; }
|
||||
.evnd__features {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
height: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.evnd-feature {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr auto;
|
||||
gap: 16px;
|
||||
grid-template-columns: 48px 1fr auto;
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
background: rgba(255,255,255,0.028);
|
||||
border: 1px solid rgba(255,255,255,0.07);
|
||||
border-radius: 12px;
|
||||
padding: 18px 20px;
|
||||
background: rgba(255,255,255,0.02);
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
overflow: hidden;
|
||||
transition: background-color 0.35s ease, border-color 0.35s ease, transform 0.35s cubic-bezier(.25,1,.5,1);
|
||||
transition: background-color 0.4s ease, border-color 0.4s ease, transform 0.4s cubic-bezier(.25,1,.5,1);
|
||||
}
|
||||
.evnd-feature::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 3px;
|
||||
background: #dc2626;
|
||||
background: #ef4444;
|
||||
transform: scaleY(0);
|
||||
transform-origin: bottom;
|
||||
transition: transform 0.35s ease;
|
||||
transition: transform 0.4s ease;
|
||||
}
|
||||
.evnd-feature:hover {
|
||||
background: rgba(239,68,68,0.03);
|
||||
border-color: rgba(239,68,68,0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.evnd-feature:hover { background: rgba(220,38,38,0.06); border-color: rgba(220,38,38,0.25); transform: translateX(4px); }
|
||||
.evnd-feature:hover::before { transform: scaleY(1); }
|
||||
.evnd-feature__icon {
|
||||
width: 40px; height: 40px;
|
||||
|
||||
.evnd-feature__icon-container {
|
||||
width: 48px; height: 48px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(220,38,38,0.1);
|
||||
border: 1px solid rgba(220,38,38,0.2);
|
||||
border-radius: 10px;
|
||||
font-size: 18px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 12px;
|
||||
transition: background-color 0.3s ease, border-color 0.3s ease;
|
||||
}
|
||||
.evnd-feature:hover .evnd-feature__icon-container {
|
||||
background: rgba(239,68,68,0.08);
|
||||
border-color: rgba(239,68,68,0.25);
|
||||
}
|
||||
|
||||
.evnd-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.evnd-feature__title {
|
||||
color: #fff !important;
|
||||
font-weight: 700;
|
||||
font-size: 15px !important;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin: 3px 0 7px;
|
||||
letter-spacing: 0.05em;
|
||||
margin: 4px 0 8px;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
.evnd-feature:hover .evnd-feature__title { color: #ef4444 !important; }
|
||||
.evnd-feature__desc {
|
||||
color: rgba(255,255,255,0.75) !important;
|
||||
color: rgba(255,255,255,0.65) !important;
|
||||
font-weight: 400 !important;
|
||||
font-size: 14.5px !important;
|
||||
line-height: 1.65 !important;
|
||||
font-size: 14px !important;
|
||||
line-height: 1.6 !important;
|
||||
margin: 0;
|
||||
}
|
||||
.evnd-feature__arrow {
|
||||
color: rgba(255,255,255,0.2);
|
||||
font-size: 14px;
|
||||
color: rgba(255,255,255,0.25);
|
||||
font-size: 16px;
|
||||
align-self: flex-start;
|
||||
margin-top: 4px;
|
||||
transition: color 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
.evnd-feature:hover .evnd-feature__arrow { color: #ef4444; transform: translate(3px, -3px); }
|
||||
@@ -349,47 +378,69 @@ export default function EVSection() {
|
||||
.evnd__bar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1px;
|
||||
background: rgba(255,255,255,0.06);
|
||||
border-radius: 12px;
|
||||
background: rgba(255,255,255,0.02);
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
margin-top: 40px;
|
||||
margin-top: 60px;
|
||||
padding: 38px 0;
|
||||
}
|
||||
.evnd__bar-item {
|
||||
background: #0d0d0d;
|
||||
padding: 24px 22px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 12px 24px;
|
||||
}
|
||||
.evnd__bar-item .dot {
|
||||
width: 7px; height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #dc2626;
|
||||
box-shadow: 0 0 8px rgba(220,38,38,0.85);
|
||||
animation: evndBlink 1.4s ease-in-out infinite;
|
||||
.evnd__bar-item:not(:last-child)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 15%;
|
||||
height: 70%;
|
||||
width: 1px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.evnd__bar-val {
|
||||
color: #ef4444 !important;
|
||||
font-weight: 800;
|
||||
font-size: clamp(32px, 4vw, 56px);
|
||||
line-height: 1;
|
||||
}
|
||||
.evnd__bar-label {
|
||||
color: #fff !important;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: none;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.evnd__bar-item b { color: #fff !important; font-weight: 800; font-size: clamp(22px, 2.6vw, 30px); line-height: 1; }
|
||||
.evnd__bar-item span { color: rgba(255,255,255,0.45) !important; font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; }
|
||||
|
||||
@keyframes evndFloat { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-7px); } }
|
||||
@keyframes evndGlow { 0%,100% { opacity: 0.6; } 50% { opacity: 1; } }
|
||||
@keyframes evndBlink { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } }
|
||||
@keyframes evndGlow { 0%,100% { opacity: 0.75; } 50% { opacity: 1; } }
|
||||
|
||||
/* ---- Responsive ---- */
|
||||
@media (max-width: 900px) {
|
||||
.evnd { padding: 48px 28px 52px; }
|
||||
.evnd__top { grid-template-columns: 1fr; gap: 28px; margin-bottom: 36px; }
|
||||
.evnd__grid { grid-template-columns: 1fr; gap: 36px; }
|
||||
@media (max-width: 991px) {
|
||||
.evnd { padding: 48px 32px 56px; }
|
||||
.evnd__grid { grid-template-columns: 1fr; gap: 40px; }
|
||||
.evnd__title { margin-bottom: 28px; }
|
||||
.evnd__features { gap: 14px; }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.evnd { padding: 40px 18px 44px; }
|
||||
.evnd__bar { grid-template-columns: repeat(2, 1fr); }
|
||||
.evnd__pill { padding: 11px 16px; }
|
||||
@media (max-width: 767px) {
|
||||
.evnd__bar { grid-template-columns: repeat(2, 1fr); gap: 24px 0; padding: 24px 0; }
|
||||
.evnd__bar-item:nth-child(even)::after { display: none; }
|
||||
.evnd__bar-item:nth-child(2)::after { display: none; }
|
||||
.evnd__bar-item { padding: 12px 16px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.evnd__imgwrap, .evnd__glow, .evnd__pill .dot, .evnd__bar-item .dot { animation: none !important; }
|
||||
@media (max-width: 480px) {
|
||||
.evnd { padding: 40px 16px 48px; }
|
||||
.evnd__bar { grid-template-columns: 1fr; gap: 28px 0; }
|
||||
.evnd__bar-item::after { display: none !important; }
|
||||
.evnd__badge { padding: 8px 12px; }
|
||||
.evnd__badge b { font-size: 20px; }
|
||||
}
|
||||
`}} />
|
||||
|
||||
@@ -441,24 +492,10 @@ export default function EVSection() {
|
||||
{/* ===== EV-Native Design (redesigned) ===== */}
|
||||
<section className="evnd" id="evnd" aria-label="EV-Native Design">
|
||||
<div className="evnd__inner">
|
||||
{/* TOP ROW */}
|
||||
<div className="evnd__top">
|
||||
<div className="evnd__head">
|
||||
<span className="evnd__eyebrow">/ EV-Native Design /</span>
|
||||
<div className="evnd__title">
|
||||
BUILT FOR ELECTRIC. <span className="accent">NOT ADAPTED.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="evnd__pills">
|
||||
{PILLS.map((p) => (
|
||||
<div className="evnd__pill" key={p.label}>
|
||||
<span className="dot" />
|
||||
<b>{p.value}</b>
|
||||
<span>{p.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<span className="evnd__eyebrow">/ EV-Native Design /</span>
|
||||
<h2 className="evnd__title">
|
||||
BUILT FOR ELECTRIC. <span className="accent">NOT ADAPTED.</span>
|
||||
</h2>
|
||||
|
||||
{/* MAIN GRID */}
|
||||
<div className="evnd__grid">
|
||||
@@ -471,37 +508,32 @@ export default function EVSection() {
|
||||
<img className="evnd__img" src="/images/premium-ev-van.png" alt="DoorMile electric delivery van" decoding="async" />
|
||||
<div className="evnd__badge evnd__badge--tl">
|
||||
<b>100%</b>
|
||||
<span>Electric Fleet</span>
|
||||
<span>ELECTRIC FLEET</span>
|
||||
</div>
|
||||
<div className="evnd__badge evnd__badge--br">
|
||||
<b>−40%</b>
|
||||
<span>Cost / Mile</span>
|
||||
<b>-40%</b>
|
||||
<span>COST / MILE</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="evnd__ministats">
|
||||
{MINI_STATS.map((s) => (
|
||||
<div className="evnd__mini" key={s.label}>
|
||||
<CountUp value={s.value} decimals={s.decimals} suffix={s.suffix} />
|
||||
<span>{s.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right column */}
|
||||
<div className="evnd__features">
|
||||
{FEATURES.map((f) => (
|
||||
<div className="evnd-feature" key={f.title}>
|
||||
<span className="evnd-feature__icon" aria-hidden="true">{f.icon}</span>
|
||||
<div className="evnd-feature__body">
|
||||
<div className="evnd-feature__title">{f.title}</div>
|
||||
<p className="evnd-feature__desc">{f.desc}</p>
|
||||
<div className="evnd__right">
|
||||
<div className="evnd__features">
|
||||
{FEATURES.map((f) => (
|
||||
<div className="evnd-feature" key={f.title}>
|
||||
<div className="evnd-feature__icon-container" aria-hidden="true">
|
||||
{f.icon}
|
||||
</div>
|
||||
<div className="evnd-feature__body">
|
||||
<div className="evnd-feature__title">{f.title}</div>
|
||||
<p className="evnd-feature__desc">{f.desc}</p>
|
||||
</div>
|
||||
<span className="evnd-feature__arrow" aria-hidden="true">↗</span>
|
||||
</div>
|
||||
<span className="evnd-feature__arrow" aria-hidden="true">↗</span>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -509,9 +541,8 @@ export default function EVSection() {
|
||||
<div className="evnd__bar">
|
||||
{BOTTOM_STATS.map((s) => (
|
||||
<div className="evnd__bar-item" key={s.label}>
|
||||
<span className="dot" />
|
||||
<CountUp value={s.value} decimals={s.decimals} suffix={s.suffix} />
|
||||
<span>{s.label}</span>
|
||||
<span className="evnd__bar-label">{s.label}</span>
|
||||
<CountUp value={s.value} decimals={s.decimals} suffix={s.suffix} className="evnd__bar-val" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -25,15 +25,16 @@ export default function IndustrySolutions() {
|
||||
style={{marginLeft: "50px"}}
|
||||
>
|
||||
<div className="elementor-widget-container" style={{ margin: "30px 0 0 0"}}>
|
||||
<h3 className="logico-title" style={{ fontSize: "clamp(36px, 5.5vw, 62px)", lineHeight: "1.1", fontWeight: 800, textTransform: "uppercase", paddingRight: "clamp(20px, 8vw, 120px)" }}>
|
||||
<style dangerouslySetInnerHTML={{ __html: `
|
||||
@media (min-width: 1024px) {
|
||||
.industry-title-single-line {
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
}
|
||||
`}} />
|
||||
<h3 className="logico-title industry-title-single-line" style={{ fontSize: "clamp(28px, 3.5vw, 48px)", lineHeight: "1.1", fontWeight: 800, textTransform: "uppercase" }}>
|
||||
<ScrollReveal delay={0.05} duration={0.8} yOffset={25}>
|
||||
<span className="block">Smart solutions built</span>
|
||||
</ScrollReveal>
|
||||
<ScrollReveal delay={0.15} duration={0.8} yOffset={25}>
|
||||
<span className="block">exclusively for your</span>
|
||||
</ScrollReveal>
|
||||
<ScrollReveal delay={0.25} duration={0.8} yOffset={25}>
|
||||
<span className="block" style={{ color: "#c01227" }}>industry</span>
|
||||
Smart solutions built exclusively for your <span style={{ color: "#c01227" }}>industry</span>
|
||||
</ScrollReveal>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -1,498 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import IndustryWorldMap from "./IndustryWorldMap";
|
||||
|
||||
type Tab = "challenges" | "solutions";
|
||||
|
||||
interface Industry {
|
||||
id: string;
|
||||
tab: string;
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
image: string;
|
||||
alt: string;
|
||||
desc: string;
|
||||
chips: [string, string];
|
||||
challenges: string[];
|
||||
solutions: string[];
|
||||
}
|
||||
|
||||
const INDUSTRIES: Industry[] = [
|
||||
{
|
||||
id: "fmcg",
|
||||
tab: "FMCG",
|
||||
eyebrow: "Fast-Moving Consumer Goods",
|
||||
title: "FMCG",
|
||||
image: "/images/tab-pic-1-solution.jpeg",
|
||||
alt: "FMCG logistics",
|
||||
desc:
|
||||
"FMCG logistics demands speed, precision, and continuous fulfillment across high-volume delivery networks — balancing tight timelines, inventory movement, and efficiency without compromising availability.",
|
||||
chips: ["99.2% On-Time", "Live Route Sync"],
|
||||
challenges: [
|
||||
"Unpredictable demand spikes create delivery pressure during peak periods.",
|
||||
"Fresh-product expiry constraints require faster, precisely timed deliveries.",
|
||||
"Multi-stop route complexity increases travel time and coordination cost.",
|
||||
"Inventory stockout risks rise when delays disrupt fast-moving distribution.",
|
||||
],
|
||||
solutions: [
|
||||
"AI demand forecasting adapts delivery plans instantly to real-time order demand.",
|
||||
"Expiry-aware routing prioritises perishable goods for on-time freshness.",
|
||||
"Smart multi-stop optimisation groups orders to cut cost and travel time.",
|
||||
"Real-time inventory sync prevents stockouts and improves fulfilment accuracy.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "pharma",
|
||||
tab: "Pharma",
|
||||
eyebrow: "Pharmaceutical Logistics",
|
||||
title: "Pharma",
|
||||
image: "/images/tab-pic-2-solution.jpeg",
|
||||
alt: "Pharma logistics",
|
||||
desc:
|
||||
"Pharma logistics requires precision, compliance, and real-time monitoring so every shipment arrives safely and on time — from temperature-sensitive medicines to urgent emergency deliveries.",
|
||||
chips: ["Cold Chain Active", "Zero Delay SLA"],
|
||||
challenges: [
|
||||
"Cold chain integrity demands precise temperature control throughout transit.",
|
||||
"Regulatory compliance must be tracked and documented on every delivery.",
|
||||
"Critical delivery time windows require highly accurate scheduling.",
|
||||
"Emergency shipments need instant dispatch and zero-delay execution.",
|
||||
],
|
||||
solutions: [
|
||||
"Cold chain monitoring with automatic re-routing keeps shipments in-spec.",
|
||||
"Compliance engine with audit trails ensures full chain-of-custody visibility.",
|
||||
"Precision scheduling locks in critical delivery windows reliably.",
|
||||
"Priority dispatch queue fast-tracks urgent, life-critical shipments.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "b2b",
|
||||
tab: "Enterprise & B2B",
|
||||
eyebrow: "Enterprise & B2B",
|
||||
title: "Enterprise & B2B",
|
||||
image: "/images/tab-pic-3-solution.jpeg",
|
||||
alt: "Enterprise and B2B logistics",
|
||||
desc:
|
||||
"Enterprise and B2B logistics require coordination and reliability to manage high-value shipments at scale — with appointment scheduling, white-glove standards, and strict SLA commitments.",
|
||||
chips: ["SLA Guaranteed", "White-Glove Ready"],
|
||||
challenges: [
|
||||
"Appointment scheduling requires precise timing across many locations.",
|
||||
"White-glove delivery standards demand premium handling and accuracy.",
|
||||
"Multi-location routing complexity grows with large-scale operations.",
|
||||
"Strict SLA commitments pressure teams to stay timely and error-free.",
|
||||
],
|
||||
solutions: [
|
||||
"Intelligent appointment engine streamlines and automates delivery slots.",
|
||||
"White-glove workflow module enforces premium handling end to end.",
|
||||
"Enterprise route planner coordinates efficient multi-location delivery.",
|
||||
"SLA monitoring dashboard tracks commitments and flags risk in real time.",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function SolutionCard1() {
|
||||
const [active, setActive] = useState(0);
|
||||
const [tab, setTab] = useState<Tab>("challenges");
|
||||
|
||||
const ind = INDUSTRIES[active];
|
||||
|
||||
const selectIndustry = (i: number) => {
|
||||
setActive(i);
|
||||
setTab("challenges"); // reset to Challenges on industry switch
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<style dangerouslySetInnerHTML={{ __html: `
|
||||
/* ============================================================
|
||||
Solutions — Industry section (FMCG / Pharma / Enterprise & B2B)
|
||||
Brand red #dc2626 / #ef4444 · bg #0d0d0d · Syne + DM Sans
|
||||
============================================================ */
|
||||
|
||||
/* The theme forces Manrope on every element via a high-specificity
|
||||
universal :not() !important rule; re-assert Syne / DM Sans from the
|
||||
ID selector (which outranks it) directly on each text node. */
|
||||
#ind-solutions .ind__eyebrow,
|
||||
#ind-solutions .ind__title,
|
||||
#ind-solutions .ind__tab,
|
||||
#ind-solutions .ind__toggle-btn {
|
||||
font-family: var(--font-syne), 'Syne', sans-serif !important;
|
||||
}
|
||||
#ind-solutions .ind__desc,
|
||||
#ind-solutions .ind__chip,
|
||||
#ind-solutions .ind__list li {
|
||||
font-family: var(--font-dm-sans), 'DM Sans', sans-serif !important;
|
||||
}
|
||||
/* kit-5 also forces heading color/size on the title (an <h3>). */
|
||||
#ind-solutions .ind__title {
|
||||
color: #fff !important;
|
||||
font-size: clamp(34px, 5.5vw, 68px) !important;
|
||||
font-weight: 800 !important;
|
||||
line-height: 1.02 !important;
|
||||
margin: 0 0 16px !important;
|
||||
letter-spacing: -0.01em !important;
|
||||
}
|
||||
#ind-solutions .ind__list li { color: #c9c9c9 !important; }
|
||||
|
||||
.ind {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
background: #0d0d0d;
|
||||
border-radius: clamp(16px, 2vw, 26px);
|
||||
max-width: 1400px;
|
||||
margin: clamp(24px, 4vw, 56px) auto;
|
||||
padding: clamp(34px, 5vw, 76px) clamp(18px, 4vw, 64px);
|
||||
}
|
||||
.ind__map {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0.55;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.ind__inner {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 1240px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ---- Top tab bar ---- */
|
||||
.ind__tabs {
|
||||
display: flex;
|
||||
gap: clamp(10px, 3vw, 36px);
|
||||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||
margin-bottom: clamp(28px, 4vw, 52px);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ind__tab {
|
||||
appearance: none;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 10px 2px 16px;
|
||||
font-weight: 700;
|
||||
font-size: clamp(14px, 1.4vw, 18px);
|
||||
color: #888;
|
||||
position: relative;
|
||||
letter-spacing: 0.01em;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
.ind__tab::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0; right: 0; bottom: -1px;
|
||||
height: 2px;
|
||||
background: #dc2626;
|
||||
transform: scaleX(0);
|
||||
transform-origin: left;
|
||||
transition: transform 0.35s cubic-bezier(.25,1,.5,1);
|
||||
}
|
||||
.ind__tab:hover { color: #ccc; }
|
||||
.ind__tab.active { color: #fff; }
|
||||
.ind__tab.active::after { transform: scaleX(1); }
|
||||
|
||||
/* ---- Grid ---- */
|
||||
.ind__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 0.9fr 1.1fr;
|
||||
gap: clamp(28px, 4vw, 64px);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ---- Left media ---- */
|
||||
.ind__media {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 300px;
|
||||
}
|
||||
.ind__glow {
|
||||
position: absolute;
|
||||
left: 50%; bottom: 4%;
|
||||
width: 74%; height: 70px;
|
||||
transform: translateX(-50%);
|
||||
background: radial-gradient(50% 50% at 50% 50%, rgba(220,38,38,0.5), transparent 72%);
|
||||
filter: blur(32px);
|
||||
z-index: 0;
|
||||
animation: indGlow 4s ease-in-out infinite;
|
||||
}
|
||||
.ind__img-wrap {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
max-width: 460px;
|
||||
animation: indFloat 6s ease-in-out infinite;
|
||||
will-change: transform;
|
||||
}
|
||||
.ind__img-wrap::before,
|
||||
.ind__img-wrap::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 44px; height: 44px;
|
||||
border: 2px solid #dc2626;
|
||||
z-index: 3;
|
||||
}
|
||||
.ind__img-wrap::before {
|
||||
top: -10px; left: -10px;
|
||||
border-right: none; border-bottom: none;
|
||||
border-radius: 10px 0 0 0;
|
||||
}
|
||||
.ind__img-wrap::after {
|
||||
bottom: -10px; right: -10px;
|
||||
border-left: none; border-top: none;
|
||||
border-radius: 0 0 10px 0;
|
||||
}
|
||||
.ind__img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 12px;
|
||||
object-fit: cover;
|
||||
box-shadow: 0 30px 60px -25px rgba(0,0,0,0.7);
|
||||
animation: indImgFade 0.55s ease both;
|
||||
}
|
||||
.ind__chip {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 16px;
|
||||
border-radius: 999px;
|
||||
background: rgba(16,16,16,0.82);
|
||||
border: 1px solid rgba(239,68,68,0.4);
|
||||
backdrop-filter: blur(8px);
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 12px 28px -12px rgba(0,0,0,0.7);
|
||||
animation: indFloat 6s ease-in-out infinite;
|
||||
}
|
||||
.ind__chip .dot {
|
||||
width: 7px; height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #ef4444;
|
||||
box-shadow: 0 0 8px #ef4444;
|
||||
animation: indDot 1.6s ease-in-out infinite;
|
||||
}
|
||||
.ind__chip--1 { top: 7%; left: -5%; animation-delay: 0.4s; }
|
||||
.ind__chip--2 { bottom: 12%; right: -5%; animation-delay: 1.3s; }
|
||||
|
||||
/* ---- Right text ---- */
|
||||
.ind__text { min-width: 0; }
|
||||
.ind__eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: #ef4444;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
font-size: clamp(11px, 1vw, 13px);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.ind__eyebrow::before {
|
||||
content: '';
|
||||
width: 30px; height: 2px;
|
||||
background: #ef4444;
|
||||
}
|
||||
.ind__desc {
|
||||
color: #b4b4b4;
|
||||
font-size: clamp(15px, 1.2vw, 18px);
|
||||
line-height: 1.7;
|
||||
font-weight: 400;
|
||||
margin: 0 0 24px;
|
||||
max-width: 580px;
|
||||
}
|
||||
|
||||
/* ---- Challenges / Solutions toggle ---- */
|
||||
.ind__toggle {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
padding: 5px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,0.05);
|
||||
border: 1px solid rgba(255,255,255,0.09);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.ind__toggle-btn {
|
||||
appearance: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 9px 28px;
|
||||
border-radius: 999px;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
color: #9a9a9a;
|
||||
background: transparent;
|
||||
transition: color 0.3s ease, background-color 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
.ind__toggle-btn:hover { color: #ddd; }
|
||||
.ind__toggle-btn.active {
|
||||
background: #dc2626;
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 20px -8px rgba(220,38,38,0.6);
|
||||
}
|
||||
|
||||
/* ---- Sliding panels ---- */
|
||||
.ind__slider { overflow: hidden; }
|
||||
.ind__track {
|
||||
display: flex;
|
||||
width: 200%;
|
||||
transition: transform 0.4s cubic-bezier(.4,0,.2,1);
|
||||
}
|
||||
.ind__panel { width: 50%; flex: 0 0 50%; }
|
||||
.ind__list { list-style: none; margin: 0; padding: 0; }
|
||||
.ind__list li {
|
||||
position: relative;
|
||||
padding-left: 30px;
|
||||
margin-bottom: 16px;
|
||||
font-size: clamp(14px, 1.1vw, 16px);
|
||||
line-height: 1.6;
|
||||
opacity: 0;
|
||||
transform: translateX(-16px);
|
||||
}
|
||||
.ind__list li:last-child { margin-bottom: 0; }
|
||||
.ind__list li::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 4px; top: 8px;
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #dc2626;
|
||||
box-shadow: 0 0 8px rgba(220,38,38,0.7);
|
||||
}
|
||||
.ind__panel.is-active .ind__list li {
|
||||
animation: indBullet 0.5s cubic-bezier(.25,1,.5,1) forwards;
|
||||
}
|
||||
.ind__panel.is-active .ind__list li:nth-child(1) { animation-delay: 0.05s; }
|
||||
.ind__panel.is-active .ind__list li:nth-child(2) { animation-delay: 0.13s; }
|
||||
.ind__panel.is-active .ind__list li:nth-child(3) { animation-delay: 0.21s; }
|
||||
.ind__panel.is-active .ind__list li:nth-child(4) { animation-delay: 0.29s; }
|
||||
.ind__panel.is-active .ind__list li:nth-child(5) { animation-delay: 0.37s; }
|
||||
|
||||
@keyframes indFloat { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-14px); } }
|
||||
@keyframes indGlow { 0%,100% { opacity: 0.6; } 50% { opacity: 1; } }
|
||||
@keyframes indDot { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }
|
||||
@keyframes indBullet { to { opacity: 1; transform: translateX(0); } }
|
||||
@keyframes indImgFade { from { opacity: 0; transform: scale(0.97); } to { opacity: 1; transform: scale(1); } }
|
||||
|
||||
/* ---- Responsive ---- */
|
||||
@media (max-width: 900px) {
|
||||
.ind__grid { grid-template-columns: 1fr; gap: clamp(40px, 8vw, 56px); }
|
||||
.ind__media { order: -1; }
|
||||
.ind__img-wrap { max-width: 380px; }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.ind__chip { font-size: 12px; padding: 7px 12px; }
|
||||
.ind__chip--1 { left: 0; }
|
||||
.ind__chip--2 { right: 0; }
|
||||
.ind__toggle-btn { padding: 9px 20px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ind__img-wrap, .ind__glow, .ind__chip, .ind__chip .dot { animation: none !important; }
|
||||
.ind__track { transition: none !important; }
|
||||
.ind__panel.is-active .ind__list li { animation: none !important; opacity: 1; transform: none; }
|
||||
}
|
||||
`}} />
|
||||
|
||||
<section id="ind-solutions" className="ind" aria-label="Industry solutions">
|
||||
<IndustryWorldMap />
|
||||
|
||||
<div className="ind__inner">
|
||||
{/* Top tab bar */}
|
||||
<div className="ind__tabs" role="tablist" aria-label="Industries">
|
||||
{INDUSTRIES.map((it, i) => (
|
||||
<button
|
||||
key={it.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={i === active}
|
||||
className={`ind__tab ${i === active ? "active" : ""}`}
|
||||
onClick={() => selectIndustry(i)}
|
||||
>
|
||||
{it.tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="ind__grid">
|
||||
{/* Left media */}
|
||||
<div className="ind__media">
|
||||
<div className="ind__glow" />
|
||||
<div className="ind__img-wrap">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img key={active} className="ind__img" src={ind.image} alt={ind.alt} decoding="async" />
|
||||
</div>
|
||||
<div className="ind__chip ind__chip--1">
|
||||
<span className="dot" />
|
||||
{ind.chips[0]}
|
||||
</div>
|
||||
<div className="ind__chip ind__chip--2">
|
||||
<span className="dot" />
|
||||
{ind.chips[1]}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right text */}
|
||||
<div className="ind__text">
|
||||
<span className="ind__eyebrow">{ind.eyebrow}</span>
|
||||
<h3 className="ind__title">{ind.title}</h3>
|
||||
<p className="ind__desc">{ind.desc}</p>
|
||||
|
||||
<div className="ind__toggle" role="tablist" aria-label="Challenges or Solutions">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === "challenges"}
|
||||
className={`ind__toggle-btn ${tab === "challenges" ? "active" : ""}`}
|
||||
onClick={() => setTab("challenges")}
|
||||
>
|
||||
Challenges
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === "solutions"}
|
||||
className={`ind__toggle-btn ${tab === "solutions" ? "active" : ""}`}
|
||||
onClick={() => setTab("solutions")}
|
||||
>
|
||||
Solutions
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="ind__slider">
|
||||
<div
|
||||
className="ind__track"
|
||||
key={active}
|
||||
style={{ transform: tab === "challenges" ? "translateX(0)" : "translateX(-50%)" }}
|
||||
>
|
||||
<div className={`ind__panel ${tab === "challenges" ? "is-active" : ""}`}>
|
||||
<ul className="ind__list">
|
||||
{ind.challenges.map((c, idx) => (
|
||||
<li key={idx}>{c}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className={`ind__panel ${tab === "solutions" ? "is-active" : ""}`}>
|
||||
<ul className="ind__list">
|
||||
{ind.solutions.map((s, idx) => (
|
||||
<li key={idx}>{s}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import LogisticsBrainSection from "../logisticsbrain/LogisticsBrainSection";
|
||||
|
||||
export default function Workflow2() {
|
||||
const [activeSlide, setActiveSlide] = useState(0);
|
||||
@@ -22,83 +23,67 @@ export default function Workflow2() {
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="dm-workflow" aria-label="Workflow 2 — Competitive Edge & Innovation">
|
||||
<div className="dm-workflow__container">
|
||||
<section className="dm-wf2" aria-label="Workflow 2 — How Our Logistics Brain Works & Innovation">
|
||||
|
||||
{/* ── Top sub-section: Competitive Edge banner ── */}
|
||||
<div className="dm-workflow-banner">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
className="dm-workflow-banner__img"
|
||||
src="/images/miletruth-2.png"
|
||||
alt="Our Competitive Edge"
|
||||
width={1733}
|
||||
height={773}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<div className="dm-workflow-banner__caption">
|
||||
<span className="dm-workflow-banner__title-text">Our Competitive Edge</span>
|
||||
</div>
|
||||
{/* ── Top sub-section: the complete "How Our Logistics Brain Works" experience ── */}
|
||||
<LogisticsBrainSection connected />
|
||||
|
||||
{/* ── Bottom sub-section: Innovation content, flush + colour-matched to the
|
||||
logistics-brain card above so the whole workflow reads as one container ── */}
|
||||
<div className="dm-wf2-card">
|
||||
{/* Left Column: Overlapping Chevron Graphic */}
|
||||
<div className="dm-workflow-left">
|
||||
<svg viewBox="0 0 320 280" fill="none" xmlns="http://www.w3.org/2000/svg" className="dm-workflow-svg">
|
||||
<path
|
||||
d="M 30,20 C 22,20 16,26 16,34 L 78,85 C 81,88 81,92 78,95 L 16,146 C 16,154 22,160 30,160 L 130,160 C 138,160 145,154 148,146 L 204,95 C 207,92 207,88 204,85 L 148,34 C 145,26 138,20 130,20 Z"
|
||||
stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" opacity="0.25"
|
||||
/>
|
||||
<path
|
||||
d="M 110,100 C 102,100 96,106 96,114 L 158,165 C 161,168 161,172 158,175 L 96,226 C 96,234 102,240 110,240 L 210,240 C 218,240 225,234 228,226 L 284,175 C 287,172 287,168 284,165 L 228,114 C 225,106 218,100 210,100 Z"
|
||||
stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" opacity="0.85"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* ── Bottom sub-section: Innovation content ── */}
|
||||
<div className="dm-workflow-card">
|
||||
{/* Left Column: Overlapping Chevron Graphic */}
|
||||
<div className="dm-workflow-left">
|
||||
<svg viewBox="0 0 320 280" fill="none" xmlns="http://www.w3.org/2000/svg" className="dm-workflow-svg">
|
||||
<path
|
||||
d="M 30,20 C 22,20 16,26 16,34 L 78,85 C 81,88 81,92 78,95 L 16,146 C 16,154 22,160 30,160 L 130,160 C 138,160 145,154 148,146 L 204,95 C 207,92 207,88 204,85 L 148,34 C 145,26 138,20 130,20 Z"
|
||||
stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" opacity="0.25"
|
||||
/>
|
||||
<path
|
||||
d="M 110,100 C 102,100 96,106 96,114 L 158,165 C 161,168 161,172 158,175 L 96,226 C 96,234 102,240 110,240 L 210,240 C 218,240 225,234 228,226 L 284,175 C 287,172 287,168 284,165 L 228,114 C 225,106 218,100 210,100 Z"
|
||||
stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" opacity="0.85"
|
||||
/>
|
||||
</svg>
|
||||
{/* Right Column: Quotes & Text Content */}
|
||||
<div className="dm-workflow-right">
|
||||
<svg width="32" height="24" viewBox="0 0 32 24" fill="none" xmlns="http://www.w3.org/2000/svg" className="dm-workflow-quote">
|
||||
<rect x="2" y="2" width="9" height="20" rx="1.5" transform="skewX(-12)" fill="#C01227" />
|
||||
<rect x="16" y="2" width="9" height="20" rx="1.5" transform="skewX(-12)" fill="#C01227" />
|
||||
</svg>
|
||||
|
||||
<h3 className="dm-workflow-title">{slides[activeSlide].title}</h3>
|
||||
|
||||
<div className="dm-workflow-text-container">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.p
|
||||
key={activeSlide}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
transition={{ duration: 0.28, ease: "easeInOut" }}
|
||||
className="dm-workflow-text"
|
||||
>
|
||||
{slides[activeSlide].text}
|
||||
</motion.p>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Quotes & Text Content */}
|
||||
<div className="dm-workflow-right">
|
||||
<svg width="32" height="24" viewBox="0 0 32 24" fill="none" xmlns="http://www.w3.org/2000/svg" className="dm-workflow-quote">
|
||||
<rect x="2" y="2" width="9" height="20" rx="1.5" transform="skewX(-12)" fill="#C01227" />
|
||||
<rect x="16" y="2" width="9" height="20" rx="1.5" transform="skewX(-12)" fill="#C01227" />
|
||||
</svg>
|
||||
|
||||
<h3 className="dm-workflow-title">{slides[activeSlide].title}</h3>
|
||||
|
||||
<div className="dm-workflow-text-container">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.p
|
||||
key={activeSlide}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
transition={{ duration: 0.28, ease: "easeInOut" }}
|
||||
className="dm-workflow-text"
|
||||
>
|
||||
{slides[activeSlide].text}
|
||||
</motion.p>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="dm-workflow-nav">
|
||||
<span className="dm-workflow-counter">0{activeSlide + 1}/03</span>
|
||||
<div className="dm-workflow-bars">
|
||||
{slides.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
aria-label={`Go to slide ${index + 1}`}
|
||||
className={`dm-workflow-bar ${index === activeSlide ? "is-active" : ""}`}
|
||||
onClick={() => setActiveSlide(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="dm-workflow-nav">
|
||||
<span className="dm-workflow-counter">0{activeSlide + 1}/03</span>
|
||||
<div className="dm-workflow-bars">
|
||||
{slides.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
aria-label={`Go to slide ${index + 1}`}
|
||||
className={`dm-workflow-bar ${index === activeSlide ? "is-active" : ""}`}
|
||||
onClick={() => setActiveSlide(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<style dangerouslySetInnerHTML={{ __html: styles }} />
|
||||
@@ -108,73 +93,37 @@ export default function Workflow2() {
|
||||
|
||||
const styles = `
|
||||
/* ============================================================
|
||||
Workflow = ONE container: image-title banner (top) flush with
|
||||
the dark content card (bottom). Single overflow:hidden wrapper
|
||||
rounds the whole stack — no gap, no separate backgrounds.
|
||||
Workflow 2 = ONE container:
|
||||
├─ How Our Logistics Brain Works (full LogisticsBrainSection)
|
||||
└─ Innovation (content card, flush + colour-matched)
|
||||
The Innovation card is pulled up to butt against the logistics-brain
|
||||
card's flat bottom and shares its dark red/black surface, so the two
|
||||
read as a single continuous container with no gap / no break — the
|
||||
same connected storytelling structure used in Workflow 1
|
||||
(Impact of Optimisation → Performance).
|
||||
============================================================ */
|
||||
.dm-workflow {
|
||||
max-width: 100%;
|
||||
margin: 24px auto;
|
||||
padding: 0 40px;
|
||||
box-sizing: border-box;
|
||||
.dm-wf2 {
|
||||
position: relative;
|
||||
margin: 0 auto 24px;
|
||||
}
|
||||
|
||||
.dm-workflow__container {
|
||||
/* Innovation card — aligned to the logistics-brain card (16px side insets),
|
||||
red/black-matched, flat top, rounded bottom, pulled up to close the seam. */
|
||||
.dm-wf2-card {
|
||||
position: relative;
|
||||
border-radius: 40px;
|
||||
overflow: hidden;
|
||||
background: #181818;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
box-shadow: 0 14px 50px -16px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
/* ── Banner (top) ── */
|
||||
.dm-workflow-banner {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
font-size: 0;
|
||||
line-height: 0;
|
||||
}
|
||||
.dm-workflow-banner__img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 380px;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
.dm-workflow-banner__caption {
|
||||
position: absolute;
|
||||
left: 40px;
|
||||
bottom: 32px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
padding: 16px 26px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 28px -10px rgba(0, 0, 0, 0.45);
|
||||
line-height: normal;
|
||||
}
|
||||
.dm-workflow-banner__title-text {
|
||||
display: block;
|
||||
color: #C01227 !important;
|
||||
font-family: var(--font-space-grotesk), var(--font-manrope), system-ui, sans-serif !important;
|
||||
font-size: clamp(20px, 2.4vw, 34px) !important;
|
||||
font-weight: 700 !important;
|
||||
line-height: 1.1 !important;
|
||||
letter-spacing: -0.01em;
|
||||
text-transform: none !important;
|
||||
}
|
||||
|
||||
/* ── Content card (bottom), flush under the banner ── */
|
||||
.dm-workflow-card {
|
||||
position: relative;
|
||||
background: #181818;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
z-index: 2;
|
||||
margin: 0 16px 0;
|
||||
background: radial-gradient(120% 100% at 50% 0%, #12090c 0%, #0a070a 55%, #060507 100%);
|
||||
border: 1px solid rgba(192, 18, 39, 0.16);
|
||||
border-top: none;
|
||||
border-radius: 0 0 28px 28px;
|
||||
box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.5);
|
||||
padding: 48px 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 40px;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -251,24 +200,21 @@ const styles = `
|
||||
.dm-workflow-bar:hover { background: rgba(255, 255, 255, 0.35); }
|
||||
.dm-workflow-bar.is-active:hover { background: #C01227; }
|
||||
|
||||
/* ── Responsive ── */
|
||||
/* ── Responsive — keep insets/radius aligned to the logistics-brain card ── */
|
||||
@media (max-width: 1024px) {
|
||||
.dm-workflow__container { border-radius: 32px; }
|
||||
.dm-workflow-banner__img { max-height: 300px; }
|
||||
.dm-workflow-banner__caption { left: 28px; bottom: 24px; padding: 13px 20px; }
|
||||
.dm-workflow-card { padding: 44px 44px; gap: 44px; }
|
||||
.dm-wf2-card {
|
||||
padding: 44px 44px;
|
||||
gap: 44px;
|
||||
}
|
||||
.dm-workflow-title { font-size: 32px; }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dm-workflow { padding: 0 16px; }
|
||||
.dm-workflow__container { border-radius: 24px; }
|
||||
.dm-workflow-banner__img { max-height: 220px; }
|
||||
.dm-workflow-banner__caption { left: 16px; bottom: 16px; padding: 11px 16px; }
|
||||
.dm-workflow-card {
|
||||
flex-direction: column;
|
||||
@media (max-width: 767px) {
|
||||
.dm-wf2-card {
|
||||
margin: 0 10px 0;
|
||||
border-radius: 0 0 20px 20px;
|
||||
padding: 36px 28px;
|
||||
gap: 36px;
|
||||
flex-direction: column;
|
||||
}
|
||||
.dm-workflow-left { max-width: 280px; }
|
||||
.dm-workflow-right { width: 100%; }
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import StrategySection from "../strategy/StrategySection";
|
||||
|
||||
export default function Workflow3() {
|
||||
const [activeSlide, setActiveSlide] = useState(0);
|
||||
@@ -22,83 +23,69 @@ export default function Workflow3() {
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="dm-workflow" aria-label="Workflow 3 — Happier Riders & Strategy">
|
||||
<div className="dm-workflow__container">
|
||||
<section className="dm-wf3" aria-label="Workflow 3 — Happier Riders. Higher Fulfillment. & Strategy">
|
||||
|
||||
{/* ── Top sub-section: Happier Riders banner ── */}
|
||||
<div className="dm-workflow-banner">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
className="dm-workflow-banner__img"
|
||||
src="/images/miletruth-3.png"
|
||||
alt="Happier Riders. Higher Fulfillment"
|
||||
width={1733}
|
||||
height={773}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<div className="dm-workflow-banner__caption">
|
||||
<span className="dm-workflow-banner__title-text">Happier Riders. Higher Fulfillment</span>
|
||||
</div>
|
||||
{/* ── Top sub-section: the full "Happier Riders. Higher Fulfillment."
|
||||
3D scroll-storytelling experience ── */}
|
||||
<StrategySection connected />
|
||||
|
||||
{/* ── 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 same connected structure used in Workflow 1 & 2 ── */}
|
||||
<div className="dm-wf3-card">
|
||||
{/* Left Column: Overlapping Chevron Graphic */}
|
||||
<div className="dm-workflow-left">
|
||||
<svg viewBox="0 0 320 280" fill="none" xmlns="http://www.w3.org/2000/svg" className="dm-workflow-svg">
|
||||
<path
|
||||
d="M 30,20 C 22,20 16,26 16,34 L 78,85 C 81,88 81,92 78,95 L 16,146 C 16,154 22,160 30,160 L 130,160 C 138,160 145,154 148,146 L 204,95 C 207,92 207,88 204,85 L 148,34 C 145,26 138,20 130,20 Z"
|
||||
stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" opacity="0.25"
|
||||
/>
|
||||
<path
|
||||
d="M 110,100 C 102,100 96,106 96,114 L 158,165 C 161,168 161,172 158,175 L 96,226 C 96,234 102,240 110,240 L 210,240 C 218,240 225,234 228,226 L 284,175 C 287,172 287,168 284,165 L 228,114 C 225,106 218,100 210,100 Z"
|
||||
stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" opacity="0.85"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* ── Bottom sub-section: Strategy content ── */}
|
||||
<div className="dm-workflow-card">
|
||||
{/* Left Column: Overlapping Chevron Graphic */}
|
||||
<div className="dm-workflow-left">
|
||||
<svg viewBox="0 0 320 280" fill="none" xmlns="http://www.w3.org/2000/svg" className="dm-workflow-svg">
|
||||
<path
|
||||
d="M 30,20 C 22,20 16,26 16,34 L 78,85 C 81,88 81,92 78,95 L 16,146 C 16,154 22,160 30,160 L 130,160 C 138,160 145,154 148,146 L 204,95 C 207,92 207,88 204,85 L 148,34 C 145,26 138,20 130,20 Z"
|
||||
stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" opacity="0.25"
|
||||
/>
|
||||
<path
|
||||
d="M 110,100 C 102,100 96,106 96,114 L 158,165 C 161,168 161,172 158,175 L 96,226 C 96,234 102,240 110,240 L 210,240 C 218,240 225,234 228,226 L 284,175 C 287,172 287,168 284,165 L 228,114 C 225,106 218,100 210,100 Z"
|
||||
stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" opacity="0.85"
|
||||
/>
|
||||
</svg>
|
||||
{/* Right Column: Quotes & Text Content */}
|
||||
<div className="dm-workflow-right">
|
||||
<svg width="32" height="24" viewBox="0 0 32 24" fill="none" xmlns="http://www.w3.org/2000/svg" className="dm-workflow-quote">
|
||||
<rect x="2" y="2" width="9" height="20" rx="1.5" transform="skewX(-12)" fill="#C01227" />
|
||||
<rect x="16" y="2" width="9" height="20" rx="1.5" transform="skewX(-12)" fill="#C01227" />
|
||||
</svg>
|
||||
|
||||
<h3 className="dm-workflow-title">{slides[activeSlide].title}</h3>
|
||||
|
||||
<div className="dm-workflow-text-container">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.p
|
||||
key={activeSlide}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
transition={{ duration: 0.28, ease: "easeInOut" }}
|
||||
className="dm-workflow-text"
|
||||
>
|
||||
{slides[activeSlide].text}
|
||||
</motion.p>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Quotes & Text Content */}
|
||||
<div className="dm-workflow-right">
|
||||
<svg width="32" height="24" viewBox="0 0 32 24" fill="none" xmlns="http://www.w3.org/2000/svg" className="dm-workflow-quote">
|
||||
<rect x="2" y="2" width="9" height="20" rx="1.5" transform="skewX(-12)" fill="#C01227" />
|
||||
<rect x="16" y="2" width="9" height="20" rx="1.5" transform="skewX(-12)" fill="#C01227" />
|
||||
</svg>
|
||||
|
||||
<h3 className="dm-workflow-title">{slides[activeSlide].title}</h3>
|
||||
|
||||
<div className="dm-workflow-text-container">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.p
|
||||
key={activeSlide}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
transition={{ duration: 0.28, ease: "easeInOut" }}
|
||||
className="dm-workflow-text"
|
||||
>
|
||||
{slides[activeSlide].text}
|
||||
</motion.p>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="dm-workflow-nav">
|
||||
<span className="dm-workflow-counter">0{activeSlide + 1}/03</span>
|
||||
<div className="dm-workflow-bars">
|
||||
{slides.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
aria-label={`Go to slide ${index + 1}`}
|
||||
className={`dm-workflow-bar ${index === activeSlide ? "is-active" : ""}`}
|
||||
onClick={() => setActiveSlide(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="dm-workflow-nav">
|
||||
<span className="dm-workflow-counter">0{activeSlide + 1}/03</span>
|
||||
<div className="dm-workflow-bars">
|
||||
{slides.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
aria-label={`Go to slide ${index + 1}`}
|
||||
className={`dm-workflow-bar ${index === activeSlide ? "is-active" : ""}`}
|
||||
onClick={() => setActiveSlide(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<style dangerouslySetInnerHTML={{ __html: styles }} />
|
||||
@@ -108,73 +95,33 @@ export default function Workflow3() {
|
||||
|
||||
const styles = `
|
||||
/* ============================================================
|
||||
Workflow = ONE container: image-title banner (top) flush with
|
||||
the dark content card (bottom). Single overflow:hidden wrapper
|
||||
rounds the whole stack — no gap, no separate backgrounds.
|
||||
Workflow 3 = ONE container:
|
||||
├─ Happier Riders. Higher Fulfillment. (full StrategySection — 3D)
|
||||
└─ Strategy (content card, flush, pulled up)
|
||||
The Strategy card aligns to the 3D card's 16px side insets, butts against
|
||||
its flat bottom and rounds the bottom corners, so the two read as a single
|
||||
continuous container — same technique as Workflow 1 & 2.
|
||||
============================================================ */
|
||||
.dm-workflow {
|
||||
max-width: 100%;
|
||||
margin: 24px auto;
|
||||
padding: 0 40px;
|
||||
box-sizing: border-box;
|
||||
.dm-wf3 {
|
||||
position: relative;
|
||||
margin: 0 auto 24px;
|
||||
}
|
||||
|
||||
.dm-workflow__container {
|
||||
.dm-wf3-card {
|
||||
position: relative;
|
||||
border-radius: 40px;
|
||||
overflow: hidden;
|
||||
z-index: 2;
|
||||
margin: 0 16px 0;
|
||||
background: #181818;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
box-shadow: 0 14px 50px -16px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
/* ── Banner (top) ── */
|
||||
.dm-workflow-banner {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
font-size: 0;
|
||||
line-height: 0;
|
||||
}
|
||||
.dm-workflow-banner__img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 380px;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
.dm-workflow-banner__caption {
|
||||
position: absolute;
|
||||
left: 40px;
|
||||
bottom: 32px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
padding: 16px 26px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 28px -10px rgba(0, 0, 0, 0.45);
|
||||
line-height: normal;
|
||||
}
|
||||
.dm-workflow-banner__title-text {
|
||||
display: block;
|
||||
color: #C01227 !important;
|
||||
font-family: var(--font-space-grotesk), var(--font-manrope), system-ui, sans-serif !important;
|
||||
font-size: clamp(20px, 2.4vw, 34px) !important;
|
||||
font-weight: 700 !important;
|
||||
line-height: 1.1 !important;
|
||||
letter-spacing: -0.01em;
|
||||
text-transform: none !important;
|
||||
}
|
||||
|
||||
/* ── Content card (bottom), flush under the banner ── */
|
||||
.dm-workflow-card {
|
||||
position: relative;
|
||||
background: #181818;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-top: none;
|
||||
border-radius: 0 0 28px 28px;
|
||||
box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.5);
|
||||
padding: 48px 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 40px;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -251,24 +198,18 @@ const styles = `
|
||||
.dm-workflow-bar:hover { background: rgba(255, 255, 255, 0.35); }
|
||||
.dm-workflow-bar.is-active:hover { background: #C01227; }
|
||||
|
||||
/* ── Responsive ── */
|
||||
/* ── Responsive — keep insets/radius aligned to the 3D card ── */
|
||||
@media (max-width: 1024px) {
|
||||
.dm-workflow__container { border-radius: 32px; }
|
||||
.dm-workflow-banner__img { max-height: 300px; }
|
||||
.dm-workflow-banner__caption { left: 28px; bottom: 24px; padding: 13px 20px; }
|
||||
.dm-workflow-card { padding: 44px 44px; gap: 44px; }
|
||||
.dm-wf3-card { padding: 44px 44px; gap: 44px; }
|
||||
.dm-workflow-title { font-size: 32px; }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dm-workflow { padding: 0 16px; }
|
||||
.dm-workflow__container { border-radius: 24px; }
|
||||
.dm-workflow-banner__img { max-height: 220px; }
|
||||
.dm-workflow-banner__caption { left: 16px; bottom: 16px; padding: 11px 16px; }
|
||||
.dm-workflow-card {
|
||||
flex-direction: column;
|
||||
@media (max-width: 767px) {
|
||||
.dm-wf3-card {
|
||||
margin: 0 10px 0;
|
||||
border-radius: 0 0 20px 20px;
|
||||
padding: 36px 28px;
|
||||
gap: 36px;
|
||||
flex-direction: column;
|
||||
}
|
||||
.dm-workflow-left { max-width: 280px; }
|
||||
.dm-workflow-right { width: 100%; }
|
||||
|
||||
421
src/components/strategy/StrategyCanvas.tsx
Normal file
421
src/components/strategy/StrategyCanvas.tsx
Normal file
@@ -0,0 +1,421 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useRef } from "react";
|
||||
import { Canvas, useFrame } from "@react-three/fiber";
|
||||
import { RoundedBox, Line, Sparkles, Html } from "@react-three/drei";
|
||||
import { EffectComposer, Bloom } from "@react-three/postprocessing";
|
||||
import { KernelSize } from "postprocessing";
|
||||
import * as THREE from "three";
|
||||
import { STAGES, N, stagePosition, samplePath } from "./theme";
|
||||
|
||||
type Props = {
|
||||
progress: React.RefObject<number>;
|
||||
reduced?: boolean;
|
||||
isMobile?: boolean;
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
const BG = "#eef1f6";
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Camera — fully scroll-driven. A fixed back/up offset from the active stage
|
||||
keeps the active stage at a near-constant distance, so a constant DoF plane
|
||||
keeps it sharp while past/future stages fall out of focus (Apple-style).
|
||||
--------------------------------------------------------------------------- */
|
||||
function CameraRig({ progress, reduced }: { progress: React.RefObject<number>; reduced: boolean }) {
|
||||
const look = useRef(new THREE.Vector3(...stagePosition(0)));
|
||||
useFrame((state, dt) => {
|
||||
const p = progress.current ?? 0;
|
||||
const idx = p * (N - 1);
|
||||
const [tx, ty, tz] = samplePath(idx);
|
||||
const t = state.clock.elapsedTime;
|
||||
|
||||
// Gentle parallax: camera slides toward the centre-line and breathes.
|
||||
const camX = tx * 0.45 + Math.sin(t * 0.25) * 0.25;
|
||||
const camY = ty + 1.35 + Math.sin(t * 0.35) * 0.12;
|
||||
const camZ = tz + 5.4;
|
||||
|
||||
const k = reduced ? 1 : Math.min(1, dt * 3.2);
|
||||
const cam = state.camera;
|
||||
cam.position.x += (camX - cam.position.x) * k;
|
||||
cam.position.y += (camY - cam.position.y) * k;
|
||||
cam.position.z += (camZ - cam.position.z) * k;
|
||||
|
||||
look.current.x += (tx - look.current.x) * k;
|
||||
look.current.y += (ty - look.current.y) * k;
|
||||
look.current.z += (tz - look.current.z) * k;
|
||||
cam.lookAt(look.current);
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Themed accent object floating in front of each glass stage.
|
||||
--------------------------------------------------------------------------- */
|
||||
function Accent({ i, color }: { i: number; color: THREE.Color }) {
|
||||
const mat = (
|
||||
<meshStandardMaterial
|
||||
color={color}
|
||||
emissive={color}
|
||||
emissiveIntensity={1.4}
|
||||
roughness={0.25}
|
||||
metalness={0.2}
|
||||
toneMapped={false}
|
||||
/>
|
||||
);
|
||||
switch (i) {
|
||||
case 0: // Rider happiness — soft orb
|
||||
return <mesh><icosahedronGeometry args={[0.46, 1]} />{mat}</mesh>;
|
||||
case 1: // Dispatch — node / octahedron
|
||||
return <mesh rotation={[0.4, 0, 0.4]}><octahedronGeometry args={[0.55, 0]} />{mat}</mesh>;
|
||||
case 2: // Route execution — flowing knot
|
||||
return <mesh><torusKnotGeometry args={[0.34, 0.12, 96, 16]} />{mat}</mesh>;
|
||||
case 3: // Fulfillment — gauge ring
|
||||
return <mesh rotation={[Math.PI / 2.2, 0, 0]}><torusGeometry args={[0.46, 0.1, 24, 64]} />{mat}</mesh>;
|
||||
default: // Hero — trophy (cup + stem + base)
|
||||
return (
|
||||
<group>
|
||||
<mesh position={[0, 0.28, 0]}><sphereGeometry args={[0.34, 32, 16, 0, Math.PI * 2, 0, Math.PI / 1.7]} />{mat}</mesh>
|
||||
<mesh position={[0, -0.05, 0]}><cylinderGeometry args={[0.07, 0.07, 0.34, 16]} />{mat}</mesh>
|
||||
<mesh position={[0, -0.26, 0]}><cylinderGeometry args={[0.26, 0.3, 0.12, 24]} />{mat}</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===========================================================================
|
||||
STAGE 01 — a real logistics intake scene rendered on the glass panel:
|
||||
orders.csv → 59 orders streaming in → AI assignment hub → 4 rider markers,
|
||||
with glowing assignment beams. Labels/counters/icons are crisp drei <Html>
|
||||
(real DOM, glass-styled) positioned in 3D; structure + motion stay in WebGL.
|
||||
=========================================================================== */
|
||||
const GREEN = "#22C55E";
|
||||
const RIDERS = [
|
||||
{ id: "A", veh: "EV Bike", icon: "🚲" },
|
||||
{ id: "B", veh: "Auto", icon: "🛺" },
|
||||
{ id: "C", veh: "Cargo Truck", icon: "🚚" },
|
||||
{ id: "D", veh: "EV Van", icon: "🚐" },
|
||||
];
|
||||
const RIDER_X = [-1.18, -0.39, 0.39, 1.18];
|
||||
const HUB = new THREE.Vector3(0, 0.14, 0.22);
|
||||
|
||||
/** A small order card that streams down from the source into the AI hub, on a loop. */
|
||||
function OrderCard({ index }: { index: number }) {
|
||||
const group = useRef<THREE.Group>(null);
|
||||
const mat = useRef<THREE.MeshStandardMaterial>(null);
|
||||
useFrame((state) => {
|
||||
const t = (state.clock.elapsedTime * 0.55 + index * 0.27) % 1;
|
||||
const o = Math.sin(t * Math.PI); // fade/scale in at the ends of the fall
|
||||
if (group.current) {
|
||||
group.current.position.set((index - 1.5) * 0.16, 0.78 - t * 0.62, 0.32);
|
||||
group.current.scale.setScalar(0.66 + o * 0.34);
|
||||
}
|
||||
if (mat.current) mat.current.opacity = o * 0.95;
|
||||
});
|
||||
return (
|
||||
<group ref={group}>
|
||||
<RoundedBox args={[0.5, 0.34, 0.02]} radius={0.05} smoothness={3}>
|
||||
<meshStandardMaterial ref={mat} color="#ffffff" emissive={GREEN} emissiveIntensity={0.35} transparent opacity={0.9} />
|
||||
</RoundedBox>
|
||||
<mesh position={[0, 0.1, 0.012]}><planeGeometry args={[0.5, 0.05]} /><meshBasicMaterial color={GREEN} toneMapped={false} transparent opacity={0.85} /></mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/** Glowing assignment beam from the hub to a rider, with a travelling packet. */
|
||||
function AssignBeam({ to, delay }: { to: [number, number, number]; delay: number }) {
|
||||
const dot = useRef<THREE.Mesh>(null);
|
||||
const b = useMemo(() => new THREE.Vector3(...to), [to]);
|
||||
const points = useMemo(() => [HUB.clone(), b.clone()], [b]);
|
||||
useFrame((state) => {
|
||||
if (dot.current) dot.current.position.lerpVectors(HUB, b, (state.clock.elapsedTime * 0.6 + delay) % 1);
|
||||
});
|
||||
return (
|
||||
<group>
|
||||
<Line points={points} color={GREEN} lineWidth={1.4} transparent opacity={0.5} toneMapped={false} />
|
||||
<mesh ref={dot}><sphereGeometry args={[0.045, 12, 12]} /><meshBasicMaterial color={GREEN} toneMapped={false} /></mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/** A rider node (glowing marker) + a glass DOM chip with vehicle icon + name. */
|
||||
function RiderMarker({ x, rider, register }: { x: number; rider: typeof RIDERS[number]; register: (el: HTMLElement | null) => void }) {
|
||||
const pulse = useRef<THREE.Mesh>(null);
|
||||
useFrame((state) => {
|
||||
if (pulse.current) {
|
||||
const s = 1 + Math.sin(state.clock.elapsedTime * 2 + x) * 0.18;
|
||||
pulse.current.scale.setScalar(s);
|
||||
}
|
||||
});
|
||||
return (
|
||||
<group position={[x, -0.74, 0.22]}>
|
||||
<mesh><sphereGeometry args={[0.11, 24, 24]} /><meshStandardMaterial color={GREEN} emissive={GREEN} emissiveIntensity={0.9} toneMapped={false} /></mesh>
|
||||
<mesh ref={pulse}><ringGeometry args={[0.15, 0.17, 32]} /><meshBasicMaterial color={GREEN} transparent opacity={0.5} toneMapped={false} side={THREE.DoubleSide} /></mesh>
|
||||
<Html center distanceFactor={6.5} position={[0, -0.3, 0]} zIndexRange={[20, 0]} pointerEvents="none">
|
||||
<div className="dm-st3d-chip" ref={register}>
|
||||
<span className="dm-st3d-chip__ico">{rider.icon}</span>
|
||||
<span className="dm-st3d-chip__txt"><b>Rider {rider.id}</b>{rider.veh}</span>
|
||||
</div>
|
||||
</Html>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function Stage1Scene({ progress }: { progress: React.RefObject<number> }) {
|
||||
const counter = useRef<HTMLSpanElement>(null);
|
||||
const labels = useRef<HTMLElement[]>([]);
|
||||
const register = (el: HTMLElement | null) => { if (el && !labels.current.includes(el)) labels.current.push(el); };
|
||||
useFrame((state) => {
|
||||
// Live order intake — count up 0→59, hold, repeat.
|
||||
if (counter.current) {
|
||||
const cyc = state.clock.elapsedTime % 3.6;
|
||||
counter.current.textContent = String(Math.min(59, Math.round((cyc / 2.4) * 59)));
|
||||
}
|
||||
// Fade the DOM labels out as the camera leaves stage 1.
|
||||
const idx = (progress.current ?? 0) * (N - 1);
|
||||
const op = THREE.MathUtils.clamp(1 - (idx - 0.45) / 0.6, 0, 1);
|
||||
for (const el of labels.current) el.style.opacity = String(op);
|
||||
});
|
||||
return (
|
||||
<group>
|
||||
{/* Source file */}
|
||||
<Html center distanceFactor={6.5} position={[0, 0.96, 0.34]} zIndexRange={[20, 0]} pointerEvents="none">
|
||||
<div className="dm-st3d-file" ref={register}>📄 orders.csv</div>
|
||||
</Html>
|
||||
{/* Live intake counter */}
|
||||
<Html center distanceFactor={6.5} position={[0, 0.62, 0.34]} zIndexRange={[20, 0]} pointerEvents="none">
|
||||
<div className="dm-st3d-count" ref={register}><span ref={counter}>0</span> Orders</div>
|
||||
</Html>
|
||||
{/* Order cards streaming in */}
|
||||
{[0, 1, 2, 3].map((j) => <OrderCard key={j} index={j} />)}
|
||||
{/* AI assignment hub */}
|
||||
<mesh position={[HUB.x, HUB.y, 0.16]}><icosahedronGeometry args={[0.16, 1]} /><meshStandardMaterial color={GREEN} emissive={GREEN} emissiveIntensity={1.2} toneMapped={false} /></mesh>
|
||||
<Html center distanceFactor={6.5} position={[0, HUB.y, 0.36]} zIndexRange={[20, 0]} pointerEvents="none">
|
||||
<div className="dm-st3d-ai" ref={register}>🤖 AI Assignment</div>
|
||||
</Html>
|
||||
{/* Assignment beams + rider markers */}
|
||||
{RIDERS.map((r, j) => <AssignBeam key={r.id} to={[RIDER_X[j], -0.66, 0.22]} delay={j * 0.25} />)}
|
||||
{RIDERS.map((r, j) => <RiderMarker key={r.id} x={RIDER_X[j]} rider={r} register={register} />)}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
One floating glassmorphism stage. Per-frame it reacts to scroll: the active
|
||||
stage scales up + brightens; completed stages push back + dim.
|
||||
--------------------------------------------------------------------------- */
|
||||
function StageNode({ i, progress, reduced }: { i: number; progress: React.RefObject<number>; reduced: boolean }) {
|
||||
const group = useRef<THREE.Group>(null);
|
||||
const accentGroup = useRef<THREE.Group>(null);
|
||||
const glassMat = useRef<THREE.MeshPhysicalMaterial>(null);
|
||||
const haloMat = useRef<THREE.MeshBasicMaterial>(null);
|
||||
|
||||
const base = useMemo(() => stagePosition(i), [i]);
|
||||
const color = useMemo(() => new THREE.Color(STAGES[i].theme), [i]);
|
||||
|
||||
const sEased = useRef(0.6);
|
||||
const pushEased = useRef(0);
|
||||
const oEased = useRef(0.2);
|
||||
|
||||
useFrame((state, dt) => {
|
||||
const p = progress.current ?? 0;
|
||||
const idx = p * (N - 1);
|
||||
const d = idx - i; // >0 completed, <0 future, ~0 active
|
||||
const ad = Math.abs(d);
|
||||
const active = ad < 0.5;
|
||||
|
||||
const targetScale = THREE.MathUtils.clamp(1.12 - ad * 0.17, 0.62, 1.14);
|
||||
const targetPush = d > 0 ? d * 2.8 : 0; // completed recede
|
||||
const targetOpacity =
|
||||
d > 0 ? THREE.MathUtils.clamp(0.95 - d * 0.42, 0.16, 0.95) // completed dim
|
||||
: d < 0 ? THREE.MathUtils.clamp(0.95 + d * 0.28, 0.28, 0.95) // future faintly hidden
|
||||
: 0.95;
|
||||
|
||||
const k = reduced ? 1 : Math.min(1, dt * 4);
|
||||
sEased.current = THREE.MathUtils.lerp(sEased.current, targetScale, k);
|
||||
pushEased.current = THREE.MathUtils.lerp(pushEased.current, targetPush, k);
|
||||
oEased.current = THREE.MathUtils.lerp(oEased.current, targetOpacity, k);
|
||||
|
||||
const t = state.clock.elapsedTime;
|
||||
if (group.current) {
|
||||
group.current.scale.setScalar(sEased.current);
|
||||
group.current.position.set(
|
||||
base[0],
|
||||
base[1] + Math.sin(t * 0.6 + i) * 0.16,
|
||||
base[2] - pushEased.current,
|
||||
);
|
||||
group.current.rotation.y = Math.sin(t * 0.3 + i * 1.3) * 0.07;
|
||||
}
|
||||
if (glassMat.current) {
|
||||
glassMat.current.opacity = oEased.current * 0.3;
|
||||
(glassMat.current.emissive as THREE.Color).copy(color).multiplyScalar(active ? 0.55 : 0.16);
|
||||
}
|
||||
if (haloMat.current) {
|
||||
haloMat.current.opacity = oEased.current * (active ? 0.92 : 0.4);
|
||||
}
|
||||
if (accentGroup.current) {
|
||||
accentGroup.current.rotation.y += dt * (active ? 0.7 : 0.25);
|
||||
accentGroup.current.position.y = Math.sin(t * 0.9 + i) * 0.12;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={group} position={base}>
|
||||
{/* Coloured halo backplate — reads as the stage glow under Bloom */}
|
||||
<RoundedBox args={[3.62, 2.42, 0.05]} radius={0.22} smoothness={4} position={[0, 0, -0.05]}>
|
||||
<meshBasicMaterial ref={haloMat} color={color} transparent opacity={0.8} toneMapped={false} />
|
||||
</RoundedBox>
|
||||
|
||||
{/* Frosted glass panel */}
|
||||
<RoundedBox args={[3.4, 2.2, 0.14]} radius={0.2} smoothness={4}>
|
||||
<meshPhysicalMaterial
|
||||
ref={glassMat}
|
||||
color="#ffffff"
|
||||
transparent
|
||||
opacity={0.3}
|
||||
roughness={0.12}
|
||||
metalness={0}
|
||||
clearcoat={1}
|
||||
clearcoatRoughness={0.18}
|
||||
ior={1.25}
|
||||
reflectivity={0.45}
|
||||
emissive={color}
|
||||
emissiveIntensity={1}
|
||||
/>
|
||||
</RoundedBox>
|
||||
|
||||
{/* Stage 1 = a real logistics intake scene; other stages = floating accent */}
|
||||
{i === 0 ? (
|
||||
<Stage1Scene progress={progress} />
|
||||
) : (
|
||||
<group ref={accentGroup} position={[0, 0, 0.55]}>
|
||||
<Accent i={i} color={color} />
|
||||
</group>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Glowing connector + a light packet travelling from one stage to the next.
|
||||
--------------------------------------------------------------------------- */
|
||||
function Connector({ i }: { i: number }) {
|
||||
const dot = useRef<THREE.Mesh>(null);
|
||||
const a = useMemo(() => stagePosition(i), [i]);
|
||||
const b = useMemo(() => stagePosition(i + 1), [i]);
|
||||
const cA = useMemo(() => new THREE.Color(STAGES[i].theme), [i]);
|
||||
const cB = useMemo(() => new THREE.Color(STAGES[i + 1].theme), [i]);
|
||||
const dotColor = useMemo(() => cA.clone().lerp(cB, 0.5), [cA, cB]);
|
||||
|
||||
// A gentle arc between the two stages (midpoint lifted).
|
||||
const points = useMemo(() => {
|
||||
const mid: [number, number, number] = [
|
||||
(a[0] + b[0]) / 2,
|
||||
(a[1] + b[1]) / 2 + 0.9,
|
||||
(a[2] + b[2]) / 2,
|
||||
];
|
||||
const curve = new THREE.QuadraticBezierCurve3(
|
||||
new THREE.Vector3(...a),
|
||||
new THREE.Vector3(...mid),
|
||||
new THREE.Vector3(...b),
|
||||
);
|
||||
return curve.getPoints(40);
|
||||
}, [a, b]);
|
||||
|
||||
const curve = useMemo(() => {
|
||||
const mid = new THREE.Vector3(
|
||||
(a[0] + b[0]) / 2,
|
||||
(a[1] + b[1]) / 2 + 0.9,
|
||||
(a[2] + b[2]) / 2,
|
||||
);
|
||||
return new THREE.QuadraticBezierCurve3(new THREE.Vector3(...a), mid, new THREE.Vector3(...b));
|
||||
}, [a, b]);
|
||||
|
||||
useFrame((state) => {
|
||||
if (!dot.current) return;
|
||||
const t = (state.clock.elapsedTime * 0.32 + i * 0.27) % 1;
|
||||
dot.current.position.copy(curve.getPoint(t));
|
||||
});
|
||||
|
||||
return (
|
||||
<group>
|
||||
<Line points={points} color={dotColor} lineWidth={1.5} transparent opacity={0.5} toneMapped={false} />
|
||||
<mesh ref={dot}>
|
||||
<sphereGeometry args={[0.09, 16, 16]} />
|
||||
<meshBasicMaterial color={dotColor} toneMapped={false} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function Scene({ progress, reduced, isMobile }: { progress: React.RefObject<number>; reduced: boolean; isMobile: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<color attach="background" args={[BG]} />
|
||||
<fog attach="fog" args={[BG, 16, 46]} />
|
||||
<ambientLight intensity={0.95} />
|
||||
<hemisphereLight args={["#ffffff", "#dfe4ee", 0.7]} />
|
||||
<directionalLight position={[6, 10, 8]} intensity={0.85} />
|
||||
<directionalLight position={[-8, 4, -4]} intensity={0.35} color="#cdd6ff" />
|
||||
|
||||
<CameraRig progress={progress} reduced={reduced} />
|
||||
|
||||
{STAGES.map((_, i) => (
|
||||
<StageNode key={i} i={i} progress={progress} reduced={reduced} />
|
||||
))}
|
||||
{STAGES.slice(0, -1).map((_, i) => (
|
||||
<Connector key={i} i={i} />
|
||||
))}
|
||||
|
||||
{/* Ambient floating particles + a confetti-like burst near the hero stage */}
|
||||
{!reduced && (
|
||||
<>
|
||||
<Sparkles
|
||||
count={isMobile ? 50 : 110}
|
||||
scale={[14, 8, N * 6.4]}
|
||||
position={[0, 0, (-(N - 1) * 6.4) / 2]}
|
||||
size={2.2}
|
||||
speed={0.3}
|
||||
opacity={0.5}
|
||||
color="#9aa6c4"
|
||||
/>
|
||||
<Sparkles
|
||||
count={isMobile ? 28 : 60}
|
||||
scale={[5, 4, 4]}
|
||||
position={stagePosition(N - 1)}
|
||||
size={3.4}
|
||||
speed={0.5}
|
||||
opacity={0.9}
|
||||
color="#ff9aa9"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!reduced && (
|
||||
<EffectComposer multisampling={isMobile ? 0 : 2}>
|
||||
<Bloom
|
||||
mipmapBlur
|
||||
intensity={isMobile ? 0.7 : 1.05}
|
||||
luminanceThreshold={0.55}
|
||||
luminanceSmoothing={0.06}
|
||||
radius={isMobile ? 0.6 : 0.78}
|
||||
kernelSize={KernelSize.MEDIUM}
|
||||
/>
|
||||
</EffectComposer>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StrategyCanvas({ progress, reduced = false, isMobile = false, active = true }: Props) {
|
||||
return (
|
||||
<Canvas
|
||||
dpr={[1, isMobile || reduced ? 1.25 : 1.5]}
|
||||
camera={{ position: [0, 1.4, 5.4], fov: 48, near: 0.1, far: 90 }}
|
||||
gl={{ antialias: !isMobile, powerPreference: "high-performance", alpha: false }}
|
||||
frameloop={active ? "always" : "never"}
|
||||
>
|
||||
<Scene progress={progress} reduced={reduced} isMobile={isMobile} />
|
||||
</Canvas>
|
||||
);
|
||||
}
|
||||
516
src/components/strategy/StrategySection.tsx
Normal file
516
src/components/strategy/StrategySection.tsx
Normal file
@@ -0,0 +1,516 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { motion, useMotionValue, useTransform, type MotionValue } from "framer-motion";
|
||||
import gsap from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import { STAGES, N } from "./theme";
|
||||
|
||||
const StrategyCanvas = dynamic(() => import("./StrategyCanvas"), { ssr: false });
|
||||
|
||||
/** Center of each stage's scroll window (0…1). */
|
||||
const CENTER = (i: number) => i / (N - 1);
|
||||
|
||||
/** Persistent top rail: the 5 stages, current one highlighted. */
|
||||
function StageRail({ active }: { active: number }) {
|
||||
return (
|
||||
<div className="dm-st-rail" aria-hidden>
|
||||
{STAGES.map((s, i) => {
|
||||
const state = i < active ? "done" : i === active ? "current" : "todo";
|
||||
return (
|
||||
<React.Fragment key={s.n}>
|
||||
{i > 0 && <span className={`dm-st-rail__line is-${i <= active ? "on" : "off"}`} />}
|
||||
<div className={`dm-st-rail__step is-${state}`} style={{ ["--c" as string]: s.theme }}>
|
||||
<span className="dm-st-rail__num">{i < active ? "✓" : s.n}</span>
|
||||
<span className="dm-st-rail__title">{s.kicker}</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A cross-fading glass content card pinned to one side, themed per stage. */
|
||||
function StageCard({
|
||||
i,
|
||||
scroll,
|
||||
side,
|
||||
children,
|
||||
}: {
|
||||
i: number;
|
||||
scroll: MotionValue<number>;
|
||||
side: "left" | "right";
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const c = CENTER(i);
|
||||
const opacity = useTransform(scroll, [c - 0.14, c - 0.06, c + 0.06, c + 0.14], [0, 1, 1, 0]);
|
||||
const y = useTransform(scroll, [c - 0.14, c - 0.05], [34, 0]);
|
||||
const s = STAGES[i];
|
||||
return (
|
||||
<motion.div
|
||||
className={`dm-st-card-story is-${side}`}
|
||||
style={{ opacity, y, ["--c" as string]: s.theme }}
|
||||
>
|
||||
<div className="dm-st-card-story__head">
|
||||
<span className="dm-st-pillar__num">{s.n}</span>
|
||||
<span className="dm-st-pillar__kicker">{s.kicker}</span>
|
||||
</div>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Strategy" — a premium, Apple-keynote-style 3D scroll-storytelling section.
|
||||
* A single GSAP ScrollTrigger maps scroll to a normalized progress that drives
|
||||
* the R3F camera through five floating glass stages while DOM glass cards
|
||||
* cross-fade in lockstep. Pins via a self-managed fixed element (the site's
|
||||
* fixed header + an ancestor `overflow:hidden` break CSS sticky / GSAP pin).
|
||||
*/
|
||||
export default function StrategySection({ connected = false }: { connected?: boolean } = {}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const progressRef = useRef(0);
|
||||
const scroll = useMotionValue(0);
|
||||
|
||||
const [pinState, setPinState] = useState<"before" | "pinned" | "after">("before");
|
||||
const [active, setActive] = useState(0);
|
||||
const [mountScene, setMountScene] = useState(false);
|
||||
const [sceneActive, setSceneActive] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [reduced, setReduced] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const mqMobile = window.matchMedia("(max-width: 767px)");
|
||||
const mqReduce = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const sync = () => { setIsMobile(mqMobile.matches); setReduced(mqReduce.matches); };
|
||||
sync();
|
||||
mqMobile.addEventListener("change", sync);
|
||||
mqReduce.addEventListener("change", sync);
|
||||
return () => { mqMobile.removeEventListener("change", sync); mqReduce.removeEventListener("change", sync); };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const mountIo = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((e) => e.isIntersecting)) {
|
||||
setMountScene(true);
|
||||
setSceneActive(true);
|
||||
mountIo.disconnect();
|
||||
}
|
||||
},
|
||||
{ rootMargin: "70% 0px" },
|
||||
);
|
||||
const activeIo = new IntersectionObserver(
|
||||
(entries) => setSceneActive(entries.some((e) => e.isIntersecting)),
|
||||
{ rootMargin: "10% 0px" },
|
||||
);
|
||||
mountIo.observe(el);
|
||||
activeIo.observe(el);
|
||||
return () => { mountIo.disconnect(); activeIo.disconnect(); };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
let lastPin: "before" | "pinned" | "after" = "before";
|
||||
let lastActive = 0;
|
||||
const st = ScrollTrigger.create({
|
||||
trigger: el,
|
||||
start: "top top",
|
||||
end: "bottom bottom",
|
||||
scrub: 0.5,
|
||||
invalidateOnRefresh: true,
|
||||
onUpdate: (self) => {
|
||||
const p = self.progress;
|
||||
progressRef.current = p;
|
||||
scroll.set(p);
|
||||
const ns = p <= 0.0002 ? "before" : p >= 0.9998 ? "after" : "pinned";
|
||||
if (ns !== lastPin) { lastPin = ns; setPinState(ns); }
|
||||
const na = Math.round(p * (N - 1));
|
||||
if (na !== lastActive) { lastActive = na; setActive(na); }
|
||||
},
|
||||
});
|
||||
const refresh = setTimeout(() => ScrollTrigger.refresh(), 300);
|
||||
return () => { clearTimeout(refresh); st.kill(); };
|
||||
}, [scroll]);
|
||||
|
||||
// Intro hint fades out as the journey begins.
|
||||
const introOpacity = useTransform(scroll, [0, 0.03, 0.06], [1, 1, 0]);
|
||||
// Persistent header fades in after the intro.
|
||||
const headerOpacity = useTransform(scroll, [0.02, 0.07], [0, 1]);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={containerRef}
|
||||
className={`dm-st is-${pinState}${connected ? " is-connected" : ""}`}
|
||||
aria-label="Strategy — Happier Riders. Higher Fulfillment."
|
||||
>
|
||||
<div className="dm-st-sticky">
|
||||
<div className="dm-st-card">
|
||||
{mountScene && (
|
||||
<div className="dm-st-canvas">
|
||||
<StrategyCanvas progress={progressRef} reduced={reduced} isMobile={isMobile} active={sceneActive} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="dm-st-ui">
|
||||
{/* Persistent header */}
|
||||
<motion.div className="dm-st-top" style={{ opacity: headerOpacity }}>
|
||||
<div className="dm-st-eyebrow"><span className="dm-st-dot" /> MileTruth Strategy Engine</div>
|
||||
<StageRail active={active} />
|
||||
</motion.div>
|
||||
|
||||
{/* Intro hint */}
|
||||
<motion.div className="dm-st-scrollhint" style={{ opacity: introOpacity }}>
|
||||
<span>Scroll to follow the strategy</span>
|
||||
<span className="dm-st-arrow">↓</span>
|
||||
</motion.div>
|
||||
|
||||
{/* STAGE 01 — INPUT (green): orders + riders enter the system */}
|
||||
<StageCard i={0} scroll={scroll} side="left">
|
||||
<h3 className="dm-st-pillar__title">Orders & riders enter the system</h3>
|
||||
<div className="dm-st-grid2">
|
||||
<div className="dm-st-tile">
|
||||
<span className="dm-st-tile__ico">📄</span>
|
||||
<span className="dm-st-tile__num">59</span>
|
||||
<span className="dm-st-tile__lbl">Orders</span>
|
||||
<span className="dm-st-tile__sub">Order intake</span>
|
||||
</div>
|
||||
<div className="dm-st-tile">
|
||||
<span className="dm-st-tile__ico">🧑✈️</span>
|
||||
<span className="dm-st-tile__num">4</span>
|
||||
<span className="dm-st-tile__lbl">Total Riders</span>
|
||||
<span className="dm-st-tile__sub">Available fleet</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dm-st-file">
|
||||
<span className="dm-st-file__ico">⬆️</span>
|
||||
<span className="dm-st-file__name">orders.csv</span>
|
||||
<span className="dm-st-file__meta">59 rows · uploaded</span>
|
||||
<span className="dm-st-file__ok">✓</span>
|
||||
</div>
|
||||
<ul className="dm-st-riders">
|
||||
{[
|
||||
{ a: "A", name: "Rider A", v: "EV Bike", cap: "12 orders" },
|
||||
{ a: "B", name: "Rider B", v: "Auto", cap: "18 orders" },
|
||||
{ a: "C", name: "Rider C", v: "Cargo Truck", cap: "20 orders" },
|
||||
{ a: "D", name: "Rider D", v: "EV Van", cap: "9 orders" },
|
||||
].map((r) => (
|
||||
<li key={r.a} className="dm-st-rider">
|
||||
<span className="dm-st-rider__av">{r.a}<i /></span>
|
||||
<span className="dm-st-rider__name">{r.name}</span>
|
||||
<span className="dm-st-rider__veh">{r.v}</span>
|
||||
<span className="dm-st-rider__cap">{r.cap}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</StageCard>
|
||||
|
||||
{/* STAGE 02 — PARALLEL EXECUTION (purple): 6 strategies at once */}
|
||||
<StageCard i={1} scroll={scroll} side="right">
|
||||
<h3 className="dm-st-pillar__title">Six strategies, evaluated in parallel</h3>
|
||||
<div className="dm-st-engines">
|
||||
<div className="dm-st-engine">
|
||||
<div className="dm-st-engine__head"><span className="dm-st-engine__name">Legacy Engine</span><span className="dm-st-engine__tag">Baseline</span></div>
|
||||
<div className="dm-st-pills">
|
||||
{["Proximity", "Balanced", "Fuel Saver"].map((p) => <span key={p} className="dm-st-pill">{p}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="dm-st-engine is-unified">
|
||||
<div className="dm-st-engine__head"><span className="dm-st-engine__name">Unified Engine</span><span className="dm-st-engine__tag dm-st-engine__tag--u">MileTruth</span></div>
|
||||
<div className="dm-st-pills">
|
||||
{["EV Aware", "Multi Trip", "Time Aware"].map((p) => <span key={p} className="dm-st-pill dm-st-pill--u">{p}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="dm-st-foot"><span className="dm-st-livedot" /> All 6 strategies run at the same time</p>
|
||||
</StageCard>
|
||||
|
||||
{/* STAGE 03 — SMART OPTIMIZATION (blue): validation pipeline */}
|
||||
<StageCard i={2} scroll={scroll} side="left">
|
||||
<h3 className="dm-st-pillar__title">Routes validated & optimized</h3>
|
||||
<div className="dm-st-pipe">
|
||||
{[
|
||||
{ ico: "⚙️", name: "VRP Optimizer", desc: "Google OR-Tools solver" },
|
||||
{ ico: "🔋", name: "Battery Simulation", desc: "EV range & charging feasibility" },
|
||||
{ ico: "⏱️", name: "SLA Validator", desc: "ETA vs promised window" },
|
||||
].map((m, j) => (
|
||||
<React.Fragment key={m.name}>
|
||||
{j > 0 && <span className="dm-st-pipe__arrow"><i /></span>}
|
||||
<div className="dm-st-pipe__node">
|
||||
<span className="dm-st-pipe__ico">{m.ico}</span>
|
||||
<span className="dm-st-pipe__name">{m.name}</span>
|
||||
<span className="dm-st-pipe__desc">{m.desc}</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</StageCard>
|
||||
|
||||
{/* STAGE 04 — PERFORMANCE GRADING (orange): every strategy scored */}
|
||||
<StageCard i={3} scroll={scroll} side="right">
|
||||
<h3 className="dm-st-pillar__title">Every strategy is scored</h3>
|
||||
<div className="dm-st-stars"><span className="dm-st-stars__on">★★★★</span><span className="dm-st-stars__off">★</span><span className="dm-st-stars__txt">4.5 / 5 grade</span></div>
|
||||
<ul className="dm-st-metrics">
|
||||
{[
|
||||
{ name: "Fulfillment Rate", score: "88%", w: 88, status: "Good", desc: "Orders delivered vs total" },
|
||||
{ name: "SLA Compliance", score: "95%", w: 95, status: "Pass", desc: "On-time within window" },
|
||||
{ name: "Efficiency Score", score: "92", w: 92, status: "Strong", desc: "Distance & fleet usage" },
|
||||
{ name: "Battery & Route", score: "OK", w: 100, status: "Feasible", desc: "EV range respected" },
|
||||
].map((m) => (
|
||||
<li key={m.name} className="dm-st-metric">
|
||||
<span className="dm-st-metric__name">{m.name}</span>
|
||||
<span className="dm-st-metric__bar"><i style={{ width: `${m.w}%` }} /></span>
|
||||
<span className="dm-st-metric__score">{m.score}</span>
|
||||
<span className="dm-st-metric__status">✓ {m.status}</span>
|
||||
<span className="dm-st-metric__desc">{m.desc}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</StageCard>
|
||||
|
||||
{/* STAGE 05 — STRATEGY COMPARISON (red, hero): the winner */}
|
||||
<StageCard i={4} scroll={scroll} side="right">
|
||||
<div className="dm-st-winner">
|
||||
<span className="dm-st-winner__eyebrow">Best strategy recommendation</span>
|
||||
<div className="dm-st-winner__name"><span className="dm-st-trophy">🏆</span> EV Aware</div>
|
||||
<span className="dm-st-winner__grade">High Performance Grade</span>
|
||||
</div>
|
||||
<div className="dm-st-hero">
|
||||
<div className="dm-st-hero__ring">
|
||||
<span className="dm-st-hero__pct">88%</span>
|
||||
<span className="dm-st-hero__sub">Score</span>
|
||||
</div>
|
||||
<ul className="dm-st-wins">
|
||||
<li><strong>52/59</strong> Orders Fulfilled</li>
|
||||
<li><strong>88%</strong> Performance Score</li>
|
||||
<li><strong>3</strong> SLA Violations</li>
|
||||
<li><strong>A</strong> Performance Grade</li>
|
||||
</ul>
|
||||
</div>
|
||||
</StageCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style>{styles}</style>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = `
|
||||
.dm-st { position: relative; height: 620vh; background: transparent; }
|
||||
.dm-st-sticky { position: absolute; top: 0; left: 0; width: 100%; height: 100vh; overflow: hidden; }
|
||||
.dm-st.is-pinned .dm-st-sticky { position: fixed; top: 0; left: 0; }
|
||||
.dm-st.is-after .dm-st-sticky { position: absolute; top: auto; bottom: 0; }
|
||||
|
||||
.dm-st-card {
|
||||
position: absolute !important; inset: 16px !important;
|
||||
border-radius: 28px !important; overflow: hidden !important;
|
||||
background: radial-gradient(120% 100% at 50% 0%, #ffffff 0%, #eef1f6 60%, #e6eaf2 100%) !important;
|
||||
border: 1px solid rgba(15,23,42,0.08) !important;
|
||||
box-shadow: 0 30px 90px -34px rgba(15,23,42,0.4) !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
@media (max-width: 767px) { .dm-st-card { inset: 10px !important; border-radius: 20px !important; } }
|
||||
|
||||
/* Connected mode (inside Workflow 3): flatten the card's bottom so the Strategy
|
||||
content card below butts directly against it — same seam as Workflow 1 & 2. */
|
||||
.dm-st.is-connected .dm-st-card {
|
||||
top: 16px !important; left: 16px !important; right: 16px !important; bottom: 0 !important;
|
||||
border-radius: 28px 28px 0 0 !important; border-bottom: none !important;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.dm-st.is-connected .dm-st-card {
|
||||
top: 10px !important; left: 10px !important; right: 10px !important; bottom: 0 !important;
|
||||
border-radius: 20px 20px 0 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.dm-st-canvas { position: absolute; inset: 0; z-index: 1; }
|
||||
.dm-st-canvas canvas { display: block; }
|
||||
|
||||
.dm-st-ui { position: absolute; inset: 0; z-index: 4; pointer-events: none;
|
||||
font-family: var(--font-space-grotesk), var(--font-manrope), system-ui, sans-serif; color: #0f172a; }
|
||||
|
||||
/* ---- Persistent header: title + 5-stage rail ---- */
|
||||
.dm-st-top { position: absolute; top: clamp(96px, 13vh, 128px); left: 0; right: 0; z-index: 5;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 0 16px; }
|
||||
.dm-st-eyebrow { display: inline-flex; align-items: center; gap: 8px; font-size: 11px; line-height: 1.35;
|
||||
letter-spacing: 0.28em; text-transform: uppercase; color: #475569; padding: 9px 18px; border-radius: 999px;
|
||||
background: rgba(255,255,255,0.72); border: 1px solid rgba(15,23,42,0.08); backdrop-filter: blur(10px); white-space: nowrap; }
|
||||
.dm-st-dot { width: 6px; height: 6px; border-radius: 50%; background: #6366f1; box-shadow: 0 0 10px #6366f1; }
|
||||
|
||||
.dm-st-rail { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; max-width: 980px; }
|
||||
.dm-st-rail__step { display: inline-flex; align-items: center; gap: 7px; padding: 5px 11px; border-radius: 999px;
|
||||
background: rgba(255,255,255,0.7); border: 1px solid rgba(15,23,42,0.08); backdrop-filter: blur(8px);
|
||||
transition: all 0.45s cubic-bezier(0.22,1,0.36,1); }
|
||||
.dm-st-rail__num { width: 18px; height: 18px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 10px; font-weight: 800; color: #64748b; background: rgba(15,23,42,0.06); }
|
||||
.dm-st-rail__title { font-size: 11px; font-weight: 600; letter-spacing: 0.02em; color: #64748b; white-space: nowrap; }
|
||||
.dm-st-rail__step.is-current { background: color-mix(in srgb, var(--c) 16%, white); border-color: var(--c);
|
||||
box-shadow: 0 0 22px -6px var(--c); }
|
||||
.dm-st-rail__step.is-current .dm-st-rail__num { background: var(--c); color: #fff; }
|
||||
.dm-st-rail__step.is-current .dm-st-rail__title { color: #0f172a; }
|
||||
.dm-st-rail__step.is-done .dm-st-rail__num { background: #22C55E; color: #fff; }
|
||||
.dm-st-rail__step.is-done .dm-st-rail__title { color: #334155; }
|
||||
.dm-st-rail__line { width: 14px; height: 1px; background: rgba(15,23,42,0.14); margin: 0 3px; transition: background 0.45s ease; }
|
||||
.dm-st-rail__line.is-on { background: var(--c, #22C55E); }
|
||||
|
||||
.dm-st-scrollhint { position: absolute; bottom: clamp(26px, 6vh, 60px); left: 50%; transform: translateX(-50%);
|
||||
display: flex; flex-direction: column; align-items: center; gap: 8px; font-size: 12px; letter-spacing: 0.12em;
|
||||
color: #64748b; text-transform: uppercase; text-align: center; }
|
||||
.dm-st-arrow { font-size: 18px; animation: dmStBob 1.8s ease-in-out infinite; }
|
||||
@keyframes dmStBob { 0%,100% { transform: translateY(0); opacity: 0.5; } 50% { transform: translateY(6px); opacity: 1; } }
|
||||
|
||||
/* ---- Per-stage glass content card ---- */
|
||||
.dm-st-card-story { position: absolute; bottom: clamp(24px, 6vh, 64px); width: min(484px, 88vw);
|
||||
pointer-events: auto; will-change: opacity, transform; padding: 20px 22px; border-radius: 20px;
|
||||
background: rgba(255,255,255,0.94); border: 1px solid rgba(15,23,42,0.08);
|
||||
/* backdrop blur removed — card cross-fades/translates per scroll-stage; blur was the
|
||||
heaviest per-frame cost on this section. Near-opaque white keeps the glass look. */
|
||||
border-top: 3px solid var(--c);
|
||||
box-shadow: 0 28px 70px -34px rgba(15,23,42,0.5); }
|
||||
.dm-st-card-story.is-left { left: clamp(18px, 5vw, 72px); }
|
||||
.dm-st-card-story.is-right { right: clamp(18px, 5vw, 72px); }
|
||||
.dm-st-card-story__head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
|
||||
.dm-st-pillar__num { font-size: 12px; font-weight: 800; letter-spacing: 0.1em; color: #fff;
|
||||
background: var(--c); border-radius: 7px; padding: 3px 8px; }
|
||||
.dm-st-pillar__kicker { font-size: clamp(11px, 1.1vw, 13px); font-weight: 700; letter-spacing: 0.16em;
|
||||
text-transform: uppercase; color: var(--c); }
|
||||
.dm-st .dm-st-pillar__title { margin: 0 0 14px !important; padding: 0 !important; color: #0f172a !important;
|
||||
font-weight: 700 !important; text-transform: none !important; letter-spacing: -0.015em !important;
|
||||
font-size: clamp(18px, 2vw, 26px) !important; line-height: 1.16 !important; }
|
||||
.dm-st .dm-st-pillar__title--hero { font-size: clamp(22px, 2.6vw, 34px) !important;
|
||||
background: linear-gradient(90deg, #C01227, #E2354A) !important; -webkit-background-clip: text !important;
|
||||
background-clip: text !important; -webkit-text-fill-color: transparent !important; }
|
||||
.dm-st-foot { margin: 12px 0 0; font-size: clamp(12px, 1.1vw, 13.5px); line-height: 1.5; color: #475569;
|
||||
display: flex; align-items: center; gap: 8px; }
|
||||
.dm-st-livedot { width: 8px; height: 8px; border-radius: 50%; background: var(--c); box-shadow: 0 0 0 0 var(--c);
|
||||
animation: dmStPulse 1.8s ease-out infinite; }
|
||||
@keyframes dmStPulse { 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--c) 55%, transparent); } 100% { box-shadow: 0 0 0 9px transparent; } }
|
||||
|
||||
/* In-scene 3D labels (drei <Html>) — crisp glass chips floating in the WebGL scene */
|
||||
.dm-st3d-file, .dm-st3d-count, .dm-st3d-ai, .dm-st3d-chip {
|
||||
font-family: var(--font-space-grotesk), var(--font-manrope), system-ui, sans-serif;
|
||||
pointer-events: none; user-select: none; white-space: nowrap; transition: opacity 0.2s linear; }
|
||||
.dm-st3d-file, .dm-st3d-ai {
|
||||
display: inline-flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 700; color: #0f172a;
|
||||
background: rgba(255,255,255,0.88); border: 1px solid rgba(34,197,94,0.4); border-radius: 999px;
|
||||
padding: 6px 13px; box-shadow: 0 8px 22px -12px rgba(34,197,94,0.7); backdrop-filter: blur(8px); }
|
||||
.dm-st3d-count { font-size: 15px; font-weight: 800; color: #0f172a; background: rgba(255,255,255,0.9);
|
||||
border: 1px solid rgba(34,197,94,0.45); border-radius: 12px; padding: 6px 14px;
|
||||
box-shadow: 0 10px 26px -12px rgba(34,197,94,0.8); backdrop-filter: blur(8px); }
|
||||
.dm-st3d-count span { color: #16a34a; font-size: 19px; }
|
||||
.dm-st3d-chip { display: inline-flex; align-items: center; gap: 8px; background: rgba(255,255,255,0.92);
|
||||
border: 1px solid rgba(34,197,94,0.4); border-radius: 12px; padding: 6px 11px;
|
||||
box-shadow: 0 10px 26px -14px rgba(15,23,42,0.7); backdrop-filter: blur(8px); }
|
||||
.dm-st3d-chip__ico { font-size: 17px; }
|
||||
.dm-st3d-chip__txt { display: flex; flex-direction: column; line-height: 1.15; }
|
||||
.dm-st3d-chip__txt b { font-size: 12.5px; font-weight: 800; color: #0f172a; }
|
||||
.dm-st3d-chip__txt { font-size: 10.5px; color: #475569; }
|
||||
|
||||
/* STAGE 01 — Input */
|
||||
.dm-st-grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 10px; }
|
||||
.dm-st-tile { position: relative; background: color-mix(in srgb, var(--c) 7%, white); border: 1px solid color-mix(in srgb, var(--c) 22%, white);
|
||||
border-radius: 14px; padding: 12px 14px; display: grid; grid-template-columns: auto 1fr; grid-template-rows: auto auto; column-gap: 10px; align-items: center; }
|
||||
.dm-st-tile__ico { grid-row: 1 / 3; font-size: 24px; }
|
||||
.dm-st-tile__num { font-size: 26px; font-weight: 800; color: #0f172a; line-height: 1; }
|
||||
.dm-st-tile__lbl { font-size: 12px; font-weight: 700; color: #334155; }
|
||||
.dm-st-tile__sub { grid-column: 1 / 3; margin-top: 4px; font-size: 10.5px; letter-spacing: 0.04em; text-transform: uppercase; color: #64748b; }
|
||||
.dm-st-file { display: flex; align-items: center; gap: 9px; margin-bottom: 12px; padding: 9px 12px; border-radius: 12px;
|
||||
background: rgba(15,23,42,0.04); border: 1px dashed rgba(15,23,42,0.18); }
|
||||
.dm-st-file__ico { font-size: 15px; }
|
||||
.dm-st-file__name { font-size: 13px; font-weight: 700; color: #0f172a; }
|
||||
.dm-st-file__meta { font-size: 11.5px; color: #64748b; }
|
||||
.dm-st-file__ok { margin-left: auto; font-size: 12px; font-weight: 800; color: #22C55E; }
|
||||
.dm-st-riders { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
|
||||
.dm-st-rider { display: grid; grid-template-columns: 26px 1fr auto auto; align-items: center; gap: 9px;
|
||||
padding: 6px 8px; border-radius: 10px; background: rgba(15,23,42,0.03); }
|
||||
.dm-st-rider__av { position: relative; width: 26px; height: 26px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 11px; font-weight: 800; color: #fff; background: linear-gradient(135deg, var(--c), color-mix(in srgb, var(--c) 60%, #1e293b)); }
|
||||
.dm-st-rider__av i { position: absolute; right: -1px; bottom: -1px; width: 8px; height: 8px; border-radius: 50%; background: #22C55E; border: 2px solid #fff; }
|
||||
.dm-st-rider__name { font-size: 12.5px; font-weight: 700; color: #0f172a; }
|
||||
.dm-st-rider__veh { font-size: 11px; font-weight: 600; color: #475569; padding: 2px 8px; border-radius: 999px; background: rgba(15,23,42,0.06); }
|
||||
.dm-st-rider__cap { font-size: 11px; color: #64748b; white-space: nowrap; }
|
||||
|
||||
/* STAGE 02 — Parallel execution */
|
||||
.dm-st-engines { display: grid; gap: 10px; }
|
||||
.dm-st-engine { padding: 11px 12px; border-radius: 14px; background: rgba(15,23,42,0.03); border: 1px solid rgba(15,23,42,0.07); }
|
||||
.dm-st-engine.is-unified { background: color-mix(in srgb, var(--c) 8%, white); border-color: color-mix(in srgb, var(--c) 28%, white); }
|
||||
.dm-st-engine__head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||
.dm-st-engine__name { font-size: 13px; font-weight: 800; color: #0f172a; }
|
||||
.dm-st-engine__tag { font-size: 9.5px; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; color: #64748b;
|
||||
background: rgba(15,23,42,0.07); padding: 2px 7px; border-radius: 6px; }
|
||||
.dm-st-engine__tag--u { color: #fff; background: var(--c); }
|
||||
.dm-st-pills { display: flex; flex-wrap: wrap; gap: 7px; }
|
||||
.dm-st-pill { font-size: 12px; font-weight: 700; color: #334155; padding: 6px 12px; border-radius: 999px;
|
||||
background: rgba(255,255,255,0.9); border: 1px solid rgba(15,23,42,0.12); }
|
||||
.dm-st-pill--u { color: #0f172a; background: color-mix(in srgb, var(--c) 14%, white); border-color: color-mix(in srgb, var(--c) 40%, white); }
|
||||
|
||||
/* STAGE 03 — Optimization pipeline */
|
||||
.dm-st-pipe { display: grid; gap: 0; }
|
||||
.dm-st-pipe__node { display: grid; grid-template-columns: auto 1fr; grid-template-rows: auto auto; column-gap: 11px; align-items: center;
|
||||
padding: 11px 14px; border-radius: 14px; background: rgba(255,255,255,0.92);
|
||||
border: 1px solid color-mix(in srgb, var(--c) 30%, white); box-shadow: 0 8px 22px -14px var(--c); }
|
||||
.dm-st-pipe__ico { grid-row: 1 / 3; font-size: 22px; }
|
||||
.dm-st-pipe__name { font-size: 13.5px; font-weight: 800; color: #0f172a; }
|
||||
.dm-st-pipe__desc { font-size: 11.5px; color: #64748b; }
|
||||
.dm-st-pipe__arrow { display: flex; align-items: center; justify-content: center; height: 22px; }
|
||||
.dm-st-pipe__arrow i { position: relative; width: 2px; height: 22px; background: linear-gradient(180deg, color-mix(in srgb, var(--c) 50%, transparent), var(--c)); overflow: visible; }
|
||||
.dm-st-pipe__arrow i::after { content: ""; position: absolute; left: 50%; top: -4px; width: 6px; height: 6px; border-radius: 50%;
|
||||
background: var(--c); box-shadow: 0 0 8px var(--c); transform: translateX(-50%); animation: dmStFlow 1.4s linear infinite; }
|
||||
@keyframes dmStFlow { 0% { top: -4px; opacity: 0; } 20% { opacity: 1; } 100% { top: 22px; opacity: 0; } }
|
||||
|
||||
/* STAGE 04 — Performance grading */
|
||||
.dm-st-stars { display: flex; align-items: center; gap: 6px; margin-bottom: 12px; }
|
||||
.dm-st-stars__on { color: var(--c); letter-spacing: 2px; font-size: 16px; }
|
||||
.dm-st-stars__off { color: rgba(15,23,42,0.18); font-size: 16px; }
|
||||
.dm-st-stars__txt { font-size: 12px; font-weight: 700; color: #475569; margin-left: 4px; }
|
||||
.dm-st-metrics { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
|
||||
.dm-st-metric { display: grid; grid-template-columns: 1fr 70px auto; grid-template-rows: auto auto; column-gap: 10px; row-gap: 3px; align-items: center; }
|
||||
.dm-st-metric__name { font-size: 12.5px; font-weight: 700; color: #0f172a; }
|
||||
.dm-st-metric__bar { grid-column: 1 / 2; grid-row: 2; height: 6px; border-radius: 999px; background: rgba(15,23,42,0.08); overflow: hidden; }
|
||||
.dm-st-metric__bar i { display: block; height: 100%; border-radius: 999px; background: var(--c); }
|
||||
.dm-st-metric__score { grid-column: 2; grid-row: 1 / 3; font-size: 17px; font-weight: 800; color: #0f172a; text-align: right; }
|
||||
.dm-st-metric__status { grid-column: 3; grid-row: 1 / 3; font-size: 11px; font-weight: 800; color: #16a34a;
|
||||
background: rgba(34,197,94,0.12); border: 1px solid rgba(34,197,94,0.3); padding: 3px 8px; border-radius: 999px; white-space: nowrap; }
|
||||
.dm-st-metric__desc { grid-column: 1 / 2; grid-row: 1; font-size: 10.5px; color: #64748b; align-self: end; display: none; }
|
||||
|
||||
/* STAGE 05 — Strategy comparison (hero) */
|
||||
.dm-st-winner { margin-bottom: 14px; }
|
||||
.dm-st-winner__eyebrow { display: block; font-size: 10.5px; font-weight: 800; letter-spacing: 0.14em; text-transform: uppercase; color: #64748b; }
|
||||
.dm-st-winner__name { display: flex; align-items: center; gap: 10px; margin: 4px 0; font-size: clamp(24px, 2.8vw, 34px); font-weight: 800; color: #0f172a; letter-spacing: -0.02em; }
|
||||
.dm-st-winner__grade { display: inline-block; font-size: 11px; font-weight: 800; letter-spacing: 0.04em; color: #fff;
|
||||
background: linear-gradient(90deg, #C01227, #E2354A); padding: 4px 11px; border-radius: 999px; }
|
||||
.dm-st-trophy { font-size: 32px; filter: drop-shadow(0 8px 18px rgba(192,18,39,0.4)); animation: dmStFloat 3s ease-in-out infinite; }
|
||||
@keyframes dmStFloat { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-5px); } }
|
||||
.dm-st-hero { display: flex; align-items: center; gap: 20px; }
|
||||
.dm-st-hero__ring { position: relative; flex-shrink: 0; width: 96px; height: 96px; border-radius: 50%; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center; background: conic-gradient(#C01227 88%, rgba(15,23,42,0.1) 0); }
|
||||
.dm-st-hero__ring::after { content: ""; position: absolute; inset: 9px; border-radius: 50%; background: #fff; }
|
||||
.dm-st-hero__pct { position: relative; z-index: 1; font-size: 24px; font-weight: 800; color: #C01227; }
|
||||
.dm-st-hero__sub { position: relative; z-index: 1; font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: #64748b; }
|
||||
.dm-st-wins { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
|
||||
.dm-st-wins li { font-size: 13px; color: #334155; display: flex; align-items: baseline; gap: 8px; }
|
||||
.dm-st-wins strong { color: #C01227; font-weight: 800; min-width: 48px; }
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.dm-st-rail__title { display: none; }
|
||||
.dm-st-rail__step { padding: 5px 7px; }
|
||||
.dm-st-rail__line { width: 9px; }
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.dm-st { height: 560vh; }
|
||||
.dm-st-card-story { left: 0 !important; right: 0 !important; margin: 0 auto; width: calc(100% - 28px);
|
||||
bottom: clamp(18px, 4vh, 40px); padding: 15px 16px; }
|
||||
.dm-st-rider__cap { display: none; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dm-st-arrow, .dm-st-trophy { animation: none !important; }
|
||||
}
|
||||
`;
|
||||
67
src/components/strategy/theme.test.ts
Normal file
67
src/components/strategy/theme.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { STAGES, N, Z_SPACING, X_OFFSET, stagePosition, samplePath } from "./theme";
|
||||
|
||||
describe("strategy/theme — stage data", () => {
|
||||
it("exposes five stages and a matching N", () => {
|
||||
expect(STAGES).toHaveLength(5);
|
||||
expect(N).toBe(5);
|
||||
});
|
||||
|
||||
it("has unique, well-formed stage descriptors", () => {
|
||||
const keys = STAGES.map((s) => s.key);
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
for (const s of STAGES) {
|
||||
expect(s.n).toMatch(/^\d{2}$/);
|
||||
expect(s.theme).toMatch(/^#[0-9A-Fa-f]{6}$/);
|
||||
expect(s.kicker.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("strategy/theme — stagePosition()", () => {
|
||||
it("alternates x sign by parity and steps z by -Z_SPACING", () => {
|
||||
expect(stagePosition(0)).toEqual([-X_OFFSET, -0.45, -0]);
|
||||
expect(stagePosition(1)).toEqual([X_OFFSET, 0.55, -Z_SPACING]);
|
||||
expect(stagePosition(2)).toEqual([-X_OFFSET, -0.45, -2 * Z_SPACING]);
|
||||
expect(stagePosition(3)).toEqual([X_OFFSET, 0.55, -3 * Z_SPACING]);
|
||||
});
|
||||
|
||||
it("places even stages left and odd stages right", () => {
|
||||
for (let i = 0; i < N; i++) {
|
||||
const [x] = stagePosition(i);
|
||||
if (i % 2 === 0) expect(x).toBeLessThan(0);
|
||||
else expect(x).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("strategy/theme — samplePath()", () => {
|
||||
it("equals stagePosition() at integer indices", () => {
|
||||
for (let i = 0; i < N; i++) {
|
||||
const sampled = samplePath(i);
|
||||
const exact = stagePosition(i);
|
||||
sampled.forEach((v, k) => expect(v).toBeCloseTo(exact[k], 10));
|
||||
}
|
||||
});
|
||||
|
||||
it("linearly interpolates between adjacent stages", () => {
|
||||
const [x, y, z] = samplePath(0.5);
|
||||
expect(x).toBeCloseTo(0, 10); // midpoint of -2.5 and +2.5
|
||||
expect(y).toBeCloseTo(0.05, 10); // midpoint of -0.45 and 0.55
|
||||
expect(z).toBeCloseTo(-Z_SPACING / 2, 10);
|
||||
});
|
||||
|
||||
it("clamps indices below 0 to the first stage", () => {
|
||||
expect(samplePath(-3)).toEqual(stagePosition(0));
|
||||
});
|
||||
|
||||
it("clamps indices above N-1 to the last stage", () => {
|
||||
expect(samplePath(99)).toEqual(stagePosition(N - 1));
|
||||
});
|
||||
|
||||
it("never produces NaN across a fine sweep", () => {
|
||||
for (let p = 0; p <= 1.0001; p += 0.05) {
|
||||
const idx = p * (N - 1);
|
||||
for (const v of samplePath(idx)) expect(Number.isNaN(v)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
43
src/components/strategy/theme.ts
Normal file
43
src/components/strategy/theme.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// Shared data + geometry for the "Strategy" 3D scroll-storytelling section.
|
||||
// Five themed stages laid out along a zig-zag path in 3D. The camera travels
|
||||
// stage-to-stage as a normalized scroll progress (0→1) advances.
|
||||
|
||||
export type StageTheme = {
|
||||
n: string; // "01"
|
||||
key: string;
|
||||
kicker: string; // short label for the step rail
|
||||
theme: string; // hex accent
|
||||
};
|
||||
|
||||
export const STAGES: StageTheme[] = [
|
||||
{ n: "01", key: "input", kicker: "Input", theme: "#22C55E" }, // green
|
||||
{ n: "02", key: "parallel", kicker: "Parallel Execution", theme: "#8B5CF6" }, // purple
|
||||
{ n: "03", key: "optimize", kicker: "Smart Optimization", theme: "#3B82F6" }, // blue
|
||||
{ n: "04", key: "grading", kicker: "Performance Grading", theme: "#F59E0B" }, // orange
|
||||
{ n: "05", key: "winner", kicker: "Strategy Comparison", theme: "#C01227" }, // red — hero
|
||||
];
|
||||
|
||||
export const N = STAGES.length;
|
||||
|
||||
// Zig-zag layout constants (world units).
|
||||
export const Z_SPACING = 6.4;
|
||||
export const X_OFFSET = 2.5;
|
||||
|
||||
/** Resting world position of stage `i` along the zig-zag path. */
|
||||
export function stagePosition(i: number): [number, number, number] {
|
||||
const x = (i % 2 === 0 ? -1 : 1) * X_OFFSET;
|
||||
const y = i % 2 === 0 ? -0.45 : 0.55;
|
||||
const z = -i * Z_SPACING;
|
||||
return [x, y, z];
|
||||
}
|
||||
|
||||
/** Continuous position sampled at a fractional stage index (lerp between stages). */
|
||||
export function samplePath(idx: number): [number, number, number] {
|
||||
const clamped = Math.max(0, Math.min(N - 1, idx));
|
||||
const f = Math.floor(clamped);
|
||||
const c = Math.min(N - 1, f + 1);
|
||||
const t = clamped - f;
|
||||
const a = stagePosition(f);
|
||||
const b = stagePosition(c);
|
||||
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
|
||||
}
|
||||
Reference in New Issue
Block a user